Recipe edit, we can now delete groups, steps and ingredients
authorGreg Burri <greg.burri@gmail.com>
Sat, 28 Dec 2024 21:52:07 +0000 (22:52 +0100)
committerGreg Burri <greg.burri@gmail.com>
Sat, 28 Dec 2024 21:52:07 +0000 (22:52 +0100)
Cargo.lock
backend/scss/modal-dialog.scss [new file with mode: 0644]
backend/scss/style.scss
backend/src/services/ron.rs
backend/templates/base.html
backend/templates/recipe_edit.html
frontend/Cargo.toml
frontend/src/handles.rs
frontend/src/lib.rs
frontend/src/modal_dialog.rs [new file with mode: 0644]
frontend/src/on_click.rs [new file with mode: 0644]

index de94fa4..df74748 100644 (file)
@@ -791,6 +791,7 @@ version = "0.1.0"
 dependencies = [
  "common",
  "console_error_panic_hook",
+ "futures",
  "gloo",
  "ron",
  "serde",
@@ -808,6 +809,7 @@ checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
 dependencies = [
  "futures-channel",
  "futures-core",
+ "futures-executor",
  "futures-io",
  "futures-sink",
  "futures-task",
@@ -2196,9 +2198,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
 
 [[package]]
 name = "serde"
-version = "1.0.216"
+version = "1.0.217"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0b9781016e935a97e8beecf0c933758c97a5520d32930e460142b4cd80c6338e"
+checksum = "02fc4265df13d6fa1d00ecff087228cc0a2b5f3c0e87e258d8b94a156e984c70"
 dependencies = [
  "serde_derive",
 ]
@@ -2216,9 +2218,9 @@ dependencies = [
 
 [[package]]
 name = "serde_derive"
-version = "1.0.216"
+version = "1.0.217"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "46f859dbbf73865c6627ed570e78961cd3ac92407a2d117204c49232485da55e"
+checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -2624,9 +2626,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
 
 [[package]]
 name = "syn"
-version = "2.0.92"
+version = "2.0.93"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "70ae51629bf965c5c098cc9e87908a3df5301051a9e087d6f9bef5c9771ed126"
+checksum = "9c786062daee0d6db1132800e623df74274a0a87322d8e183338e01b3d98d058"
 dependencies = [
  "proc-macro2",
  "quote",
diff --git a/backend/scss/modal-dialog.scss b/backend/scss/modal-dialog.scss
new file mode 100644 (file)
index 0000000..7ea2fb1
--- /dev/null
@@ -0,0 +1,19 @@
+#modal-dialog {
+    // visibility: hidden;
+    color: white;
+    max-width: 300px;
+    margin-left: -125px;
+    background-color: black;
+    text-align: center;
+    border-radius: 2px;
+    padding: 16px; // TODO: 'rem' better?
+    position: fixed;
+    z-index: 1;
+    left: 50%;
+    bottom: 30px;
+    box-shadow: -1px 1px 10px rgba(0, 0, 0, 0.3);
+}
+
+#modal-dialog.show {
+    visibility: visible;
+}
\ No newline at end of file
index f5e0cdf..fa47f09 100644 (file)
@@ -1,4 +1,5 @@
 @use 'toast.scss';
+@use 'modal-dialog.scss';
 
 @font-face {
     font-family: Fira Code;
index 8f8ea96..8be7ab7 100644 (file)
@@ -1,51 +1,3 @@
-// use actix_web::{
-//     http::{header, header::ContentType, StatusCode},
-//     post, put, web, HttpMessage, HttpRequest, HttpResponse, Responder,
-// };
-// use log::{debug, error, info, log_enabled, Level};
-// use ron::de::from_bytes;
-
-// use super::Result;
-// use crate::data::db;
-
-// #[put("/ron-api/recipe/set-title")]
-// pub async fn set_recipe_title(
-//     req: HttpRequest,
-//     body: web::Bytes,
-//     connection: web::Data<db::Connection>,
-// ) -> Result<HttpResponse> {
-//     let ron_req: common::ron_api::SetRecipeTitle = from_bytes(&body)?;
-//     connection
-//         .set_recipe_title_async(ron_req.recipe_id, &ron_req.title)
-//         .await?;
-//     Ok(HttpResponse::Ok().finish())
-// }
-
-// #[put("/ron-api/recipe/set-description")]
-// pub async fn set_recipe_description(
-//     req: HttpRequest,
-//     body: web::Bytes,
-//     connection: web::Data<db::Connection>,
-// ) -> Result<HttpResponse> {
-//     let ron_req: common::ron_api::SetRecipeDescription = from_bytes(&body)?;
-//     connection
-//         .set_recipe_description_async(ron_req.recipe_id, &ron_req.description)
-//         .await?;
-//     Ok(HttpResponse::Ok().finish())
-// }
-
-// #[put("/ron-api/recipe/add-image)]
-// #[put("/ron-api/recipe/rm-photo")]
-// #[put("/ron-api/recipe/add-ingredient")]
-// #[put("/ron-api/recipe/rm-ingredient")]
-// #[put("/ron-api/recipe/set-ingredients-order")]
-// #[put("/ron-api/recipe/add-group")]
-// #[put("/ron-api/recipe/rm-group")]
-// #[put("/ron-api/recipe/set-groups-order")]
-// #[put("/ron-api/recipe/add-step")]
-// #[put("/ron-api/recipe/rm-step")]
-// #[put("/ron-api/recipe/set-steps-order")]
-
 use axum::{
     debug_handler,
     extract::{Extension, Query, State},
index 0f61352..8da3d5d 100644 (file)
 
         <div id="toast"></div>
 
+        <dialog id="modal-dialog">
+            <div class="content"></div>
+            <input type="button" class="ok" value="OK" />
+            <input type="button" class="cancel" value="Cancel" />
+        </dialog>
+
         {% block body_container %}{% endblock %}
 
         <footer class="footer-container">gburri - 2025</footer>
index 5438d3c..7ef799f 100644 (file)
         </div>
 
         <div class="ingredient">
+            <label for="input-ingredient-name">Name</label>
+            <input class="input-ingredient-name" type="text" />
+
             <label for="input-ingredient-quantity">Quantity</label>
             <input class="input-ingredient-quantity" type="number" step="0.1" min="0" max="10000" />
 
             <label for="input-ingredient-unit">Unit</label>
             <input class="input-ingredient-unit" type="text" />
 
-            <label for="input-ingredient-name">Name</label>
-            <input class="input-ingredient-name" type="text" />
-
             <label for="input-ingredient-comment">Comment</label>
             <input class="input-ingredient-comment" type="text" />
 
index 8c388f9..5495232 100644 (file)
@@ -17,6 +17,8 @@ ron = "0.8"
 serde = { version = "1.0", features = ["derive"] }
 thiserror = "2"
 
+futures = "0.3"
+
 wasm-bindgen = "0.2"
 wasm-bindgen-futures = "0.4"
 web-sys = { version = "0.3", features = [
@@ -32,6 +34,7 @@ web-sys = { version = "0.3", features = [
     "HtmlInputElement",
     "HtmlTextAreaElement",
     "HtmlSelectElement",
+    "HtmlDialogElement",
 ] }
 
 gloo = "0.11"
index 20e70ae..1512b0c 100644 (file)
@@ -1,12 +1,14 @@
 use gloo::{console::log, events::EventListener, net::http::Request, utils::document};
 use wasm_bindgen::prelude::*;
 use wasm_bindgen_futures::spawn_local;
-use web_sys::{Element, Event, HtmlInputElement, HtmlSelectElement, HtmlTextAreaElement};
+use web_sys::{
+    Element, Event, HtmlDialogElement, HtmlInputElement, HtmlSelectElement, HtmlTextAreaElement,
+};
 
 use common::ron_api::{self, Ingredient};
 
 use crate::{
-    request,
+    modal_dialog, request,
     toast::{self, Level},
     utils::{by_id, select, select_and_clone, SelectExt},
 };
@@ -169,7 +171,7 @@ pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> {
             .set_attribute("id", &format!("group-{}", group.id))
             .unwrap();
 
-        let groups_container = document().get_element_by_id("groups-container").unwrap();
+        let groups_container: Element = by_id("groups-container");
         groups_container.append_child(&group_element).unwrap();
 
         // Group name.
@@ -209,13 +211,19 @@ pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> {
         .forget();
 
         // Delete button.
-        // TODO: add a user confirmation.
+        let group_element_cloned = group_element.clone();
         let delete_button: HtmlInputElement = group_element.select(".input-group-delete");
         EventListener::new(&delete_button, "click", move |_event| {
+            let name = group_element_cloned
+                .select::<HtmlInputElement>(".input-group-name")
+                .value();
             spawn_local(async move {
-                let body = ron_api::RemoveRecipeGroup { group_id };
-                let _ = request::delete::<(), _>("recipe/remove_group", body).await;
-                by_id::<Element>(&format!("group-{}", group_id)).remove();
+                if modal_dialog::show(&format!("Are you sure to delete the group '{}'", name)).await
+                {
+                    let body = ron_api::RemoveRecipeGroup { group_id };
+                    let _ = request::delete::<(), _>("recipe/remove_group", body).await;
+                    by_id::<Element>(&format!("group-{}", group_id)).remove();
+                }
             });
         })
         .forget();
@@ -228,7 +236,7 @@ pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> {
                 let response: ron_api::AddRecipeStepResult =
                     request::post("recipe/add_step", body).await.unwrap();
                 create_step_element(
-                    &by_id::<Element>(&format!("group-{}", group_id)),
+                    &select::<Element>(&format!("#group-{} .steps", group_id)),
                     &ron_api::Step {
                         id: response.step_id,
                         action: "".to_string(),
@@ -268,6 +276,25 @@ pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> {
         })
         .forget();
 
+        // Delete button.
+        let step_element_cloned = step_element.clone();
+        let delete_button: HtmlInputElement = step_element.select(".input-step-delete");
+        EventListener::new(&delete_button, "click", move |_event| {
+            let action = step_element_cloned
+                .select::<HtmlTextAreaElement>(".text-area-step-action")
+                .value();
+            spawn_local(async move {
+                if modal_dialog::show(&format!("Are you sure to delete the step '{}'", action))
+                    .await
+                {
+                    let body = ron_api::RemoveRecipeStep { step_id };
+                    let _ = request::delete::<(), _>("recipe/remove_step", body).await;
+                    by_id::<Element>(&format!("step-{}", step_id)).remove();
+                }
+            });
+        })
+        .forget();
+
         // Add ingredient button.
         let add_ingredient_button: HtmlInputElement = step_element.select(".input-add-ingredient");
         EventListener::new(&add_ingredient_button, "click", move |_event| {
@@ -276,7 +303,7 @@ pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> {
                 let response: ron_api::AddRecipeIngredientResult =
                     request::post("recipe/add_ingredient", body).await.unwrap();
                 create_ingredient_element(
-                    &by_id::<Element>(&format!("step-{}", step_id)),
+                    &select::<Element>(&format!("#step-{} .ingredients", step_id)),
                     &ron_api::Ingredient {
                         id: response.ingredient_id,
                         name: "".to_string(),
@@ -384,6 +411,25 @@ pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> {
         })
         .forget();
 
+        // Delete button.
+        let ingredient_element_cloned = ingredient_element.clone();
+        let delete_button: HtmlInputElement = ingredient_element.select(".input-ingredient-delete");
+        EventListener::new(&delete_button, "click", move |_event| {
+            let name = ingredient_element_cloned
+                .select::<HtmlInputElement>(".input-ingredient-name")
+                .value();
+            spawn_local(async move {
+                if modal_dialog::show(&format!("Are you sure to delete the ingredient '{}'", name))
+                    .await
+                {
+                    let body = ron_api::RemoveRecipeIngredient { ingredient_id };
+                    let _ = request::delete::<(), _>("recipe/remove_ingredient", body).await;
+                    by_id::<Element>(&format!("ingredient-{}", ingredient_id)).remove();
+                }
+            });
+        })
+        .forget();
+
         ingredient_element
     }
 
@@ -399,10 +445,13 @@ pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> {
                 let group_element = create_group_element(&group);
 
                 for step in group.steps {
-                    let step_element = create_step_element(&group_element, &step);
+                    let step_element = create_step_element(&group_element.select(".steps"), &step);
 
                     for ingredient in step.ingredients {
-                        create_ingredient_element(&step_element, &ingredient);
+                        create_ingredient_element(
+                            &step_element.select(".ingredients"),
+                            &ingredient,
+                        );
                     }
                 }
             }
index 073eaef..1a2bde4 100644 (file)
@@ -1,4 +1,6 @@
 mod handles;
+mod modal_dialog;
+mod on_click;
 mod request;
 mod toast;
 mod utils;
diff --git a/frontend/src/modal_dialog.rs b/frontend/src/modal_dialog.rs
new file mode 100644 (file)
index 0000000..7d3cd02
--- /dev/null
@@ -0,0 +1,30 @@
+use futures::{future::FutureExt, pin_mut, select};
+use web_sys::{Element, HtmlDialogElement};
+
+use crate::utils::{by_id, SelectExt};
+
+use crate::on_click;
+
+pub async fn show(message: &str) -> bool {
+    let dialog: HtmlDialogElement = by_id("modal-dialog");
+    let input_ok: Element = dialog.select(".ok");
+    let input_cancel: Element = dialog.select(".cancel");
+
+    dialog.select::<Element>(".content").set_inner_html(message);
+
+    dialog.show_modal().unwrap();
+
+    let click_ok = on_click::OnClick::new(&input_ok).fuse();
+    let click_cancel = on_click::OnClick::new(&input_cancel).fuse();
+
+    pin_mut!(click_ok, click_cancel);
+
+    let result = select! {
+        () = click_ok => true,
+        () = click_cancel => false,
+    };
+
+    dialog.close();
+
+    result
+}
diff --git a/frontend/src/on_click.rs b/frontend/src/on_click.rs
new file mode 100644 (file)
index 0000000..a634e30
--- /dev/null
@@ -0,0 +1,51 @@
+use futures::channel::mpsc;
+use futures::stream::Stream;
+use gloo::{console::log, events::EventListener, net::http::Request, utils::document};
+use std::{
+    future::Future,
+    pin::Pin,
+    task::{Context, Poll},
+};
+use wasm_bindgen::prelude::*;
+use web_sys::EventTarget;
+
+// From: https://docs.rs/gloo-events/latest/gloo_events/struct.EventListener.html
+
+pub struct OnClick {
+    receiver: mpsc::UnboundedReceiver<()>,
+    // Automatically removed from the DOM on drop.
+    listener: EventListener,
+}
+
+impl OnClick {
+    pub fn new(target: &EventTarget) -> Self {
+        let (sender, receiver) = mpsc::unbounded();
+
+        // Attach an event listener.
+        let listener = EventListener::new(target, "click", move |_event| {
+            sender.unbounded_send(()).unwrap_throw();
+        });
+
+        Self { receiver, listener }
+    }
+}
+
+// Multiple clicks.
+impl Stream for OnClick {
+    type Item = ();
+
+    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context) -> Poll<Option<Self::Item>> {
+        Pin::new(&mut self.receiver).poll_next(cx)
+    }
+}
+
+// Just one click.
+impl Future for OnClick {
+    type Output = ();
+
+    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
+        Pin::new(&mut self.receiver)
+            .poll_next(cx)
+            .map(Option::unwrap)
+    }
+}