app,server: Allow to add ingredients to existing recipes

This commit is contained in:
traxys 2023-06-25 18:48:49 +02:00
parent 6004520fb9
commit 27f9295aa3
5 changed files with 319 additions and 69 deletions

View file

@ -118,3 +118,8 @@ pub struct RecipeRenameRequest {
pub struct RecipeIngredientEditRequest { pub struct RecipeIngredientEditRequest {
pub amount: f64, pub amount: f64,
} }
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct AddRecipeIngredientRequest {
pub amount: f64,
}

View file

@ -1,4 +1,7 @@
use api::{RecipeInfo, RecipeIngredientEditRequest, RecipeRenameRequest}; use api::{
AddRecipeIngredientRequest, IngredientInfo, RecipeInfo, RecipeIngredientEditRequest,
RecipeRenameRequest,
};
use itertools::Itertools; use itertools::Itertools;
use uuid::Uuid; use uuid::Uuid;
use wasm_bindgen::JsCast; use wasm_bindgen::JsCast;
@ -12,6 +15,7 @@ use yew_router::prelude::*;
use crate::{ use crate::{
api, api,
bootstrap::{bs, ConfirmDangerModal, FormModal, ModalToggleButton}, bootstrap::{bs, ConfirmDangerModal, FormModal, ModalToggleButton},
recipe_creator::IngredientSelectBase,
RegaladeGlobalState, Route, RegaladeGlobalState, Route,
}; };
@ -350,6 +354,150 @@ fn EditIngredient(props: &EditIngredientProps) -> Html {
</>} </>}
} }
async fn do_add_ingredient_recipe(
token: String,
household: Uuid,
recipe: i64,
ingredient: i64,
amount: f64,
) -> anyhow::Result<()> {
let rsp = gloo_net::http::Request::put(api!(
"household/{household}/recipe/{recipe}/ingredients/{ingredient}"
))
.json(&AddRecipeIngredientRequest { amount })?
.header("Authorization", &format!("Bearer {token}"))
.send()
.await?;
if !rsp.ok() {
let body = rsp.text().await.unwrap_or_default();
anyhow::bail!("Could not get recipes (code={}): {body}", rsp.status());
}
Ok(())
}
#[derive(Clone, PartialEq, Properties)]
struct AddIngredientProps {
token: String,
household: Uuid,
recipe: i64,
update: Callback<()>,
}
#[function_component]
fn AddIngredientInner(props: &AddIngredientProps) -> HtmlResult {
let error = use_state(|| None::<String>);
let amount = use_state(|| None::<f64>);
let selected_ig = use_state(|| None::<(i64, IngredientInfo)>);
let s_ig = selected_ig.clone();
let am = amount.clone();
let err = error.clone();
let token = props.token.clone();
let household = props.household;
let recipe = props.recipe;
let update = props.update.clone();
let on_submit = Callback::from(move |_| match &*s_ig {
&Some((id, _)) => match &*am {
&Some(amount) => {
let fut = do_add_ingredient_recipe(token.clone(), household, recipe, id, amount);
let am = am.clone();
let s_ig = s_ig.clone();
let err = err.clone();
let update = update.clone();
wasm_bindgen_futures::spawn_local(async move {
match fut.await {
Ok(_) => {
err.set(None);
am.set(None);
s_ig.set(None);
update.emit(());
let modal = bs::Modal::get_instance("#rcpEditNewIg");
modal.hide();
}
Err(e) => {
err.set(Some(format!("Could not add ingredient: {e}")));
},
}
});
}
None => {
err.set(Some("Amount can't be empty".into()));
}
},
None => {
err.set(Some("Ingredient does not exist".into()));
}
});
let on_ig_change = {
let selected_ig = selected_ig.clone();
Callback::from(move |v| {
selected_ig.set(v);
})
};
let on_amount_change = {
let amount = amount.clone();
Callback::from(move |v| {
amount.set(Some(v));
})
};
Ok({
html! {<>
<FormModal
id="rcpEditNewIg"
fade=true
centered=true
submit_label="Add"
{on_submit}
title="Add ingredient"
>
if let Some(e) = &*error {
<div class={classes!("alert", "alert-danger")} role="alert">
{e}
</div>
}
<IngredientSelectBase
token={props.token.clone()}
household={props.household}
{on_ig_change}
{on_amount_change}
amount={*amount}
ig_select={(*selected_ig).as_ref().map(|(_, info)| AttrValue::from(info.name.clone()))}
/>
</FormModal>
<ModalToggleButton modal_id="rcpEditNewIg" classes={classes!("btn", "btn-secondary")}>
{"Add Ingredient"}
</ModalToggleButton>
</>}
})
}
#[function_component]
fn AddIngredient(props: &AddIngredientProps) -> Html {
let fallback = html! {
<div class="spinner-border" role="status">
<span class="visually-hidden">{"Loading ..."}</span>
</div>
};
html! {<>
<Suspense {fallback}>
<AddIngredientInner
token={props.token.clone()}
household={props.household}
recipe={props.recipe}
update={props.update.clone()}
/>
</Suspense>
</>}
}
async fn do_delete_ig( async fn do_delete_ig(
token: String, token: String,
household: Uuid, household: Uuid,
@ -427,7 +575,7 @@ fn RecipeViewerInner(props: &RecipeViewerInnerProps) -> HtmlResult {
<hr /> <hr />
<div class="text-start"> <div class="text-start">
<h2>{"Ingredients"}</h2> <h2>{"Ingredients"}</h2>
<ul class="list-group"> <ul class="list-group mb-2">
{for r.ingredients.iter().map(|(id, info, amount)| { {for r.ingredients.iter().map(|(id, info, amount)| {
let delete_modal_id = format!("rcpRmIg{id}"); let delete_modal_id = format!("rcpRmIg{id}");
html!{ html!{
@ -463,6 +611,12 @@ fn RecipeViewerInner(props: &RecipeViewerInnerProps) -> HtmlResult {
</li> </li>
}})} }})}
</ul> </ul>
<AddIngredient
token={props.token.clone()}
household={props.household}
recipe={props.id}
update={update.clone()}
/>
</div> </div>
<hr /> <hr />
<div class="text-start"> <div class="text-start">

View file

@ -14,10 +14,10 @@ use crate::{
}; };
#[derive(Clone)] #[derive(Clone)]
struct RecipeIngredient { pub(super) struct RecipeIngredient {
id: i64, id: i64,
info: IngredientInfo, info: IngredientInfo,
amount: u64, amount: f64,
} }
async fn fetch_ingredients( async fn fetch_ingredients(
@ -35,66 +35,57 @@ async fn fetch_ingredients(
} }
#[derive(PartialEq, Properties, Clone)] #[derive(PartialEq, Properties, Clone)]
struct IngredientSelectProps { pub(super) struct IngredientSelectBaseProps {
token: String, pub token: String,
household: Uuid, pub household: Uuid,
onselect: Callback<RecipeIngredient>, pub on_amount_change: Callback<f64>,
pub on_ig_change: Callback<Option<(i64, IngredientInfo)>>,
#[prop_or_default]
pub children: Children,
pub amount: Option<f64>,
pub ig_select: Option<AttrValue>,
} }
#[function_component] #[function_component]
fn IngredientSelect(props: &IngredientSelectProps) -> HtmlResult { pub(super) fn IngredientSelectBase(props: &IngredientSelectBaseProps) -> HtmlResult {
let ingredients = use_future(|| fetch_ingredients(props.token.clone(), props.household))?; let ingredients = use_future(|| fetch_ingredients(props.token.clone(), props.household))?;
let ingredient = use_state(|| None::<(i64, IngredientInfo)>); let unit = use_state(|| None::<String>);
let input_value_h = use_state(String::new); let input_value_h = use_state(|| {
props
.ig_select
.as_ref()
.map(|v| v.to_string())
.unwrap_or_default()
});
let input_value = input_value_h.to_string(); let input_value = input_value_h.to_string();
let error = use_state(|| None::<String>); let error = use_state(|| None::<String>);
let selected_ig = ingredient.clone(); let amount_change = props.on_amount_change.clone();
let on_select = props.onselect.clone(); let am_change = Callback::from(move |e: Event| {
let err = error.clone(); let Some(target) = e.target() else {
let onclick = Callback::from(move |_| match &*selected_ig { return;
Some((id, info)) => { };
let document = gloo_utils::document();
let amount: HtmlInputElement = document let Ok(target) = target.dyn_into::<HtmlInputElement>() else {
.get_element_by_id("igAmount") return;
.unwrap() };
.dyn_into()
.unwrap();
if !amount.report_validity() { if !target.report_validity() {
err.set(Some("Invalid ingredient amount".into())); return;
return;
}
let amount = amount.value();
match amount.is_empty() {
false => {
on_select.emit(RecipeIngredient {
id: *id,
info: info.clone(),
amount: amount.parse().unwrap(),
});
err.set(None);
}
true => {
err.set(Some("Amount can't be empty".into()));
}
}
} }
None => {
err.set(Some("Ingredient does not exist".into())); if let Ok(value) = target.value().parse() {
amount_change.emit(value);
} }
}); });
match &*ingredients { match &*ingredients {
Ok(ig) => { Ok(ig) => {
let igc = ig.clone(); let igc = ig.clone();
let selected_ig = ingredient.clone(); let u = unit.clone();
let on_ig_change = props.on_ig_change.clone();
let onchange = Callback::from(move |e: Event| { let onchange = Callback::from(move |e: Event| {
let Some(target) = e.target() else { let Some(target) = e.target() else {
return; return;
@ -109,10 +100,12 @@ fn IngredientSelect(props: &IngredientSelectProps) -> HtmlResult {
match igc.get(&target.value()) { match igc.get(&target.value()) {
Some(info) => { Some(info) => {
selected_ig.set(Some(info.clone())); on_ig_change.emit(Some(info.clone()));
u.set(info.1.unit.clone());
} }
None => { None => {
selected_ig.set(None); on_ig_change.emit(None);
u.set(None);
} }
} }
}); });
@ -141,14 +134,19 @@ fn IngredientSelect(props: &IngredientSelectProps) -> HtmlResult {
<div class="d-flex mw-50 align-items-center"> <div class="d-flex mw-50 align-items-center">
<label for="igAmount" class="px-1">{"Amount: "}</label> <label for="igAmount" class="px-1">{"Amount: "}</label>
<div class="input-group"> <div class="input-group">
<input class="form-control" type="number" id="igAmount" min="0" /> <input
if let Some(ig) = &*ingredient { class="form-control"
if let Some(unit) = &ig.1.unit { type="number"
<span class="input-group-text">{unit}</span> id="igAmount"
} min="0"
value={props.amount.map(|v| v.to_string())}
onchange={am_change}
/>
if let Some(unit) = &*unit {
<span class="input-group-text">{unit}</span>
} }
</div> </div>
<button class="btn btn-primary ms-1" {onclick}>{"Add"}</button> {props.children.clone()}
</div> </div>
</div> </div>
}) })
@ -161,6 +159,86 @@ fn IngredientSelect(props: &IngredientSelectProps) -> HtmlResult {
} }
} }
#[derive(PartialEq, Properties, Clone)]
struct IngredientSelectProps {
token: String,
household: Uuid,
onselect: Callback<RecipeIngredient>,
}
#[function_component]
fn IngredientSelect(props: &IngredientSelectProps) -> Html {
let on_select = props.onselect.clone();
let error = use_state(|| None::<String>);
let amount = use_state(|| None::<f64>);
let selected_ig = use_state(|| None::<(i64, IngredientInfo)>);
let s_ig = selected_ig.clone();
let am = amount.clone();
let err = error.clone();
let onclick = Callback::from(move |_| match &*s_ig {
Some((id, info)) => match &*am {
&Some(amount) => {
on_select.emit(RecipeIngredient {
id: *id,
info: info.clone(),
amount,
});
err.set(None);
}
None => {
err.set(Some("Amount can't be empty".into()));
}
},
None => {
err.set(Some("Ingredient does not exist".into()));
}
});
let fallback = html! {
<div class="spinner-border" role="status">
<span class="visually-hidden">{"Loading ..."}</span>
</div>
};
let on_ig_change = {
let selected_ig = selected_ig.clone();
Callback::from(move |v| {
selected_ig.set(v);
})
};
let on_amount_change = {
let amount = amount.clone();
Callback::from(move |v| {
amount.set(Some(v));
})
};
html! {<>
if let Some(e) = &*error {
<div class={classes!("alert", "alert-danger")} role="alert">
{e}
</div>
}
<Suspense {fallback}>
<IngredientSelectBase
token={props.token.clone()}
household={props.household}
{on_ig_change}
{on_amount_change}
amount={*amount}
ig_select={(*selected_ig).as_ref().map(|(_, info)| AttrValue::from(info.name.clone()))}
>
<button class="btn btn-primary" {onclick}>
{"Add"}
</button>
</IngredientSelectBase>
</Suspense>
</>}
}
async fn do_create_recipe( async fn do_create_recipe(
token: String, token: String,
household: Uuid, household: Uuid,
@ -195,8 +273,6 @@ pub fn RecipeCreator() -> Html {
}) })
}; };
let fallback = html! {"Loading Ingredients ..."};
let ingredients = use_state(im::Vector::new); let ingredients = use_state(im::Vector::new);
let ig = ingredients.clone(); let ig = ingredients.clone();
let onselect = Callback::from(move |rcp_ig: RecipeIngredient| { let onselect = Callback::from(move |rcp_ig: RecipeIngredient| {
@ -298,9 +374,7 @@ pub fn RecipeCreator() -> Html {
rating: (*rtg), rating: (*rtg),
ingredients: ig ingredients: ig
.iter() .iter()
.map(|rcp_ig: &RecipeIngredient| { .map(|rcp_ig: &RecipeIngredient| (rcp_ig.id, rcp_ig.amount / (*pc) as f64))
(rcp_ig.id, rcp_ig.amount as f64 / (*pc) as f64)
})
.collect(), .collect(),
steps: (*s).iter().cloned().collect(), steps: (*s).iter().cloned().collect(),
}, },
@ -410,13 +484,11 @@ pub fn RecipeCreator() -> Html {
</div> </div>
<div class="d-flex flex-column justify-content-start"> <div class="d-flex flex-column justify-content-start">
<h2>{"Ingredients"}</h2> <h2>{"Ingredients"}</h2>
<Suspense {fallback}> <IngredientSelect
<IngredientSelect token={global_state.token.token.clone()}
token={global_state.token.token.clone()} household={global_state.household.id}
household={global_state.household.id} {onselect}
{onselect} />
/>
</Suspense>
<ul class="list-group list-group-flush text-start"> <ul class="list-group list-group-flush text-start">
{for (*ingredients).iter().enumerate().map(|(idx, ig)| { {for (*ingredients).iter().enumerate().map(|(idx, ig)| {
html!{ html!{

View file

@ -232,6 +232,7 @@ pub(crate) fn router(api_allowed: Option<HeaderValue>) -> Router<AppState> {
"/household/:house_id/recipe/:recipe_id/ingredients/:iid", "/household/:house_id/recipe/:recipe_id/ingredients/:iid",
patch(recipe::edit_ig_amount) patch(recipe::edit_ig_amount)
.delete(recipe::delete_ig) .delete(recipe::delete_ig)
.layer(mk_service(vec![Method::PATCH, Method::DELETE])), .put(recipe::add_ig_request)
.layer(mk_service(vec![Method::PATCH, Method::DELETE, Method::PUT])),
) )
} }

View file

@ -1,6 +1,7 @@
use api::{ use api::{
CreateRecipeRequest, CreateRecipeResponse, EmptyResponse, IngredientInfo, ListRecipesResponse, AddRecipeIngredientRequest, CreateRecipeRequest, CreateRecipeResponse, EmptyResponse,
RecipeInfo, RecipeIngredientEditRequest, RecipeRenameRequest, IngredientInfo, ListRecipesResponse, RecipeInfo, RecipeIngredientEditRequest,
RecipeRenameRequest,
}; };
use axum::{ use axum::{
async_trait, async_trait,
@ -208,3 +209,20 @@ pub(super) async fn delete_ig(
Ok(EmptyResponse {}.into()) Ok(EmptyResponse {}.into())
} }
pub(super) async fn add_ig_request(
State(state): State<AppState>,
RecipeExtractor(recipe): RecipeExtractor,
IngredientExtractor(ingredient): IngredientExtractor,
Json(req): Json<AddRecipeIngredientRequest>,
) -> JsonResult<EmptyResponse> {
let model = recipe_ingerdients::ActiveModel {
recipe_id: ActiveValue::Set(recipe.id),
ingredient_id: ActiveValue::Set(ingredient.id),
amount: ActiveValue::Set(req.amount),
};
model.insert(&state.db).await?;
Ok(EmptyResponse {}.into())
}