code: rudimentary implementation of detail and create

This commit is contained in:
Gabor Körber 2024-01-27 17:42:20 +01:00
parent d1c98516a8
commit c439220409
11 changed files with 236 additions and 68 deletions

View File

@ -9,3 +9,4 @@
- [ ] better 404 handling - [ ] better 404 handling
- [ ] better 500 handling - [ ] better 500 handling

View File

@ -2,7 +2,8 @@ pub use config::AdminModelConfig;
pub use dto::AdminApp; pub use dto::AdminApp;
pub use dto::AdminModel; pub use dto::AdminModel;
pub use repository::{ pub use repository::{
AdminRepository, RepoInfo, RepositoryInfo, RepositoryInfoBuilder, RepositoryList, AdminRepository, LookupKey, RepoInfo, RepositoryInfo, RepositoryInfoBuilder, RepositoryItem,
RepositoryList,
}; };
mod auth { mod auth {
@ -49,29 +50,77 @@ mod dto {
} }
pub mod repository { pub mod repository {
use super::dto::AdminModel;
use derive_builder::Builder; use derive_builder::Builder;
use serde::{Serialize, Serializer}; use serde::{Serialize, Serializer};
use serde_json::Value; use serde_json::Value;
use std::vec::IntoIter; use std::vec::IntoIter;
pub enum LookupKey {
Integer(usize),
String(String),
}
// Note that LookupKey auto converts to integer.
impl From<String> for LookupKey {
fn from(s: String) -> Self {
if let Ok(int_key) = s.parse::<usize>() {
LookupKey::Integer(int_key)
} else {
LookupKey::String(s)
}
}
}
impl From<&str> for LookupKey {
fn from(s: &str) -> Self {
if let Ok(int_key) = s.parse::<usize>() {
LookupKey::Integer(int_key)
} else {
LookupKey::String(s.to_owned())
}
}
}
impl From<usize> for LookupKey {
fn from(i: usize) -> Self {
LookupKey::Integer(i)
}
}
impl std::fmt::Display for LookupKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LookupKey::Integer(i) => write!(f, "{}", i),
LookupKey::String(s) => write!(f, "{}", s),
}
}
}
#[derive(Serialize)]
pub struct RepositoryItem {
pub fields: Value,
pub detail_url: Option<String>,
}
pub enum RepositoryList { pub enum RepositoryList {
Empty, Empty,
List { List {
values: Vec<Value>, values: Vec<RepositoryItem>,
}, },
Page { Page {
values: Vec<Value>, values: Vec<RepositoryItem>,
offset: usize, offset: usize,
total: usize, total: usize,
}, },
Stream { Stream {
values: Vec<Value>, values: Vec<RepositoryItem>,
next_index: Option<String>, next_index: Option<String>,
}, },
} }
impl IntoIterator for RepositoryList { impl IntoIterator for RepositoryList {
type Item = Value; type Item = RepositoryItem;
type IntoIter = IntoIter<Self::Item>; type IntoIter = IntoIter<Self::Item>;
fn into_iter(self) -> Self::IntoIter { fn into_iter(self) -> Self::IntoIter {
@ -148,11 +197,11 @@ pub mod repository {
} }
pub trait AdminRepository: Send + Sync { pub trait AdminRepository: Send + Sync {
fn info(&self) -> RepositoryInfo; fn info(&self, model: &AdminModel) -> RepositoryInfo;
fn list(&self) -> RepositoryList; fn list(&self, model: &AdminModel) -> RepositoryList;
fn get(&self, id: usize) -> Option<Value>; fn get(&self, model: &AdminModel, id: LookupKey) -> Option<RepositoryItem>;
fn create(&self, data: Value) -> Option<Value>; fn create(&self, model: &AdminModel, data: Value) -> Option<Value>;
fn update(&self, id: usize, data: Value) -> Option<Value>; fn update(&self, model: &AdminModel, id: LookupKey, data: Value) -> Option<Value>;
fn delete(&self, id: usize) -> Option<Value>; fn delete(&self, model: &AdminModel, id: LookupKey) -> Option<Value>;
} }
} }

View File

@ -1,7 +1,8 @@
// implementation of static repository // implementation of static repository
use super::domain::{AdminModelConfig, AdminRepository, RepoInfo, RepositoryInfo, RepositoryList}; use super::domain::*;
use super::state::AdminRegistry; use super::state::AdminRegistry;
use log::warn;
use serde_json::{json, Value}; use serde_json::{json, Value};
struct MyStaticRepository { struct MyStaticRepository {
@ -18,24 +19,54 @@ impl MyStaticRepository {
json!({"id": 4, "name": "Rex", "age": 72}), json!({"id": 4, "name": "Rex", "age": 72}),
json!({"id": 5, "name": "Justin", "age": 46}), json!({"id": 5, "name": "Justin", "age": 46}),
json!({"id": 6, "name": "Reacher", "age": 39, "level": "adept", "powers": 0}), json!({"id": 6, "name": "Reacher", "age": 39, "level": "adept", "powers": 0}),
json!({"id": 7, "name": "Arnold", "age": 64}), json!({"id": 7, "name": "Arnold", "age": 7}),
json!({"id": 8, "name": "Eight", "age": 8}),
json!({"id": 9, "name": "Nine", "age": 9}),
json!({"id": 10, "name": "Ten", "age": 10}),
json!({"id": 11, "name": "Eleven", "age": 11}),
], ],
} }
} }
} }
impl AdminRepository for MyStaticRepository { impl AdminRepository for MyStaticRepository {
fn get(&self, id: usize) -> Option<Value> { fn get(&self, model: &AdminModel, id: LookupKey) -> Option<RepositoryItem> {
self.content.get(id).cloned() if let LookupKey::Integer(id) = id {
let item = self.content.get(id - 1).cloned().unwrap();
Some(RepositoryItem {
detail_url: Some(format!(
"{}/detail/{}",
model.admin_url,
item.get("id").unwrap()
)),
fields: item,
})
} else {
warn!("Got non-integer lookup key: {}", id);
None
}
} }
fn list(&self) -> RepositoryList { fn list(&self, model: &AdminModel) -> RepositoryList {
// Admin needs to inject info in these.
RepositoryList::List { RepositoryList::List {
values: self.content.clone(), values: self
.content
.clone()
.into_iter()
.map(|item| RepositoryItem {
detail_url: Some(format!(
"{}/detail/{}",
model.admin_url,
item.get("id").unwrap()
)),
fields: item,
})
.collect(),
} }
} }
fn info(&self) -> RepositoryInfo { fn info(&self, model: &AdminModel) -> RepositoryInfo {
RepoInfo { RepoInfo {
name: "My Static Repository", name: "My Static Repository",
lookup_key: "id", lookup_key: "id",
@ -44,17 +75,17 @@ impl AdminRepository for MyStaticRepository {
.into() .into()
} }
fn create(&self, data: Value) -> Option<Value> { fn create(&self, model: &AdminModel, data: Value) -> Option<Value> {
println!("I would now create: {}", data); println!("I would now create: {}", data);
None None
} }
fn update(&self, id: usize, data: Value) -> Option<Value> { fn update(&self, model: &AdminModel, id: LookupKey, data: Value) -> Option<Value> {
println!("I would now update: {}, {}", id, data); println!("I would now update: {}, {}", id, data);
None None
} }
fn delete(&self, id: usize) -> Option<Value> { fn delete(&self, model: &AdminModel, id: LookupKey) -> Option<Value> {
println!("Would delete: {}", id); println!("Would delete: {}", id);
None None
} }

View File

@ -12,6 +12,7 @@ pub fn routes() -> Router<AppState> {
.route("/", get(views::index).post(views::index_action)) .route("/", get(views::index).post(views::index_action))
.route("/app/:app", get(views::list_app)) .route("/app/:app", get(views::list_app))
.route("/app/:app/model/:model", get(views::list_item_collection)) .route("/app/:app/model/:model", get(views::list_item_collection))
.route("/app/:app/model/:model/add", get(views::create_item))
.route( .route(
"/app/:app/model/:model/detail/:id", "/app/:app/model/:model/detail/:id",
get(views::item_details), get(views::item_details),

View File

@ -56,15 +56,16 @@ impl AdminRegistry {
} }
fn model_from_internal(&self, internal_model: &internal::AdminModel) -> AdminModel { fn model_from_internal(&self, internal_model: &internal::AdminModel) -> AdminModel {
let admin_url = format!(
"/{}/app/{}/model/{}",
self.base_path, internal_model.app_key, internal_model.model_key
);
AdminModel { AdminModel {
key: internal_model.model_key.clone(), key: internal_model.model_key.clone(),
name: internal_model.name.clone(), name: internal_model.name.clone(),
admin_url: format!(
"/{}/app/{}/model/{}",
self.base_path, internal_model.app_key, internal_model.model_key
),
view_only: false, view_only: false,
add_url: None, add_url: Some(format!("{}/add", admin_url)),
admin_url: admin_url,
} }
} }
@ -93,9 +94,9 @@ impl AdminRegistry {
if self.models.contains_key(&local_config_name) { if self.models.contains_key(&local_config_name) {
return Err(format!("Model {} already exists", local_config_name)); return Err(format!("Model {} already exists", local_config_name));
} }
let model_key = local_config.model_key.clone(); let full_model_key = local_config_name.clone();
self.models.insert(local_config_name, local_config); self.models.insert(local_config_name, local_config);
Ok(model_key) Ok(full_model_key)
} }
pub fn register_model<R: AdminRepository + 'static>( pub fn register_model<R: AdminRepository + 'static>(
@ -108,8 +109,13 @@ impl AdminRegistry {
Ok(()) Ok(())
} }
pub fn get_repository(&self, model_key: &str) -> Result<&Box<dyn AdminRepository>, String> { pub fn get_repository(
if let Some(repo) = self.repositories.get(model_key) { &self,
app_key: &str,
model_key: &str,
) -> Result<&Box<dyn AdminRepository>, String> {
let full_model_key = format!("{}.{}", app_key, model_key);
if let Some(repo) = self.repositories.get(&full_model_key) {
return Ok(repo); return Ok(repo);
} else { } else {
return Err("Couldn't find repository".to_owned()); return Err("Couldn't find repository".to_owned());
@ -143,4 +149,14 @@ mod internal {
} }
} }
} }
impl From<(&str, &str)> for AdminModel {
fn from(value: (&str, &str)) -> Self {
AdminModel {
app_key: value.0.to_owned(),
model_key: slug::slugify(value.1.to_owned()),
name: value.1.to_owned(),
}
}
}
} }

View File

@ -1,27 +1,15 @@
use std::sync::Arc; use std::sync::Arc;
use axum::extract::Path; use axum::extract::Path;
use axum::{extract::State, response::IntoResponse, Form}; use axum::{extract::State, response::IntoResponse};
use log::info; use log::info;
use crate::admin::domain::{AdminApp, AdminModel}; use crate::admin::domain::{AdminApp, AdminModel};
use crate::admin::state; use crate::admin::state;
use crate::service::templates; use crate::service::templates;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::domain::{RepositoryInfo, RepositoryList}; use super::domain::{LookupKey, RepositoryInfo, RepositoryItem, RepositoryList};
#[derive(Deserialize)]
pub struct Question {
question: String,
}
#[derive(Deserialize)]
pub struct ExampleData {
title: String,
content: String,
}
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
pub struct AdminRequest { pub struct AdminRequest {
@ -43,9 +31,10 @@ pub struct AdminContext {
pub request: AdminRequest, pub request: AdminRequest,
pub available_apps: Vec<AdminApp>, pub available_apps: Vec<AdminApp>,
pub item_model: Option<AdminModel>,
pub item_info: Option<RepositoryInfo>, pub item_info: Option<RepositoryInfo>,
pub item_list: RepositoryList, pub item_list: RepositoryList,
pub item: Option<Value>, pub item: Option<RepositoryItem>,
} }
impl Default for AdminContext { impl Default for AdminContext {
@ -65,6 +54,7 @@ impl Default for AdminContext {
request: AdminRequest { request: AdminRequest {
path: "".to_owned(), path: "".to_owned(),
}, },
item_model: None,
item_info: None, item_info: None,
item_list: RepositoryList::Empty, item_list: RepositoryList::Empty,
item: None, item: None,
@ -86,7 +76,7 @@ pub async fn index(
} }
// Index Action is POST to the index site. We can anchor some general business code here. // Index Action is POST to the index site. We can anchor some general business code here.
pub async fn index_action(Form(example_data): Form<ExampleData>) -> impl IntoResponse { pub async fn index_action() -> impl IntoResponse {
"There is your answer!".to_owned() "There is your answer!".to_owned()
} }
@ -104,13 +94,18 @@ pub async fn list_item_collection(
Path((app_key, model_key)): Path<(String, String)>, Path((app_key, model_key)): Path<(String, String)>,
) -> impl IntoResponse { ) -> impl IntoResponse {
info!("list_item_collection {} for model {}", app_key, model_key); info!("list_item_collection {} for model {}", app_key, model_key);
let context = if let Ok(repo) = registry.get_repository(&model_key) { let context = if let Ok(repo) = registry.get_repository(&app_key, &model_key) {
// we should consider using Vec<Value> instead in get_list. 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.
AdminContext { AdminContext {
available_apps: registry.get_apps(), available_apps: registry.get_apps(),
content: model_key.to_owned(), content: model_key.to_owned(),
item_info: Some(repo.info()), item_info: Some(repo.info(&admin_model)),
item_list: repo.list(), item_list: repo.list(&admin_model),
item_model: Some(admin_model),
..Default::default() ..Default::default()
} }
} else { } else {
@ -124,7 +119,6 @@ pub async fn list_item_collection(
// Items Action is a POST to an item list. By default these are actions, that work on a list of items as input. // Items Action is a POST to an item list. By default these are actions, that work on a list of items as input.
pub async fn item_collection_action( pub async fn item_collection_action(
Form(question): Form<Question>,
Path((app_key, model_key)): Path<(String, String)>, Path((app_key, model_key)): Path<(String, String)>,
) -> impl IntoResponse { ) -> impl IntoResponse {
"There is your answer!".to_owned() "There is your answer!".to_owned()
@ -133,14 +127,62 @@ 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 item_details(
templates: State<templates::Templates>, templates: State<templates::Templates>,
registry: State<Arc<state::AdminRegistry>>,
Path((app_key, model_key, id)): Path<(String, String, String)>, Path((app_key, model_key, id)): Path<(String, String, String)>,
) -> impl IntoResponse { ) -> impl IntoResponse {
templates.render_html("admin/items/item_detail.jinja", ()) let context = if let Ok(repo) = registry.get_repository(&app_key, &model_key) {
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.
let key: LookupKey = id.into();
AdminContext {
available_apps: registry.get_apps(),
content: model_key.to_owned(),
item_info: Some(repo.info(&admin_model)),
item_list: repo.list(&admin_model),
item: repo.get(&admin_model, key),
item_model: Some(admin_model),
..Default::default()
}
} else {
AdminContext {
available_apps: registry.get_apps(),
..Default::default()
}
};
templates.render_html("admin/items/item_detail.jinja", context)
}
pub async fn create_item(
templates: State<templates::Templates>,
registry: State<Arc<state::AdminRegistry>>,
Path((app_key, model_key)): Path<(String, String)>,
) -> impl IntoResponse {
let context = if let Ok(repo) = registry.get_repository(&app_key, &model_key) {
let admin_model = registry
.get_model(&app_key, &model_key)
.expect("Admin Model not found?");
AdminContext {
available_apps: registry.get_apps(),
content: model_key.to_owned(),
item_info: Some(repo.info(&admin_model)),
item_list: repo.list(&admin_model),
item_model: Some(admin_model),
..Default::default()
}
} else {
AdminContext {
available_apps: registry.get_apps(),
..Default::default()
}
};
templates.render_html("admin/items/item_create.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(
Form(question): Form<Question>,
Path((app_key, model_key, model_id)): Path<(String, String, String)>, Path((app_key, model_key, model_id)): Path<(String, String, String)>,
) -> impl IntoResponse { ) -> impl IntoResponse {
"There is your answer!".to_owned() "There is your answer!".to_owned()

View File

@ -20,11 +20,13 @@
margin-left: var(--sidebar-margin); margin-left: var(--sidebar-margin);
} }
/*
@media screen and (min-width: 1920px) { @media screen and (min-width: 1920px) {
.pushover { .pushover {
margin-left: 0px; margin-left: 0px;
} }
} }
*/
#main_sidemenu { #main_sidemenu {
background-color: rgb(27, 28, 29); background-color: rgb(27, 28, 29);

View File

@ -110,12 +110,12 @@
<a href="{{ model.admin_url }}" {% if model.admin_url in request.path|urlencode %} aria-current="page" {% <a href="{{ model.admin_url }}" {% if model.admin_url in request.path|urlencode %} aria-current="page" {%
endif %}>{{ model.name }}</a> endif %}>{{ model.name }}</a>
{% else %} {% else %}
{{ model.name }} <span>{{ model.name }}</span>
{% endif %} {% endif %}
<i class="plus link icon"></i>
{% if model.add_url %} {% if model.add_url %}<i class="plus link icon">
<a href="{{ model.add_url }}" class="addlink">{{ translate( 'Add') }}</a> <a href="{{ model.add_url }}" class="addlink">{{ translate( 'Add') }}</a></i>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
</table> </table>

View File

@ -0,0 +1,17 @@
{% extends "admin/base.jinja" %}
{% macro input(name, value="", type="text") -%}
<input type="{{ type }}" name="{{ name }}" value="{{ value }}">
{%- endmacro %}
{% block content %}
Create {{item_model.name}} in {{item_info.name}}
<form>
{% set fields = item_info.display_list %}
{% for field in fields %}
<p><label>{{field}}</label>{{ input(field) }}</p>
{% endfor %}
<input type="submit" name="submit" value="Create">
</form>
{% endblock content %}

View File

@ -1 +1,11 @@
Item Detail. {% extends "admin/base.jinja" %}
{% block content %}
{% if item %}
{{item.fields}}
{% else %}
No Item found.
{% endif %}
{% endblock content %}

View File

@ -16,7 +16,7 @@
{% set primary_key = item_info.lookup_key %} {% set primary_key = item_info.lookup_key %}
{% endif %} {% endif %}
<div class="ui short scrolling container"> <div class="ui basic container">
<table class="ui very compact celled table head stuck unstackable"> <table class="ui very compact celled table head stuck unstackable">
<caption>{{ item_info.name|none(content) }}</caption> <caption>{{ item_info.name|none(content) }}</caption>
<thead> <thead>
@ -39,12 +39,11 @@
<tr> <tr>
{% for key in item_keys %} {% for key in item_keys %}
{% if key==primary_key %} {% if key==primary_key %}
<td class="selectable warning"> <td class="selectable warning">{% if item.detail_url %}<a href="{{item.detail_url}}">{{
<a href="#">{{ item[key] }}</a> item.fields[key] }}</a>{%
</td> else %}{{item.fields[key] }}{% endif %}</td>
{% else %} {% else %}
<td>{{ item[key] }}</td> <td>{{ item.fields[key] }}</td>{% endif %}
{% endif %}
{% endfor %} {% endfor %}
</tr> </tr>
{% endfor %} {% endfor %}