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 500 handling

View File

@ -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<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 {
Empty,
List {
values: Vec<Value>,
values: Vec<RepositoryItem>,
},
Page {
values: Vec<Value>,
values: Vec<RepositoryItem>,
offset: usize,
total: usize,
},
Stream {
values: Vec<Value>,
values: Vec<RepositoryItem>,
next_index: Option<String>,
},
}
impl IntoIterator for RepositoryList {
type Item = Value;
type Item = RepositoryItem;
type IntoIter = IntoIter<Self::Item>;
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<Value>;
fn create(&self, data: Value) -> Option<Value>;
fn update(&self, id: usize, data: Value) -> Option<Value>;
fn delete(&self, id: usize) -> Option<Value>;
fn info(&self, model: &AdminModel) -> RepositoryInfo;
fn list(&self, model: &AdminModel) -> RepositoryList;
fn get(&self, model: &AdminModel, id: LookupKey) -> Option<RepositoryItem>;
fn create(&self, model: &AdminModel, data: Value) -> Option<Value>;
fn update(&self, model: &AdminModel, id: LookupKey, data: Value) -> Option<Value>;
fn delete(&self, model: &AdminModel, id: LookupKey) -> Option<Value>;
}
}

View File

@ -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<Value> {
self.content.get(id).cloned()
}
fn list(&self) -> RepositoryList {
RepositoryList::List {
values: self.content.clone(),
fn get(&self, model: &AdminModel, id: LookupKey) -> Option<RepositoryItem> {
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<Value> {
fn create(&self, model: &AdminModel, data: Value) -> Option<Value> {
println!("I would now create: {}", data);
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);
None
}
fn delete(&self, id: usize) -> Option<Value> {
fn delete(&self, model: &AdminModel, id: LookupKey) -> Option<Value> {
println!("Would delete: {}", id);
None
}

View File

@ -12,6 +12,7 @@ pub fn routes() -> Router<AppState> {
.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),

View File

@ -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<R: AdminRepository + 'static>(
@ -108,8 +109,13 @@ impl AdminRegistry {
Ok(())
}
pub fn get_repository(&self, model_key: &str) -> Result<&Box<dyn AdminRepository>, String> {
if let Some(repo) = self.repositories.get(model_key) {
pub fn get_repository(
&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);
} 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(),
}
}
}
}

View File

@ -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<AdminApp>,
pub item_model: Option<AdminModel>,
pub item_info: Option<RepositoryInfo>,
pub item_list: RepositoryList,
pub item: Option<Value>,
pub item: Option<RepositoryItem>,
}
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<ExampleData>) -> 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<Value> 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<Question>,
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<templates::Templates>,
registry: State<Arc<state::AdminRegistry>>,
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<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.
pub async fn item_action(
Form(question): Form<Question>,
Path((app_key, model_key, model_id)): Path<(String, String, String)>,
) -> impl IntoResponse {
"There is your answer!".to_owned()

View File

@ -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);

View File

@ -110,12 +110,12 @@
<a href="{{ model.admin_url }}" {% if model.admin_url in request.path|urlencode %} aria-current="page" {%
endif %}>{{ model.name }}</a>
{% else %}
{{ model.name }}
<span>{{ model.name }}</span>
{% endif %}
<i class="plus link icon"></i>
{% if model.add_url %}
<a href="{{ model.add_url }}" class="addlink">{{ translate( 'Add') }}</a>
{% if model.add_url %}<i class="plus link icon">
<a href="{{ model.add_url }}" class="addlink">{{ translate( 'Add') }}</a></i>
{% endif %}
{% endfor %}
</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 %}
{% endif %}
<div class="ui short scrolling container">
<div class="ui basic container">
<table class="ui very compact celled table head stuck unstackable">
<caption>{{ item_info.name|none(content) }}</caption>
<thead>
@ -39,12 +39,11 @@
<tr>
{% for key in item_keys %}
{% if key==primary_key %}
<td class="selectable warning">
<a href="#">{{ item[key] }}</a>
</td>
<td class="selectable warning">{% if item.detail_url %}<a href="{{item.detail_url}}">{{
item.fields[key] }}</a>{%
else %}{{item.fields[key] }}{% endif %}</td>
{% else %}
<td>{{ item[key] }}</td>
{% endif %}
<td>{{ item.fields[key] }}</td>{% endif %}
{% endfor %}
</tr>
{% endfor %}