Recipe edit (WIP): forms to edit groups, steps and ingredients
authorGreg Burri <greg.burri@gmail.com>
Thu, 26 Dec 2024 00:39:07 +0000 (01:39 +0100)
committerGreg Burri <greg.burri@gmail.com>
Thu, 26 Dec 2024 00:39:07 +0000 (01:39 +0100)
25 files changed:
Cargo.lock
backend/build.rs
backend/scss/style.scss
backend/sql/data_test.sql
backend/sql/version_1.sql
backend/src/config.rs
backend/src/consts.rs
backend/src/data/db.rs
backend/src/data/db/recipe.rs
backend/src/data/db/user.rs
backend/src/data/model.rs
backend/src/email.rs
backend/src/main.rs
backend/src/ron_utils.rs
backend/src/services.rs
backend/src/services/fragments.rs
backend/src/services/ron.rs
backend/src/services/user.rs
backend/templates/recipe_edit.html
common/src/ron_api.rs
frontend/Cargo.toml
frontend/src/handles.rs
frontend/src/lib.rs
frontend/src/request.rs [new file with mode: 0644]
frontend/src/toast.rs

index 22fc2c7..2ae3f71 100644 (file)
@@ -793,6 +793,9 @@ dependencies = [
  "common",
  "console_error_panic_hook",
  "gloo",
+ "ron",
+ "serde",
+ "thiserror 2.0.9",
  "wasm-bindgen",
  "wasm-bindgen-futures",
  "web-sys",
@@ -2976,9 +2979,9 @@ checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825"
 
 [[package]]
 name = "unicase"
-version = "2.8.0"
+version = "2.8.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7e51b68083f157f853b6379db119d1c1be0e6e4dec98101079dec41f6f5cf6df"
+checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539"
 
 [[package]]
 name = "unicode-bidi"
index 5628d92..0af3eca 100644 (file)
@@ -35,7 +35,7 @@ fn main() {
     }
 
     let output = if exists_in_path("sass.bat") {
-        run_sass(Command::new("cmd").args(&["/C", "sass.bat"]))
+        run_sass(Command::new("cmd").args(["/C", "sass.bat"]))
     } else {
         run_sass(&mut Command::new("sass"))
     };
index 1405ecd..761ae70 100644 (file)
@@ -93,6 +93,22 @@ body {
             h1 {
                 text-align: center;
             }
+
+            .group {
+                border: 0.1em solid white;
+            }
+
+            .step {
+                border: 0.1em solid white;
+            }
+
+            .ingredient {
+                border: 0.1em solid white;
+            }
+
+            #hidden-templates {
+                display: none;
+            }
         }
 
         form {
index 632a75e..a00170c 100644 (file)
@@ -18,14 +18,61 @@ VALUES (
     NULL
 );
 
-INSERT INTO [Recipe] ([user_id], [title], [is_published])
-VALUES (1, 'Croissant au jambon', true);
+INSERT INTO [Recipe] ([id], [user_id], [title], [is_published])
+VALUES (1, 1, 'Croissant au jambon', true);
 
-INSERT INTO [Recipe] ([user_id], [title], [is_published])
-VALUES (1, 'Gratin de thon aux olives', true);
+INSERT INTO [Recipe] ([id], [user_id], [title], [is_published])
+VALUES (2, 1, 'Gratin de thon aux olives', true);
 
-INSERT INTO [Recipe] ([user_id], [title], [is_published])
-VALUES (1, 'Saumon en croute', true);
+INSERT INTO [Recipe] ([id], [user_id], [title], [is_published])
+VALUES (3, 1, 'Saumon en croute', true);
 
-INSERT INTO [Recipe] ([user_id], [title], [is_published])
-VALUES (2, 'Ouiche lorraine', true);
+INSERT INTO [Recipe] ([id], [user_id], [title], [is_published])
+VALUES (4, 2, 'Ouiche lorraine', true);
+
+
+-- Groups, steps and ingredients for 'Gratin de thon'.
+INSERT INTO [Group] ([id], [order], [recipe_id], [name], [comment])
+VALUES (1, 1, 2, "Fond du gratin", "");
+
+INSERT INTO [Group] ([id], [order], [recipe_id], [name], [comment])
+VALUES (2, 2, 2, "Sauce", "");
+
+
+INSERT INTO [Step] ([id], [order], [group_id], [action])
+VALUES (1, 1, 1, "Égoutter et émietter dans un plat à gratting graissé");
+
+INSERT INTO [Ingredient] ([id], [step_id], [name], [comment], [quantity_value], [quantity_unit])
+VALUES (1, 1, "Thon en boîte", "", 240, "g");
+
+
+INSERT INTO [Step] ([id], [order], [group_id], [action])
+VALUES (2, 2, 1, "Saupoudrer");
+
+INSERT INTO [Ingredient] ([id], [step_id], [name], [comment], [quantity_value], [quantity_unit])
+VALUES (2, 2, "Sel à l'origan", "", 1, "c-à-c");
+
+
+INSERT INTO [Step] ([id], [order], [group_id], [action])
+VALUES (3, 3, 2, "Mélanger au fouet et verser sur le thon dans le plat");
+
+INSERT INTO [Ingredient] ([id], [step_id], [name], [comment], [quantity_value], [quantity_unit])
+VALUES (3, 3, "Concentré de tomate", "", 4, "c-à-s");
+
+INSERT INTO [Ingredient] ([id], [step_id], [name], [comment], [quantity_value], [quantity_unit])
+VALUES (4, 3, "Poivre", "", 0.25, "c-à-c");
+
+INSERT INTO [Ingredient] ([id], [step_id], [name], [comment], [quantity_value], [quantity_unit])
+VALUES (5, 3, "Herbe de Provence", "", 0.5, "c-à-c");
+
+INSERT INTO [Ingredient] ([id], [step_id], [name], [comment], [quantity_value], [quantity_unit])
+VALUES (6, 3, "Crème à café ou demi-crème", "", 2, "dl");
+
+INSERT INTO [Ingredient] ([id], [step_id], [name], [comment], [quantity_value], [quantity_unit])
+VALUES (7, 3, "Olives farcies coupées en deuxs", "", 50, "g");
+
+
+INSERT INTO [Group] ([id], [order], [recipe_id], [name], [comment])
+VALUES (3, 3, 2,
+    "15 à 20 minutes de cuisson au four à 220 °C",
+    "Servir avec du riz ou des patates robe des champs");
\ No newline at end of file
index 3fffc66..1efde53 100644 (file)
@@ -89,21 +89,12 @@ CREATE TABLE [Tag] (
 
 CREATE UNIQUE INDEX [Tag_name_lang_index] ON [Tag] ([name], [lang]);
 
-CREATE TABLE [Ingredient] (
-    [id] INTEGER PRIMARY KEY,
-    [name] TEXT NOT NULL,
-    [comment] TEXT NOT NULL DEFAULT '',
-    [quantity_value] INTEGER,
-    [quantity_unit] TEXT NOT NULL DEFAULT '',
-    [input_step_id] INTEGER NOT NULL,
-
-    FOREIGN KEY([input_step_id]) REFERENCES [Step]([id]) ON DELETE CASCADE
-) STRICT;
-
 CREATE TABLE [Group] (
     [id] INTEGER PRIMARY KEY,
+
     [order] INTEGER NOT NULL DEFAULT 0,
     [recipe_id] INTEGER NOT NULL,
+
     [name] TEXT NOT NULL DEFAULT '',
     [comment] TEXT NOT NULL DEFAULT '',
 
@@ -114,15 +105,30 @@ CREATE INDEX [Group_order_index] ON [Group]([order]);
 
 CREATE TABLE [Step] (
     [id] INTEGER PRIMARY KEY,
+
     [order] INTEGER NOT NULL DEFAULT 0,
-    [action] TEXT NOT NULL DEFAULT '',
     [group_id] INTEGER NOT NULL,
 
+    [action] TEXT NOT NULL DEFAULT '',
+
     FOREIGN KEY(group_id) REFERENCES [Group](id) ON DELETE CASCADE
 ) STRICT;
 
 CREATE INDEX [Step_order_index] ON [Group]([order]);
 
+CREATE TABLE [Ingredient] (
+    [id] INTEGER PRIMARY KEY,
+
+    [step_id] INTEGER NOT NULL,
+
+    [name] TEXT NOT NULL,
+    [comment] TEXT NOT NULL DEFAULT '',
+    [quantity_value] REAL,
+    [quantity_unit] TEXT NOT NULL DEFAULT '',
+
+    FOREIGN KEY([step_id]) REFERENCES [Step]([id]) ON DELETE CASCADE
+) STRICT;
+
 -- CREATE TABLE [IntermediateSubstance] (
 --     [id] INTEGER PRIMARY KEY,
 --     [name] TEXT NOT NULL DEFAULT '',
index 59ee30c..3d2a08d 100644 (file)
@@ -41,22 +41,31 @@ impl fmt::Debug for Config {
 
 pub fn load() -> Config {
     match File::open(consts::FILE_CONF) {
-        Ok(file) => from_reader(file).expect(&format!(
-            "Failed to open configuration file {}",
-            consts::FILE_CONF
-        )),
+        Ok(file) => from_reader(file).unwrap_or_else(|error| {
+            panic!(
+                "Failed to open configuration file {}: {}",
+                consts::FILE_CONF,
+                error
+            )
+        }),
         Err(_) => {
-            let file = File::create(consts::FILE_CONF).expect(&format!(
-                "Failed to create default configuration file {}",
-                consts::FILE_CONF
-            ));
+            let file = File::create(consts::FILE_CONF).unwrap_or_else(|error| {
+                panic!(
+                    "Failed to create default configuration file {}: {}",
+                    consts::FILE_CONF,
+                    error
+                )
+            });
 
             let default_config = Config::default();
 
-            to_writer_pretty(file, &default_config, PrettyConfig::new()).expect(&format!(
-                "Failed to write default configuration file {}",
-                consts::FILE_CONF
-            ));
+            to_writer_pretty(file, &default_config, PrettyConfig::new()).unwrap_or_else(|error| {
+                panic!(
+                    "Failed to write default configuration file {}: {}",
+                    consts::FILE_CONF,
+                    error
+                )
+            });
 
             default_config
         }
index 7b776e9..bedffc8 100644 (file)
@@ -4,10 +4,10 @@ pub const FILE_CONF: &str = "conf.ron";
 pub const DB_DIRECTORY: &str = "data";
 pub const DB_FILENAME: &str = "recipes.sqlite";
 pub const SQL_FILENAME: &str = "sql/version_{VERSION}.sql";
-pub const VALIDATION_TOKEN_DURATION: i64 = 1 * 60 * 60; // [s]. (1 jour).
+pub const VALIDATION_TOKEN_DURATION: i64 = 60 * 60; // [s]. (1 jour).
 pub const COOKIE_AUTH_TOKEN_NAME: &str = "auth_token";
 
-pub const VALIDATION_PASSWORD_RESET_TOKEN_DURATION: i64 = 1 * 60 * 60; // [s]. (1 jour).
+pub const VALIDATION_PASSWORD_RESET_TOKEN_DURATION: i64 = 60 * 60; // [s]. (1 jour).
 
 // Number of alphanumeric characters for tokens
 // (cookie authentication, password reset, validation token).
@@ -21,4 +21,4 @@ pub const REVERSE_PROXY_IP_HTTP_FIELD: &str = "x-real-ip"; // Set by the reverse
 
 pub const MAX_DB_CONNECTION: u32 = 1024;
 
-pub const LANGUAGES: [(&'static str, &'static str); 2] = [("Français", "fr"), ("English", "en")];
+pub const LANGUAGES: [(&str, &str); 2] = [("Français", "fr"), ("English", "en")];
index baff9d6..848c4d4 100644 (file)
@@ -196,26 +196,10 @@ WHERE [type] = 'table' AND [name] = 'Version'
 }
 
 fn load_sql_file<P: AsRef<Path> + fmt::Display>(sql_file: P) -> Result<String> {
-    let mut file = File::open(&sql_file).map_err(|err| {
-        DBError::Other(format!(
-            "Cannot open SQL file ({}): {}",
-            &sql_file,
-            err.to_string()
-        ))
-    })?;
+    let mut file = File::open(&sql_file)
+        .map_err(|err| DBError::Other(format!("Cannot open SQL file ({}): {}", &sql_file, err)))?;
     let mut sql = String::new();
-    file.read_to_string(&mut sql).map_err(|err| {
-        DBError::Other(format!(
-            "Cannot read SQL file ({}) : {}",
-            &sql_file,
-            err.to_string()
-        ))
-    })?;
+    file.read_to_string(&mut sql)
+        .map_err(|err| DBError::Other(format!("Cannot read SQL file ({}) : {}", &sql_file, err)))?;
     Ok(sql)
 }
-
-// #[cfg(test)]
-// mod tests {
-//     use super::*;
-
-// }
index 2525cc5..ba8f677 100644 (file)
@@ -45,6 +45,21 @@ ORDER BY [title]
             .map_err(DBError::from)
     }
 
+    pub async fn can_edit_recipe_group(&self, user_id: i64, group_id: i64) -> Result<bool> {
+        sqlx::query_scalar(
+            r#"
+SELECT COUNT(*)
+FROM [Recipe] INNER JOIN [Group] ON [Group].[recipe_id] = [Recipe].[id]
+WHERE [Group].[id] = $1 AND [user_id] = $2
+            "#,
+        )
+        .bind(group_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#"
@@ -166,6 +181,88 @@ WHERE [Recipe].[user_id] = $1
             .map(|_| ())
             .map_err(DBError::from)
     }
+
+    pub async fn get_groups(&self, recipe_id: i64) -> Result<Vec<model::Group>> {
+        let mut tx = self.tx().await?;
+        let mut groups: Vec<model::Group> = sqlx::query_as(
+            r#"
+SELECT [id], [name], [comment]
+FROM [Group]
+WHERE [recipe_id] = $1
+ORDER BY [order]
+            "#,
+        )
+        .bind(recipe_id)
+        .fetch_all(&mut *tx)
+        .await?;
+
+        for group in groups.iter_mut() {
+            group.steps = sqlx::query_as(
+                r#"
+SELECT [id], [action]
+FROM [Step]
+WHERE [group_id] = $1
+ORDER BY [order]
+                "#,
+            )
+            .bind(group.id)
+            .fetch_all(&mut *tx)
+            .await?;
+
+            for step in group.steps.iter_mut() {
+                step.ingredients = sqlx::query_as(
+                    r#"
+SELECT [id], [name], [comment], [quantity_value], [quantity_unit]
+FROM [Ingredient]
+WHERE [step_id] = $1
+ORDER BY [name]
+                    "#,
+                )
+                .bind(step.id)
+                .fetch_all(&mut *tx)
+                .await?;
+            }
+        }
+
+        Ok(groups)
+    }
+
+    pub async fn add_recipe_group(&self, recipe_id: i64) -> Result<i64> {
+        let db_result = sqlx::query("INSERT INTO [Group] ([recipe_id]) VALUES ($1)")
+            .bind(recipe_id)
+            .execute(&self.pool)
+            .await?;
+        Ok(db_result.last_insert_rowid())
+    }
+
+    pub async fn rm_recipe_group(&self, group_id: i64) -> Result<()> {
+        sqlx::query("DELETE FROM [Group] WHERE [id] = $1")
+            .bind(group_id)
+            .execute(&self.pool)
+            .await
+            .map(|_| ())
+            .map_err(DBError::from)
+    }
+
+    pub async fn set_group_name(&self, group_id: i64, name: &str) -> Result<()> {
+        sqlx::query("UPDATE [Group] SET [name] = $2 WHERE [id] = $1")
+            .bind(group_id)
+            .bind(name)
+            .execute(&self.pool)
+            .await
+            .map(|_| ())
+            .map_err(DBError::from)
+    }
+
+    pub async fn set_group_comment(&self, group_id: i64, comment: &str) -> Result<()> {
+        sqlx::query("UPDATE [Group] SET [comment] = $2 WHERE [id] = $1")
+            .bind(group_id)
+            .bind(comment)
+            .execute(&self.pool)
+            .await
+            .map(|_| ())
+            .map_err(DBError::from)
+    }
 }
 
 #[cfg(test)]
@@ -214,7 +311,7 @@ mod tests {
         assert_eq!(recipe.estimated_time, Some(420));
         assert_eq!(recipe.difficulty, Difficulty::Medium);
         assert_eq!(recipe.lang, "fr");
-        assert_eq!(recipe.is_published, true);
+        assert!(recipe.is_published);
 
         Ok(())
     }
index 698aa64..4a37d69 100644 (file)
@@ -190,7 +190,7 @@ FROM [User] WHERE [email] = $1
                     return Ok(SignUpResult::UserAlreadyExists);
                 }
                 let token = generate_token();
-                let hashed_password = hash(password).map_err(|e| DBError::from_dyn_error(e))?;
+                let hashed_password = hash(password).map_err(DBError::from_dyn_error)?;
                 sqlx::query(
                     r#"
 UPDATE [User]
@@ -208,7 +208,7 @@ WHERE [id] = $1
             }
             None => {
                 let token = generate_token();
-                let hashed_password = hash(password).map_err(|e| DBError::from_dyn_error(e))?;
+                let hashed_password = hash(password).map_err(DBError::from_dyn_error)?;
                 sqlx::query(
                     r#"
 INSERT INTO [User]
@@ -336,19 +336,18 @@ WHERE [id] = $1
 
     pub async fn sign_out(&self, token: &str) -> Result<()> {
         let mut tx = self.tx().await?;
-        match sqlx::query_scalar::<_, i64>("SELECT [id] FROM [UserLoginToken] WHERE [token] = $1")
-            .bind(token)
-            .fetch_optional(&mut *tx)
-            .await?
+
+        if let Some(login_id) =
+            sqlx::query_scalar::<_, i64>("SELECT [id] FROM [UserLoginToken] WHERE [token] = $1")
+                .bind(token)
+                .fetch_optional(&mut *tx)
+                .await?
         {
-            Some(login_id) => {
-                sqlx::query("DELETE FROM [UserLoginToken] WHERE [id] = $1")
-                    .bind(login_id)
-                    .execute(&mut *tx)
-                    .await?;
-                tx.commit().await?;
-            }
-            None => (),
+            sqlx::query("DELETE FROM [UserLoginToken] WHERE [id] = $1")
+                .bind(login_id)
+                .execute(&mut *tx)
+                .await?;
+            tx.commit().await?;
         }
         Ok(())
     }
@@ -429,7 +428,7 @@ WHERE [password_reset_token] = $1
                 .execute(&mut *tx)
                 .await?;
 
-            let hashed_new_password = hash(new_password).map_err(|e| DBError::from_dyn_error(e))?;
+            let hashed_new_password = hash(new_password).map_err(DBError::from_dyn_error)?;
 
             sqlx::query(
                 r#"
@@ -853,7 +852,7 @@ VALUES (
         };
 
         connection
-            .reset_password(&new_password, &token, Duration::hours(1))
+            .reset_password(new_password, &token, Duration::hours(1))
             .await?;
 
         // Sign in.
index 0d82ba8..84bad3c 100644 (file)
@@ -34,20 +34,30 @@ pub struct Recipe {
     // pub groups: Vec<Group>,
 }
 
+#[derive(FromRow)]
 pub struct Group {
+    pub id: i64,
     pub name: String,
     pub comment: String,
+
+    #[sqlx(skip)]
     pub steps: Vec<Step>,
 }
 
+#[derive(FromRow)]
 pub struct Step {
+    pub id: i64,
     pub action: String,
+
+    #[sqlx(skip)]
     pub ingredients: Vec<Ingredient>,
 }
 
+#[derive(FromRow)]
 pub struct Ingredient {
+    pub id: i64,
     pub name: String,
     pub comment: String,
-    pub quantity: i32,
+    pub quantity_value: f64,
     pub quantity_unit: String,
 }
index fb19d7b..32cf5c3 100644 (file)
@@ -9,20 +9,20 @@ use crate::consts;
 
 #[derive(Debug, Display)]
 pub enum Error {
-    ParseError(lettre::address::AddressError),
-    SmtpError(lettre::transport::smtp::Error),
+    Parse(lettre::address::AddressError),
+    Smtp(lettre::transport::smtp::Error),
     Email(lettre::error::Error),
 }
 
 impl From<lettre::address::AddressError> for Error {
     fn from(error: lettre::address::AddressError) -> Self {
-        Error::ParseError(error)
+        Error::Parse(error)
     }
 }
 
 impl From<lettre::transport::smtp::Error> for Error {
     fn from(error: lettre::transport::smtp::Error) -> Self {
-        Error::SmtpError(error)
+        Error::Smtp(error)
     }
 }
 
index 7ae4503..dcb3d05 100644 (file)
@@ -5,7 +5,7 @@ use axum::{
     http::StatusCode,
     middleware::{self, Next},
     response::{Response, Result},
-    routing::{get, put},
+    routing::{delete, get, post, put},
     Router,
 };
 use axum_extra::extract::cookie::CookieJar;
@@ -101,6 +101,14 @@ async fn main() {
             "/recipe/set_is_published",
             put(services::ron::set_is_published),
         )
+        .route("/recipe/get_groups", get(services::ron::get_groups))
+        .route("/recipe/add_group", post(services::ron::add_group))
+        .route("/recipe/remove_group", delete(services::ron::rm_group))
+        .route("/recipe/set_group_name", put(services::ron::set_group_name))
+        .route(
+            "/recipe/set_group_comment",
+            put(services::ron::set_group_comment),
+        )
         .fallback(services::ron::not_found);
 
     let fragments_routes = Router::new().route(
@@ -183,7 +191,7 @@ async fn get_current_user(
 ) -> Option<model::User> {
     match jar.get(consts::COOKIE_AUTH_TOKEN_NAME) {
         Some(token_cookie) => match connection
-            .authentication(token_cookie.value(), &client_ip, &client_user_agent)
+            .authentication(token_cookie.value(), client_ip, client_user_agent)
             .await
         {
             Ok(db::user::AuthenticationResult::NotValidToken) => None,
@@ -234,12 +242,15 @@ async fn process_args() -> bool {
                     }
                 })
                 .unwrap();
-            std::fs::copy(&db_path, &db_path_bckup).expect(&format!(
-                "Unable to make backup of {:?} to {:?}",
-                &db_path, &db_path_bckup
-            ));
-            std::fs::remove_file(&db_path)
-                .expect(&format!("Unable to remove db file: {:?}", &db_path));
+            std::fs::copy(&db_path, &db_path_bckup).unwrap_or_else(|error| {
+                panic!(
+                    "Unable to make backup of {:?} to {:?}: {}",
+                    &db_path, &db_path_bckup, error
+                )
+            });
+            std::fs::remove_file(&db_path).unwrap_or_else(|error| {
+                panic!("Unable to remove db file {:?}: {}", &db_path, error)
+            });
         }
 
         match db::Connection::new().await {
index 8616c70..4aa7d22 100644 (file)
@@ -60,10 +60,8 @@ where
 {
     match from_bytes::<T>(&body) {
         Ok(ron) => Ok(ron),
-        Err(error) => {
-            return Err(RonError {
-                error: format!("Ron parsing error: {}", error),
-            });
-        }
+        Err(error) => Err(RonError {
+            error: format!("Ron parsing error: {}", error),
+        }),
     }
 }
index 8231335..65067a3 100644 (file)
@@ -1,7 +1,7 @@
 use axum::{
     body, debug_handler,
     extract::{Extension, Request, State},
-    http::header,
+    http::{header, StatusCode},
     middleware::Next,
     response::{IntoResponse, Response, Result},
 };
@@ -66,5 +66,8 @@ pub async fn home_page(
 
 #[debug_handler]
 pub async fn not_found(Extension(user): Extension<Option<model::User>>) -> impl IntoResponse {
-    MessageTemplate::new_with_user("404: Not found", user)
+    (
+        StatusCode::NOT_FOUND,
+        MessageTemplate::new_with_user("404: Not found", user),
+    )
 }
index d0e18ba..84a2d40 100644 (file)
@@ -1,8 +1,9 @@
 use axum::{
     debug_handler,
-    extract::{Extension, State},
+    extract::{Extension, Query, State},
     response::{IntoResponse, Result},
 };
+use serde::Deserialize;
 // use tracing::{event, Level};
 
 use crate::{
@@ -10,9 +11,15 @@ use crate::{
     html_templates::*,
 };
 
+#[derive(Deserialize)]
+pub struct CurrentRecipeId {
+    current_recipe_id: Option<i64>,
+}
+
 #[debug_handler]
 pub async fn recipes_list_fragments(
     State(connection): State<db::Connection>,
+    current_recipe: Query<CurrentRecipeId>,
     Extension(user): Extension<Option<model::User>>,
 ) -> Result<impl IntoResponse> {
     let recipes = Recipes {
@@ -24,8 +31,7 @@ pub async fn recipes_list_fragments(
         } else {
             vec![]
         },
-        current_id: None,
+        current_id: current_recipe.current_recipe_id,
     };
-
     Ok(RecipesListFragmentTemplate { user, recipes })
 }
index 89cd896..d6dbe7a 100644 (file)
 
 use axum::{
     debug_handler,
-    extract::{Extension, State},
+    extract::{Extension, Query, State},
     http::StatusCode,
     response::{ErrorResponse, IntoResponse, Result},
 };
+use serde::Deserialize;
 // use tracing::{event, Level};
 
-use crate::{data::db, model, ron_extractor::ExtractRon, ron_utils::ron_error};
+use crate::{
+    data::db,
+    model,
+    ron_extractor::ExtractRon,
+    ron_utils::{ron_error, ron_response},
+};
 
 #[allow(dead_code)]
 #[debug_handler]
@@ -81,7 +87,7 @@ pub async fn update_user(
     Ok(StatusCode::OK)
 }
 
-async fn check_user_rights(
+async fn check_user_rights_recipe(
     connection: &db::Connection,
     user: &Option<model::User>,
     recipe_id: i64,
@@ -100,13 +106,32 @@ async fn check_user_rights(
     }
 }
 
+async fn check_user_rights_recipe_group(
+    connection: &db::Connection,
+    user: &Option<model::User>,
+    group_id: i64,
+) -> Result<()> {
+    if user.is_none()
+        || !connection
+            .can_edit_recipe_group(user.as_ref().unwrap().id, group_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>,
     Extension(user): Extension<Option<model::User>>,
     ExtractRon(ron): ExtractRon<common::ron_api::SetRecipeTitle>,
 ) -> Result<StatusCode> {
-    check_user_rights(&connection, &user, ron.recipe_id).await?;
+    check_user_rights_recipe(&connection, &user, ron.recipe_id).await?;
     connection
         .set_recipe_title(ron.recipe_id, &ron.title)
         .await?;
@@ -119,7 +144,7 @@ pub async fn set_recipe_description(
     Extension(user): Extension<Option<model::User>>,
     ExtractRon(ron): ExtractRon<common::ron_api::SetRecipeDescription>,
 ) -> Result<StatusCode> {
-    check_user_rights(&connection, &user, ron.recipe_id).await?;
+    check_user_rights_recipe(&connection, &user, ron.recipe_id).await?;
     connection
         .set_recipe_description(ron.recipe_id, &ron.description)
         .await?;
@@ -132,7 +157,7 @@ pub async fn set_estimated_time(
     Extension(user): Extension<Option<model::User>>,
     ExtractRon(ron): ExtractRon<common::ron_api::SetRecipeEstimatedTime>,
 ) -> Result<StatusCode> {
-    check_user_rights(&connection, &user, ron.recipe_id).await?;
+    check_user_rights_recipe(&connection, &user, ron.recipe_id).await?;
     connection
         .set_recipe_estimated_time(ron.recipe_id, ron.estimated_time)
         .await?;
@@ -145,7 +170,7 @@ pub async fn set_difficulty(
     Extension(user): Extension<Option<model::User>>,
     ExtractRon(ron): ExtractRon<common::ron_api::SetRecipeDifficulty>,
 ) -> Result<StatusCode> {
-    check_user_rights(&connection, &user, ron.recipe_id).await?;
+    check_user_rights_recipe(&connection, &user, ron.recipe_id).await?;
     connection
         .set_recipe_difficulty(ron.recipe_id, ron.difficulty)
         .await?;
@@ -158,7 +183,7 @@ pub async fn set_language(
     Extension(user): Extension<Option<model::User>>,
     ExtractRon(ron): ExtractRon<common::ron_api::SetRecipeLanguage>,
 ) -> Result<StatusCode> {
-    check_user_rights(&connection, &user, ron.recipe_id).await?;
+    check_user_rights_recipe(&connection, &user, ron.recipe_id).await?;
     connection
         .set_recipe_language(ron.recipe_id, &ron.lang)
         .await?;
@@ -171,13 +196,128 @@ pub async fn set_is_published(
     Extension(user): Extension<Option<model::User>>,
     ExtractRon(ron): ExtractRon<common::ron_api::SetIsPublished>,
 ) -> Result<StatusCode> {
-    check_user_rights(&connection, &user, ron.recipe_id).await?;
+    check_user_rights_recipe(&connection, &user, ron.recipe_id).await?;
     connection
         .set_recipe_is_published(ron.recipe_id, ron.is_published)
         .await?;
     Ok(StatusCode::OK)
 }
 
+impl From<model::Group> for common::ron_api::Group {
+    fn from(group: model::Group) -> Self {
+        Self {
+            id: group.id,
+            name: group.name,
+            comment: group.comment,
+            steps: group
+                .steps
+                .into_iter()
+                .map(common::ron_api::Step::from)
+                .collect(),
+        }
+    }
+}
+
+impl From<model::Step> for common::ron_api::Step {
+    fn from(step: model::Step) -> Self {
+        Self {
+            id: step.id,
+            action: step.action,
+            ingredients: step
+                .ingredients
+                .into_iter()
+                .map(common::ron_api::Ingredient::from)
+                .collect(),
+        }
+    }
+}
+
+impl From<model::Ingredient> for common::ron_api::Ingredient {
+    fn from(ingredient: model::Ingredient) -> Self {
+        Self {
+            id: ingredient.id,
+            name: ingredient.name,
+            comment: ingredient.comment,
+            quantity_value: ingredient.quantity_value,
+            quantity_unit: ingredient.quantity_unit,
+        }
+    }
+}
+
+#[derive(Deserialize)]
+pub struct RecipeId {
+    #[serde(rename = "recipe_id")]
+    id: i64,
+}
+
+#[debug_handler]
+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,
+        connection
+            .get_groups(recipe_id.id)
+            .await?
+            .into_iter()
+            .map(common::ron_api::Group::from)
+            .collect::<Vec<_>>(),
+    ))
+}
+
+#[debug_handler]
+pub async fn add_group(
+    State(connection): State<db::Connection>,
+    Extension(user): Extension<Option<model::User>>,
+    ExtractRon(ron): ExtractRon<common::ron_api::AddRecipeGroup>,
+) -> Result<impl IntoResponse> {
+    check_user_rights_recipe(&connection, &user, ron.recipe_id).await?;
+    let group_id = connection.add_recipe_group(ron.recipe_id).await?;
+
+    Ok(ron_response(
+        StatusCode::OK,
+        common::ron_api::AddRecipeGroupResult { group_id },
+    ))
+}
+
+#[debug_handler]
+pub async fn rm_group(
+    State(connection): State<db::Connection>,
+    Extension(user): Extension<Option<model::User>>,
+    ExtractRon(ron): ExtractRon<common::ron_api::RemoveRecipeGroup>,
+) -> Result<impl IntoResponse> {
+    check_user_rights_recipe_group(&connection, &user, ron.group_id).await?;
+    connection.rm_recipe_group(ron.group_id).await?;
+    Ok(StatusCode::OK)
+}
+
+#[debug_handler]
+pub async fn set_group_name(
+    State(connection): State<db::Connection>,
+    Extension(user): Extension<Option<model::User>>,
+    ExtractRon(ron): ExtractRon<common::ron_api::SetGroupName>,
+) -> Result<impl IntoResponse> {
+    check_user_rights_recipe_group(&connection, &user, ron.group_id).await?;
+    connection.set_group_name(ron.group_id, &ron.name).await?;
+    Ok(StatusCode::OK)
+}
+
+#[debug_handler]
+pub async fn set_group_comment(
+    State(connection): State<db::Connection>,
+    Extension(user): Extension<Option<model::User>>,
+    ExtractRon(ron): ExtractRon<common::ron_api::SetGroupComment>,
+) -> Result<impl IntoResponse> {
+    check_user_rights_recipe_group(&connection, &user, ron.group_id).await?;
+    connection
+        .set_group_comment(ron.group_id, &ron.comment)
+        .await?;
+    Ok(StatusCode::OK)
+}
+
 ///// 404 /////
 #[debug_handler]
 pub async fn not_found(Extension(_user): Extension<Option<model::User>>) -> impl IntoResponse {
index a50eff1..5eeee8b 100644 (file)
@@ -22,7 +22,7 @@ use crate::{
     utils, AppState,
 };
 
-//// SIGN UP /////
+/// SIGN UP ///
 
 #[debug_handler]
 pub async fn sign_up_get(
@@ -207,7 +207,7 @@ pub async fn sign_up_validation(
     }
 }
 
-///// SIGN IN /////
+/// SIGN IN ///
 
 #[debug_handler]
 pub async fn sign_in_get(
@@ -271,7 +271,7 @@ pub async fn sign_in_post(
     }
 }
 
-///// SIGN OUT /////
+/// SIGN OUT ///
 
 #[debug_handler]
 pub async fn sign_out(
@@ -287,7 +287,7 @@ pub async fn sign_out(
     Ok((jar, Redirect::to("/")))
 }
 
-///// RESET PASSWORD /////
+/// RESET PASSWORD ///
 
 #[debug_handler]
 pub async fn ask_reset_password_get(
@@ -510,7 +510,7 @@ pub async fn reset_password_post(
     }
 }
 
-///// EDIT PROFILE /////
+/// EDIT PROFILE ///
 
 #[debug_handler]
 pub async fn edit_user_get(Extension(user): Extension<Option<model::User>>) -> Response {
@@ -614,7 +614,7 @@ pub async fn edit_user_post(
         match connection
             .update_user(
                 user.id,
-                Some(&email_trimmed),
+                Some(email_trimmed),
                 Some(&form_data.name),
                 new_password,
             )
index fdd54f3..f6134f6 100644 (file)
     <input
         id="input-title"
         type="text"
-        name="title"
         value="{{ recipe.title }}"
-        autocomplete="title"
         autofocus="true" />
 
-    <label for="input-description">Description</label>
-    <input
-        id="input-description"
-        type="text"
-        name="description"
-        value="{{ recipe.description }}"
-        autocomplete="title" />
+    <label for="text-area-description">Description</label>
+    <textarea
+        id="text-area-description">{{ recipe.description }}</textarea>
 
-    <label for="input-description">Estimated time</label>
+    <label for="input-estimated-time">Estimated time</label>
     <input
         id="input-estimated-time"
         type="number"
-        name="estimated-time"
         value="
             {% match recipe.estimated_time %}
             {% when Some with (t) %}
                 {{ t }}
             {% when None %}
                 0
-            {% endmatch %}"
-        autocomplete="title" />
+            {% endmatch %}"/>
 
     <label for="select-difficulty">Difficulty</label>
-    <select id="select-difficulty" name="difficulty">
+    <select id="select-difficulty">
         <option value="0" {%+ call is_difficulty(common::ron_api::Difficulty::Unknown) %}> - </option>
         <option value="1" {%+ call is_difficulty(common::ron_api::Difficulty::Easy) %}>Easy</option>
         <option value="2" {%+ call is_difficulty(common::ron_api::Difficulty::Medium) %}>Medium</option>
     </select>
 
     <label for="select-language">Language</label>
-    <select id="select-language" name="language">
+    <select id="select-language">
     {% for lang in languages %}
-        <option value="{{ lang.1 }}">{{ lang.0 }}</option>
+        <option value="{{ lang.1 }}"
+        {%+ if recipe.lang == lang.1 %}
+            selected
+        {% endif %}
+        >{{ lang.0 }}</option>
     {% endfor %}
     </select>
 
     <input
         id="input-is-published"
         type="checkbox"
-        name="is-published"
         {%+ if recipe.is_published %}
             checked
         {% endif %}
     <label for="input-is-published">Is published</label>
 
     <div id="groups-container">
+
+    </div>
+    <input id="button-add-group" type="button" value="Add a group"/>
+
+    <div id="hidden-templates">
+        <div class="group">
+            <label for="input-group-name">Name</label>
+            <input class="input-group-name" type="text" />
+
+            <label for="input-group-comment">Comment</label>
+            <input class="input-group-comment" type="text" />
+
+            <div class="steps"></div>
+
+            <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>
+
+            <div class="ingredients"></div>
+
+            <input class="button-add-ingedient" type="button" value="Add an ingredient"/>
+        </div>
+
+        <div class="ingredient">
+            <label for="input-ingredient-quantity">Quantity</label>
+            <input class="input-ingredient-quantity" type="number" />
+
+            <label for="input-ingredient-unit">Unity</label>
+            <input class="input-ingredient-unit" type="text" />
+
+            <label for="input-ingredient-name">Name</label>
+            <input class="input-ingredient-name" type="text" />
+        </div>
     </div>
 </div>
 
index b0209ac..639b0c6 100644 (file)
@@ -65,6 +65,57 @@ pub struct SetIsPublished {
     pub is_published: bool,
 }
 
+#[derive(Serialize, Deserialize, Clone)]
+pub struct AddRecipeGroup {
+    pub recipe_id: i64,
+}
+
+#[derive(Serialize, Deserialize, Clone)]
+pub struct AddRecipeGroupResult {
+    pub group_id: i64,
+}
+
+#[derive(Serialize, Deserialize, Clone)]
+pub struct RemoveRecipeGroup {
+    pub group_id: i64,
+}
+
+#[derive(Serialize, Deserialize, Clone)]
+pub struct SetGroupName {
+    pub group_id: i64,
+    pub name: String,
+}
+
+#[derive(Serialize, Deserialize, Clone)]
+pub struct SetGroupComment {
+    pub group_id: i64,
+    pub comment: String,
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug)]
+pub struct Group {
+    pub id: i64,
+    pub name: String,
+    pub comment: String,
+    pub steps: Vec<Step>,
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug)]
+pub struct Step {
+    pub id: i64,
+    pub action: String,
+    pub ingredients: Vec<Ingredient>,
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug)]
+pub struct Ingredient {
+    pub id: i64,
+    pub name: String,
+    pub comment: String,
+    pub quantity_value: f64,
+    pub quantity_unit: String,
+}
+
 // #[derive(Serialize, Deserialize, Clone)]
 // pub struct AddRecipeImage {
 //     pub recipe_id: i64,
index 318b8e5..8c388f9 100644 (file)
@@ -13,6 +13,10 @@ default = ["console_error_panic_hook"]
 [dependencies]
 common = { path = "../common" }
 
+ron = "0.8"
+serde = { version = "1.0", features = ["derive"] }
+thiserror = "2"
+
 wasm-bindgen = "0.2"
 wasm-bindgen-futures = "0.4"
 web-sys = { version = "0.3", features = [
@@ -26,6 +30,7 @@ web-sys = { version = "0.3", features = [
     "EventTarget",
     "HtmlLabelElement",
     "HtmlInputElement",
+    "HtmlTextAreaElement",
     "HtmlSelectElement",
 ] }
 
index 4eca53b..d5aa8ff 100644 (file)
@@ -1,24 +1,21 @@
 use gloo::{console::log, events::EventListener, net::http::Request, utils::document};
 use wasm_bindgen::prelude::*;
 use wasm_bindgen_futures::spawn_local;
-use web_sys::{Document, HtmlInputElement, HtmlSelectElement};
+use web_sys::{Element, HtmlInputElement, HtmlSelectElement, HtmlTextAreaElement};
 
-use crate::toast::{self, Level};
+use common::ron_api;
 
-async fn api_request(body: String, api_name: &str) {
-    if let Err(error) = Request::put(&format!("/ron-api/recipe/{}", api_name))
-        .header("Content-Type", "application/ron")
-        .body(body)
-        .unwrap()
+use crate::{
+    request,
+    toast::{self, Level},
+};
+
+async fn reload_recipes_list(current_recipe_id: i64) {
+    match Request::get("/fragments/recipes_list")
+        .query([("current_recipe_id", current_recipe_id.to_string())])
         .send()
         .await
     {
-        toast::show(Level::Info, &format!("Internal server error: {}", error));
-    }
-}
-
-async fn reload_recipes_list() {
-    match Request::get("/fragments/recipes_list").send().await {
         Err(error) => {
             toast::show(Level::Info, &format!("Internal server error: {}", error));
         }
@@ -35,17 +32,20 @@ pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> {
         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 input_title = document().get_element_by_id("input-title").unwrap();
-            let title = input_title.dyn_ref::<HtmlInputElement>().unwrap();
+            let title = document()
+                .get_element_by_id("input-title")
+                .unwrap()
+                .dyn_into::<HtmlInputElement>()
+                .unwrap();
             if title.value() != current_title {
                 current_title = title.value();
-                let body = common::ron_api::to_string(common::ron_api::SetRecipeTitle {
+                let body = ron_api::SetRecipeTitle {
                     recipe_id,
                     title: title.value(),
-                });
+                };
                 spawn_local(async move {
-                    api_request(body, "set_title").await;
-                    reload_recipes_list().await;
+                    let _ = request::put::<(), _>("recipe/set_title", body).await;
+                    reload_recipes_list(recipe_id).await;
                 });
             }
         });
@@ -54,23 +54,28 @@ pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> {
 
     // Description.
     {
-        let input_description = document().get_element_by_id("input-description").unwrap();
-        let mut current_description = input_description
-            .dyn_ref::<HtmlInputElement>()
+        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 on_input_description_blur =
-            EventListener::new(&input_description, "blur", move |_event| {
-                let input_description = document().get_element_by_id("input-description").unwrap();
-                let description = input_description.dyn_ref::<HtmlInputElement>().unwrap();
+            EventListener::new(&text_area_description, "blur", move |_event| {
+                let description = document()
+                    .get_element_by_id("text-area-description")
+                    .unwrap()
+                    .dyn_into::<HtmlTextAreaElement>()
+                    .unwrap();
                 if description.value() != current_description {
                     current_description = description.value();
-                    let body = common::ron_api::to_string(common::ron_api::SetRecipeDescription {
+                    let body = ron_api::SetRecipeDescription {
                         recipe_id,
                         description: description.value(),
-                    });
+                    };
                     spawn_local(async move {
-                        api_request(body, "set_description").await;
+                        let _ = request::put::<(), _>("recipe/set_description", body).await;
                     });
                 }
             });
@@ -88,30 +93,28 @@ pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> {
             .value();
         let on_input_estimated_time_blur =
             EventListener::new(&input_estimated_time, "blur", move |_event| {
-                let input_estimated_time = document()
+                let estimated_time = document()
                     .get_element_by_id("input-estimated-time")
+                    .unwrap()
+                    .dyn_into::<HtmlInputElement>()
                     .unwrap();
-                let estimated_time = input_estimated_time.dyn_ref::<HtmlInputElement>().unwrap();
                 if estimated_time.value() != current_time {
                     let time = if estimated_time.value().is_empty() {
                         None
+                    } else if let Ok(t) = estimated_time.value().parse::<u32>() {
+                        Some(t)
                     } else {
-                        if let Ok(t) = estimated_time.value().parse::<u32>() {
-                            Some(t)
-                        } else {
-                            estimated_time.set_value(&current_time);
-                            return;
-                        }
+                        estimated_time.set_value(&current_time);
+                        return;
                     };
 
                     current_time = estimated_time.value();
-                    let body =
-                        common::ron_api::to_string(common::ron_api::SetRecipeEstimatedTime {
-                            recipe_id,
-                            estimated_time: time,
-                        });
+                    let body = ron_api::SetRecipeEstimatedTime {
+                        recipe_id,
+                        estimated_time: time,
+                    };
                     spawn_local(async move {
-                        api_request(body, "set_estimated_time").await;
+                        let _ = request::put::<(), _>("recipe/set_estimated_time", body).await;
                     });
                 }
             });
@@ -127,20 +130,23 @@ pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> {
             .value();
         let on_select_difficulty_blur =
             EventListener::new(&select_difficulty, "blur", move |_event| {
-                let select_difficulty = document().get_element_by_id("select-difficulty").unwrap();
-                let difficulty = select_difficulty.dyn_ref::<HtmlSelectElement>().unwrap();
+                let difficulty = document()
+                    .get_element_by_id("select-difficulty")
+                    .unwrap()
+                    .dyn_into::<HtmlSelectElement>()
+                    .unwrap();
                 if difficulty.value() != current_difficulty {
                     current_difficulty = difficulty.value();
 
-                    let body = common::ron_api::to_string(common::ron_api::SetRecipeDifficulty {
+                    let body = ron_api::SetRecipeDifficulty {
                         recipe_id,
-                        difficulty: common::ron_api::Difficulty::try_from(
+                        difficulty: ron_api::Difficulty::try_from(
                             current_difficulty.parse::<u32>().unwrap(),
                         )
                         .unwrap(),
-                    });
+                    };
                     spawn_local(async move {
-                        api_request(body, "set_difficulty").await;
+                        let _ = request::put::<(), _>("recipe/set_difficulty", body).await;
                     });
                 }
             });
@@ -155,17 +161,20 @@ pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> {
             .unwrap()
             .value();
         let on_select_language_blur = EventListener::new(&select_language, "blur", move |_event| {
-            let select_language = document().get_element_by_id("select-language").unwrap();
-            let difficulty = select_language.dyn_ref::<HtmlSelectElement>().unwrap();
-            if difficulty.value() != current_language {
-                current_language = difficulty.value();
+            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 = common::ron_api::to_string(common::ron_api::SetRecipeLanguage {
+                let body = ron_api::SetRecipeLanguage {
                     recipe_id,
-                    lang: difficulty.value(),
-                });
+                    lang: language.value(),
+                };
                 spawn_local(async move {
-                    api_request(body, "set_language").await;
+                    let _ = request::put::<(), _>("recipe/set_language", body).await;
                 });
             }
         });
@@ -177,22 +186,147 @@ pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> {
         let input_is_published = document().get_element_by_id("input-is-published").unwrap();
         let on_input_is_published_blur =
             EventListener::new(&input_is_published, "input", move |_event| {
-                let input_is_published =
-                    document().get_element_by_id("input-is-published").unwrap();
-                let is_published = input_is_published.dyn_ref::<HtmlInputElement>().unwrap();
+                let is_published = document()
+                    .get_element_by_id("input-is-published")
+                    .unwrap()
+                    .dyn_into::<HtmlInputElement>()
+                    .unwrap();
 
-                let body = common::ron_api::to_string(common::ron_api::SetIsPublished {
+                let body = ron_api::SetIsPublished {
                     recipe_id,
                     is_published: is_published.checked(),
-                });
+                };
                 spawn_local(async move {
-                    api_request(body, "set_is_published").await;
-                    reload_recipes_list().await;
+                    let _ = request::put::<(), _>("recipe/set_is_published", body).await;
+                    reload_recipes_list(recipe_id).await;
                 });
             });
         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))
+            .unwrap();
+
+        let groups_container = document().get_element_by_id("groups-container").unwrap();
+        groups_container.append_child(&group_html).unwrap();
+        group_html
+    }
+
+    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))
+            .unwrap();
+
+        group_element.append_child(&step_html).unwrap();
+        step_html
+    }
+
+    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))
+            .unwrap();
+
+        step_element.append_child(&ingredient_html).unwrap();
+        ingredient_html
+    }
+
+    // Load initial groups, steps and ingredients.
+    {
+        spawn_local(async move {
+            let groups: Vec<common::ron_api::Group> =
+                request::get("recipe/get_groups", [("recipe_id", &recipe_id.to_string())])
+                    .await
+                    .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))
+
+                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);
+
+                    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);
+                    }
+                }
+            }
+
+            // log!(format!("{:?}", groups));
+        });
+    }
+
+    // Add a new group.
+    {
+        let button_add_group = document().get_element_by_id("button-add-group").unwrap();
+        let on_click_add_group = EventListener::new(&button_add_group, "click", move |_event| {
+            log!("Click!");
+            let body = ron_api::AddRecipeGroup { recipe_id };
+
+            spawn_local(async move {
+                let response: ron_api::AddRecipeGroupResult =
+                    request::post("recipe/add_group", body).await.unwrap();
+                create_group_element(response.group_id);
+                // group_html.set_attribute("id", "test").unwrap();
+            });
+        });
+        on_click_add_group.forget();
+    }
+
     Ok(())
 }
 
index c8e6ce9..073eaef 100644 (file)
@@ -1,10 +1,10 @@
 mod handles;
+mod request;
 mod toast;
 mod utils;
 
-use gloo::{console::log, events::EventListener, utils::window};
+use gloo::utils::window;
 use wasm_bindgen::prelude::*;
-use web_sys::console;
 
 // #[wasm_bindgen]
 // extern "C" {
@@ -27,17 +27,14 @@ pub fn main() -> Result<(), JsValue> {
     let location = window().location().pathname()?;
     let path: Vec<&str> = location.split('/').skip(1).collect();
 
-    match path[..] {
-        ["recipe", "edit", id] => {
-            let id = id.parse::<i64>().unwrap(); // TODO: remove unwrap.
-            handles::recipe_edit(id)?;
-        }
+    if let ["recipe", "edit", id] = path[..] {
+        let id = id.parse::<i64>().unwrap(); // TODO: remove unwrap.
+        handles::recipe_edit(id)?;
 
         // Disable: user editing data are now submitted as classic form data.
         // ["user", "edit"] => {
         //     handles::user_edit(document)?;
         // }
-        _ => (),
     }
 
     Ok(())
diff --git a/frontend/src/request.rs b/frontend/src/request.rs
new file mode 100644 (file)
index 0000000..ab4a398
--- /dev/null
@@ -0,0 +1,132 @@
+use gloo::net::http::{Request, RequestBuilder};
+use serde::{de::DeserializeOwned, Serialize};
+use thiserror::Error;
+
+use common::ron_api;
+
+use crate::toast::{self, Level};
+
+#[derive(Error, Debug)]
+pub enum Error {
+    #[error("Gloo error: {0}")]
+    Gloo(#[from] gloo::net::Error),
+
+    #[error("RON Spanned error: {0}")]
+    Ron(#[from] ron::error::SpannedError),
+
+    #[error("HTTP error: {0}")]
+    Http(String),
+
+    #[error("Unknown error: {0}")]
+    Other(String),
+}
+
+type Result<T> = std::result::Result<T, Error>;
+
+const CONTENT_TYPE: &str = "Content-Type";
+const CONTENT_TYPE_RON: &str = "application/ron";
+
+async fn req_with_body<T, U>(
+    api_name: &str,
+    body: U,
+    method_fn: fn(&str) -> RequestBuilder,
+) -> Result<T>
+where
+    T: DeserializeOwned,
+    U: Serialize,
+{
+    let url = format!("/ron-api/{}", api_name);
+    let request_builder = method_fn(&url).header(CONTENT_TYPE, CONTENT_TYPE_RON);
+    send_req(request_builder.body(ron_api::to_string(body))?).await
+}
+
+async fn req_with_params<'a, T, U, V>(
+    api_name: &str,
+    params: U,
+    method_fn: fn(&str) -> RequestBuilder,
+) -> Result<T>
+where
+    T: DeserializeOwned,
+    U: IntoIterator<Item = (&'a str, V)>,
+    V: AsRef<str>,
+{
+    let url = format!("/ron-api/{}", api_name);
+    let request_builder = method_fn(&url)
+        .header(CONTENT_TYPE, CONTENT_TYPE_RON)
+        .query(params);
+    send_req(request_builder.build()?).await
+}
+
+async fn send_req<T>(request: Request) -> Result<T>
+where
+    T: DeserializeOwned,
+{
+    match request.send().await {
+        Err(error) => {
+            toast::show(Level::Info, &format!("Internal server error: {}", error));
+            Err(Error::Gloo(error))
+        }
+        Ok(response) => {
+            if !response.ok() {
+                toast::show(
+                    Level::Info,
+                    &format!("HTTP error: {}", response.status_text()),
+                );
+                Err(Error::Http(response.status_text()))
+            } else {
+                // Ok(())
+                Ok(ron::de::from_bytes::<T>(&response.binary().await?)?)
+            }
+        }
+    }
+}
+
+pub async fn put<T, U>(api_name: &str, body: U) -> Result<T>
+where
+    T: DeserializeOwned,
+    U: Serialize,
+{
+    req_with_body(api_name, body, Request::put).await
+}
+
+pub async fn post<T, U>(api_name: &str, body: U) -> Result<T>
+where
+    T: DeserializeOwned,
+    U: Serialize,
+{
+    req_with_body(api_name, body, Request::post).await
+}
+
+pub async fn delete<T, U>(api_name: &str, body: U) -> Result<T>
+where
+    T: DeserializeOwned,
+    U: Serialize,
+{
+    req_with_body(api_name, body, Request::delete).await
+}
+
+pub async fn get<'a, T, U, V>(api_name: &str, params: U) -> Result<T>
+where
+    T: DeserializeOwned,
+    U: IntoIterator<Item = (&'a str, V)>,
+    V: AsRef<str>,
+{
+    req_with_params(api_name, params, Request::get).await
+}
+
+// pub async fn api_request_get<T>(api_name: &str, params: QueryParams) -> Result<T, String>
+// where
+//     T: DeserializeOwned,
+// {
+//     match Request::get(&format!("/ron-api/recipe/{}?{}", api_name, params))
+//         .header("Content-Type", "application/ron")
+//         .send()
+//         .await
+//     {
+//         Err(error) => {
+//             toast::show(Level::Info, &format!("Internal server error: {}", error));
+//             Err(error.to_string())
+//         }
+//         Ok(response) => Ok(ron::de::from_bytes::<T>(&response.binary().await.unwrap()).unwrap()),
+//     }
+// }
index a2791dc..1c30a71 100644 (file)
@@ -1,5 +1,4 @@
-use gloo::{console::log, timers::callback::Timeout, utils::document};
-use web_sys::{console, Document, HtmlInputElement};
+use gloo::{timers::callback::Timeout, utils::document};
 
 pub enum Level {
     Success,