Browse Source

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 <m_github@0x26.net>
Thomas Brandého 1 year ago
parent
commit
9ba179410b

+ 2 - 2
reflex/app.py

@@ -41,7 +41,6 @@ from reflex.base import Base
 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 @@ from reflex.components.component import (
     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):

+ 7 - 1
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 @@ from .upload import (
 
 connection_banner = ConnectionBanner.create
 connection_modal = ConnectionModal.create
+connection_toaster = ConnectionToaster.create
 connection_pulser = ConnectionPulser.create
 debounce_input = DebounceInput.create
 foreach = Foreach.create

+ 71 - 2
reflex/components/core/banner.py

@@ -16,8 +16,11 @@ 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 = VarData(  # type: ignore
@@ -25,6 +28,13 @@ connect_error_var_data: VarData = VarData(  # type: ignore
     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 @@ class WifiOffPulse(Icon):
             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 @@ class ConnectionPulser(Div):
                 has_connection_errors,
                 WifiOffPulse.create(**props),
             ),
+            title=f"Connection Error: {connection_error}",
             position="fixed",
             width="100vw",
             height="0",

+ 130 - 0
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

+ 2 - 2
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]

+ 2 - 2
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]

+ 1 - 1
reflex/experimental/client_state.py

@@ -110,7 +110,7 @@ class ClientStateVar(Var):
                         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")],
                     },
                 ),