Recipe edit (WIP): all form fields are now saved
authorGreg Burri <greg.burri@gmail.com>
Thu, 26 Dec 2024 23:39:23 +0000 (00:39 +0100)
committerGreg Burri <greg.burri@gmail.com>
Thu, 26 Dec 2024 23:39:23 +0000 (00:39 +0100)
12 files changed:
Cargo.lock
backend/scss/style.scss
backend/src/data/db/recipe.rs
backend/src/main.rs
backend/src/services/ron.rs
backend/templates/base.html
backend/templates/recipe_edit.html
common/Cargo.toml
common/src/ron_api.rs
common/src/utils.rs
frontend/src/handles.rs
frontend/src/utils.rs

index 2ae3f71..ca1f318 100644 (file)
@@ -478,7 +478,6 @@ checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990"
 name = "common"
 version = "0.1.0"
 dependencies = [
- "lazy_static",
  "regex",
  "ron",
  "serde",
@@ -1946,9 +1945,9 @@ dependencies = [
 
 [[package]]
 name = "quote"
-version = "1.0.37"
+version = "1.0.38"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af"
+checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc"
 dependencies = [
  "proc-macro2",
 ]
@@ -2179,9 +2178,9 @@ dependencies = [
 
 [[package]]
 name = "rustversion"
-version = "1.0.18"
+version = "1.0.19"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0e819f2bc632f285be6d7cd36e25940d45b2391dd6d9b939e79de557f7014248"
+checksum = "f7c45b9784283f1b2e7fb61b42047c2fd678ef0960d4f6f1eba131594cc369d4"
 
 [[package]]
 name = "ryu"
@@ -2625,9 +2624,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
 
 [[package]]
 name = "syn"
-version = "2.0.91"
+version = "2.0.92"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d53cbcb5a243bd33b7858b1d7f4aca2153490815872d86d955d6ea29f743c035"
+checksum = "70ae51629bf965c5c098cc9e87908a3df5301051a9e087d6f9bef5c9771ed126"
 dependencies = [
  "proc-macro2",
  "quote",
index 761ae70..f5e0cdf 100644 (file)
@@ -53,7 +53,7 @@ body {
     font-family: Fira Code, Helvetica Neue, Helvetica, Arial, sans-serif;
     text-shadow: 2px 2px 2px rgb(0, 0, 0);
     // line-height: 18px;
-    color: rgb(255, 255, 255);
+    color: lighten($primary, 60%);
     background-color: $background;
     margin: 0px;
 
@@ -63,7 +63,7 @@ body {
 
     .recipe-item-current {
         padding: 3px;
-        border: 1px solid white;
+        border: 1px solid lighten($primary, 30%);
     }
 
     .header-container {
@@ -87,7 +87,7 @@ body {
             flex-grow: 1;
 
             background-color: $background-container;
-            border: 0.1em solid white;
+            border: 0.1em solid lighten($primary, 50%);
             padding: 0.5em;
 
             h1 {
@@ -95,15 +95,15 @@ body {
             }
 
             .group {
-                border: 0.1em solid white;
+                border: 0.1em solid lighten($primary, 30%);
             }
 
             .step {
-                border: 0.1em solid white;
+                border: 0.1em solid lighten($primary, 30%);
             }
 
             .ingredient {
-                border: 0.1em solid white;
+                border: 0.1em solid lighten($primary, 30%);
             }
 
             #hidden-templates {
index ba8f677..d112b12 100644 (file)
@@ -49,7 +49,8 @@ ORDER BY [title]
         sqlx::query_scalar(
             r#"
 SELECT COUNT(*)
-FROM [Recipe] INNER JOIN [Group] ON [Group].[recipe_id] = [Recipe].[id]
+FROM [Recipe]
+INNER JOIN [Group] ON [Group].[recipe_id] = [Recipe].[id]
 WHERE [Group].[id] = $1 AND [user_id] = $2
             "#,
         )
@@ -60,6 +61,45 @@ WHERE [Group].[id] = $1 AND [user_id] = $2
         .map_err(DBError::from)
     }
 
+    pub async fn can_edit_recipe_step(&self, user_id: i64, step_id: i64) -> Result<bool> {
+        sqlx::query_scalar(
+            r#"
+SELECT COUNT(*)
+FROM [Recipe]
+INNER JOIN [Group] ON [Group].[recipe_id] = [Recipe].[id]
+INNER JOIN [Step] ON [Step].[group_id] = [Group].[id]
+WHERE [Step].[id] = $1 AND [user_id] = $2
+            "#,
+        )
+        .bind(step_id)
+        .bind(user_id)
+        .fetch_one(&self.pool)
+        .await
+        .map_err(DBError::from)
+    }
+
+    pub async fn can_edit_recipe_ingredient(
+        &self,
+        user_id: i64,
+        ingredient_id: i64,
+    ) -> Result<bool> {
+        sqlx::query_scalar(
+            r#"
+SELECT COUNT(*)
+FROM [Recipe]
+INNER JOIN [Group] ON [Group].[recipe_id] = [Recipe].[id]
+INNER JOIN [Step] ON [Step].[group_id] = [Group].[id]
+INNER JOIN [Ingredient] ON [Ingredient].[step_id] = [Step].[id]
+WHERE [Ingredient].[id] = $1 AND [user_id] = $2
+            "#,
+        )
+        .bind(ingredient_id)
+        .bind(user_id)
+        .fetch_one(&self.pool)
+        .await
+        .map_err(DBError::from)
+    }
+
     pub async fn get_recipe(&self, id: i64) -> Result<Option<model::Recipe>> {
         sqlx::query_as(
             r#"
@@ -263,6 +303,60 @@ ORDER BY [name]
             .map(|_| ())
             .map_err(DBError::from)
     }
+
+    pub async fn set_step_action(&self, step_id: i64, action: &str) -> Result<()> {
+        sqlx::query("UPDATE [Step] SET [action] = $2 WHERE [id] = $1")
+            .bind(step_id)
+            .bind(action)
+            .execute(&self.pool)
+            .await
+            .map(|_| ())
+            .map_err(DBError::from)
+    }
+
+    pub async fn set_ingredient_name(&self, ingredient_id: i64, name: &str) -> Result<()> {
+        sqlx::query("UPDATE [Ingredient] SET [name] = $2 WHERE [id] = $1")
+            .bind(ingredient_id)
+            .bind(name)
+            .execute(&self.pool)
+            .await
+            .map(|_| ())
+            .map_err(DBError::from)
+    }
+
+    pub async fn set_ingredient_comment(&self, ingredient_id: i64, comment: &str) -> Result<()> {
+        sqlx::query("UPDATE [Ingredient] SET [comment] = $2 WHERE [id] = $1")
+            .bind(ingredient_id)
+            .bind(comment)
+            .execute(&self.pool)
+            .await
+            .map(|_| ())
+            .map_err(DBError::from)
+    }
+
+    pub async fn set_ingredient_quantity(
+        &self,
+        ingredient_id: i64,
+        quantity: Option<f64>,
+    ) -> Result<()> {
+        sqlx::query("UPDATE [Ingredient] SET [quantity_value] = $2 WHERE [id] = $1")
+            .bind(ingredient_id)
+            .bind(quantity)
+            .execute(&self.pool)
+            .await
+            .map(|_| ())
+            .map_err(DBError::from)
+    }
+
+    pub async fn set_ingredient_unit(&self, ingredient_id: i64, unit: &str) -> Result<()> {
+        sqlx::query("UPDATE [Ingredient] SET [quantity_unit] = $2 WHERE [id] = $1")
+            .bind(ingredient_id)
+            .bind(unit)
+            .execute(&self.pool)
+            .await
+            .map(|_| ())
+            .map_err(DBError::from)
+    }
 }
 
 #[cfg(test)]
index dcb3d05..46ebe06 100644 (file)
@@ -61,14 +61,14 @@ const TRACING_LEVEL: tracing::Level = tracing::Level::INFO;
 // TODO: Should main returns 'Result'?
 #[tokio::main]
 async fn main() {
-    if process_args().await {
-        return;
-    }
-
     tracing_subscriber::fmt()
         .with_max_level(TRACING_LEVEL)
         .init();
 
+    if process_args().await {
+        return;
+    }
+
     event!(Level::INFO, "Starting Recipes as web server...");
 
     let config = config::load();
@@ -109,6 +109,26 @@ async fn main() {
             "/recipe/set_group_comment",
             put(services::ron::set_group_comment),
         )
+        .route(
+            "/recipe/set_step_action",
+            put(services::ron::set_step_action),
+        )
+        .route(
+            "/recipe/set_ingredient_name",
+            put(services::ron::set_ingredient_name),
+        )
+        .route(
+            "/recipe/set_ingredient_comment",
+            put(services::ron::set_ingredient_comment),
+        )
+        .route(
+            "/recipe/set_ingredient_quantity",
+            put(services::ron::set_ingredient_quantity),
+        )
+        .route(
+            "/recipe/set_ingredient_unit",
+            put(services::ron::set_ingredient_unit),
+        )
         .fallback(services::ron::not_found);
 
     let fragments_routes = Router::new().route(
index d6dbe7a..12c0f76 100644 (file)
@@ -125,6 +125,44 @@ async fn check_user_rights_recipe_group(
     }
 }
 
+async fn check_user_rights_recipe_step(
+    connection: &db::Connection,
+    user: &Option<model::User>,
+    step_id: i64,
+) -> Result<()> {
+    if user.is_none()
+        || !connection
+            .can_edit_recipe_step(user.as_ref().unwrap().id, step_id)
+            .await?
+    {
+        Err(ErrorResponse::from(ron_error(
+            StatusCode::UNAUTHORIZED,
+            "Action not authorized",
+        )))
+    } else {
+        Ok(())
+    }
+}
+
+async fn check_user_rights_recipe_ingredient(
+    connection: &db::Connection,
+    user: &Option<model::User>,
+    ingredient_id: i64,
+) -> Result<()> {
+    if user.is_none()
+        || !connection
+            .can_edit_recipe_ingredient(user.as_ref().unwrap().id, ingredient_id)
+            .await?
+    {
+        Err(ErrorResponse::from(ron_error(
+            StatusCode::UNAUTHORIZED,
+            "Action not authorized",
+        )))
+    } else {
+        Ok(())
+    }
+}
+
 #[debug_handler]
 pub async fn set_recipe_title(
     State(connection): State<db::Connection>,
@@ -255,7 +293,6 @@ pub async fn get_groups(
     State(connection): State<db::Connection>,
     recipe_id: Query<RecipeId>,
 ) -> Result<impl IntoResponse> {
-    println!("PROUT");
     // Here we don't check user rights on purpose.
     Ok(ron_response(
         StatusCode::OK,
@@ -318,6 +355,69 @@ pub async fn set_group_comment(
     Ok(StatusCode::OK)
 }
 
+#[debug_handler]
+pub async fn set_step_action(
+    State(connection): State<db::Connection>,
+    Extension(user): Extension<Option<model::User>>,
+    ExtractRon(ron): ExtractRon<common::ron_api::SetStepAction>,
+) -> Result<impl IntoResponse> {
+    check_user_rights_recipe_step(&connection, &user, ron.step_id).await?;
+    connection.set_step_action(ron.step_id, &ron.action).await?;
+    Ok(StatusCode::OK)
+}
+
+#[debug_handler]
+pub async fn set_ingredient_name(
+    State(connection): State<db::Connection>,
+    Extension(user): Extension<Option<model::User>>,
+    ExtractRon(ron): ExtractRon<common::ron_api::SetIngredientName>,
+) -> Result<impl IntoResponse> {
+    check_user_rights_recipe_ingredient(&connection, &user, ron.ingredient_id).await?;
+    connection
+        .set_ingredient_name(ron.ingredient_id, &ron.name)
+        .await?;
+    Ok(StatusCode::OK)
+}
+
+#[debug_handler]
+pub async fn set_ingredient_comment(
+    State(connection): State<db::Connection>,
+    Extension(user): Extension<Option<model::User>>,
+    ExtractRon(ron): ExtractRon<common::ron_api::SetIngredientComment>,
+) -> Result<impl IntoResponse> {
+    check_user_rights_recipe_ingredient(&connection, &user, ron.ingredient_id).await?;
+    connection
+        .set_ingredient_comment(ron.ingredient_id, &ron.comment)
+        .await?;
+    Ok(StatusCode::OK)
+}
+
+#[debug_handler]
+pub async fn set_ingredient_quantity(
+    State(connection): State<db::Connection>,
+    Extension(user): Extension<Option<model::User>>,
+    ExtractRon(ron): ExtractRon<common::ron_api::SetIngredientQuantity>,
+) -> Result<impl IntoResponse> {
+    check_user_rights_recipe_ingredient(&connection, &user, ron.ingredient_id).await?;
+    connection
+        .set_ingredient_quantity(ron.ingredient_id, ron.quantity)
+        .await?;
+    Ok(StatusCode::OK)
+}
+
+#[debug_handler]
+pub async fn set_ingredient_unit(
+    State(connection): State<db::Connection>,
+    Extension(user): Extension<Option<model::User>>,
+    ExtractRon(ron): ExtractRon<common::ron_api::SetIngredientUnit>,
+) -> Result<impl IntoResponse> {
+    check_user_rights_recipe_ingredient(&connection, &user, ron.ingredient_id).await?;
+    connection
+        .set_ingredient_unit(ron.ingredient_id, &ron.unit)
+        .await?;
+    Ok(StatusCode::OK)
+}
+
 ///// 404 /////
 #[debug_handler]
 pub async fn not_found(Extension(_user): Extension<Option<model::User>>) -> impl IntoResponse {
index 147ed3d..0f61352 100644 (file)
@@ -20,6 +20,6 @@
 
         {% block body_container %}{% endblock %}
 
-        <footer class="footer-container">gburri - 2022</footer>
+        <footer class="footer-container">gburri - 2025</footer>
     </body>
 </html>
\ No newline at end of file
index f6134f6..8b3c888 100644 (file)
     <textarea
         id="text-area-description">{{ recipe.description }}</textarea>
 
-    <label for="input-estimated-time">Estimated time</label>
+    <label for="input-estimated-time">Estimated time [min]</label>
     <input
         id="input-estimated-time"
         type="number"
+        step="1" min="0" max="1000"
         value="
             {% match recipe.estimated_time %}
             {% when Some with (t) %}
@@ -63,7 +64,7 @@
     <div id="groups-container">
 
     </div>
-    <input id="button-add-group" type="button" value="Add a group"/>
+    <input id="button-add-group" type="button" value="Add a group" />
 
     <div id="hidden-templates">
         <div class="group">
             <label for="input-group-comment">Comment</label>
             <input class="input-group-comment" type="text" />
 
+            <input class="input-group-delete" type="button" value="Remove group" />
+
             <div class="steps"></div>
 
-            <input class="button-add-step" type="button" value="Add a step"/>
+            <input class="button-add-step" type="button" value="Add a step" />
         </div>
 
         <div class="step">
             <label for="text-area-step-action">Action</label>
             <textarea class="text-area-step-action"></textarea>
 
+            <input class="input-step-delete" type="button" value="Remove step" />
+
             <div class="ingredients"></div>
 
             <input class="button-add-ingedient" type="button" value="Add an ingredient"/>
 
         <div class="ingredient">
             <label for="input-ingredient-quantity">Quantity</label>
-            <input class="input-ingredient-quantity" type="number" />
+            <input class="input-ingredient-quantity" type="number" step="0.1" min="0" max="10000" />
 
-            <label for="input-ingredient-unit">Unity</label>
+            <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" />
+
+            <input class="input-ingredient-delete" type="button" value="Remove ingredient" />
         </div>
     </div>
 </div>
index b15784d..871cd85 100644 (file)
@@ -6,7 +6,6 @@ edition = "2021"
 
 [dependencies]
 regex = "1"
-lazy_static = "1"
 
 ron = "0.8"
 serde = { version = "1.0", features = ["derive"] }
index 639b0c6..2b8b9cb 100644 (file)
@@ -92,6 +92,36 @@ pub struct SetGroupComment {
     pub comment: String,
 }
 
+#[derive(Serialize, Deserialize, Clone)]
+pub struct SetStepAction {
+    pub step_id: i64,
+    pub action: String,
+}
+
+#[derive(Serialize, Deserialize, Clone)]
+pub struct SetIngredientName {
+    pub ingredient_id: i64,
+    pub name: String,
+}
+
+#[derive(Serialize, Deserialize, Clone)]
+pub struct SetIngredientComment {
+    pub ingredient_id: i64,
+    pub comment: String,
+}
+
+#[derive(Serialize, Deserialize, Clone)]
+pub struct SetIngredientQuantity {
+    pub ingredient_id: i64,
+    pub quantity: Option<f64>,
+}
+
+#[derive(Serialize, Deserialize, Clone)]
+pub struct SetIngredientUnit {
+    pub ingredient_id: i64,
+    pub unit: String,
+}
+
 #[derive(Serialize, Deserialize, Clone, Debug)]
 pub struct Group {
     pub id: i64,
index 9322ed6..7bf0f40 100644 (file)
@@ -1,4 +1,5 @@
-use lazy_static::lazy_static;
+use std::sync::LazyLock;
+
 use regex::Regex;
 
 pub enum EmailValidation {
@@ -6,12 +7,12 @@ pub enum EmailValidation {
     NotValid,
 }
 
-lazy_static! {
-    static ref EMAIL_REGEX: Regex = Regex::new(
-        r"^([a-z0-9_+]([a-z0-9_+.]*[a-z0-9_+])?)@([a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,6})"
+static EMAIL_REGEX: LazyLock<Regex> = LazyLock::new(|| {
+    Regex::new(
+        r"^([a-z0-9_+]([a-z0-9_+.]*[a-z0-9_+])?)@([a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,6})",
     )
-    .expect("Error parsing email regex");
-}
+    .expect("Error parsing email regex")
+});
 
 pub fn validate_email(email: &str) -> EmailValidation {
     if EMAIL_REGEX.is_match(email) {
index d5aa8ff..36e0de1 100644 (file)
@@ -1,13 +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, HtmlInputElement, HtmlSelectElement, HtmlTextAreaElement};
+use web_sys::{Element, Event, HtmlInputElement, HtmlSelectElement, HtmlTextAreaElement};
 
-use common::ron_api;
+use common::ron_api::{self, Ingredient};
 
 use crate::{
     request,
     toast::{self, Level},
+    utils::{by_id, select, select_and_clone, SelectExt},
 };
 
 async fn reload_recipes_list(current_recipe_id: i64) {
@@ -29,14 +30,9 @@ async fn reload_recipes_list(current_recipe_id: i64) {
 pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> {
     // Title.
     {
-        let input_title = document().get_element_by_id("input-title").unwrap();
-        let mut current_title = input_title.dyn_ref::<HtmlInputElement>().unwrap().value();
-        let on_input_title_blur = EventListener::new(&input_title, "blur", move |_event| {
-            let title = document()
-                .get_element_by_id("input-title")
-                .unwrap()
-                .dyn_into::<HtmlInputElement>()
-                .unwrap();
+        let title: HtmlInputElement = by_id("input-title");
+        let mut current_title = title.value();
+        EventListener::new(&title.clone(), "blur", move |_event| {
             if title.value() != current_title {
                 current_title = title.value();
                 let body = ron_api::SetRecipeTitle {
@@ -48,26 +44,16 @@ pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> {
                     reload_recipes_list(recipe_id).await;
                 });
             }
-        });
-        on_input_title_blur.forget();
+        })
+        .forget();
     }
 
     // Description.
     {
-        let text_area_description = document()
-            .get_element_by_id("text-area-description")
-            .unwrap();
-        let mut current_description = text_area_description
-            .dyn_ref::<HtmlTextAreaElement>()
-            .unwrap()
-            .value();
+        let description: HtmlTextAreaElement = by_id("text-area-description");
+        let mut current_description = description.value();
         let on_input_description_blur =
-            EventListener::new(&text_area_description, "blur", move |_event| {
-                let description = document()
-                    .get_element_by_id("text-area-description")
-                    .unwrap()
-                    .dyn_into::<HtmlTextAreaElement>()
-                    .unwrap();
+            EventListener::new(&description.clone(), "blur", move |_event| {
                 if description.value() != current_description {
                     current_description = description.value();
                     let body = ron_api::SetRecipeDescription {
@@ -84,31 +70,24 @@ pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> {
 
     // Estimated time.
     {
-        let input_estimated_time = document()
-            .get_element_by_id("input-estimated-time")
-            .unwrap();
-        let mut current_time = input_estimated_time
-            .dyn_ref::<HtmlInputElement>()
-            .unwrap()
-            .value();
+        let estimated_time: HtmlInputElement = by_id("input-estimated-time");
+        let mut current_time = estimated_time.value_as_number();
         let on_input_estimated_time_blur =
-            EventListener::new(&input_estimated_time, "blur", move |_event| {
-                let estimated_time = document()
-                    .get_element_by_id("input-estimated-time")
-                    .unwrap()
-                    .dyn_into::<HtmlInputElement>()
-                    .unwrap();
-                if estimated_time.value() != current_time {
-                    let time = if estimated_time.value().is_empty() {
+            EventListener::new(&estimated_time.clone(), "blur", move |_event| {
+                let n = estimated_time.value_as_number();
+                if n.is_nan() {
+                    estimated_time.set_value("");
+                }
+                if n != current_time {
+                    let time = if n.is_nan() {
                         None
-                    } else if let Ok(t) = estimated_time.value().parse::<u32>() {
-                        Some(t)
                     } else {
-                        estimated_time.set_value(&current_time);
-                        return;
+                        // TODO: Find a better way to validate integer numbers.
+                        let n = n as u32;
+                        estimated_time.set_value_as_number(n as f64);
+                        Some(n)
                     };
-
-                    current_time = estimated_time.value();
+                    current_time = n;
                     let body = ron_api::SetRecipeEstimatedTime {
                         recipe_id,
                         estimated_time: time,
@@ -123,18 +102,10 @@ pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> {
 
     // Difficulty.
     {
-        let select_difficulty = document().get_element_by_id("select-difficulty").unwrap();
-        let mut current_difficulty = select_difficulty
-            .dyn_ref::<HtmlSelectElement>()
-            .unwrap()
-            .value();
+        let difficulty: HtmlSelectElement = by_id("select-difficulty");
+        let mut current_difficulty = difficulty.value();
         let on_select_difficulty_blur =
-            EventListener::new(&select_difficulty, "blur", move |_event| {
-                let difficulty = document()
-                    .get_element_by_id("select-difficulty")
-                    .unwrap()
-                    .dyn_into::<HtmlSelectElement>()
-                    .unwrap();
+            EventListener::new(&difficulty.clone(), "blur", move |_event| {
                 if difficulty.value() != current_difficulty {
                     current_difficulty = difficulty.value();
 
@@ -155,43 +126,30 @@ pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> {
 
     // Language.
     {
-        let select_language = document().get_element_by_id("select-language").unwrap();
-        let mut current_language = select_language
-            .dyn_ref::<HtmlSelectElement>()
-            .unwrap()
-            .value();
-        let on_select_language_blur = EventListener::new(&select_language, "blur", move |_event| {
-            let language = document()
-                .get_element_by_id("select-language")
-                .unwrap()
-                .dyn_into::<HtmlSelectElement>()
-                .unwrap();
-            if language.value() != current_language {
-                current_language = language.value();
-
-                let body = ron_api::SetRecipeLanguage {
-                    recipe_id,
-                    lang: language.value(),
-                };
-                spawn_local(async move {
-                    let _ = request::put::<(), _>("recipe/set_language", body).await;
-                });
-            }
-        });
+        let language: HtmlSelectElement = by_id("select-language");
+        let mut current_language = language.value();
+        let on_select_language_blur =
+            EventListener::new(&language.clone(), "blur", move |_event| {
+                if language.value() != current_language {
+                    current_language = language.value();
+
+                    let body = ron_api::SetRecipeLanguage {
+                        recipe_id,
+                        lang: language.value(),
+                    };
+                    spawn_local(async move {
+                        let _ = request::put::<(), _>("recipe/set_language", body).await;
+                    });
+                }
+            });
         on_select_language_blur.forget();
     }
 
     // Is published.
     {
-        let input_is_published = document().get_element_by_id("input-is-published").unwrap();
+        let is_published: HtmlInputElement = by_id("input-is-published");
         let on_input_is_published_blur =
-            EventListener::new(&input_is_published, "input", move |_event| {
-                let is_published = document()
-                    .get_element_by_id("input-is-published")
-                    .unwrap()
-                    .dyn_into::<HtmlInputElement>()
-                    .unwrap();
-
+            EventListener::new(&is_published.clone(), "input", move |_event| {
                 let body = ron_api::SetIsPublished {
                     recipe_id,
                     is_published: is_published.checked(),
@@ -204,62 +162,185 @@ pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> {
         on_input_is_published_blur.forget();
     }
 
-    // let groups_container = document().get_element_by_id("groups-container").unwrap();
-    // if !groups_container.has_child_nodes() {
-
-    // }
-
-    fn create_group_element(group_id: i64) -> Element {
-        let group_html = document()
-            .query_selector("#hidden-templates .group")
-            .unwrap()
-            .unwrap()
-            .clone_node_with_deep(true)
-            .unwrap()
-            .dyn_into::<Element>()
-            .unwrap();
-
-        group_html
-            .set_attribute("id", &format!("group-{}", group_id))
+    fn create_group_element(group: &ron_api::Group) -> Element {
+        let group_id = group.id;
+        let group_element: Element = select_and_clone("#hidden-templates .group");
+        group_element
+            .set_attribute("id", &format!("group-{}", group.id))
             .unwrap();
 
         let groups_container = document().get_element_by_id("groups-container").unwrap();
-        groups_container.append_child(&group_html).unwrap();
-        group_html
+        groups_container.append_child(&group_element).unwrap();
+
+        // Group name.
+        let name = group_element.select::<HtmlInputElement>(".input-group-name");
+        name.set_value(&group.name);
+        let mut current_name = group.name.clone();
+        EventListener::new(&name.clone(), "blur", move |_event| {
+            if name.value() != current_name {
+                current_name = name.value();
+                let body = ron_api::SetGroupName {
+                    group_id,
+                    name: name.value(),
+                };
+                spawn_local(async move {
+                    let _ = request::put::<(), _>("recipe/set_group_name", body).await;
+                })
+            }
+        })
+        .forget();
+
+        // Group comment.
+        let comment: HtmlInputElement = group_element.select(".input-group-comment");
+        comment.set_value(&group.comment);
+        let mut current_comment = group.comment.clone();
+        EventListener::new(&comment.clone(), "blur", move |_event| {
+            if comment.value() != current_comment {
+                current_comment = comment.value();
+                let body = ron_api::SetGroupComment {
+                    group_id,
+                    comment: comment.value(),
+                };
+                spawn_local(async move {
+                    let _ = request::put::<(), _>("recipe/set_group_comment", body).await;
+                });
+            }
+        })
+        .forget();
+
+        // Delete button.
+        // TODO: add a user confirmation.
+        let delete_button: HtmlInputElement = group_element.select(".input-group-delete");
+        EventListener::new(&delete_button, "click", move |_event| {
+            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();
+            });
+        })
+        .forget();
+
+        group_element
     }
 
-    fn create_step_element(group_element: &Element, step_id: i64) -> Element {
-        let step_html = document()
-            .query_selector("#hidden-templates .step")
-            .unwrap()
-            .unwrap()
-            .clone_node_with_deep(true)
-            .unwrap()
-            .dyn_into::<Element>()
-            .unwrap();
-        step_html
-            .set_attribute("id", &format!("step-{}", step_id))
+    fn create_step_element(group_element: &Element, step: &ron_api::Step) -> Element {
+        let step_id = step.id;
+        let step_element: Element = select_and_clone("#hidden-templates .step");
+        step_element
+            .set_attribute("id", &format!("step-{}", step.id))
             .unwrap();
+        group_element.append_child(&step_element).unwrap();
+
+        // Step action.
+        let action: HtmlTextAreaElement = step_element.select(".text-area-step-action");
+        action.set_value(&step.action);
+        let mut current_action = step.action.clone();
+        EventListener::new(&action.clone(), "blur", move |_event| {
+            if action.value() != current_action {
+                current_action = action.value();
+                let body = ron_api::SetStepAction {
+                    step_id,
+                    action: action.value(),
+                };
+                spawn_local(async move {
+                    let _ = request::put::<(), _>("recipe/set_step_action", body).await;
+                });
+            }
+        })
+        .forget();
 
-        group_element.append_child(&step_html).unwrap();
-        step_html
+        step_element
     }
 
-    fn create_ingredient_element(step_element: &Element, ingredient_id: i64) -> Element {
-        let ingredient_html = document()
-            .query_selector("#hidden-templates .ingredient")
-            .unwrap()
-            .unwrap()
-            .clone_node_with_deep(true)
-            .unwrap()
-            .dyn_into::<Element>()
-            .unwrap();
-        ingredient_html
-            .set_attribute("id", &format!("step-{}", ingredient_id))
+    fn create_ingredient_element(
+        step_element: &Element,
+        ingredient: &ron_api::Ingredient,
+    ) -> Element {
+        let ingredient_id = ingredient.id;
+        let ingredient_element: Element = select_and_clone("#hidden-templates .ingredient");
+        ingredient_element
+            .set_attribute("id", &format!("step-{}", ingredient.id))
             .unwrap();
+        step_element.append_child(&ingredient_element).unwrap();
+
+        // Ingredient name.
+        let name: HtmlInputElement = ingredient_element.select(".input-ingredient-name");
+        name.set_value(&ingredient.name);
+        let mut current_name = ingredient.name.clone();
+        EventListener::new(&name.clone(), "blur", move |_event| {
+            if name.value() != current_name {
+                current_name = name.value();
+                let body = ron_api::SetIngredientName {
+                    ingredient_id,
+                    name: name.value(),
+                };
+                spawn_local(async move {
+                    let _ = request::put::<(), _>("recipe/set_ingredient_name", body).await;
+                });
+            }
+        })
+        .forget();
+
+        // Ingredient comment.
+        let comment: HtmlInputElement = ingredient_element.select(".input-ingredient-comment");
+        comment.set_value(&ingredient.comment);
+        let mut current_comment = ingredient.comment.clone();
+        EventListener::new(&comment.clone(), "blur", move |_event| {
+            if comment.value() != current_comment {
+                current_comment = comment.value();
+                let body = ron_api::SetIngredientComment {
+                    ingredient_id,
+                    comment: comment.value(),
+                };
+                spawn_local(async move {
+                    let _ = request::put::<(), _>("recipe/set_ingredient_comment", body).await;
+                });
+            }
+        })
+        .forget();
+
+        // Ingredient quantity.
+        let quantity: HtmlInputElement = ingredient_element.select(".input-ingredient-quantity");
+        quantity.set_value(&ingredient.quantity_value.to_string());
+        let mut current_quantity = ingredient.quantity_value;
+        EventListener::new(&quantity.clone(), "blur", move |_event| {
+            let n = quantity.value_as_number();
+            if n.is_nan() {
+                quantity.set_value("");
+            }
+            if n != current_quantity {
+                let q = if n.is_nan() { None } else { Some(n) };
+                current_quantity = n;
+                let body = ron_api::SetIngredientQuantity {
+                    ingredient_id,
+                    quantity: q,
+                };
+                spawn_local(async move {
+                    let _ = request::put::<(), _>("recipe/set_ingredient_quantity", body).await;
+                });
+            }
+        })
+        .forget();
+
+        // Ingredient unit.
+        let unit: HtmlInputElement = ingredient_element.select(".input-ingredient-unit");
+        unit.set_value(&ingredient.quantity_unit);
+        let mut current_unit = ingredient.quantity_unit.clone();
+        EventListener::new(&unit.clone(), "blur", move |_event| {
+            if unit.value() != current_unit {
+                current_unit = unit.value();
+                let body = ron_api::SetIngredientUnit {
+                    ingredient_id,
+                    unit: unit.value(),
+                };
+                spawn_local(async move {
+                    let _ = request::put::<(), _>("recipe/set_ingredient_unit", body).await;
+                });
+            }
+        })
+        .forget();
 
-        step_element.append_child(&ingredient_html).unwrap();
-        ingredient_html
+        ingredient_element
     }
 
     // Load initial groups, steps and ingredients.
@@ -271,42 +352,16 @@ pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> {
                     .unwrap();
 
             for group in groups {
-                let group_element = create_group_element(group.id);
-                let input_name = group_element
-                    .query_selector(".input-group-name")
-                    .unwrap()
-                    .unwrap()
-                    .dyn_into::<HtmlInputElement>()
-                    .unwrap();
-                input_name.set_value(&group.name);
-
-                // document().get_element_by_id(&format!("group-{}", group_id))
+                let group_element = create_group_element(&group);
 
                 for step in group.steps {
-                    let step_element = create_step_element(&group_element, step.id);
-                    let text_area_action = step_element
-                        .query_selector(".text-area-step-action")
-                        .unwrap()
-                        .unwrap()
-                        .dyn_into::<HtmlTextAreaElement>()
-                        .unwrap();
-                    text_area_action.set_value(&step.action);
+                    let step_element = create_step_element(&group_element, &step);
 
                     for ingredient in step.ingredients {
-                        let ingredient_element =
-                            create_ingredient_element(&step_element, ingredient.id);
-                        let input_name = ingredient_element
-                            .query_selector(".input-ingredient-name")
-                            .unwrap()
-                            .unwrap()
-                            .dyn_into::<HtmlInputElement>()
-                            .unwrap();
-                        input_name.set_value(&ingredient.name);
+                        create_ingredient_element(&step_element, &ingredient);
                     }
                 }
             }
-
-            // log!(format!("{:?}", groups));
         });
     }
 
@@ -320,7 +375,12 @@ pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> {
             spawn_local(async move {
                 let response: ron_api::AddRecipeGroupResult =
                     request::post("recipe/add_group", body).await.unwrap();
-                create_group_element(response.group_id);
+                create_group_element(&ron_api::Group {
+                    id: response.group_id,
+                    name: "".to_string(),
+                    comment: "".to_string(),
+                    steps: vec![],
+                });
                 // group_html.set_attribute("id", "test").unwrap();
             });
         });
index 4ca2f86..d49fff4 100644 (file)
@@ -1,19 +1,59 @@
-// use web_sys::console;
+use gloo::utils::document;
+use wasm_bindgen::prelude::*;
+use web_sys::Element;
 
-pub fn set_panic_hook() {
-    // When the `console_error_panic_hook` feature is enabled, we can call the
-    // `set_panic_hook` function at least once during initialization, and then
-    // we will get better error messages if our code ever panics.
-    //
-    // For more details see
-    // https://github.com/rustwasm/console_error_panic_hook#readme
-    #[cfg(feature = "console_error_panic_hook")]
-    console_error_panic_hook::set_once();
+pub trait SelectExt {
+    fn select<T>(&self, selectors: &str) -> T
+    where
+        T: JsCast;
 }
 
-// #[macro_export]
-// macro_rules! console_log {
-//     // Note that this is using the `log` function imported above during
-//     // `bare_bones`
-//     ($($t:tt)*) => (console::log_1(&format_args!($($t)*).to_string().into()))
-// }
+impl SelectExt for Element {
+    fn select<T>(&self, selectors: &str) -> T
+    where
+        T: JsCast,
+    {
+        self.query_selector(selectors)
+            .unwrap()
+            .unwrap()
+            .dyn_into::<T>()
+            .unwrap()
+    }
+}
+
+pub fn select<T>(selectors: &str) -> T
+where
+    T: JsCast,
+{
+    document()
+        .query_selector(selectors)
+        .unwrap()
+        .unwrap()
+        .dyn_into::<T>()
+        .unwrap()
+}
+
+pub fn select_and_clone<T>(selectors: &str) -> T
+where
+    T: JsCast,
+{
+    document()
+        .query_selector(selectors)
+        .unwrap()
+        .unwrap()
+        .clone_node_with_deep(true)
+        .unwrap()
+        .dyn_into::<T>()
+        .unwrap()
+}
+
+pub fn by_id<T>(element_id: &str) -> T
+where
+    T: JsCast,
+{
+    document()
+        .get_element_by_id(element_id)
+        .unwrap()
+        .dyn_into::<T>()
+        .unwrap()
+}