From 9ba179410b5d5a0be26c13ff53517918717b38ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Brand=C3=A9ho?= Date: Fri, 17 May 2024 07:08:32 +0200 Subject: [PATCH] wip connection toaster (#3242) * wip connection toaster * never duplicate toast for websocket-error * wip update banner * clean up PR * fix for 3.8 * update pyi * ConnectionToaster tweaks * Use `has_too_many_connection_errors` to avoid showing the banner immediately * Increase toast duration to avoid frequent, distracting flashing of the toast * Automatically dismiss the toast when the connection comes back up * Include `close_button` for user to dismiss the toast * If the user dismisses the toast, do not show it again until the connection comes back and drops again * Use `connection_error` var instead of a custom util_hook to get the message * ConnectionPulser: hide behind toast * Hide the connection pulser behind the toast (33x33) * Add a title (tooltip) that shows the connection error * Re-add connection pulser to default overlay_component If the user dismisses the toast, we still want to indicate that the backend is actually down. * Fix pre-commit issue from main --------- Co-authored-by: Masen Furer --- reflex/app.py | 4 +- reflex/components/core/__init__.py | 8 +- reflex/components/core/banner.py | 73 +++++++++++++++- reflex/components/core/banner.pyi | 130 ++++++++++++++++++++++++++++ reflex/components/sonner/toast.py | 4 +- reflex/components/sonner/toast.pyi | 4 +- reflex/experimental/client_state.py | 2 +- 7 files changed, 215 insertions(+), 10 deletions(-) diff --git a/reflex/app.py b/reflex/app.py index 4d99d6949e..4a7c60e2e4 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -41,7 +41,6 @@ from reflex.compiler import compiler from reflex.compiler import utils as compiler_utils from reflex.compiler.compiler import ExecutorSafeFunctions -from reflex.components import connection_modal, connection_pulser from reflex.components.base.app_wrap import AppWrap from reflex.components.base.fragment import Fragment from reflex.components.component import ( @@ -49,6 +48,7 @@ ComponentStyle, evaluate_style_namespaces, ) +from reflex.components.core import connection_pulser, connection_toaster from reflex.components.core.client_side_routing import ( Default404Page, wait_for_client_redirect, @@ -91,7 +91,7 @@ def default_overlay_component() -> Component: Returns: The default overlay_component, which is a connection_modal. """ - return Fragment.create(connection_pulser(), connection_modal()) + return Fragment.create(connection_pulser(), connection_toaster()) class OverlayFragment(Fragment): diff --git a/reflex/components/core/__init__.py b/reflex/components/core/__init__.py index 80c73add87..877d27739d 100644 --- a/reflex/components/core/__init__.py +++ b/reflex/components/core/__init__.py @@ -1,7 +1,12 @@ """Core Reflex components.""" from . import layout as layout -from .banner import ConnectionBanner, ConnectionModal, ConnectionPulser +from .banner import ( + ConnectionBanner, + ConnectionModal, + ConnectionPulser, + ConnectionToaster, +) from .colors import color from .cond import Cond, color_mode_cond, cond from .debounce import DebounceInput @@ -26,6 +31,7 @@ connection_banner = ConnectionBanner.create connection_modal = ConnectionModal.create +connection_toaster = ConnectionToaster.create connection_pulser = ConnectionPulser.create debounce_input = DebounceInput.create foreach = Foreach.create diff --git a/reflex/components/core/banner.py b/reflex/components/core/banner.py index c2fe3e6886..c6250743cf 100644 --- a/reflex/components/core/banner.py +++ b/reflex/components/core/banner.py @@ -16,8 +16,11 @@ ) from reflex.components.radix.themes.layout import Flex from reflex.components.radix.themes.typography.text import Text +from reflex.components.sonner.toast import Toaster, ToastProps from reflex.constants import Dirs, Hooks, Imports +from reflex.constants.compiler import CompileVars from reflex.utils import imports +from reflex.utils.serializers import serialize from reflex.vars import Var, VarData connect_error_var_data: VarData = VarData( # type: ignore @@ -25,6 +28,13 @@ hooks={Hooks.EVENTS: None}, ) +connect_errors: Var = Var.create_safe( + value=CompileVars.CONNECT_ERROR, + _var_is_local=True, + _var_is_string=False, + _var_data=connect_error_var_data, +) + connection_error: Var = Var.create_safe( value="(connectErrors.length > 0) ? connectErrors[connectErrors.length - 1].message : ''", _var_is_local=False, @@ -85,6 +95,64 @@ def default_connection_error() -> list[str | Var | Component]: ] +class ConnectionToaster(Toaster): + """A connection toaster component.""" + + def add_hooks(self) -> list[str]: + """Add the hooks for the connection toaster. + + Returns: + The hooks for the connection toaster. + """ + toast_id = "websocket-error" + target_url = WebsocketTargetURL.create() + props = ToastProps( # type: ignore + description=Var.create( + f"`Check if server is reachable at ${target_url}`", + _var_is_string=False, + _var_is_local=False, + ), + close_button=True, + duration=120000, + id=toast_id, + ) + hook = Var.create( + f""" +const toast_props = {serialize(props)}; +const [userDismissed, setUserDismissed] = useState(false); +useEffect(() => {{ + if ({has_too_many_connection_errors}) {{ + if (!userDismissed) {{ + toast.error( + `Cannot connect to server: {connection_error}.`, + {{...toast_props, onDismiss: () => setUserDismissed(true)}}, + ) + }} + }} else {{ + toast.dismiss("{toast_id}"); + setUserDismissed(false); // after reconnection reset dismissed state + }} +}}, [{connect_errors}]);""" + ) + + hook._var_data = VarData.merge( # type: ignore + connect_errors._var_data, + VarData( + imports={ + "react": [ + imports.ImportVar(tag="useEffect"), + imports.ImportVar(tag="useState"), + ], + **target_url._get_imports(), + } + ), + ) + return [ + Hooks.EVENTS, + hook, # type: ignore + ] + + class ConnectionBanner(Component): """A connection banner component.""" @@ -162,8 +230,8 @@ def create(cls, **props) -> Component: size=props.pop("size", 32), z_index=props.pop("z_index", 9999), position=props.pop("position", "fixed"), - bottom=props.pop("botton", "30px"), - right=props.pop("right", "30px"), + bottom=props.pop("botton", "33px"), + right=props.pop("right", "33px"), animation=Var.create(f"${{pulse}} 1s infinite", _var_is_string=True), **props, ) @@ -205,6 +273,7 @@ def create(cls, **props) -> Component: has_connection_errors, WifiOffPulse.create(**props), ), + title=f"Connection Error: {connection_error}", position="fixed", width="100vw", height="0", diff --git a/reflex/components/core/banner.pyi b/reflex/components/core/banner.pyi index 43fc53e291..64f9761f9a 100644 --- a/reflex/components/core/banner.pyi +++ b/reflex/components/core/banner.pyi @@ -20,11 +20,15 @@ from reflex.components.radix.themes.components.dialog import ( ) from reflex.components.radix.themes.layout import Flex from reflex.components.radix.themes.typography.text import Text +from reflex.components.sonner.toast import Toaster, ToastProps from reflex.constants import Dirs, Hooks, Imports +from reflex.constants.compiler import CompileVars from reflex.utils import imports +from reflex.utils.serializers import serialize from reflex.vars import Var, VarData connect_error_var_data: VarData +connect_errors: Var connection_error: Var connection_errors_count: Var has_connection_errors: Var @@ -99,6 +103,132 @@ class WebsocketTargetURL(Bare): def default_connection_error() -> list[str | Var | Component]: ... +class ConnectionToaster(Toaster): + def add_hooks(self) -> list[str]: ... + @overload + @classmethod + def create( # type: ignore + cls, + *children, + theme: Optional[Union[Var[str], str]] = None, + rich_colors: Optional[Union[Var[bool], bool]] = None, + expand: Optional[Union[Var[bool], bool]] = None, + visible_toasts: Optional[Union[Var[int], int]] = None, + position: Optional[ + Union[ + Var[ + Literal[ + "top-left", + "top-center", + "top-right", + "bottom-left", + "bottom-center", + "bottom-right", + ] + ], + Literal[ + "top-left", + "top-center", + "top-right", + "bottom-left", + "bottom-center", + "bottom-right", + ], + ] + ] = None, + close_button: Optional[Union[Var[bool], bool]] = None, + offset: Optional[Union[Var[str], str]] = None, + dir: Optional[Union[Var[str], str]] = None, + hotkey: Optional[Union[Var[str], str]] = None, + invert: Optional[Union[Var[bool], bool]] = None, + toast_options: Optional[Union[Var[ToastProps], ToastProps]] = None, + gap: Optional[Union[Var[int], int]] = None, + loading_icon: Optional[Union[Var[Icon], Icon]] = None, + pause_when_page_is_hidden: Optional[Union[Var[bool], bool]] = None, + style: Optional[Style] = None, + key: Optional[Any] = None, + id: Optional[Any] = None, + class_name: Optional[Any] = None, + autofocus: Optional[bool] = None, + custom_attrs: Optional[Dict[str, Union[Var, str]]] = None, + on_blur: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_click: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_context_menu: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_double_click: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_focus: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_mount: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_mouse_down: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_mouse_enter: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_mouse_leave: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_mouse_move: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_mouse_out: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_mouse_over: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_mouse_up: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_scroll: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_unmount: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + **props + ) -> "ConnectionToaster": + """Create the component. + + Args: + *children: The children of the component. + theme: the theme of the toast + rich_colors: whether to show rich colors + expand: whether to expand the toast + visible_toasts: the number of toasts that are currently visible + position: the position of the toast + close_button: whether to show the close button + offset: offset of the toast + dir: directionality of the toast (default: ltr) + hotkey: Keyboard shortcut that will move focus to the toaster area. + invert: Dark toasts in light mode and vice versa. + toast_options: These will act as default options for all toasts. See toast() for all available options. + gap: Gap between toasts when expanded + loading_icon: Changes the default loading icon + pause_when_page_is_hidden: Pauses toast timers when the page is hidden, e.g., when the tab is backgrounded, the browser is minimized, or the OS is locked. + style: The style of the component. + key: A unique key for the component. + id: The id for the component. + class_name: The class name for the component. + autofocus: Whether the component should take the focus once the page is loaded + custom_attrs: custom attribute + **props: The props of the component. + + Returns: + The component. + """ + ... + class ConnectionBanner(Component): @overload @classmethod diff --git a/reflex/components/sonner/toast.py b/reflex/components/sonner/toast.py index d820af2da5..648b0db9c4 100644 --- a/reflex/components/sonner/toast.py +++ b/reflex/components/sonner/toast.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any, Literal, Optional +from typing import Any, Literal, Optional, Union from reflex.base import Base from reflex.components.component import Component, ComponentNamespace @@ -74,7 +74,7 @@ class ToastProps(PropsBase): """Props for the toast component.""" # Toast's description, renders underneath the title. - description: Optional[str] + description: Optional[Union[str, Var]] # Whether to show the close button. close_button: Optional[bool] diff --git a/reflex/components/sonner/toast.pyi b/reflex/components/sonner/toast.pyi index 5bd6cdeb41..6bc5ab2b5e 100644 --- a/reflex/components/sonner/toast.pyi +++ b/reflex/components/sonner/toast.pyi @@ -7,7 +7,7 @@ from typing import Any, Dict, Literal, Optional, Union, overload from reflex.vars import Var, BaseVar, ComputedVar from reflex.event import EventChain, EventHandler, EventSpec from reflex.style import Style -from typing import Any, Literal, Optional +from typing import Any, Literal, Optional, Union from reflex.base import Base from reflex.components.component import Component, ComponentNamespace from reflex.components.lucide.icon import Icon @@ -37,7 +37,7 @@ class ToastAction(Base): def serialize_action(action: ToastAction) -> dict: ... class ToastProps(PropsBase): - description: Optional[str] + description: Optional[Union[str, Var]] close_button: Optional[bool] invert: Optional[bool] important: Optional[bool] diff --git a/reflex/experimental/client_state.py b/reflex/experimental/client_state.py index d0028991c6..93405b29fb 100644 --- a/reflex/experimental/client_state.py +++ b/reflex/experimental/client_state.py @@ -110,7 +110,7 @@ def create(cls, var_name, default=None) -> "ClientStateVar": f"{_client_state_ref(setter_name)} = {setter_name}": None, }, imports={ - "react": {ImportVar(tag="useState", install=False)}, + "react": [ImportVar(tag="useState", install=False)], f"/{constants.Dirs.STATE_PATH}": [ImportVar(tag="refs")], }, ),