code: list+create improvements, expanded notes, added logging

This commit is contained in:
Gabor Körber 2024-02-07 13:37:35 +01:00
parent fdbc37db06
commit 1c6317808a
18 changed files with 293 additions and 90 deletions

28
Cargo.lock generated
View File

@ -1847,6 +1847,8 @@ dependencies = [
"sqlformat", "sqlformat",
"strinto", "strinto",
"tokio", "tokio",
"tower-http",
"tracing",
] ]
[[package]] [[package]]
@ -3477,6 +3479,23 @@ dependencies = [
"tracing", "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]] [[package]]
name = "tower-layer" name = "tower-layer"
version = "0.3.2" version = "0.3.2"
@ -3491,11 +3510,10 @@ checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52"
[[package]] [[package]]
name = "tracing" name = "tracing"
version = "0.1.37" version = "0.1.40"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ce8c33a8d48bd45d624a6e523445fd21ec13d3653cd51f681abf67418f54eb8" checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef"
dependencies = [ dependencies = [
"cfg-if",
"log", "log",
"pin-project-lite", "pin-project-lite",
"tracing-attributes", "tracing-attributes",
@ -3515,9 +3533,9 @@ dependencies = [
[[package]] [[package]]
name = "tracing-core" name = "tracing-core"
version = "0.1.31" version = "0.1.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0955b8137a1df6f1a2e9a37d8a6656291ff0297c1a97c24e0d8425fe2312f79a" checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54"
dependencies = [ dependencies = [
"once_cell", "once_cell",
] ]

View File

@ -52,3 +52,5 @@ serde_json = "1.0.108"
slug = "0.1.5" slug = "0.1.5"
derive_builder = "0.13.0" derive_builder = "0.13.0"
async-trait = "0.1.77" async-trait = "0.1.77"
tracing = "0.1.40"
tower-http = { version = "0.5.1", features = ["trace"] }

View File

@ -156,6 +156,7 @@ As I started by quickly using a django admin template with CSS to jump start the
### UiKit ### UiKit
in general really nice, but I got annoyed by uk- prefixes pretty fast. 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? It would however seem to align with hx- prefixes of htmx?
### Tailwind/DaisyUI ### 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. 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. ### Builder Pattern etc.
// consider builder: https://docs.rs/derive_builder/latest/derive_builder/ consider builder: https://docs.rs/derive_builder/latest/derive_builder/
// downside of builders as macro: no intellisense! downside of builders as macro: no intellisense!
// each repository has to implement a repo info.
//#[derive(Builder)] //#[derive(Builder)]
//#[builder(setter(into))] //#[builder(setter(into))]
@ -235,4 +238,28 @@ let dynamic_field = Field::widget(Cow::Owned(dynamic_label));
- "object safe" - "object safe"
- the async dynamic dispatch. - the async dynamic dispatch.
- the building pattern debacle here - the building pattern debacle here
### 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.

View File

@ -39,3 +39,18 @@ auth:
In the widget section, we need to add more abilities to adjust attributes of html elements directly. 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. 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
<div class="field">
<label>Field Summary</label>
<div class="two fields">
<div class="field">
<input>...</input>
</div>
</div>
</div>
```
-

View File

@ -60,9 +60,18 @@ pub mod repository {
pub type RepositoryContext = AdminModel; pub type RepositoryContext = AdminModel;
impl RepositoryContext { impl RepositoryContext {
pub fn get_default_detail_url(&self, key: &str) -> Option<String> {
Some(format!("{}/detail/{}", self.admin_url, key))
}
pub fn get_default_change_url(&self, key: &str) -> Option<String> {
Some(format!("{}/change/{}", self.admin_url, key))
}
pub fn build_item(&self, key: &str, fields: Value) -> RepositoryItem { pub fn build_item(&self, key: &str, fields: Value) -> RepositoryItem {
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, fields: fields,
} }
} }
@ -217,6 +226,7 @@ pub mod repository {
pub struct RepositoryItem { pub struct RepositoryItem {
pub fields: Value, pub fields: Value,
pub detail_url: Option<String>, pub detail_url: Option<String>,
pub change_url: Option<String>,
} }
pub enum RepositoryList { pub enum RepositoryList {

View File

@ -15,8 +15,9 @@ pub fn routes() -> Router<AppState> {
"/app/:app/model/:model/add", "/app/:app/model/:model/add",
get(views::new_item).post(views::create_item), get(views::new_item).post(views::create_item),
) )
.route("/app/:app/model/:model/change/:id", get(views::change_item))
.route( .route(
"/app/:app/model/:model/detail/:id", "/app/:app/model/:model/detail/:id",
get(views::item_details), get(views::view_item_details),
) )
} }

View File

@ -116,9 +116,7 @@ pub async fn list_item_collection(
let repo = repo.lock().await; let repo = repo.lock().await;
let admin_model = registry let admin_model = registry
.get_model(&app_key, &model_key) .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 .expect("Admin Model not found?");
// 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.
AdminContext { AdminContext {
base: base_template(&headers), base: base_template(&headers),
available_apps: registry.get_apps(), available_apps: registry.get_apps(),
@ -145,7 +143,7 @@ pub async fn item_collection_action(
} }
// Item Details shows one single dataset. // Item Details shows one single dataset.
pub async fn item_details( pub async fn view_item_details(
templates: State<templates::Templates>, templates: State<templates::Templates>,
registry: State<Arc<state::AdminRegistry>>, registry: State<Arc<state::AdminRegistry>>,
headers: HeaderMap, headers: HeaderMap,
@ -155,9 +153,7 @@ pub async fn item_details(
let repo = repo.lock().await; let repo = repo.lock().await;
let admin_model = registry let admin_model = registry
.get_model(&app_key, &model_key) .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 .expect("Admin Model not found?");
// 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.
let key: LookupKey = id.into(); let key: LookupKey = id.into();
AdminContext { AdminContext {
base: base_template(&headers), base: base_template(&headers),
@ -243,6 +239,37 @@ pub async fn create_item(
templates.render_html("admin/items/item_create.jinja", context) templates.render_html("admin/items/item_create.jinja", context)
} }
pub async fn change_item(
templates: State<templates::Templates>,
registry: State<Arc<state::AdminRegistry>>,
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. // Item Action allows running an action on one single dataset.
pub async fn item_action( pub async fn item_action(
Path((app_key, model_key, model_id)): Path<(String, String, String)>, Path((app_key, model_key, model_id)): Path<(String, String, String)>,

View File

@ -1,8 +1,6 @@
use crate::admin::domain::*; use crate::admin::domain::*;
use crate::admin::state::AdminRegistry;
use async_trait::async_trait; use async_trait::async_trait;
use log::{debug, warn}; use serde_json::Value;
use serde_json::{json, Value};
struct Repository {} struct Repository {}

View File

@ -44,8 +44,8 @@ impl AdminRepository for MyStaticRepository {
fields: &["name", "level", "age", "powers"], fields: &["name", "level", "age", "powers"],
} }
.build() .build()
.set_widget("age", Widget::default().as_password().labeled("hi there.")) //.set_widget("age", Widget::default().as_password().labeled("hi there."))
.set_widget("name", Widget::textarea().options(&[("disabled", "true")])) //.set_widget("name", Widget::textarea().options(&[("disabled", "true")]))
} }
async fn get(&self, model: &RepositoryContext, id: LookupKey) -> Option<RepositoryItem> { async fn get(&self, model: &RepositoryContext, id: LookupKey) -> Option<RepositoryItem> {
@ -117,10 +117,7 @@ impl AdminRepository for MyStaticRepository {
*item = data.clone(); *item = data.clone();
if let Some(item_id) = item.get("id") { if let Some(item_id) = item.get("id") {
return Some(RepositoryItem { return Some(model.build_item(&*item_id.to_string(), data));
detail_url: Some(format!("{}/detail/{}", model.admin_url, item_id)),
fields: data,
});
} }
} }

View File

@ -7,6 +7,12 @@ mod state;
use crate::service::{handlers, templates}; use crate::service::{handlers, templates};
use crate::state::AppState; use crate::state::AppState;
use admin_examples::static_repository; use admin_examples::static_repository;
use axum::{
body::Bytes,
extract::MatchedPath,
http::{HeaderMap, Request},
response::Response,
};
use axum::{ use axum::{
extract::State, handler::HandlerWithoutStateExt, response::IntoResponse, routing::get, Router, extract::State, handler::HandlerWithoutStateExt, response::IntoResponse, routing::get, Router,
}; };
@ -15,7 +21,10 @@ use log::info;
use std::env; use std::env;
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tower_http::{classify::ServerErrorsFailureClass, trace::TraceLayer};
use tracing::{info_span, Span};
async fn home(templates: State<templates::Templates>) -> impl IntoResponse { async fn home(templates: State<templates::Templates>) -> impl IntoResponse {
templates.render_html("index.html", ()) templates.render_html("index.html", ())
@ -51,7 +60,48 @@ async fn main() {
.nest("/howto", howto::routes()) .nest("/howto", howto::routes())
.route_service("/static/*file", handlers::static_handler.into_service()) .route_service("/static/*file", handlers::static_handler.into_service())
.fallback(handlers::not_found_handler) .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::<MatchedPath>()
.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 // Run Server
let app_host: std::net::IpAddr = env::var("APP_HOST") let app_host: std::net::IpAddr = env::var("APP_HOST")

View File

@ -113,8 +113,9 @@
<span>{{ model.name }}</span> <span>{{ model.name }}</span>
{% endif %} {% endif %}
{% if model.add_url %}<i class="plus link icon"> {% if model.add_url %}<i class="plus link icon" hx-get="{{ model.add_url }}" hx-target="#main"
<a href="{{ model.add_url }}" class="addlink">{{ translate( 'Add') }}</a></i> hx-push-url="true">
{% if captions %}<a href="{{ model.add_url }}" class="addlink">{{ translate( 'Add') }}</a>{% endif %}</i>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
</table> </table>

View File

@ -0,0 +1,10 @@
{% extends base|none("admin/base.jinja") %}
{% block content %}
<div class="ui container">
<h1>Update {{item_model.name}} in {{item_info.name}}</h1>
{% set fields = item_info.fields %}
{% set form = { 'action': item.change_url } %}
{% include "/admin/items/item_change_form.jinja" %}
</div>
{% endblock content %}

View File

@ -0,0 +1,16 @@
<form action="{{form.action}}" method="{{form.method|default('POST')}}" class="ui form">
<!--noformat-->
{% 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 %}
<div class="field">
{{ field_widget(field_name, field_defs, field_value) }}
</div>
{% endfor %}
<!--noformat-->
<button class="ui button" type="submit">Create</button>
</form>

View File

@ -1,22 +1,17 @@
{% extends base|none("admin/base.jinja") %} {% extends base|none("admin/base.jinja") %}
{% block content %} {% block content %}
{% from "/admin/items/items.jinja" import field_widget %}
<div class="ui container"> <div class="ui container">
<h1>Create {{item_model.name}} in {{item_info.name}}</h1> <h1>Create {{item_model.name}} in {{item_info.name}}</h1>
<form action="{{item_model.add_url}}" method="POST" class="ui form"> {% block navigation_bar %}
<!--noformat--> <div class="ui container">
{% set fields = item_info.fields %} <button hx-get="{{ item_model.admin_url }}" hx-target="#main" hx-push-url="true">List Items</button>
{% for field_name, field_defs in fields %} </div>
{% if item %} {% endblock %}
{% set field_value = item.fields[field_name]|none("") %}
{% else %} {% set fields = item_info.fields %}
{% set field_value = "" %} {% set form = { 'action': item_model.add_url } %}
{% endif %} {% include "/admin/items/item_change_form.jinja" %}
{{ field_widget(field_name, field_defs, field_value) }}
{% endfor %}
<button class="ui button" type="submit">Create</button>
</form>
</div> </div>
{% endblock content %} {% endblock content %}

View File

@ -1,57 +1,84 @@
{% extends base|none("admin/base.jinja") %} {% extends base|none("admin/base.jinja") %}
{% block content %} {% block content %}
<div class="ui container">
<h1>List of {{ item_info.name }}</h1>
{% block search_bar %} {% block navigation_bar %}
<div class="ui icon input"> <div class="ui container">
<input type="text" placeholder="Search..."> <button hx-get="{{ item_model.add_url }}" hx-target="#main" hx-push-url="true">Create New</button>
<i class="circular search link icon"></i> </div>
</div> {% endblock %}
{% endblock %}
{% if item_list %} {% block search_bar %}
{% set item_keys = item_info.display_list or item_list|table_keys %} <div class="ui icon input">
<input type="text" placeholder="Search...">
<i class="circular search link icon"></i>
</div>
{% endblock %}
{% if item_info.lookup_key in item_keys %} {% if item_list %}
{% set primary_key = item_info.lookup_key %} {% set item_keys = item_info.display_list or item_list|table_keys %}
{% endif %}
<div class="ui basic container"> {% if item_info.lookup_key in item_keys %}
<table class="ui very compact celled table head stuck unstackable"> {% set primary_key = item_info.lookup_key %}
<caption>{{ item_info.name|none(content) }}</caption> {% endif %}
<thead>
<tr>
{% for key in item_keys %}
{% if key==primary_key %}
<th class="blue"><i class="key icon"></i>
{{ key|capitalize }}
</th>
{% else %}
<th>{{ key|capitalize }}</th>
{% endif %}
{% endfor %} <div class="ui basic container">
</tr> <table class="ui very compact celled table head stuck unstackable">
</thead> <caption>{{ item_info.name }}</caption>
<thead>
<tr>
<th class="collapsing grey"></th>
{% for key in item_keys %}
{% if key==primary_key %}
<th class="blue"><i class="key icon"></i>
{{ key|capitalize }}
</th>
{% else %}
<th>{{ key|capitalize }}</th>
{% endif %}
<tbody> {% endfor %}
{% for item in item_list %}
<tr> </tr>
{% for key in item_keys %} </thead>
{% if key==primary_key %}
<td class="selectable warning">{% if item.detail_url %}<a href="{{item.detail_url}}" <tbody>
hx-get="{{item.detail_url}}" hx-target="#main" hx-push-url="true">{{ {% for item in item_list %}
<tr>
<td class="collapsing">
<div class="ui buttons">
<button class="ui compact icon button left attached" {% if item.detail_url %} hx-get="{{item.detail_url}}"
hx-target="#main" hx-push-url="true" {% endif %}>
<i class="eye icon"></i>
</button>
<button class="ui compact icon button right attached" {% if item.change_url %}
hx-get="{{item.change_url}}" hx-target="#main" hx-push-url="true" {% endif %}>
<i class="edit icon"></i>
</button>
</div>
</td>
{% for key in item_keys %}
{% if key==primary_key %}
<td class="selectable warning">{% if item.change_url %}<a href="{{item.change_url}}"
hx-get="{{item.change_url}}" hx-target="#main" hx-push-url="true">{{
item.fields[key] }}</a>{% item.fields[key] }}</a>{%
else %}{{item.fields[key] }}{% endif %}</td> else %}{{item.fields[key] }}{% endif %}</td>
{% else %} {% else %}
<td>{{ item.fields[key] }}</td>{% endif %} <td>{{ item.fields[key] }}</td>{% endif %}
{% endfor %}
</tr>
{% endfor %} {% endfor %}
</tr> </tbody>
{% endfor %} <tfoot>
</tbody> </tfoot>
<tfoot> </table>
</tfoot> </div>
</table>
</div> </div>
{% else %} {% else %}
No Items found. No Items found.

View File

@ -1,4 +1,5 @@
<div class="inline field"> <div class="ui input {% if 'disabled' in field.options and field.options.disabled %}disabled{% endif %} inline field">
<label>{{field.label or field.name|capitalize}}</label> <label>{{field.label or field.name|capitalize}}</label>
<input type="{{ field.type|none('text') }}" name="{{ field.name }}" value="{{ field.value }}" placeholder="{{field.placeholder}}"> <input type="{{ field.type|none('text') }}" name="{{ field.name }}" value="{{ field.value }}"
placeholder="{{field.placeholder}}">
</div> </div>

View File

@ -1,4 +1,4 @@
<div class="inline field"> <div class="ui input {% if 'disabled' in field.options and field.options.disabled %}disabled{% endif %} inline field">
<label>{{ field.label or field.name|capitalize }}</label> <label>{{ field.label or field.name|capitalize }}</label>
<!--noformat--> <!--noformat-->
<textarea name="{{ field.name }}" {% if field.placeholder %}placeholder="{{ field.placeholder }}"{% endif %} <textarea name="{{ field.name }}" {% if field.placeholder %}placeholder="{{ field.placeholder }}"{% endif %}

View File

@ -0,0 +1,8 @@
<div class="ui labeled input">
<div class="ui label">{{field.label or field.name|capitalize}}</div>
<select class="ui selection dropdown">
{% for value, label in field.value %}
<option value="{{value}}">{{label}}</option>
{% endfor %}
</select>
</div>