Merge the state management experiment into main

This commit is contained in:
Jeremy Wall 2023-01-04 18:39:21 -05:00
commit f7cf7bd468
30 changed files with 1340 additions and 1097 deletions

3
.gitignore vendored
View File

@ -6,4 +6,5 @@ webdist/
nix/*/result nix/*/result
result result
.vscode/ .vscode/
.session_store/ .session_store/
.gitignore/

681
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -14,6 +14,7 @@ let
# incorrect. We override those here. # incorrect. We override those here.
"sycamore-0.8.2" = "sha256-D968+8C5EelGGmot9/LkAlULZOf/Cr+1WYXRCMwb1nQ="; "sycamore-0.8.2" = "sha256-D968+8C5EelGGmot9/LkAlULZOf/Cr+1WYXRCMwb1nQ=";
"sqlx-0.6.2" = "sha256-X/LFvtzRfiOIEZJiVzmFvvULPpjhqvI99pSwH7a//GM="; "sqlx-0.6.2" = "sha256-X/LFvtzRfiOIEZJiVzmFvvULPpjhqvI99pSwH7a//GM=";
"sycamore-state-0.0.1" = "sha256-RatNr1b6r7eP3fOVatHA44D9xhDAljqSIWtFpMeBA9Y=";
}; };
}); });
in in

View File

@ -51,6 +51,14 @@ pub fn as_categories(i: &str) -> std::result::Result<BTreeMap<String, String>, S
} }
} }
pub fn as_measure(i: &str) -> std::result::Result<Measure, String> {
match measure(StrIter::new(i)) {
Result::Abort(e) | Result::Fail(e) => Err(format_err(e)),
Result::Incomplete(_) => Err(format!("Incomplete categories list can not parse")),
Result::Complete(_, m) => Ok(m),
}
}
make_fn!( make_fn!(
pub categories<StrIter, BTreeMap<String, String>>, pub categories<StrIter, BTreeMap<String, String>>,
do_each!( do_each!(

View File

@ -15,6 +15,7 @@ crate-type = ["cdylib", "rlib"]
[dependencies] [dependencies]
recipes = { path = "../recipes" } recipes = { path = "../recipes" }
client-api = { path = "../api", package="api", features = ["browser"] } client-api = { path = "../api", package="api", features = ["browser"] }
sycamore-state = { git="https://github.com/zaphar/sycamore-state", rev="v0.1.0" }
# This makes debugging panics more tractable. # This makes debugging panics more tractable.
console_error_panic_hook = "0.1.7" console_error_panic_hook = "0.1.7"
serde_json = "1.0.79" serde_json = "1.0.79"

View File

@ -17,14 +17,16 @@ use base64;
use reqwasm; use reqwasm;
use serde_json::{from_str, to_string}; use serde_json::{from_str, to_string};
use sycamore::prelude::*; use sycamore::prelude::*;
use tracing::{debug, error, info, instrument, warn}; use tracing::{debug, error, instrument, warn};
use client_api::*; use client_api::*;
use recipes::{parse, IngredientKey, Recipe, RecipeEntry}; use recipes::{parse, IngredientKey, Recipe, RecipeEntry};
use wasm_bindgen::JsValue; use wasm_bindgen::JsValue;
use web_sys::Storage;
use crate::{app_state, js_lib}; use crate::{app_state::AppState, js_lib};
// FIXME(jwall): We should be able to delete this now.
#[instrument] #[instrument]
fn filter_recipes( fn filter_recipes(
recipe_entries: &Option<Vec<RecipeEntry>>, recipe_entries: &Option<Vec<RecipeEntry>>,
@ -53,79 +55,6 @@ fn filter_recipes(
} }
} }
#[instrument(skip(state))]
pub async fn init_page_state(store: &HttpStore, state: &app_state::State) -> Result<(), String> {
info!("Synchronizing Recipes");
// TODO(jwall): Make our caching logic using storage more robust.
let recipes = store.get_recipes().await.map_err(|e| format!("{:?}", e))?;
if let Ok((staples, recipes)) = filter_recipes(&recipes) {
state.staples.set(staples);
if let Some(recipes) = recipes {
state.recipes.set(recipes);
}
}
if let Ok(Some(plan)) = store.get_plan().await {
// set the counts.
for (id, count) in plan {
state.set_recipe_count_by_index(&id, count as usize);
}
} else {
// Initialize things to zero
if let Some(rs) = recipes {
for r in rs {
if !state.recipe_counts.get().contains_key(r.recipe_id()) {
state.set_recipe_count_by_index(&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) {
state.auth.set(user_data)
}
}
info!("Synchronizing categories");
match store.get_categories().await {
Ok(Some(categories_content)) => {
debug!(categories=?categories_content);
let category_map = recipes::parse::as_categories(&categories_content)?;
state.category_map.set(category_map);
}
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, mut extra_items)) => {
state.reset_modified_amts(modified_amts);
state.filtered_ingredients.set(filtered_ingredients);
state.extras.set(
extra_items
.drain(0..)
.enumerate()
.map(|(idx, (amt, name))| {
(idx, (create_rc_signal(amt.clone()), create_rc_signal(name)))
})
.collect(),
)
}
Err(e) => {
error!("{:?}", e);
}
}
Ok(())
}
#[derive(Debug)] #[derive(Debug)]
pub struct Error(String); pub struct Error(String);
@ -179,14 +108,224 @@ fn token68(user: String, pass: String) -> String {
base64::encode(format!("{}:{}", user, pass)) base64::encode(format!("{}:{}", user, pass))
} }
#[derive(Clone, Debug)]
pub struct LocalStore {
store: Storage,
}
impl LocalStore {
pub fn new() -> Self {
Self {
store: js_lib::get_storage(),
}
}
/// Gets user data from local storage.
pub fn get_user_data(&self) -> Option<UserData> {
self.store
.get("user_data")
.map_or(None, |val| val.map(|val| from_str(&val).unwrap_or(None)))
.flatten()
}
// Set's user data to local storage.
pub fn set_user_data(&self, data: Option<&UserData>) {
if let Some(data) = data {
self.store
.set(
"user_data",
&to_string(data).expect("Failed to desrialize user_data"),
)
.expect("Failed to set user_data");
} else {
self.store
.delete("user_data")
.expect("Failed to delete user_data");
}
}
/// Gets categories from local storage.
pub fn get_categories(&self) -> Option<String> {
self.store
.get("categories")
.expect("Failed go get categories")
}
/// Set the categories to the given string.
pub fn set_categories(&self, categories: Option<&String>) {
if let Some(c) = categories {
self.store
.set("categories", c)
.expect("Failed to set categories");
} else {
self.store
.delete("categories")
.expect("Failed to delete categories")
}
}
fn get_storage_keys(&self) -> Vec<String> {
let mut keys = Vec::new();
for idx in 0..self.store.length().unwrap() {
if let Some(k) = self.store.key(idx).expect("Failed to get storage key") {
keys.push(k)
}
}
keys
}
fn get_recipe_keys(&self) -> impl Iterator<Item = String> {
self.get_storage_keys()
.into_iter()
.filter(|k| k.starts_with("recipe:"))
}
/// Gets all the recipes from local storage.
pub fn get_recipes(&self) -> Option<Vec<RecipeEntry>> {
let mut recipe_list = Vec::new();
for recipe_key in self.get_recipe_keys() {
if let Some(entry) = self
.store
.get(&recipe_key)
.expect(&format!("Failed to get recipe: {}", recipe_key))
{
match from_str(&entry) {
Ok(entry) => {
recipe_list.push(entry);
}
Err(e) => {
error!(recipe_key, err = ?e, "Failed to parse recipe entry");
}
}
}
}
if recipe_list.is_empty() {
return None;
}
Some(recipe_list)
}
pub fn get_recipe_entry(&self, id: &str) -> Option<RecipeEntry> {
let key = recipe_key(id);
self.store
.get(&key)
.expect(&format!("Failed to get recipe {}", key))
.map(|entry| from_str(&entry).expect(&format!("Failed to get recipe {}", key)))
}
/// Sets the set of recipes to the entries passed in. Deletes any recipes not
/// in the list.
pub fn set_all_recipes(&self, entries: &Vec<RecipeEntry>) {
for recipe_key in self.get_recipe_keys() {
self.store
.delete(&recipe_key)
.expect(&format!("Failed to get recipe {}", recipe_key));
}
for entry in entries {
self.set_recipe_entry(entry);
}
}
/// Set recipe entry in local storage.
pub fn set_recipe_entry(&self, entry: &RecipeEntry) {
self.store
.set(
&recipe_key(entry.recipe_id()),
&to_string(&entry).expect(&format!("Failed to get recipe {}", entry.recipe_id())),
)
.expect(&format!("Failed to store recipe {}", entry.recipe_id()))
}
/// Delete recipe entry from local storage.
pub fn delete_recipe_entry(&self, recipe_id: &str) {
self.store
.delete(&recipe_key(recipe_id))
.expect(&format!("Failed to delete recipe {}", recipe_id))
}
/// Save working plan to local storage.
pub fn save_plan(&self, plan: &Vec<(String, i32)>) {
self.store
.set("plan", &to_string(&plan).expect("Failed to serialize plan"))
.expect("Failed to store plan'");
}
pub fn get_plan(&self) -> Option<Vec<(String, i32)>> {
if let Some(plan) = self.store.get("plan").expect("Failed to store plan") {
Some(from_str(&plan).expect("Failed to deserialize plan"))
} else {
None
}
}
pub fn get_inventory_data(
&self,
) -> Option<(
BTreeSet<IngredientKey>,
BTreeMap<IngredientKey, String>,
Vec<(String, String)>,
)> {
if let Some(inventory) = self
.store
.get("inventory")
.expect("Failed to retrieve inventory data")
{
let (filtered, modified, extras): (
BTreeSet<IngredientKey>,
Vec<(IngredientKey, String)>,
Vec<(String, String)>,
) = from_str(&inventory).expect("Failed to deserialize inventory");
return Some((filtered, BTreeMap::from_iter(modified), extras));
}
return None;
}
pub fn delete_inventory_data(&self) {
self.store
.delete("inventory")
.expect("Failed to delete inventory data");
}
pub fn set_inventory_data(
&self,
inventory: (
&BTreeSet<IngredientKey>,
&BTreeMap<IngredientKey, String>,
&Vec<(String, String)>,
),
) {
let filtered = inventory.0;
let modified_amts = inventory
.1
.iter()
.map(|(k, amt)| (k.clone(), amt.clone()))
.collect::<Vec<(IngredientKey, String)>>();
let extras = inventory.2;
let inventory_data = (filtered, &modified_amts, extras);
self.store
.set(
"inventory",
&to_string(&inventory_data).expect(&format!(
"Failed to serialize inventory {:?}",
inventory_data
)),
)
.expect("Failed to set inventory");
}
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct HttpStore { pub struct HttpStore {
root: String, root: String,
local_store: LocalStore,
} }
impl HttpStore { impl HttpStore {
fn new(root: String) -> Self { pub fn new(root: String) -> Self {
Self { root } Self {
root,
local_store: LocalStore::new(),
}
} }
pub fn v1_path(&self) -> String { pub fn v1_path(&self) -> String {
@ -215,7 +354,6 @@ impl HttpStore {
debug!("attempting login request against api."); debug!("attempting login request against api.");
let mut path = self.v1_path(); let mut path = self.v1_path();
path.push_str("/auth"); path.push_str("/auth");
let storage = js_lib::get_storage();
let result = reqwasm::http::Request::get(&path) let result = reqwasm::http::Request::get(&path)
.header( .header(
"Authorization", "Authorization",
@ -230,12 +368,6 @@ impl HttpStore {
.await .await
.expect("Unparseable authentication response") .expect("Unparseable authentication response")
.as_success(); .as_success();
storage
.set(
"user_data",
&to_string(&user_data).expect("Unable to serialize user_data"),
)
.unwrap();
return user_data; return user_data;
} }
error!(status = resp.status(), "Login was unsuccessful") error!(status = resp.status(), "Login was unsuccessful")
@ -249,12 +381,11 @@ impl HttpStore {
pub async fn get_categories(&self) -> Result<Option<String>, Error> { pub async fn get_categories(&self) -> Result<Option<String>, Error> {
let mut path = self.v1_path(); let mut path = self.v1_path();
path.push_str("/categories"); path.push_str("/categories");
let storage = js_lib::get_storage();
let resp = match reqwasm::http::Request::get(&path).send().await { let resp = match reqwasm::http::Request::get(&path).send().await {
Ok(resp) => resp, Ok(resp) => resp,
Err(reqwasm::Error::JsError(err)) => { Err(reqwasm::Error::JsError(err)) => {
error!(path, ?err, "Error hitting api"); error!(path, ?err, "Error hitting api");
return Ok(storage.get("categories")?); return Ok(self.local_store.get_categories());
} }
Err(err) => { Err(err) => {
return Err(err)?; return Err(err)?;
@ -262,14 +393,12 @@ impl HttpStore {
}; };
if resp.status() == 404 { if resp.status() == 404 {
debug!("Categories returned 404"); debug!("Categories returned 404");
storage.remove_item("categories")?;
Ok(None) Ok(None)
} else if resp.status() != 200 { } else if resp.status() != 200 {
Err(format!("Status: {}", resp.status()).into()) Err(format!("Status: {}", resp.status()).into())
} else { } else {
debug!("We got a valid response back!"); debug!("We got a valid response back!");
let resp = resp.json::<CategoryResponse>().await?.as_success().unwrap(); let resp = resp.json::<CategoryResponse>().await?.as_success().unwrap();
storage.set("categories", &resp)?;
Ok(Some(resp)) Ok(Some(resp))
} }
} }
@ -278,26 +407,16 @@ impl HttpStore {
pub async fn get_recipes(&self) -> Result<Option<Vec<RecipeEntry>>, Error> { pub async fn get_recipes(&self) -> Result<Option<Vec<RecipeEntry>>, Error> {
let mut path = self.v1_path(); let mut path = self.v1_path();
path.push_str("/recipes"); path.push_str("/recipes");
let storage = js_lib::get_storage();
let resp = match reqwasm::http::Request::get(&path).send().await { let resp = match reqwasm::http::Request::get(&path).send().await {
Ok(resp) => resp, Ok(resp) => resp,
Err(reqwasm::Error::JsError(err)) => { Err(reqwasm::Error::JsError(err)) => {
error!(path, ?err, "Error hitting api"); error!(path, ?err, "Error hitting api");
let mut entries = Vec::new(); return Ok(self.local_store.get_recipes());
for key in js_lib::get_storage_keys() {
if key.starts_with("recipe:") {
let entry = from_str(&storage.get_item(&key)?.unwrap())
.map_err(|e| format!("{}", e))?;
entries.push(entry);
}
}
return Ok(Some(entries));
} }
Err(err) => { Err(err) => {
return Err(err)?; return Err(err)?;
} }
}; };
let storage = js_lib::get_storage();
if resp.status() != 200 { if resp.status() != 200 {
Err(format!("Status: {}", resp.status()).into()) Err(format!("Status: {}", resp.status()).into())
} else { } else {
@ -307,14 +426,6 @@ impl HttpStore {
.await .await
.map_err(|e| format!("{}", e))? .map_err(|e| format!("{}", e))?
.as_success(); .as_success();
if let Some(ref entries) = entries {
for r in entries.iter() {
storage.set(
&recipe_key(r.recipe_id()),
&to_string(&r).expect("Unable to serialize recipe entries"),
)?;
}
}
Ok(entries) Ok(entries)
} }
} }
@ -326,15 +437,11 @@ impl HttpStore {
let mut path = self.v1_path(); let mut path = self.v1_path();
path.push_str("/recipe/"); path.push_str("/recipe/");
path.push_str(id.as_ref()); path.push_str(id.as_ref());
let storage = js_lib::get_storage();
let resp = match reqwasm::http::Request::get(&path).send().await { let resp = match reqwasm::http::Request::get(&path).send().await {
Ok(resp) => resp, Ok(resp) => resp,
Err(reqwasm::Error::JsError(err)) => { Err(reqwasm::Error::JsError(err)) => {
error!(path, ?err, "Error hitting api"); error!(path, ?err, "Error hitting api");
return match storage.get(&recipe_key(&id))? { return Ok(self.local_store.get_recipe_entry(id.as_ref()));
Some(s) => Ok(Some(from_str(&s).map_err(|e| format!("{}", e))?)),
None => Ok(None),
};
} }
Err(err) => { Err(err) => {
return Err(err)?; return Err(err)?;
@ -354,8 +461,7 @@ impl HttpStore {
.as_success() .as_success()
.unwrap(); .unwrap();
if let Some(ref entry) = entry { if let Some(ref entry) = entry {
let serialized: String = to_string(entry).map_err(|e| format!("{}", e))?; self.local_store.set_recipe_entry(entry);
storage.set(&recipe_key(entry.recipe_id()), &serialized)?
} }
Ok(entry) Ok(entry)
} }
@ -365,15 +471,10 @@ impl HttpStore {
pub async fn save_recipes(&self, recipes: Vec<RecipeEntry>) -> Result<(), Error> { pub async fn save_recipes(&self, recipes: Vec<RecipeEntry>) -> Result<(), Error> {
let mut path = self.v1_path(); let mut path = self.v1_path();
path.push_str("/recipes"); path.push_str("/recipes");
let storage = js_lib::get_storage();
for r in recipes.iter() { for r in recipes.iter() {
if r.recipe_id().is_empty() { if r.recipe_id().is_empty() {
return Err("Recipe Ids can not be empty".into()); return Err("Recipe Ids can not be empty".into());
} }
storage.set(
&recipe_key(r.recipe_id()),
&to_string(&r).expect("Unable to serialize recipe entries"),
)?;
} }
let serialized = to_string(&recipes).expect("Unable to serialize recipe entries"); let serialized = to_string(&recipes).expect("Unable to serialize recipe entries");
let resp = reqwasm::http::Request::post(&path) let resp = reqwasm::http::Request::post(&path)
@ -393,8 +494,6 @@ impl HttpStore {
pub async fn save_categories(&self, categories: String) -> Result<(), Error> { pub async fn save_categories(&self, categories: String) -> Result<(), Error> {
let mut path = self.v1_path(); let mut path = self.v1_path();
path.push_str("/categories"); path.push_str("/categories");
let storage = js_lib::get_storage();
storage.set("categories", &categories)?;
let resp = reqwasm::http::Request::post(&path) let resp = reqwasm::http::Request::post(&path)
.body(to_string(&categories).expect("Unable to encode categories as json")) .body(to_string(&categories).expect("Unable to encode categories as json"))
.header("content-type", "application/json") .header("content-type", "application/json")
@ -408,25 +507,23 @@ impl HttpStore {
} }
} }
#[instrument] #[instrument(skip_all)]
pub async fn save_state(&self, state: std::rc::Rc<app_state::State>) -> Result<(), Error> { pub async fn save_app_state(&self, state: AppState) -> Result<(), Error> {
let mut plan = Vec::new(); let mut plan = Vec::new();
for (key, count) in state.recipe_counts.get_untracked().iter() { for (key, count) in state.recipe_counts.iter() {
plan.push((key.clone(), *count.get_untracked() as i32)); plan.push((key.clone(), *count as i32));
} }
debug!("Saving plan data"); debug!("Saving plan data");
self.save_plan(plan).await?; self.save_plan(plan).await?;
debug!("Saving inventory data"); debug!("Saving inventory data");
self.save_inventory_data( self.save_inventory_data(
state.filtered_ingredients.get_untracked().as_ref().clone(), state.filtered_ingredients,
state.get_current_modified_amts(), state.modified_amts,
state state
.extras .extras
.get()
.as_ref()
.iter() .iter()
.map(|t| (t.1 .0.get().as_ref().clone(), t.1 .1.get().as_ref().clone())) .cloned()
.collect(), .collect::<Vec<(String, String)>>(),
) )
.await .await
} }
@ -434,9 +531,6 @@ impl HttpStore {
pub async fn save_plan(&self, plan: Vec<(String, i32)>) -> Result<(), Error> { pub async fn save_plan(&self, plan: Vec<(String, i32)>) -> Result<(), Error> {
let mut path = self.v1_path(); let mut path = self.v1_path();
path.push_str("/plan"); path.push_str("/plan");
let storage = js_lib::get_storage();
let serialized_plan = to_string(&plan).expect("Unable to encode plan as json");
storage.set("plan", &serialized_plan)?;
let resp = reqwasm::http::Request::post(&path) let resp = reqwasm::http::Request::post(&path)
.body(to_string(&plan).expect("Unable to encode plan as json")) .body(to_string(&plan).expect("Unable to encode plan as json"))
.header("content-type", "application/json") .header("content-type", "application/json")
@ -454,7 +548,6 @@ impl HttpStore {
let mut path = self.v1_path(); let mut path = self.v1_path();
path.push_str("/plan"); path.push_str("/plan");
let resp = reqwasm::http::Request::get(&path).send().await?; let resp = reqwasm::http::Request::get(&path).send().await?;
let storage = js_lib::get_storage();
if resp.status() != 200 { if resp.status() != 200 {
Err(format!("Status: {}", resp.status()).into()) Err(format!("Status: {}", resp.status()).into())
} else { } else {
@ -464,10 +557,6 @@ impl HttpStore {
.await .await
.map_err(|e| format!("{}", e))? .map_err(|e| format!("{}", e))?
.as_success(); .as_success();
if let Some(ref entry) = plan {
let serialized: String = to_string(entry).map_err(|e| format!("{}", e))?;
storage.set("plan", &serialized)?
}
Ok(plan) Ok(plan)
} }
} }
@ -484,30 +573,12 @@ impl HttpStore {
> { > {
let mut path = self.v2_path(); let mut path = self.v2_path();
path.push_str("/inventory"); path.push_str("/inventory");
let storage = js_lib::get_storage();
let resp = reqwasm::http::Request::get(&path).send().await?; let resp = reqwasm::http::Request::get(&path).send().await?;
if resp.status() != 200 { if resp.status() != 200 {
let err = Err(format!("Status: {}", resp.status()).into()); let err = Err(format!("Status: {}", resp.status()).into());
Ok(match storage.get("inventory") { Ok(match self.local_store.get_inventory_data() {
Ok(Some(val)) => match from_str(&val) { Some(val) => val,
// TODO(jwall): Once we remove the v1 endpoint this is no longer needed. None => return err,
Ok((filtered_ingredients, modified_amts)) => {
(filtered_ingredients, modified_amts, Vec::new())
}
Err(_) => match from_str(&val) {
Ok((filtered_ingredients, modified_amts, extra_items)) => {
(filtered_ingredients, modified_amts, extra_items)
}
Err(_) => {
// Whatever is in storage is corrupted or invalid so we should delete it.
storage
.delete("inventory")
.expect("Unable to delete corrupt data in inventory cache");
return err;
}
},
},
Ok(None) | Err(_) => return err,
}) })
} else { } else {
debug!("We got a valid response back"); debug!("We got a valid response back");
@ -521,11 +592,6 @@ impl HttpStore {
.map_err(|e| format!("{}", e))? .map_err(|e| format!("{}", e))?
.as_success() .as_success()
.unwrap(); .unwrap();
let _ = storage.set(
"inventory",
&to_string(&(&filtered_ingredients, &modified_amts))
.expect("Failed to serialize inventory data"),
);
Ok(( Ok((
filtered_ingredients.into_iter().collect(), filtered_ingredients.into_iter().collect(),
modified_amts.into_iter().collect(), modified_amts.into_iter().collect(),
@ -545,13 +611,9 @@ impl HttpStore {
path.push_str("/inventory"); path.push_str("/inventory");
let filtered_ingredients: Vec<IngredientKey> = filtered_ingredients.into_iter().collect(); let filtered_ingredients: Vec<IngredientKey> = filtered_ingredients.into_iter().collect();
let modified_amts: Vec<(IngredientKey, String)> = modified_amts.into_iter().collect(); let modified_amts: Vec<(IngredientKey, String)> = modified_amts.into_iter().collect();
debug!("Storing inventory data in cache");
let serialized_inventory = to_string(&(filtered_ingredients, modified_amts, extra_items)) let serialized_inventory = to_string(&(filtered_ingredients, modified_amts, extra_items))
.expect("Unable to encode plan as json"); .expect("Unable to encode plan as json");
let storage = js_lib::get_storage();
debug!("Storing inventory data in cache");
storage
.set("inventory", &serialized_inventory)
.expect("Failed to cache inventory data");
debug!("Storing inventory data via API"); debug!("Storing inventory data via API");
let resp = reqwasm::http::Request::post(&path) let resp = reqwasm::http::Request::post(&path)
.body(&serialized_inventory) .body(&serialized_inventory)

View File

@ -11,139 +11,373 @@
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
use std::collections::{BTreeMap, BTreeSet}; use std::{
collections::{BTreeMap, BTreeSet},
use sycamore::prelude::*; fmt::Debug,
use tracing::{debug, instrument, warn}; };
use client_api::UserData; use client_api::UserData;
use recipes::{Ingredient, IngredientAccumulator, IngredientKey, Recipe}; use recipes::{parse, IngredientKey, Recipe, RecipeEntry};
use sycamore::futures::spawn_local_scoped;
use sycamore::prelude::*;
use sycamore_state::{Handler, MessageMapper};
use tracing::{debug, error, info, instrument, warn};
use wasm_bindgen::throw_str;
#[derive(Debug)] use crate::api::{HttpStore, LocalStore};
pub struct State {
pub recipe_counts: RcSignal<BTreeMap<String, RcSignal<usize>>>, #[derive(Debug, Clone, PartialEq)]
pub extras: RcSignal<Vec<(usize, (RcSignal<String>, RcSignal<String>))>>, pub struct AppState {
pub staples: RcSignal<Option<Recipe>>, pub recipe_counts: BTreeMap<String, usize>,
pub recipes: RcSignal<BTreeMap<String, Recipe>>, pub extras: Vec<(String, String)>,
pub category_map: RcSignal<BTreeMap<String, String>>, pub staples: Option<Recipe>,
pub filtered_ingredients: RcSignal<BTreeSet<IngredientKey>>, pub recipes: BTreeMap<String, Recipe>,
pub modified_amts: RcSignal<BTreeMap<IngredientKey, RcSignal<String>>>, pub category_map: String,
pub auth: RcSignal<Option<UserData>>, pub filtered_ingredients: BTreeSet<IngredientKey>,
pub modified_amts: BTreeMap<IngredientKey, String>,
pub auth: Option<UserData>,
} }
impl State { impl AppState {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
recipe_counts: create_rc_signal(BTreeMap::new()), recipe_counts: BTreeMap::new(),
extras: create_rc_signal(Vec::new()), extras: Vec::new(),
staples: create_rc_signal(None), staples: None,
recipes: create_rc_signal(BTreeMap::new()), recipes: BTreeMap::new(),
category_map: create_rc_signal(BTreeMap::new()), category_map: String::new(),
filtered_ingredients: create_rc_signal(BTreeSet::new()), filtered_ingredients: BTreeSet::new(),
modified_amts: create_rc_signal(BTreeMap::new()), modified_amts: BTreeMap::new(),
auth: create_rc_signal(None), auth: None,
} }
} }
}
pub fn provide_context(cx: Scope) {
provide_context(cx, std::rc::Rc::new(Self::new())); pub enum Message {
} ResetRecipeCounts,
UpdateRecipeCount(String, usize),
pub fn get_from_context(cx: Scope) -> std::rc::Rc<Self> { AddExtra(String, String),
use_context::<std::rc::Rc<Self>>(cx).clone() RemoveExtra(usize),
} UpdateExtra(usize, String, String),
SaveRecipe(RecipeEntry),
pub fn get_menu_list(&self) -> Vec<(String, RcSignal<usize>)> { SetRecipe(String, Recipe),
self.recipe_counts SetCategoryMap(String),
.get() ResetInventory,
.iter() AddFilteredIngredient(IngredientKey),
.map(|(k, v)| (k.clone(), v.clone())) UpdateAmt(IngredientKey, String),
.filter(|(_, v)| *(v.get_untracked()) != 0) SetUserData(UserData),
.collect() SaveState(Option<Box<dyn FnOnce()>>),
} LoadState(Option<Box<dyn FnOnce()>>),
}
#[instrument(skip(self))]
pub fn get_shopping_list( impl Debug for Message {
&self, fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
show_staples: bool, match self {
) -> BTreeMap<String, Vec<(Ingredient, BTreeSet<String>)>> { Self::ResetRecipeCounts => write!(f, "ResetRecipeCounts"),
let mut acc = IngredientAccumulator::new(); Self::UpdateRecipeCount(arg0, arg1) => f
let recipe_counts = self.get_menu_list(); .debug_tuple("UpdateRecipeCount")
for (idx, count) in recipe_counts.iter() { .field(arg0)
for _ in 0..*count.get_untracked() { .field(arg1)
acc.accumulate_from( .finish(),
self.recipes Self::AddExtra(arg0, arg1) => {
.get() f.debug_tuple("AddExtra").field(arg0).field(arg1).finish()
.get(idx) }
.expect(&format!("No such recipe id exists: {}", idx)), Self::RemoveExtra(arg0) => f.debug_tuple("RemoveExtra").field(arg0).finish(),
); Self::UpdateExtra(arg0, arg1, arg2) => f
} .debug_tuple("UpdateExtra")
} .field(arg0)
if show_staples { .field(arg1)
if let Some(staples) = self.staples.get().as_ref() { .field(arg2)
acc.accumulate_from(staples); .finish(),
} Self::SaveRecipe(arg0) => f.debug_tuple("SaveRecipe").field(arg0).finish(),
} Self::SetRecipe(arg0, arg1) => {
let mut ingredients = acc.ingredients(); f.debug_tuple("SetRecipe").field(arg0).field(arg1).finish()
let mut groups = BTreeMap::new(); }
let cat_map = self.category_map.get().clone(); Self::SetCategoryMap(arg0) => f.debug_tuple("SetCategoryMap").field(arg0).finish(),
for (_, (i, recipes)) in ingredients.iter_mut() { Self::ResetInventory => write!(f, "ResetInventory"),
let category = if let Some(cat) = cat_map.get(&i.name) { Self::AddFilteredIngredient(arg0) => {
cat.clone() f.debug_tuple("AddFilteredIngredient").field(arg0).finish()
} else { }
"other".to_owned() Self::UpdateAmt(arg0, arg1) => {
}; f.debug_tuple("UpdateAmt").field(arg0).field(arg1).finish()
i.category = category.clone(); }
groups Self::SetUserData(arg0) => f.debug_tuple("SetUserData").field(arg0).finish(),
.entry(category) Self::SaveState(_) => write!(f, "SaveState"),
.or_insert(vec![]) Self::LoadState(_) => write!(f, "LoadState"),
.push((i.clone(), recipes.clone())); }
} }
debug!(?self.category_map); }
// FIXME(jwall): Sort by categories and names.
groups pub struct StateMachine {
} store: HttpStore,
local_store: LocalStore,
/// Retrieves the count for a recipe without triggering subscribers to the entire }
/// recipe count set.
pub fn get_recipe_count_by_index(&self, key: &String) -> Option<RcSignal<usize>> { #[instrument]
self.recipe_counts.get_untracked().get(key).cloned() fn filter_recipes(
} recipe_entries: &Option<Vec<RecipeEntry>>,
) -> Result<(Option<Recipe>, Option<BTreeMap<String, Recipe>>), String> {
pub fn reset_recipe_counts(&self) { match recipe_entries {
for (_, count) in self.recipe_counts.get_untracked().iter() { Some(parsed) => {
count.set(0); let mut staples = None;
} let mut parsed_map = BTreeMap::new();
} for r in parsed {
let recipe = match parse::as_recipe(&r.recipe_text()) {
/// Set the recipe_count by index. Does not trigger subscribers to the entire set of recipe_counts. Ok(r) => r,
/// This does trigger subscribers of the specific recipe you are updating though. Err(e) => {
pub fn set_recipe_count_by_index(&self, key: &String, count: usize) -> RcSignal<usize> { error!("Error parsing recipe {}", e);
let mut counts = self.recipe_counts.get_untracked().as_ref().clone(); continue;
counts }
.entry(key.clone()) };
.and_modify(|e| e.set(count)) if recipe.title == "Staples" {
.or_insert_with(|| create_rc_signal(count)); staples = Some(recipe);
self.recipe_counts.set(counts); } else {
self.recipe_counts.get_untracked().get(key).unwrap().clone() parsed_map.insert(r.recipe_id().to_owned(), recipe);
} }
}
pub fn get_current_modified_amts(&self) -> BTreeMap<IngredientKey, String> { Ok((staples, Some(parsed_map)))
let mut modified_amts = BTreeMap::new(); }
for (key, amt) in self.modified_amts.get_untracked().iter() { None => Ok((None, None)),
modified_amts.insert(key.clone(), amt.get_untracked().as_ref().clone()); }
} }
modified_amts
} impl StateMachine {
pub fn new(store: HttpStore, local_store: LocalStore) -> Self {
pub fn reset_modified_amts(&self, modified_amts: BTreeMap<IngredientKey, String>) { Self { store, local_store }
let mut modified_amts_copy = self.modified_amts.get().as_ref().clone(); }
for (key, amt) in modified_amts {
modified_amts_copy async fn load_state(
.entry(key) store: &HttpStore,
.and_modify(|amt_signal| amt_signal.set(amt.clone())) local_store: &LocalStore,
.or_insert_with(|| create_rc_signal(amt)); original: &Signal<AppState>,
} ) -> Result<(), crate::api::Error> {
self.modified_amts.set(modified_amts_copy); let mut state = original.get().as_ref().clone();
} info!("Synchronizing Recipes");
let recipe_entries = &store.get_recipes().await?;
let (staples, recipes) = filter_recipes(&recipe_entries)?;
if let Some(recipes) = recipes {
state.staples = staples;
state.recipes = recipes;
};
if let Some(recipe_entries) = recipe_entries {
local_store.set_all_recipes(recipe_entries);
}
let plan = store.get_plan().await?;
if let Some(plan) = plan {
// set the counts.
let mut plan_map = BTreeMap::new();
for (id, count) in plan {
plan_map.insert(id, count as usize);
}
state.recipe_counts = plan_map;
} else {
// Initialize things to zero
if let Some(rs) = recipe_entries {
for r in rs {
state.recipe_counts.insert(r.recipe_id().to_owned(), 0);
}
}
}
let plan = state
.recipe_counts
.iter()
.map(|(k, v)| (k.clone(), *v as i32))
.collect::<Vec<(String, i32)>>();
local_store.save_plan(&plan);
info!("Checking for user_data in local storage");
let user_data = local_store.get_user_data();
state.auth = user_data;
info!("Synchronizing categories");
match store.get_categories().await {
Ok(Some(categories_content)) => {
debug!(categories=?categories_content);
local_store.set_categories(Some(&categories_content));
state.category_map = categories_content;
}
Ok(None) => {
warn!("There is no category file");
local_store.set_categories(None);
}
Err(e) => {
error!("{:?}", e);
}
}
info!("Synchronizing inventory data");
match store.get_inventory_data().await {
Ok((filtered_ingredients, modified_amts, extra_items)) => {
local_store.set_inventory_data((
&filtered_ingredients,
&modified_amts,
&extra_items,
));
state.modified_amts = modified_amts;
state.filtered_ingredients = filtered_ingredients;
state.extras = extra_items;
}
Err(e) => {
error!("{:?}", e);
}
}
original.set(state);
Ok(())
}
}
impl MessageMapper<Message, AppState> for StateMachine {
#[instrument(skip_all, fields(?msg))]
fn map<'ctx>(&self, cx: Scope<'ctx>, msg: Message, original: &'ctx Signal<AppState>) {
let mut original_copy = original.get().as_ref().clone();
debug!("handling state message");
match msg {
Message::ResetRecipeCounts => {
let mut map = BTreeMap::new();
for (id, _) in original_copy.recipes.iter() {
map.insert(id.clone(), 0);
}
let plan: Vec<(String, i32)> =
map.iter().map(|(s, i)| (s.clone(), *i as i32)).collect();
self.local_store.save_plan(&plan);
original_copy.recipe_counts = map;
}
Message::UpdateRecipeCount(id, count) => {
original_copy.recipe_counts.insert(id, count);
let plan: Vec<(String, i32)> = original_copy
.recipe_counts
.iter()
.map(|(s, i)| (s.clone(), *i as i32))
.collect();
self.local_store.save_plan(&plan);
}
Message::AddExtra(amt, name) => {
original_copy.extras.push((amt, name));
self.local_store.set_inventory_data((
&original_copy.filtered_ingredients,
&original_copy.modified_amts,
&original_copy.extras,
))
}
Message::RemoveExtra(idx) => {
original_copy.extras.remove(idx);
self.local_store.set_inventory_data((
&original_copy.filtered_ingredients,
&original_copy.modified_amts,
&original_copy.extras,
))
}
Message::UpdateExtra(idx, amt, name) => {
match original_copy.extras.get_mut(idx) {
Some(extra) => {
extra.0 = amt;
extra.1 = name;
}
None => {
throw_str("Attempted to remove extra that didn't exist");
}
}
self.local_store.set_inventory_data((
&original_copy.filtered_ingredients,
&original_copy.modified_amts,
&original_copy.extras,
))
}
Message::SetRecipe(id, recipe) => {
original_copy.recipes.insert(id, recipe);
}
Message::SaveRecipe(entry) => {
let recipe =
parse::as_recipe(entry.recipe_text()).expect("Failed to parse RecipeEntry");
original_copy
.recipes
.insert(entry.recipe_id().to_owned(), recipe);
original_copy
.recipe_counts
.insert(entry.recipe_id().to_owned(), 0);
let store = self.store.clone();
self.local_store.set_recipe_entry(&entry);
spawn_local_scoped(cx, async move {
if let Err(e) = store.save_recipes(vec![entry]).await {
error!(err=?e, "Unable to save Recipe");
}
});
}
Message::SetCategoryMap(category_text) => {
original_copy.category_map = category_text.clone();
self.local_store.set_categories(Some(&category_text));
let store = self.store.clone();
spawn_local_scoped(cx, async move {
if let Err(e) = store.save_categories(category_text).await {
error!(?e, "Failed to save categories");
}
});
}
Message::ResetInventory => {
original_copy.filtered_ingredients = BTreeSet::new();
original_copy.modified_amts = BTreeMap::new();
original_copy.extras = Vec::new();
self.local_store.set_inventory_data((
&original_copy.filtered_ingredients,
&original_copy.modified_amts,
&original_copy.extras,
));
}
Message::AddFilteredIngredient(key) => {
original_copy.filtered_ingredients.insert(key);
self.local_store.set_inventory_data((
&original_copy.filtered_ingredients,
&original_copy.modified_amts,
&original_copy.extras,
));
}
Message::UpdateAmt(key, amt) => {
original_copy.modified_amts.insert(key, amt);
self.local_store.set_inventory_data((
&original_copy.filtered_ingredients,
&original_copy.modified_amts,
&original_copy.extras,
));
}
Message::SetUserData(user_data) => {
self.local_store.set_user_data(Some(&user_data));
original_copy.auth = Some(user_data);
}
Message::SaveState(f) => {
let original_copy = original_copy.clone();
let store = self.store.clone();
spawn_local_scoped(cx, async move {
if let Err(e) = store.save_app_state(original_copy).await {
error!(err=?e, "Error saving app state")
};
f.map(|f| f());
});
}
Message::LoadState(f) => {
let store = self.store.clone();
let local_store = self.local_store.clone();
spawn_local_scoped(cx, async move {
Self::load_state(&store, &local_store, original)
.await
.expect("Failed to load_state.");
local_store.set_inventory_data((
&original.get().filtered_ingredients,
&original.get().modified_amts,
&original.get().extras,
));
f.map(|f| f());
});
return;
}
}
original.set(original_copy);
}
}
pub type StateHandler<'ctx> = &'ctx Handler<'ctx, StateMachine, AppState, Message>;
pub fn get_state_handler<'ctx>(
cx: Scope<'ctx>,
initial: AppState,
store: HttpStore,
) -> StateHandler<'ctx> {
Handler::new(cx, initial, StateMachine::new(store, LocalStore::new()))
} }

View File

@ -14,6 +14,7 @@
use sycamore::{futures::spawn_local_scoped, prelude::*}; use sycamore::{futures::spawn_local_scoped, prelude::*};
use tracing::{error, info}; use tracing::{error, info};
use crate::app_state::{Message, StateHandler};
use recipes::RecipeEntry; use recipes::RecipeEntry;
const STARTER_RECIPE: &'static str = "title: TITLE_PLACEHOLDER const STARTER_RECIPE: &'static str = "title: TITLE_PLACEHOLDER
@ -28,7 +29,7 @@ Instructions here
"; ";
#[component] #[component]
pub fn AddRecipe<G: Html>(cx: Scope) -> View<G> { pub fn AddRecipe<'ctx, G: Html>(cx: Scope<'ctx>, sh: StateHandler<'ctx>) -> View<G> {
let recipe_title = create_signal(cx, String::new()); let recipe_title = create_signal(cx, String::new());
let create_recipe_signal = create_signal(cx, ()); let create_recipe_signal = create_signal(cx, ());
let dirty = create_signal(cx, false); let dirty = create_signal(cx, false);
@ -47,39 +48,6 @@ pub fn AddRecipe<G: Html>(cx: Scope) -> View<G> {
) )
}); });
create_effect(cx, move || {
create_recipe_signal.track();
if !*dirty.get_untracked() {
return;
}
spawn_local_scoped(cx, {
let store = crate::api::HttpStore::get_from_context(cx);
async move {
let entry = entry.get_untracked();
// TODO(jwall): Better error reporting here.
match store.get_recipe_text(entry.recipe_id()).await {
Ok(Some(_)) => {
// TODO(jwall): We should tell the user that this id already exists
info!(recipe_id = entry.recipe_id(), "Recipe already exists");
return;
}
Ok(None) => {
// noop
}
Err(err) => {
// TODO(jwall): We should tell the user that this is failing
error!(?err)
}
}
store
.save_recipes(vec![entry.as_ref().clone()])
.await
.expect("Unable to save New Recipe");
crate::js_lib::navigate_to_path(&format!("/ui/recipe/{}", entry.recipe_id()))
.expect("Unable to navigate to recipe");
}
});
});
view! {cx, view! {cx,
label(for="recipe_title") { "Recipe Title" } label(for="recipe_title") { "Recipe Title" }
input(bind:value=recipe_title, type="text", name="recipe_title", id="recipe_title", on:change=move |_| { input(bind:value=recipe_title, type="text", name="recipe_title", id="recipe_title", on:change=move |_| {
@ -87,6 +55,33 @@ pub fn AddRecipe<G: Html>(cx: Scope) -> View<G> {
}) })
button(on:click=move |_| { button(on:click=move |_| {
create_recipe_signal.trigger_subscribers(); create_recipe_signal.trigger_subscribers();
if !*dirty.get_untracked() {
return;
}
spawn_local_scoped(cx, {
let store = crate::api::HttpStore::get_from_context(cx);
async move {
let entry = entry.get_untracked();
// TODO(jwall): Better error reporting here.
match store.get_recipe_text(entry.recipe_id()).await {
Ok(Some(_)) => {
// TODO(jwall): We should tell the user that this id already exists
info!(recipe_id = entry.recipe_id(), "Recipe already exists");
return;
}
Ok(None) => {
// noop
}
Err(err) => {
// TODO(jwall): We should tell the user that this is failing
error!(?err)
}
}
sh.dispatch(cx, Message::SaveRecipe((*entry).clone()));
crate::js_lib::navigate_to_path(&format!("/ui/recipe/edit/{}", entry.recipe_id()))
.expect("Unable to navigate to recipe");
}
});
}) { "Create" } }) { "Create" }
} }
} }

View File

@ -11,14 +11,16 @@
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
use crate::{
app_state::{Message, StateHandler},
js_lib::get_element_by_id,
};
use sycamore::{futures::spawn_local_scoped, prelude::*}; use sycamore::{futures::spawn_local_scoped, prelude::*};
use tracing::{debug, error, instrument}; use tracing::{debug, error, instrument};
use web_sys::HtmlDialogElement; use web_sys::HtmlDialogElement;
use recipes::parse; use recipes::parse;
use crate::js_lib::get_element_by_id;
fn get_error_dialog() -> HtmlDialogElement { fn get_error_dialog() -> HtmlDialogElement {
get_element_by_id::<HtmlDialogElement>("error-dialog") get_element_by_id::<HtmlDialogElement>("error-dialog")
.expect("error-dialog isn't an html dialog element!") .expect("error-dialog isn't an html dialog element!")
@ -38,10 +40,9 @@ fn check_category_text_parses(unparsed: &str, error_text: &Signal<String>) -> bo
} }
} }
#[instrument] #[instrument(skip_all)]
#[component] #[component]
pub fn Categories<G: Html>(cx: Scope) -> View<G> { pub fn Categories<'ctx, G: Html>(cx: Scope<'ctx>, sh: StateHandler<'ctx>) -> View<G> {
let save_signal = create_signal(cx, ());
let error_text = create_signal(cx, String::new()); let error_text = create_signal(cx, String::new());
let category_text: &Signal<String> = create_signal(cx, String::new()); let category_text: &Signal<String> = create_signal(cx, String::new());
let dirty = create_signal(cx, false); let dirty = create_signal(cx, false);
@ -59,28 +60,6 @@ pub fn Categories<G: Html>(cx: Scope) -> View<G> {
} }
}); });
create_effect(cx, move || {
save_signal.track();
if !*dirty.get() {
return;
}
spawn_local_scoped(cx, {
let store = crate::api::HttpStore::get_from_context(cx);
async move {
// TODO(jwall): Save the categories.
if let Err(e) = store
.save_categories(category_text.get_untracked().as_ref().clone())
.await
{
error!(?e, "Failed to save categories");
error_text.set(format!("{:?}", e));
} else {
dirty.set(false);
}
}
});
});
let dialog_view = view! {cx, let dialog_view = view! {cx,
dialog(id="error-dialog") { dialog(id="error-dialog") {
article{ article{
@ -107,10 +86,15 @@ pub fn Categories<G: Html>(cx: Scope) -> View<G> {
check_category_text_parses(category_text.get().as_str(), error_text); check_category_text_parses(category_text.get().as_str(), error_text);
}) { "Check" } " " }) { "Check" } " "
span(role="button", on:click=move |_| { span(role="button", on:click=move |_| {
// TODO(jwall): check and then save the categories. if !*dirty.get() {
return;
}
if check_category_text_parses(category_text.get().as_str(), error_text) { if check_category_text_parses(category_text.get().as_str(), error_text) {
debug!("triggering category save"); debug!("triggering category save");
save_signal.trigger_subscribers(); sh.dispatch(
cx,
Message::SetCategoryMap(category_text.get_untracked().as_ref().clone()),
);
} }
}) { "Save" } }) { "Save" }
} }

View File

@ -14,17 +14,13 @@
use sycamore::prelude::*; use sycamore::prelude::*;
use crate::app_state; use crate::app_state::StateHandler;
#[component] #[component]
pub fn Header<G: Html>(cx: Scope) -> View<G> { pub fn Header<'ctx, G: Html>(cx: Scope<'ctx>, h: StateHandler<'ctx>) -> View<G> {
let state = app_state::State::get_from_context(cx); let login = h.get_selector(cx, |sig| match &sig.get().auth {
let login = create_memo(cx, move || { Some(id) => id.user_id.clone(),
let user_id = state.auth.get(); None => "Login".to_owned(),
match user_id.as_ref() {
Some(user_data) => format!("{}", user_data.user_id),
None => "Login".to_owned(),
}
}); });
view! {cx, view! {cx,
nav(class="no-print") { nav(class="no-print") {

View File

@ -14,7 +14,7 @@
use sycamore::{futures::spawn_local_scoped, prelude::*}; use sycamore::{futures::spawn_local_scoped, prelude::*};
use tracing::{debug, error}; use tracing::{debug, error};
use crate::app_state; use crate::app_state::{Message, StateHandler};
use recipes::{self, RecipeEntry}; use recipes::{self, RecipeEntry};
fn check_recipe_parses( fn check_recipe_parses(
@ -34,8 +34,15 @@ fn check_recipe_parses(
} }
} }
#[derive(Props)]
pub struct RecipeComponentProps<'ctx> {
recipe_id: String,
sh: StateHandler<'ctx>,
}
#[component] #[component]
pub fn Editor<G: Html>(cx: Scope, recipe_id: String) -> View<G> { pub fn Editor<'ctx, G: Html>(cx: Scope<'ctx>, props: RecipeComponentProps<'ctx>) -> View<G> {
let RecipeComponentProps { recipe_id, sh } = props;
let store = crate::api::HttpStore::get_from_context(cx); let store = crate::api::HttpStore::get_from_context(cx);
let recipe: &Signal<RecipeEntry> = let recipe: &Signal<RecipeEntry> =
create_signal(cx, RecipeEntry::new(&recipe_id, String::new())); create_signal(cx, RecipeEntry::new(&recipe_id, String::new()));
@ -60,45 +67,8 @@ pub fn Editor<G: Html>(cx: Scope, recipe_id: String) -> View<G> {
}); });
let id = create_memo(cx, || recipe.get().recipe_id().to_owned()); let id = create_memo(cx, || recipe.get().recipe_id().to_owned());
let save_signal = create_signal(cx, ());
let dirty = create_signal(cx, false); let dirty = create_signal(cx, false);
debug!("Creating effect");
create_effect(cx, move || {
save_signal.track();
if !*dirty.get_untracked() {
debug!("Recipe text is unchanged");
return;
}
debug!("Recipe text is changed");
spawn_local_scoped(cx, {
let store = crate::api::HttpStore::get_from_context(cx);
let state = app_state::State::get_from_context(cx);
async move {
debug!("Attempting to save recipe");
if let Err(e) = store
.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));
} else {
// We also need to set recipe in our state
dirty.set(false);
if let Ok(recipe) = recipes::parse::as_recipe(text.get_untracked().as_ref()) {
state
.recipes
.modify()
.insert(id.get_untracked().as_ref().to_owned(), recipe);
}
};
}
});
});
debug!("creating editor view"); debug!("creating editor view");
view! {cx, view! {cx,
div(class="grid") { div(class="grid") {
@ -115,7 +85,36 @@ pub fn Editor<G: Html>(cx: Scope, recipe_id: String) -> View<G> {
let unparsed = text.get(); let unparsed = text.get();
if check_recipe_parses(unparsed.as_str(), error_text, aria_hint) { if check_recipe_parses(unparsed.as_str(), error_text, aria_hint) {
debug!("triggering a save"); debug!("triggering a save");
save_signal.trigger_subscribers(); if !*dirty.get_untracked() {
debug!("Recipe text is unchanged");
return;
}
debug!("Recipe text is changed");
spawn_local_scoped(cx, {
let store = crate::api::HttpStore::get_from_context(cx);
async move {
debug!("Attempting to save recipe");
if let Err(e) = store
.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));
} else {
// We also need to set recipe in our state
dirty.set(false);
if let Ok(recipe) = recipes::parse::as_recipe(text.get_untracked().as_ref()) {
sh.dispatch(
cx,
Message::SetRecipe(id.get_untracked().as_ref().to_owned(), recipe),
);
}
};
}
});
} else { } else {
} }
}) { "Save" } }) { "Save" }
@ -154,13 +153,20 @@ fn Steps<G: Html>(cx: Scope, steps: Vec<recipes::Step>) -> View<G> {
} }
#[component] #[component]
pub fn Viewer<G: Html>(cx: Scope, recipe_id: String) -> View<G> { pub fn Viewer<'ctx, G: Html>(cx: Scope<'ctx>, props: RecipeComponentProps<'ctx>) -> View<G> {
let state = app_state::State::get_from_context(cx); let RecipeComponentProps { recipe_id, sh } = props;
let view = create_signal(cx, View::empty()); let view = create_signal(cx, View::empty());
if let Some(recipe) = state.recipes.get_untracked().get(&recipe_id) { let recipe_signal = sh.get_selector(cx, move |state| {
let title = recipe.title.clone(); if let Some(recipe) = state.get().recipes.get(&recipe_id) {
let desc = recipe.desc.clone().unwrap_or_else(|| String::new()); let title = recipe.title.clone();
let steps = recipe.steps.clone(); let desc = recipe.desc.clone().unwrap_or_else(|| String::new());
let steps = recipe.steps.clone();
Some((title, desc, steps))
} else {
None
}
});
if let Some((title, desc, steps)) = recipe_signal.get().as_ref().clone() {
debug!("Viewing recipe."); debug!("Viewing recipe.");
view.set(view! {cx, view.set(view! {cx,
div(class="recipe") { div(class="recipe") {

View File

@ -11,25 +11,32 @@
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
use crate::{app_state, components::recipe::Viewer}; use crate::{app_state::StateHandler, components::recipe::Viewer};
use sycamore::prelude::*; use sycamore::prelude::*;
use tracing::{debug, instrument}; use tracing::{debug, instrument};
#[instrument] #[instrument(skip_all)]
#[component] #[component]
pub fn RecipeList<G: Html>(cx: Scope) -> View<G> { pub fn RecipeList<'ctx, G: Html>(cx: Scope<'ctx>, sh: StateHandler<'ctx>) -> View<G> {
let state = app_state::State::get_from_context(cx); let menu_list = sh.get_selector(cx, |state| {
let menu_list = create_memo(cx, move || state.get_menu_list()); state
.get()
.recipe_counts
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.filter(|(_, v)| *(v) != 0)
.collect()
});
view! {cx, view! {cx,
h1 { "Recipe List" } h1 { "Recipe List" }
div() { div() {
Indexed( Indexed(
iterable=menu_list, iterable=menu_list,
view= |cx, (id, _count)| { view= move |cx, (id, _count)| {
debug!(id=%id, "Rendering recipe"); debug!(id=%id, "Rendering recipe");
view ! {cx, view ! {cx,
Viewer(id) Viewer(recipe_id=id, sh=sh)
hr() hr()
} }
} }

View File

@ -12,22 +12,20 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
use recipes::Recipe; use recipes::Recipe;
use sycamore::{futures::spawn_local_scoped, prelude::*}; use sycamore::prelude::*;
use tracing::{error, instrument}; use tracing::instrument;
use crate::app_state::{Message, StateHandler};
use crate::components::recipe_selection::*; use crate::components::recipe_selection::*;
use crate::{api::*, app_state};
#[allow(non_snake_case)] #[allow(non_snake_case)]
#[instrument] #[instrument(skip_all)]
pub fn RecipePlan<G: Html>(cx: Scope) -> View<G> { pub fn RecipePlan<'ctx, G: Html>(cx: Scope<'ctx>, sh: StateHandler<'ctx>) -> View<G> {
let rows = create_memo(cx, move || { let rows = sh.get_selector(cx, move |state| {
let state = app_state::State::get_from_context(cx);
let mut rows = Vec::new(); let mut rows = Vec::new();
for row in state for row in state
.recipes
.get() .get()
.as_ref() .recipes
.iter() .iter()
.map(|(k, v)| create_signal(cx, (k.clone(), v.clone()))) .map(|(k, v)| create_signal(cx, (k.clone(), v.clone())))
.collect::<Vec<&Signal<(String, Recipe)>>>() .collect::<Vec<&Signal<(String, Recipe)>>>()
@ -37,30 +35,6 @@ pub fn RecipePlan<G: Html>(cx: Scope) -> View<G> {
} }
rows rows
}); });
let refresh_click = create_signal(cx, false);
let save_click = create_signal(cx, false);
create_effect(cx, move || {
refresh_click.track();
let store = HttpStore::get_from_context(cx);
let state = app_state::State::get_from_context(cx);
spawn_local_scoped(cx, {
async move {
if let Err(err) = init_page_state(store.as_ref(), state.as_ref()).await {
error!(?err);
};
}
});
});
create_effect(cx, move || {
save_click.track();
let store = HttpStore::get_from_context(cx);
let state = app_state::State::get_from_context(cx);
spawn_local_scoped(cx, {
async move {
store.save_state(state).await.expect("Failed to save plan");
}
})
});
view! {cx, view! {cx,
table(class="recipe_selector no-print") { table(class="recipe_selector no-print") {
(View::new_fragment( (View::new_fragment(
@ -68,10 +42,10 @@ pub fn RecipePlan<G: Html>(cx: Scope) -> View<G> {
view ! {cx, view ! {cx,
tr { Keyed( tr { Keyed(
iterable=r, iterable=r,
view=|cx, sig| { view=move |cx, sig| {
let title = create_memo(cx, move || sig.get().1.title.clone()); let title = create_memo(cx, move || sig.get().1.title.clone());
view! {cx, view! {cx,
td { RecipeSelection(i=sig.get().0.to_owned(), title=title) } td { RecipeSelection(i=sig.get().0.to_owned(), title=title, sh=sh) }
} }
}, },
key=|sig| sig.get().0.to_owned(), key=|sig| sig.get().0.to_owned(),
@ -81,18 +55,14 @@ pub fn RecipePlan<G: Html>(cx: Scope) -> View<G> {
)) ))
} }
input(type="button", value="Reset", on:click=move |_| { input(type="button", value="Reset", on:click=move |_| {
// Poor man's click event signaling. sh.dispatch(cx, Message::LoadState(None));
let toggle = !*refresh_click.get();
refresh_click.set(toggle);
}) })
input(type="button", value="Clear All", on:click=move |_| { input(type="button", value="Clear All", on:click=move |_| {
let state = app_state::State::get_from_context(cx); sh.dispatch(cx, Message::ResetRecipeCounts);
state.reset_recipe_counts();
}) })
input(type="button", value="Save Plan", on:click=move |_| { input(type="button", value="Save Plan", on:click=move |_| {
// Poor man's click event signaling. // Poor man's click event signaling.
let toggle = !*save_click.get(); sh.dispatch(cx, Message::SaveState(None));
save_click.set(toggle);
}) })
} }
} }

View File

@ -16,43 +16,37 @@ use std::rc::Rc;
use sycamore::prelude::*; use sycamore::prelude::*;
use tracing::{debug, instrument}; use tracing::{debug, instrument};
use crate::app_state; use crate::app_state::{Message, StateHandler};
#[derive(Props)] #[derive(Props)]
pub struct RecipeCheckBoxProps<'ctx> { pub struct RecipeCheckBoxProps<'ctx> {
pub i: String, pub i: String,
pub title: &'ctx ReadSignal<String>, pub title: &'ctx ReadSignal<String>,
pub sh: StateHandler<'ctx>,
} }
#[instrument(skip(props, cx), fields( #[instrument(skip(props, cx), fields(
idx=%props.i, id=%props.i,
title=%props.title.get() title=%props.title.get()
))] ))]
#[component] #[component]
pub fn RecipeSelection<G: Html>(cx: Scope, props: RecipeCheckBoxProps) -> View<G> { pub fn RecipeSelection<'ctx, G: Html>(
let state = app_state::State::get_from_context(cx); cx: Scope<'ctx>,
// This is total hack but it works around the borrow issues with props: RecipeCheckBoxProps<'ctx>,
// the `view!` macro. ) -> View<G> {
let id = Rc::new(props.i); let RecipeCheckBoxProps { i, title, sh } = props;
let id = Rc::new(i);
let id_clone = id.clone();
let count = create_signal( let count = create_signal(
cx, cx,
format!( sh.get_value(
"{}", |state| match state.get_untracked().recipe_counts.get(id_clone.as_ref()) {
state Some(count) => format!("{}", count),
.get_recipe_count_by_index(id.as_ref()) None => "0".to_owned(),
.unwrap_or_else(|| state.set_recipe_count_by_index(id.as_ref(), 0)) },
), ),
); );
create_effect(cx, { let title = title.get().clone();
let id = id.clone();
let state = app_state::State::get_from_context(cx);
move || {
if let Some(usize_count) = state.get_recipe_count_by_index(id.as_ref()) {
count.set(format!("{}", *usize_count.get()));
}
}
});
let title = props.title.get().clone();
let for_id = id.clone(); let for_id = id.clone();
let href = format!("/ui/recipe/view/{}", id); let href = format!("/ui/recipe/view/{}", id);
let name = format!("recipe_id:{}", id); let name = format!("recipe_id:{}", id);
@ -60,9 +54,8 @@ pub fn RecipeSelection<G: Html>(cx: Scope, props: RecipeCheckBoxProps) -> View<G
div() { div() {
label(for=for_id) { a(href=href) { (*title) } } label(for=for_id) { a(href=href) { (*title) } }
input(type="number", class="item-count-sel", min="0", bind:value=count, name=name, on:change=move |_| { input(type="number", class="item-count-sel", min="0", bind:value=count, name=name, on:change=move |_| {
let state = app_state::State::get_from_context(cx);
debug!(idx=%id, count=%(*count.get()), "setting recipe count"); debug!(idx=%id, count=%(*count.get()), "setting recipe count");
state.set_recipe_count_by_index(id.as_ref(), count.get().parse().expect("recipe count isn't a valid usize number")); sh.dispatch(cx, Message::UpdateRecipeCount(id.as_ref().clone(), count.get().parse().expect("Count is not a valid usize")));
}) })
} }
} }

View File

@ -11,36 +11,88 @@
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
use std::collections::{BTreeMap, BTreeSet}; use std::collections::BTreeSet;
use recipes::{Ingredient, IngredientKey}; use recipes::{IngredientAccumulator, IngredientKey};
use sycamore::{futures::spawn_local_scoped, prelude::*}; use sycamore::prelude::*;
use tracing::{debug, info, instrument}; use tracing::{debug, info, instrument};
use crate::app_state::{Message, StateHandler};
#[instrument(skip_all)]
fn make_ingredients_rows<'ctx, G: Html>( fn make_ingredients_rows<'ctx, G: Html>(
cx: Scope<'ctx>, cx: Scope<'ctx>,
ingredients: &'ctx ReadSignal<Vec<(IngredientKey, (Ingredient, BTreeSet<String>))>>, sh: StateHandler<'ctx>,
modified_amts: RcSignal<BTreeMap<IngredientKey, RcSignal<String>>>, show_staples: &'ctx ReadSignal<bool>,
filtered_keys: RcSignal<BTreeSet<IngredientKey>>,
) -> View<G> { ) -> View<G> {
debug!("Making ingredients rows");
let ingredients = sh.get_selector(cx, move |state| {
let state = state.get();
debug!("building ingredient list from state");
let mut acc = IngredientAccumulator::new();
for (id, count) in state.recipe_counts.iter() {
for _ in 0..(*count) {
acc.accumulate_from(
state
.recipes
.get(id)
.expect(&format!("No such recipe id exists: {}", id)),
);
}
}
if *show_staples.get() {
if let Some(staples) = &state.staples {
acc.accumulate_from(staples);
}
}
acc.ingredients()
.into_iter()
// First we filter out any filtered ingredients
.filter(|(i, _)| !state.filtered_ingredients.contains(i))
// Then we take into account our modified amts
.map(|(k, (i, rs))| {
if state.modified_amts.contains_key(&k) {
(
k.clone(),
(
i.name,
i.form,
i.category,
state.modified_amts.get(&k).unwrap().clone(),
rs,
),
)
} else {
(
k.clone(),
(
i.name,
i.form,
i.category,
format!("{}", i.amt.normalize()),
rs,
),
)
}
})
.collect::<Vec<(
IngredientKey,
(String, Option<String>, String, String, BTreeSet<String>),
)>>()
});
view!( view!(
cx, cx,
Indexed( Indexed(
iterable = ingredients, iterable = ingredients,
view = move |cx, (k, (i, rs))| { view = move |cx, (k, (name, form, category, amt, rs))| {
let mut modified_amt_set = modified_amts.get().as_ref().clone(); let category = if category == "" {
let amt = modified_amt_set
.entry(k.clone())
.or_insert(create_rc_signal(format!("{}", i.amt.normalize())))
.clone();
modified_amts.set(modified_amt_set);
let name = i.name;
let category = if i.category == "" {
"other".to_owned() "other".to_owned()
} else { } else {
i.category category
}; };
let form = i.form.map(|form| format!("({})", form)).unwrap_or_default(); let amt_signal = create_signal(cx, amt);
let k_clone = k.clone();
let form = form.map(|form| format!("({})", form)).unwrap_or_default();
let recipes = rs let recipes = rs
.iter() .iter()
.fold(String::new(), |acc, s| format!("{}{},", acc, s)) .fold(String::new(), |acc, s| format!("{}{},", acc, s))
@ -49,15 +101,14 @@ fn make_ingredients_rows<'ctx, G: Html>(
view! {cx, view! {cx,
tr { tr {
td { td {
input(bind:value=amt, type="text") input(bind:value=amt_signal, type="text", on:change=move |_| {
sh.dispatch(cx, Message::UpdateAmt(k_clone.clone(), amt_signal.get_untracked().as_ref().clone()));
})
} }
td { td {
input(type="button", class="no-print destructive", value="X", on:click={ input(type="button", class="no-print destructive", value="X", on:click={
let filtered_keys = filtered_keys.clone();
move |_| { move |_| {
let mut keyset = filtered_keys.get().as_ref().clone(); sh.dispatch(cx, Message::AddFilteredIngredient(k.clone()));
keyset.insert(k.clone());
filtered_keys.set(keyset);
}}) }})
} }
td { (name) " " (form) "" br {} "" (category) "" } td { (name) " " (form) "" br {} "" (category) "" }
@ -69,55 +120,53 @@ fn make_ingredients_rows<'ctx, G: Html>(
) )
} }
fn make_extras_rows<'ctx, G: Html>( #[instrument(skip_all)]
cx: Scope<'ctx>, fn make_extras_rows<'ctx, G: Html>(cx: Scope<'ctx>, sh: StateHandler<'ctx>) -> View<G> {
extras: RcSignal<Vec<(usize, (RcSignal<String>, RcSignal<String>))>>, debug!("Making extras rows");
) -> View<G> { let extras_read_signal = sh.get_selector(cx, |state| {
let extras_read_signal = create_memo(cx, { state.get().extras.iter().cloned().enumerate().collect()
let extras = extras.clone();
move || extras.get().as_ref().clone()
}); });
view! {cx, view! {cx,
Indexed( Indexed(
iterable=extras_read_signal, iterable=extras_read_signal,
view= move |cx, (idx, (amt, name))| { view= move |cx, (idx, (amt, name))| {
view! {cx, let amt_signal = create_signal(cx, amt.clone());
tr { let name_signal = create_signal(cx, name.clone());
td { view! {cx,
input(bind:value=amt, type="text") tr {
} td {
td { input(bind:value=amt_signal, type="text", on:change=move |_| {
input(type="button", class="no-print destructive", value="X", on:click={ sh.dispatch(cx, Message::UpdateExtra(idx,
let extras = extras.clone(); amt_signal.get_untracked().as_ref().clone(),
move |_| { name_signal.get_untracked().as_ref().clone()));
extras.set(extras.get().iter() })
.filter(|(i, _)| *i != idx)
.map(|(_, v)| v.clone())
.enumerate()
.collect())
}})
}
td {
input(bind:value=name, type="text")
}
td { "Misc" }
}
} }
td {
input(type="button", class="no-print destructive", value="X", on:click=move |_| {
sh.dispatch(cx, Message::RemoveExtra(idx));
})
}
td {
input(bind:value=name_signal, type="text", on:change=move |_| {
sh.dispatch(cx, Message::UpdateExtra(idx,
amt_signal.get_untracked().as_ref().clone(),
name_signal.get_untracked().as_ref().clone()));
})
}
td { "Misc" }
} }
) }
}
)
} }
} }
fn make_shopping_table<'ctx, G: Html>( fn make_shopping_table<'ctx, G: Html>(
cx: Scope<'ctx>, cx: Scope<'ctx>,
ingredients: &'ctx ReadSignal<Vec<(IngredientKey, (Ingredient, BTreeSet<String>))>>, sh: StateHandler<'ctx>,
modified_amts: RcSignal<BTreeMap<IngredientKey, RcSignal<String>>>, show_staples: &'ctx ReadSignal<bool>,
extras: RcSignal<Vec<(usize, (RcSignal<String>, RcSignal<String>))>>,
filtered_keys: RcSignal<BTreeSet<IngredientKey>>,
) -> View<G> { ) -> View<G> {
let extra_rows_view = make_extras_rows(cx, extras); debug!("Making shopping table");
let ingredient_rows =
make_ingredients_rows(cx, ingredients, modified_amts, filtered_keys.clone());
view! {cx, view! {cx,
table(class="pad-top shopping-list page-breaker container-fluid", role="grid") { table(class="pad-top shopping-list page-breaker container-fluid", role="grid") {
tr { tr {
@ -127,103 +176,33 @@ fn make_shopping_table<'ctx, G: Html>(
th { " Recipes " } th { " Recipes " }
} }
tbody { tbody {
(ingredient_rows) (make_ingredients_rows(cx, sh, show_staples))
(extra_rows_view) (make_extras_rows(cx, sh))
} }
} }
} }
} }
#[instrument] #[instrument(skip_all)]
#[component] #[component]
pub fn ShoppingList<G: Html>(cx: Scope) -> View<G> { pub fn ShoppingList<'ctx, G: Html>(cx: Scope<'ctx>, sh: StateHandler<'ctx>) -> View<G> {
let state = crate::app_state::State::get_from_context(cx);
// FIXME(jwall): We need to init this state for the page at some point.
let filtered_keys: RcSignal<BTreeSet<IngredientKey>> = state.filtered_ingredients.clone();
let ingredients_map = create_rc_signal(BTreeMap::new());
let show_staples = create_signal(cx, true); let show_staples = create_signal(cx, true);
let save_click = create_signal(cx, ());
create_effect(cx, {
let state = crate::app_state::State::get_from_context(cx);
let ingredients_map = ingredients_map.clone();
move || {
ingredients_map.set(state.get_shopping_list(*show_staples.get()));
}
});
debug!(ingredients_map=?ingredients_map.get_untracked());
let ingredients = create_memo(cx, {
let filtered_keys = filtered_keys.clone();
let ingredients_map = ingredients_map.clone();
move || {
let mut ingredients = Vec::new();
// This has the effect of sorting the ingredients by category
for (_, ingredients_list) in ingredients_map.get().iter() {
for (i, recipes) in ingredients_list.iter() {
if !filtered_keys.get().contains(&i.key()) {
ingredients.push((i.key(), (i.clone(), recipes.clone())));
}
}
}
ingredients
}
});
let table_view = create_signal(cx, View::empty());
create_effect(cx, {
let filtered_keys = filtered_keys.clone();
let state = crate::app_state::State::get_from_context(cx);
move || {
if (ingredients.get().len() > 0) || (state.extras.get().len() > 0) {
table_view.set(make_shopping_table(
cx,
ingredients,
state.modified_amts.clone(),
state.extras.clone(),
filtered_keys.clone(),
));
} else {
table_view.set(View::empty());
}
}
});
create_effect(cx, move || {
save_click.track();
info!("Registering save request for inventory");
spawn_local_scoped(cx, {
let state = crate::app_state::State::get_from_context(cx);
let store = crate::api::HttpStore::get_from_context(cx);
async move {
debug!(?state, "Attempting save for inventory");
store
.save_state(state)
.await
.expect("Unable to save inventory data");
}
})
});
let state = crate::app_state::State::get_from_context(cx);
view! {cx, view! {cx,
h1 { "Shopping List " } h1 { "Shopping List " }
label(for="show_staples_cb") { "Show staples" } label(for="show_staples_cb") { "Show staples" }
input(id="show_staples_cb", type="checkbox", bind:checked=show_staples) input(id="show_staples_cb", type="checkbox", bind:checked=show_staples)
(table_view.get().as_ref().clone()) (make_shopping_table(cx, sh, show_staples))
input(type="button", value="Add Item", class="no-print", on:click=move |_| { input(type="button", value="Add Item", class="no-print", on:click=move |_| {
let mut cloned_extras: Vec<(RcSignal<String>, RcSignal<String>)> = (*state.extras.get()).iter().map(|(_, tpl)| tpl.clone()).collect(); info!("Registering add item request for inventory");
cloned_extras.push((create_rc_signal("".to_owned()), create_rc_signal("".to_owned()))); sh.dispatch(cx, Message::AddExtra(String::new(), String::new()));
state.extras.set(cloned_extras.drain(0..).enumerate().collect());
}) })
input(type="button", value="Reset", class="no-print", on:click={ input(type="button", value="Reset", class="no-print", on:click=move |_| {
let state = crate::app_state::State::get_from_context(cx); info!("Registering reset request for inventory");
move |_| { sh.dispatch(cx, Message::ResetInventory);
// TODO(jwall): We should actually pop up a modal here or use a different set of items.
ingredients_map.set(state.get_shopping_list(*show_staples.get()));
// clear the filter_signal
filtered_keys.set(BTreeSet::new());
state.modified_amts.set(BTreeMap::new());
state.extras.set(Vec::new());
}
}) })
input(type="button", value="Save", class="no-print", on:click=|_| { input(type="button", value="Save", class="no-print", on:click=move |_| {
save_click.trigger_subscribers(); info!("Registering save request for inventory");
sh.dispatch(cx, Message::SaveState(None));
}) })
} }
} }

View File

@ -43,7 +43,6 @@ pub fn TabbedView<'a, G: Html>(cx: Scope<'a>, state: TabState<'a, G>) -> View<G>
view! {cx, view! {cx,
li(class=class) { a(href=href) { (show) } } li(class=class) { a(href=href) { (show) } }
} }
// TODO
}) })
.collect(), .collect(),
); );

View File

@ -43,12 +43,3 @@ pub fn get_storage() -> Storage {
.expect("Failed to get storage") .expect("Failed to get storage")
.expect("No storage available") .expect("No storage available")
} }
pub fn get_storage_keys() -> Vec<String> {
let storage = get_storage();
let mut keys = Vec::new();
for idx in 0..storage.length().unwrap() {
keys.push(get_storage().key(idx).unwrap().unwrap())
}
keys
}

View File

@ -11,28 +11,16 @@
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
use sycamore::{futures::spawn_local_scoped, prelude::*}; use sycamore::futures::spawn_local_scoped;
use sycamore::prelude::*;
use tracing::{debug, info}; use tracing::{debug, info};
use crate::app_state; use crate::app_state::{Message, StateHandler};
#[component] #[component]
pub fn LoginForm<G: Html>(cx: Scope) -> View<G> { pub fn LoginForm<'ctx, G: Html>(cx: Scope<'ctx>, sh: StateHandler<'ctx>) -> View<G> {
let username = create_signal(cx, "".to_owned()); let username = create_signal(cx, "".to_owned());
let password = create_signal(cx, "".to_owned()); let password = create_signal(cx, "".to_owned());
let clicked = create_signal(cx, ("".to_owned(), "".to_owned()));
create_effect(cx, move || {
let (username, password) = (*clicked.get()).clone();
if username != "" && password != "" {
spawn_local_scoped(cx, async move {
let state = app_state::State::get_from_context(cx);
let store = crate::api::HttpStore::get_from_context(cx);
debug!("authenticating against ui");
// TODO(jwall): Navigate to plan if the below is successful.
state.auth.set(store.authenticate(username, password).await);
});
}
});
view! {cx, view! {cx,
form() { form() {
label(for="username") { "Username" } label(for="username") { "Username" }
@ -41,17 +29,26 @@ pub fn LoginForm<G: Html>(cx: Scope) -> View<G> {
input(type="password", bind:value=password) input(type="password", bind:value=password)
input(type="button", value="Login", on:click=move |_| { input(type="button", value="Login", on:click=move |_| {
info!("Attempting login request"); info!("Attempting login request");
clicked.set(((*username.get_untracked()).clone(), (*password.get_untracked()).clone())); let (username, password) = ((*username.get_untracked()).clone(), (*password.get_untracked()).clone());
if username != "" && password != "" {
spawn_local_scoped(cx, async move {
let store = crate::api::HttpStore::get_from_context(cx);
debug!("authenticating against ui");
if let Some(user_data) = store.authenticate(username, password).await {
sh.dispatch(cx, Message::SetUserData(user_data));
sh.dispatch(cx, Message::LoadState(Some(Box::new(|| sycamore_router::navigate("/ui/planning/plan")))));
}
});
}
debug!("triggering login click subscribers"); debug!("triggering login click subscribers");
clicked.trigger_subscribers();
}) { } }) { }
} }
} }
} }
#[component] #[component]
pub fn LoginPage<G: Html>(cx: Scope) -> View<G> { pub fn LoginPage<'ctx, G: Html>(cx: Scope<'ctx>, sh: StateHandler<'ctx>) -> View<G> {
view! {cx, view! {cx,
LoginForm() LoginForm(sh)
} }
} }

View File

@ -12,15 +12,15 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
use super::ManagePage; use super::ManagePage;
use crate::components::add_recipe::AddRecipe; use crate::{app_state::StateHandler, components::add_recipe::AddRecipe};
use sycamore::prelude::*; use sycamore::prelude::*;
#[component] #[component]
pub fn AddRecipePage<G: Html>(cx: Scope) -> View<G> { pub fn AddRecipePage<'ctx, G: Html>(cx: Scope<'ctx>, sh: StateHandler<'ctx>) -> View<G> {
view! {cx, view! {cx,
ManagePage( ManagePage(
selected=Some("New Recipe".to_owned()), selected=Some("New Recipe".to_owned()),
) { AddRecipe() } ) { AddRecipe(sh) }
} }
} }

View File

@ -12,15 +12,15 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
use super::ManagePage; use super::ManagePage;
use crate::components::categories::*; use crate::{app_state::StateHandler, components::categories::*};
use sycamore::prelude::*; use sycamore::prelude::*;
#[component()] #[component()]
pub fn CategoryPage<G: Html>(cx: Scope) -> View<G> { pub fn CategoryPage<'ctx, G: Html>(cx: Scope<'ctx>, sh: StateHandler<'ctx>) -> View<G> {
view! {cx, view! {cx,
ManagePage( ManagePage(
selected=Some("Categories".to_owned()), selected=Some("Categories".to_owned()),
) { Categories() } ) { Categories(sh) }
} }
} }

View File

@ -12,17 +12,17 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
use super::ManagePage; use super::ManagePage;
use crate::components::recipe::Editor; use crate::{app_state::StateHandler, components::recipe::Editor};
use sycamore::prelude::*; use sycamore::prelude::*;
use tracing::instrument; use tracing::instrument;
#[instrument] #[instrument(skip_all)]
#[component()] #[component()]
pub fn StaplesPage<G: Html>(cx: Scope) -> View<G> { pub fn StaplesPage<'ctx, G: Html>(cx: Scope<'ctx>, sh: StateHandler<'ctx>) -> View<G> {
view! {cx, view! {cx,
ManagePage( ManagePage(
selected=Some("Staples".to_owned()), selected=Some("Staples".to_owned()),
) { Editor("staples.txt".to_owned()) } ) { Editor(recipe_id="staples.txt".to_owned(), sh=sh) }
} }
} }

View File

@ -14,13 +14,13 @@
use sycamore::prelude::*; use sycamore::prelude::*;
use super::PlanningPage; use super::PlanningPage;
use crate::components::recipe_list::*; use crate::{app_state::StateHandler, components::recipe_list::*};
#[component] #[component]
pub fn CookPage<G: Html>(cx: Scope) -> View<G> { pub fn CookPage<'ctx, G: Html>(cx: Scope<'ctx>, sh: StateHandler<'ctx>) -> View<G> {
view! {cx, view! {cx,
PlanningPage( PlanningPage(
selected=Some("Cook".to_owned()), selected=Some("Cook".to_owned()),
) { RecipeList() } ) { RecipeList(sh) }
} }
} }

View File

@ -14,13 +14,13 @@
use sycamore::prelude::*; use sycamore::prelude::*;
use super::PlanningPage; use super::PlanningPage;
use crate::components::shopping_list::*; use crate::{app_state::StateHandler, components::shopping_list::*};
#[component] #[component]
pub fn InventoryPage<G: Html>(cx: Scope) -> View<G> { pub fn InventoryPage<'ctx, G: Html>(cx: Scope<'ctx>, sh: StateHandler<'ctx>) -> View<G> {
view! {cx, view! {cx,
PlanningPage( PlanningPage(
selected=Some("Inventory".to_owned()), selected=Some("Inventory".to_owned()),
) { ShoppingList() } ) { ShoppingList(sh) }
} }
} }

View File

@ -12,15 +12,15 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
use super::PlanningPage; use super::PlanningPage;
use crate::components::recipe_plan::*; use crate::{app_state::StateHandler, components::recipe_plan::*};
use sycamore::prelude::*; use sycamore::prelude::*;
#[component] #[component]
pub fn PlanPage<G: Html>(cx: Scope) -> View<G> { pub fn PlanPage<'ctx, G: Html>(cx: Scope<'ctx>, sh: StateHandler<'ctx>) -> View<G> {
view! {cx, view! {cx,
PlanningPage( PlanningPage(
selected=Some("Plan".to_owned()), selected=Some("Plan".to_owned()),
) { RecipePlan() } ) { RecipePlan(sh) }
} }
} }

View File

@ -17,13 +17,14 @@ use crate::components::recipe::Editor;
use sycamore::prelude::*; use sycamore::prelude::*;
use tracing::instrument; use tracing::instrument;
#[instrument] #[instrument(skip_all, fields(recipe=props.recipe))]
#[component()] #[component()]
pub fn RecipeEditPage<G: Html>(cx: Scope, props: RecipePageProps) -> View<G> { pub fn RecipeEditPage<'ctx, G: Html>(cx: Scope<'ctx>, props: RecipePageProps<'ctx>) -> View<G> {
let RecipePageProps { recipe, sh } = props;
view! {cx, view! {cx,
RecipePage( RecipePage(
selected=Some("Edit".to_owned()), selected=Some("Edit".to_owned()),
recipe=props.recipe.clone(), recipe=recipe.clone(),
) { Editor(props.recipe) } ) { Editor(recipe_id=recipe, sh=sh) }
} }
} }

View File

@ -13,16 +13,17 @@
// limitations under the License. // limitations under the License.
use sycamore::prelude::*; use sycamore::prelude::*;
use crate::components::tabs::*; use crate::{app_state::StateHandler, components::tabs::*};
mod edit; mod edit;
mod view; mod view;
pub use edit::*; pub use edit::*;
pub use view::*; pub use view::*;
#[derive(Debug, Props)] #[derive(Props)]
pub struct RecipePageProps { pub struct RecipePageProps<'ctx> {
pub recipe: String, pub recipe: String,
pub sh: StateHandler<'ctx>,
} }
#[derive(Props)] #[derive(Props)]

View File

@ -18,13 +18,14 @@ use tracing::instrument;
use super::{RecipePage, RecipePageProps}; use super::{RecipePage, RecipePageProps};
#[instrument] #[instrument(skip_all, fields(recipe=props.recipe))]
#[component()] #[component()]
pub fn RecipeViewPage<G: Html>(cx: Scope, props: RecipePageProps) -> View<G> { pub fn RecipeViewPage<'ctx, G: Html>(cx: Scope<'ctx>, props: RecipePageProps<'ctx>) -> View<G> {
let RecipePageProps { recipe, sh } = props;
view! {cx, view! {cx,
RecipePage( RecipePage(
selected=Some("View".to_owned()), selected=Some("View".to_owned()),
recipe=props.recipe.clone(), recipe=recipe.clone(),
) { Viewer(props.recipe) } ) { Viewer(recipe_id=recipe, sh=sh) }
} }
} }

View File

@ -12,63 +12,15 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
use crate::{
app_state::StateHandler,
components::{Footer, Header},
pages::*,
};
use sycamore::prelude::*; use sycamore::prelude::*;
use sycamore_router::{HistoryIntegration, Route, Router}; use sycamore_router::{HistoryIntegration, Route, Router};
use tracing::{debug, instrument}; use tracing::{debug, instrument};
use crate::pages::*;
#[instrument]
fn route_switch<'a, G: Html>(cx: Scope<'a>, route: &'a ReadSignal<Routes>) -> View<G> {
// NOTE(jwall): This needs to not be a dynamic node. The rules around
// this are somewhat unclear and underdocumented for Sycamore. But basically
// avoid conditionals in the `view!` macro calls here.
let switcher = |cx: Scope, route: &Routes| {
debug!(?route, "Dispatching for route");
match route {
Routes::Planning(Plan) => view! {cx,
PlanPage()
},
Routes::Planning(Inventory) => view! {cx,
InventoryPage()
},
Routes::Planning(Cook) => view! {cx,
CookPage()
},
Routes::Login => view! {cx,
LoginPage()
},
Routes::Recipe(RecipeRoutes::View(id)) => view! {cx,
RecipeViewPage(recipe=id.clone())
},
Routes::Recipe(RecipeRoutes::Edit(id)) => view! {cx,
RecipeEditPage(recipe=id.clone())
},
Routes::Manage(ManageRoutes::Categories) => view! {cx,
CategoryPage()
},
Routes::Manage(ManageRoutes::NewRecipe) => view! {cx,
AddRecipePage()
},
Routes::Manage(ManageRoutes::Staples) => view! {cx,
StaplesPage()
},
Routes::NotFound
| Routes::Manage(ManageRoutes::NotFound)
| Routes::Planning(PlanningRoutes::NotFound)
| Routes::Recipe(RecipeRoutes::NotFound) => view! {cx,
// TODO(Create a real one)
PlanPage()
},
}
};
use PlanningRoutes::*;
view! {cx,
(switcher(cx, route.get().as_ref()))
}
}
#[derive(Route, Debug)] #[derive(Route, Debug)]
pub enum Routes { pub enum Routes {
#[to("/ui/planning/<_..>")] #[to("/ui/planning/<_..>")]
@ -117,12 +69,69 @@ pub enum PlanningRoutes {
NotFound, NotFound,
} }
#[derive(Props)]
pub struct HandlerProps<'ctx> {
sh: StateHandler<'ctx>,
}
#[instrument(skip_all, fields(?route))]
fn route_switch<'ctx, G: Html>(route: &Routes, cx: Scope<'ctx>, sh: StateHandler<'ctx>) -> View<G> {
debug!("Handling route change");
use ManageRoutes::*;
use PlanningRoutes::*;
match route {
Routes::Planning(Plan) => view! {cx,
PlanPage(sh)
},
Routes::Planning(Inventory) => view! {cx,
InventoryPage(sh)
},
Routes::Planning(Cook) => view! {cx,
CookPage(sh)
},
Routes::Login => view! {cx,
LoginPage(sh)
},
Routes::Recipe(RecipeRoutes::View(id)) => view! {cx,
RecipeViewPage(recipe=id.clone(), sh=sh)
},
Routes::Recipe(RecipeRoutes::Edit(id)) => view! {cx,
RecipeEditPage(recipe=id.clone(), sh=sh)
},
Routes::Manage(Categories) => view! {cx,
CategoryPage(sh)
},
Routes::Manage(NewRecipe) => view! {cx,
AddRecipePage(sh)
},
Routes::Manage(Staples) => view! {cx,
StaplesPage(sh)
},
Routes::NotFound
| Routes::Manage(ManageRoutes::NotFound)
| Routes::Planning(PlanningRoutes::NotFound)
| Routes::Recipe(RecipeRoutes::NotFound) => view! {cx,
// TODO(Create a real one)
PlanPage(sh)
},
}
}
#[component] #[component]
pub fn Handler<G: Html>(cx: Scope) -> View<G> { pub fn Handler<'ctx, G: Html>(cx: Scope<'ctx>, props: HandlerProps<'ctx>) -> View<G> {
let HandlerProps { sh } = props;
view! {cx, view! {cx,
Router( Router(
integration=HistoryIntegration::new(), integration=HistoryIntegration::new(),
view=route_switch, view=move |cx: Scope, route: &ReadSignal<Routes>| {
view!{cx,
div(class="app") {
Header(sh)
(route_switch(route.get().as_ref(), cx, sh))
Footer { }
}
}
},
) )
} }
} }

View File

@ -12,35 +12,25 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
use sycamore::{futures::spawn_local_scoped, prelude::*}; use sycamore::{futures::spawn_local_scoped, prelude::*};
use tracing::{error, info, instrument}; use tracing::{info, instrument};
use crate::components::{Footer, Header}; use crate::app_state::Message;
use crate::{api, routing::Handler as RouteHandler}; use crate::{api, routing::Handler as RouteHandler};
#[instrument] #[instrument]
#[component] #[component]
pub fn UI<G: Html>(cx: Scope) -> View<G> { pub fn UI<G: Html>(cx: Scope) -> View<G> {
crate::app_state::State::provide_context(cx);
api::HttpStore::provide_context(cx, "/api".to_owned()); api::HttpStore::provide_context(cx, "/api".to_owned());
let store = api::HttpStore::get_from_context(cx).as_ref().clone();
info!("Starting UI"); info!("Starting UI");
let app_state = crate::app_state::AppState::new();
let sh = crate::app_state::get_state_handler(cx, app_state, store);
let view = create_signal(cx, View::empty()); 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, { spawn_local_scoped(cx, {
let store = api::HttpStore::get_from_context(cx);
let state = crate::app_state::State::get_from_context(cx);
async move { async move {
if let Err(err) = api::init_page_state(store.as_ref(), state.as_ref()).await { sh.dispatch(cx, Message::LoadState(None));
error!(?err);
};
// TODO(jwall): This needs to be moved into the RouteHandler
view.set(view! { cx, view.set(view! { cx,
div(class="app") { RouteHandler(sh=sh)
Header { }
RouteHandler()
Footer { }
}
}); });
} }
}); });