diff --git a/kitchen/sqlx-data.json b/kitchen/sqlx-data.json index 1cf8d4f..4e621a1 100644 --- a/kitchen/sqlx-data.json +++ b/kitchen/sqlx-data.json @@ -18,17 +18,7 @@ }, "query": "select password_hashed from users where id = ?" }, - "2519fa6cd665764d0c9a0152e5c8ce20152fc4853c5cd9c34259cec27d8bf47e": { - "describe": { - "columns": [], - "nullable": [], - "parameters": { - "Right": 2 - } - }, - "query": "insert into categories (user_id, category_text) values (?, ?)" - }, - "3311b1ceb15128dca0a3554f1e0f50c03e6c531919a6be1e7f9f940544236143": { + "3fd4017569dca4fe73e97e0e2bd612027a8c1b17b0b10faabd6459f56ca1c0bb": { "describe": { "columns": [], "nullable": [], @@ -36,7 +26,7 @@ "Right": 3 } }, - "query": "insert into recipes (user_id, recipe_id, recipe_text) values (?, ?, ?)" + "query": "insert into recipes (user_id, recipe_id, recipe_text) values (?, ?, ?)\n on conflict(user_id, recipe_id) do update set recipe_text=excluded.recipe_text" }, "5d743897fb0d8fd54c3708f1b1c6e416346201faa9e28823c1ba5a421472b1fa": { "describe": { @@ -58,6 +48,16 @@ }, "query": "delete from sessions where id = ?" }, + "8490e1bb40879caed62ac1c38cb9af48246f3451b6f7f1e1f33850f1dbe25f58": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Right": 2 + } + }, + "query": "insert into categories (user_id, category_text) values (?, ?)\n on conflict(user_id) do update set category_text=excluded.category_text" + }, "928a479ca0f765ec7715bf8784c5490e214486edbf5b78fd501823feb328375b": { "describe": { "columns": [ diff --git a/kitchen/src/web/mod.rs b/kitchen/src/web/mod.rs index 91d3140..30447b6 100644 --- a/kitchen/src/web/mod.rs +++ b/kitchen/src/web/mod.rs @@ -137,7 +137,7 @@ async fn api_categories( async fn api_save_categories( Extension(app_store): Extension>, session: storage::UserIdFromSession, - categories: String, + Json(categories): Json, ) -> impl IntoResponse { use storage::{UserId, UserIdFromSession::FoundUserId}; if let FoundUserId(UserId(id)) = session { @@ -159,12 +159,12 @@ async fn api_save_categories( async fn api_save_recipe( Extension(app_store): Extension>, session: storage::UserIdFromSession, - Json(recipe): Json, + Json(recipes): Json>, ) -> impl IntoResponse { use storage::{UserId, UserIdFromSession::FoundUserId}; if let FoundUserId(UserId(id)) = session { if let Err(e) = app_store - .store_recipes_for_user(id.as_str(), &vec![recipe]) + .store_recipes_for_user(id.as_str(), &recipes) .await { return (StatusCode::INTERNAL_SERVER_ERROR, format!("{:?}", e)); diff --git a/kitchen/src/web/storage/mod.rs b/kitchen/src/web/storage/mod.rs index dd3b3f6..5d57145 100644 --- a/kitchen/src/web/storage/mod.rs +++ b/kitchen/src/web/storage/mod.rs @@ -307,7 +307,8 @@ impl APIStore for SqliteStore { let recipe_id = entry.recipe_id().to_owned(); let recipe_text = entry.recipe_text().to_owned(); sqlx::query!( - "insert into recipes (user_id, recipe_id, recipe_text) values (?, ?, ?)", + "insert into recipes (user_id, recipe_id, recipe_text) values (?, ?, ?) + on conflict(user_id, recipe_id) do update set recipe_text=excluded.recipe_text", user_id, recipe_id, recipe_text, @@ -320,7 +321,8 @@ impl APIStore for SqliteStore { async fn store_categories_for_user(&self, user_id: &str, categories: &str) -> Result<()> { sqlx::query!( - "insert into categories (user_id, category_text) values (?, ?)", + "insert into categories (user_id, category_text) values (?, ?) + on conflict(user_id) do update set category_text=excluded.category_text", user_id, categories, ) diff --git a/recipe-store/src/lib.rs b/recipe-store/src/lib.rs index a0ca4da..e1e2234 100644 --- a/recipe-store/src/lib.rs +++ b/recipe-store/src/lib.rs @@ -19,8 +19,6 @@ use async_std::{ stream::StreamExt, }; use async_trait::async_trait; -#[cfg(target_arch = "wasm32")] -use reqwasm; use serde::{Deserialize, Serialize}; #[cfg(not(target_arch = "wasm32"))] use tracing::warn; @@ -47,13 +45,6 @@ impl From for Error { } } -#[cfg(target_arch = "wasm32")] -impl From for Error { - fn from(item: reqwasm::Error) -> Self { - Error(format!("{:?}", item)) - } -} - pub trait TenantStoreFactory where S: RecipeStore, @@ -157,50 +148,3 @@ impl RecipeStore for AsyncFileStore { Ok(Some(entry_vec)) } } - -#[cfg(target_arch = "wasm32")] -#[derive(Clone, Debug)] -pub struct HttpStore { - root: String, -} - -#[cfg(target_arch = "wasm32")] -impl HttpStore { - pub fn new(root: String) -> Self { - Self { root } - } -} - -#[cfg(target_arch = "wasm32")] -#[async_trait(?Send)] -impl RecipeStore for HttpStore { - #[instrument] - async fn get_categories(&self) -> Result, Error> { - let mut path = self.root.clone(); - path.push_str("/categories"); - let resp = reqwasm::http::Request::get(&path).send().await?; - if resp.status() == 404 { - debug!("Categories returned 404"); - Ok(None) - } else if resp.status() != 200 { - Err(format!("Status: {}", resp.status()).into()) - } else { - debug!("We got a valid response back!"); - let resp = resp.text().await; - Ok(Some(resp.map_err(|e| format!("{}", e))?)) - } - } - - #[instrument] - async fn get_recipes(&self) -> Result>, Error> { - let mut path = self.root.clone(); - path.push_str("/recipes"); - let resp = reqwasm::http::Request::get(&path).send().await?; - if resp.status() != 200 { - Err(format!("Status: {}", resp.status()).into()) - } else { - debug!("We got a valid response back!"); - Ok(resp.json().await.map_err(|e| format!("{}", e))?) - } - } -} diff --git a/web/src/components/recipe.rs b/web/src/components/recipe.rs index bd75139..8453108 100644 --- a/web/src/components/recipe.rs +++ b/web/src/components/recipe.rs @@ -1,4 +1,3 @@ -use recipe_store::RecipeEntry; // Copyright 2022 Jeremy Wall // // Licensed under the Apache License, Version 2.0 (the "License"); @@ -12,11 +11,12 @@ use recipe_store::RecipeEntry; // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -use sycamore::prelude::*; -use tracing::error; +use sycamore::{futures::spawn_local_in_scope, prelude::*}; +use tracing::{debug, error}; use web_sys::HtmlDialogElement; use crate::{js_lib::get_element_by_id, service::get_appservice_from_context}; +use recipe_store::RecipeEntry; use recipes; fn get_error_dialog() -> HtmlDialogElement { @@ -42,8 +42,27 @@ fn check_recipe_parses(text: &str, error_text: Signal) -> bool { #[component(Editor)] fn editor(recipe: RecipeEntry) -> View { + let id = Signal::new(recipe.recipe_id().to_owned()); let text = Signal::new(recipe.recipe_text().to_owned()); let error_text = Signal::new(String::new()); + let app_service = get_appservice_from_context(); + let save_signal = Signal::new(()); + + create_effect( + cloned!((id, app_service, text, save_signal, error_text) => move || { + save_signal.get(); + spawn_local_in_scope({ + cloned!((id, app_service, text, error_text) => async move { + if let Err(e) = app_service + .save_recipes(vec![RecipeEntry(id.get_untracked().as_ref().clone(), text.get_untracked().as_ref().clone())]) + .await { + error!(?e, "Failed to save recipe"); + error_text.set(format!("{:?}", e)); + }; + }) + }); + }), + ); let dialog_view = cloned!((error_text) => view! { dialog(id="error-dialog") { @@ -72,7 +91,8 @@ fn editor(recipe: RecipeEntry) -> View { a(role="button", href="#", on:click=cloned!((text, error_text) => move |_| { let unparsed = text.get(); if check_recipe_parses(unparsed.as_str(), error_text.clone()) { - // TODO(jwall): Now actually save the recipe? + debug!("triggering a save"); + save_signal.trigger_subscribers(); }; })) { "Save" } }) diff --git a/web/src/service.rs b/web/src/service.rs index 8378c26..aff8d5e 100644 --- a/web/src/service.rs +++ b/web/src/service.rs @@ -13,43 +13,33 @@ // limitations under the License. use std::collections::{BTreeMap, BTreeSet}; +use reqwasm; +//use serde::{Deserialize, Serialize}; use serde_json::{from_str, to_string}; use sycamore::{context::use_context, prelude::*}; use tracing::{debug, error, info, instrument, warn}; -use wasm_bindgen::JsCast; -use web_sys::{window, Element, Storage}; +use web_sys::Storage; use recipe_store::*; use recipes::{parse, Ingredient, IngredientAccumulator, Recipe}; use crate::js_lib; -#[cfg(not(target_arch = "wasm32"))] -pub fn get_appservice_from_context() -> AppService { - use_context::>() -} -#[cfg(target_arch = "wasm32")] -pub fn get_appservice_from_context() -> AppService { - use_context::>() +pub fn get_appservice_from_context() -> AppService { + use_context::() } #[derive(Clone, Debug)] -pub struct AppService -where - S: RecipeStore, -{ +pub struct AppService { recipes: Signal>>, staples: Signal>, category_map: Signal>, menu_list: Signal>, - store: S, + store: HttpStore, } -impl AppService -where - S: RecipeStore, -{ - pub fn new(store: S) -> Self { +impl AppService { + pub fn new(store: HttpStore) -> Self { Self { recipes: Signal::new(BTreeMap::new()), staples: Signal::new(None), @@ -262,6 +252,16 @@ where .collect() } + pub async fn save_recipes(&self, recipes: Vec) -> Result<(), String> { + self.store.save_recipes(recipes).await?; + Ok(()) + } + + pub async fn save_categories(&self, categories: String) -> Result<(), String> { + self.store.save_categories(categories).await?; + Ok(()) + } + pub fn set_recipes(&mut self, recipes: BTreeMap) { self.recipes.set( recipes @@ -275,3 +275,111 @@ where self.category_map.set(categories); } } + +#[derive(Debug)] +pub struct Error(String); + +impl From for Error { + fn from(item: std::io::Error) -> Self { + Error(format!("{:?}", item)) + } +} + +impl From for String { + fn from(item: Error) -> Self { + format!("{:?}", item) + } +} + +impl From for Error { + fn from(item: String) -> Self { + Error(item) + } +} + +impl From for Error { + fn from(item: std::string::FromUtf8Error) -> Self { + Error(format!("{:?}", item)) + } +} + +impl From for Error { + fn from(item: reqwasm::Error) -> Self { + Error(format!("{:?}", item)) + } +} + +#[derive(Clone, Debug)] +pub struct HttpStore { + root: String, +} + +impl HttpStore { + pub fn new(root: String) -> Self { + Self { root } + } + + #[instrument] + async fn get_categories(&self) -> Result, Error> { + let mut path = self.root.clone(); + path.push_str("/categories"); + let resp = reqwasm::http::Request::get(&path).send().await?; + if resp.status() == 404 { + debug!("Categories returned 404"); + Ok(None) + } else if resp.status() != 200 { + Err(format!("Status: {}", resp.status()).into()) + } else { + debug!("We got a valid response back!"); + let resp = resp.text().await; + Ok(Some(resp.map_err(|e| format!("{}", e))?)) + } + } + + #[instrument] + async fn get_recipes(&self) -> Result>, Error> { + let mut path = self.root.clone(); + path.push_str("/recipes"); + let resp = reqwasm::http::Request::get(&path).send().await?; + if resp.status() != 200 { + Err(format!("Status: {}", resp.status()).into()) + } else { + debug!("We got a valid response back!"); + Ok(resp.json().await.map_err(|e| format!("{}", e))?) + } + } + + #[instrument(skip(recipes), fields(count=recipes.len()))] + async fn save_recipes(&self, recipes: Vec) -> Result<(), Error> { + let mut path = self.root.clone(); + path.push_str("/recipes"); + let resp = reqwasm::http::Request::post(&path) + .body(to_string(&recipes).expect("Unable to serialize recipe entries")) + .header("content-type", "application/json") + .send() + .await?; + if resp.status() != 200 { + Err(format!("Status: {}", resp.status()).into()) + } else { + debug!("We got a valid response back!"); + Ok(()) + } + } + + #[instrument(skip(categories))] + async fn save_categories(&self, categories: String) -> Result<(), Error> { + let mut path = self.root.clone(); + path.push_str("/recipes"); + let resp = reqwasm::http::Request::post(&path) + .body(to_string(&categories).expect("Unable to encode categories as json")) + .header("content-type", "application/json") + .send() + .await?; + if resp.status() != 200 { + Err(format!("Status: {}", resp.status()).into()) + } else { + debug!("We got a valid response back!"); + Ok(()) + } + } +} diff --git a/web/src/web.rs b/web/src/web.rs index bcc511b..b128c6e 100644 --- a/web/src/web.rs +++ b/web/src/web.rs @@ -12,10 +12,14 @@ // See the License for the specific language governing permissions and // limitations under the License. use crate::pages::*; -use crate::{app_state::*, components::*, router_integration::*, service::AppService}; +use crate::{ + app_state::*, + components::*, + router_integration::*, + service::{self, AppService}, +}; use tracing::{debug, error, info, instrument}; -use recipe_store::{self, *}; use sycamore::{ context::{ContextProvider, ContextProviderProps}, futures::spawn_local_in_scope, @@ -56,13 +60,8 @@ fn route_switch(route: ReadSignal) -> View { }) } -#[cfg(not(target_arch = "wasm32"))] -fn get_appservice() -> AppService { - AppService::new(recipe_store::AsyncFileStore::new("/".to_owned())) -} -#[cfg(target_arch = "wasm32")] -fn get_appservice() -> AppService { - AppService::new(recipe_store::HttpStore::new("/api/v1".to_owned())) +fn get_appservice() -> AppService { + AppService::new(service::HttpStore::new("/api/v1".to_owned())) } #[instrument]