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
result
.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<()>;
#[derive(Serialize, Deserialize, Debug)]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct UserData {
pub user_id: String,
}

View File

@ -14,6 +14,7 @@ let
# incorrect. We override those here.
"sycamore-0.8.2" = "sha256-D968+8C5EelGGmot9/LkAlULZOf/Cr+1WYXRCMwb1nQ=";
"sqlx-0.6.2" = "sha256-X/LFvtzRfiOIEZJiVzmFvvULPpjhqvI99pSwH7a//GM=";
"sycamore-state-0.0.1" = "sha256-RatNr1b6r7eP3fOVatHA44D9xhDAljqSIWtFpMeBA9Y=";
};
});
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!(
pub categories<StrIter, BTreeMap<String, String>>,
do_each!(

View File

@ -15,6 +15,7 @@ crate-type = ["cdylib", "rlib"]
[dependencies]
recipes = { path = "../recipes" }
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.
console_error_panic_hook = "0.1.7"
serde_json = "1.0.79"

View File

@ -17,14 +17,16 @@ use base64;
use reqwasm;
use serde_json::{from_str, to_string};
use sycamore::prelude::*;
use tracing::{debug, error, info, instrument, warn};
use tracing::{debug, error, instrument, warn};
use client_api::*;
use recipes::{parse, IngredientKey, Recipe, RecipeEntry};
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]
fn filter_recipes(
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)]
pub struct Error(String);
@ -179,14 +108,224 @@ fn token68(user: String, pass: String) -> String {
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)]
pub struct HttpStore {
root: String,
local_store: LocalStore,
}
impl HttpStore {
fn new(root: String) -> Self {
Self { root }
pub fn new(root: String) -> Self {
Self {
root,
local_store: LocalStore::new(),
}
}
pub fn v1_path(&self) -> String {
@ -215,7 +354,6 @@ impl HttpStore {
debug!("attempting login request against api.");
let mut path = self.v1_path();
path.push_str("/auth");
let storage = js_lib::get_storage();
let result = reqwasm::http::Request::get(&path)
.header(
"Authorization",
@ -230,12 +368,6 @@ impl HttpStore {
.await
.expect("Unparseable authentication response")
.as_success();
storage
.set(
"user_data",
&to_string(&user_data).expect("Unable to serialize user_data"),
)
.unwrap();
return user_data;
}
error!(status = resp.status(), "Login was unsuccessful")
@ -249,12 +381,11 @@ impl HttpStore {
pub async fn get_categories(&self) -> Result<Option<String>, Error> {
let mut path = self.v1_path();
path.push_str("/categories");
let storage = js_lib::get_storage();
let resp = match reqwasm::http::Request::get(&path).send().await {
Ok(resp) => resp,
Err(reqwasm::Error::JsError(err)) => {
error!(path, ?err, "Error hitting api");
return Ok(storage.get("categories")?);
return Ok(self.local_store.get_categories());
}
Err(err) => {
return Err(err)?;
@ -262,14 +393,12 @@ impl HttpStore {
};
if resp.status() == 404 {
debug!("Categories returned 404");
storage.remove_item("categories")?;
Ok(None)
} else if resp.status() != 200 {
Err(format!("Status: {}", resp.status()).into())
} else {
debug!("We got a valid response back!");
let resp = resp.json::<CategoryResponse>().await?.as_success().unwrap();
storage.set("categories", &resp)?;
Ok(Some(resp))
}
}
@ -278,26 +407,16 @@ impl HttpStore {
pub async fn get_recipes(&self) -> Result<Option<Vec<RecipeEntry>>, Error> {
let mut path = self.v1_path();
path.push_str("/recipes");
let storage = js_lib::get_storage();
let resp = match reqwasm::http::Request::get(&path).send().await {
Ok(resp) => resp,
Err(reqwasm::Error::JsError(err)) => {
error!(path, ?err, "Error hitting api");
let mut entries = Vec::new();
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));
return Ok(self.local_store.get_recipes());
}
Err(err) => {
return Err(err)?;
}
};
let storage = js_lib::get_storage();
if resp.status() != 200 {
Err(format!("Status: {}", resp.status()).into())
} else {
@ -307,14 +426,6 @@ impl HttpStore {
.await
.map_err(|e| format!("{}", e))?
.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)
}
}
@ -326,15 +437,11 @@ impl HttpStore {
let mut path = self.v1_path();
path.push_str("/recipe/");
path.push_str(id.as_ref());
let storage = js_lib::get_storage();
let resp = match reqwasm::http::Request::get(&path).send().await {
Ok(resp) => resp,
Err(reqwasm::Error::JsError(err)) => {
error!(path, ?err, "Error hitting api");
return match storage.get(&recipe_key(&id))? {
Some(s) => Ok(Some(from_str(&s).map_err(|e| format!("{}", e))?)),
None => Ok(None),
};
return Ok(self.local_store.get_recipe_entry(id.as_ref()));
}
Err(err) => {
return Err(err)?;
@ -354,8 +461,7 @@ impl HttpStore {
.as_success()
.unwrap();
if let Some(ref entry) = entry {
let serialized: String = to_string(entry).map_err(|e| format!("{}", e))?;
storage.set(&recipe_key(entry.recipe_id()), &serialized)?
self.local_store.set_recipe_entry(entry);
}
Ok(entry)
}
@ -365,15 +471,10 @@ impl HttpStore {
pub async fn save_recipes(&self, recipes: Vec<RecipeEntry>) -> Result<(), Error> {
let mut path = self.v1_path();
path.push_str("/recipes");
let storage = js_lib::get_storage();
for r in recipes.iter() {
if r.recipe_id().is_empty() {
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 resp = reqwasm::http::Request::post(&path)
@ -393,8 +494,6 @@ impl HttpStore {
pub async fn save_categories(&self, categories: String) -> Result<(), Error> {
let mut path = self.v1_path();
path.push_str("/categories");
let storage = js_lib::get_storage();
storage.set("categories", &categories)?;
let resp = reqwasm::http::Request::post(&path)
.body(to_string(&categories).expect("Unable to encode categories as json"))
.header("content-type", "application/json")
@ -408,25 +507,23 @@ impl HttpStore {
}
}
#[instrument]
pub async fn save_state(&self, state: std::rc::Rc<app_state::State>) -> Result<(), Error> {
#[instrument(skip_all)]
pub async fn save_app_state(&self, state: AppState) -> Result<(), Error> {
let mut plan = Vec::new();
for (key, count) in state.recipe_counts.get_untracked().iter() {
plan.push((key.clone(), *count.get_untracked() as i32));
for (key, count) in state.recipe_counts.iter() {
plan.push((key.clone(), *count as i32));
}
debug!("Saving plan data");
self.save_plan(plan).await?;
debug!("Saving inventory data");
self.save_inventory_data(
state.filtered_ingredients.get_untracked().as_ref().clone(),
state.get_current_modified_amts(),
state.filtered_ingredients,
state.modified_amts,
state
.extras
.get()
.as_ref()
.iter()
.map(|t| (t.1 .0.get().as_ref().clone(), t.1 .1.get().as_ref().clone()))
.collect(),
.cloned()
.collect::<Vec<(String, String)>>(),
)
.await
}
@ -434,9 +531,6 @@ impl HttpStore {
pub async fn save_plan(&self, plan: Vec<(String, i32)>) -> Result<(), Error> {
let mut path = self.v1_path();
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)
.body(to_string(&plan).expect("Unable to encode plan as json"))
.header("content-type", "application/json")
@ -454,7 +548,6 @@ impl HttpStore {
let mut path = self.v1_path();
path.push_str("/plan");
let resp = reqwasm::http::Request::get(&path).send().await?;
let storage = js_lib::get_storage();
if resp.status() != 200 {
Err(format!("Status: {}", resp.status()).into())
} else {
@ -464,10 +557,6 @@ impl HttpStore {
.await
.map_err(|e| format!("{}", e))?
.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)
}
}
@ -484,30 +573,12 @@ impl HttpStore {
> {
let mut path = self.v2_path();
path.push_str("/inventory");
let storage = js_lib::get_storage();
let resp = reqwasm::http::Request::get(&path).send().await?;
if resp.status() != 200 {
let err = Err(format!("Status: {}", resp.status()).into());
Ok(match storage.get("inventory") {
Ok(Some(val)) => match from_str(&val) {
// TODO(jwall): Once we remove the v1 endpoint this is no longer needed.
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,
Ok(match self.local_store.get_inventory_data() {
Some(val) => val,
None => return err,
})
} else {
debug!("We got a valid response back");
@ -521,11 +592,6 @@ impl HttpStore {
.map_err(|e| format!("{}", e))?
.as_success()
.unwrap();
let _ = storage.set(
"inventory",
&to_string(&(&filtered_ingredients, &modified_amts))
.expect("Failed to serialize inventory data"),
);
Ok((
filtered_ingredients.into_iter().collect(),
modified_amts.into_iter().collect(),
@ -545,13 +611,9 @@ impl HttpStore {
path.push_str("/inventory");
let filtered_ingredients: Vec<IngredientKey> = filtered_ingredients.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))
.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");
let resp = reqwasm::http::Request::post(&path)
.body(&serialized_inventory)

View File

@ -11,139 +11,373 @@
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use std::collections::{BTreeMap, BTreeSet};
use sycamore::prelude::*;
use tracing::{debug, instrument, warn};
use std::{
collections::{BTreeMap, BTreeSet},
fmt::Debug,
};
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)]
pub struct State {
pub recipe_counts: RcSignal<BTreeMap<String, RcSignal<usize>>>,
pub extras: RcSignal<Vec<(usize, (RcSignal<String>, RcSignal<String>))>>,
pub staples: RcSignal<Option<Recipe>>,
pub recipes: RcSignal<BTreeMap<String, Recipe>>,
pub category_map: RcSignal<BTreeMap<String, String>>,
pub filtered_ingredients: RcSignal<BTreeSet<IngredientKey>>,
pub modified_amts: RcSignal<BTreeMap<IngredientKey, RcSignal<String>>>,
pub auth: RcSignal<Option<UserData>>,
use crate::api::{HttpStore, LocalStore};
#[derive(Debug, Clone, PartialEq)]
pub struct AppState {
pub recipe_counts: BTreeMap<String, usize>,
pub extras: Vec<(String, String)>,
pub staples: Option<Recipe>,
pub recipes: BTreeMap<String, Recipe>,
pub category_map: String,
pub filtered_ingredients: BTreeSet<IngredientKey>,
pub modified_amts: BTreeMap<IngredientKey, String>,
pub auth: Option<UserData>,
}
impl State {
impl AppState {
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),
recipe_counts: BTreeMap::new(),
extras: Vec::new(),
staples: None,
recipes: BTreeMap::new(),
category_map: String::new(),
filtered_ingredients: BTreeSet::new(),
modified_amts: BTreeMap::new(),
auth: 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()
}
pub fn get_menu_list(&self) -> Vec<(String, RcSignal<usize>)> {
self.recipe_counts
.get()
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.filter(|(_, v)| *(v.get_untracked()) != 0)
.collect()
}
#[instrument(skip(self))]
pub fn get_shopping_list(
&self,
show_staples: bool,
) -> BTreeMap<String, Vec<(Ingredient, BTreeSet<String>)>> {
let mut acc = IngredientAccumulator::new();
let recipe_counts = self.get_menu_list();
for (idx, count) in recipe_counts.iter() {
for _ in 0..*count.get_untracked() {
acc.accumulate_from(
self.recipes
.get()
.get(idx)
.expect(&format!("No such recipe id exists: {}", idx)),
);
}
}
if show_staples {
if let Some(staples) = self.staples.get().as_ref() {
acc.accumulate_from(staples);
}
}
let mut ingredients = acc.ingredients();
let mut groups = BTreeMap::new();
let cat_map = self.category_map.get().clone();
for (_, (i, recipes)) in ingredients.iter_mut() {
let category = if let Some(cat) = cat_map.get(&i.name) {
cat.clone()
} else {
"other".to_owned()
};
i.category = category.clone();
groups
.entry(category)
.or_insert(vec![])
.push((i.clone(), recipes.clone()));
}
debug!(?self.category_map);
// FIXME(jwall): Sort by categories and names.
groups
}
/// 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>> {
self.recipe_counts.get_untracked().get(key).cloned()
}
pub fn reset_recipe_counts(&self) {
for (_, count) in self.recipe_counts.get_untracked().iter() {
count.set(0);
}
}
/// Set the recipe_count by index. Does not trigger subscribers to the entire set of recipe_counts.
/// This does trigger subscribers of the specific recipe you are updating though.
pub fn set_recipe_count_by_index(&self, key: &String, count: usize) -> RcSignal<usize> {
let mut counts = self.recipe_counts.get_untracked().as_ref().clone();
counts
.entry(key.clone())
.and_modify(|e| e.set(count))
.or_insert_with(|| create_rc_signal(count));
self.recipe_counts.set(counts);
self.recipe_counts.get_untracked().get(key).unwrap().clone()
}
pub fn get_current_modified_amts(&self) -> BTreeMap<IngredientKey, String> {
let mut modified_amts = BTreeMap::new();
for (key, amt) in self.modified_amts.get_untracked().iter() {
modified_amts.insert(key.clone(), amt.get_untracked().as_ref().clone());
}
modified_amts
}
pub fn reset_modified_amts(&self, modified_amts: BTreeMap<IngredientKey, String>) {
let mut modified_amts_copy = self.modified_amts.get().as_ref().clone();
for (key, amt) in modified_amts {
modified_amts_copy
.entry(key)
.and_modify(|amt_signal| amt_signal.set(amt.clone()))
.or_insert_with(|| create_rc_signal(amt));
}
self.modified_amts.set(modified_amts_copy);
}
}
pub enum Message {
ResetRecipeCounts,
UpdateRecipeCount(String, usize),
AddExtra(String, String),
RemoveExtra(usize),
UpdateExtra(usize, String, String),
SaveRecipe(RecipeEntry),
SetRecipe(String, Recipe),
SetCategoryMap(String),
ResetInventory,
AddFilteredIngredient(IngredientKey),
UpdateAmt(IngredientKey, String),
SetUserData(UserData),
SaveState(Option<Box<dyn FnOnce()>>),
LoadState(Option<Box<dyn FnOnce()>>),
}
impl Debug for Message {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ResetRecipeCounts => write!(f, "ResetRecipeCounts"),
Self::UpdateRecipeCount(arg0, arg1) => f
.debug_tuple("UpdateRecipeCount")
.field(arg0)
.field(arg1)
.finish(),
Self::AddExtra(arg0, arg1) => {
f.debug_tuple("AddExtra").field(arg0).field(arg1).finish()
}
Self::RemoveExtra(arg0) => f.debug_tuple("RemoveExtra").field(arg0).finish(),
Self::UpdateExtra(arg0, arg1, arg2) => f
.debug_tuple("UpdateExtra")
.field(arg0)
.field(arg1)
.field(arg2)
.finish(),
Self::SaveRecipe(arg0) => f.debug_tuple("SaveRecipe").field(arg0).finish(),
Self::SetRecipe(arg0, arg1) => {
f.debug_tuple("SetRecipe").field(arg0).field(arg1).finish()
}
Self::SetCategoryMap(arg0) => f.debug_tuple("SetCategoryMap").field(arg0).finish(),
Self::ResetInventory => write!(f, "ResetInventory"),
Self::AddFilteredIngredient(arg0) => {
f.debug_tuple("AddFilteredIngredient").field(arg0).finish()
}
Self::UpdateAmt(arg0, arg1) => {
f.debug_tuple("UpdateAmt").field(arg0).field(arg1).finish()
}
Self::SetUserData(arg0) => f.debug_tuple("SetUserData").field(arg0).finish(),
Self::SaveState(_) => write!(f, "SaveState"),
Self::LoadState(_) => write!(f, "LoadState"),
}
}
}
pub struct StateMachine {
store: HttpStore,
local_store: LocalStore,
}
#[instrument]
fn filter_recipes(
recipe_entries: &Option<Vec<RecipeEntry>>,
) -> Result<(Option<Recipe>, Option<BTreeMap<String, Recipe>>), String> {
match recipe_entries {
Some(parsed) => {
let mut staples = None;
let mut parsed_map = BTreeMap::new();
for r in parsed {
let recipe = match parse::as_recipe(&r.recipe_text()) {
Ok(r) => r,
Err(e) => {
error!("Error parsing recipe {}", e);
continue;
}
};
if recipe.title == "Staples" {
staples = Some(recipe);
} else {
parsed_map.insert(r.recipe_id().to_owned(), recipe);
}
}
Ok((staples, Some(parsed_map)))
}
None => Ok((None, None)),
}
}
impl StateMachine {
pub fn new(store: HttpStore, local_store: LocalStore) -> Self {
Self { store, local_store }
}
async fn load_state(
store: &HttpStore,
local_store: &LocalStore,
original: &Signal<AppState>,
) -> Result<(), crate::api::Error> {
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 tracing::{error, info};
use crate::app_state::{Message, StateHandler};
use recipes::RecipeEntry;
const STARTER_RECIPE: &'static str = "title: TITLE_PLACEHOLDER
@ -28,7 +29,7 @@ Instructions here
";
#[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 create_recipe_signal = create_signal(cx, ());
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,
label(for="recipe_title") { "Recipe Title" }
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 |_| {
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" }
}
}

View File

@ -11,14 +11,16 @@
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::{
app_state::{Message, StateHandler},
js_lib::get_element_by_id,
};
use sycamore::{futures::spawn_local_scoped, prelude::*};
use tracing::{debug, error, instrument};
use web_sys::HtmlDialogElement;
use recipes::parse;
use crate::js_lib::get_element_by_id;
fn get_error_dialog() -> HtmlDialogElement {
get_element_by_id::<HtmlDialogElement>("error-dialog")
.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]
pub fn Categories<G: Html>(cx: Scope) -> View<G> {
let save_signal = create_signal(cx, ());
pub fn Categories<'ctx, G: Html>(cx: Scope<'ctx>, sh: StateHandler<'ctx>) -> View<G> {
let error_text = create_signal(cx, String::new());
let category_text: &Signal<String> = create_signal(cx, String::new());
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,
dialog(id="error-dialog") {
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" } " "
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) {
debug!("triggering category save");
save_signal.trigger_subscribers();
sh.dispatch(
cx,
Message::SetCategoryMap(category_text.get_untracked().as_ref().clone()),
);
}
}) { "Save" }
}

View File

@ -14,17 +14,13 @@
use sycamore::prelude::*;
use crate::app_state;
use crate::app_state::StateHandler;
#[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),
None => "Login".to_owned(),
}
pub fn Header<'ctx, G: Html>(cx: Scope<'ctx>, h: StateHandler<'ctx>) -> 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

@ -14,7 +14,7 @@
use sycamore::{futures::spawn_local_scoped, prelude::*};
use tracing::{debug, error};
use crate::app_state;
use crate::app_state::{Message, StateHandler};
use recipes::{self, RecipeEntry};
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]
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 recipe: &Signal<RecipeEntry> =
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 save_signal = create_signal(cx, ());
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");
view! {cx,
div(class="grid") {
@ -115,7 +85,36 @@ pub fn Editor<G: Html>(cx: Scope, recipe_id: String) -> View<G> {
let unparsed = text.get();
if check_recipe_parses(unparsed.as_str(), error_text, aria_hint) {
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 {
}
}) { "Save" }
@ -154,13 +153,20 @@ fn Steps<G: Html>(cx: Scope, steps: Vec<recipes::Step>) -> View<G> {
}
#[component]
pub fn Viewer<G: Html>(cx: Scope, recipe_id: String) -> View<G> {
let state = app_state::State::get_from_context(cx);
pub fn Viewer<'ctx, G: Html>(cx: Scope<'ctx>, props: RecipeComponentProps<'ctx>) -> View<G> {
let RecipeComponentProps { recipe_id, sh } = props;
let view = create_signal(cx, View::empty());
if let Some(recipe) = state.recipes.get_untracked().get(&recipe_id) {
let title = recipe.title.clone();
let desc = recipe.desc.clone().unwrap_or_else(|| String::new());
let steps = recipe.steps.clone();
let recipe_signal = sh.get_selector(cx, move |state| {
if let Some(recipe) = state.get().recipes.get(&recipe_id) {
let title = recipe.title.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.");
view.set(view! {cx,
div(class="recipe") {

View File

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

View File

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

View File

@ -16,43 +16,37 @@ use std::rc::Rc;
use sycamore::prelude::*;
use tracing::{debug, instrument};
use crate::app_state;
use crate::app_state::{Message, StateHandler};
#[derive(Props)]
pub struct RecipeCheckBoxProps<'ctx> {
pub i: String,
pub title: &'ctx ReadSignal<String>,
pub sh: StateHandler<'ctx>,
}
#[instrument(skip(props, cx), fields(
idx=%props.i,
id=%props.i,
title=%props.title.get()
))]
#[component]
pub fn RecipeSelection<G: Html>(cx: Scope, props: RecipeCheckBoxProps) -> View<G> {
let state = app_state::State::get_from_context(cx);
// This is total hack but it works around the borrow issues with
// the `view!` macro.
let id = Rc::new(props.i);
pub fn RecipeSelection<'ctx, G: Html>(
cx: Scope<'ctx>,
props: RecipeCheckBoxProps<'ctx>,
) -> View<G> {
let RecipeCheckBoxProps { i, title, sh } = props;
let id = Rc::new(i);
let id_clone = id.clone();
let count = create_signal(
cx,
format!(
"{}",
state
.get_recipe_count_by_index(id.as_ref())
.unwrap_or_else(|| state.set_recipe_count_by_index(id.as_ref(), 0))
sh.get_value(
|state| match state.get_untracked().recipe_counts.get(id_clone.as_ref()) {
Some(count) => format!("{}", count),
None => "0".to_owned(),
},
),
);
create_effect(cx, {
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 title = title.get().clone();
let for_id = id.clone();
let href = format!("/ui/recipe/view/{}", id);
let name = format!("recipe_id:{}", id);
@ -60,9 +54,8 @@ pub fn RecipeSelection<G: Html>(cx: Scope, props: RecipeCheckBoxProps) -> View<G
div() {
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 |_| {
let state = app_state::State::get_from_context(cx);
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.
// See the License for the specific language governing permissions and
// limitations under the License.
use std::collections::{BTreeMap, BTreeSet};
use std::collections::BTreeSet;
use recipes::{Ingredient, IngredientKey};
use sycamore::{futures::spawn_local_scoped, prelude::*};
use recipes::{IngredientAccumulator, IngredientKey};
use sycamore::prelude::*;
use tracing::{debug, info, instrument};
use crate::app_state::{Message, StateHandler};
#[instrument(skip_all)]
fn make_ingredients_rows<'ctx, G: Html>(
cx: Scope<'ctx>,
ingredients: &'ctx ReadSignal<Vec<(IngredientKey, (Ingredient, BTreeSet<String>))>>,
modified_amts: RcSignal<BTreeMap<IngredientKey, RcSignal<String>>>,
filtered_keys: RcSignal<BTreeSet<IngredientKey>>,
sh: StateHandler<'ctx>,
show_staples: &'ctx ReadSignal<bool>,
) -> 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!(
cx,
Indexed(
iterable = ingredients,
view = move |cx, (k, (i, rs))| {
let mut modified_amt_set = modified_amts.get().as_ref().clone();
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 == "" {
view = move |cx, (k, (name, form, category, amt, rs))| {
let category = if category == "" {
"other".to_owned()
} 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
.iter()
.fold(String::new(), |acc, s| format!("{}{},", acc, s))
@ -49,15 +101,14 @@ fn make_ingredients_rows<'ctx, G: Html>(
view! {cx,
tr {
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 {
input(type="button", class="no-print destructive", value="X", on:click={
let filtered_keys = filtered_keys.clone();
move |_| {
let mut keyset = filtered_keys.get().as_ref().clone();
keyset.insert(k.clone());
filtered_keys.set(keyset);
sh.dispatch(cx, Message::AddFilteredIngredient(k.clone()));
}})
}
td { (name) " " (form) "" br {} "" (category) "" }
@ -69,55 +120,53 @@ fn make_ingredients_rows<'ctx, G: Html>(
)
}
fn make_extras_rows<'ctx, G: Html>(
cx: Scope<'ctx>,
extras: RcSignal<Vec<(usize, (RcSignal<String>, RcSignal<String>))>>,
) -> View<G> {
let extras_read_signal = create_memo(cx, {
let extras = extras.clone();
move || extras.get().as_ref().clone()
#[instrument(skip_all)]
fn make_extras_rows<'ctx, G: Html>(cx: Scope<'ctx>, sh: StateHandler<'ctx>) -> View<G> {
debug!("Making extras rows");
let extras_read_signal = sh.get_selector(cx, |state| {
state.get().extras.iter().cloned().enumerate().collect()
});
view! {cx,
Indexed(
iterable=extras_read_signal,
view= move |cx, (idx, (amt, name))| {
view! {cx,
tr {
td {
input(bind:value=amt, type="text")
}
td {
input(type="button", class="no-print destructive", value="X", on:click={
let extras = extras.clone();
move |_| {
extras.set(extras.get().iter()
.filter(|(i, _)| *i != idx)
.map(|(_, v)| v.clone())
.enumerate()
.collect())
}})
}
td {
input(bind:value=name, type="text")
}
td { "Misc" }
}
Indexed(
iterable=extras_read_signal,
view= move |cx, (idx, (amt, name))| {
let amt_signal = create_signal(cx, amt.clone());
let name_signal = create_signal(cx, name.clone());
view! {cx,
tr {
td {
input(bind:value=amt_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 {
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>(
cx: Scope<'ctx>,
ingredients: &'ctx ReadSignal<Vec<(IngredientKey, (Ingredient, BTreeSet<String>))>>,
modified_amts: RcSignal<BTreeMap<IngredientKey, RcSignal<String>>>,
extras: RcSignal<Vec<(usize, (RcSignal<String>, RcSignal<String>))>>,
filtered_keys: RcSignal<BTreeSet<IngredientKey>>,
sh: StateHandler<'ctx>,
show_staples: &'ctx ReadSignal<bool>,
) -> View<G> {
let extra_rows_view = make_extras_rows(cx, extras);
let ingredient_rows =
make_ingredients_rows(cx, ingredients, modified_amts, filtered_keys.clone());
debug!("Making shopping table");
view! {cx,
table(class="pad-top shopping-list page-breaker container-fluid", role="grid") {
tr {
@ -127,103 +176,33 @@ fn make_shopping_table<'ctx, G: Html>(
th { " Recipes " }
}
tbody {
(ingredient_rows)
(extra_rows_view)
(make_ingredients_rows(cx, sh, show_staples))
(make_extras_rows(cx, sh))
}
}
}
}
#[instrument]
#[instrument(skip_all)]
#[component]
pub fn ShoppingList<G: Html>(cx: Scope) -> 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());
pub fn ShoppingList<'ctx, G: Html>(cx: Scope<'ctx>, sh: StateHandler<'ctx>) -> View<G> {
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,
h1 { "Shopping List " }
label(for="show_staples_cb") { "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 |_| {
let mut cloned_extras: Vec<(RcSignal<String>, RcSignal<String>)> = (*state.extras.get()).iter().map(|(_, tpl)| tpl.clone()).collect();
cloned_extras.push((create_rc_signal("".to_owned()), create_rc_signal("".to_owned())));
state.extras.set(cloned_extras.drain(0..).enumerate().collect());
info!("Registering add item request for inventory");
sh.dispatch(cx, Message::AddExtra(String::new(), String::new()));
})
input(type="button", value="Reset", class="no-print", on:click={
let state = crate::app_state::State::get_from_context(cx);
move |_| {
// 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="Reset", class="no-print", on:click=move |_| {
info!("Registering reset request for inventory");
sh.dispatch(cx, Message::ResetInventory);
})
input(type="button", value="Save", class="no-print", on:click=|_| {
save_click.trigger_subscribers();
input(type="button", value="Save", class="no-print", on:click=move |_| {
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,
li(class=class) { a(href=href) { (show) } }
}
// TODO
})
.collect(),
);

View File

@ -43,12 +43,3 @@ pub fn get_storage() -> Storage {
.expect("Failed to get storage")
.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.
// See the License for the specific language governing permissions and
// 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 crate::app_state;
use crate::app_state::{Message, StateHandler};
#[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 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,
form() {
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="button", value="Login", on:click=move |_| {
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");
clicked.trigger_subscribers();
}) { }
}
}
}
#[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,
LoginForm()
LoginForm(sh)
}
}

View File

@ -12,15 +12,15 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use super::ManagePage;
use crate::components::add_recipe::AddRecipe;
use crate::{app_state::StateHandler, components::add_recipe::AddRecipe};
use sycamore::prelude::*;
#[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,
ManagePage(
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
// limitations under the License.
use super::ManagePage;
use crate::components::categories::*;
use crate::{app_state::StateHandler, components::categories::*};
use sycamore::prelude::*;
#[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,
ManagePage(
selected=Some("Categories".to_owned()),
) { Categories() }
) { Categories(sh) }
}
}

View File

@ -12,17 +12,17 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use super::ManagePage;
use crate::components::recipe::Editor;
use crate::{app_state::StateHandler, components::recipe::Editor};
use sycamore::prelude::*;
use tracing::instrument;
#[instrument]
#[instrument(skip_all)]
#[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,
ManagePage(
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 super::PlanningPage;
use crate::components::recipe_list::*;
use crate::{app_state::StateHandler, components::recipe_list::*};
#[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,
PlanningPage(
selected=Some("Cook".to_owned()),
) { RecipeList() }
) { RecipeList(sh) }
}
}

View File

@ -14,13 +14,13 @@
use sycamore::prelude::*;
use super::PlanningPage;
use crate::components::shopping_list::*;
use crate::{app_state::StateHandler, components::shopping_list::*};
#[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,
PlanningPage(
selected=Some("Inventory".to_owned()),
) { ShoppingList() }
) { ShoppingList(sh) }
}
}

View File

@ -12,15 +12,15 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use super::PlanningPage;
use crate::components::recipe_plan::*;
use crate::{app_state::StateHandler, components::recipe_plan::*};
use sycamore::prelude::*;
#[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,
PlanningPage(
selected=Some("Plan".to_owned()),
) { RecipePlan() }
) { RecipePlan(sh) }
}
}

View File

@ -17,13 +17,14 @@ use crate::components::recipe::Editor;
use sycamore::prelude::*;
use tracing::instrument;
#[instrument]
#[instrument(skip_all, fields(recipe=props.recipe))]
#[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,
RecipePage(
selected=Some("Edit".to_owned()),
recipe=props.recipe.clone(),
) { Editor(props.recipe) }
recipe=recipe.clone(),
) { Editor(recipe_id=recipe, sh=sh) }
}
}

View File

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

View File

@ -18,13 +18,14 @@ use tracing::instrument;
use super::{RecipePage, RecipePageProps};
#[instrument]
#[instrument(skip_all, fields(recipe=props.recipe))]
#[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,
RecipePage(
selected=Some("View".to_owned()),
recipe=props.recipe.clone(),
) { Viewer(props.recipe) }
recipe=recipe.clone(),
) { Viewer(recipe_id=recipe, sh=sh) }
}
}

View File

@ -12,63 +12,15 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::{
app_state::StateHandler,
components::{Footer, Header},
pages::*,
};
use sycamore::prelude::*;
use sycamore_router::{HistoryIntegration, Route, Router};
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)]
pub enum Routes {
#[to("/ui/planning/<_..>")]
@ -117,12 +69,69 @@ pub enum PlanningRoutes {
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]
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,
Router(
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
// 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::app_state::Message;
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());
let store = api::HttpStore::get_from_context(cx).as_ref().clone();
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());
// 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);
};
// TODO(jwall): This needs to be moved into the RouteHandler
sh.dispatch(cx, Message::LoadState(None));
view.set(view! { cx,
div(class="app") {
Header { }
RouteHandler()
Footer { }
}
RouteHandler(sh=sh)
});
}
});