app,server: Allow to add ingredients to existing recipes
This commit is contained in:
parent
6004520fb9
commit
27f9295aa3
5 changed files with 319 additions and 69 deletions
|
|
@ -1,4 +1,7 @@
|
|||
use api::{RecipeInfo, RecipeIngredientEditRequest, RecipeRenameRequest};
|
||||
use api::{
|
||||
AddRecipeIngredientRequest, IngredientInfo, RecipeInfo, RecipeIngredientEditRequest,
|
||||
RecipeRenameRequest,
|
||||
};
|
||||
use itertools::Itertools;
|
||||
use uuid::Uuid;
|
||||
use wasm_bindgen::JsCast;
|
||||
|
|
@ -12,6 +15,7 @@ use yew_router::prelude::*;
|
|||
use crate::{
|
||||
api,
|
||||
bootstrap::{bs, ConfirmDangerModal, FormModal, ModalToggleButton},
|
||||
recipe_creator::IngredientSelectBase,
|
||||
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(
|
||||
token: String,
|
||||
household: Uuid,
|
||||
|
|
@ -427,7 +575,7 @@ fn RecipeViewerInner(props: &RecipeViewerInnerProps) -> HtmlResult {
|
|||
<hr />
|
||||
<div class="text-start">
|
||||
<h2>{"Ingredients"}</h2>
|
||||
<ul class="list-group">
|
||||
<ul class="list-group mb-2">
|
||||
{for r.ingredients.iter().map(|(id, info, amount)| {
|
||||
let delete_modal_id = format!("rcpRmIg{id}");
|
||||
html!{
|
||||
|
|
@ -463,6 +611,12 @@ fn RecipeViewerInner(props: &RecipeViewerInnerProps) -> HtmlResult {
|
|||
</li>
|
||||
}})}
|
||||
</ul>
|
||||
<AddIngredient
|
||||
token={props.token.clone()}
|
||||
household={props.household}
|
||||
recipe={props.id}
|
||||
update={update.clone()}
|
||||
/>
|
||||
</div>
|
||||
<hr />
|
||||
<div class="text-start">
|
||||
|
|
|
|||
|
|
@ -14,10 +14,10 @@ use crate::{
|
|||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct RecipeIngredient {
|
||||
pub(super) struct RecipeIngredient {
|
||||
id: i64,
|
||||
info: IngredientInfo,
|
||||
amount: u64,
|
||||
amount: f64,
|
||||
}
|
||||
|
||||
async fn fetch_ingredients(
|
||||
|
|
@ -35,66 +35,57 @@ async fn fetch_ingredients(
|
|||
}
|
||||
|
||||
#[derive(PartialEq, Properties, Clone)]
|
||||
struct IngredientSelectProps {
|
||||
token: String,
|
||||
household: Uuid,
|
||||
onselect: Callback<RecipeIngredient>,
|
||||
pub(super) struct IngredientSelectBaseProps {
|
||||
pub token: String,
|
||||
pub household: Uuid,
|
||||
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]
|
||||
fn IngredientSelect(props: &IngredientSelectProps) -> HtmlResult {
|
||||
pub(super) fn IngredientSelectBase(props: &IngredientSelectBaseProps) -> HtmlResult {
|
||||
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 error = use_state(|| None::<String>);
|
||||
|
||||
let selected_ig = ingredient.clone();
|
||||
let on_select = props.onselect.clone();
|
||||
let err = error.clone();
|
||||
let onclick = Callback::from(move |_| match &*selected_ig {
|
||||
Some((id, info)) => {
|
||||
let document = gloo_utils::document();
|
||||
let amount_change = props.on_amount_change.clone();
|
||||
let am_change = Callback::from(move |e: Event| {
|
||||
let Some(target) = e.target() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let amount: HtmlInputElement = document
|
||||
.get_element_by_id("igAmount")
|
||||
.unwrap()
|
||||
.dyn_into()
|
||||
.unwrap();
|
||||
let Ok(target) = target.dyn_into::<HtmlInputElement>() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if !amount.report_validity() {
|
||||
err.set(Some("Invalid ingredient amount".into()));
|
||||
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()));
|
||||
}
|
||||
}
|
||||
if !target.report_validity() {
|
||||
return;
|
||||
}
|
||||
None => {
|
||||
err.set(Some("Ingredient does not exist".into()));
|
||||
|
||||
if let Ok(value) = target.value().parse() {
|
||||
amount_change.emit(value);
|
||||
}
|
||||
});
|
||||
|
||||
match &*ingredients {
|
||||
Ok(ig) => {
|
||||
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 Some(target) = e.target() else {
|
||||
return;
|
||||
|
|
@ -109,10 +100,12 @@ fn IngredientSelect(props: &IngredientSelectProps) -> HtmlResult {
|
|||
|
||||
match igc.get(&target.value()) {
|
||||
Some(info) => {
|
||||
selected_ig.set(Some(info.clone()));
|
||||
on_ig_change.emit(Some(info.clone()));
|
||||
u.set(info.1.unit.clone());
|
||||
}
|
||||
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">
|
||||
<label for="igAmount" class="px-1">{"Amount: "}</label>
|
||||
<div class="input-group">
|
||||
<input class="form-control" type="number" id="igAmount" min="0" />
|
||||
if let Some(ig) = &*ingredient {
|
||||
if let Some(unit) = &ig.1.unit {
|
||||
<span class="input-group-text">{unit}</span>
|
||||
}
|
||||
<input
|
||||
class="form-control"
|
||||
type="number"
|
||||
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>
|
||||
<button class="btn btn-primary ms-1" {onclick}>{"Add"}</button>
|
||||
{props.children.clone()}
|
||||
</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(
|
||||
token: String,
|
||||
household: Uuid,
|
||||
|
|
@ -195,8 +273,6 @@ pub fn RecipeCreator() -> Html {
|
|||
})
|
||||
};
|
||||
|
||||
let fallback = html! {"Loading Ingredients ..."};
|
||||
|
||||
let ingredients = use_state(im::Vector::new);
|
||||
let ig = ingredients.clone();
|
||||
let onselect = Callback::from(move |rcp_ig: RecipeIngredient| {
|
||||
|
|
@ -298,9 +374,7 @@ pub fn RecipeCreator() -> Html {
|
|||
rating: (*rtg),
|
||||
ingredients: ig
|
||||
.iter()
|
||||
.map(|rcp_ig: &RecipeIngredient| {
|
||||
(rcp_ig.id, rcp_ig.amount as f64 / (*pc) as f64)
|
||||
})
|
||||
.map(|rcp_ig: &RecipeIngredient| (rcp_ig.id, rcp_ig.amount / (*pc) as f64))
|
||||
.collect(),
|
||||
steps: (*s).iter().cloned().collect(),
|
||||
},
|
||||
|
|
@ -410,13 +484,11 @@ pub fn RecipeCreator() -> Html {
|
|||
</div>
|
||||
<div class="d-flex flex-column justify-content-start">
|
||||
<h2>{"Ingredients"}</h2>
|
||||
<Suspense {fallback}>
|
||||
<IngredientSelect
|
||||
token={global_state.token.token.clone()}
|
||||
household={global_state.household.id}
|
||||
{onselect}
|
||||
/>
|
||||
</Suspense>
|
||||
<IngredientSelect
|
||||
token={global_state.token.token.clone()}
|
||||
household={global_state.household.id}
|
||||
{onselect}
|
||||
/>
|
||||
<ul class="list-group list-group-flush text-start">
|
||||
{for (*ingredients).iter().enumerate().map(|(idx, ig)| {
|
||||
html!{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue