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>
This commit is contained in:
Thomas Brandého 2024-05-17 07:08:32 +02:00 committed by GitHub
parent 99d59104ad
commit 9ba179410b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 215 additions and 10 deletions

View File

@ -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):

View File

@ -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

View File

@ -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",

View File

@ -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

View File

@ -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]

View File

@ -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]

View File

@ -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")],
},
),