From 6876a254e19a59b8eb85457e1c0b8da13be3021c Mon Sep 17 00:00:00 2001 From: Greg Burri Date: Fri, 27 Dec 2024 00:39:23 +0100 Subject: [PATCH] Recipe edit (WIP): all form fields are now saved --- Cargo.lock | 13 +- backend/scss/style.scss | 12 +- backend/src/data/db/recipe.rs | 96 ++++++- backend/src/main.rs | 28 ++- backend/src/services/ron.rs | 102 +++++++- backend/templates/base.html | 2 +- backend/templates/recipe_edit.html | 20 +- common/Cargo.toml | 1 - common/src/ron_api.rs | 30 +++ common/src/utils.rs | 13 +- frontend/src/handles.rs | 386 +++++++++++++++++------------ frontend/src/utils.rs | 72 ++++-- 12 files changed, 564 insertions(+), 211 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2ae3f71..ca1f318 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/backend/scss/style.scss b/backend/scss/style.scss index 761ae70..f5e0cdf 100644 --- a/backend/scss/style.scss +++ b/backend/scss/style.scss @@ -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 { diff --git a/backend/src/data/db/recipe.rs b/backend/src/data/db/recipe.rs index ba8f677..d112b12 100644 --- a/backend/src/data/db/recipe.rs +++ b/backend/src/data/db/recipe.rs @@ -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 { + 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 { + 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> { 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, + ) -> 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)] diff --git a/backend/src/main.rs b/backend/src/main.rs index dcb3d05..46ebe06 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -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( diff --git a/backend/src/services/ron.rs b/backend/src/services/ron.rs index d6dbe7a..12c0f76 100644 --- a/backend/src/services/ron.rs +++ b/backend/src/services/ron.rs @@ -125,6 +125,44 @@ async fn check_user_rights_recipe_group( } } +async fn check_user_rights_recipe_step( + connection: &db::Connection, + user: &Option, + 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, + 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, @@ -255,7 +293,6 @@ pub async fn get_groups( State(connection): State, recipe_id: Query, ) -> Result { - 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, + Extension(user): Extension>, + ExtractRon(ron): ExtractRon, +) -> Result { + 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, + Extension(user): Extension>, + ExtractRon(ron): ExtractRon, +) -> Result { + 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, + Extension(user): Extension>, + ExtractRon(ron): ExtractRon, +) -> Result { + 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, + Extension(user): Extension>, + ExtractRon(ron): ExtractRon, +) -> Result { + 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, + Extension(user): Extension>, + ExtractRon(ron): ExtractRon, +) -> Result { + 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>) -> impl IntoResponse { diff --git a/backend/templates/base.html b/backend/templates/base.html index 147ed3d..0f61352 100644 --- a/backend/templates/base.html +++ b/backend/templates/base.html @@ -20,6 +20,6 @@ {% block body_container %}{% endblock %} -
gburri - 2022
+
gburri - 2025
\ No newline at end of file diff --git a/backend/templates/recipe_edit.html b/backend/templates/recipe_edit.html index f6134f6..8b3c888 100644 --- a/backend/templates/recipe_edit.html +++ b/backend/templates/recipe_edit.html @@ -20,10 +20,11 @@ - + - +
@@ -73,15 +74,19 @@ + +
- +
+ +
@@ -89,13 +94,18 @@
- + - + + + + + +
diff --git a/common/Cargo.toml b/common/Cargo.toml index b15784d..871cd85 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -6,7 +6,6 @@ edition = "2021" [dependencies] regex = "1" -lazy_static = "1" ron = "0.8" serde = { version = "1.0", features = ["derive"] } diff --git a/common/src/ron_api.rs b/common/src/ron_api.rs index 639b0c6..2b8b9cb 100644 --- a/common/src/ron_api.rs +++ b/common/src/ron_api.rs @@ -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, +} + +#[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, diff --git a/common/src/utils.rs b/common/src/utils.rs index 9322ed6..7bf0f40 100644 --- a/common/src/utils.rs +++ b/common/src/utils.rs @@ -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 = 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) { diff --git a/frontend/src/handles.rs b/frontend/src/handles.rs index d5aa8ff..36e0de1 100644 --- a/frontend/src/handles.rs +++ b/frontend/src/handles.rs @@ -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::().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::() - .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::() - .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::() - .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::() - .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::() - .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::() { - Some(t) } else { - estimated_time.set_value(¤t_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::() - .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::() - .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::() - .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::() - .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::() - .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::() - .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::(".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::(&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::() - .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::() - .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::() - .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::() - .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::() - .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(); }); }); diff --git a/frontend/src/utils.rs b/frontend/src/utils.rs index 4ca2f86..d49fff4 100644 --- a/frontend/src/utils.rs +++ b/frontend/src/utils.rs @@ -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(&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(&self, selectors: &str) -> T + where + T: JsCast, + { + self.query_selector(selectors) + .unwrap() + .unwrap() + .dyn_into::() + .unwrap() + } +} + +pub fn select(selectors: &str) -> T +where + T: JsCast, +{ + document() + .query_selector(selectors) + .unwrap() + .unwrap() + .dyn_into::() + .unwrap() +} + +pub fn select_and_clone(selectors: &str) -> T +where + T: JsCast, +{ + document() + .query_selector(selectors) + .unwrap() + .unwrap() + .clone_node_with_deep(true) + .unwrap() + .dyn_into::() + .unwrap() +} + +pub fn by_id(element_id: &str) -> T +where + T: JsCast, +{ + document() + .get_element_by_id(element_id) + .unwrap() + .dyn_into::() + .unwrap() +} -- 2.49.0