Переглянути джерело

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 рік тому
батько
коміт
9ba179410b

+ 2 - 2
reflex/app.py

@@ -41,7 +41,6 @@ from reflex.base import Base
 from reflex.compiler import compiler
 from reflex.compiler import compiler
 from reflex.compiler import utils as compiler_utils
 from reflex.compiler import utils as compiler_utils
 from reflex.compiler.compiler import ExecutorSafeFunctions
 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.app_wrap import AppWrap
 from reflex.components.base.fragment import Fragment
 from reflex.components.base.fragment import Fragment
 from reflex.components.component import (
 from reflex.components.component import (
@@ -49,6 +48,7 @@ from reflex.components.component import (
     ComponentStyle,
     ComponentStyle,
     evaluate_style_namespaces,
     evaluate_style_namespaces,
 )
 )
+from reflex.components.core import connection_pulser, connection_toaster
 from reflex.components.core.client_side_routing import (
 from reflex.components.core.client_side_routing import (
     Default404Page,
     Default404Page,
     wait_for_client_redirect,
     wait_for_client_redirect,
@@ -91,7 +91,7 @@ def default_overlay_component() -> Component:
     Returns:
     Returns:
         The default overlay_component, which is a connection_modal.
         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):
 class OverlayFragment(Fragment):

+ 7 - 1
reflex/components/core/__init__.py

@@ -1,7 +1,12 @@
 """Core Reflex components."""
 """Core Reflex components."""
 
 
 from . import layout as layout
 from . import layout as layout
-from .banner import ConnectionBanner, ConnectionModal, ConnectionPulser
+from .banner import (
+    ConnectionBanner,
+    ConnectionModal,
+    ConnectionPulser,
+    ConnectionToaster,
+)
 from .colors import color
 from .colors import color
 from .cond import Cond, color_mode_cond, cond
 from .cond import Cond, color_mode_cond, cond
 from .debounce import DebounceInput
 from .debounce import DebounceInput
@@ -26,6 +31,7 @@ from .upload import (
 
 
 connection_banner = ConnectionBanner.create
 connection_banner = ConnectionBanner.create
 connection_modal = ConnectionModal.create
 connection_modal = ConnectionModal.create
+connection_toaster = ConnectionToaster.create
 connection_pulser = ConnectionPulser.create
 connection_pulser = ConnectionPulser.create
 debounce_input = DebounceInput.create
 debounce_input = DebounceInput.create
 foreach = Foreach.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.layout import Flex
 from reflex.components.radix.themes.typography.text import Text
 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 import Dirs, Hooks, Imports
+from reflex.constants.compiler import CompileVars
 from reflex.utils import imports
 from reflex.utils import imports
+from reflex.utils.serializers import serialize
 from reflex.vars import Var, VarData
 from reflex.vars import Var, VarData
 
 
 connect_error_var_data: VarData = VarData(  # type: ignore
 connect_error_var_data: VarData = VarData(  # type: ignore
@@ -25,6 +28,13 @@ connect_error_var_data: VarData = VarData(  # type: ignore
     hooks={Hooks.EVENTS: None},
     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(
 connection_error: Var = Var.create_safe(
     value="(connectErrors.length > 0) ? connectErrors[connectErrors.length - 1].message : ''",
     value="(connectErrors.length > 0) ? connectErrors[connectErrors.length - 1].message : ''",
     _var_is_local=False,
     _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):
 class ConnectionBanner(Component):
     """A connection banner component."""
     """A connection banner component."""
 
 
@@ -162,8 +230,8 @@ class WifiOffPulse(Icon):
             size=props.pop("size", 32),
             size=props.pop("size", 32),
             z_index=props.pop("z_index", 9999),
             z_index=props.pop("z_index", 9999),
             position=props.pop("position", "fixed"),
             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),
             animation=Var.create(f"${{pulse}} 1s infinite", _var_is_string=True),
             **props,
             **props,
         )
         )
@@ -205,6 +273,7 @@ class ConnectionPulser(Div):
                 has_connection_errors,
                 has_connection_errors,
                 WifiOffPulse.create(**props),
                 WifiOffPulse.create(**props),
             ),
             ),
+            title=f"Connection Error: {connection_error}",
             position="fixed",
             position="fixed",
             width="100vw",
             width="100vw",
             height="0",
             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.layout import Flex
 from reflex.components.radix.themes.typography.text import Text
 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 import Dirs, Hooks, Imports
+from reflex.constants.compiler import CompileVars
 from reflex.utils import imports
 from reflex.utils import imports
+from reflex.utils.serializers import serialize
 from reflex.vars import Var, VarData
 from reflex.vars import Var, VarData
 
 
 connect_error_var_data: VarData
 connect_error_var_data: VarData
+connect_errors: Var
 connection_error: Var
 connection_error: Var
 connection_errors_count: Var
 connection_errors_count: Var
 has_connection_errors: Var
 has_connection_errors: Var
@@ -99,6 +103,132 @@ class WebsocketTargetURL(Bare):
 
 
 def default_connection_error() -> list[str | Var | Component]: ...
 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):
 class ConnectionBanner(Component):
     @overload
     @overload
     @classmethod
     @classmethod

+ 2 - 2
reflex/components/sonner/toast.py

@@ -2,7 +2,7 @@
 
 
 from __future__ import annotations
 from __future__ import annotations
 
 
-from typing import Any, Literal, Optional
+from typing import Any, Literal, Optional, Union
 
 
 from reflex.base import Base
 from reflex.base import Base
 from reflex.components.component import Component, ComponentNamespace
 from reflex.components.component import Component, ComponentNamespace
@@ -74,7 +74,7 @@ class ToastProps(PropsBase):
     """Props for the toast component."""
     """Props for the toast component."""
 
 
     # Toast's description, renders underneath the title.
     # Toast's description, renders underneath the title.
-    description: Optional[str]
+    description: Optional[Union[str, Var]]
 
 
     # Whether to show the close button.
     # Whether to show the close button.
     close_button: Optional[bool]
     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.vars import Var, BaseVar, ComputedVar
 from reflex.event import EventChain, EventHandler, EventSpec
 from reflex.event import EventChain, EventHandler, EventSpec
 from reflex.style import Style
 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.base import Base
 from reflex.components.component import Component, ComponentNamespace
 from reflex.components.component import Component, ComponentNamespace
 from reflex.components.lucide.icon import Icon
 from reflex.components.lucide.icon import Icon
@@ -37,7 +37,7 @@ class ToastAction(Base):
 def serialize_action(action: ToastAction) -> dict: ...
 def serialize_action(action: ToastAction) -> dict: ...
 
 
 class ToastProps(PropsBase):
 class ToastProps(PropsBase):
-    description: Optional[str]
+    description: Optional[Union[str, Var]]
     close_button: Optional[bool]
     close_button: Optional[bool]
     invert: Optional[bool]
     invert: Optional[bool]
     important: 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,
                         f"{_client_state_ref(setter_name)} = {setter_name}": None,
                     },
                     },
                     imports={
                     imports={
-                        "react": {ImportVar(tag="useState", install=False)},
+                        "react": [ImportVar(tag="useState", install=False)],
                         f"/{constants.Dirs.STATE_PATH}": [ImportVar(tag="refs")],
                         f"/{constants.Dirs.STATE_PATH}": [ImportVar(tag="refs")],
                     },
                     },
                 ),
                 ),