From 02536d63d8c3222d0b83e78a414d86d5d207df54 Mon Sep 17 00:00:00 2001 From: Jeremy Wall Date: Fri, 23 Dec 2022 13:34:40 -0500 Subject: [PATCH] Implement a sycamore-state Handler for Kitchen --- Cargo.lock | 8 +++ api/src/lib.rs | 2 +- web/Cargo.toml | 1 + web/src/api.rs | 87 +++++++++++++++++++++- web/src/app_state.rs | 135 ++++++++++++++++++++++++++++++----- web/src/components/header.rs | 18 ++--- web/src/web.rs | 14 ++-- 7 files changed, 228 insertions(+), 37 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3c670a8..8b213cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1344,6 +1344,7 @@ dependencies = [ "serde_json", "sycamore", "sycamore-router", + "sycamore-state", "tracing", "tracing-subscriber", "tracing-web", @@ -2249,6 +2250,13 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "sycamore-state" +version = "0.0.1" +dependencies = [ + "sycamore", +] + [[package]] name = "sycamore-web" version = "0.8.2" diff --git a/api/src/lib.rs b/api/src/lib.rs index 294ae2b..7f0dd1c 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -103,7 +103,7 @@ pub type CategoryResponse = Response; pub type EmptyResponse = Response<()>; -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct UserData { pub user_id: String, } diff --git a/web/Cargo.toml b/web/Cargo.toml index cf4fa30..990d670 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -15,6 +15,7 @@ crate-type = ["cdylib", "rlib"] [dependencies] recipes = { path = "../recipes" } client-api = { path = "../api", package="api", features = ["browser"] } +sycamore-state = { path = "../../sycamore-state"} # This makes debugging panics more tractable. console_error_panic_hook = "0.1.7" serde_json = "1.0.79" diff --git a/web/src/api.rs b/web/src/api.rs index 754970d..6863e2d 100644 --- a/web/src/api.rs +++ b/web/src/api.rs @@ -17,13 +17,17 @@ use base64; use reqwasm; use serde_json::{from_str, to_string}; use sycamore::prelude::*; +use sycamore_state::Handler; use tracing::{debug, error, info, instrument, warn}; use client_api::*; use recipes::{parse, IngredientKey, Recipe, RecipeEntry}; use wasm_bindgen::JsValue; -use crate::{app_state, js_lib}; +use crate::{ + app_state::{self, AppState, Message, StateMachine}, + js_lib, +}; #[instrument] fn filter_recipes( @@ -53,6 +57,87 @@ fn filter_recipes( } } +pub async fn init_app_state<'ctx>( + cx: Scope<'ctx>, + h: &'ctx Handler<'ctx, StateMachine, AppState, Message>, +) { + info!("Synchronizing Recipes"); + let store = HttpStore::get_from_context(cx); + // TODO(jwall): Make our caching logic using storage more robust. + let recipe_entries = match store.get_recipes().await { + Ok(recipe_entries) => { + if let Ok((staples, recipes)) = filter_recipes(&recipe_entries) { + h.dispatch(Message::SetStaples(staples)); + if let Some(recipes) = recipes { + h.dispatch(Message::InitRecipes(recipes)); + } + } + recipe_entries + } + Err(err) => { + error!(?err); + None + } + }; + + if let Ok(Some(plan)) = store.get_plan().await { + // set the counts. + let mut plan_map = BTreeMap::new(); + for (id, count) in plan { + plan_map.insert(id, count as usize); + } + h.dispatch(Message::InitRecipeCounts(plan_map)); + } else { + // Initialize things to zero + if let Some(rs) = recipe_entries { + for r in rs { + h.dispatch(Message::UpdateRecipeCount(r.recipe_id().to_owned(), 0)) + } + } + } + info!("Checking for user_data in local storage"); + let storage = js_lib::get_storage(); + let user_data = storage + .get("user_data") + .expect("Couldn't read from storage"); + if let Some(data) = user_data { + if let Ok(user_data) = from_str(&data) { + h.dispatch(Message::SetUserData(user_data)); + } + } + info!("Synchronizing categories"); + match store.get_categories().await { + Ok(Some(categories_content)) => { + debug!(categories=?categories_content); + match recipes::parse::as_categories(&categories_content) { + Ok(category_map) => { + h.dispatch(Message::SetCategoryMap(category_map)); + } + Err(err) => { + error!(?err) + } + }; + } + Ok(None) => { + warn!("There is no category file"); + } + Err(e) => { + error!("{:?}", e); + } + } + info!("Synchronizing inventory data"); + match store.get_inventory_data().await { + Ok((filtered_ingredients, modified_amts, extra_items)) => { + h.dispatch(Message::InitAmts(modified_amts)); + h.dispatch(Message::InitFilteredIngredient(filtered_ingredients)); + h.dispatch(Message::InitExtras(BTreeSet::from_iter(extra_items))); + } + Err(e) => { + error!("{:?}", e); + } + } +} + #[instrument(skip(state))] pub async fn init_page_state(store: &HttpStore, state: &app_state::State) -> Result<(), String> { info!("Synchronizing Recipes"); diff --git a/web/src/app_state.rs b/web/src/app_state.rs index 7d77fe5..81e921e 100644 --- a/web/src/app_state.rs +++ b/web/src/app_state.rs @@ -19,6 +19,124 @@ use tracing::{debug, instrument, warn}; use client_api::UserData; use recipes::{Ingredient, IngredientAccumulator, IngredientKey, Recipe}; +use sycamore_state::{Handler, MessageMapper}; + +#[derive(Debug, Clone, PartialEq)] +pub struct AppState { + pub recipe_counts: BTreeMap, + pub extras: BTreeSet<(String, String)>, + pub staples: Option, + pub recipes: BTreeMap, + pub category_map: BTreeMap, + pub filtered_ingredients: BTreeSet, + pub modified_amts: BTreeMap, + pub auth: Option, +} + +impl AppState { + pub fn new() -> Self { + Self { + recipe_counts: BTreeMap::new(), + extras: BTreeSet::new(), + staples: None, + recipes: BTreeMap::new(), + category_map: BTreeMap::new(), + filtered_ingredients: BTreeSet::new(), + modified_amts: BTreeMap::new(), + auth: None, + } + } +} + +pub enum Message { + InitRecipeCounts(BTreeMap), + UpdateRecipeCount(String, usize), + InitExtras(BTreeSet<(String, String)>), + AddExtra(String, String), + RemoveExtra(String, String), + InitRecipes(BTreeMap), + SetRecipe(String, Recipe), + RemoveRecipe(String), + SetStaples(Option), + SetCategoryMap(BTreeMap), + InitFilteredIngredient(BTreeSet), + AddFilteredIngredient(IngredientKey), + RemoveFilteredIngredient(IngredientKey), + InitAmts(BTreeMap), + UpdateAmt(IngredientKey, String), + SetUserData(UserData), + UnsetUserData, +} + +// TODO(jwall): Add HttpStore here and do the proper things for each event. +pub struct StateMachine(); + +impl MessageMapper for StateMachine { + fn map(&self, msg: Message, original: &ReadSignal) -> AppState { + let mut original_copy = original.get().as_ref().clone(); + match msg { + Message::InitRecipeCounts(map) => { + original_copy.recipe_counts = map; + } + Message::UpdateRecipeCount(id, count) => { + original_copy.recipe_counts.insert(id, count); + } + Message::InitExtras(set) => { + original_copy.extras = set; + } + Message::AddExtra(amt, name) => { + original_copy.extras.insert((amt, name)); + } + Message::RemoveExtra(amt, name) => { + original_copy.extras.remove(&(amt, name)); + } + Message::SetStaples(staples) => { + original_copy.staples = staples; + } + Message::InitRecipes(recipes) => { + original_copy.recipes = recipes; + } + Message::SetRecipe(id, recipe) => { + original_copy.recipes.insert(id, recipe); + } + Message::RemoveRecipe(id) => { + original_copy.recipes.remove(&id); + } + Message::SetCategoryMap(map) => { + original_copy.category_map = map; + } + Message::InitFilteredIngredient(set) => { + original_copy.filtered_ingredients = set; + } + Message::AddFilteredIngredient(key) => { + original_copy.filtered_ingredients.insert(key); + } + Message::RemoveFilteredIngredient(key) => { + original_copy.filtered_ingredients.remove(&key); + } + Message::InitAmts(map) => { + original_copy.modified_amts = map; + } + Message::UpdateAmt(key, amt) => { + original_copy.modified_amts.insert(key, amt); + } + Message::SetUserData(user_data) => { + original_copy.auth = Some(user_data); + } + Message::UnsetUserData => { + original_copy.auth = None; + } + } + original_copy + } +} + +pub fn get_state_handler<'ctx>( + cx: Scope<'ctx>, + initial: AppState, +) -> &'ctx Handler<'ctx, StateMachine, AppState, Message> { + Handler::new(cx, initial, StateMachine()) +} #[derive(Debug)] pub struct State { pub recipe_counts: RcSignal>>, @@ -32,23 +150,6 @@ pub struct State { } impl State { - pub fn new() -> Self { - Self { - recipe_counts: create_rc_signal(BTreeMap::new()), - extras: create_rc_signal(Vec::new()), - staples: create_rc_signal(None), - recipes: create_rc_signal(BTreeMap::new()), - category_map: create_rc_signal(BTreeMap::new()), - filtered_ingredients: create_rc_signal(BTreeSet::new()), - modified_amts: create_rc_signal(BTreeMap::new()), - auth: create_rc_signal(None), - } - } - - pub fn provide_context(cx: Scope) { - provide_context(cx, std::rc::Rc::new(Self::new())); - } - pub fn get_from_context(cx: Scope) -> std::rc::Rc { use_context::>(cx).clone() } diff --git a/web/src/components/header.rs b/web/src/components/header.rs index b3d45fc..80cf0a5 100644 --- a/web/src/components/header.rs +++ b/web/src/components/header.rs @@ -14,17 +14,17 @@ use sycamore::prelude::*; -use crate::app_state; +use crate::app_state::{AppState, Message, StateMachine}; +use sycamore_state::Handler; #[component] -pub fn Header(cx: Scope) -> View { - let state = app_state::State::get_from_context(cx); - let login = create_memo(cx, move || { - let user_id = state.auth.get(); - match user_id.as_ref() { - Some(user_data) => format!("{}", user_data.user_id), - None => "Login".to_owned(), - } +pub fn Header<'ctx, G: Html>( + cx: Scope<'ctx>, + h: &'ctx Handler<'ctx, StateMachine, AppState, Message>, +) -> View { + let login = h.get_selector(cx, |sig| match &sig.get().auth { + Some(id) => id.user_id.clone(), + None => "Login".to_owned(), }); view! {cx, nav(class="no-print") { diff --git a/web/src/web.rs b/web/src/web.rs index 6f3625b..efedbf3 100644 --- a/web/src/web.rs +++ b/web/src/web.rs @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. use sycamore::{futures::spawn_local_scoped, prelude::*}; -use tracing::{error, info, instrument}; +use tracing::{info, instrument}; use crate::components::{Footer, Header}; use crate::{api, routing::Handler as RouteHandler}; @@ -20,24 +20,20 @@ use crate::{api, routing::Handler as RouteHandler}; #[instrument] #[component] pub fn UI(cx: Scope) -> View { - crate::app_state::State::provide_context(cx); api::HttpStore::provide_context(cx, "/api".to_owned()); info!("Starting UI"); - + let app_state = crate::app_state::AppState::new(); + let handler = crate::app_state::get_state_handler(cx, app_state); let view = create_signal(cx, View::empty()); // FIXME(jwall): We need a way to trigger refreshes when required. Turn this // into a create_effect with a refresh signal stored as a context. spawn_local_scoped(cx, { - let store = api::HttpStore::get_from_context(cx); - let state = crate::app_state::State::get_from_context(cx); async move { - if let Err(err) = api::init_page_state(store.as_ref(), state.as_ref()).await { - error!(?err); - }; + api::init_app_state(cx, handler).await; // TODO(jwall): This needs to be moved into the RouteHandler view.set(view! { cx, div(class="app") { - Header { } + Header(handler) RouteHandler() Footer { } }