app,server: Allow to edit recipe ingredients

This commit is contained in:
traxys 2023-06-25 17:25:42 +02:00
parent 47b547caf4
commit 6004520fb9
6 changed files with 307 additions and 35 deletions

View file

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

View file

@ -10,8 +10,14 @@ pub mod bs {
#[wasm_bindgen(static_method_of = Modal, js_name = "getInstance")] #[wasm_bindgen(static_method_of = Modal, js_name = "getInstance")]
pub fn get_instance(selector: &str) -> Modal; pub fn get_instance(selector: &str) -> Modal;
#[wasm_bindgen(static_method_of = Modal, js_name = "getOrCreateInstance")]
pub fn get_or_create_instance(selector: &str) -> Modal;
#[wasm_bindgen(method)] #[wasm_bindgen(method)]
pub fn hide(this: &Modal); pub fn hide(this: &Modal);
#[wasm_bindgen(method)]
pub fn show(this: &Modal);
} }
} }

View file

@ -1,4 +1,5 @@
use api::{RecipeInfo, RecipeRenameRequest}; use api::{RecipeInfo, RecipeIngredientEditRequest, RecipeRenameRequest};
use itertools::Itertools;
use uuid::Uuid; use uuid::Uuid;
use wasm_bindgen::JsCast; use wasm_bindgen::JsCast;
use web_sys::HtmlInputElement; use web_sys::HtmlInputElement;
@ -10,7 +11,7 @@ use yew_router::prelude::*;
use crate::{ use crate::{
api, api,
bootstrap::{FormModal, ModalToggleButton, bs}, bootstrap::{bs, ConfirmDangerModal, FormModal, ModalToggleButton},
RegaladeGlobalState, Route, RegaladeGlobalState, Route,
}; };
@ -45,7 +46,7 @@ fn RecipeListInner(props: &RecipeListProps) -> HtmlResult {
Ok(l) => html! { Ok(l) => html! {
<div class="container text-center"> <div class="container text-center">
<div class="row row-cols-2 row-cols-sm-2 row-cols-md-4 g-2 mb-2"> <div class="row row-cols-2 row-cols-sm-2 row-cols-md-4 g-2 mb-2">
{for l.recipes.iter().map(|(id, name)| html!{ {for l.recipes.iter().sorted_by_key(|(_, name)| name).map(|(id, name)| html!{
<div class="col" key={*id}> <div class="col" key={*id}>
<div class="p-3 border rounded border-light-subtle h-100"> <div class="p-3 border rounded border-light-subtle h-100">
<Link<Route> <Link<Route>
@ -191,7 +192,7 @@ fn EditName(props: &EditNameProps) -> Html {
err.set(None); err.set(None);
update.emit(()); update.emit(());
}, }
Err(e) => { Err(e) => {
err.set(Some(format!("Could not edit name: {e}"))); err.set(Some(format!("Could not edit name: {e}")));
} }
@ -230,6 +231,146 @@ fn EditName(props: &EditNameProps) -> Html {
</>} </>}
} }
#[derive(Clone, PartialEq, Properties)]
struct EditIngredientProps {
token: String,
household: Uuid,
recipe: i64,
ingredient: i64,
amount: f64,
update: Callback<()>,
}
async fn do_edit_ingredient_recipe(
token: String,
household: Uuid,
recipe: i64,
ingredient: i64,
amount: f64,
) -> anyhow::Result<()> {
let rsp = gloo_net::http::Request::patch(api!(
"household/{household}/recipe/{recipe}/ingredients/{ingredient}"
))
.json(&RecipeIngredientEditRequest { 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(())
}
#[function_component]
fn EditIngredient(props: &EditIngredientProps) -> Html {
let amount = use_state(|| props.amount);
let am = amount.clone();
let onchange = Callback::from(move |e: Event| {
let Some(target) = e.target() else {
return;
};
let Ok(target) = target.dyn_into::<HtmlInputElement>() else {
return;
};
am.set(target.value().parse().unwrap());
});
let error = use_state(|| None::<String>);
let modal_id = format!("rcpEditIg{}", props.ingredient);
let am = amount.clone();
let err = error.clone();
let token = props.token.clone();
let household = props.household;
let recipe = props.recipe;
let ingredient = props.ingredient;
let update = props.update.clone();
let mid = modal_id.clone();
let on_submit = Callback::from(move |_| {
let future = do_edit_ingredient_recipe(token.clone(), household, recipe, ingredient, *am);
let err = err.clone();
let update = update.clone();
let modal_selector = format!("#{mid}");
wasm_bindgen_futures::spawn_local(async move {
match future.await {
Ok(_) => {
let modal = bs::Modal::get_instance(&modal_selector);
modal.hide();
err.set(None);
update.emit(());
}
Err(e) => {
err.set(Some(format!("Could not edit ingredient: {e}")));
}
}
})
});
let input_id = format!("{modal_id}Inp");
html! {<>
<FormModal
id={modal_id.clone()}
fade=true
centered=true
submit_label="Edit"
title="Edit Ingredient"
{on_submit}
>
if let Some(e) = &*error {
<div class={classes!("alert", "alert-danger")} role="alert">
{e}
</div>
}
<div class="form-floating">
<input
class="form-control"
id={input_id.clone()}
placeholder="Ingredient Amount"
value={amount.to_string()}
type="number"
step="any"
{onchange}
/>
<label for={input_id}>{"Ingredient Amount"}</label>
</div>
</FormModal>
<ModalToggleButton {modal_id}>
<i class="bi-pencil-fill" />
</ModalToggleButton>
</>}
}
async fn do_delete_ig(
token: String,
household: Uuid,
recipe: i64,
ingredient: i64,
) -> anyhow::Result<()> {
let rsp = gloo_net::http::Request::delete(api!(
"household/{household}/recipe/{recipe}/ingredients/{ingredient}"
))
.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(())
}
#[function_component] #[function_component]
fn RecipeViewerInner(props: &RecipeViewerInnerProps) -> HtmlResult { fn RecipeViewerInner(props: &RecipeViewerInnerProps) -> HtmlResult {
let recipe_render = use_state(|| 0u64); let recipe_render = use_state(|| 0u64);
@ -242,25 +383,85 @@ fn RecipeViewerInner(props: &RecipeViewerInnerProps) -> HtmlResult {
recipe_render.set((*recipe_render).wrapping_add(1)); recipe_render.set((*recipe_render).wrapping_add(1));
}); });
let error = use_state(|| None::<String>);
let mk_del_ig = |&id| {
let update = update.clone();
let token = props.token.clone();
let household = props.household;
let recipe = props.id;
let err = error.clone();
Callback::from(move |_| {
let update = update.clone();
let future = do_delete_ig(token.clone(), household, recipe, id);
let err = err.clone();
wasm_bindgen_futures::spawn_local(async move {
match future.await {
Err(e) => {
err.set(Some(format!("Could not delete ingredient: {e}")));
}
Ok(_) => {
update.emit(());
err.set(None);
}
}
})
})
};
Ok(match &*recipe { Ok(match &*recipe {
Ok(r) => html! {<> Ok(r) => html! {<>
<h1>{&r.name}</h1> <h1>{&r.name}</h1>
if let Some(e) = &*error {
<div class={classes!("alert", "alert-danger")} role="alert">
{e}
</div>
}
<EditName <EditName
token={props.token.clone()} token={props.token.clone()}
id={props.id} id={props.id}
household={props.household} household={props.household}
name={r.name.clone()} name={r.name.clone()}
{update} update={update.clone()}
/> />
<hr /> <hr />
<div class="text-start"> <div class="text-start">
<h2>{"Ingredients"}</h2> <h2>{"Ingredients"}</h2>
<ul class="list-group"> <ul class="list-group">
{for r.ingredients.iter().map(|(id, info, amount)| html!{ {for r.ingredients.iter().map(|(id, info, amount)| {
<li key={*id} class="list-group-item"> let delete_modal_id = format!("rcpRmIg{id}");
html!{
<li
key={*id}
class="list-group-item d-flex justify-content-between align-items-center"
>
{format!("{amount}{} {}", info.unit.as_deref().unwrap_or(""), info.name)} {format!("{amount}{} {}", info.unit.as_deref().unwrap_or(""), info.name)}
<div>
<ConfirmDangerModal
id={delete_modal_id.clone()}
title="Remove ingredient"
centered=true
on_confirm={mk_del_ig(id)}
>
{format!("Are you sure you to delete '{}'", info.name)}
</ConfirmDangerModal>
<ModalToggleButton
modal_id={delete_modal_id}
classes={classes!("btn", "btn-danger", "me-1")}
>
<i class="bi-trash3" />
</ModalToggleButton>
<EditIngredient
token={props.token.clone()}
recipe={props.id}
ingredient={*id}
household={props.household}
amount={*amount}
update={update.clone()}
/>
</div>
</li> </li>
})} }})}
</ul> </ul>
</div> </div>
<hr /> <hr />

View file

@ -1,5 +1,7 @@
use axum::{ use axum::{
extract::{Path, State}, async_trait,
extract::{FromRef, FromRequestParts, Path, State},
http::request::Parts,
Json, Json,
}; };
@ -10,7 +12,7 @@ use api::{
use sea_orm::{prelude::*, ActiveValue}; use sea_orm::{prelude::*, ActiveValue};
use serde::Deserialize; use serde::Deserialize;
use super::{household::AuthorizedHousehold, AppState, JsonResult}; use super::{household::AuthorizedHousehold, AppState, JsonResult, RouteError};
use crate::entity::{ingredient, prelude::*}; use crate::entity::{ingredient, prelude::*};
pub(super) async fn create_ingredient( pub(super) async fn create_ingredient(
@ -52,37 +54,57 @@ pub(super) async fn list_ingredients(
})) }))
} }
pub(super) struct IngredientExtractor(pub(super) ingredient::Model);
#[async_trait]
impl<S> FromRequestParts<S> for IngredientExtractor
where
S: Send + Sync,
AppState: FromRef<S>,
{
type Rejection = RouteError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let State(app_state): State<AppState> = State::from_request_parts(parts, state)
.await
.expect("No state");
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct IngredientId { pub struct IngredientId {
iid: i64, iid: i64,
} }
let household = AuthorizedHousehold::from_request_parts(parts, state).await?;
let Path(ingredient_id): Path<IngredientId> =
Path::from_request_parts(parts, state).await?;
match household
.0
.find_related(Ingredient)
.filter(ingredient::Column::Id.eq(ingredient_id.iid))
.one(&app_state.db)
.await?
{
None => Err(RouteError::RessourceNotFound),
Some(r) => Ok(Self(r)),
}
}
}
pub(super) async fn remove_ingredient( pub(super) async fn remove_ingredient(
AuthorizedHousehold(household): AuthorizedHousehold,
State(state): State<AppState>, State(state): State<AppState>,
Path(IngredientId { iid }): Path<IngredientId>, IngredientExtractor(ingredient): IngredientExtractor,
) -> JsonResult<EmptyResponse> { ) -> JsonResult<EmptyResponse> {
Ingredient::delete_by_id(iid) ingredient.delete(&state.db).await?;
.filter(ingredient::Column::Household.eq(household.id))
.exec(&state.db)
.await?;
Ok(Json(EmptyResponse {})) Ok(Json(EmptyResponse {}))
} }
pub(super) async fn edit_ingredient( pub(super) async fn edit_ingredient(
AuthorizedHousehold(household): AuthorizedHousehold,
State(state): State<AppState>, State(state): State<AppState>,
Path(IngredientId { iid }): Path<IngredientId>, IngredientExtractor(ingredient): IngredientExtractor,
Json(request): Json<EditIngredientRequest>, Json(request): Json<EditIngredientRequest>,
) -> JsonResult<EmptyResponse> { ) -> JsonResult<EmptyResponse> {
let Some(ingredient) = Ingredient::find_by_id(iid)
.filter(ingredient::Column::Household.eq(household.id))
.one(&state.db)
.await? else {
return Err(super::RouteError::RessourceNotFound);
};
let mut ingredient: ingredient::ActiveModel = ingredient.into(); let mut ingredient: ingredient::ActiveModel = ingredient.into();
if let Some(name) = request.name { if let Some(name) = request.name {

View file

@ -11,7 +11,7 @@ use axum::{
HeaderValue, Method, StatusCode, HeaderValue, Method, StatusCode,
}, },
response::IntoResponse, response::IntoResponse,
routing::{delete, get, post, put}, routing::{delete, get, patch, post, put},
Json, Router, TypedHeader, Json, Router, TypedHeader,
}; };
use jwt_simple::prelude::*; use jwt_simple::prelude::*;
@ -228,4 +228,10 @@ pub(crate) fn router(api_allowed: Option<HeaderValue>) -> Router<AppState> {
.patch(recipe::edit_name) .patch(recipe::edit_name)
.layer(mk_service(vec![Method::GET, Method::PATCH])), .layer(mk_service(vec![Method::GET, Method::PATCH])),
) )
.route(
"/household/:house_id/recipe/:recipe_id/ingredients/:iid",
patch(recipe::edit_ig_amount)
.delete(recipe::delete_ig)
.layer(mk_service(vec![Method::PATCH, Method::DELETE])),
)
} }

View file

@ -1,6 +1,6 @@
use api::{ use api::{
CreateRecipeRequest, CreateRecipeResponse, EmptyResponse, IngredientInfo, ListRecipesResponse, CreateRecipeRequest, CreateRecipeResponse, EmptyResponse, IngredientInfo, ListRecipesResponse,
RecipeInfo, RecipeRenameRequest, RecipeInfo, RecipeIngredientEditRequest, RecipeRenameRequest,
}; };
use axum::{ use axum::{
async_trait, async_trait,
@ -12,7 +12,10 @@ use sea_orm::{prelude::*, ActiveValue, TransactionTrait};
use crate::entity::{ingredient, prelude::*, recipe, recipe_ingerdients, recipe_steps}; use crate::entity::{ingredient, prelude::*, recipe, recipe_ingerdients, recipe_steps};
use super::{household::AuthorizedHousehold, AppState, JsonResult, RouteError}; use super::{
household::AuthorizedHousehold, ingredients::IngredientExtractor, AppState, JsonResult,
RouteError,
};
pub(super) async fn create_recipe( pub(super) async fn create_recipe(
AuthorizedHousehold(household): AuthorizedHousehold, AuthorizedHousehold(household): AuthorizedHousehold,
@ -176,3 +179,32 @@ pub(super) async fn edit_name(
Ok(EmptyResponse {}.into()) Ok(EmptyResponse {}.into())
} }
pub(super) async fn edit_ig_amount(
State(state): State<AppState>,
RecipeExtractor(recipe): RecipeExtractor,
IngredientExtractor(ingredient): IngredientExtractor,
Json(req): Json<RecipeIngredientEditRequest>,
) -> JsonResult<EmptyResponse> {
let active_model = recipe_ingerdients::ActiveModel {
recipe_id: ActiveValue::Set(recipe.id),
ingredient_id: ActiveValue::Set(ingredient.id),
amount: ActiveValue::Set(req.amount),
};
active_model.update(&state.db).await?;
Ok(EmptyResponse {}.into())
}
pub(super) async fn delete_ig(
State(state): State<AppState>,
RecipeExtractor(recipe): RecipeExtractor,
IngredientExtractor(ingredient): IngredientExtractor,
) -> JsonResult<EmptyResponse> {
RecipeIngerdients::delete_by_id((recipe.id, ingredient.id))
.exec(&state.db)
.await?;
Ok(EmptyResponse {}.into())
}