Implement a sycamore-state Handler for Kitchen

This commit is contained in:
Jeremy Wall 2022-12-23 13:34:40 -05:00
parent 907af7f23c
commit 02536d63d8
7 changed files with 228 additions and 37 deletions

8
Cargo.lock generated
View File

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

View File

@ -103,7 +103,7 @@ pub type CategoryResponse = Response<String>;
pub type EmptyResponse = Response<()>;
#[derive(Serialize, Deserialize, Debug)]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct UserData {
pub user_id: String,
}

View File

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

View File

@ -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");

View File

@ -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<String, usize>,
pub extras: BTreeSet<(String, String)>,
pub staples: Option<Recipe>,
pub recipes: BTreeMap<String, Recipe>,
pub category_map: BTreeMap<String, String>,
pub filtered_ingredients: BTreeSet<IngredientKey>,
pub modified_amts: BTreeMap<IngredientKey, String>,
pub auth: Option<UserData>,
}
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<String, usize>),
UpdateRecipeCount(String, usize),
InitExtras(BTreeSet<(String, String)>),
AddExtra(String, String),
RemoveExtra(String, String),
InitRecipes(BTreeMap<String, Recipe>),
SetRecipe(String, Recipe),
RemoveRecipe(String),
SetStaples(Option<Recipe>),
SetCategoryMap(BTreeMap<String, String>),
InitFilteredIngredient(BTreeSet<IngredientKey>),
AddFilteredIngredient(IngredientKey),
RemoveFilteredIngredient(IngredientKey),
InitAmts(BTreeMap<IngredientKey, String>),
UpdateAmt(IngredientKey, String),
SetUserData(UserData),
UnsetUserData,
}
// TODO(jwall): Add HttpStore here and do the proper things for each event.
pub struct StateMachine();
impl MessageMapper<Message, AppState> for StateMachine {
fn map(&self, msg: Message, original: &ReadSignal<AppState>) -> 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<BTreeMap<String, RcSignal<usize>>>,
@ -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<Self> {
use_context::<std::rc::Rc<Self>>(cx).clone()
}

View File

@ -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<G: Html>(cx: Scope) -> View<G> {
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),
pub fn Header<'ctx, G: Html>(
cx: Scope<'ctx>,
h: &'ctx Handler<'ctx, StateMachine, AppState, Message>,
) -> View<G> {
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") {

View File

@ -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<G: Html>(cx: Scope) -> View<G> {
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 { }
}