diff --git a/TODOS.md b/TODOS.md index 0a84920..462e4c9 100644 --- a/TODOS.md +++ b/TODOS.md @@ -8,4 +8,5 @@ - [ ] develop a django-css independent theme for the admin. - [ ] better 404 handling - [ ] better 500 handling - \ No newline at end of file + + diff --git a/src/admin/domain.rs b/src/admin/domain.rs index 4236adb..ae4d23b 100644 --- a/src/admin/domain.rs +++ b/src/admin/domain.rs @@ -2,7 +2,8 @@ pub use config::AdminModelConfig; pub use dto::AdminApp; pub use dto::AdminModel; pub use repository::{ - AdminRepository, RepoInfo, RepositoryInfo, RepositoryInfoBuilder, RepositoryList, + AdminRepository, LookupKey, RepoInfo, RepositoryInfo, RepositoryInfoBuilder, RepositoryItem, + RepositoryList, }; mod auth { @@ -49,29 +50,77 @@ mod dto { } pub mod repository { + use super::dto::AdminModel; use derive_builder::Builder; use serde::{Serialize, Serializer}; use serde_json::Value; use std::vec::IntoIter; + pub enum LookupKey { + Integer(usize), + String(String), + } + + // Note that LookupKey auto converts to integer. + impl From for LookupKey { + fn from(s: String) -> Self { + if let Ok(int_key) = s.parse::() { + 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::() { + LookupKey::Integer(int_key) + } else { + LookupKey::String(s.to_owned()) + } + } + } + + impl From 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, + } + pub enum RepositoryList { Empty, List { - values: Vec, + values: Vec, }, Page { - values: Vec, + values: Vec, offset: usize, total: usize, }, Stream { - values: Vec, + values: Vec, next_index: Option, }, } impl IntoIterator for RepositoryList { - type Item = Value; + type Item = RepositoryItem; type IntoIter = IntoIter; fn into_iter(self) -> Self::IntoIter { @@ -148,11 +197,11 @@ pub mod repository { } pub trait AdminRepository: Send + Sync { - fn info(&self) -> RepositoryInfo; - fn list(&self) -> RepositoryList; - fn get(&self, id: usize) -> Option; - fn create(&self, data: Value) -> Option; - fn update(&self, id: usize, data: Value) -> Option; - fn delete(&self, id: usize) -> Option; + fn info(&self, model: &AdminModel) -> RepositoryInfo; + fn list(&self, model: &AdminModel) -> RepositoryList; + fn get(&self, model: &AdminModel, id: LookupKey) -> Option; + fn create(&self, model: &AdminModel, data: Value) -> Option; + fn update(&self, model: &AdminModel, id: LookupKey, data: Value) -> Option; + fn delete(&self, model: &AdminModel, id: LookupKey) -> Option; } } diff --git a/src/admin/example.rs b/src/admin/example.rs index c8d5583..14fca81 100644 --- a/src/admin/example.rs +++ b/src/admin/example.rs @@ -1,7 +1,8 @@ // implementation of static repository -use super::domain::{AdminModelConfig, AdminRepository, RepoInfo, RepositoryInfo, RepositoryList}; +use super::domain::*; use super::state::AdminRegistry; +use log::warn; use serde_json::{json, Value}; struct MyStaticRepository { @@ -18,24 +19,54 @@ impl MyStaticRepository { json!({"id": 4, "name": "Rex", "age": 72}), json!({"id": 5, "name": "Justin", "age": 46}), 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 { - fn get(&self, id: usize) -> Option { - self.content.get(id).cloned() - } - - fn list(&self) -> RepositoryList { - RepositoryList::List { - values: self.content.clone(), + fn get(&self, model: &AdminModel, id: LookupKey) -> Option { + 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 info(&self) -> RepositoryInfo { + fn list(&self, model: &AdminModel) -> RepositoryList { + // Admin needs to inject info in these. + RepositoryList::List { + 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, model: &AdminModel) -> RepositoryInfo { RepoInfo { name: "My Static Repository", lookup_key: "id", @@ -44,17 +75,17 @@ impl AdminRepository for MyStaticRepository { .into() } - fn create(&self, data: Value) -> Option { + fn create(&self, model: &AdminModel, data: Value) -> Option { println!("I would now create: {}", data); None } - fn update(&self, id: usize, data: Value) -> Option { + fn update(&self, model: &AdminModel, id: LookupKey, data: Value) -> Option { println!("I would now update: {}, {}", id, data); None } - fn delete(&self, id: usize) -> Option { + fn delete(&self, model: &AdminModel, id: LookupKey) -> Option { println!("Would delete: {}", id); None } diff --git a/src/admin/mod.rs b/src/admin/mod.rs index 0fbf5c3..572e531 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -12,6 +12,7 @@ pub fn routes() -> Router { .route("/", get(views::index).post(views::index_action)) .route("/app/:app", get(views::list_app)) .route("/app/:app/model/:model", get(views::list_item_collection)) + .route("/app/:app/model/:model/add", get(views::create_item)) .route( "/app/:app/model/:model/detail/:id", get(views::item_details), diff --git a/src/admin/state.rs b/src/admin/state.rs index 8d52cbf..36ef52f 100644 --- a/src/admin/state.rs +++ b/src/admin/state.rs @@ -56,15 +56,16 @@ impl AdminRegistry { } 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 { key: internal_model.model_key.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, - 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) { 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); - Ok(model_key) + Ok(full_model_key) } pub fn register_model( @@ -108,8 +109,13 @@ impl AdminRegistry { Ok(()) } - pub fn get_repository(&self, model_key: &str) -> Result<&Box, String> { - if let Some(repo) = self.repositories.get(model_key) { + pub fn get_repository( + &self, + app_key: &str, + model_key: &str, + ) -> Result<&Box, String> { + let full_model_key = format!("{}.{}", app_key, model_key); + if let Some(repo) = self.repositories.get(&full_model_key) { return Ok(repo); } else { 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(), + } + } + } } diff --git a/src/admin/views.rs b/src/admin/views.rs index b3d97d5..f4a4d2c 100644 --- a/src/admin/views.rs +++ b/src/admin/views.rs @@ -1,27 +1,15 @@ use std::sync::Arc; use axum::extract::Path; -use axum::{extract::State, response::IntoResponse, Form}; +use axum::{extract::State, response::IntoResponse}; use log::info; use crate::admin::domain::{AdminApp, AdminModel}; use crate::admin::state; use crate::service::templates; use serde::{Deserialize, Serialize}; -use serde_json::Value; -use super::domain::{RepositoryInfo, RepositoryList}; - -#[derive(Deserialize)] -pub struct Question { - question: String, -} - -#[derive(Deserialize)] -pub struct ExampleData { - title: String, - content: String, -} +use super::domain::{LookupKey, RepositoryInfo, RepositoryItem, RepositoryList}; #[derive(Serialize, Deserialize)] pub struct AdminRequest { @@ -43,9 +31,10 @@ pub struct AdminContext { pub request: AdminRequest, pub available_apps: Vec, + pub item_model: Option, pub item_info: Option, pub item_list: RepositoryList, - pub item: Option, + pub item: Option, } impl Default for AdminContext { @@ -65,6 +54,7 @@ impl Default for AdminContext { request: AdminRequest { path: "".to_owned(), }, + item_model: None, item_info: None, item_list: RepositoryList::Empty, 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. -pub async fn index_action(Form(example_data): Form) -> impl IntoResponse { +pub async fn index_action() -> impl IntoResponse { "There is your answer!".to_owned() } @@ -104,13 +94,18 @@ pub async fn list_item_collection( Path((app_key, model_key)): Path<(String, String)>, ) -> impl IntoResponse { info!("list_item_collection {} for model {}", app_key, model_key); - let context = if let Ok(repo) = registry.get_repository(&model_key) { - // we should consider using Vec instead in get_list. + 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. AdminContext { available_apps: registry.get_apps(), content: model_key.to_owned(), - item_info: Some(repo.info()), - item_list: repo.list(), + item_info: Some(repo.info(&admin_model)), + item_list: repo.list(&admin_model), + item_model: Some(admin_model), ..Default::default() } } 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. pub async fn item_collection_action( - Form(question): Form, Path((app_key, model_key)): Path<(String, String)>, ) -> impl IntoResponse { "There is your answer!".to_owned() @@ -133,14 +127,62 @@ pub async fn item_collection_action( // Item Details shows one single dataset. pub async fn item_details( templates: State, + registry: State>, Path((app_key, model_key, id)): Path<(String, String, String)>, ) -> 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, + registry: State>, + 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. pub async fn item_action( - Form(question): Form, Path((app_key, model_key, model_id)): Path<(String, String, String)>, ) -> impl IntoResponse { "There is your answer!".to_owned() diff --git a/static/admin/admin.css b/static/admin/admin.css index 8393202..66b28f6 100644 --- a/static/admin/admin.css +++ b/static/admin/admin.css @@ -20,11 +20,13 @@ margin-left: var(--sidebar-margin); } +/* @media screen and (min-width: 1920px) { .pushover { margin-left: 0px; } } +*/ #main_sidemenu { background-color: rgb(27, 28, 29); diff --git a/templates/admin/base.jinja b/templates/admin/base.jinja index 6f56833..b77d623 100644 --- a/templates/admin/base.jinja +++ b/templates/admin/base.jinja @@ -110,12 +110,12 @@ {{ model.name }} {% else %} - {{ model.name }} + {{ model.name }} {% endif %} - - {% if model.add_url %} - {{ translate( 'Add') }} + + {% if model.add_url %} + {{ translate( 'Add') }} {% endif %} {% endfor %} diff --git a/templates/admin/items/item_create.jinja b/templates/admin/items/item_create.jinja new file mode 100644 index 0000000..1d2c546 --- /dev/null +++ b/templates/admin/items/item_create.jinja @@ -0,0 +1,17 @@ +{% extends "admin/base.jinja" %} + +{% macro input(name, value="", type="text") -%} + +{%- endmacro %} + +{% block content %} +Create {{item_model.name}} in {{item_info.name}} + +
+ {% set fields = item_info.display_list %} + {% for field in fields %} +

{{ input(field) }}

+ {% endfor %} + +
+{% endblock content %} \ No newline at end of file diff --git a/templates/admin/items/item_detail.jinja b/templates/admin/items/item_detail.jinja index 52ab774..df50696 100644 --- a/templates/admin/items/item_detail.jinja +++ b/templates/admin/items/item_detail.jinja @@ -1 +1,11 @@ -Item Detail. \ No newline at end of file +{% extends "admin/base.jinja" %} + +{% block content %} + +{% if item %} +{{item.fields}} +{% else %} +No Item found. +{% endif %} + +{% 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 57f3cea..2f5af61 100644 --- a/templates/admin/items/item_list.jinja +++ b/templates/admin/items/item_list.jinja @@ -16,7 +16,7 @@ {% set primary_key = item_info.lookup_key %} {% endif %} -
+
@@ -39,12 +39,11 @@ {% for key in item_keys %} {% if key==primary_key %} - + {% else %} - - {% endif %} + {% endif %} {% endfor %} {% endfor %}
{{ item_info.name|none(content) }}
- {{ item[key] }} - {% if item.detail_url %}{{ + item.fields[key] }}{% + else %}{{item.fields[key] }}{% endif %}{{ item[key] }}{{ item.fields[key] }}