Skip to content

Commit

Permalink
wip connection toaster (#3242)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
Lendemor and masenf committed May 17, 2024
1 parent 99d5910 commit 9ba1794
Show file tree
Hide file tree
Showing 7 changed files with 215 additions and 10 deletions.
4 changes: 2 additions & 2 deletions reflex/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,14 @@
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 (
Component,
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,
Expand Down Expand Up @@ -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):
Expand Down
8 changes: 7 additions & 1 deletion reflex/components/core/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
73 changes: 71 additions & 2 deletions reflex/components/core/banner.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,25 @@
)
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
imports=Imports.EVENTS,
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,
Expand Down Expand Up @@ -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."""

Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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",
Expand Down
130 changes: 130 additions & 0 deletions reflex/components/core/banner.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions reflex/components/sonner/toast.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down
4 changes: 2 additions & 2 deletions reflex/components/sonner/toast.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion reflex/experimental/client_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")],
},
),
Expand Down

0 comments on commit 9ba1794

Please sign in to comment.