Toast message when scheduling a recipe
authorGreg Burri <greg.burri@gmail.com>
Wed, 5 Feb 2025 14:44:48 +0000 (15:44 +0100)
committerGreg Burri <greg.burri@gmail.com>
Wed, 5 Feb 2025 14:44:48 +0000 (15:44 +0100)
backend/src/data/db/recipe.rs
backend/src/data/db/user.rs
backend/src/ron_utils.rs
backend/src/services/ron.rs
backend/src/translation.rs
backend/templates/recipe_view.html
backend/translation.ron
common/src/ron_api.rs
common/src/utils.rs
frontend/src/recipe_view.rs
frontend/src/utils.rs

index 4db47da..0776a84 100644 (file)
@@ -1,9 +1,15 @@
-use chrono::{prelude::*, Days};
+use chrono::prelude::*;
 use common::ron_api::Difficulty;
 use itertools::Itertools;
+use sqlx::Error;
 
 use super::{Connection, DBError, Result};
-use crate::{data::model, user_authentication};
+use crate::data::model;
+
+pub enum AddScheduledRecipeResult {
+    Ok,
+    RecipeAlreadyScheduledAtThisDate,
+}
 
 impl Connection {
     /// Returns all the recipe titles where recipe is written in the given language.
@@ -758,8 +764,8 @@ VALUES ($1, $2)
         recipe_id: i64,
         date: NaiveDate,
         servings: u32,
-    ) -> Result<()> {
-        sqlx::query(
+    ) -> Result<AddScheduledRecipeResult> {
+        match sqlx::query(
             r#"
 INSERT INTO [RecipeScheduled] (user_id, recipe_id, date, servings)
 VALUES ($1, $2, $3, $4)
@@ -771,8 +777,15 @@ VALUES ($1, $2, $3, $4)
         .bind(servings)
         .execute(&self.pool)
         .await
-        .map(|_| ())
-        .map_err(DBError::from)
+        {
+            Err(Error::Database(error))
+                if error.code() == Some(std::borrow::Cow::Borrowed("2067"))
+                && error.message() == "UNIQUE constraint failed: RecipeScheduled.user_id, RecipeScheduled.recipe_id, RecipeScheduled.date" =>
+            {
+                Ok(AddScheduledRecipeResult::RecipeAlreadyScheduledAtThisDate)
+            }
+            _ => Ok(AddScheduledRecipeResult::Ok),
+        }
     }
 
     pub async fn rm_scheduled_recipe(
@@ -783,9 +796,9 @@ VALUES ($1, $2, $3, $4)
     ) -> Result<()> {
         sqlx::query(
             r#"
-DELETE FROM [RecipeScheduled]
-WHERE [user_id] = $1 AND [recipe_id] = $2 AND [date] = $3
-            "#,
+    DELETE FROM [RecipeScheduled]
+    WHERE [user_id] = $1 AND [recipe_id] = $2 AND [date] = $3
+                "#,
         )
         .bind(user_id)
         .bind(recipe_id)
@@ -823,6 +836,7 @@ ORDER BY [date]
 #[cfg(test)]
 mod tests {
     use super::*;
+    use chrono::Days;
 
     #[tokio::test]
     async fn create_a_new_recipe_then_update_its_title() -> Result<()> {
@@ -1007,6 +1021,14 @@ VALUES
             ]
         );
 
+        // Recipe scheduled at the same date is forbidden.
+        let Ok(AddScheduledRecipeResult::RecipeAlreadyScheduledAtThisDate) = connection
+            .add_scheduled_recipe(user_id, recipe_id_1, today, 4)
+            .await
+        else {
+            panic!("DBError::RecipeAlreadyScheduledAtThisDate must be returned");
+        };
+
         connection
             .rm_scheduled_recipe(user_id, recipe_id_1, today)
             .await?;
index f057d8c..cdfa76f 100644 (file)
@@ -945,6 +945,7 @@ VALUES
                 1,
                 Some("muaddib@fremen.com"),
                 Some("muaddib"),
+                None,
                 Some("Chani"),
             )
             .await?
index 4aa7d22..98dd03e 100644 (file)
@@ -42,6 +42,13 @@ pub fn ron_error(status: StatusCode, message: &str) -> impl IntoResponse {
     )
 }
 
+pub fn ron_response_ok<T>(ron: T) -> impl IntoResponse
+where
+    T: Serialize,
+{
+    ron_response(StatusCode::OK, ron)
+}
+
 pub fn ron_response<T>(status: StatusCode, ron: T) -> impl IntoResponse
 where
     T: Serialize,
index cfc4650..192c65c 100644 (file)
@@ -2,7 +2,7 @@ use axum::{
     debug_handler,
     extract::{Extension, Query, State},
     http::{HeaderMap, StatusCode},
-    response::{ErrorResponse, IntoResponse, Result},
+    response::{ErrorResponse, IntoResponse, Response, Result},
 };
 use axum_extra::extract::cookie::{Cookie, CookieJar};
 use chrono::NaiveDate;
@@ -11,10 +11,10 @@ use serde::Deserialize;
 
 use crate::{
     consts,
-    data::db,
+    data::{self, db},
     model,
     ron_extractor::ExtractRon,
-    ron_utils::{ron_error, ron_response},
+    ron_utils::{ron_error, ron_response_ok},
 };
 
 const NOT_AUTHORIZED_MESSAGE: &str = "Action not authorized";
@@ -257,13 +257,10 @@ pub async fn get_tags(
     State(connection): State<db::Connection>,
     recipe_id: Query<RecipeId>,
 ) -> Result<impl IntoResponse> {
-    Ok(ron_response(
-        StatusCode::OK,
-        common::ron_api::Tags {
-            recipe_id: recipe_id.id,
-            tags: connection.get_recipes_tags(recipe_id.id).await?,
-        },
-    ))
+    Ok(ron_response_ok(common::ron_api::Tags {
+        recipe_id: recipe_id.id,
+        tags: connection.get_recipes_tags(recipe_id.id).await?,
+    }))
 }
 
 #[debug_handler]
@@ -401,8 +398,7 @@ pub async fn get_groups(
     recipe_id: Query<RecipeId>,
 ) -> Result<impl IntoResponse> {
     // Here we don't check user rights on purpose.
-    Ok(ron_response(
-        StatusCode::OK,
+    Ok(ron_response_ok(
         connection
             .get_groups(recipe_id.id)
             .await?
@@ -421,7 +417,7 @@ pub async fn add_group(
     check_user_rights_recipe(&connection, &user, ron.id).await?;
     let id = connection.add_recipe_group(ron.id).await?;
 
-    Ok(ron_response(StatusCode::OK, common::ron_api::Id { id }))
+    Ok(ron_response_ok(common::ron_api::Id { id }))
 }
 
 #[debug_handler]
@@ -479,7 +475,7 @@ pub async fn add_step(
     check_user_rights_recipe_group(&connection, &user, ron.id).await?;
     let id = connection.add_recipe_step(ron.id).await?;
 
-    Ok(ron_response(StatusCode::OK, common::ron_api::Id { id }))
+    Ok(ron_response_ok(common::ron_api::Id { id }))
 }
 
 #[debug_handler]
@@ -523,7 +519,7 @@ pub async fn add_ingredient(
 ) -> Result<impl IntoResponse> {
     check_user_rights_recipe_step(&connection, &user, ron.id).await?;
     let id = connection.add_recipe_ingredient(ron.id).await?;
-    Ok(ron_response(StatusCode::OK, common::ron_api::Id { id }))
+    Ok(ron_response_ok(common::ron_api::Id { id }))
 }
 
 #[debug_handler]
@@ -615,14 +611,11 @@ pub async fn get_scheduled_recipes(
     date_range: Query<DateRange>,
 ) -> Result<impl IntoResponse> {
     if let Some(user) = user {
-        Ok(ron_response(
-            StatusCode::OK,
-            common::ron_api::ScheduledRecipes {
-                recipes: connection
-                    .get_scheduled_recipes(user.id, date_range.start_date, date_range.end_date)
-                    .await?,
-            },
-        ))
+        Ok(ron_response_ok(common::ron_api::ScheduledRecipes {
+            recipes: connection
+                .get_scheduled_recipes(user.id, date_range.start_date, date_range.end_date)
+                .await?,
+        }))
     } else {
         Err(ErrorResponse::from(ron_error(
             StatusCode::UNAUTHORIZED,
@@ -631,19 +624,35 @@ pub async fn get_scheduled_recipes(
     }
 }
 
+impl From<data::db::recipe::AddScheduledRecipeResult> for common::ron_api::ScheduleRecipeResult {
+    fn from(db_res: data::db::recipe::AddScheduledRecipeResult) -> Self {
+        match db_res {
+            db::recipe::AddScheduledRecipeResult::Ok => Self::Ok,
+            db::recipe::AddScheduledRecipeResult::RecipeAlreadyScheduledAtThisDate => {
+                Self::RecipeAlreadyScheduledAtThisDate
+            }
+        }
+    }
+}
+
 #[debug_handler]
 pub async fn schedule_recipe(
     State(connection): State<db::Connection>,
     Extension(user): Extension<Option<model::User>>,
     ExtractRon(ron): ExtractRon<common::ron_api::ScheduleRecipe>,
-) -> Result<impl IntoResponse> {
+) -> Result<Response> {
     check_user_rights_recipe(&connection, &user, ron.recipe_id).await?;
     if let Some(user) = user {
         connection
             .add_scheduled_recipe(user.id, ron.recipe_id, ron.date, ron.servings)
-            .await?;
+            .await
+            .map(|res| {
+                ron_response_ok(common::ron_api::ScheduleRecipeResult::from(res)).into_response()
+            })
+            .map_err(ErrorResponse::from)
+    } else {
+        Ok(StatusCode::OK.into_response())
     }
-    Ok(StatusCode::OK)
 }
 
 #[debug_handler]
index 189cf8c..472e83a 100644 (file)
@@ -143,6 +143,7 @@ pub enum Sentence {
     CalendarDecember,
     CalendarAddToPlanner,
     CalendarAddToPlannerSuccess,
+    CalendarAddToPlannerAlreadyExists,
     CalendarDateFormat, // See https://docs.rs/chrono/latest/chrono/format/strftime/index.html.
 }
 
index f32c8a3..1d899b9 100644 (file)
@@ -94,6 +94,7 @@
         {% endif %}
 
         <span class="calendar-add-to-planner-success">{{ tr.t(Sentence::CalendarAddToPlannerSuccess) }}</span>
+        <span class="calendar-add-to-planner-already-exists">{{ tr.t(Sentence::CalendarAddToPlannerAlreadyExists) }}</span>
         <span class="calendar-date-format">{{ tr.t(Sentence::CalendarDateFormat) }}</span>
     </div>
 </div>
index 6b4d939..a24c95b 100644 (file)
             (CalendarNovember, "November"),
             (CalendarDecember, "December"),
             (CalendarAddToPlanner, "Add to planner"),
-            (CalendarAddToPlannerSuccess, "Recipe {} has been scheduled for {}"),
-            (CalendarDateFormat, "%A, %-d %B, %C%y"),
+            (CalendarAddToPlannerSuccess, "Recipe {title} has been scheduled for {date}"),
+            (CalendarAddToPlannerAlreadyExists, "Recipe {title} has already been scheduled for {date}"),
+            (CalendarDateFormat, "%A, %-d %B, %C%y"), // See https://docs.rs/chrono/latest/chrono/format/strftime/index.html.
         ]
     ),
     (
             (CalendarNovember, "Novembre"),
             (CalendarDecember, "Décembre"),
             (CalendarAddToPlanner, "Ajouter au planificateur"),
-            (CalendarAddToPlannerSuccess, "La recette {} a été agendée pour le {}"),
-            (CalendarDateFormat, "%A %-d %B %C%y"),
+            (CalendarAddToPlannerSuccess, "La recette {title} a été agendée pour le {date}"),
+            (CalendarAddToPlannerAlreadyExists, "La recette {title} a été déjà été agendée pour le {date}"),
+            (CalendarDateFormat, "%A %-d %B %C%y"), // See https://docs.rs/chrono/latest/chrono/format/strftime/index.html.
         ]
     )
 ]
\ No newline at end of file
index 189cabd..6b610ec 100644 (file)
@@ -183,6 +183,12 @@ pub struct ScheduleRecipe {
     pub servings: u32,
 }
 
+#[derive(Serialize, Deserialize, Clone, Debug)]
+pub enum ScheduleRecipeResult {
+    Ok,
+    RecipeAlreadyScheduledAtThisDate,
+}
+
 #[derive(Serialize, Deserialize, Clone, Debug)]
 pub struct ScheduledRecipe {
     pub recipe_id: i64,
index 2efe599..344882b 100644 (file)
@@ -35,6 +35,18 @@ pub fn substitute(str: &str, pattern: &str, replacements: &[&str]) -> String {
     result
 }
 
+/// Example: substitute_with_names("{hello}, {world}!", &["{hello}", "{world"], &["Hello", "World"])
+pub fn substitute_with_names(str: &str, names: &[&str], replacements: &[&str]) -> String {
+    let mut result = str.to_string();
+    for (i, name) in names.iter().enumerate() {
+        if i >= replacements.len() {
+            break;
+        }
+        result = result.replace(name, replacements[i]);
+    }
+    result
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -52,4 +64,36 @@ mod tests {
         assert_eq!(substitute("{}{}{}", "{}", &["a", "bc", "def"]), "abcdef");
         assert_eq!(substitute("{}{}{}", "{}", &["a"]), "a");
     }
+
+    #[test]
+    fn test_substitute_with_names() {
+        assert_eq!(
+            substitute_with_names("{hello}, {world}!", &["{hello}"], &["Hello", "World"]),
+            "Hello, {world}!"
+        );
+
+        assert_eq!(
+            substitute_with_names("{hello}, {world}!", &[], &["Hello", "World"]),
+            "{hello}, {world}!"
+        );
+
+        assert_eq!(
+            substitute_with_names("{hello}, {world}!", &["{hello}", "{world}"], &["Hello"]),
+            "Hello, {world}!"
+        );
+
+        assert_eq!(
+            substitute_with_names("{hello}, {world}!", &["{hello}", "{world}"], &[]),
+            "{hello}, {world}!"
+        );
+
+        assert_eq!(
+            substitute_with_names(
+                "{hello}, {world}!",
+                &["{hello}", "{world}"],
+                &["Hello", "World"]
+            ),
+            "Hello, World!"
+        );
+    }
 }
index 26b9699..de99be8 100644 (file)
@@ -1,24 +1,16 @@
-use std::{cell::RefCell, future::Future, rc::Rc, str::FromStr};
+use std::str::FromStr;
 
 use chrono::Locale;
-use common::{ron_api, utils::substitute};
-use gloo::{
-    console::log,
-    events::EventListener,
-    net::http::Request,
-    utils::{document, window},
-};
+use common::{ron_api, utils::substitute_with_names};
+use gloo::events::EventListener;
 use wasm_bindgen::prelude::*;
 use wasm_bindgen_futures::spawn_local;
-use web_sys::{
-    DragEvent, Element, HtmlDivElement, HtmlInputElement, HtmlSelectElement, HtmlTextAreaElement,
-    KeyboardEvent,
-};
+use web_sys::{Element, HtmlInputElement};
 
 use crate::{
     calendar, modal_dialog, request,
     toast::{self, Level},
-    utils::{by_id, selector, selector_and_clone, SelectorExt},
+    utils::{get_locale, selector, SelectorExt},
 };
 
 pub fn setup_page(recipe_id: i64) -> Result<(), JsValue> {
@@ -38,7 +30,7 @@ pub fn setup_page(recipe_id: i64) -> Result<(), JsValue> {
             )
             .await
             {
-                if request::post::<(), _>(
+                if let Ok(result) = request::post::<ron_api::ScheduleRecipeResult, _>(
                     "calendar/schedule_recipe",
                     ron_api::ScheduleRecipe {
                         recipe_id,
@@ -47,31 +39,37 @@ pub fn setup_page(recipe_id: i64) -> Result<(), JsValue> {
                     },
                 )
                 .await
-                .is_ok()
                 {
                     toast::show_element_and_initialize(
-                        Level::Success,
-                        "#hidden-templates .calendar-add-to-planner-success",
+                        match result {
+                            ron_api::ScheduleRecipeResult::Ok => Level::Success,
+                            ron_api::ScheduleRecipeResult::RecipeAlreadyScheduledAtThisDate => {
+                                Level::Warning
+                            }
+                        },
+                        match result {
+                            ron_api::ScheduleRecipeResult::Ok => {
+                                "#hidden-templates .calendar-add-to-planner-success"
+                            }
+                            ron_api::ScheduleRecipeResult::RecipeAlreadyScheduledAtThisDate => {
+                                "#hidden-templates .calendar-add-to-planner-already-exists"
+                            }
+                        },
                         |element| {
                             let title =
                                 selector::<Element>("#recipe-view .recipe-title").inner_html();
                             let date_format =
                                 selector::<Element>("#hidden-templates .calendar-date-format")
                                     .inner_html();
-                            let locale = {
-                                let lang_and_territory = selector::<Element>("html")
-                                    .get_attribute("lang")
-                                    .unwrap()
-                                    .replace("-", "_");
-                                Locale::from_str(&lang_and_territory).unwrap_or_default()
-                            };
 
-                            element.set_inner_html(&substitute(
+                            element.set_inner_html(&substitute_with_names(
                                 &element.inner_html(),
-                                "{}",
+                                &["{title}", "{date}"],
                                 &[
                                     &title,
-                                    &date.format_localized(&date_format, locale).to_string(),
+                                    &date
+                                        .format_localized(&date_format, get_locale())
+                                        .to_string(),
                                 ],
                             ));
                         },
index 6e400e0..4732ad3 100644 (file)
@@ -1,3 +1,6 @@
+use std::str::FromStr;
+
+use chrono::Locale;
 use gloo::utils::document;
 use wasm_bindgen::prelude::*;
 use web_sys::Element;
@@ -86,3 +89,11 @@ where
         .dyn_into::<T>()
         .unwrap()
 }
+
+pub fn get_locale() -> Locale {
+    let lang_and_territory = selector::<Element>("html")
+        .get_attribute("lang")
+        .unwrap()
+        .replace("-", "_");
+    Locale::from_str(&lang_and_territory).unwrap_or_default()
+}