regalade/gui/src/recipe/creator.rs

379 lines
13 KiB
Rust
Raw Normal View History

use std::{marker::PhantomData, rc::Rc};
use api::{CreateRecipeRequest, CreateRecipeResponse, IngredientInfo};
use dioxus::prelude::*;
2023-08-05 12:54:49 +02:00
use dioxus_router::prelude::*;
use uuid::Uuid;
use crate::{
api,
bootstrap::{bs, FormModal, ModalBody, ModalFooter, ModalToggleButton, TitledModal},
ingredients::do_add_ingredient,
recipe::IngredientSelect,
2023-08-05 12:54:49 +02:00
use_error, use_trimmed_context, ErrorView, Route,
};
use super::RecipeIngredient;
#[derive(Props)]
struct IngredientAddProps<'a> {
add: Rc<dyn Fn(RecipeIngredient)>,
#[props(default = PhantomData)]
_ph: PhantomData<&'a ()>,
}
fn IngredientCreate<'a>(cx: Scope<'a, IngredientAddProps>) -> Element<'a> {
let error = use_error(cx);
let (token, household) = use_trimmed_context(cx);
let amount = use_state(cx, String::new);
let unit = use_state(cx, String::new);
let name = use_state(cx, String::new);
let on_submit = move |_| {
let am: f64 = match amount.parse() {
Ok(v) if v >= 0. => v,
_ => {
error.set(Some("Amount must be a positive number".into()));
return;
}
};
if name.is_empty() {
error.set(Some("Name can't be empty".into()));
return;
}
let on_add = cx.props.add.clone();
to_owned![token, name, unit, error, amount];
cx.spawn(async move {
match do_add_ingredient(token, household, name.to_string(), unit.to_string()).await {
Ok(rsp) => {
(on_add)(RecipeIngredient {
id: rsp.id,
info: IngredientInfo {
name: name.to_string(),
unit: (!unit.is_empty()).then(|| unit.to_string()),
},
amount: am,
});
error.set(None);
name.set(String::new());
unit.set(String::new());
amount.set(String::new());
let modal = bs::Modal::get_instance("#newRcpCreateIg");
modal.hide();
}
Err(e) => {
error.set(Some(format!("Could not add ingredient: {e}")));
}
}
})
};
cx.render(rsx! {
FormModal {
id: "newRcpCreateIg",
fade: true,
centered: true,
submit_label: "Create & Add",
title: "Create & Add ingredient",
on_submit: on_submit,
ErrorView { error: error }
div { class: "form-floating",
input {
class: "form-control",
id: "newRcpCreateIgNameInp",
placeholder: "Name",
value: "{name}",
oninput: move |e| name.set(e.value.clone())
}
label { "for": "newRcpCreateIgNameInp", "Ingredient Name" }
}
div { class: "form-floating my-1",
input {
class: "form-control",
id: "newRcpCreateIgUnitInp",
placeholder: "Unit",
value: "{unit}",
oninput: move |e| unit.set(e.value.clone())
}
label { "for": "newRcpCreateIgUnitInp", "Ingredient Unit" }
}
div { class: "form-floating",
input {
class: "form-control",
"type": "number",
min: "0",
id: "newRcpCreateIgAmountInp",
placeholder: "Amount",
value: "{amount}",
oninput: move |e| amount.set(e.value.clone())
}
label { "for": "newRcpCreateIgAmountInp", "Ingredient Amount" }
}
}
})
}
fn IngredientAdd<'a>(cx: Scope<'a, IngredientAddProps>) -> Element<'a> {
let amount = use_state(cx, || Ok::<_, String>(None));
let selected_ingredient = use_state(cx, || Err::<(i64, IngredientInfo), _>("".to_string()));
let refresh = use_state(cx, || 0u64);
let error = use_error(cx);
let add_ingredient = move |_| {
let amount = match &**amount {
&Ok(Some(v)) => v,
Ok(None) => {
error.set(Some("Amount must be a number".to_string()));
return;
}
Err(e) => {
error.set(Some(e.clone()));
return;
}
};
let (id, info) = match &**selected_ingredient {
Ok(v) => v.clone(),
Err(e) => {
error.set(Some(format!("Ingredient does not exist: '{e}'")));
return;
}
};
(cx.props.add)(RecipeIngredient { id, info, amount });
error.set(None);
};
let create_ingredient = {
let on_add = cx.props.add.clone();
to_owned![refresh];
Rc::new(move |ig| {
(on_add)(ig);
refresh.set(refresh.wrapping_add(1));
})
};
cx.render(rsx! {
ErrorView { error: error }
IngredientCreate { add: create_ingredient }
IngredientSelect {
on_amount_change: move |v| amount.set(v),
on_ingredient_change: move |v| selected_ingredient.set(v),
refresh: **refresh,
button { class: "btn btn-primary me-1", onclick: add_ingredient, "Add" }
ModalToggleButton { class: "btn btn-secondary", modal_id: "newRcpCreateIg", "Create" }
}
})
}
async fn do_create_recipe(
token: String,
household: Uuid,
request: CreateRecipeRequest,
) -> anyhow::Result<i64> {
let rsp = gloo_net::http::Request::post(api!("household/{household}/recipe"))
.header("Authorization", &format!("Bearer {token}"))
.json(&request)?
.send()
.await?;
if !rsp.ok() {
let body = rsp.text().await.unwrap_or_default();
anyhow::bail!("Could not post recipe (code={}): {body}", rsp.status());
}
let rsp: CreateRecipeResponse = rsp.json().await?;
Ok(rsp.id)
}
pub fn RecipeCreator(cx: Scope) -> Element {
let (token, household) = use_trimmed_context(cx);
let error = use_error(cx);
let name = use_state(cx, String::new);
let current_rating = use_state(cx, || 0u8);
let person_count_input = use_state(cx, || "1".to_string());
let person_count = use_memo(cx, &**person_count_input, |pc| pc.parse().unwrap_or(1));
let ingredients = use_ref(cx, Vec::<RecipeIngredient>::new);
let steps = use_state(cx, String::new);
2023-08-05 12:54:49 +02:00
let navigator = use_navigator(cx);
let ingredient_list: Vec<_> =
ingredients.with(|ig| {
ig.iter().enumerate().map(move |(idx, ig)| {
let ig = ig.clone();
rsx! {
li { class: "list-group-item d-flex justify-content-between align-items-center",
"{ig.amount}{ig.info.unit.as_deref().unwrap_or(\"\")} {ig.info.name}"
button {
class: "btn btn-danger",
onclick: move |_| {
ingredients
.with_mut(|igs| {
igs.remove(idx);
})
},
"Remove"
}
}
}
}).collect()
});
let add_ingredient = {
to_owned![ingredients];
Rc::new(move |i| ingredients.with_mut(|ig| ig.push(i)))
};
let new_rcp_submit = move |_| {
if name.is_empty() {
error.set(Some("Name can't be empty".into()));
return;
}
to_owned![
token,
current_rating,
name,
ingredients,
person_count,
steps,
error,
2023-08-05 12:54:49 +02:00
navigator
];
2023-08-05 12:54:49 +02:00
cx.spawn(async move {
match do_create_recipe(
token,
household,
CreateRecipeRequest {
person_count,
name: name.to_string(),
rating: *current_rating,
ingredients: ingredients.with(|ig| {
ig.iter()
.map(|i| (i.id, i.amount / (person_count as f64)))
.collect()
}),
steps: steps.to_string(),
},
)
.await
{
Ok(id) => {
steps.set(Default::default());
ingredients.set(Default::default());
current_rating.set(Default::default());
name.set(Default::default());
error.set(Default::default());
2023-08-05 12:54:49 +02:00
navigator.push(Route::RecipeView{id});
}
Err(e) => {
error.set(Some(format!("Error creating recipe: {e:?}")));
}
}
});
};
cx.render(rsx! {
div { class: "d-flex align-items-center justify-content-center w-100",
div { class: "container rounded border py-2 m-2 text-center",
h1 { "Create a new recipe" }
ErrorView { error: error }
hr {}
div { class: "form-floating",
input {
id: "newRcpName",
class: "form-control",
placeholder: "Name",
value: "{name}",
oninput: move |e| name.set(e.value.clone())
}
label { "for": "newRcpName", "Name" }
}
div { class: "form-floating",
input {
id: "newRcpPersonCount",
class: "form-control",
placeholder: "Person Count",
"type": "number",
min: "1",
value: "{person_count_input}",
oninput: move |e| person_count_input.set(e.value.clone())
}
label { "for": "newRcpPersonCount", "Person Count" }
}
div { class: "pt-2",
"Rating: "
for (i , label) in ["Like", "Like a lot", "Love"].iter().enumerate() {
div { class: "form-check form-check-inline",
input {
class: "form-check-input",
"type": "radio",
name: "ratingOptions",
id: "rating{i}",
checked: **current_rating == i as u8,
oninput: move |_| current_rating.set(i as _)
}
label { class: "form-check-label", "for": "rating{i}", *label }
}
}
}
div { class: "d-flex flex-column justify-content-start",
h2 { "Ingredients" }
IngredientAdd { add: add_ingredient }
ul { class: "list-group list-group-flush text-start", ingredient_list.into_iter() }
}
div {
h2 { "Steps" }
2023-07-27 00:06:36 +02:00
div { class: "text-start",
textarea {
class: "form-control",
id: "steps-area",
value: "{steps}",
rows: "10",
oninput: move |e| steps.set(e.value.clone())
}
}
}
hr {}
ModalToggleButton { class: "btn btn-lg btn-primary", modal_id: "newRcpModal", "Create Recipe" }
TitledModal { id: "newRcpModal", fade: true, centered: true, title: "Create Recipe",
ModalBody { "Do you confirm this recipe ?" }
ModalFooter {
button {
"type": "button",
class: "btn btn-secondary",
"data-bs-dismiss": "modal",
"Cancel"
}
button {
"type": "button",
class: "btn btn-primary",
"data-bs-dismiss": "modal",
onclick: new_rcp_submit,
"Confirm"
}
}
}
}
}
})
}