Ver código fonte

notifying frontend about backend error looks better (#3491)

Thomas Brandého 10 meses atrás
pai
commit
b9927b6f49

+ 20 - 2
reflex/app.py

@@ -112,11 +112,29 @@ def default_backend_exception_handler(exception: Exception) -> EventSpec:
         EventSpec: The window alert event.
         EventSpec: The window alert event.
 
 
     """
     """
+    from reflex.components.sonner.toast import Toaster, toast
+
     error = traceback.format_exc()
     error = traceback.format_exc()
 
 
     console.error(f"[Reflex Backend Exception]\n {error}\n")
     console.error(f"[Reflex Backend Exception]\n {error}\n")
 
 
-    return window_alert("An error occurred. See logs for details.")
+    error_message = (
+        ["Contact the website administrator."]
+        if is_prod_mode()
+        else [f"{type(exception).__name__}: {exception}.", "See logs for details."]
+    )
+    if Toaster.is_used:
+        return toast(
+            level="error",
+            title="An error occurred.",
+            description="<br/>".join(error_message),
+            position="top-center",
+            id="backend_error",
+            style={"width": "500px"},
+        )  # type: ignore
+    else:
+        error_message.insert(0, "An error occurred.")
+        return window_alert("\n".join(error_message))
 
 
 
 
 def default_overlay_component() -> Component:
 def default_overlay_component() -> Component:
@@ -183,7 +201,7 @@ class App(MiddlewareMixin, LifespanMixin, Base):
 
 
     # A component that is present on every page (defaults to the Connection Error banner).
     # A component that is present on every page (defaults to the Connection Error banner).
     overlay_component: Optional[Union[Component, ComponentCallable]] = (
     overlay_component: Optional[Union[Component, ComponentCallable]] = (
-        default_overlay_component
+        default_overlay_component()
     )
     )
 
 
     # Error boundary component to wrap the app with.
     # Error boundary component to wrap the app with.

+ 14 - 0
reflex/components/core/banner.py

@@ -153,6 +153,20 @@ useEffect(() => {{
             hook,
             hook,
         ]
         ]
 
 
+    @classmethod
+    def create(cls, *children, **props) -> Component:
+        """Create a connection toaster component.
+
+        Args:
+            *children: The children of the component.
+            **props: The properties of the component.
+
+        Returns:
+            The connection toaster component.
+        """
+        Toaster.is_used = True
+        return super().create(*children, **props)
+
 
 
 class ConnectionBanner(Component):
 class ConnectionBanner(Component):
     """A connection banner component."""
     """A connection banner component."""

+ 3 - 3
reflex/components/core/banner.pyi

@@ -187,7 +187,7 @@ class ConnectionToaster(Toaster):
         ] = None,
         ] = None,
         **props,
         **props,
     ) -> "ConnectionToaster":
     ) -> "ConnectionToaster":
-        """Create the component.
+        """Create a connection toaster component.
 
 
         Args:
         Args:
             *children: The children of the component.
             *children: The children of the component.
@@ -211,10 +211,10 @@ class ConnectionToaster(Toaster):
             class_name: The class name for the component.
             class_name: The class name for the component.
             autofocus: Whether the component should take the focus once the page is loaded
             autofocus: Whether the component should take the focus once the page is loaded
             custom_attrs: custom attribute
             custom_attrs: custom attribute
-            **props: The props of the component.
+            **props: The properties of the component.
 
 
         Returns:
         Returns:
-            The component.
+            The connection toaster component.
         """
         """
         ...
         ...
 
 

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

@@ -2,7 +2,7 @@
 
 
 from __future__ import annotations
 from __future__ import annotations
 
 
-from typing import Any, Literal, Optional, Union
+from typing import Any, ClassVar, 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
@@ -211,6 +211,9 @@ class Toaster(Component):
     # Pauses toast timers when the page is hidden, e.g., when the tab is backgrounded, the browser is minimized, or the OS is locked.
     # Pauses toast timers when the page is hidden, e.g., when the tab is backgrounded, the browser is minimized, or the OS is locked.
     pause_when_page_is_hidden: Var[bool]
     pause_when_page_is_hidden: Var[bool]
 
 
+    # Marked True when any Toast component is created.
+    is_used: ClassVar[bool] = False
+
     def add_hooks(self) -> list[Var | str]:
     def add_hooks(self) -> list[Var | str]:
         """Add hooks for the toaster component.
         """Add hooks for the toaster component.
 
 
@@ -231,7 +234,7 @@ class Toaster(Component):
         return [hook]
         return [hook]
 
 
     @staticmethod
     @staticmethod
-    def send_toast(message: str, level: str | None = None, **props) -> EventSpec:
+    def send_toast(message: str = "", level: str | None = None, **props) -> EventSpec:
         """Send a toast message.
         """Send a toast message.
 
 
         Args:
         Args:
@@ -239,10 +242,19 @@ class Toaster(Component):
             level: The level of the toast.
             level: The level of the toast.
             **props: The options for the toast.
             **props: The options for the toast.
 
 
+        Raises:
+            ValueError: If the Toaster component is not created.
+
         Returns:
         Returns:
             The toast event.
             The toast event.
         """
         """
+        if not Toaster.is_used:
+            raise ValueError(
+                "Toaster component must be created before sending a toast. (use `rx.toast.provider()`)"
+            )
         toast_command = f"{toast_ref}.{level}" if level is not None else toast_ref
         toast_command = f"{toast_ref}.{level}" if level is not None else toast_ref
+        if message == "" and ("title" not in props or "description" not in props):
+            raise ValueError("Toast message or title or description must be provided.")
         if props:
         if props:
             args = serialize(ToastProps(**props))  # type: ignore
             args = serialize(ToastProps(**props))  # type: ignore
             toast = f"{toast_command}(`{message}`, {args})"
             toast = f"{toast_command}(`{message}`, {args})"
@@ -331,6 +343,20 @@ class Toaster(Component):
         )
         )
         return call_script(dismiss_action)
         return call_script(dismiss_action)
 
 
+    @classmethod
+    def create(cls, *children, **props) -> Component:
+        """Create a toaster component.
+
+        Args:
+            *children: The children of the toaster.
+            **props: The properties of the toaster.
+
+        Returns:
+            The toaster component.
+        """
+        cls.is_used = True
+        return super().create(*children, **props)
+
 
 
 # TODO: figure out why loading toast stay open forever
 # TODO: figure out why loading toast stay open forever
 # def toast_loading(message: str, **kwargs):
 # def toast_loading(message: str, **kwargs):

+ 14 - 7
reflex/components/sonner/toast.pyi

@@ -3,7 +3,7 @@
 # ------------------- DO NOT EDIT ----------------------
 # ------------------- DO NOT EDIT ----------------------
 # This file was generated by `reflex/utils/pyi_generator.py`!
 # This file was generated by `reflex/utils/pyi_generator.py`!
 # ------------------------------------------------------
 # ------------------------------------------------------
-from typing import Any, Callable, Dict, Literal, Optional, Union, overload
+from typing import Any, Callable, ClassVar, Dict, Literal, Optional, Union, overload
 
 
 from reflex.base import Base
 from reflex.base import Base
 from reflex.components.component import Component, ComponentNamespace
 from reflex.components.component import Component, ComponentNamespace
@@ -52,9 +52,13 @@ class ToastProps(PropsBase):
     def dict(self, *args, **kwargs) -> dict[str, Any]: ...
     def dict(self, *args, **kwargs) -> dict[str, Any]: ...
 
 
 class Toaster(Component):
 class Toaster(Component):
+    is_used: ClassVar[bool] = False
+
     def add_hooks(self) -> list[Var | str]: ...
     def add_hooks(self) -> list[Var | str]: ...
     @staticmethod
     @staticmethod
-    def send_toast(message: str, level: str | None = None, **props) -> EventSpec: ...
+    def send_toast(
+        message: str = "", level: str | None = None, **props
+    ) -> EventSpec: ...
     @staticmethod
     @staticmethod
     def toast_info(message: str, **kwargs): ...
     def toast_info(message: str, **kwargs): ...
     @staticmethod
     @staticmethod
@@ -158,10 +162,10 @@ class Toaster(Component):
         ] = None,
         ] = None,
         **props,
         **props,
     ) -> "Toaster":
     ) -> "Toaster":
-        """Create the component.
+        """Create a toaster component.
 
 
         Args:
         Args:
-            *children: The children of the component.
+            *children: The children of the toaster.
             theme: the theme of the toast
             theme: the theme of the toast
             rich_colors: whether to show rich colors
             rich_colors: whether to show rich colors
             expand: whether to expand the toast
             expand: whether to expand the toast
@@ -182,10 +186,10 @@ class Toaster(Component):
             class_name: The class name for the component.
             class_name: The class name for the component.
             autofocus: Whether the component should take the focus once the page is loaded
             autofocus: Whether the component should take the focus once the page is loaded
             custom_attrs: custom attribute
             custom_attrs: custom attribute
-            **props: The props of the component.
+            **props: The properties of the toaster.
 
 
         Returns:
         Returns:
-            The component.
+            The toaster component.
         """
         """
         ...
         ...
 
 
@@ -200,7 +204,7 @@ class ToastNamespace(ComponentNamespace):
 
 
     @staticmethod
     @staticmethod
     def __call__(
     def __call__(
-        message: str, level: Optional[str] = None, **props
+        message: str = "", level: Optional[str] = None, **props
     ) -> "Optional[EventSpec]":
     ) -> "Optional[EventSpec]":
         """Send a toast message.
         """Send a toast message.
 
 
@@ -209,6 +213,9 @@ class ToastNamespace(ComponentNamespace):
             level: The level of the toast.
             level: The level of the toast.
             **props: The options for the toast.
             **props: The options for the toast.
 
 
+        Raises:
+            ValueError: If the Toaster component is not created.
+
         Returns:
         Returns:
             The toast event.
             The toast event.
         """
         """

+ 2 - 1
reflex/state.py

@@ -31,6 +31,8 @@ from typing import (
 import dill
 import dill
 from sqlalchemy.orm import DeclarativeBase
 from sqlalchemy.orm import DeclarativeBase
 
 
+from reflex.config import get_config
+
 try:
 try:
     import pydantic.v1 as pydantic
     import pydantic.v1 as pydantic
 except ModuleNotFoundError:
 except ModuleNotFoundError:
@@ -42,7 +44,6 @@ from redis.exceptions import ResponseError
 
 
 from reflex import constants
 from reflex import constants
 from reflex.base import Base
 from reflex.base import Base
-from reflex.config import get_config
 from reflex.event import (
 from reflex.event import (
     BACKGROUND_TASK_MARKER,
     BACKGROUND_TASK_MARKER,
     Event,
     Event,

+ 24 - 5
tests/test_state.py

@@ -19,6 +19,7 @@ import reflex.config
 from reflex import constants
 from reflex import constants
 from reflex.app import App
 from reflex.app import App
 from reflex.base import Base
 from reflex.base import Base
+from reflex.components.sonner.toast import Toaster
 from reflex.constants import CompileVars, RouteVar, SocketEvent
 from reflex.constants import CompileVars, RouteVar, SocketEvent
 from reflex.event import Event, EventHandler
 from reflex.event import Event, EventHandler
 from reflex.state import (
 from reflex.state import (
@@ -1527,7 +1528,6 @@ async def test_state_with_invalid_yield(capsys, mock_app):
     Args:
     Args:
         capsys: Pytest fixture for capture standard streams.
         capsys: Pytest fixture for capture standard streams.
         mock_app: Mock app fixture.
         mock_app: Mock app fixture.
-
     """
     """
 
 
     class StateWithInvalidYield(BaseState):
     class StateWithInvalidYield(BaseState):
@@ -1546,10 +1546,29 @@ async def test_state_with_invalid_yield(capsys, mock_app):
         rx.event.Event(token="fake_token", name="invalid_handler")
         rx.event.Event(token="fake_token", name="invalid_handler")
     ):
     ):
         assert not update.delta
         assert not update.delta
-        assert update.events == rx.event.fix_events(
-            [rx.window_alert("An error occurred. See logs for details.")],
-            token="",
-        )
+        if Toaster.is_used:
+            assert update.events == rx.event.fix_events(
+                [
+                    rx.toast(
+                        title="An error occurred.",
+                        description="TypeError: Your handler test_state_with_invalid_yield.<locals>.StateWithInvalidYield.invalid_handler must only return/yield: None, Events or other EventHandlers referenced by their class (not using `self`).<br/>See logs for details.",
+                        level="error",
+                        id="backend_error",
+                        position="top-center",
+                        style={"width": "500px"},
+                    )  # type: ignore
+                ],
+                token="",
+            )
+        else:
+            assert update.events == rx.event.fix_events(
+                [
+                    rx.window_alert(
+                        "An error occurred.\nContact the website administrator."
+                    )
+                ],
+                token="",
+            )
     captured = capsys.readouterr()
     captured = capsys.readouterr()
     assert "must only return/yield: None, Events or other EventHandlers" in captured.out
     assert "must only return/yield: None, Events or other EventHandlers" in captured.out