Implement Save Recipe functionality

This commit is contained in:
Jeremy Wall 2022-09-08 20:17:46 -04:00
parent a8407d51ef
commit 3094dee9f7
7 changed files with 178 additions and 105 deletions

View File

@ -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": [

View File

@ -137,7 +137,7 @@ async fn api_categories(
async fn api_save_categories(
Extension(app_store): Extension<Arc<storage::SqliteStore>>,
session: storage::UserIdFromSession,
categories: String,
Json(categories): Json<String>,
) -> 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<Arc<storage::SqliteStore>>,
session: storage::UserIdFromSession,
Json(recipe): Json<RecipeEntry>,
Json(recipes): Json<Vec<RecipeEntry>>,
) -> 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));

View File

@ -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,
)

View File

@ -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<std::string::FromUtf8Error> for Error {
}
}
#[cfg(target_arch = "wasm32")]
impl From<reqwasm::Error> for Error {
fn from(item: reqwasm::Error) -> Self {
Error(format!("{:?}", item))
}
}
pub trait TenantStoreFactory<S>
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<Option<String>, 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<Option<Vec<RecipeEntry>>, 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))?)
}
}
}

View File

@ -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<String>) -> bool {
#[component(Editor<G>)]
fn editor(recipe: RecipeEntry) -> View<G> {
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<G> {
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" }
})

View File

@ -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<AsyncFileStore> {
use_context::<AppService<AsyncFileStore>>()
}
#[cfg(target_arch = "wasm32")]
pub fn get_appservice_from_context() -> AppService<HttpStore> {
use_context::<AppService<HttpStore>>()
pub fn get_appservice_from_context() -> AppService {
use_context::<AppService>()
}
#[derive(Clone, Debug)]
pub struct AppService<S>
where
S: RecipeStore,
{
pub struct AppService {
recipes: Signal<BTreeMap<String, Signal<Recipe>>>,
staples: Signal<Option<Recipe>>,
category_map: Signal<BTreeMap<String, String>>,
menu_list: Signal<BTreeMap<String, usize>>,
store: S,
store: HttpStore,
}
impl<S> AppService<S>
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<RecipeEntry>) -> 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<String, Recipe>) {
self.recipes.set(
recipes
@ -275,3 +275,111 @@ where
self.category_map.set(categories);
}
}
#[derive(Debug)]
pub struct Error(String);
impl From<std::io::Error> for Error {
fn from(item: std::io::Error) -> Self {
Error(format!("{:?}", item))
}
}
impl From<Error> for String {
fn from(item: Error) -> Self {
format!("{:?}", item)
}
}
impl From<String> for Error {
fn from(item: String) -> Self {
Error(item)
}
}
impl From<std::string::FromUtf8Error> for Error {
fn from(item: std::string::FromUtf8Error) -> Self {
Error(format!("{:?}", item))
}
}
impl From<reqwasm::Error> 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<Option<String>, 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<Option<Vec<RecipeEntry>>, 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<RecipeEntry>) -> 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(())
}
}
}

View File

@ -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<G: Html>(route: ReadSignal<AppRoutes>) -> View<G> {
})
}
#[cfg(not(target_arch = "wasm32"))]
fn get_appservice() -> AppService<AsyncFileStore> {
AppService::new(recipe_store::AsyncFileStore::new("/".to_owned()))
}
#[cfg(target_arch = "wasm32")]
fn get_appservice() -> AppService<HttpStore> {
AppService::new(recipe_store::HttpStore::new("/api/v1".to_owned()))
fn get_appservice() -> AppService {
AppService::new(service::HttpStore::new("/api/v1".to_owned()))
}
#[instrument]