diff --git a/Cargo.lock b/Cargo.lock index d40288a..f0c6ec5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -748,10 +748,25 @@ dependencies = [ ] [[package]] -name = "futures-channel" -version = "0.3.21" +name = "futures" +version = "0.3.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3083ce4b914124575708913bca19bfe887522d6e2e6d0952943f5eac4a74010" +checksum = "7f21eda599937fba36daeb58a22e8f5cee2d14c4a17b5b7739c7c8e5e3b8230c" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30bdd20c28fadd505d0fd6712cdfcb0d4b5648baf45faef7f852afb2399bb050" dependencies = [ "futures-core", "futures-sink", @@ -759,15 +774,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.21" +version = "0.3.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c09fd04b7e4073ac7156a9539b57a484a8ea920f79c7c675d05d289ab6110d3" +checksum = "4e5aa3de05362c3fb88de6531e6296e85cde7739cccad4b9dfeeb7f6ebce56bf" [[package]] name = "futures-executor" -version = "0.3.21" +version = "0.3.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9420b90cfa29e327d0429f19be13e7ddb68fa1cccb09d65e5706b8c7a749b8a6" +checksum = "9ff63c23854bee61b6e9cd331d523909f238fc7636290b96826e9cfa5faa00ab" dependencies = [ "futures-core", "futures-task", @@ -787,9 +802,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.21" +version = "0.3.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc4045962a5a5e935ee2fdedaa4e08284547402885ab326734432bed5d12966b" +checksum = "bbf4d2a7a308fd4578637c0b17c7e1c7ba127b8f6ba00b29f717e9655d85eb68" [[package]] name = "futures-lite" @@ -806,6 +821,17 @@ dependencies = [ "waker-fn", ] +[[package]] +name = "futures-macro" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42cd15d1c7456c04dbdf7e88bcd69760d74f3a798d6444e16974b505b0e62f17" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-rustls" version = "0.22.2" @@ -819,25 +845,29 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.21" +version = "0.3.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21163e139fa306126e6eedaf49ecdb4588f939600f0b1e770f4205ee4b7fa868" +checksum = "21b20ba5a92e727ba30e72834706623d94ac93a725410b6a6b6fbc1b07f7ba56" [[package]] name = "futures-task" -version = "0.3.21" +version = "0.3.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c66a976bf5909d801bbef33416c41372779507e7a6b3a5e25e4749c58f776a" +checksum = "a6508c467c73851293f390476d4491cf4d227dbabcd4170f3bb6044959b294f1" [[package]] name = "futures-util" -version = "0.3.21" +version = "0.3.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b7abd5d659d9b90c8cba917f6ec750a74e2dc23902ef9cd4cc8c8b22e6036a" +checksum = "44fb6cb1be61cc1d2e43b262516aafcf63b241cffdb1d3fa115f91d9c7b09c90" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-macro", "futures-sink", "futures-task", + "memchr", "pin-project-lite", "pin-utils", "slab", @@ -1009,6 +1039,15 @@ dependencies = [ "digest 0.9.0", ] +[[package]] +name = "html-escape" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e7479fa1ef38eb49fb6a42c426be515df2d063f06cb8efd3e50af073dbc26c" +dependencies = [ + "utf8-width", +] + [[package]] name = "http" version = "0.2.8" @@ -1194,79 +1233,6 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" -[[package]] -name = "lexical" -version = "6.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7aefb36fd43fef7003334742cbf77b243fcd36418a1d1bdd480d613a67968f6" -dependencies = [ - "lexical-core", -] - -[[package]] -name = "lexical-core" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cde5de06e8d4c2faabc400238f9ae1c74d5412d03a7bd067645ccbc47070e46" -dependencies = [ - "lexical-parse-float", - "lexical-parse-integer", - "lexical-util", - "lexical-write-float", - "lexical-write-integer", -] - -[[package]] -name = "lexical-parse-float" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683b3a5ebd0130b8fb52ba0bdc718cc56815b6a097e28ae5a6997d0ad17dc05f" -dependencies = [ - "lexical-parse-integer", - "lexical-util", - "static_assertions", -] - -[[package]] -name = "lexical-parse-integer" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d0994485ed0c312f6d965766754ea177d07f9c00c9b82a5ee62ed5b47945ee9" -dependencies = [ - "lexical-util", - "static_assertions", -] - -[[package]] -name = "lexical-util" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5255b9ff16ff898710eb9eb63cb39248ea8a5bb036bea8085b1a767ff6c4e3fc" -dependencies = [ - "static_assertions", -] - -[[package]] -name = "lexical-write-float" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accabaa1c4581f05a3923d1b4cfd124c329352288b7b9da09e766b0668116862" -dependencies = [ - "lexical-util", - "lexical-write-integer", - "static_assertions", -] - -[[package]] -name = "lexical-write-integer" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1b6f3d1f4422866b68192d62f77bc5c700bee84f3069f2469d7bc8c77852446" -dependencies = [ - "lexical-util", - "static_assertions", -] - [[package]] name = "libc" version = "0.2.126" @@ -1886,6 +1852,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "slotmap" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e08e261d0e8f5c43123b7adf3e4ca1690d655377ac93a03b2c9d3e98de1342" +dependencies = [ + "version_check", +] + [[package]] name = "smallvec" version = "1.9.0" @@ -2008,12 +1983,6 @@ dependencies = [ "futures-rustls", ] -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - [[package]] name = "stringprep" version = "0.1.2" @@ -2038,28 +2007,48 @@ checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" [[package]] name = "sycamore" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5cea65876897bb946a623e16bf3df2de4997a6872d95b99dfaed5dd8e14e264" +version = "0.8.2" +source = "git+https://github.com/sycamore-rs/sycamore/?rev=20b6069c470a51d2ba6197bb322036e8324ff297#20b6069c470a51d2ba6197bb322036e8324ff297" dependencies = [ "ahash", + "futures", "indexmap", "js-sys", - "lexical", "paste", - "smallvec", + "sycamore-core", + "sycamore-futures", "sycamore-macro", "sycamore-reactive", + "sycamore-web", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", ] +[[package]] +name = "sycamore-core" +version = "0.8.2" +source = "git+https://github.com/sycamore-rs/sycamore/?rev=20b6069c470a51d2ba6197bb322036e8324ff297#20b6069c470a51d2ba6197bb322036e8324ff297" +dependencies = [ + "ahash", + "sycamore-reactive", +] + +[[package]] +name = "sycamore-futures" +version = "0.8.0" +source = "git+https://github.com/sycamore-rs/sycamore/?rev=20b6069c470a51d2ba6197bb322036e8324ff297#20b6069c470a51d2ba6197bb322036e8324ff297" +dependencies = [ + "futures", + "sycamore-reactive", + "tokio", + "wasm-bindgen-futures", +] + [[package]] name = "sycamore-macro" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d6911dba86d928ed3c898ee182c1a8a9f00299aa78875bc9308e7fd389e5bb4" +version = "0.8.2" +source = "git+https://github.com/sycamore-rs/sycamore/?rev=20b6069c470a51d2ba6197bb322036e8324ff297#20b6069c470a51d2ba6197bb322036e8324ff297" dependencies = [ "once_cell", "proc-macro2", @@ -2069,14 +2058,28 @@ dependencies = [ [[package]] name = "sycamore-reactive" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20809429d0f9c2ffcbb3f192957a5d0c505519138e41c5d38808c5b42b3c53ab" +version = "0.8.1" +source = "git+https://github.com/sycamore-rs/sycamore/?rev=20b6069c470a51d2ba6197bb322036e8324ff297#20b6069c470a51d2ba6197bb322036e8324ff297" dependencies = [ "ahash", + "bumpalo", "indexmap", "serde", + "slotmap", "smallvec", +] + +[[package]] +name = "sycamore-web" +version = "0.8.2" +source = "git+https://github.com/sycamore-rs/sycamore/?rev=20b6069c470a51d2ba6197bb322036e8324ff297#20b6069c470a51d2ba6197bb322036e8324ff297" +dependencies = [ + "html-escape", + "indexmap", + "js-sys", + "once_cell", + "sycamore-core", + "sycamore-reactive", "wasm-bindgen", "web-sys", ] @@ -2409,6 +2412,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "utf8-width" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5190c9442dcdaf0ddd50f37420417d219ae5261bbf5db120d0f9bab996c9cba1" + [[package]] name = "uuid" version = "1.1.2" diff --git a/Cargo.toml b/Cargo.toml index 1d2cebb..b97268d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,2 +1,6 @@ [workspace] -members = [ "recipes", "kitchen", "web", "recipe-store"] \ No newline at end of file +members = [ "recipes", "kitchen", "web", "recipe-store"] + +[patch.crates-io] +# TODO(jwall): When the fix for RcSignal Binding is released we can drop this patch. +sycamore = {git = "https://github.com/sycamore-rs/sycamore/", rev = "20b6069c470a51d2ba6197bb322036e8324ff297" } \ No newline at end of file diff --git a/recipes/src/parse.rs b/recipes/src/parse.rs index 5a9f833..b6de8cf 100644 --- a/recipes/src/parse.rs +++ b/recipes/src/parse.rs @@ -181,10 +181,10 @@ make_fn!( ( Duration::from_secs( match u { - "ms" => (cnt / 1000), + "ms" => cnt / 1000, "s" | "sec" => cnt.into(), - "m" | "min" => (dbg!(cnt) * 60), - "h" | "hr" | "hrs" => (cnt * 60 * 60), + "m" | "min" => dbg!(cnt) * 60, + "h" | "hr" | "hrs" => cnt * 60 * 60, _ => unreachable!(), }.into() ) diff --git a/web/Cargo.toml b/web/Cargo.toml index 565db65..7d04dd6 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -48,9 +48,9 @@ features = [ ] [dependencies.sycamore] -version = "0.7.1" -features = ["futures", "serde", "default"] +version = "0.8.2" +features = ["suspense", "serde", "default", ] [profile.release] lto = true -opt-level = "s" \ No newline at end of file +opt-level = "s" diff --git a/web/src/components/categories.rs b/web/src/components/categories.rs index 23587c7..b78d889 100644 --- a/web/src/components/categories.rs +++ b/web/src/components/categories.rs @@ -11,14 +11,14 @@ // 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 serde_json::{from_str, to_string}; -use sycamore::{futures::spawn_local_in_scope, prelude::*}; +use serde_json::from_str; +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, service::get_appservice_from_context}; +use crate::{js_lib::get_element_by_id, service::AppService}; fn get_error_dialog() -> HtmlDialogElement { get_element_by_id::("error-dialog") @@ -26,7 +26,7 @@ fn get_error_dialog() -> HtmlDialogElement { .unwrap() } -fn check_category_text_parses(unparsed: &str, error_text: Signal) -> bool { +fn check_category_text_parses(unparsed: &str, error_text: &Signal) -> bool { let el = get_error_dialog(); if let Err(e) = parse::as_categories(unparsed) { error!(?e, "Error parsing categories"); @@ -40,12 +40,13 @@ fn check_category_text_parses(unparsed: &str, error_text: Signal) -> boo } #[instrument] -#[component(Categories)] -pub fn categories() -> View { - let app_service = get_appservice_from_context(); - let save_signal = Signal::new(()); - let error_text = Signal::new(String::new()); - let category_text = Signal::new( +#[component] +pub fn Categories(cx: Scope) -> View { + let app_service = use_context::(cx); + let save_signal = create_signal(cx, ()); + let error_text = create_signal(cx, String::new()); + let category_text = create_signal( + cx, match app_service .get_category_text() .expect("Failed to get categories.") @@ -54,26 +55,27 @@ pub fn categories() -> View { .map_err(|e| format!("{}", e)) .expect("Failed to parse categories as json"), None => String::new(), - }, //.unwrap_or_else(|| String::new()), + }, ); - create_effect( - cloned!((app_service, category_text, save_signal, error_text) => move || { - // TODO(jwall): This is triggering on load which is not desired. - save_signal.get(); - spawn_local_in_scope({ - cloned!((app_service, category_text, error_text) => async move { - // TODO(jwall): Save the categories. - if let Err(e) = app_service.save_categories(category_text.get_untracked().as_ref().clone()).await { - error!(?e, "Failed to save categories"); - error_text.set(format!("{:?}", e)); - } - }) - }); - }), - ); + create_effect(cx, move || { + // TODO(jwall): This is triggering on load which is not desired. + save_signal.track(); + spawn_local_scoped(cx, { + async move { + // TODO(jwall): Save the categories. + if let Err(e) = app_service + .save_categories(category_text.get_untracked().as_ref().clone()) + .await + { + error!(?e, "Failed to save categories"); + error_text.set(format!("{:?}", e)); + } + } + }); + }); - let dialog_view = cloned!((error_text) => view! { + let dialog_view = view! {cx, dialog(id="error-dialog") { article{ header { @@ -88,20 +90,20 @@ pub fn categories() -> View { } } } - }); + }; - cloned!((category_text, error_text) => view! { + view! {cx, (dialog_view) - textarea(bind:value=category_text.clone(), rows=20) - a(role="button", href="#", on:click=cloned!((category_text, error_text) => move |_| { - check_category_text_parses(category_text.get().as_str(), error_text.clone()); - })) { "Check" } " " - a(role="button", href="#", on:click=cloned!((category_text, error_text) => move |_| { + textarea(bind:value=category_text, rows=20) + a(role="button", href="#", on:click=move |_| { + check_category_text_parses(category_text.get().as_str(), error_text); + }) { "Check" } " " + a(role="button", href="#", on:click=move |_| { // TODO(jwall): check and then save the categories. - if check_category_text_parses(category_text.get().as_str(), error_text.clone()) { + if check_category_text_parses(category_text.get().as_str(), error_text) { debug!("triggering category save"); save_signal.trigger_subscribers(); } - })) { "Save" } - }) + }) { "Save" } + } } diff --git a/web/src/components/header.rs b/web/src/components/header.rs index 9e9c6c7..0297160 100644 --- a/web/src/components/header.rs +++ b/web/src/components/header.rs @@ -14,10 +14,10 @@ use sycamore::prelude::*; -#[component(Header)] -pub fn header() -> View { - view! { - div(class="menu") { +#[component] +pub fn Header(cx: Scope) -> View { + view! {cx, + div(class="menu no-print") { h1 { "Meal Plan" } } } diff --git a/web/src/components/recipe.rs b/web/src/components/recipe.rs index a112cbc..b7866cd 100644 --- a/web/src/components/recipe.rs +++ b/web/src/components/recipe.rs @@ -11,11 +11,11 @@ // 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_in_scope, prelude::*}; +use sycamore::{futures::spawn_local_scoped, prelude::*}; use tracing::{debug, error}; use web_sys::HtmlDialogElement; -use crate::{js_lib::get_element_by_id, service::get_appservice_from_context}; +use crate::{js_lib::get_element_by_id, service::AppService}; use recipe_store::RecipeEntry; use recipes; @@ -25,7 +25,7 @@ fn get_error_dialog() -> HtmlDialogElement { .unwrap() } -fn check_recipe_parses(text: &str, error_text: Signal) -> bool { +fn check_recipe_parses(text: &str, error_text: &Signal) -> bool { if let Err(e) = recipes::parse::as_recipe(text) { error!(?e, "Error parsing recipe"); error_text.set(e); @@ -40,32 +40,34 @@ fn check_recipe_parses(text: &str, error_text: Signal) -> bool { } } -#[component(Editor)] -fn editor(recipe: RecipeEntry) -> View { - let id = Signal::new(recipe.recipe_id().to_owned()); - let text = Signal::new(recipe.recipe_text().to_owned()); - let error_text = Signal::new(String::new()); - let app_service = get_appservice_from_context(); - let save_signal = Signal::new(()); +#[component] +fn Editor(cx: Scope, recipe: &RecipeEntry) -> View { + let id = create_signal(cx, recipe.recipe_id().to_owned()); + let text = create_signal(cx, recipe.recipe_text().to_owned()); + let error_text = create_signal(cx, String::new()); + let app_service = use_context::(cx); + let save_signal = create_signal(cx, ()); - create_effect( - cloned!((id, app_service, text, save_signal, error_text) => move || { - // TODO(jwall): This is triggering on load which is not desired. - save_signal.get(); - spawn_local_in_scope({ - cloned!((id, app_service, text, error_text) => async move { - if let Err(e) = app_service - .save_recipes(vec![RecipeEntry(id.get_untracked().as_ref().clone(), text.get_untracked().as_ref().clone())]) - .await { - error!(?e, "Failed to save recipe"); - error_text.set(format!("{:?}", e)); - }; - }) - }); - }), - ); + create_effect(cx, move || { + // TODO(jwall): This is triggering on load which is not desired. + save_signal.track(); + spawn_local_scoped(cx, { + async move { + if let Err(e) = app_service + .save_recipes(vec![RecipeEntry( + id.get_untracked().as_ref().clone(), + text.get_untracked().as_ref().clone(), + )]) + .await + { + error!(?e, "Failed to save recipe"); + error_text.set(format!("{:?}", e)); + }; + } + }); + }); - let dialog_view = cloned!((error_text) => view! { + let dialog_view = view! {cx, dialog(id="error-dialog") { article{ header { @@ -80,73 +82,84 @@ fn editor(recipe: RecipeEntry) -> View { } } } - }); + }; - cloned!((text, error_text) => view! { + view! {cx, (dialog_view) - textarea(bind:value=text.clone(), rows=20) - a(role="button" , href="#", on:click=cloned!((text, error_text) => move |_| { + textarea(bind:value=text, rows=20) + a(role="button" , href="#", on:click=move |_| { let unparsed = text.get(); check_recipe_parses(unparsed.as_str(), error_text.clone()); - })) { "Check" } " " - a(role="button", href="#", on:click=cloned!((text, error_text) => move |_| { + }) { "Check" } " " + a(role="button", href="#", on:click=move |_| { let unparsed = text.get(); if check_recipe_parses(unparsed.as_str(), error_text.clone()) { debug!("triggering a save"); save_signal.trigger_subscribers(); }; - })) { "Save" } - }) + }) { "Save" } + } } -#[component(Steps)] -fn steps(steps: ReadSignal>) -> View { - view! { +#[component] +fn Steps<'ctx, G: Html>(cx: Scope<'ctx>, steps: &'ctx ReadSignal>) -> View { + view! {cx, h2 { "Steps: " } div(class="recipe_steps") { - Indexed(IndexedProps{ - iterable: steps, - template: |step: recipes::Step| { view! { + Indexed( + iterable=steps, + view = |cx, step: recipes::Step| { view! {cx, div { h3 { "Instructions" } ul(class="ingredients") { - Indexed(IndexedProps{ - iterable: Signal::new(step.ingredients).handle(), - template: |i| { view! { + Indexed( + iterable = create_signal(cx, step.ingredients), + view = |cx, i| { view! {cx, li { (i.amt) " " (i.name) " " (i.form.as_ref().map(|f| format!("({})", f)).unwrap_or(String::new())) } }} - }) + ) } div(class="instructions") { (step.instructions) } }} } - }) + ) } } } -#[component(Recipe)] -pub fn recipe(idx: ReadSignal) -> View { - let app_service = get_appservice_from_context(); - let view = Signal::new(View::empty()); - let show_edit = Signal::new(false); - create_effect(cloned!((idx, app_service, view, show_edit) => move || { - if *show_edit.get() { - return; - } - let recipe_id: String = idx.get().as_ref().to_owned(); - if let Some(recipe) = app_service.get_recipes().get().get(&recipe_id) { - let recipe = recipe.clone(); - let title = create_memo(cloned!((recipe) => move || recipe.get().title.clone())); - let desc = create_memo( - cloned!((recipe) => move || recipe.clone().get().desc.clone().unwrap_or_else(|| String::new())), - ); - let steps = create_memo(cloned!((recipe) => move || recipe.get().steps.clone())); - view.set(view! { +#[component] +pub fn Recipe<'ctx, G: Html>(cx: Scope<'ctx>, recipe_id: String) -> View { + let app_service = use_context::(cx).clone(); + let view = create_signal(cx, View::empty()); + let show_edit = create_signal(cx, false); + // FIXME(jwall): This has too many unwrap() calls + if let Some(recipe) = app_service + .fetch_recipes_from_storage() + .unwrap() + .1 + .unwrap() + .get(&recipe_id) + { + let recipe = create_signal(cx, recipe.clone()); + let title = create_memo(cx, move || recipe.get().title.clone()); + let desc = create_memo(cx, move || { + recipe + .clone() + .get() + .desc + .clone() + .unwrap_or_else(|| String::new()) + }); + let steps = create_memo(cx, move || recipe.get().steps.clone()); + create_effect(cx, move || { + if *show_edit.get() { + return; + } + view.set(view! {cx, div(class="recipe") { h1(class="recipe_title") { (title.get()) } div(class="recipe_description") { @@ -155,22 +168,25 @@ pub fn recipe(idx: ReadSignal) -> View { Steps(steps) } }); - } - })); - create_effect(cloned!((idx, app_service, view, show_edit) => move || { - let recipe_id: String = idx.get().as_ref().to_owned(); - if !(*show_edit.get()) { - return; - } - if let Some(entry) = app_service.fetch_recipe_text(recipe_id.as_str()).expect("No such recipe") { - view.set(view! { - Editor(entry) + }); + if let Some(entry) = app_service + .fetch_recipe_text(recipe_id.as_str()) + .expect("No such recipe") + { + let entry_ref = create_ref(cx, entry); + create_effect(cx, move || { + if !(*show_edit.get()) { + return; + } + view.set(view! {cx, + Editor(entry_ref) + }); }); } - })); - view! { - a(role="button", href="#", on:click=cloned!((show_edit) => move |_| { show_edit.set(true); })) { "Edit" } " " - a(role="button", href="#", on:click=cloned!((show_edit) => move |_| { show_edit.set(false); })) { "View" } + } + view! {cx, + a(role="button", href="#", on:click=move |_| { show_edit.set(true); }) { "Edit" } " " + a(role="button", href="#", on:click=move |_| { show_edit.set(false); }) { "View" } (view.get().as_ref()) } } diff --git a/web/src/components/recipe_list.rs b/web/src/components/recipe_list.rs index 35d1e32..c2d236b 100644 --- a/web/src/components/recipe_list.rs +++ b/web/src/components/recipe_list.rs @@ -11,32 +11,29 @@ // 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::components::Recipe; +use crate::{components::Recipe, service::AppService}; use sycamore::prelude::*; use tracing::{debug, instrument}; -use crate::service::get_appservice_from_context; - #[instrument] -#[component(RecipeList)] -pub fn recipe_list() -> View { - let app_service = get_appservice_from_context(); - let menu_list = create_memo(move || app_service.get_menu_list()); - view! { +#[component] +pub fn RecipeList(cx: Scope) -> View { + let app_service = use_context::(cx); + let menu_list = create_memo(cx, || app_service.get_menu_list()); + view! {cx, h1 { "Recipe List" } div() { - Indexed(IndexedProps{ - iterable: menu_list, - template: |(idx, _count)| { + Indexed( + iterable=menu_list, + view= |cx, (idx, _count)| { debug!(idx=%idx, "Rendering recipe"); - let idx = Signal::new(idx); - view ! { - Recipe(idx.handle()) + view ! {cx, + Recipe(idx) hr() } } - }) + ) } } } diff --git a/web/src/components/recipe_selection.rs b/web/src/components/recipe_selection.rs index 85da76e..ed48be7 100644 --- a/web/src/components/recipe_selection.rs +++ b/web/src/components/recipe_selection.rs @@ -18,37 +18,43 @@ use tracing::{debug, instrument}; use crate::service::get_appservice_from_context; -pub struct RecipeCheckBoxProps { +#[derive(Prop)] +pub struct RecipeCheckBoxProps<'ctx> { pub i: String, - pub title: ReadSignal, + pub title: &'ctx ReadSignal, } -#[instrument(skip(props), fields( +#[instrument(skip(props, cx), fields( idx=%props.i, title=%props.title.get() ))] -#[component(RecipeSelection)] -pub fn recipe_selection(props: RecipeCheckBoxProps) -> View { - let app_service = get_appservice_from_context(); +#[component] +pub fn RecipeSelection(cx: Scope, props: RecipeCheckBoxProps) -> View { + let mut app_service = get_appservice_from_context(cx).clone(); // This is total hack but it works around the borrow issues with // the `view!` macro. let id = Rc::new(props.i); - let count = Signal::new(format!( - "{}", - app_service.get_recipe_count_by_index(id.as_ref()) - )); + let count = create_signal( + cx, + format!( + "{}", + app_service + .get_recipe_count_by_index(id.as_ref()) + .unwrap_or_else(|| app_service.set_recipe_count_by_index(id.as_ref(), 0)) + ), + ); + let title = props.title.get().clone(); let for_id = id.clone(); let href = format!("/ui/recipe/{}", id); let name = format!("recipe_id:{}", id); - let value = id.clone(); - view! { + view! {cx, div() { - label(for=for_id) { a(href=href) { (props.title.get()) } } - input(type="number", class="item-count-sel", min="0", bind:value=count.clone(), name=name, value=value, on:change=cloned!((id) => move |_| { + 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 mut app_service = app_service.clone(); debug!(idx=%id, count=%(*count.get()), "setting recipe count"); - app_service.set_recipe_count_by_index(id.as_ref().to_owned(), count.get().parse().unwrap()); - })) + app_service.set_recipe_count_by_index(id.as_ref(), count.get().parse().unwrap()); + }) } } } diff --git a/web/src/components/recipe_selector.rs b/web/src/components/recipe_selector.rs index 4148901..be78c4d 100644 --- a/web/src/components/recipe_selector.rs +++ b/web/src/components/recipe_selector.rs @@ -12,48 +12,57 @@ // See the License for the specific language governing permissions and // limitations under the License. use recipes::Recipe; -use sycamore::{futures::spawn_local_in_scope, prelude::*}; +use sycamore::{futures::spawn_local_scoped, prelude::*}; use tracing::{error, instrument}; use crate::components::recipe_selection::*; -use crate::service::AppService; +use crate::service::*; #[instrument] -#[component(RecipeSelector)] -pub fn recipe_selector(app_service: AppService) -> View { - let rows = create_memo(cloned!(app_service => move || { +pub fn RecipeSelector(cx: Scope) -> View { + let app_service = get_appservice_from_context(cx).clone(); + let rows = create_memo(cx, move || { let mut rows = Vec::new(); - for row in app_service.get_recipes().get().iter().map(|(k, v)| (k.clone(), v.clone())).collect::)>>().chunks(4) { - rows.push(Signal::new(Vec::from(row))); + if let (_, Some(bt)) = app_service.fetch_recipes_from_storage().unwrap() { + for row in bt + .iter() + .map(|(k, v)| create_signal(cx, (k.clone(), v.clone()))) + .collect::>>() + .chunks(4) + { + rows.push(create_signal(cx, Vec::from(row))); + } } rows - })); - let clicked = Signal::new(false); - create_effect(cloned!((clicked, app_service) => move || { - clicked.get(); - spawn_local_in_scope(cloned!((app_service) => { + }); + let app_service = get_appservice_from_context(cx).clone(); + let clicked = create_signal(cx, false); + create_effect(cx, move || { + clicked.track(); + spawn_local_scoped(cx, { let mut app_service = app_service.clone(); async move { - if let Err(err) = app_service.refresh().await { + if let Err(err) = app_service.synchronize().await { error!(?err); }; } - })); - })); - view! { + }); + }); + view! {cx, table(class="recipe_selector no-print") { (View::new_fragment( rows.get().iter().cloned().map(|r| { - view ! { - tr { Keyed(KeyedProps{ - iterable: r.handle(), - template: |(i, recipe)| { - view! { - td { RecipeSelection(RecipeCheckBoxProps{i: i, title: create_memo(move || recipe.get().title.clone())}) } + view ! {cx, + tr { Keyed( + iterable=r, + view=|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) } } }, - key: |r| r.0.clone(), - })} + key=|sig| sig.get().0.to_owned(), + )} } }).collect() )) diff --git a/web/src/components/shopping_list.rs b/web/src/components/shopping_list.rs index dda218b..65b2756 100644 --- a/web/src/components/shopping_list.rs +++ b/web/src/components/shopping_list.rs @@ -13,131 +13,192 @@ // limitations under the License. use std::collections::{BTreeMap, BTreeSet}; +use recipes::{Ingredient, IngredientKey}; use sycamore::prelude::*; use tracing::{debug, instrument}; use crate::service::get_appservice_from_context; -#[instrument] -#[component(ShoppingList)] -pub fn shopping_list() -> View { - let app_service = get_appservice_from_context(); - let filtered_keys = Signal::new(BTreeSet::new()); - let ingredients_map = Signal::new(BTreeMap::new()); - let extras = Signal::new(Vec::<(usize, (Signal, Signal))>::new()); - let modified_amts = Signal::new(BTreeMap::new()); - let show_staples = Signal::new(true); - create_effect( - cloned!((app_service, ingredients_map, show_staples) => move || { - ingredients_map.set(app_service.get_shopping_list(*show_staples.get())); - }), - ); - debug!(ingredients_map=?ingredients_map.get_untracked()); - let ingredients = create_memo(cloned!((ingredients_map, filtered_keys) => 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()))); +fn make_ingredients_rows<'ctx, G: Html>( + cx: Scope<'ctx>, + ingredients: &'ctx ReadSignal))>>, + modified_amts: &'ctx Signal>>, + filtered_keys: RcSignal>, +) -> View { + 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 == "" { + "other".to_owned() + } else { + i.category + }; + let form = i.form.map(|form| format!("({})", form)).unwrap_or_default(); + let recipes = rs + .iter() + .fold(String::new(), |acc, s| format!("{}{},", acc, s)) + .trim_end_matches(",") + .to_owned(); + view! {cx, + tr { + td { + input(bind:value=amt, type="text") + } + 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); + }}) + } + td { (name) " " (form) "" br {} "" (category) "" } + td { (recipes) } + } } } - } - ingredients - })); - debug!(ingredients = ?ingredients.get_untracked()); - let table_view = Signal::new(View::empty()); - create_effect( - cloned!((table_view, ingredients, filtered_keys, modified_amts, extras) => move || { - if (ingredients.get().len() > 0) || (extras.get().len() > 0) { - let t = view ! { - table(class="pad-top shopping-list page-breaker container-fluid", role="grid") { - tr { - th { " Quantity " } - th { " Delete " } - th { " Ingredient " } - th { " Recipes " } + ) + ) +} + +fn make_extras_rows<'ctx, G: Html>( + cx: Scope<'ctx>, + extras: &'ctx Signal, &'ctx Signal))>>, +) -> View { + view! {cx, + Indexed( + iterable=extras, + 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=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" } + } } - tbody { - Indexed(IndexedProps{ - iterable: ingredients.clone(), - template: cloned!((filtered_keys, modified_amts) => move |(k, (i, rs))| { - let mut modified_amt_set = (*modified_amts.get()).clone(); - let amt = modified_amt_set.entry(k.clone()).or_insert(Signal::new(format!("{}", i.amt.normalize()))).clone(); - modified_amts.set(modified_amt_set); - let name = i.name; - let category = if i.category == "" { "other".to_owned() } else { i.category }; - let form = i.form.map(|form| format!("({})", form)).unwrap_or_default(); - let recipes = rs.iter().fold(String::new(), |acc, s| format!("{}{},", acc, s)).trim_end_matches(",").to_owned(); - view! { - tr { - td { - input(bind:value=amt.clone(), type="text") - } - td { - input(type="button", class="no-print destructive", value="X", on:click=cloned!((filtered_keys) => move |_| { - let mut keyset = (*filtered_keys.get()).clone(); - keyset.insert(k.clone()); - filtered_keys.set(keyset); - })) - } - td { (name) " " (form) "" br {} "" (category) "" } - td { (recipes) } - } - } - }), - }) - Indexed(IndexedProps{ - iterable: extras.handle(), - template: cloned!((extras) => move |(idx, (amt, name))| { - view! { - tr { - td { - input(bind:value=amt.clone(), type="text") - } - td { - input(type="button", class="no-print destructive", value="X", on:click=cloned!((extras) => move |_| { - extras.set(extras.get().iter() - .filter(|(i, _)| *i != idx) - .map(|(_, v)| v.clone()) - .enumerate() - .collect()) - })) - } - td { - input(bind:value=name.clone(), type="text") - } - td { "Misc" } - } - } - }) - }) } + ) + } +} + +fn make_shopping_table<'ctx, G: Html>( + cx: Scope<'ctx>, + ingredients: &'ctx ReadSignal))>>, + modified_amts: &'ctx Signal>>, + extras: &'ctx Signal, &'ctx Signal))>>, + filtered_keys: RcSignal>, +) -> View { + let extra_rows_view = make_extras_rows(cx, extras); + let ingredient_rows = + make_ingredients_rows(cx, ingredients, modified_amts, filtered_keys.clone()); + view! {cx, + table(class="pad-top shopping-list page-breaker container-fluid", role="grid") { + tr { + th { " Quantity " } + th { " Delete " } + th { " Ingredient " } + th { " Recipes " } + } + tbody { + (ingredient_rows) + (extra_rows_view) + } + } + } +} + +#[instrument] +#[component] +pub fn ShoppingList(cx: Scope) -> View { + let app_service = get_appservice_from_context(cx); + let filtered_keys: RcSignal> = create_rc_signal(BTreeSet::new()); + let ingredients_map = create_rc_signal(BTreeMap::new()); + let extras = create_signal( + cx, + Vec::<(usize, (&Signal, &Signal))>::new(), + ); + let modified_amts = create_signal(cx, BTreeMap::new()); + let show_staples = create_signal(cx, true); + create_effect(cx, { + let ingredients_map = ingredients_map.clone(); + move || { + ingredients_map.set(app_service.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()))); } - }; - table_view.set(t); + } + } + ingredients + } + }); + let table_view = create_signal(cx, View::empty()); + create_effect(cx, { + let filtered_keys = filtered_keys.clone(); + move || { + if (ingredients.get().len() > 0) || (extras.get().len() > 0) { + table_view.set(make_shopping_table( + cx, + ingredients, + modified_amts.clone(), + extras.clone(), + filtered_keys.clone(), + )); } else { table_view.set(View::empty()); } - }), - ); - view! { + } + }); + view! {cx, h1 { "Shopping List " } label(for="show_staples_cb") { "Show staples" } - input(id="show_staples_cb", type="checkbox", bind:checked=show_staples.clone()) + input(id="show_staples_cb", type="checkbox", bind:checked=show_staples) (table_view.get().as_ref().clone()) - input(type="button", value="Add Item", class="no-print", on:click=cloned!((extras) => move |_| { - let mut cloned_extras: Vec<(Signal, Signal)> = (*extras.get()).iter().map(|(_, v)| v.clone()).collect(); - cloned_extras.push((Signal::new("".to_owned()), Signal::new("".to_owned()))); + input(type="button", value="Add Item", class="no-print", on:click=move |_| { + let mut cloned_extras: Vec<(&Signal, &Signal)> = (*extras.get()).iter().map(|(_, tpl)| *tpl).collect(); + cloned_extras.push((create_signal(cx, "".to_owned()), create_signal(cx, "".to_owned()))); extras.set(cloned_extras.drain(0..).enumerate().collect()); - })) - input(type="button", value="Reset", class="no-print", on:click=cloned!((ingredients_map, filtered_keys, app_service, modified_amts, extras, show_staples) => move |_| { + }) + input(type="button", value="Reset", class="no-print", on:click=move |_| { // TODO(jwall): We should actually pop up a modal here or use a different set of items. ingredients_map.set(app_service.get_shopping_list(*show_staples.get())); // clear the filter_signal filtered_keys.set(BTreeSet::new()); modified_amts.set(BTreeMap::new()); extras.set(Vec::new()); - })) + }) } } diff --git a/web/src/components/tabs.rs b/web/src/components/tabs.rs index 80aecf2..c67f723 100644 --- a/web/src/components/tabs.rs +++ b/web/src/components/tabs.rs @@ -13,15 +13,17 @@ // limitations under the License. use sycamore::prelude::*; -#[derive(Clone)] +use super::Header; + +#[derive(Clone, Prop)] pub struct TabState { pub inner: View, } -#[component(TabbedView)] -pub fn tabbed_view(state: TabState) -> View { - cloned!((state) => view! { - header(class="no-print") { +#[component] +pub fn TabbedView(cx: Scope, state: TabState) -> View { + view! {cx, + Header { } nav { ul { li { a(href="/ui/plan", class="no-print") { "Plan" } " > " @@ -38,9 +40,8 @@ pub fn tabbed_view(state: TabState) -> View { li { a(href="https://github.com/zaphar/kitchen") { "Github" } } } } - } main(class=".conatiner-fluid") { (state.inner) } - }) + } } diff --git a/web/src/lib.rs b/web/src/lib.rs index f9d968f..35f9eaa 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -33,5 +33,5 @@ pub fn main() { // TODO(jwall): use the tracing_subscriber_browser default setup function when it exists. tracing_browser_subscriber::configure_as_global_default(); } - sycamore::render(|| view! { UI() }); + sycamore::render(|cx| view! { cx, UI() }); } diff --git a/web/src/pages/categories.rs b/web/src/pages/categories.rs index 28084b9..627e6e6 100644 --- a/web/src/pages/categories.rs +++ b/web/src/pages/categories.rs @@ -18,12 +18,12 @@ use sycamore::prelude::*; use tracing::instrument; #[instrument] -#[component(CategoryPage)] -pub fn category_page() -> View { - view! { +#[component()] +pub fn CategoryPage(cx: Scope) -> View { + view! {cx, TabbedView(TabState { - inner: view! { - Categories() + inner: view! {cx, + Categories { } } }) } diff --git a/web/src/pages/cook.rs b/web/src/pages/cook.rs index fbfb147..4da3de8 100644 --- a/web/src/pages/cook.rs +++ b/web/src/pages/cook.rs @@ -15,12 +15,12 @@ use crate::components::{recipe_list::*, tabs::*}; use sycamore::prelude::*; -#[component(CookPage)] -pub fn cook_page() -> View { - view! { +#[component] +pub fn CookPage(cx: Scope) -> View { + view! {cx, TabbedView(TabState { - inner: view! { - RecipeList() + inner: view! {cx, + RecipeList { } }, }) } diff --git a/web/src/pages/inventory.rs b/web/src/pages/inventory.rs index 7f20026..454e83d 100644 --- a/web/src/pages/inventory.rs +++ b/web/src/pages/inventory.rs @@ -15,12 +15,12 @@ use crate::components::{shopping_list::*, tabs::*}; use sycamore::prelude::*; -#[component(InventoryPage)] -pub fn inventory_page() -> View { - view! { +#[component] +pub fn InventoryPage(cx: Scope) -> View { + view! {cx, TabbedView(TabState { - inner: view! { - ShoppingList() + inner: view! {cx, + ShoppingList {} }, }) } diff --git a/web/src/pages/login.rs b/web/src/pages/login.rs index 791dd5c..c9f392b 100644 --- a/web/src/pages/login.rs +++ b/web/src/pages/login.rs @@ -15,7 +15,7 @@ use crate::components::tabs::*; use base64; use reqwasm::http; -use sycamore::{futures::spawn_local_in_scope, prelude::*}; +use sycamore::{futures::spawn_local_scoped, prelude::*}; use tracing::{debug, error, info}; fn token68(user: String, pass: String) -> String { @@ -46,42 +46,42 @@ async fn authenticate(user: String, pass: String) -> bool { return false; } -#[component(LoginForm)] -pub fn login_form() -> View { - let username = Signal::new("".to_owned()); - let password = Signal::new("".to_owned()); - let clicked = Signal::new(("".to_owned(), "".to_owned())); - create_effect(cloned!((clicked) => move || { +#[component] +pub fn LoginForm(cx: Scope) -> View { + 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_in_scope(async move { + spawn_local_scoped(cx, async move { debug!("authenticating against ui"); // TODO(jwall): Navigate to plan if the below is successful. authenticate(username, password).await; }); } - })); - view! { + }); + view! {cx, form() { label(for="username") { "Username" } - input(type="text", id="username", bind:value=username.clone()) + input(type="text", id="username", bind:value=username) label(for="password") { "Password" } - input(type="password", bind:value=password.clone()) - input(type="button", value="Login", on:click=cloned!((clicked) => move |_| { + 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())); debug!("triggering login click subscribers"); clicked.trigger_subscribers(); - })) { } + }) { } } } } -#[component(LoginPage)] -pub fn login_page() -> View { - view! { +#[component] +pub fn LoginPage(cx: Scope) -> View { + view! {cx, TabbedView(TabState { - inner: view! { LoginForm() } + inner: view! {cx, LoginForm { } } }) } } diff --git a/web/src/pages/plan.rs b/web/src/pages/plan.rs index 0780fe9..7e842d2 100644 --- a/web/src/pages/plan.rs +++ b/web/src/pages/plan.rs @@ -15,14 +15,12 @@ use crate::components::{recipe_selector::*, tabs::*}; use sycamore::prelude::*; -use super::PageProps; - -#[component(PlanPage)] -pub fn plan_page(props: PageProps) -> View { - view! { +#[component] +pub fn PlanPage(cx: Scope) -> View { + view! {cx, TabbedView(TabState { - inner: view! { - RecipeSelector(props.service.clone()) + inner: view! {cx, + RecipeSelector() }, }) } diff --git a/web/src/pages/recipe.rs b/web/src/pages/recipe.rs index caf8ad3..1a35808 100644 --- a/web/src/pages/recipe.rs +++ b/web/src/pages/recipe.rs @@ -16,18 +16,18 @@ use crate::components::{recipe::Recipe, tabs::*}; use sycamore::prelude::*; use tracing::instrument; -#[derive(Debug)] +#[derive(Debug, Prop)] pub struct RecipePageProps { - pub recipe: Signal, + pub recipe: String, } #[instrument] -#[component(RecipePage)] -pub fn recipe_page(props: RecipePageProps) -> View { - view! { +#[component()] +pub fn RecipePage(cx: Scope, props: RecipePageProps) -> View { + view! {cx, TabbedView(TabState { - inner: view! { - Recipe(props.recipe.handle()) + inner: view! {cx, + Recipe(props.recipe) } }) } diff --git a/web/src/router_integration.rs b/web/src/router_integration.rs index 1f952b8..c2dd368 100644 --- a/web/src/router_integration.rs +++ b/web/src/router_integration.rs @@ -24,12 +24,12 @@ use web_sys::{Element, HtmlAnchorElement}; use crate::app_state::AppRoutes; #[derive(Clone, Debug)] -pub struct BrowserIntegration(Signal<(String, String, String)>); +pub struct BrowserIntegration(RcSignal<(String, String, String)>); impl BrowserIntegration { pub fn new() -> Self { let location = web_sys::window().unwrap_throw().location(); - Self(Signal::new(( + Self(create_rc_signal(( location.origin().unwrap_or(String::new()), location.pathname().unwrap_or(String::new()), location.hash().unwrap_or(String::new()), @@ -102,7 +102,7 @@ pub struct RouterProps where G: GenericNode, R: DeriveRoute + NotFound + Clone + Default + Debug + 'static, - F: Fn(ReadSignal) -> View + 'static, + F: Fn(Scope, &ReadSignal) -> View + 'static, { pub route: R, pub route_select: F, @@ -114,56 +114,62 @@ where pathn=props.browser_integration.0.get().1, hash=props.browser_integration.0.get().2), skip(props))] -#[component(Router)] -pub fn router(props: RouterProps) -> View +#[component] +pub fn Router<'ctx, G, R, F>(cx: Scope, props: RouterProps) -> View where + G: Html, R: DeriveRoute + NotFound + Clone + Default + Debug + 'static, - F: Fn(ReadSignal) -> View + 'static, + F: Fn(Scope, &ReadSignal) -> View + 'static, { debug!("Setting up router"); let integration = Rc::new(props.browser_integration); let route_select = Rc::new(props.route_select); - let view_signal = Signal::new(View::empty()); - create_effect( - cloned!((view_signal, integration, route_select) => move || { + let view_signal = create_signal(cx, View::empty()); + create_effect(cx, { + let integration = integration.clone(); + move || { let path_signal = integration.0.clone(); debug!(origin=%path_signal.get().0, path=%path_signal.get().1, hash=%path_signal.get().2, "new path"); let path = path_signal.clone(); let route = R::from(path.get().as_ref()); debug!(?route, "new route"); // TODO(jwall): this is an unnecessary use of signal. - let view = route_select.as_ref()(Signal::new(route).handle()); - register_click_handler(&view, integration.clone()); + let view = route_select.as_ref()(cx, &*create_signal(cx, route)); + register_click_handler(cx, &view, integration.clone()); view_signal.set(view); - }), - ); + } + }); let path_signal = integration.0.clone(); - integration.register_post_state_handler(Box::new(cloned!((path_signal) => move || { + integration.register_post_state_handler(Box::new(move || { let location = web_sys::window().unwrap_throw().location(); - path_signal.set((location.origin().unwrap_throw(), location.pathname().unwrap_throw(), location.hash().unwrap_throw())); - }))); + path_signal.set(( + location.origin().unwrap_throw(), + location.pathname().unwrap_throw(), + location.hash().unwrap_throw(), + )); + })); // NOTE(jwall): This needs to be a dynamic node so Sycamore knows to rerender it // based on the results of the effect above. - view! { + view! {cx, (view_signal.get().as_ref()) } } #[instrument(skip_all)] -fn register_click_handler(view: &View, integration: Rc) +fn register_click_handler(cx: Scope, view: &View, integration: Rc) where G: GenericNode, { debug!("Registring click handler on node(s)"); if let Some(node) = view.as_node() { - node.event("click", integration.click_handler()); + node.event(cx, "click", integration.click_handler()); } else if let Some(frag) = view.as_fragment() { debug!(fragment=?frag); for n in frag { - register_click_handler(n, integration.clone()); + register_click_handler(cx, n, integration.clone()); } } else if let Some(dyn_node) = view.as_dyn() { debug!(dynamic_node=?dyn_node); diff --git a/web/src/service.rs b/web/src/service.rs index 7ac67fe..6c6ba19 100644 --- a/web/src/service.rs +++ b/web/src/service.rs @@ -16,7 +16,7 @@ use std::collections::{BTreeMap, BTreeSet}; use reqwasm; //use serde::{Deserialize, Serialize}; use serde_json::{from_str, to_string}; -use sycamore::{context::use_context, prelude::*}; +use sycamore::prelude::*; use tracing::{debug, error, info, instrument, warn}; use web_sys::Storage; @@ -25,26 +25,27 @@ use recipes::{parse, Ingredient, IngredientAccumulator, Recipe}; use crate::js_lib; -pub fn get_appservice_from_context() -> AppService { - use_context::() +pub fn get_appservice_from_context(cx: Scope) -> &AppService { + use_context::(cx) } +// TODO(jwall): We should not be cloning this. #[derive(Clone, Debug)] pub struct AppService { - recipes: Signal>>, - staples: Signal>, - category_map: Signal>, - menu_list: Signal>, + recipe_counts: RcSignal>, + staples: RcSignal>, + recipes: RcSignal>, + category_map: RcSignal>, store: HttpStore, } impl AppService { pub fn new(store: HttpStore) -> Self { Self { - recipes: Signal::new(BTreeMap::new()), - staples: Signal::new(None), - category_map: Signal::new(BTreeMap::new()), - menu_list: Signal::new(BTreeMap::new()), + recipe_counts: create_rc_signal(BTreeMap::new()), + staples: create_rc_signal(None), + recipes: create_rc_signal(BTreeMap::new()), + category_map: create_rc_signal(BTreeMap::new()), store: store, } } @@ -53,9 +54,18 @@ impl AppService { js_lib::get_storage().map_err(|e| format!("{:?}", e)) } + pub fn get_menu_list(&self) -> Vec<(String, usize)> { + self.recipe_counts + .get() + .iter() + .map(|(k, v)| (k.clone(), *v)) + .collect() + } + #[instrument(skip(self))] - async fn synchronize(&self) -> Result<(), String> { + pub async fn synchronize(&mut self) -> Result<(), String> { info!("Synchronizing Recipes"); + // TODO(jwall): Make our caching logic using storage more robust. let storage = self.get_storage()?.unwrap(); let recipes = self .store @@ -68,6 +78,19 @@ impl AppService { &(to_string(&recipes).map_err(|e| format!("{:?}", e))?), ) .map_err(|e| format!("{:?}", e))?; + if let Ok((staples, recipes)) = self.fetch_recipes_from_storage() { + self.staples.set(staples); + if let Some(recipes) = recipes { + self.recipes.set(recipes); + } + } + if let Some(rs) = recipes { + for r in rs { + if !self.recipe_counts.get().contains_key(r.recipe_id()) { + self.set_recipe_count_by_index(&r.recipe_id().to_owned(), 0); + } + } + } info!("Synchronizing categories"); match self.store.get_categories().await { Ok(Some(categories_content)) => { @@ -86,6 +109,54 @@ impl AppService { Ok(()) } + pub fn get_recipe_count_by_index(&self, key: &String) -> Option { + self.recipe_counts.get().get(key).cloned() + } + + pub fn set_recipe_count_by_index(&mut self, key: &String, count: usize) -> usize { + let mut counts = self.recipe_counts.get().as_ref().clone(); + counts.insert(key.clone(), count); + self.recipe_counts.set(counts); + count + } + + #[instrument(skip(self))] + pub fn get_shopping_list( + &self, + show_staples: bool, + ) -> BTreeMap)>> { + let mut acc = IngredientAccumulator::new(); + let recipe_counts = self.get_menu_list(); + for (idx, count) in recipe_counts.iter() { + for _ in 0..*count { + acc.accumulate_from(self.recipes.get().get(idx).unwrap()); + } + } + 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 + } + pub fn get_category_text(&self) -> Result, String> { let storage = self.get_storage()?.unwrap(); storage @@ -175,86 +246,6 @@ impl AppService { Ok(self.fetch_categories_from_storage()?) } - #[instrument(skip(self))] - pub async fn refresh(&mut self) -> Result<(), String> { - self.synchronize().await?; - debug!("refreshing recipes"); - if let (staples, Some(r)) = self.fetch_recipes().await? { - self.set_recipes(r); - self.staples.set(staples); - } - debug!("refreshing categories"); - if let Some(categories) = self.fetch_categories().await? { - self.set_categories(categories); - } - Ok(()) - } - - pub fn get_recipe_by_index(&self, idx: &str) -> Option> { - self.recipes.get().get(idx).map(|r| r.clone()) - } - - #[instrument(skip(self))] - pub fn get_shopping_list( - &self, - show_staples: bool, - ) -> BTreeMap)>> { - let mut acc = IngredientAccumulator::new(); - let recipe_counts = self.menu_list.get(); - for (idx, count) in recipe_counts.iter() { - for _ in 0..*count { - acc.accumulate_from(self.get_recipe_by_index(idx).unwrap().get().as_ref()); - } - } - 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 - } - - pub fn set_recipe_count_by_index(&mut self, i: String, count: usize) { - let mut v = (*self.menu_list.get()).clone(); - v.insert(i, count); - self.menu_list.set(v); - } - - pub fn get_recipe_count_by_index(&self, i: &str) -> usize { - self.menu_list.get().get(i).map(|i| *i).unwrap_or_default() - } - - pub fn get_recipes(&self) -> Signal>> { - self.recipes.clone() - } - - pub fn get_menu_list(&self) -> Vec<(String, usize)> { - self.menu_list - .get() - .iter() - // We exclude recipes in the menu_list with count 0 - .filter(|&(_, count)| *count != 0) - .map(|(idx, count)| (idx.clone(), *count)) - .collect() - } - pub async fn save_recipes(&self, recipes: Vec) -> Result<(), String> { self.store.save_recipes(recipes).await?; Ok(()) @@ -264,19 +255,6 @@ impl AppService { self.store.save_categories(categories).await?; Ok(()) } - - pub fn set_recipes(&mut self, recipes: BTreeMap) { - self.recipes.set( - recipes - .iter() - .map(|(i, r)| (i.clone(), Signal::new(r.clone()))) - .collect(), - ); - } - - pub fn set_categories(&mut self, categories: BTreeMap) { - self.category_map.set(categories); - } } #[derive(Debug)] diff --git a/web/src/web.rs b/web/src/web.rs index 24306c9..b3dc6b1 100644 --- a/web/src/web.rs +++ b/web/src/web.rs @@ -18,95 +18,75 @@ use crate::{ router_integration::*, service::{self, AppService}, }; -use tracing::{debug, error, info, instrument}; +use tracing::{error, info, instrument}; -use sycamore::{ - context::{ContextProvider, ContextProviderProps}, - futures::spawn_local_in_scope, - prelude::*, -}; +use sycamore::{futures::spawn_local_scoped, prelude::*}; #[instrument] -fn route_switch(route: ReadSignal) -> View { +fn route_switch(cx: Scope, route: &ReadSignal) -> View { // 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. - cloned!((route) => match route.get().as_ref() { - AppRoutes::Plan => view! { + match route.get().as_ref() { + AppRoutes::Plan => view! {cx, PlanPage() }, - AppRoutes::Inventory => view! { + AppRoutes::Inventory => view! {cx, InventoryPage() }, - AppRoutes::Login => view! { + AppRoutes::Login => view! {cx, LoginPage() }, - AppRoutes::Cook => view! { + AppRoutes::Cook => view! {cx, CookPage() }, - AppRoutes::Recipe(idx) => view! { - RecipePage(RecipePageProps { recipe: Signal::new(idx.clone()) }) + AppRoutes::Recipe(idx) => view! {cx, + RecipePage(recipe=idx.clone()) }, - AppRoutes::Categories => view ! { + AppRoutes::Categories => view! {cx, CategoryPage() }, - AppRoutes::NotFound => view! { + AppRoutes::NotFound => view! {cx, // TODO(Create a real one) PlanPage() }, AppRoutes::Error(ref e) => { let e = e.clone(); - view! { + view! {cx, "Error: " (e) } } - }) -} - -fn get_appservice() -> AppService { - AppService::new(service::HttpStore::new("/api/v1".to_owned())) + } } #[instrument] -#[component(UI)] -pub fn ui() -> View { - let app_service = get_appservice(); +#[component] +pub fn UI(cx: Scope) -> View { + let app_service = AppService::new(service::HttpStore::new("/api/v1".to_owned())); + provide_context(cx, app_service.clone()); info!("Starting UI"); - view! { - // NOTE(jwall): Set the app_service in our toplevel scope. Children will be able - // to find the service as long as they are a child of this scope. - ContextProvider(ContextProviderProps { - value: app_service.clone(), - children: || { - create_effect(move || { - spawn_local_in_scope({ - let mut app_service = app_service.clone(); - async move { - debug!("fetching recipes"); - match app_service.fetch_recipes_from_storage() { - Ok((_, Some(recipes))) => { - app_service.set_recipes(recipes); - } - Ok((_, None)) => { - error!("No recipes to find"); - } - Err(msg) => error!("Failed to get recipes {}", msg), - } - } - }); - }); - view! { - div(class="app") { - Header() - Router(RouterProps { - route: AppRoutes::Plan, - route_select: route_switch, - browser_integration: BrowserIntegration::new(), - }) - } + 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 mut app_service = crate::service::get_appservice_from_context(cx).clone(); + async move { + if let Err(err) = app_service.synchronize().await { + error!(?err); + }; + view.set(view! { cx, + div(class="app") { + Header() + Router(RouterProps { + route: AppRoutes::Plan, + route_select: route_switch, + browser_integration: BrowserIntegration::new(), + }) } - } - }) - } + }); + } + }); + + view! { cx, (view.get().as_ref()) } }