Messaging mechanism

This commit is contained in:
Jeremy Wall 2023-03-25 08:55:32 -04:00
parent c2b8e79288
commit 4f3cf8d825
5 changed files with 193 additions and 29 deletions

View File

@ -25,7 +25,10 @@ use sycamore_state::{Handler, MessageMapper};
use tracing::{debug, error, info, instrument, warn}; use tracing::{debug, error, info, instrument, warn};
use wasm_bindgen::throw_str; use wasm_bindgen::throw_str;
use crate::api::{HttpStore, LocalStore}; use crate::{
api::{HttpStore, LocalStore},
components,
};
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct AppState { pub struct AppState {
@ -377,6 +380,10 @@ impl MessageMapper<Message, AppState> for StateMachine {
if let Err(e) = store.store_recipes(vec![entry]).await { if let Err(e) = store.store_recipes(vec![entry]).await {
// FIXME(jwall): We should have a global way to trigger error messages // FIXME(jwall): We should have a global way to trigger error messages
error!(err=?e, "Unable to save Recipe"); error!(err=?e, "Unable to save Recipe");
// FIXME(jwall): This should be an error message
components::toast::error_message(cx, "Failed to save Recipe", None);
} else {
components::toast::message(cx, "Saved Recipe", None);
} }
callback.map(|f| f()); callback.map(|f| f());
}); });
@ -389,6 +396,9 @@ impl MessageMapper<Message, AppState> for StateMachine {
spawn_local_scoped(cx, async move { spawn_local_scoped(cx, async move {
if let Err(err) = store.delete_recipe(&recipe).await { if let Err(err) = store.delete_recipe(&recipe).await {
error!(?err, "Failed to delete recipe"); error!(?err, "Failed to delete recipe");
components::toast::error_message(cx, "Unable to delete recipe", None);
} else {
components::toast::message(cx, "Deleted Recipe", None);
} }
callback.map(|f| f()); callback.map(|f| f());
}); });
@ -416,6 +426,7 @@ impl MessageMapper<Message, AppState> for StateMachine {
&original_copy.modified_amts, &original_copy.modified_amts,
&original_copy.extras, &original_copy.extras,
)); ));
components::toast::message(cx, "Reset Inventory", None);
} }
Message::AddFilteredIngredient(key) => { Message::AddFilteredIngredient(key) => {
original_copy.filtered_ingredients.insert(key); original_copy.filtered_ingredients.insert(key);
@ -448,7 +459,10 @@ impl MessageMapper<Message, AppState> for StateMachine {
.plan_dates .plan_dates
.insert(original_copy.selected_plan_date.map(|d| d.clone()).unwrap()); .insert(original_copy.selected_plan_date.map(|d| d.clone()).unwrap());
if let Err(e) = store.store_app_state(&original_copy).await { if let Err(e) = store.store_app_state(&original_copy).await {
error!(err=?e, "Error saving app state") error!(err=?e, "Error saving app state");
components::toast::error_message(cx, "Failed to save user state", None);
} else {
components::toast::message(cx, "Saved user state", None);
}; };
original.set(original_copy); original.set(original_copy);
f.map(|f| f()); f.map(|f| f());
@ -461,14 +475,17 @@ impl MessageMapper<Message, AppState> for StateMachine {
let store = self.store.clone(); let store = self.store.clone();
let local_store = self.local_store.clone(); let local_store = self.local_store.clone();
spawn_local_scoped(cx, async move { spawn_local_scoped(cx, async move {
Self::load_state(&store, &local_store, original) if let Err(err) = Self::load_state(&store, &local_store, original).await {
.await error!(?err, "Failed to load user state");
.expect("Failed to load_state."); components::toast::error_message(cx, "Failed to load_state.", None);
} else {
components::toast::message(cx, "Loaded user state", None);
local_store.set_inventory_data(( local_store.set_inventory_data((
&original.get().filtered_ingredients, &original.get().filtered_ingredients,
&original.get().modified_amts, &original.get().modified_amts,
&original.get().extras, &original.get().extras,
)); ));
}
f.map(|f| f()); f.map(|f| f());
}); });
return; return;
@ -478,11 +495,13 @@ impl MessageMapper<Message, AppState> for StateMachine {
let local_store = self.local_store.clone(); let local_store = self.local_store.clone();
spawn_local_scoped(cx, async move { spawn_local_scoped(cx, async move {
local_store.set_staples(&content); local_store.set_staples(&content);
store if let Err(err) = store.store_staples(content).await {
.store_staples(content) error!(?err, "Failed to store staples");
.await components::toast::error_message(cx, "Failed to store staples", None);
.expect("Failed to store staples"); } else {
components::toast::message(cx, "Updated staples", None);
callback.map(|f| f()); callback.map(|f| f());
}
}); });
return; return;
} }
@ -527,10 +546,14 @@ impl MessageMapper<Message, AppState> for StateMachine {
let store = self.store.clone(); let store = self.store.clone();
let local_store = self.local_store.clone(); let local_store = self.local_store.clone();
spawn_local_scoped(cx, async move { spawn_local_scoped(cx, async move {
store if let Err(err) = store.delete_plan_for_date(&date).await {
.delete_plan_for_date(&date) components::toast::error_message(
.await cx,
.expect("Failed to delete meal plan for date"); "Failed to delete meal plan for date",
None,
);
error!(?err, "Error deleting plan");
} else {
local_store.delete_plan(); local_store.delete_plan();
original_copy.plan_dates.remove(&date); original_copy.plan_dates.remove(&date);
@ -540,8 +563,10 @@ impl MessageMapper<Message, AppState> for StateMachine {
original_copy.modified_amts = BTreeMap::new(); original_copy.modified_amts = BTreeMap::new();
original_copy.extras = Vec::new(); original_copy.extras = Vec::new();
original.set(original_copy); original.set(original_copy);
components::toast::message(cx, "Deleted Plan", None);
callback.map(|f| f()); callback.map(|f| f());
}
}); });
return; return;
} }

View File

@ -24,6 +24,7 @@ pub mod recipe_selection;
pub mod shopping_list; pub mod shopping_list;
pub mod staples; pub mod staples;
pub mod tabs; pub mod tabs;
pub mod toast;
pub use add_recipe::*; pub use add_recipe::*;
pub use categories::*; pub use categories::*;

View File

@ -0,0 +1,79 @@
// Copyright 2023 Jeremy Wall (Jeremy@marzhilsltudios.com)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// 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::{easing, motion, prelude::*};
use tracing::debug;
use wasm_bindgen::UnwrapThrowExt;
const SECTION_ID: &'static str = "toast-container";
#[component]
pub fn Container<'a, G: Html>(cx: Scope<'a>) -> View<G> {
view! {cx,
section(id=SECTION_ID) { }
}
}
pub fn create_output_element(msg: &str, class: &str) -> web_sys::Element {
let document = web_sys::window()
.expect("No window present")
.document()
.expect("No document in window");
let output = document.create_element("output").unwrap_throw();
let message_node = document.create_text_node(msg);
output.set_attribute("class", class).unwrap_throw();
output.set_attribute("role", "status").unwrap_throw();
output.append_child(&message_node).unwrap_throw();
output
}
fn show_toast<'a>(cx: Scope<'a>, msg: &str, class: &str, timeout: Option<chrono::Duration>) {
let timeout = timeout.unwrap_or_else(|| chrono::Duration::seconds(3));
// Insert a toast output element into the container.
let tweened = motion::create_tweened_signal(
cx,
0.0 as f32,
timeout
.to_std()
.expect("Failed to convert timeout duration."),
easing::quad_in,
);
tweened.set(1.0);
create_effect_scoped(cx, move |_cx| {
if !tweened.is_tweening() {
debug!("Detected message timeout.");
let container = crate::js_lib::get_element_by_id::<web_sys::HtmlElement>(SECTION_ID)
.expect("Failed to get toast-container")
.expect("No toast-container");
if let Some(node_to_remove) = container.first_element_child() {
// Always remove the first child if there is one.
container.remove_child(&node_to_remove).unwrap_throw();
}
}
});
let output_element = create_output_element(msg, class);
crate::js_lib::get_element_by_id::<web_sys::HtmlElement>(SECTION_ID)
.expect("Failed to get toast-container")
.expect("No toast-container")
// Always append after the last child.
.append_child(&output_element)
.unwrap_throw();
}
pub fn message<'a>(cx: Scope<'a>, msg: &str, timeout: Option<chrono::Duration>) {
show_toast(cx, msg, "toast", timeout);
}
pub fn error_message<'a>(cx: Scope<'a>, msg: &str, timeout: Option<chrono::Duration>) {
show_toast(cx, msg, "toast error", timeout);
}

View File

@ -14,7 +14,7 @@
use crate::{ use crate::{
app_state::StateHandler, app_state::StateHandler,
components::{Footer, Header}, components::{toast::Container, Footer, Header},
pages::*, pages::*,
}; };
use sycamore::prelude::*; use sycamore::prelude::*;
@ -137,6 +137,7 @@ pub fn Handler<'ctx, G: Html>(cx: Scope<'ctx>, props: HandlerProps<'ctx>) -> Vie
view=move |cx: Scope, route: &ReadSignal<Routes>| { view=move |cx: Scope, route: &ReadSignal<Routes>| {
view!{cx, view!{cx,
div(class="app") { div(class="app") {
Container()
Header(sh) Header(sh)
(route_switch(route.get().as_ref(), cx, sh)) (route_switch(route.get().as_ref(), cx, sh))
Footer { } Footer { }

View File

@ -19,8 +19,20 @@
--tab-border-style: solid; --tab-border-style: solid;
--tab-border-radius: 15px; --tab-border-radius: 15px;
--unicode-button-size: 2em; --unicode-button-size: 2em;
--toast-anim-duration: 3s;
--toast-travel-distance: 0;
--notification-font-size: calc(var(--font-size) / 2);
--error-message-color: rgba(255, 98, 0, 0.797);
--error-message-bg: grey;
} }
@media (prefers-reduced-motion: no-preference) {
.gui-toast {
--toast-travel-distance: 5vh;
}
}
@media print { @media print {
.no-print, .no-print,
@ -77,3 +89,49 @@ nav>h1 {
.item-count-inc-dec { .item-count-inc-dec {
font-size: var(--unicode-button-size); font-size: var(--unicode-button-size);
} }
#toast-container {
position: fixed;
z-index: 1;
inset-block-start: 0;
inset-inline: 0;
padding-block-start: 5vh;
display: grid;
justify-items: center;
justify-content: center;
gap: 1vh;
font-size: var(--notification-font-size);
pointer-events: none;
}
.toast-container .error {
color: var(--error-message-color);
background-color: var(--error-message-bg);
}
.toast {
max-inline-size: min(25ch, 90vw);
padding-block: .5ch;
padding-inline: 1ch;
border-radius: 3px;
will-change: transform;
animation:
fade-in 1s ease,
fade-out .5s ease var(--toast-anim-duration);
}
@keyframes fade-in {
from {
opacity: 0
}
}
@keyframes fade-out {
to {
opacity: 0
}
}