Toast message when scheduling a recipe

This commit is contained in:
Greg Burri 2025-02-05 15:44:48 +01:00
parent fbef990022
commit ccb1248da3
11 changed files with 168 additions and 66 deletions

View file

@ -1,9 +1,15 @@
use chrono::{prelude::*, Days}; use chrono::prelude::*;
use common::ron_api::Difficulty; use common::ron_api::Difficulty;
use itertools::Itertools; use itertools::Itertools;
use sqlx::Error;
use super::{Connection, DBError, Result}; use super::{Connection, DBError, Result};
use crate::{data::model, user_authentication}; use crate::data::model;
pub enum AddScheduledRecipeResult {
Ok,
RecipeAlreadyScheduledAtThisDate,
}
impl Connection { impl Connection {
/// Returns all the recipe titles where recipe is written in the given language. /// Returns all the recipe titles where recipe is written in the given language.
@ -758,8 +764,8 @@ VALUES ($1, $2)
recipe_id: i64, recipe_id: i64,
date: NaiveDate, date: NaiveDate,
servings: u32, servings: u32,
) -> Result<()> { ) -> Result<AddScheduledRecipeResult> {
sqlx::query( match sqlx::query(
r#" r#"
INSERT INTO [RecipeScheduled] (user_id, recipe_id, date, servings) INSERT INTO [RecipeScheduled] (user_id, recipe_id, date, servings)
VALUES ($1, $2, $3, $4) VALUES ($1, $2, $3, $4)
@ -771,8 +777,15 @@ VALUES ($1, $2, $3, $4)
.bind(servings) .bind(servings)
.execute(&self.pool) .execute(&self.pool)
.await .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( pub async fn rm_scheduled_recipe(
@ -823,6 +836,7 @@ ORDER BY [date]
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use chrono::Days;
#[tokio::test] #[tokio::test]
async fn create_a_new_recipe_then_update_its_title() -> Result<()> { 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 connection
.rm_scheduled_recipe(user_id, recipe_id_1, today) .rm_scheduled_recipe(user_id, recipe_id_1, today)
.await?; .await?;

View file

@ -945,6 +945,7 @@ VALUES
1, 1,
Some("muaddib@fremen.com"), Some("muaddib@fremen.com"),
Some("muaddib"), Some("muaddib"),
None,
Some("Chani"), Some("Chani"),
) )
.await? .await?

View 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 pub fn ron_response<T>(status: StatusCode, ron: T) -> impl IntoResponse
where where
T: Serialize, T: Serialize,

View file

@ -2,7 +2,7 @@ use axum::{
debug_handler, debug_handler,
extract::{Extension, Query, State}, extract::{Extension, Query, State},
http::{HeaderMap, StatusCode}, http::{HeaderMap, StatusCode},
response::{ErrorResponse, IntoResponse, Result}, response::{ErrorResponse, IntoResponse, Response, Result},
}; };
use axum_extra::extract::cookie::{Cookie, CookieJar}; use axum_extra::extract::cookie::{Cookie, CookieJar};
use chrono::NaiveDate; use chrono::NaiveDate;
@ -11,10 +11,10 @@ use serde::Deserialize;
use crate::{ use crate::{
consts, consts,
data::db, data::{self, db},
model, model,
ron_extractor::ExtractRon, ron_extractor::ExtractRon,
ron_utils::{ron_error, ron_response}, ron_utils::{ron_error, ron_response_ok},
}; };
const NOT_AUTHORIZED_MESSAGE: &str = "Action not authorized"; const NOT_AUTHORIZED_MESSAGE: &str = "Action not authorized";
@ -257,13 +257,10 @@ pub async fn get_tags(
State(connection): State<db::Connection>, State(connection): State<db::Connection>,
recipe_id: Query<RecipeId>, recipe_id: Query<RecipeId>,
) -> Result<impl IntoResponse> { ) -> Result<impl IntoResponse> {
Ok(ron_response( Ok(ron_response_ok(common::ron_api::Tags {
StatusCode::OK,
common::ron_api::Tags {
recipe_id: recipe_id.id, recipe_id: recipe_id.id,
tags: connection.get_recipes_tags(recipe_id.id).await?, tags: connection.get_recipes_tags(recipe_id.id).await?,
}, }))
))
} }
#[debug_handler] #[debug_handler]
@ -401,8 +398,7 @@ pub async fn get_groups(
recipe_id: Query<RecipeId>, recipe_id: Query<RecipeId>,
) -> Result<impl IntoResponse> { ) -> Result<impl IntoResponse> {
// Here we don't check user rights on purpose. // Here we don't check user rights on purpose.
Ok(ron_response( Ok(ron_response_ok(
StatusCode::OK,
connection connection
.get_groups(recipe_id.id) .get_groups(recipe_id.id)
.await? .await?
@ -421,7 +417,7 @@ pub async fn add_group(
check_user_rights_recipe(&connection, &user, ron.id).await?; check_user_rights_recipe(&connection, &user, ron.id).await?;
let id = connection.add_recipe_group(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] #[debug_handler]
@ -479,7 +475,7 @@ pub async fn add_step(
check_user_rights_recipe_group(&connection, &user, ron.id).await?; check_user_rights_recipe_group(&connection, &user, ron.id).await?;
let id = connection.add_recipe_step(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] #[debug_handler]
@ -523,7 +519,7 @@ pub async fn add_ingredient(
) -> Result<impl IntoResponse> { ) -> Result<impl IntoResponse> {
check_user_rights_recipe_step(&connection, &user, ron.id).await?; check_user_rights_recipe_step(&connection, &user, ron.id).await?;
let id = connection.add_recipe_ingredient(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] #[debug_handler]
@ -615,14 +611,11 @@ pub async fn get_scheduled_recipes(
date_range: Query<DateRange>, date_range: Query<DateRange>,
) -> Result<impl IntoResponse> { ) -> Result<impl IntoResponse> {
if let Some(user) = user { if let Some(user) = user {
Ok(ron_response( Ok(ron_response_ok(common::ron_api::ScheduledRecipes {
StatusCode::OK,
common::ron_api::ScheduledRecipes {
recipes: connection recipes: connection
.get_scheduled_recipes(user.id, date_range.start_date, date_range.end_date) .get_scheduled_recipes(user.id, date_range.start_date, date_range.end_date)
.await?, .await?,
}, }))
))
} else { } else {
Err(ErrorResponse::from(ron_error( Err(ErrorResponse::from(ron_error(
StatusCode::UNAUTHORIZED, 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] #[debug_handler]
pub async fn schedule_recipe( pub async fn schedule_recipe(
State(connection): State<db::Connection>, State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>, Extension(user): Extension<Option<model::User>>,
ExtractRon(ron): ExtractRon<common::ron_api::ScheduleRecipe>, ExtractRon(ron): ExtractRon<common::ron_api::ScheduleRecipe>,
) -> Result<impl IntoResponse> { ) -> Result<Response> {
check_user_rights_recipe(&connection, &user, ron.recipe_id).await?; check_user_rights_recipe(&connection, &user, ron.recipe_id).await?;
if let Some(user) = user { if let Some(user) = user {
connection connection
.add_scheduled_recipe(user.id, ron.recipe_id, ron.date, ron.servings) .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] #[debug_handler]

View file

@ -143,6 +143,7 @@ pub enum Sentence {
CalendarDecember, CalendarDecember,
CalendarAddToPlanner, CalendarAddToPlanner,
CalendarAddToPlannerSuccess, CalendarAddToPlannerSuccess,
CalendarAddToPlannerAlreadyExists,
CalendarDateFormat, // See https://docs.rs/chrono/latest/chrono/format/strftime/index.html. CalendarDateFormat, // See https://docs.rs/chrono/latest/chrono/format/strftime/index.html.
} }

View file

@ -94,6 +94,7 @@
{% endif %} {% endif %}
<span class="calendar-add-to-planner-success">{{ tr.t(Sentence::CalendarAddToPlannerSuccess) }}</span> <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> <span class="calendar-date-format">{{ tr.t(Sentence::CalendarDateFormat) }}</span>
</div> </div>
</div> </div>

View file

@ -126,8 +126,9 @@
(CalendarNovember, "November"), (CalendarNovember, "November"),
(CalendarDecember, "December"), (CalendarDecember, "December"),
(CalendarAddToPlanner, "Add to planner"), (CalendarAddToPlanner, "Add to planner"),
(CalendarAddToPlannerSuccess, "Recipe {} has been scheduled for {}"), (CalendarAddToPlannerSuccess, "Recipe {title} has been scheduled for {date}"),
(CalendarDateFormat, "%A, %-d %B, %C%y"), (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.
] ]
), ),
( (
@ -257,8 +258,9 @@
(CalendarNovember, "Novembre"), (CalendarNovember, "Novembre"),
(CalendarDecember, "Décembre"), (CalendarDecember, "Décembre"),
(CalendarAddToPlanner, "Ajouter au planificateur"), (CalendarAddToPlanner, "Ajouter au planificateur"),
(CalendarAddToPlannerSuccess, "La recette {} a été agendée pour le {}"), (CalendarAddToPlannerSuccess, "La recette {title} a été agendée pour le {date}"),
(CalendarDateFormat, "%A %-d %B %C%y"), (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.
] ]
) )
] ]

View file

@ -183,6 +183,12 @@ pub struct ScheduleRecipe {
pub servings: u32, pub servings: u32,
} }
#[derive(Serialize, Deserialize, Clone, Debug)]
pub enum ScheduleRecipeResult {
Ok,
RecipeAlreadyScheduledAtThisDate,
}
#[derive(Serialize, Deserialize, Clone, Debug)] #[derive(Serialize, Deserialize, Clone, Debug)]
pub struct ScheduledRecipe { pub struct ScheduledRecipe {
pub recipe_id: i64, pub recipe_id: i64,

View file

@ -35,6 +35,18 @@ pub fn substitute(str: &str, pattern: &str, replacements: &[&str]) -> String {
result 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -52,4 +64,36 @@ mod tests {
assert_eq!(substitute("{}{}{}", "{}", &["a", "bc", "def"]), "abcdef"); assert_eq!(substitute("{}{}{}", "{}", &["a", "bc", "def"]), "abcdef");
assert_eq!(substitute("{}{}{}", "{}", &["a"]), "a"); 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!"
);
}
} }

View file

@ -1,24 +1,16 @@
use std::{cell::RefCell, future::Future, rc::Rc, str::FromStr}; use std::str::FromStr;
use chrono::Locale; use chrono::Locale;
use common::{ron_api, utils::substitute}; use common::{ron_api, utils::substitute_with_names};
use gloo::{ use gloo::events::EventListener;
console::log,
events::EventListener,
net::http::Request,
utils::{document, window},
};
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::spawn_local; use wasm_bindgen_futures::spawn_local;
use web_sys::{ use web_sys::{Element, HtmlInputElement};
DragEvent, Element, HtmlDivElement, HtmlInputElement, HtmlSelectElement, HtmlTextAreaElement,
KeyboardEvent,
};
use crate::{ use crate::{
calendar, modal_dialog, request, calendar, modal_dialog, request,
toast::{self, Level}, 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> { pub fn setup_page(recipe_id: i64) -> Result<(), JsValue> {
@ -38,7 +30,7 @@ pub fn setup_page(recipe_id: i64) -> Result<(), JsValue> {
) )
.await .await
{ {
if request::post::<(), _>( if let Ok(result) = request::post::<ron_api::ScheduleRecipeResult, _>(
"calendar/schedule_recipe", "calendar/schedule_recipe",
ron_api::ScheduleRecipe { ron_api::ScheduleRecipe {
recipe_id, recipe_id,
@ -47,31 +39,37 @@ pub fn setup_page(recipe_id: i64) -> Result<(), JsValue> {
}, },
) )
.await .await
.is_ok()
{ {
toast::show_element_and_initialize( toast::show_element_and_initialize(
Level::Success, match result {
"#hidden-templates .calendar-add-to-planner-success", 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| { |element| {
let title = let title =
selector::<Element>("#recipe-view .recipe-title").inner_html(); selector::<Element>("#recipe-view .recipe-title").inner_html();
let date_format = let date_format =
selector::<Element>("#hidden-templates .calendar-date-format") selector::<Element>("#hidden-templates .calendar-date-format")
.inner_html(); .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(), &element.inner_html(),
"{}", &["{title}", "{date}"],
&[ &[
&title, &title,
&date.format_localized(&date_format, locale).to_string(), &date
.format_localized(&date_format, get_locale())
.to_string(),
], ],
)); ));
}, },

View file

@ -1,3 +1,6 @@
use std::str::FromStr;
use chrono::Locale;
use gloo::utils::document; use gloo::utils::document;
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
use web_sys::Element; use web_sys::Element;
@ -86,3 +89,11 @@ where
.dyn_into::<T>() .dyn_into::<T>()
.unwrap() .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()
}