Recipe edit (WIP): all form fields are now saved

This commit is contained in:
Greg Burri 2024-12-27 00:39:23 +01:00
parent 07b7ff425e
commit 6876a254e1
12 changed files with 563 additions and 210 deletions

13
Cargo.lock generated
View file

@ -478,7 +478,6 @@ checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990"
name = "common" name = "common"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"lazy_static",
"regex", "regex",
"ron", "ron",
"serde", "serde",
@ -1946,9 +1945,9 @@ dependencies = [
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.37" version = "1.0.38"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
] ]
@ -2179,9 +2178,9 @@ dependencies = [
[[package]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.18" version = "1.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e819f2bc632f285be6d7cd36e25940d45b2391dd6d9b939e79de557f7014248" checksum = "f7c45b9784283f1b2e7fb61b42047c2fd678ef0960d4f6f1eba131594cc369d4"
[[package]] [[package]]
name = "ryu" name = "ryu"
@ -2625,9 +2624,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.91" version = "2.0.92"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d53cbcb5a243bd33b7858b1d7f4aca2153490815872d86d955d6ea29f743c035" checksum = "70ae51629bf965c5c098cc9e87908a3df5301051a9e087d6f9bef5c9771ed126"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",

View file

@ -53,7 +53,7 @@ body {
font-family: Fira Code, Helvetica Neue, Helvetica, Arial, sans-serif; font-family: Fira Code, Helvetica Neue, Helvetica, Arial, sans-serif;
text-shadow: 2px 2px 2px rgb(0, 0, 0); text-shadow: 2px 2px 2px rgb(0, 0, 0);
// line-height: 18px; // line-height: 18px;
color: rgb(255, 255, 255); color: lighten($primary, 60%);
background-color: $background; background-color: $background;
margin: 0px; margin: 0px;
@ -63,7 +63,7 @@ body {
.recipe-item-current { .recipe-item-current {
padding: 3px; padding: 3px;
border: 1px solid white; border: 1px solid lighten($primary, 30%);
} }
.header-container { .header-container {
@ -87,7 +87,7 @@ body {
flex-grow: 1; flex-grow: 1;
background-color: $background-container; background-color: $background-container;
border: 0.1em solid white; border: 0.1em solid lighten($primary, 50%);
padding: 0.5em; padding: 0.5em;
h1 { h1 {
@ -95,15 +95,15 @@ body {
} }
.group { .group {
border: 0.1em solid white; border: 0.1em solid lighten($primary, 30%);
} }
.step { .step {
border: 0.1em solid white; border: 0.1em solid lighten($primary, 30%);
} }
.ingredient { .ingredient {
border: 0.1em solid white; border: 0.1em solid lighten($primary, 30%);
} }
#hidden-templates { #hidden-templates {

View file

@ -49,7 +49,8 @@ ORDER BY [title]
sqlx::query_scalar( sqlx::query_scalar(
r#" r#"
SELECT COUNT(*) 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 WHERE [Group].[id] = $1 AND [user_id] = $2
"#, "#,
) )
@ -60,6 +61,45 @@ WHERE [Group].[id] = $1 AND [user_id] = $2
.map_err(DBError::from) .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>> { pub async fn get_recipe(&self, id: i64) -> Result<Option<model::Recipe>> {
sqlx::query_as( sqlx::query_as(
r#" r#"
@ -263,6 +303,60 @@ ORDER BY [name]
.map(|_| ()) .map(|_| ())
.map_err(DBError::from) .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)] #[cfg(test)]

View file

@ -61,14 +61,14 @@ const TRACING_LEVEL: tracing::Level = tracing::Level::INFO;
// TODO: Should main returns 'Result'? // TODO: Should main returns 'Result'?
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
if process_args().await {
return;
}
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_max_level(TRACING_LEVEL) .with_max_level(TRACING_LEVEL)
.init(); .init();
if process_args().await {
return;
}
event!(Level::INFO, "Starting Recipes as web server..."); event!(Level::INFO, "Starting Recipes as web server...");
let config = config::load(); let config = config::load();
@ -109,6 +109,26 @@ async fn main() {
"/recipe/set_group_comment", "/recipe/set_group_comment",
put(services::ron::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); .fallback(services::ron::not_found);
let fragments_routes = Router::new().route( let fragments_routes = Router::new().route(

View 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] #[debug_handler]
pub async fn set_recipe_title( pub async fn set_recipe_title(
State(connection): State<db::Connection>, State(connection): State<db::Connection>,
@ -255,7 +293,6 @@ pub async fn get_groups(
State(connection): State<db::Connection>, State(connection): State<db::Connection>,
recipe_id: Query<RecipeId>, recipe_id: Query<RecipeId>,
) -> Result<impl IntoResponse> { ) -> Result<impl IntoResponse> {
println!("PROUT");
// Here we don't check user rights on purpose. // Here we don't check user rights on purpose.
Ok(ron_response( Ok(ron_response(
StatusCode::OK, StatusCode::OK,
@ -318,6 +355,69 @@ pub async fn set_group_comment(
Ok(StatusCode::OK) 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 ///// ///// 404 /////
#[debug_handler] #[debug_handler]
pub async fn not_found(Extension(_user): Extension<Option<model::User>>) -> impl IntoResponse { pub async fn not_found(Extension(_user): Extension<Option<model::User>>) -> impl IntoResponse {

View file

@ -20,6 +20,6 @@
{% block body_container %}{% endblock %} {% block body_container %}{% endblock %}
<footer class="footer-container">gburri - 2022</footer> <footer class="footer-container">gburri - 2025</footer>
</body> </body>
</html> </html>

View file

@ -20,10 +20,11 @@
<textarea <textarea
id="text-area-description">{{ recipe.description }}</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 <input
id="input-estimated-time" id="input-estimated-time"
type="number" type="number"
step="1" min="0" max="1000"
value=" value="
{% match recipe.estimated_time %} {% match recipe.estimated_time %}
{% when Some with (t) %} {% when Some with (t) %}
@ -73,6 +74,8 @@
<label for="input-group-comment">Comment</label> <label for="input-group-comment">Comment</label>
<input class="input-group-comment" type="text" /> <input class="input-group-comment" type="text" />
<input class="input-group-delete" type="button" value="Remove group" />
<div class="steps"></div> <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" />
@ -82,6 +85,8 @@
<label for="text-area-step-action">Action</label> <label for="text-area-step-action">Action</label>
<textarea class="text-area-step-action"></textarea> <textarea class="text-area-step-action"></textarea>
<input class="input-step-delete" type="button" value="Remove step" />
<div class="ingredients"></div> <div class="ingredients"></div>
<input class="button-add-ingedient" type="button" value="Add an ingredient"/> <input class="button-add-ingedient" type="button" value="Add an ingredient"/>
@ -89,13 +94,18 @@
<div class="ingredient"> <div class="ingredient">
<label for="input-ingredient-quantity">Quantity</label> <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" /> <input class="input-ingredient-unit" type="text" />
<label for="input-ingredient-name">Name</label> <label for="input-ingredient-name">Name</label>
<input class="input-ingredient-name" type="text" /> <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> </div>
</div> </div>

View file

@ -6,7 +6,6 @@ edition = "2021"
[dependencies] [dependencies]
regex = "1" regex = "1"
lazy_static = "1"
ron = "0.8" ron = "0.8"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }

View file

@ -92,6 +92,36 @@ pub struct SetGroupComment {
pub comment: String, 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)] #[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Group { pub struct Group {
pub id: i64, pub id: i64,

View file

@ -1,4 +1,5 @@
use lazy_static::lazy_static; use std::sync::LazyLock;
use regex::Regex; use regex::Regex;
pub enum EmailValidation { pub enum EmailValidation {
@ -6,12 +7,12 @@ pub enum EmailValidation {
NotValid, NotValid,
} }
lazy_static! { static EMAIL_REGEX: LazyLock<Regex> = LazyLock::new(|| {
static ref EMAIL_REGEX: Regex = Regex::new( Regex::new(
r"^([a-z0-9_+]([a-z0-9_+.]*[a-z0-9_+])?)@([a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,6})" 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 { pub fn validate_email(email: &str) -> EmailValidation {
if EMAIL_REGEX.is_match(email) { if EMAIL_REGEX.is_match(email) {

View file

@ -1,13 +1,14 @@
use gloo::{console::log, events::EventListener, net::http::Request, utils::document}; use gloo::{console::log, events::EventListener, net::http::Request, utils::document};
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::spawn_local; 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::{ use crate::{
request, request,
toast::{self, Level}, toast::{self, Level},
utils::{by_id, select, select_and_clone, SelectExt},
}; };
async fn reload_recipes_list(current_recipe_id: i64) { 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> { pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> {
// Title. // Title.
{ {
let input_title = document().get_element_by_id("input-title").unwrap(); let title: HtmlInputElement = by_id("input-title");
let mut current_title = input_title.dyn_ref::<HtmlInputElement>().unwrap().value(); let mut current_title = title.value();
let on_input_title_blur = EventListener::new(&input_title, "blur", move |_event| { EventListener::new(&title.clone(), "blur", move |_event| {
let title = document()
.get_element_by_id("input-title")
.unwrap()
.dyn_into::<HtmlInputElement>()
.unwrap();
if title.value() != current_title { if title.value() != current_title {
current_title = title.value(); current_title = title.value();
let body = ron_api::SetRecipeTitle { let body = ron_api::SetRecipeTitle {
@ -48,26 +44,16 @@ pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> {
reload_recipes_list(recipe_id).await; reload_recipes_list(recipe_id).await;
}); });
} }
}); })
on_input_title_blur.forget(); .forget();
} }
// Description. // Description.
{ {
let text_area_description = document() let description: HtmlTextAreaElement = by_id("text-area-description");
.get_element_by_id("text-area-description") let mut current_description = description.value();
.unwrap();
let mut current_description = text_area_description
.dyn_ref::<HtmlTextAreaElement>()
.unwrap()
.value();
let on_input_description_blur = let on_input_description_blur =
EventListener::new(&text_area_description, "blur", move |_event| { EventListener::new(&description.clone(), "blur", move |_event| {
let description = document()
.get_element_by_id("text-area-description")
.unwrap()
.dyn_into::<HtmlTextAreaElement>()
.unwrap();
if description.value() != current_description { if description.value() != current_description {
current_description = description.value(); current_description = description.value();
let body = ron_api::SetRecipeDescription { let body = ron_api::SetRecipeDescription {
@ -84,31 +70,24 @@ pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> {
// Estimated time. // Estimated time.
{ {
let input_estimated_time = document() let estimated_time: HtmlInputElement = by_id("input-estimated-time");
.get_element_by_id("input-estimated-time") let mut current_time = estimated_time.value_as_number();
.unwrap();
let mut current_time = input_estimated_time
.dyn_ref::<HtmlInputElement>()
.unwrap()
.value();
let on_input_estimated_time_blur = let on_input_estimated_time_blur =
EventListener::new(&input_estimated_time, "blur", move |_event| { EventListener::new(&estimated_time.clone(), "blur", move |_event| {
let estimated_time = document() let n = estimated_time.value_as_number();
.get_element_by_id("input-estimated-time") if n.is_nan() {
.unwrap() estimated_time.set_value("");
.dyn_into::<HtmlInputElement>() }
.unwrap(); if n != current_time {
if estimated_time.value() != current_time { let time = if n.is_nan() {
let time = if estimated_time.value().is_empty() {
None None
} else if let Ok(t) = estimated_time.value().parse::<u32>() {
Some(t)
} else { } else {
estimated_time.set_value(&current_time); // TODO: Find a better way to validate integer numbers.
return; let n = n as u32;
estimated_time.set_value_as_number(n as f64);
Some(n)
}; };
current_time = n;
current_time = estimated_time.value();
let body = ron_api::SetRecipeEstimatedTime { let body = ron_api::SetRecipeEstimatedTime {
recipe_id, recipe_id,
estimated_time: time, estimated_time: time,
@ -123,18 +102,10 @@ pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> {
// Difficulty. // Difficulty.
{ {
let select_difficulty = document().get_element_by_id("select-difficulty").unwrap(); let difficulty: HtmlSelectElement = by_id("select-difficulty");
let mut current_difficulty = select_difficulty let mut current_difficulty = difficulty.value();
.dyn_ref::<HtmlSelectElement>()
.unwrap()
.value();
let on_select_difficulty_blur = let on_select_difficulty_blur =
EventListener::new(&select_difficulty, "blur", move |_event| { EventListener::new(&difficulty.clone(), "blur", move |_event| {
let difficulty = document()
.get_element_by_id("select-difficulty")
.unwrap()
.dyn_into::<HtmlSelectElement>()
.unwrap();
if difficulty.value() != current_difficulty { if difficulty.value() != current_difficulty {
current_difficulty = difficulty.value(); current_difficulty = difficulty.value();
@ -155,17 +126,10 @@ pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> {
// Language. // Language.
{ {
let select_language = document().get_element_by_id("select-language").unwrap(); let language: HtmlSelectElement = by_id("select-language");
let mut current_language = select_language let mut current_language = language.value();
.dyn_ref::<HtmlSelectElement>() let on_select_language_blur =
.unwrap() EventListener::new(&language.clone(), "blur", move |_event| {
.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 { if language.value() != current_language {
current_language = language.value(); current_language = language.value();
@ -183,15 +147,9 @@ pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> {
// Is published. // 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 = let on_input_is_published_blur =
EventListener::new(&input_is_published, "input", move |_event| { EventListener::new(&is_published.clone(), "input", move |_event| {
let is_published = document()
.get_element_by_id("input-is-published")
.unwrap()
.dyn_into::<HtmlInputElement>()
.unwrap();
let body = ron_api::SetIsPublished { let body = ron_api::SetIsPublished {
recipe_id, recipe_id,
is_published: is_published.checked(), is_published: is_published.checked(),
@ -204,62 +162,185 @@ pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> {
on_input_is_published_blur.forget(); on_input_is_published_blur.forget();
} }
// let groups_container = document().get_element_by_id("groups-container").unwrap(); fn create_group_element(group: &ron_api::Group) -> Element {
// if !groups_container.has_child_nodes() { let group_id = group.id;
let group_element: Element = select_and_clone("#hidden-templates .group");
// } group_element
.set_attribute("id", &format!("group-{}", group.id))
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))
.unwrap(); .unwrap();
let groups_container = document().get_element_by_id("groups-container").unwrap(); let groups_container = document().get_element_by_id("groups-container").unwrap();
groups_container.append_child(&group_html).unwrap(); groups_container.append_child(&group_element).unwrap();
group_html
// 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 { fn create_step_element(group_element: &Element, step: &ron_api::Step) -> Element {
let step_html = document() let step_id = step.id;
.query_selector("#hidden-templates .step") let step_element: Element = select_and_clone("#hidden-templates .step");
.unwrap() step_element
.unwrap() .set_attribute("id", &format!("step-{}", step.id))
.clone_node_with_deep(true)
.unwrap()
.dyn_into::<Element>()
.unwrap();
step_html
.set_attribute("id", &format!("step-{}", step_id))
.unwrap(); .unwrap();
group_element.append_child(&step_element).unwrap();
group_element.append_child(&step_html).unwrap(); // Step action.
step_html 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();
step_element
} }
fn create_ingredient_element(step_element: &Element, ingredient_id: i64) -> Element { fn create_ingredient_element(
let ingredient_html = document() step_element: &Element,
.query_selector("#hidden-templates .ingredient") ingredient: &ron_api::Ingredient,
.unwrap() ) -> Element {
.unwrap() let ingredient_id = ingredient.id;
.clone_node_with_deep(true) let ingredient_element: Element = select_and_clone("#hidden-templates .ingredient");
.unwrap() ingredient_element
.dyn_into::<Element>() .set_attribute("id", &format!("step-{}", ingredient.id))
.unwrap();
ingredient_html
.set_attribute("id", &format!("step-{}", ingredient_id))
.unwrap(); .unwrap();
step_element.append_child(&ingredient_element).unwrap();
step_element.append_child(&ingredient_html).unwrap(); // Ingredient name.
ingredient_html 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();
ingredient_element
} }
// Load initial groups, steps and ingredients. // Load initial groups, steps and ingredients.
@ -271,42 +352,16 @@ pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> {
.unwrap(); .unwrap();
for group in groups { for group in groups {
let group_element = create_group_element(group.id); let group_element = create_group_element(&group);
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))
for step in group.steps { for step in group.steps {
let step_element = create_step_element(&group_element, step.id); let step_element = create_step_element(&group_element, &step);
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);
for ingredient in step.ingredients { for ingredient in step.ingredients {
let ingredient_element = create_ingredient_element(&step_element, &ingredient);
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);
} }
} }
} }
// log!(format!("{:?}", groups));
}); });
} }
@ -320,7 +375,12 @@ pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> {
spawn_local(async move { spawn_local(async move {
let response: ron_api::AddRecipeGroupResult = let response: ron_api::AddRecipeGroupResult =
request::post("recipe/add_group", body).await.unwrap(); 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(); // group_html.set_attribute("id", "test").unwrap();
}); });
}); });

View 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() { pub trait SelectExt {
// When the `console_error_panic_hook` feature is enabled, we can call the fn select<T>(&self, selectors: &str) -> T
// `set_panic_hook` function at least once during initialization, and then where
// we will get better error messages if our code ever panics. T: JsCast;
//
// 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();
} }
// #[macro_export] impl SelectExt for Element {
// macro_rules! console_log { fn select<T>(&self, selectors: &str) -> T
// // Note that this is using the `log` function imported above during where
// // `bare_bones` T: JsCast,
// ($($t:tt)*) => (console::log_1(&format_args!($($t)*).to_string().into())) {
// } 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()
}