From f4202a1ed5e0390c45bdd3c8de424cbb520369ac Mon Sep 17 00:00:00 2001 From: Simon Bruder Date: Sun, 21 Jul 2024 15:57:51 +0200 Subject: [PATCH] Split frontend files --- src/frontend/item.rs | 570 ------------------------------ src/frontend/item/add.rs | 180 ++++++++++ src/frontend/item/delete.rs | 29 ++ src/frontend/item/edit.rs | 135 +++++++ src/frontend/item/list.rs | 109 ++++++ src/frontend/item/mod.rs | 19 + src/frontend/item/show.rs | 183 ++++++++++ src/frontend/item_class.rs | 412 --------------------- src/frontend/item_class/add.rs | 107 ++++++ src/frontend/item_class/delete.rs | 29 ++ src/frontend/item_class/edit.rs | 106 ++++++ src/frontend/item_class/list.rs | 77 ++++ src/frontend/item_class/mod.rs | 19 + src/frontend/item_class/show.rs | 156 ++++++++ src/frontend/jump.rs | 61 ++++ src/frontend/mod.rs | 56 +-- 16 files changed, 1213 insertions(+), 1035 deletions(-) delete mode 100644 src/frontend/item.rs create mode 100644 src/frontend/item/add.rs create mode 100644 src/frontend/item/delete.rs create mode 100644 src/frontend/item/edit.rs create mode 100644 src/frontend/item/list.rs create mode 100644 src/frontend/item/mod.rs create mode 100644 src/frontend/item/show.rs delete mode 100644 src/frontend/item_class.rs create mode 100644 src/frontend/item_class/add.rs create mode 100644 src/frontend/item_class/delete.rs create mode 100644 src/frontend/item_class/edit.rs create mode 100644 src/frontend/item_class/list.rs create mode 100644 src/frontend/item_class/mod.rs create mode 100644 src/frontend/item_class/show.rs create mode 100644 src/frontend/jump.rs diff --git a/src/frontend/item.rs b/src/frontend/item.rs deleted file mode 100644 index f00c607..0000000 --- a/src/frontend/item.rs +++ /dev/null @@ -1,570 +0,0 @@ -// SPDX-FileCopyrightText: 2024 Simon Bruder -// -// SPDX-License-Identifier: AGPL-3.0-or-later - -use std::collections::HashMap; -use std::fmt::Display; - -use actix_identity::Identity; -use actix_web::{error, get, post, web, HttpRequest, Responder}; -use maud::html; -use serde::Deserialize; -use sqlx::PgPool; -use uuid::Uuid; - -use super::templates::helpers::{ - Colour, ItemName, ItemPreview, PageAction, PageActionGroup, PageActionMethod, -}; -use super::templates::{self, datalist, forms, TemplateConfig}; -use crate::manage; -use crate::models::*; - -pub fn config(cfg: &mut web::ServiceConfig) { - cfg.service(show) - .service(list) - .service(add_form) - .service(add_post) - .service(edit_form) - .service(edit) - .service(delete); -} - -#[get("/item/{id}")] -async fn show( - pool: web::Data, - path: web::Path, - user: Identity, -) -> actix_web::Result { - let id = path.into_inner(); - - let item = manage::item::get(&pool, id) - .await - .map_err(error::ErrorInternalServerError)?; - - let item_classes = manage::item_class::get_all_as_map(&pool) - .await - .map_err(error::ErrorInternalServerError)?; - - let parents = manage::item::get_parents_details(&pool, item.id) - .await - .map_err(error::ErrorInternalServerError)?; - - let children = manage::item::get_children(&pool, item.id) - .await - .map_err(error::ErrorInternalServerError)?; - - let original_packaging = match item.original_packaging { - Some(id) => Some( - manage::item::get(&pool, id) - .await - .map_err(error::ErrorInternalServerError)?, - ), - None => None, - }; - - let original_packaging_of = manage::item::original_packaging_contents(&pool, id) - .await - .map_err(error::ErrorInternalServerError)?; - - let item_class = item_classes.get(&item.class).unwrap(); - - let item_name = ItemName::new(item.name.as_ref(), &item_class.name); - let mut title = item_name.to_string(); - title.push_str(" – Item Details"); - - Ok(templates::base( - TemplateConfig { - path: &format!("/item/{}", item.id), - title: Some(&title), - page_title: Some(Box::new(item_name.clone())), - page_actions: vec![ - (PageActionGroup::Button { - action: PageAction { - method: PageActionMethod::Get, - target: format!("/items/add?parent={}", item.id), - name: "Add Child".to_string(), - }, - colour: Colour::Success, - }), - PageActionGroup::generate_labels(&[item.id]), - (PageActionGroup::Button { - action: PageAction { - method: PageActionMethod::Get, - target: format!("/item/{}/edit", item.id), - name: "Edit".to_string(), - }, - colour: Colour::Warning, - }), - (PageActionGroup::Button { - action: PageAction { - method: PageActionMethod::Post, - target: format!("/item/{}/delete", item.id), - name: "Delete".to_string(), - }, - colour: Colour::Danger, - }), - ], - user: Some(user), - ..Default::default() - }, - html! { - table .table { - tr { - th { "UUID" } - td { (item.id) } - } - tr { - th { "Short ID" } - td { (item.short_id) } - } - tr { - th { "Name" } - td { (item_name.clone().terse()) } - } - tr { - th { "Class" } - td { a href={ "/item-class/" (item.class) } { (item_class.name) } } - } - tr { - th { "Parents" } - td { - (templates::helpers::parents_breadcrumb( - ItemName::new( - item.name.as_ref(), - &item_class.name - ), - &parents.iter().map(|parent| ItemPreview::from_parts(parent.id, parent.name.as_ref(), &item_classes.get(&parent.class).unwrap().name)).collect::>(), - true - )) - } - } - tr { - th { "Original Packaging" } - td { - @if let Some(original_packaging) = original_packaging { - a - href={ "/item/" (original_packaging.id) } - { (ItemName::new(original_packaging.name.as_ref(), &item_classes.get(&original_packaging.class).unwrap().name)) } - } @else { - "-" - } - } - } - tr { - th { "Description" } - td style="white-space: pre-wrap" { (item.description) } - } - } - - @if !children.is_empty() { - div .d-flex.justify-content-between.mt-4 { - div { - h3 { "Direct Children (" (children.len()) ")" } - } - div { - (PageActionGroup::generate_labels( - &children.iter().map(|i| i.id).collect::>(), - )) - } - } - - ul { - @for child in children { - li { - (ItemPreview::from_parts(child.id, child.name.as_ref(), &item_classes.get(&child.class).unwrap().name)) - } - } - } - } - - @if !original_packaging_of.is_empty() { - h3 .mt-4 { "Original Packaging of" } - - ul { - @for item in original_packaging_of { - li { - (ItemPreview::from_parts(item.id, item.name.as_ref(), &item_classes.get(&item.class).unwrap().name)) - } - } - } - } - }, - )) -} - -#[get("/items")] -async fn list(pool: web::Data, user: Identity) -> actix_web::Result { - let item_list = manage::item::get_all(&pool) - .await - .map_err(error::ErrorInternalServerError)?; - - let items = manage::item::get_all_as_map(&pool) - .await - .map_err(error::ErrorInternalServerError)?; - - let item_classes = manage::item_class::get_all_as_map(&pool) - .await - .map_err(error::ErrorInternalServerError)?; - - let item_tree = manage::item::get_all_parents(&pool) - .await - .map_err(error::ErrorInternalServerError)?; - - // TODO: remove clone (should be possible without it) - let item_parents: HashMap> = item_tree - .iter() - .map(|(id, parent_ids)| { - ( - *id, - parent_ids - .iter() - .map(|parent_id| items.get(parent_id).unwrap().clone()) - .collect(), - ) - }) - .collect(); - - Ok(templates::base( - TemplateConfig { - path: "/items", - title: Some("Item List"), - page_title: Some(Box::new("Item List")), - page_actions: vec![ - (PageActionGroup::Button { - action: PageAction { - method: PageActionMethod::Get, - target: "/items/add".to_string(), - name: "Add".to_string(), - }, - colour: Colour::Success, - }), - ], - user: Some(user), - ..Default::default() - }, - html! { - table .table { - thead { - tr { - th { "Name" } - th { "Class" } - th { "Parents" } - } - } - tbody { - @for item in item_list { - @let class = item_classes.get(&item.class).unwrap(); - @let parents = item_parents.get(&item.id).unwrap(); - tr { - td { - (ItemPreview::new(item.id, ItemName::new(item.name.as_ref(), &class.name).terse())) - } - td { a href={ "/item-class/" (class.id) } { (class.name) } } - td { - (templates::helpers::parents_breadcrumb( - ItemName::new( - item.name.as_ref(), - &class.name - ), - &parents.iter().map(|parent| ItemPreview::from_parts(parent.id, parent.name.as_ref(), &item_classes.get(&parent.class).unwrap().name)).collect::>(), - false - )) - } - } - } - } - } - }, - )) -} - -fn default_quantity() -> usize { - 1 -} - -#[derive(Debug, Deserialize)] -pub struct NewItemForm { - #[serde(default = "default_quantity")] - pub quantity: usize, - pub name: Option, - pub parent: Option, - pub class: Uuid, - pub original_packaging: Option, - pub description: String, -} - -#[derive(Debug, Deserialize)] -pub struct NewItemFormPrefilled { - pub quantity: Option, - pub name: Option, - pub parent: Option, - pub class: Option, - pub original_packaging: Option, - pub description: Option, -} - -#[get("/items/add")] -async fn add_form( - pool: web::Data, - form: web::Query, - user: Identity, -) -> actix_web::Result { - let datalist_items = datalist::items(&pool) - .await - .map_err(error::ErrorInternalServerError)?; - - let datalist_item_classes = datalist::item_classes(&pool) - .await - .map_err(error::ErrorInternalServerError)?; - - Ok(templates::base( - TemplateConfig { - path: "/items/add", - title: Some("Add Item"), - page_title: Some(Box::new("Add Item")), - datalists: vec![&datalist_items, &datalist_item_classes], - user: Some(user), - ..Default::default() - }, - html! { - form method="POST" { - (forms::InputGroup { - r#type: forms::InputType::Text, - name: "name", - title: "Name", - optional: true, - value: form.name.as_ref().map(|s| s as &dyn Display), - ..Default::default() - }) - (forms::InputGroup { - r#type: forms::InputType::Text, - name: "class", - title: "Class", - required: true, - value: form.class.as_ref().map(|id| id as &dyn Display), - datalist: Some(&datalist_item_classes), - ..Default::default() - }) - (forms::InputGroup { - r#type: forms::InputType::Text, - name: "parent", - title: "Parent", - optional: true, - value: form.parent.as_ref().map(|id| id as &dyn Display), - datalist: Some(&datalist_items), - ..Default::default() - }) - (forms::InputGroup { - r#type: forms::InputType::Text, - name: "original_packaging", - title: "Original Packaging", - optional: true, - disabled: true, - value: form.original_packaging.as_ref().map(|id| id as &dyn Display), - datalist: Some(&datalist_items), - ..Default::default() - }) - (forms::InputGroup { - r#type: forms::InputType::Textarea, - name: "description", - title: "Description ", - value: form.description.as_ref().map(|s| s as &dyn Display), - ..Default::default() - }) - - div .input-group { - button .btn.btn-primary type="submit" { "Add" } - input .form-control.flex-grow-0.w-auto #quantity name="quantity" type="number" value=(form.quantity.unwrap_or(default_quantity())) min="1"; - } - } - }, - )) -} - -#[post("/items/add")] -async fn add_post( - req: HttpRequest, - data: web::Form, - pool: web::Data, - user: Identity, -) -> actix_web::Result { - let data = data.into_inner(); - let new_item = NewItem { - name: data.name, - class: data.class, - parent: data.parent, - original_packaging: data.original_packaging, - description: data.description, - }; - if data.quantity == 1 { - let item = manage::item::add(&pool, new_item) - .await - .map_err(error::ErrorInternalServerError)?; - Ok( - web::Redirect::to("/item/".to_owned() + &item.id.to_string()) - .see_other() - .respond_to(&req) - .map_into_boxed_body(), - ) - } else { - let items = manage::item::add_multiple(&pool, new_item, data.quantity) - .await - .map_err(error::ErrorInternalServerError)?; - Ok(templates::base( - TemplateConfig { - path: "/items/add", - title: Some("Added Items"), - page_title: Some(Box::new("Added Items")), - page_actions: vec![PageActionGroup::generate_labels( - &items.iter().map(|i| i.id).collect::>(), - )], - user: Some(user), - ..Default::default() - }, - html! { - ul { - @for item in items { - li { - a href={ "/item/" (item.id) } { (item.id) } - } - } - } - - a href="/items" { "Back to all items" } - }, - ) - .respond_to(&req) - .map_into_boxed_body()) - } -} - -#[get("/item/{id}/edit")] -async fn edit_form( - pool: web::Data, - path: web::Path, - user: Identity, -) -> actix_web::Result { - let id = path.into_inner(); - - let item = manage::item::get(&pool, id) - .await - .map_err(error::ErrorInternalServerError)?; - - let item_class = manage::item_class::get(&pool, item.class) - .await - .map_err(error::ErrorInternalServerError)?; - - let datalist_items = datalist::items(&pool) - .await - .map_err(error::ErrorInternalServerError)?; - - let datalist_item_classes = datalist::item_classes(&pool) - .await - .map_err(error::ErrorInternalServerError)?; - - let item_name = ItemName::new(item.name.as_ref(), &item_class.name); - let mut title = item_name.to_string(); - title.push_str(" – Edit Item"); - - Ok(templates::base( - TemplateConfig { - path: &format!("/item/{}/edit", item.id), - title: Some(&title), - page_title: Some(Box::new(item_name.clone())), - datalists: vec![&datalist_items, &datalist_item_classes], - user: Some(user), - ..Default::default() - }, - html! { - form method="POST" { - (forms::InputGroup { - r#type: forms::InputType::Text, - name: "id", - title: "UUID", - required: true, - disabled: true, - value: Some(&item.id), - ..Default::default() - }) - (forms::InputGroup { - r#type: forms::InputType::Text, - name: "name", - title: "Name", - optional: true, - disabled: item.name.is_none(), - value: item.name.as_ref().map(|s| s as &dyn Display), - ..Default::default() - }) - (forms::InputGroup { - r#type: forms::InputType::Text, - name: "class", - title: "Class", - required: true, - value: Some(&item.class), - datalist: Some(&datalist_item_classes), - ..Default::default() - }) - (forms::InputGroup { - r#type: forms::InputType::Text, - name: "parent", - title: "Parent", - optional: true, - value: item.parent.as_ref().map(|id| id as &dyn Display), - disabled: item.parent.is_none(), - datalist: Some(&datalist_items), - ..Default::default() - }) - (forms::InputGroup { - r#type: forms::InputType::Text, - name: "original_packaging", - title: "Original Packaging", - optional: true, - value: item.original_packaging.as_ref().map(|id| id as &dyn Display), - disabled: item.original_packaging.is_none(), - datalist: Some(&datalist_items), - ..Default::default() - }) - (forms::InputGroup { - r#type: forms::InputType::Textarea, - name: "description", - title: "Description ", - value: Some(&item.description), - ..Default::default() - }) - - button .btn.btn-primary type="submit" { "Edit" } - } - }, - )) -} - -#[post("/item/{id}/edit")] -async fn edit( - pool: web::Data, - path: web::Path, - data: web::Form, - _user: Identity, -) -> actix_web::Result { - let id = path.into_inner(); - - let item = manage::item::update(&pool, id, data.into_inner()) - .await - .map_err(error::ErrorInternalServerError)?; - - Ok(web::Redirect::to("/item/".to_owned() + &item.id.to_string()).see_other()) -} - -#[post("/item/{id}/delete")] -async fn delete( - pool: web::Data, - path: web::Path, - _user: Identity, -) -> actix_web::Result { - let id = path.into_inner(); - - manage::item::delete(&pool, id) - .await - .map_err(error::ErrorInternalServerError)?; - - Ok(web::Redirect::to("/items").see_other()) -} diff --git a/src/frontend/item/add.rs b/src/frontend/item/add.rs new file mode 100644 index 0000000..d4bf204 --- /dev/null +++ b/src/frontend/item/add.rs @@ -0,0 +1,180 @@ +// SPDX-FileCopyrightText: 2024 Simon Bruder +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::fmt::Display; + +use actix_identity::Identity; +use actix_web::{error, get, post, web, HttpRequest, Responder}; +use maud::html; +use serde::Deserialize; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::frontend::templates::{self, datalist, forms, helpers::PageActionGroup, TemplateConfig}; +use crate::manage; +use crate::models::*; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service(get).service(post); +} + +fn default_quantity() -> usize { + 1 +} + +#[derive(Debug, Deserialize)] +pub struct NewItemForm { + #[serde(default = "default_quantity")] + pub quantity: usize, + pub name: Option, + pub parent: Option, + pub class: Uuid, + pub original_packaging: Option, + pub description: String, +} + +#[derive(Debug, Deserialize)] +pub struct NewItemFormPrefilled { + pub quantity: Option, + pub name: Option, + pub parent: Option, + pub class: Option, + pub original_packaging: Option, + pub description: Option, +} + +#[get("/items/add")] +async fn get( + pool: web::Data, + form: web::Query, + user: Identity, +) -> actix_web::Result { + let datalist_items = datalist::items(&pool) + .await + .map_err(error::ErrorInternalServerError)?; + + let datalist_item_classes = datalist::item_classes(&pool) + .await + .map_err(error::ErrorInternalServerError)?; + + Ok(templates::base( + TemplateConfig { + path: "/items/add", + title: Some("Add Item"), + page_title: Some(Box::new("Add Item")), + datalists: vec![&datalist_items, &datalist_item_classes], + user: Some(user), + ..Default::default() + }, + html! { + form method="POST" { + (forms::InputGroup { + r#type: forms::InputType::Text, + name: "name", + title: "Name", + optional: true, + value: form.name.as_ref().map(|s| s as &dyn Display), + ..Default::default() + }) + (forms::InputGroup { + r#type: forms::InputType::Text, + name: "class", + title: "Class", + required: true, + value: form.class.as_ref().map(|id| id as &dyn Display), + datalist: Some(&datalist_item_classes), + ..Default::default() + }) + (forms::InputGroup { + r#type: forms::InputType::Text, + name: "parent", + title: "Parent", + optional: true, + value: form.parent.as_ref().map(|id| id as &dyn Display), + datalist: Some(&datalist_items), + ..Default::default() + }) + (forms::InputGroup { + r#type: forms::InputType::Text, + name: "original_packaging", + title: "Original Packaging", + optional: true, + disabled: true, + value: form.original_packaging.as_ref().map(|id| id as &dyn Display), + datalist: Some(&datalist_items), + ..Default::default() + }) + (forms::InputGroup { + r#type: forms::InputType::Textarea, + name: "description", + title: "Description ", + value: form.description.as_ref().map(|s| s as &dyn Display), + ..Default::default() + }) + + div .input-group { + button .btn.btn-primary type="submit" { "Add" } + input .form-control.flex-grow-0.w-auto #quantity name="quantity" type="number" value=(form.quantity.unwrap_or(default_quantity())) min="1"; + } + } + }, + )) +} + +#[post("/items/add")] +async fn post( + req: HttpRequest, + data: web::Form, + pool: web::Data, + user: Identity, +) -> actix_web::Result { + let data = data.into_inner(); + let new_item = NewItem { + name: data.name, + class: data.class, + parent: data.parent, + original_packaging: data.original_packaging, + description: data.description, + }; + if data.quantity == 1 { + let item = manage::item::add(&pool, new_item) + .await + .map_err(error::ErrorInternalServerError)?; + Ok( + web::Redirect::to("/item/".to_owned() + &item.id.to_string()) + .see_other() + .respond_to(&req) + .map_into_boxed_body(), + ) + } else { + let items = manage::item::add_multiple(&pool, new_item, data.quantity) + .await + .map_err(error::ErrorInternalServerError)?; + Ok(templates::base( + TemplateConfig { + path: "/items/add", + title: Some("Added Items"), + page_title: Some(Box::new("Added Items")), + page_actions: vec![PageActionGroup::generate_labels( + &items.iter().map(|i| i.id).collect::>(), + )], + user: Some(user), + ..Default::default() + }, + html! { + ul { + @for item in items { + li { + a href={ "/item/" (item.id) } { (item.id) } + } + } + } + + a href="/items" { "Back to all items" } + }, + ) + .respond_to(&req) + .map_into_boxed_body()) + } +} diff --git a/src/frontend/item/delete.rs b/src/frontend/item/delete.rs new file mode 100644 index 0000000..f7f548b --- /dev/null +++ b/src/frontend/item/delete.rs @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: 2024 Simon Bruder +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use actix_identity::Identity; +use actix_web::{error, post, web, Responder}; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::manage; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service(post); +} + +#[post("/item/{id}/delete")] +async fn post( + pool: web::Data, + path: web::Path, + _user: Identity, +) -> actix_web::Result { + let id = path.into_inner(); + + manage::item::delete(&pool, id) + .await + .map_err(error::ErrorInternalServerError)?; + + Ok(web::Redirect::to("/items").see_other()) +} diff --git a/src/frontend/item/edit.rs b/src/frontend/item/edit.rs new file mode 100644 index 0000000..69a0054 --- /dev/null +++ b/src/frontend/item/edit.rs @@ -0,0 +1,135 @@ +// SPDX-FileCopyrightText: 2024 Simon Bruder +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::fmt::Display; + +use actix_identity::Identity; +use actix_web::{error, get, post, web, Responder}; +use maud::html; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::frontend::templates::{self, datalist, forms, helpers::ItemName, TemplateConfig}; +use crate::manage; +use crate::models::*; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service(get).service(post); +} + +#[get("/item/{id}/edit")] +async fn get( + pool: web::Data, + path: web::Path, + user: Identity, +) -> actix_web::Result { + let id = path.into_inner(); + + let item = manage::item::get(&pool, id) + .await + .map_err(error::ErrorInternalServerError)?; + + let item_class = manage::item_class::get(&pool, item.class) + .await + .map_err(error::ErrorInternalServerError)?; + + let datalist_items = datalist::items(&pool) + .await + .map_err(error::ErrorInternalServerError)?; + + let datalist_item_classes = datalist::item_classes(&pool) + .await + .map_err(error::ErrorInternalServerError)?; + + let item_name = ItemName::new(item.name.as_ref(), &item_class.name); + let mut title = item_name.to_string(); + title.push_str(" – Edit Item"); + + Ok(templates::base( + TemplateConfig { + path: &format!("/item/{}/edit", item.id), + title: Some(&title), + page_title: Some(Box::new(item_name.clone())), + datalists: vec![&datalist_items, &datalist_item_classes], + user: Some(user), + ..Default::default() + }, + html! { + form method="POST" { + (forms::InputGroup { + r#type: forms::InputType::Text, + name: "id", + title: "UUID", + required: true, + disabled: true, + value: Some(&item.id), + ..Default::default() + }) + (forms::InputGroup { + r#type: forms::InputType::Text, + name: "name", + title: "Name", + optional: true, + disabled: item.name.is_none(), + value: item.name.as_ref().map(|s| s as &dyn Display), + ..Default::default() + }) + (forms::InputGroup { + r#type: forms::InputType::Text, + name: "class", + title: "Class", + required: true, + value: Some(&item.class), + datalist: Some(&datalist_item_classes), + ..Default::default() + }) + (forms::InputGroup { + r#type: forms::InputType::Text, + name: "parent", + title: "Parent", + optional: true, + value: item.parent.as_ref().map(|id| id as &dyn Display), + disabled: item.parent.is_none(), + datalist: Some(&datalist_items), + ..Default::default() + }) + (forms::InputGroup { + r#type: forms::InputType::Text, + name: "original_packaging", + title: "Original Packaging", + optional: true, + value: item.original_packaging.as_ref().map(|id| id as &dyn Display), + disabled: item.original_packaging.is_none(), + datalist: Some(&datalist_items), + ..Default::default() + }) + (forms::InputGroup { + r#type: forms::InputType::Textarea, + name: "description", + title: "Description ", + value: Some(&item.description), + ..Default::default() + }) + + button .btn.btn-primary type="submit" { "Edit" } + } + }, + )) +} + +#[post("/item/{id}/edit")] +async fn post( + pool: web::Data, + path: web::Path, + data: web::Form, + _user: Identity, +) -> actix_web::Result { + let id = path.into_inner(); + + let item = manage::item::update(&pool, id, data.into_inner()) + .await + .map_err(error::ErrorInternalServerError)?; + + Ok(web::Redirect::to("/item/".to_owned() + &item.id.to_string()).see_other()) +} diff --git a/src/frontend/item/list.rs b/src/frontend/item/list.rs new file mode 100644 index 0000000..622ec9e --- /dev/null +++ b/src/frontend/item/list.rs @@ -0,0 +1,109 @@ +// SPDX-FileCopyrightText: 2024 Simon Bruder +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::collections::HashMap; + +use actix_identity::Identity; +use actix_web::{error, get, web, Responder}; +use maud::html; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::frontend::templates::{ + self, + helpers::{Colour, ItemName, ItemPreview, PageAction, PageActionGroup, PageActionMethod}, + TemplateConfig, +}; +use crate::manage; +use crate::models::*; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service(get); +} + +#[get("/items")] +async fn get(pool: web::Data, user: Identity) -> actix_web::Result { + let item_list = manage::item::get_all(&pool) + .await + .map_err(error::ErrorInternalServerError)?; + + let items = manage::item::get_all_as_map(&pool) + .await + .map_err(error::ErrorInternalServerError)?; + + let item_classes = manage::item_class::get_all_as_map(&pool) + .await + .map_err(error::ErrorInternalServerError)?; + + let item_tree = manage::item::get_all_parents(&pool) + .await + .map_err(error::ErrorInternalServerError)?; + + // TODO: remove clone (should be possible without it) + let item_parents: HashMap> = item_tree + .iter() + .map(|(id, parent_ids)| { + ( + *id, + parent_ids + .iter() + .map(|parent_id| items.get(parent_id).unwrap().clone()) + .collect(), + ) + }) + .collect(); + + Ok(templates::base( + TemplateConfig { + path: "/items", + title: Some("Item List"), + page_title: Some(Box::new("Item List")), + page_actions: vec![ + (PageActionGroup::Button { + action: PageAction { + method: PageActionMethod::Get, + target: "/items/add".to_string(), + name: "Add".to_string(), + }, + colour: Colour::Success, + }), + ], + user: Some(user), + ..Default::default() + }, + html! { + table .table { + thead { + tr { + th { "Name" } + th { "Class" } + th { "Parents" } + } + } + tbody { + @for item in item_list { + @let class = item_classes.get(&item.class).unwrap(); + @let parents = item_parents.get(&item.id).unwrap(); + tr { + td { + (ItemPreview::new(item.id, ItemName::new(item.name.as_ref(), &class.name).terse())) + } + td { a href={ "/item-class/" (class.id) } { (class.name) } } + td { + (templates::helpers::parents_breadcrumb( + ItemName::new( + item.name.as_ref(), + &class.name + ), + &parents.iter().map(|parent| ItemPreview::from_parts(parent.id, parent.name.as_ref(), &item_classes.get(&parent.class).unwrap().name)).collect::>(), + false + )) + } + } + } + } + } + }, + )) +} diff --git a/src/frontend/item/mod.rs b/src/frontend/item/mod.rs new file mode 100644 index 0000000..c2361fa --- /dev/null +++ b/src/frontend/item/mod.rs @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2024 Simon Bruder +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +mod add; +mod delete; +mod edit; +mod list; +mod show; + +use actix_web::web; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.configure(add::config) + .configure(delete::config) + .configure(edit::config) + .configure(list::config) + .configure(show::config); +} diff --git a/src/frontend/item/show.rs b/src/frontend/item/show.rs new file mode 100644 index 0000000..569a43c --- /dev/null +++ b/src/frontend/item/show.rs @@ -0,0 +1,183 @@ +// SPDX-FileCopyrightText: 2024 Simon Bruder +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use actix_identity::Identity; +use actix_web::{error, get, web, Responder}; +use maud::html; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::frontend::templates::{ + self, + helpers::{Colour, ItemName, ItemPreview, PageAction, PageActionGroup, PageActionMethod}, + TemplateConfig, +}; +use crate::manage; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service(get); +} + +#[get("/item/{id}")] +async fn get( + pool: web::Data, + path: web::Path, + user: Identity, +) -> actix_web::Result { + let id = path.into_inner(); + + let item = manage::item::get(&pool, id) + .await + .map_err(error::ErrorInternalServerError)?; + + let item_classes = manage::item_class::get_all_as_map(&pool) + .await + .map_err(error::ErrorInternalServerError)?; + + let parents = manage::item::get_parents_details(&pool, item.id) + .await + .map_err(error::ErrorInternalServerError)?; + + let children = manage::item::get_children(&pool, item.id) + .await + .map_err(error::ErrorInternalServerError)?; + + let original_packaging = match item.original_packaging { + Some(id) => Some( + manage::item::get(&pool, id) + .await + .map_err(error::ErrorInternalServerError)?, + ), + None => None, + }; + + let original_packaging_of = manage::item::original_packaging_contents(&pool, id) + .await + .map_err(error::ErrorInternalServerError)?; + + let item_class = item_classes.get(&item.class).unwrap(); + + let item_name = ItemName::new(item.name.as_ref(), &item_class.name); + let mut title = item_name.to_string(); + title.push_str(" – Item Details"); + + Ok(templates::base( + TemplateConfig { + path: &format!("/item/{}", item.id), + title: Some(&title), + page_title: Some(Box::new(item_name.clone())), + page_actions: vec![ + (PageActionGroup::Button { + action: PageAction { + method: PageActionMethod::Get, + target: format!("/items/add?parent={}", item.id), + name: "Add Child".to_string(), + }, + colour: Colour::Success, + }), + PageActionGroup::generate_labels(&[item.id]), + (PageActionGroup::Button { + action: PageAction { + method: PageActionMethod::Get, + target: format!("/item/{}/edit", item.id), + name: "Edit".to_string(), + }, + colour: Colour::Warning, + }), + (PageActionGroup::Button { + action: PageAction { + method: PageActionMethod::Post, + target: format!("/item/{}/delete", item.id), + name: "Delete".to_string(), + }, + colour: Colour::Danger, + }), + ], + user: Some(user), + ..Default::default() + }, + html! { + table .table { + tr { + th { "UUID" } + td { (item.id) } + } + tr { + th { "Short ID" } + td { (item.short_id) } + } + tr { + th { "Name" } + td { (item_name.clone().terse()) } + } + tr { + th { "Class" } + td { a href={ "/item-class/" (item.class) } { (item_class.name) } } + } + tr { + th { "Parents" } + td { + (templates::helpers::parents_breadcrumb( + ItemName::new( + item.name.as_ref(), + &item_class.name + ), + &parents.iter().map(|parent| ItemPreview::from_parts(parent.id, parent.name.as_ref(), &item_classes.get(&parent.class).unwrap().name)).collect::>(), + true + )) + } + } + tr { + th { "Original Packaging" } + td { + @if let Some(original_packaging) = original_packaging { + a + href={ "/item/" (original_packaging.id) } + { (ItemName::new(original_packaging.name.as_ref(), &item_classes.get(&original_packaging.class).unwrap().name)) } + } @else { + "-" + } + } + } + tr { + th { "Description" } + td style="white-space: pre-wrap" { (item.description) } + } + } + + @if !children.is_empty() { + div .d-flex.justify-content-between.mt-4 { + div { + h3 { "Direct Children (" (children.len()) ")" } + } + div { + (PageActionGroup::generate_labels( + &children.iter().map(|i| i.id).collect::>(), + )) + } + } + + ul { + @for child in children { + li { + (ItemPreview::from_parts(child.id, child.name.as_ref(), &item_classes.get(&child.class).unwrap().name)) + } + } + } + } + + @if !original_packaging_of.is_empty() { + h3 .mt-4 { "Original Packaging of" } + + ul { + @for item in original_packaging_of { + li { + (ItemPreview::from_parts(item.id, item.name.as_ref(), &item_classes.get(&item.class).unwrap().name)) + } + } + } + } + }, + )) +} diff --git a/src/frontend/item_class.rs b/src/frontend/item_class.rs deleted file mode 100644 index 7efaf0a..0000000 --- a/src/frontend/item_class.rs +++ /dev/null @@ -1,412 +0,0 @@ -// SPDX-FileCopyrightText: 2024 Simon Bruder -// -// SPDX-License-Identifier: AGPL-3.0-or-later - -use std::fmt::Display; - -use actix_identity::Identity; -use actix_web::{error, get, post, web, Responder}; -use maud::html; -use serde::Deserialize; -use sqlx::PgPool; -use uuid::Uuid; - -use super::templates::helpers::{ - Colour, ItemName, ItemPreview, PageAction, PageActionGroup, PageActionMethod, -}; -use super::templates::{self, datalist, forms, TemplateConfig}; -use crate::manage; -use crate::models::*; - -pub fn config(cfg: &mut web::ServiceConfig) { - cfg.service(show) - .service(list) - .service(add_form) - .service(add) - .service(edit_form) - .service(edit) - .service(delete); -} - -#[get("/item-class/{id}")] -async fn show( - pool: web::Data, - path: web::Path, - user: Identity, -) -> actix_web::Result { - let id = path.into_inner(); - - let item_class = manage::item_class::get(&pool, id) - .await - .map_err(error::ErrorInternalServerError)?; - - // TODO: Once async closures are stable, use map_or on item_class.parent instead - let parent = match item_class.parent { - Some(id) => manage::item_class::get(&pool, id) - .await - .map(Some) - .map_err(error::ErrorInternalServerError)?, - None => None, - }; - - let children = manage::item_class::children(&pool, id) - .await - .map_err(error::ErrorInternalServerError)?; - - let items = manage::item_class::items(&pool, id) - .await - .map_err(error::ErrorInternalServerError)?; - - let mut title = item_class.name.clone(); - title.push_str(" – Item Details"); - - let mut page_actions = vec![ - (PageActionGroup::Button { - action: PageAction { - method: PageActionMethod::Get, - target: format!("/items/add?class={}", item_class.id), - name: "Add Item".to_string(), - }, - colour: Colour::Success, - }), - ]; - if item_class.parent.is_none() { - page_actions.push(PageActionGroup::Button { - action: PageAction { - method: PageActionMethod::Get, - target: format!("/item-classes/add?parent={}", item_class.id), - name: "Add Child".to_string(), - }, - colour: Colour::Primary, - }); - } - page_actions.push(PageActionGroup::Button { - action: PageAction { - method: PageActionMethod::Get, - target: format!("/item-class/{}/edit", item_class.id), - name: "Edit".to_string(), - }, - colour: Colour::Warning, - }); - page_actions.push(PageActionGroup::Button { - action: PageAction { - method: PageActionMethod::Post, - target: format!("/item-class/{}/delete", item_class.id), - name: "Delete".to_string(), - }, - colour: Colour::Danger, - }); - - Ok(templates::base( - TemplateConfig { - path: &format!("/item-class/{}", item_class.id), - title: Some(&title), - page_title: Some(Box::new(item_class.name.clone())), - page_actions, - user: Some(user), - ..Default::default() - }, - html! { - table .table { - tr { - th { "UUID" } - td { (item_class.id) } - } - tr { - th { "Name" } - td { (item_class.name) } - } - @if let Some(parent) = parent { - tr { - th { "Parent" } - td { a href={ "/item-class/" (parent.id) } { (parent.name) } } - } - } - tr { - th { "Description" } - td style="white-space: pre-wrap" { (item_class.description) } - } - } - - @if !children.is_empty() { - h3 .mt-4 { "Children (" (children.len()) ")" } - - ul { - @for child in children { - li { - a href={ "/item-class/" (child.id) } { (child.name) } - } - } - } - } - - @if !items.is_empty() { - div .d-flex.justify-content-between.mt-4 { - div { - h3 { "Items (" (items.len()) ")" } - } - div { - (PageActionGroup::generate_labels( - &items.iter().map(|i| i.id).collect::>(), - )) - } - } - - ul { - @for item in items { - li { - (ItemPreview::new(item.id, ItemName::new(item.name.as_ref(), &item_class.name).terse())) - } - } - } - } - }, - )) -} - -#[get("/item-classes")] -async fn list(pool: web::Data, user: Identity) -> actix_web::Result { - let item_classes_ids = sqlx::query_scalar!("SELECT id FROM item_classes ORDER BY created_at") - .fetch_all(pool.as_ref()) - .await - .map_err(error::ErrorInternalServerError)?; - - let item_classes = manage::item_class::get_all_as_map(&pool) - .await - .map_err(error::ErrorInternalServerError)?; - - Ok(templates::base( - TemplateConfig { - path: "/item-classes", - title: Some("Item Class List"), - page_title: Some(Box::new("Item Class List")), - page_actions: vec![ - (PageActionGroup::Button { - action: PageAction { - method: PageActionMethod::Get, - target: "/item-classes/add".to_string(), - name: "Add".to_string(), - }, - colour: Colour::Success, - }), - ], - user: Some(user), - ..Default::default() - }, - html! { - table .table { - thead { - tr { - th { "Name" } - th { "Parents" } - } - } - tbody { - @for item_class in item_classes_ids { - @let item_class = item_classes.get(&item_class).unwrap(); - tr { - td { a href={ "/item-class/" (item_class.id) } { (item_class.name) } } - td { - @if let Some(parent) = item_class.parent { - @let parent = item_classes.get(&parent).unwrap(); - a href={ "/item-class/" (parent.id) } { (parent.name) } - } @else { - "-" - } - } - } - } - } - } - }, - )) -} - -#[derive(Debug, Deserialize)] -pub struct NewItemClassForm { - pub name: String, - pub parent: Option, - pub description: String, -} - -#[derive(Debug, Deserialize)] -pub struct NewItemClassFormPrefilled { - pub name: Option, - pub parent: Option, - pub description: Option, -} - -#[get("/item-classes/add")] -async fn add_form( - pool: web::Data, - form: web::Query, - user: Identity, -) -> actix_web::Result { - let datalist_item_classes = datalist::item_classes(&pool) - .await - .map_err(error::ErrorInternalServerError)?; - - Ok(templates::base( - TemplateConfig { - path: "/items-classes/add", - title: Some("Add Item Class"), - page_title: Some(Box::new("Add Item Class")), - datalists: vec![&datalist_item_classes], - user: Some(user), - ..Default::default() - }, - html! { - form method="POST" { - (forms::InputGroup { - r#type: forms::InputType::Text, - name: "name", - title: "Name", - required: true, - value: form.name.as_ref().map(|s| s as &dyn Display), - ..Default::default() - }) - (forms::InputGroup { - r#type: forms::InputType::Text, - name: "parent", - title: "Parent", - optional: true, - disabled: form.parent.is_none(), - value: form.parent.as_ref().map(|id| id as &dyn Display), - datalist: Some(&datalist_item_classes), - ..Default::default() - }) - (forms::InputGroup { - r#type: forms::InputType::Textarea, - name: "description", - title: "Description ", - value: form.description.as_ref().map(|s| s as &dyn Display), - ..Default::default() - }) - - button .btn.btn-primary type="submit" { "Add" } - } - }, - )) -} - -#[post("/item-classes/add")] -async fn add( - data: web::Form, - pool: web::Data, - _user: Identity, -) -> actix_web::Result { - let data = data.into_inner(); - let item = manage::item_class::add( - &pool, - NewItemClass { - name: data.name, - parent: data.parent, - description: data.description, - }, - ) - .await - .map_err(error::ErrorInternalServerError)?; - Ok(web::Redirect::to("/item-class/".to_owned() + &item.id.to_string()).see_other()) -} - -#[get("/item-class/{id}/edit")] -async fn edit_form( - pool: web::Data, - path: web::Path, - user: Identity, -) -> actix_web::Result { - let id = path.into_inner(); - - let item_class = manage::item_class::get(&pool, id) - .await - .map_err(error::ErrorInternalServerError)?; - - let datalist_item_classes = datalist::item_classes(&pool) - .await - .map_err(error::ErrorInternalServerError)?; - - let mut title = item_class.name.clone(); - title.push_str(" – Item Details"); - - Ok(templates::base( - TemplateConfig { - path: &format!("/items-class/{}/add", id), - title: Some(&title), - page_title: Some(Box::new(item_class.name.clone())), - datalists: vec![&datalist_item_classes], - user: Some(user), - ..Default::default() - }, - html! { - form method="POST" { - (forms::InputGroup { - r#type: forms::InputType::Text, - name: "id", - title: "UUID", - disabled: true, - required: true, - value: Some(&item_class.id), - ..Default::default() - }) - (forms::InputGroup { - r#type: forms::InputType::Text, - name: "name", - title: "Name", - required: true, - value: Some(&item_class.name), - ..Default::default() - }) - (forms::InputGroup { - r#type: forms::InputType::Text, - name: "parent", - title: "Parent", - optional: true, - disabled: item_class.parent.is_none(), - value: item_class.parent.as_ref().map(|id| id as &dyn Display), - datalist: Some(&datalist_item_classes), - ..Default::default() - }) - (forms::InputGroup { - r#type: forms::InputType::Textarea, - name: "description", - title: "Description ", - value: Some(&item_class.description), - ..Default::default() - }) - - button .btn.btn-primary type="submit" { "Edit" } - } - }, - )) -} - -#[post("/item-class/{id}/edit")] -async fn edit( - pool: web::Data, - path: web::Path, - data: web::Form, - _user: Identity, -) -> actix_web::Result { - let id = path.into_inner(); - - let item_class = manage::item_class::update(&pool, id, data.into_inner()) - .await - .map_err(error::ErrorInternalServerError)?; - - Ok(web::Redirect::to("/item-class/".to_owned() + &item_class.id.to_string()).see_other()) -} - -#[post("/item-class/{id}/delete")] -async fn delete( - pool: web::Data, - path: web::Path, - _user: Identity, -) -> actix_web::Result { - let id = path.into_inner(); - - manage::item_class::delete(&pool, id) - .await - .map_err(error::ErrorInternalServerError)?; - - Ok(web::Redirect::to("/item-classes").see_other()) -} diff --git a/src/frontend/item_class/add.rs b/src/frontend/item_class/add.rs new file mode 100644 index 0000000..75d56ad --- /dev/null +++ b/src/frontend/item_class/add.rs @@ -0,0 +1,107 @@ +// SPDX-FileCopyrightText: 2024 Simon Bruder +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::fmt::Display; + +use actix_identity::Identity; +use actix_web::{error, get, post, web, Responder}; +use maud::html; +use serde::Deserialize; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::frontend::templates::{self, datalist, forms, TemplateConfig}; +use crate::manage; +use crate::models::*; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service(get).service(post); +} + +#[derive(Debug, Deserialize)] +pub struct NewItemClassForm { + pub name: String, + pub parent: Option, + pub description: String, +} + +#[derive(Debug, Deserialize)] +pub struct NewItemClassFormPrefilled { + pub name: Option, + pub parent: Option, + pub description: Option, +} + +#[get("/item-classes/add")] +async fn get( + pool: web::Data, + form: web::Query, + user: Identity, +) -> actix_web::Result { + let datalist_item_classes = datalist::item_classes(&pool) + .await + .map_err(error::ErrorInternalServerError)?; + + Ok(templates::base( + TemplateConfig { + path: "/items-classes/add", + title: Some("Add Item Class"), + page_title: Some(Box::new("Add Item Class")), + datalists: vec![&datalist_item_classes], + user: Some(user), + ..Default::default() + }, + html! { + form method="POST" { + (forms::InputGroup { + r#type: forms::InputType::Text, + name: "name", + title: "Name", + required: true, + value: form.name.as_ref().map(|s| s as &dyn Display), + ..Default::default() + }) + (forms::InputGroup { + r#type: forms::InputType::Text, + name: "parent", + title: "Parent", + optional: true, + disabled: form.parent.is_none(), + value: form.parent.as_ref().map(|id| id as &dyn Display), + datalist: Some(&datalist_item_classes), + ..Default::default() + }) + (forms::InputGroup { + r#type: forms::InputType::Textarea, + name: "description", + title: "Description ", + value: form.description.as_ref().map(|s| s as &dyn Display), + ..Default::default() + }) + + button .btn.btn-primary type="submit" { "Add" } + } + }, + )) +} + +#[post("/item-classes/add")] +async fn post( + data: web::Form, + pool: web::Data, + _user: Identity, +) -> actix_web::Result { + let data = data.into_inner(); + let item = manage::item_class::add( + &pool, + NewItemClass { + name: data.name, + parent: data.parent, + description: data.description, + }, + ) + .await + .map_err(error::ErrorInternalServerError)?; + Ok(web::Redirect::to("/item-class/".to_owned() + &item.id.to_string()).see_other()) +} diff --git a/src/frontend/item_class/delete.rs b/src/frontend/item_class/delete.rs new file mode 100644 index 0000000..4c5aa3e --- /dev/null +++ b/src/frontend/item_class/delete.rs @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: 2024 Simon Bruder +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use actix_identity::Identity; +use actix_web::{error, post, web, Responder}; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::manage; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service(post); +} + +#[post("/item-class/{id}/delete")] +async fn post( + pool: web::Data, + path: web::Path, + _user: Identity, +) -> actix_web::Result { + let id = path.into_inner(); + + manage::item_class::delete(&pool, id) + .await + .map_err(error::ErrorInternalServerError)?; + + Ok(web::Redirect::to("/item-classes").see_other()) +} diff --git a/src/frontend/item_class/edit.rs b/src/frontend/item_class/edit.rs new file mode 100644 index 0000000..4c0b7ac --- /dev/null +++ b/src/frontend/item_class/edit.rs @@ -0,0 +1,106 @@ +// SPDX-FileCopyrightText: 2024 Simon Bruder +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::fmt::Display; + +use actix_identity::Identity; +use actix_web::{error, get, post, web, Responder}; +use maud::html; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::frontend::templates::{self, datalist, forms, TemplateConfig}; +use crate::manage; +use crate::models::*; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service(get).service(post); +} + +#[get("/item-class/{id}/edit")] +async fn get( + pool: web::Data, + path: web::Path, + user: Identity, +) -> actix_web::Result { + let id = path.into_inner(); + + let item_class = manage::item_class::get(&pool, id) + .await + .map_err(error::ErrorInternalServerError)?; + + let datalist_item_classes = datalist::item_classes(&pool) + .await + .map_err(error::ErrorInternalServerError)?; + + let mut title = item_class.name.clone(); + title.push_str(" – Item Details"); + + Ok(templates::base( + TemplateConfig { + path: &format!("/items-class/{}/add", id), + title: Some(&title), + page_title: Some(Box::new(item_class.name.clone())), + datalists: vec![&datalist_item_classes], + user: Some(user), + ..Default::default() + }, + html! { + form method="POST" { + (forms::InputGroup { + r#type: forms::InputType::Text, + name: "id", + title: "UUID", + disabled: true, + required: true, + value: Some(&item_class.id), + ..Default::default() + }) + (forms::InputGroup { + r#type: forms::InputType::Text, + name: "name", + title: "Name", + required: true, + value: Some(&item_class.name), + ..Default::default() + }) + (forms::InputGroup { + r#type: forms::InputType::Text, + name: "parent", + title: "Parent", + optional: true, + disabled: item_class.parent.is_none(), + value: item_class.parent.as_ref().map(|id| id as &dyn Display), + datalist: Some(&datalist_item_classes), + ..Default::default() + }) + (forms::InputGroup { + r#type: forms::InputType::Textarea, + name: "description", + title: "Description ", + value: Some(&item_class.description), + ..Default::default() + }) + + button .btn.btn-primary type="submit" { "Edit" } + } + }, + )) +} + +#[post("/item-class/{id}/edit")] +async fn post( + pool: web::Data, + path: web::Path, + data: web::Form, + _user: Identity, +) -> actix_web::Result { + let id = path.into_inner(); + + let item_class = manage::item_class::update(&pool, id, data.into_inner()) + .await + .map_err(error::ErrorInternalServerError)?; + + Ok(web::Redirect::to("/item-class/".to_owned() + &item_class.id.to_string()).see_other()) +} diff --git a/src/frontend/item_class/list.rs b/src/frontend/item_class/list.rs new file mode 100644 index 0000000..9c9212c --- /dev/null +++ b/src/frontend/item_class/list.rs @@ -0,0 +1,77 @@ +// SPDX-FileCopyrightText: 2024 Simon Bruder +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use actix_identity::Identity; +use actix_web::{error, get, web, Responder}; +use maud::html; +use sqlx::PgPool; + +use crate::frontend::templates::{ + self, + helpers::{Colour, PageAction, PageActionGroup, PageActionMethod}, + TemplateConfig, +}; +use crate::manage; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service(get); +} + +#[get("/item-classes")] +async fn get(pool: web::Data, user: Identity) -> actix_web::Result { + let item_classes_ids = sqlx::query_scalar!("SELECT id FROM item_classes ORDER BY created_at") + .fetch_all(pool.as_ref()) + .await + .map_err(error::ErrorInternalServerError)?; + + let item_classes = manage::item_class::get_all_as_map(&pool) + .await + .map_err(error::ErrorInternalServerError)?; + + Ok(templates::base( + TemplateConfig { + path: "/item-classes", + title: Some("Item Class List"), + page_title: Some(Box::new("Item Class List")), + page_actions: vec![ + (PageActionGroup::Button { + action: PageAction { + method: PageActionMethod::Get, + target: "/item-classes/add".to_string(), + name: "Add".to_string(), + }, + colour: Colour::Success, + }), + ], + user: Some(user), + ..Default::default() + }, + html! { + table .table { + thead { + tr { + th { "Name" } + th { "Parents" } + } + } + tbody { + @for item_class in item_classes_ids { + @let item_class = item_classes.get(&item_class).unwrap(); + tr { + td { a href={ "/item-class/" (item_class.id) } { (item_class.name) } } + td { + @if let Some(parent) = item_class.parent { + @let parent = item_classes.get(&parent).unwrap(); + a href={ "/item-class/" (parent.id) } { (parent.name) } + } @else { + "-" + } + } + } + } + } + } + }, + )) +} diff --git a/src/frontend/item_class/mod.rs b/src/frontend/item_class/mod.rs new file mode 100644 index 0000000..c2361fa --- /dev/null +++ b/src/frontend/item_class/mod.rs @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2024 Simon Bruder +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +mod add; +mod delete; +mod edit; +mod list; +mod show; + +use actix_web::web; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.configure(add::config) + .configure(delete::config) + .configure(edit::config) + .configure(list::config) + .configure(show::config); +} diff --git a/src/frontend/item_class/show.rs b/src/frontend/item_class/show.rs new file mode 100644 index 0000000..3174141 --- /dev/null +++ b/src/frontend/item_class/show.rs @@ -0,0 +1,156 @@ +// SPDX-FileCopyrightText: 2024 Simon Bruder +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use actix_identity::Identity; +use actix_web::{error, get, web, Responder}; +use maud::html; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::frontend::templates::{ + self, + helpers::{Colour, ItemName, ItemPreview, PageAction, PageActionGroup, PageActionMethod}, + TemplateConfig, +}; +use crate::manage; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service(get); +} + +#[get("/item-class/{id}")] +async fn get( + pool: web::Data, + path: web::Path, + user: Identity, +) -> actix_web::Result { + let id = path.into_inner(); + + let item_class = manage::item_class::get(&pool, id) + .await + .map_err(error::ErrorInternalServerError)?; + + // TODO: Once async closures are stable, use map_or on item_class.parent instead + let parent = match item_class.parent { + Some(id) => manage::item_class::get(&pool, id) + .await + .map(Some) + .map_err(error::ErrorInternalServerError)?, + None => None, + }; + + let children = manage::item_class::children(&pool, id) + .await + .map_err(error::ErrorInternalServerError)?; + + let items = manage::item_class::items(&pool, id) + .await + .map_err(error::ErrorInternalServerError)?; + + let mut title = item_class.name.clone(); + title.push_str(" – Item Details"); + + let mut page_actions = vec![ + (PageActionGroup::Button { + action: PageAction { + method: PageActionMethod::Get, + target: format!("/items/add?class={}", item_class.id), + name: "Add Item".to_string(), + }, + colour: Colour::Success, + }), + ]; + if item_class.parent.is_none() { + page_actions.push(PageActionGroup::Button { + action: PageAction { + method: PageActionMethod::Get, + target: format!("/item-classes/add?parent={}", item_class.id), + name: "Add Child".to_string(), + }, + colour: Colour::Primary, + }); + } + page_actions.push(PageActionGroup::Button { + action: PageAction { + method: PageActionMethod::Get, + target: format!("/item-class/{}/edit", item_class.id), + name: "Edit".to_string(), + }, + colour: Colour::Warning, + }); + page_actions.push(PageActionGroup::Button { + action: PageAction { + method: PageActionMethod::Post, + target: format!("/item-class/{}/delete", item_class.id), + name: "Delete".to_string(), + }, + colour: Colour::Danger, + }); + + Ok(templates::base( + TemplateConfig { + path: &format!("/item-class/{}", item_class.id), + title: Some(&title), + page_title: Some(Box::new(item_class.name.clone())), + page_actions, + user: Some(user), + ..Default::default() + }, + html! { + table .table { + tr { + th { "UUID" } + td { (item_class.id) } + } + tr { + th { "Name" } + td { (item_class.name) } + } + @if let Some(parent) = parent { + tr { + th { "Parent" } + td { a href={ "/item-class/" (parent.id) } { (parent.name) } } + } + } + tr { + th { "Description" } + td style="white-space: pre-wrap" { (item_class.description) } + } + } + + @if !children.is_empty() { + h3 .mt-4 { "Children (" (children.len()) ")" } + + ul { + @for child in children { + li { + a href={ "/item-class/" (child.id) } { (child.name) } + } + } + } + } + + @if !items.is_empty() { + div .d-flex.justify-content-between.mt-4 { + div { + h3 { "Items (" (items.len()) ")" } + } + div { + (PageActionGroup::generate_labels( + &items.iter().map(|i| i.id).collect::>(), + )) + } + } + + ul { + @for item in items { + li { + (ItemPreview::new(item.id, ItemName::new(item.name.as_ref(), &item_class.name).terse())) + } + } + } + } + }, + )) +} diff --git a/src/frontend/jump.rs b/src/frontend/jump.rs new file mode 100644 index 0000000..a803f08 --- /dev/null +++ b/src/frontend/jump.rs @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: 2024 Simon Bruder +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use actix_identity::Identity; +use actix_web::{error, get, web, Responder}; +use serde::Deserialize; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::manage; +use crate::models::EntityType; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service(get); +} + +#[derive(Deserialize)] +struct JumpData { + id: String, +} + +#[get("/jump")] +async fn get( + pool: web::Data, + data: web::Query, + _user: Identity, // this endpoint leaks information about the existence of items +) -> Result { + let mut id = data.id.clone(); + + let entity_type = if let Ok(id) = Uuid::parse_str(&id) { + manage::query_entity_type(&pool, id) + .await + .map_err(error::ErrorInternalServerError)? + } else if let Ok(short_id) = id.parse::() { + if let Ok(item) = manage::item::get_by_short_id(&pool, short_id) + .await + .map_err(error::ErrorInternalServerError) + { + id = item.id.to_string(); + Some(EntityType::Item) + } else { + None + } + } else { + None + }; + + if let Some(prefix) = entity_type.map(|entity_type| match entity_type { + EntityType::Item => "item", + EntityType::ItemClass => "item-class", + }) { + Ok(web::Redirect::to(format!("/{prefix}/{id}")).see_other()) + } else { + Ok(web::Redirect::to(format!( + "/items/add?{}", + serde_urlencoded::to_string([("name", &id)])? + )) + .see_other()) + } +} diff --git a/src/frontend/mod.rs b/src/frontend/mod.rs index 9abd150..1571c2d 100644 --- a/src/frontend/mod.rs +++ b/src/frontend/mod.rs @@ -5,25 +5,20 @@ mod auth; mod item; mod item_class; +mod jump; mod labels; mod templates; use actix_identity::Identity; -use actix_web::{error, get, web, Responder}; +use actix_web::{get, web, Responder}; use maud::html; -use serde::Deserialize; -use sqlx::PgPool; -use uuid::Uuid; - -use crate::manage; -use crate::models::EntityType; pub fn config(cfg: &mut web::ServiceConfig) { cfg.service(index) - .service(jump) .configure(auth::config) .configure(item::config) .configure(item_class::config) + .configure(jump::config) .configure(labels::config); } @@ -37,48 +32,3 @@ async fn index(user: Identity) -> impl Responder { html! {}, ) } - -#[derive(Deserialize)] -struct JumpData { - id: String, -} - -#[get("/jump")] -async fn jump( - pool: web::Data, - data: web::Query, - _user: Identity, // this endpoint leaks information about the existence of items -) -> Result { - let mut id = data.id.clone(); - - let entity_type = if let Ok(id) = Uuid::parse_str(&id) { - manage::query_entity_type(&pool, id) - .await - .map_err(error::ErrorInternalServerError)? - } else if let Ok(short_id) = id.parse::() { - if let Ok(item) = manage::item::get_by_short_id(&pool, short_id) - .await - .map_err(error::ErrorInternalServerError) - { - id = item.id.to_string(); - Some(EntityType::Item) - } else { - None - } - } else { - None - }; - - if let Some(prefix) = entity_type.map(|entity_type| match entity_type { - EntityType::Item => "item", - EntityType::ItemClass => "item-class", - }) { - Ok(web::Redirect::to(format!("/{prefix}/{id}")).see_other()) - } else { - Ok(web::Redirect::to(format!( - "/items/add?{}", - serde_urlencoded::to_string([("name", &id)])? - )) - .see_other()) - } -}