app: Add a UI to create recipes

This commit is contained in:
traxys 2023-06-22 22:33:38 +02:00
parent 58cadd37d2
commit cec3ef214e
6 changed files with 564 additions and 2 deletions

43
Cargo.lock generated
View file

@ -75,6 +75,7 @@ dependencies = [
"gloo-net", "gloo-net",
"gloo-storage", "gloo-storage",
"gloo-utils", "gloo-utils",
"im",
"itertools", "itertools",
"log", "log",
"serde", "serde",
@ -379,6 +380,15 @@ version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitmaps"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "031043d04099746d8db04daf1fa424b2bc8bd69d92b25962dcde24da39ab64a2"
dependencies = [
"typenum",
]
[[package]] [[package]]
name = "bitvec" name = "bitvec"
version = "1.0.1" version = "1.0.1"
@ -1446,6 +1456,20 @@ dependencies = [
"unicode-normalization", "unicode-normalization",
] ]
[[package]]
name = "im"
version = "15.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0acd33ff0285af998aaf9b57342af478078f53492322fafc47450e09397e0e9"
dependencies = [
"bitmaps",
"rand_core",
"rand_xoshiro",
"sized-chunks",
"typenum",
"version_check",
]
[[package]] [[package]]
name = "implicit-clone" name = "implicit-clone"
version = "0.3.5" version = "0.3.5"
@ -2226,6 +2250,15 @@ dependencies = [
"getrandom", "getrandom",
] ]
[[package]]
name = "rand_xoshiro"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f97cdb2a36ed4183de61b2f824cc45c9f1037f28afe0a322e9fff4c108b5aaa"
dependencies = [
"rand_core",
]
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.2.16" version = "0.2.16"
@ -2793,6 +2826,16 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f27f6278552951f1f2b8cf9da965d10969b2efdea95a6ec47987ab46edfe263a" checksum = "f27f6278552951f1f2b8cf9da965d10969b2efdea95a6ec47987ab46edfe263a"
[[package]]
name = "sized-chunks"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16d69225bde7a69b235da73377861095455d298f2b970996eec25ddbb42b3d1e"
dependencies = [
"bitmaps",
"typenum",
]
[[package]] [[package]]
name = "slab" name = "slab"
version = "0.4.8" version = "0.4.8"

View file

@ -12,6 +12,7 @@ console_log = { version = "1.0.0", features = ["color"] }
gloo-net = "0.2.6" gloo-net = "0.2.6"
gloo-storage = "0.2.2" gloo-storage = "0.2.2"
gloo-utils = "0.1.6" gloo-utils = "0.1.6"
im = "15.1.0"
itertools = "0.10.5" itertools = "0.10.5"
log = "0.4.17" log = "0.4.17"
serde = { version = "1.0.163", features = ["derive"] } serde = { version = "1.0.163", features = ["derive"] }

View file

@ -11,7 +11,7 @@ use crate::{
RegaladeGlobalState, RegaladeGlobalState,
}; };
async fn fetch_ingredients(token: String, household: Uuid) -> anyhow::Result<api::IngredientList> { pub async fn fetch_ingredients(token: String, household: Uuid) -> anyhow::Result<api::IngredientList> {
let rsp = gloo_net::http::Request::get(api!("household/{household}/ingredients")) let rsp = gloo_net::http::Request::get(api!("household/{household}/ingredients"))
.header("Authorization", &format!("Bearer {token}")) .header("Authorization", &format!("Bearer {token}"))
.send() .send()

View file

@ -19,8 +19,9 @@ use crate::{
}; };
mod bootstrap; mod bootstrap;
mod sidebar;
mod ingredients; mod ingredients;
mod recipe_creator;
mod sidebar;
const API_ROUTE: &str = match option_env!("REGALADE_API_SERVER_BASE") { const API_ROUTE: &str = match option_env!("REGALADE_API_SERVER_BASE") {
None => "http://localhost:8085", None => "http://localhost:8085",
@ -45,6 +46,10 @@ enum Route {
Ingredients, Ingredients,
#[at("/household_select")] #[at("/household_select")]
HouseholdSelect, HouseholdSelect,
#[at("/new_recipe")]
NewRecipe,
#[at("/recipe/:id")]
Recipe { id: i64 },
#[at("/404")] #[at("/404")]
#[not_found] #[not_found]
NotFound, NotFound,
@ -468,6 +473,16 @@ fn switch(route: Route) -> Html {
Route::HouseholdSelect => html! { Route::HouseholdSelect => html! {
<HouseholdSelection /> <HouseholdSelection />
}, },
Route::NewRecipe => html! {
<GlobalStateRedirector {route}>
<recipe_creator::RecipeCreator />
</GlobalStateRedirector>
},
Route::Recipe { id } => html! {
<GlobalStateRedirector {route}>
{format!("RECIPE {id}")}
</GlobalStateRedirector>
},
Route::NotFound => html! { Route::NotFound => html! {
"Page not found" "Page not found"
}, },

498
app/src/recipe_creator.rs Normal file
View file

@ -0,0 +1,498 @@
use std::{collections::BTreeMap, rc::Rc};
use api::{CreateRecipeRequest, CreateRecipeResponse, IngredientInfo};
use uuid::Uuid;
use wasm_bindgen::JsCast;
use web_sys::{HtmlInputElement, HtmlTextAreaElement};
use yew::{prelude::*, suspense::use_future};
use yew_router::prelude::use_navigator;
use crate::{
api,
bootstrap::{ModalBody, ModalFooter, ModalToggleButton, TitledModal},
RegaladeGlobalState, Route,
};
#[derive(Clone)]
struct RecipeIngredient {
id: i64,
info: IngredientInfo,
amount: u64,
}
async fn fetch_ingredients(
token: String,
household: Uuid,
) -> anyhow::Result<Rc<BTreeMap<String, (i64, IngredientInfo)>>> {
let list = crate::ingredients::fetch_ingredients(token, household).await?;
Ok(Rc::new(
list.ingredients
.into_iter()
.map(|(k, v)| (v.name.clone(), (k, v)))
.collect(),
))
}
#[derive(PartialEq, Properties, Clone)]
struct IngredientSelectProps {
token: String,
household: Uuid,
onselect: Callback<RecipeIngredient>,
}
#[function_component]
fn IngredientSelect(props: &IngredientSelectProps) -> HtmlResult {
let ingredients = use_future(|| fetch_ingredients(props.token.clone(), props.household))?;
let ingredient = use_state(|| None::<(i64, IngredientInfo)>);
let input_value_h = use_state(String::new);
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: HtmlInputElement = document
.get_element_by_id("igAmount")
.unwrap()
.dyn_into()
.unwrap();
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()));
}
}
}
None => {
err.set(Some("Ingredient does not exist".into()));
}
});
match &*ingredients {
Ok(ig) => {
let igc = ig.clone();
let selected_ig = ingredient.clone();
let onchange = Callback::from(move |e: Event| {
let Some(target) = e.target() else {
return;
};
let ip = input_value_h.clone();
let Ok(target) = target.dyn_into::<HtmlInputElement>() else {
return;
};
ip.set(target.value());
match igc.get(&target.value()) {
Some(info) => {
selected_ig.set(Some(info.clone()));
}
None => {
selected_ig.set(None);
}
}
});
Ok(html! {
<div class="d-flex flex-column align-items-start">
if let Some(e) = &*error {
<div class={classes!("alert", "alert-danger")} role="alert">
{e}
</div>
}
<script src="/awesomplete.min.js" async=true></script>
<div class="d-flex align-items-center mb-1">
<label for="igSelect" class="pe-1">{"Name:"}</label>
<input
class="awesomplete form-control"
list="igList"
value={input_value}
{onchange}
id="igSelect"
/>
<datalist id="igList">
{ for ig.keys().map(|k| html!{<option key={k.clone()}>{k}</option>}) }
</datalist>
</div>
<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>
}
}
</div>
<button class="btn btn-primary ms-1" {onclick}>{"Add"}</button>
</div>
</div>
})
}
Err(e) => Ok(html! {
<div class={classes!("alert", "alert-danger")} role="alert">
{format!("Could not load ingredients: {e:?}")}
</div>
}),
}
}
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)
}
#[function_component]
pub fn RecipeCreator() -> Html {
let current_rating = use_state(|| 0u8);
let global_state = use_state(RegaladeGlobalState::get);
let person_count = use_state(|| 1u8);
let mk_rating_oninput = |id| {
let current_rating = current_rating.clone();
Callback::from(move |_| {
current_rating.set(id);
})
};
let fallback = html! {"Loading Ingredients ..."};
let ingredients = use_state(im::Vector::new);
let ig = ingredients.clone();
let onselect = Callback::from(move |rcp_ig: RecipeIngredient| {
let mut ingredients = (*ig).clone();
ingredients.push_back(rcp_ig);
ig.set(ingredients);
});
let mk_ig_delete = |idx| {
let ig = ingredients.clone();
Callback::from(move |_| {
let mut ingredients = (*ig).clone();
ingredients.remove(idx);
ig.set(ingredients);
})
};
let steps = use_state(im::Vector::new);
let s = steps.clone();
let on_add_step = Callback::from(move |_| {
let mut steps = (*s).clone();
steps.push_back(String::new());
s.set(steps);
});
let mk_step_del = |idx| {
let s = steps.clone();
Callback::from(move |_| {
let mut steps = (*s).clone();
steps.remove(idx);
s.set(steps);
})
};
let mk_step_change = |idx| {
let s = steps.clone();
Callback::from(move |e: Event| {
let Some(target) = e.target() else {
return;
};
let Ok(target) = target.dyn_into::<HtmlTextAreaElement>() else {
return;
};
let mut steps = (*s).clone();
steps[idx] = target.value();
s.set(steps);
})
};
let name = use_state(String::new);
let nm = name.clone();
let on_name_change = Callback::from(move |e: Event| {
let Some(target) = e.target() else {
return;
};
let Ok(target) = target.dyn_into::<HtmlInputElement>() else {
return;
};
nm.set(target.value());
});
let error = use_state(|| Option::None::<String>);
let s = steps.clone();
let ig = ingredients.clone();
let rtg = current_rating.clone();
let nm = name.clone();
let err = error.clone();
let token = global_state.token.token.clone();
let household = global_state.household.id;
let pc = person_count.clone();
let nav = use_navigator().unwrap();
let new_rcp_submit = Callback::from(move |_| {
if nm.is_empty() {
err.set(Some("Name can't be empty".into()));
return;
}
let s = s.clone();
let ig = ig.clone();
let rtg = rtg.clone();
let nm = nm.clone();
let err = err.clone();
let token = token.clone();
let pc = pc.clone();
let nav = nav.clone();
wasm_bindgen_futures::spawn_local(async move {
match do_create_recipe(
token,
household,
CreateRecipeRequest {
name: (*nm).clone(),
rating: (*rtg),
ingredients: ig
.iter()
.map(|rcp_ig: &RecipeIngredient| {
(rcp_ig.id, rcp_ig.amount as f64 / (*pc) as f64)
})
.collect(),
steps: (*s).iter().cloned().collect(),
},
)
.await
{
Ok(id) => {
s.set(Default::default());
ig.set(Default::default());
rtg.set(Default::default());
nm.set(Default::default());
err.set(None);
nav.push(&Route::Recipe { id });
}
Err(e) => {
err.set(Some(format!("Error creating recipe: {e:?}")));
}
}
});
});
let pc = person_count.clone();
let on_person_count_change = Callback::from(move |e: Event| {
let Some(target) = e.target() else {
return;
};
let Ok(target) = target.dyn_into::<HtmlInputElement>() else {
return;
};
if !target.report_validity() {
return;
}
pc.set(target.value().parse().unwrap());
});
html! {
<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"}</h1>
if let Some(e) = &*error {
<div class={classes!("alert", "alert-danger")} role="alert">
{e}
</div>
}
<hr />
<div class="form-floating">
<input
id="newRcpName"
class="form-control"
placeholder="Name"
value={name.to_string()}
onchange={on_name_change}
/>
<label for="newRcpName">{"Name"}</label>
</div>
<div class="form-floating">
<input
id="newRcpPersonCount"
class="form-control"
placeholder="Person Count"
type="number"
min=0
onchange={on_person_count_change}
value={person_count.to_string()}
/>
<label for="newRcpPersonCount">{"Person Count"}</label>
</div>
<div class="pt-2">
{"Rating: "}
<div class="form-check form-check-inline">
<input
class="form-check-input"
type="radio"
name="ratingOptions"
id="likeRating"
checked={*current_rating == 0}
oninput={mk_rating_oninput(0)}
/>
<label class="form-check-label" for="likeRating">{"Like"}</label>
</div>
<div class="form-check form-check-inline">
<input
class="form-check-input"
type="radio"
name="ratingOptions"
id="likeLotRating"
checked={*current_rating == 1}
oninput={mk_rating_oninput(1)}
/>
<label class="form-check-label" for="likeLotRating">{"Like a lot"}</label>
</div>
<div class="form-check form-check-inline">
<input
class="form-check-input"
type="radio"
name="ratingOptions"
id="loveRating"
checked={*current_rating == 2}
oninput={mk_rating_oninput(2)}
/>
<label class="form-check-label" for="loveRating">{"Love"}</label>
</div>
</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>
<ul class="list-group list-group-flush text-start">
{for (*ingredients).iter().enumerate().map(|(idx, ig)| {
html!{
<li
class={classes!(
"list-group-item",
"d-flex",
"justify-content-between",
"align-items-center",
)}
>
{format!("{}{} {}",
ig.amount,
ig.info.unit.as_deref().unwrap_or(""),
ig.info.name,
)}
<button
class="btn btn-danger"
onclick={mk_ig_delete(idx)}
>
{"Remove"}
</button>
</li>
}
})
}
</ul>
</div>
<div>
<h2>{"Steps"}</h2>
<ul class="list-group list-group-flush">
{for (*steps).iter().enumerate().map(|(idx, step)| {
html!{
<li class="list-group-item">
<textarea
class="form-control"
value={step.clone()}
onchange={mk_step_change(idx)}
/>
<button
class="btn btn-danger mt-1"
onclick={mk_step_del(idx)}
>
{"Remove"}
</button>
</li>
}
})
}
</ul>
<button class="btn btn-primary" onclick={on_add_step}>{"Add Step"}</button>
</div>
<hr />
<ModalToggleButton classes={classes!("btn", "btn-lg", "btn-primary")} modal_id="newRcpModal">
{"Create Recipe"}
</ModalToggleButton>
<TitledModal id="newRcpModal" fade=true centered=true title="Create Recipe">
<ModalBody>
{"Do you confirm this recipe ?"}
</ModalBody>
<ModalFooter>
<button type="button" class={classes!("btn", "btn-secondary")} data-bs-dismiss="modal">
{"Cancel"}
</button>
<button
type="button"
class={classes!("btn", "btn-primary")}
data-bs-dismiss="modal"
onclick={new_rcp_submit}
>
{"Confirm"}
</button>
</ModalFooter>
</TitledModal>
</div>
</div>
}
}

View file

@ -440,6 +440,11 @@ pub(crate) fn RegaladeSidebar(props: &RegaladeSidebarProps) -> Html {
icon: "bi-egg-fill", icon: "bi-egg-fill",
page: Route::Ingredients, page: Route::Ingredients,
}, },
MenuEntry {
label: "New Recipe",
icon: "bi-clipboard2-plus-fill",
page: Route::NewRecipe,
},
]; ];
html! { html! {