code: rudimentary implementation of detail and create
This commit is contained in:
parent
d1c98516a8
commit
c439220409
3
TODOS.md
3
TODOS.md
@ -8,4 +8,5 @@
|
||||
- [ ] develop a django-css independent theme for the admin.
|
||||
- [ ] better 404 handling
|
||||
- [ ] better 500 handling
|
||||
|
||||
|
||||
|
||||
|
@ -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>;
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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),
|
||||
|
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
|
@ -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);
|
||||
|
@ -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>
|
||||
|
17
templates/admin/items/item_create.jinja
Normal file
17
templates/admin/items/item_create.jinja
Normal 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 %}
|
@ -1 +1,11 @@
|
||||
Item Detail.
|
||||
{% extends "admin/base.jinja" %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% if item %}
|
||||
{{item.fields}}
|
||||
{% else %}
|
||||
No Item found.
|
||||
{% endif %}
|
||||
|
||||
{% endblock content %}
|
@ -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 %}
|
||||
|
Loading…
Reference in New Issue
Block a user