app: Handle ingredient management
This commit is contained in:
parent
c64dcc1400
commit
7660146175
2 changed files with 360 additions and 1 deletions
358
app/src/ingredients.rs
Normal file
358
app/src/ingredients.rs
Normal file
|
|
@ -0,0 +1,358 @@
|
||||||
|
use api::{CreateIngredientRequest, EditIngredientRequest, IngredientInfo};
|
||||||
|
use itertools::Itertools;
|
||||||
|
use uuid::Uuid;
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
use web_sys::HtmlInputElement;
|
||||||
|
use yew::{prelude::*, suspense::use_future_with_deps};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
api,
|
||||||
|
bootstrap::{bs, FormModal},
|
||||||
|
RegaladeGlobalState,
|
||||||
|
};
|
||||||
|
|
||||||
|
async fn fetch_ingredients(token: String, household: Uuid) -> anyhow::Result<api::IngredientList> {
|
||||||
|
let rsp = gloo_net::http::Request::get(api!("household/{household}/ingredients"))
|
||||||
|
.header("Authorization", &format!("Bearer {token}"))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !rsp.ok() {
|
||||||
|
let body = rsp.body();
|
||||||
|
match body {
|
||||||
|
None => anyhow::bail!("Could not fetch ingredients: {rsp:?}"),
|
||||||
|
Some(b) => anyhow::bail!("Could not fetch ingredients: {}", b.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(rsp.json().await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn do_edit_ingredient(
|
||||||
|
token: String,
|
||||||
|
household: Uuid,
|
||||||
|
id: i64,
|
||||||
|
new_name: String,
|
||||||
|
new_unit: String,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let rsp = gloo_net::http::Request::patch(api!("household/{household}/ingredients/{id}"))
|
||||||
|
.header("Authorization", &format!("Bearer {token}"))
|
||||||
|
.json(&EditIngredientRequest {
|
||||||
|
name: Some(new_name),
|
||||||
|
has_unit: true,
|
||||||
|
unit: (!new_unit.is_empty()).then_some(new_unit),
|
||||||
|
})?
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !rsp.ok() {
|
||||||
|
let body = rsp.body();
|
||||||
|
match body {
|
||||||
|
None => anyhow::bail!("Could not edit ingredients: {rsp:?}"),
|
||||||
|
Some(b) => anyhow::bail!("Could not edit ingredients: {}", b.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn do_delete_ingredient(token: String, household: Uuid, id: i64) -> anyhow::Result<()> {
|
||||||
|
let rsp = gloo_net::http::Request::delete(api!("household/{household}/ingredients/{id}"))
|
||||||
|
.header("Authorization", &format!("Bearer {token}"))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !rsp.ok() {
|
||||||
|
let body = rsp.body();
|
||||||
|
match body {
|
||||||
|
None => anyhow::bail!("Could not delete ingredient: {rsp:?}"),
|
||||||
|
Some(b) => anyhow::bail!("Could not delete ingredient: {}", b.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq, Eq)]
|
||||||
|
struct IngredientListProps {
|
||||||
|
token: String,
|
||||||
|
household: Uuid,
|
||||||
|
render_id: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component]
|
||||||
|
fn IngredientList(props: &IngredientListProps) -> HtmlResult {
|
||||||
|
let fetch_id = use_state(|| 0u64);
|
||||||
|
let ingredients = use_future_with_deps(
|
||||||
|
|_| fetch_ingredients(props.token.clone(), props.household),
|
||||||
|
(*fetch_id as u128) << 64 | props.render_id as u128,
|
||||||
|
)?;
|
||||||
|
let error = use_state(|| None::<String>);
|
||||||
|
|
||||||
|
let edit_state = use_state(|| None);
|
||||||
|
|
||||||
|
let es = edit_state.clone();
|
||||||
|
let item_edit = |id, current: IngredientInfo| {
|
||||||
|
let es = es.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
es.set(Some((id, current.clone())));
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let es = edit_state.clone();
|
||||||
|
let token = props.token.clone();
|
||||||
|
let household = props.household;
|
||||||
|
let err = error.clone();
|
||||||
|
let fetch = fetch_id.clone();
|
||||||
|
let on_submit = Callback::from(move |()| {
|
||||||
|
if let Some((id, _)) = &*es {
|
||||||
|
let document = gloo_utils::document();
|
||||||
|
|
||||||
|
let name: HtmlInputElement = document
|
||||||
|
.get_element_by_id("editIgName")
|
||||||
|
.unwrap()
|
||||||
|
.dyn_into()
|
||||||
|
.expect("editIgName is not an input element");
|
||||||
|
let name = name.value();
|
||||||
|
|
||||||
|
let unit: HtmlInputElement = document
|
||||||
|
.get_element_by_id("editIgUnit")
|
||||||
|
.unwrap()
|
||||||
|
.dyn_into()
|
||||||
|
.expect("editIgUnit is not an input element");
|
||||||
|
let unit = unit.value();
|
||||||
|
|
||||||
|
let token = token.clone();
|
||||||
|
let id = *id;
|
||||||
|
let err = err.clone();
|
||||||
|
let fetch = fetch.clone();
|
||||||
|
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
match do_edit_ingredient(token, household, id, name, unit).await {
|
||||||
|
Ok(_) => {
|
||||||
|
let modal = bs::Modal::get_instance("#editIgModal");
|
||||||
|
modal.hide();
|
||||||
|
fetch.set(*fetch + 1);
|
||||||
|
}
|
||||||
|
Err(e) => err.set(Some(format!("Could not edit ingredient: {e:?}"))),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let global_error = use_state(|| None::<String>);
|
||||||
|
let token = props.token.clone();
|
||||||
|
let err = global_error.clone();
|
||||||
|
let item_delete = move |id| {
|
||||||
|
let fetch = fetch_id.clone();
|
||||||
|
let err = err.clone();
|
||||||
|
let token = token.clone();
|
||||||
|
|
||||||
|
Callback::from(move |_| {
|
||||||
|
let fetch = fetch.clone();
|
||||||
|
let err = err.clone();
|
||||||
|
let token = token.clone();
|
||||||
|
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
match do_delete_ingredient(token, household, id).await {
|
||||||
|
Ok(_) => {
|
||||||
|
fetch.set(*fetch + 1);
|
||||||
|
}
|
||||||
|
Err(e) => err.set(Some(format!("Could not edit ingredient: {e:?}"))),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(match &*ingredients {
|
||||||
|
Ok(l) => html! {<>
|
||||||
|
if let Some(err) = &*global_error {
|
||||||
|
<div class={classes!("alert", "alert-danger")} role="alert">
|
||||||
|
{err}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<ul class="list-group list-group-flush text-start">
|
||||||
|
{ for l.ingredients.iter().sorted_by_key(|(&k,_)| k).map(|(&k,i)| {
|
||||||
|
html! {
|
||||||
|
<li class="list-group-item d-flex align-items-center" key={k}>
|
||||||
|
<p class="flex-fill m-auto">
|
||||||
|
{&i.name}
|
||||||
|
if let Some(unit) = &i.unit {
|
||||||
|
{format!(" (unit: {unit})")}
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary"
|
||||||
|
onclick={item_edit(k, i.clone())}
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#editIgModal"
|
||||||
|
>
|
||||||
|
<i class={classes!("bi-pencil-fill")}></i>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-danger ms-1"
|
||||||
|
onclick={item_delete(k)}
|
||||||
|
>
|
||||||
|
<i class={classes!("bi-trash-fill")}></i>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
<FormModal
|
||||||
|
centered={true}
|
||||||
|
id="editIgModal"
|
||||||
|
submit_label="Edit"
|
||||||
|
title="Edit Ingredient"
|
||||||
|
{on_submit}
|
||||||
|
>
|
||||||
|
if let Some(err) = &*error {
|
||||||
|
<div class={classes!("alert", "alert-danger")} role="alert">
|
||||||
|
{err}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="form-floating">
|
||||||
|
<input
|
||||||
|
id="editIgName"
|
||||||
|
class={classes!("form-control")}
|
||||||
|
placeholder="Ingredient Name"
|
||||||
|
value={edit_state.as_ref().map(|s| s.1.name.clone())}
|
||||||
|
/>
|
||||||
|
<label for="editIgName">{"Ingredient name"}</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-floating">
|
||||||
|
<input
|
||||||
|
id="editIgUnit"
|
||||||
|
class={classes!("form-control")}
|
||||||
|
placeholder="Ingredient Unit"
|
||||||
|
value={edit_state.as_ref().map(|s| s.1.unit.clone().unwrap_or_default())}
|
||||||
|
/>
|
||||||
|
<label for="editIgUnit">{"Ingredient unit"}</label>
|
||||||
|
</div>
|
||||||
|
</FormModal>
|
||||||
|
</>},
|
||||||
|
Err(e) => html! {
|
||||||
|
{format!("Error fetching ingredients: {e:?}")}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn do_add_ingredient(
|
||||||
|
token: String,
|
||||||
|
household: Uuid,
|
||||||
|
name: String,
|
||||||
|
unit: String,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let rsp = gloo_net::http::Request::post(api!("household/{household}/ingredients"))
|
||||||
|
.header("Authorization", &format!("Bearer {token}"))
|
||||||
|
.json(&CreateIngredientRequest {
|
||||||
|
name,
|
||||||
|
unit: (!unit.is_empty()).then_some(unit),
|
||||||
|
})?
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !rsp.ok() {
|
||||||
|
let body = rsp.body();
|
||||||
|
match body {
|
||||||
|
None => anyhow::bail!("Could not add ingredient: {rsp:?}"),
|
||||||
|
Some(b) => anyhow::bail!("Could not add ingredient: {}", b.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component]
|
||||||
|
pub fn Ingredients() -> Html {
|
||||||
|
let fallback = html! { {"Loading..."} };
|
||||||
|
let global_state = use_state(RegaladeGlobalState::get);
|
||||||
|
|
||||||
|
let render_id = use_state(|| 0u64);
|
||||||
|
let error = use_state(|| None::<String>);
|
||||||
|
|
||||||
|
let token = global_state.token.token.clone();
|
||||||
|
let household = global_state.household.id;
|
||||||
|
let err = error.clone();
|
||||||
|
let render = render_id.clone();
|
||||||
|
let onsubmit = Callback::from(move |e: SubmitEvent| {
|
||||||
|
e.prevent_default();
|
||||||
|
|
||||||
|
let document = gloo_utils::document();
|
||||||
|
|
||||||
|
let name_elem: HtmlInputElement = document
|
||||||
|
.get_element_by_id("newIgName")
|
||||||
|
.unwrap()
|
||||||
|
.dyn_into()
|
||||||
|
.expect("editIgName is not an input element");
|
||||||
|
let name = name_elem.value();
|
||||||
|
|
||||||
|
let unit_elem: HtmlInputElement = document
|
||||||
|
.get_element_by_id("newIgUnit")
|
||||||
|
.unwrap()
|
||||||
|
.dyn_into()
|
||||||
|
.expect("editIgUnit is not an input element");
|
||||||
|
let unit = unit_elem.value();
|
||||||
|
|
||||||
|
let token = token.clone();
|
||||||
|
let err = err.clone();
|
||||||
|
let render = render.clone();
|
||||||
|
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
match do_add_ingredient(token, household, name, unit).await {
|
||||||
|
Ok(_) => {
|
||||||
|
name_elem.set_value("");
|
||||||
|
unit_elem.set_value("");
|
||||||
|
render.set(*render + 1);
|
||||||
|
}
|
||||||
|
Err(e) => err.set(Some(format!("Could not add ingredient: {e:?}"))),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div class="d-flex align-items-center justify-content-center w-100">
|
||||||
|
<div class={classes!("container", "text-center", "rounded", "border", "pt-2", "m-2")}>
|
||||||
|
<form {onsubmit}>
|
||||||
|
if let Some(err) = &*error {
|
||||||
|
<div class={classes!("alert", "alert-danger")} role="alert">
|
||||||
|
{err}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="form-floating">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="Ingredient Name"
|
||||||
|
id="newIgName"
|
||||||
|
/>
|
||||||
|
<label for="newIgName">{"Ingredient Name"}</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-floating my-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="Ingredient Unit"
|
||||||
|
id="newIgUnit"
|
||||||
|
/>
|
||||||
|
<label for="newIgUnit">{"Ingredient Unit"}</label>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary mt-2">
|
||||||
|
{"Add Ingredient"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<hr />
|
||||||
|
<Suspense {fallback}>
|
||||||
|
<IngredientList
|
||||||
|
token={global_state.token.token.clone()}
|
||||||
|
household={global_state.household.id}
|
||||||
|
render_id={*render_id}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,7 @@ use crate::{
|
||||||
|
|
||||||
mod bootstrap;
|
mod bootstrap;
|
||||||
mod sidebar;
|
mod sidebar;
|
||||||
|
mod ingredients;
|
||||||
|
|
||||||
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",
|
||||||
|
|
@ -461,7 +462,7 @@ fn switch(route: Route) -> Html {
|
||||||
},
|
},
|
||||||
Route::Ingredients => html! {
|
Route::Ingredients => html! {
|
||||||
<GlobalStateRedirector {route}>
|
<GlobalStateRedirector {route}>
|
||||||
{"Ingredients"}
|
<ingredients::Ingredients />
|
||||||
</GlobalStateRedirector>
|
</GlobalStateRedirector>
|
||||||
},
|
},
|
||||||
Route::HouseholdSelect => html! {
|
Route::HouseholdSelect => html! {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue