From e53b3227049d128b2ae8ea96483e8eb4ca57904e Mon Sep 17 00:00:00 2001 From: dgodinez-dh <77981300+dgodinez-dh@users.noreply.github.com> Date: Tue, 26 Nov 2024 08:27:07 -0700 Subject: [PATCH] feat: Toast Implementation (#1030) closes https://github.com/deephaven/deephaven-plugins/issues/410 closes https://github.com/deephaven/deephaven-plugins/issues/874 --------- Co-authored-by: Mike Bender --- plugins/ui/docs/components/toast.md | 231 ++++++++++++++++++ plugins/ui/docs/sidebar.json | 4 + .../deephaven/ui/_internal/EventContext.py | 89 +++++++ .../ui/_internal/NoContextException.py | 2 + .../deephaven/ui/_internal/RenderContext.py | 5 +- .../ui/src/deephaven/ui/_internal/__init__.py | 5 + .../src/deephaven/ui/components/__init__.py | 2 + .../ui/src/deephaven/ui/components/toast.py | 41 ++++ plugins/ui/src/deephaven/ui/hooks/__init__.py | 2 + .../src/deephaven/ui/hooks/use_send_event.py | 12 + .../ui/object_types/ElementMessageStream.py | 69 ++++-- .../deephaven/ui/object_types/EventEncoder.py | 38 +++ plugins/ui/src/deephaven/ui/types/types.py | 3 + plugins/ui/src/js/src/events/Toast.ts | 33 +++ .../ui/src/js/src/widget/WidgetHandler.tsx | 41 +++- plugins/ui/src/js/src/widget/WidgetTypes.ts | 3 + 16 files changed, 560 insertions(+), 20 deletions(-) create mode 100644 plugins/ui/docs/components/toast.md create mode 100644 plugins/ui/src/deephaven/ui/_internal/EventContext.py create mode 100644 plugins/ui/src/deephaven/ui/_internal/NoContextException.py create mode 100644 plugins/ui/src/deephaven/ui/components/toast.py create mode 100644 plugins/ui/src/deephaven/ui/hooks/use_send_event.py create mode 100644 plugins/ui/src/deephaven/ui/object_types/EventEncoder.py create mode 100644 plugins/ui/src/js/src/events/Toast.ts diff --git a/plugins/ui/docs/components/toast.md b/plugins/ui/docs/components/toast.md new file mode 100644 index 000000000..37b68fd8c --- /dev/null +++ b/plugins/ui/docs/components/toast.md @@ -0,0 +1,231 @@ +# Toast + +Toasts display brief, temporary notifications of actions, errors, or other events in an application. + +## Example + +```python +from deephaven import ui + +btn = ui.button( + "Show toast", + on_press=lambda: ui.toast("Toast is done!"), + variant="primary", +) +``` + +## Content + +Toasts are triggered using the method `ui.toast`. Toasts use `variant` to specify the following styles: `neutral`, `positive`, `negative`, and `info`. Toast will default to `neutral` if `variant` is omitted. + +Toasts are shown according to the order they are added, with the most recent toast appearing at the bottom of the stack. Please use Toasts sparingly. + +```python +from deephaven import ui + +toasts = ui.button_group( + ui.button( + "Show neutral toast", + on_press=lambda: ui.toast("Toast available", variant="neutral"), + variant="secondary", + ), + ui.button( + "Show positive toast", + on_press=lambda: ui.toast("Toast is done!", variant="positive"), + variant="primary", + ), + ui.button( + "Show negative toast", + on_press=lambda: ui.toast("Toast is burned!", variant="negative"), + variant="negative", + ), + ui.button( + "Show info toast", + on_press=lambda: ui.toast("Toasting...", variant="info"), + variant="accent", + style="outline", + ), +) +``` + +## Events + +Toasts can include an optional action by specifying the `action_label` and `on_action` options when queueing a toast. In addition, the `on_close` event is triggered when the toast is dismissed. The `should_close_on_action` option automatically closes the toast when an action is performed. + +```python +from deephaven import ui + + +btn = ui.button( + "Show toast", + on_press=lambda: ui.toast( + "An update is available", + action_label="Update", + on_action=lambda: print("Updating!"), + should_close_on_action=True, + on_close=lambda: print("Closed"), + variant="positive", + ), + variant="primary", +) +``` + +## Auto-dismiss + +Toasts support a `timeout` option to automatically hide them after a certain amount of time. For accessibility, toasts have a minimum `timeout` of 5 seconds, and actionable toasts will not auto dismiss. In addition, timers will pause when the user focuses or hovers over a toast. + +Be sure only to automatically dismiss toasts when the information is not important, or may be found elsewhere. Some users may require additional time to read a toast message, and screen zoom users may miss toasts entirely. + +```python +from deephaven import ui + + +btn = ui.button( + "Show toast", + on_press=lambda: ui.toast("Toast is done!", timeout=5000, variant="positive"), + variant="primary", +) +``` + +## Show toast on mount + +This example shows how to display a toast when a component mounts. + +```python +from deephaven import ui + + +@ui.component +def ui_toast_on_mount(): + ui.toast("Mounted.", variant="info") + return ui.heading("Toast was shown on mount.") + + +my_mount_example = ui_toast_on_mount() +``` + +## Toast from table example + +This example shows how to create a toast from the latest update of a ticking table. It is recommended to auto dismiss these toasts with a `timeout` and to avoid ticking faster than the value of the `timeout`. + +```python +from deephaven import time_table +from deephaven import ui + +_source = time_table("PT5S").update("X = i").tail(5) + + +@ui.component +def toast_table(t): + render_queue = ui.use_render_queue() + + def listener_function(update, is_replay): + data_added = update.added()["X"][0] + render_queue(lambda: ui.toast(f"added {data_added}", timeout=5000)) + + ui.use_table_listener(t, listener_function, [t]) + return t + + +my_toast_table = toast_table(_source) +``` + +# Multi threading example + +This example shows how to use toast with multi threading. + +```python +import threading +from deephaven import read_csv, ui + + +@ui.component +def csv_loader(): + # The render_queue we fetch using the `use_render_queue` hook at the top of the component + render_queue = ui.use_render_queue() + table, set_table = ui.use_state() + error, set_error = ui.use_state() + + def handle_submit(data): + # We define a callable that we'll queue up on our own thread + def load_table(): + try: + # Read the table from the URL + t = read_csv(data["url"]) + + # Define our state updates in another callable. We'll need to call this on the render thread + def update_state(): + set_error(None) + set_table(t) + ui.toast("Table loaded", variant="positive", timeout=5000) + + # Queue up the state update on the render thread + render_queue(update_state) + except Exception as e: + # In case we have any errors, we should show the error to the user. We still need to call this from the render thread, + # so we must assign the exception to a variable and call the render_queue with a callable that will set the error + error_message = e + + def update_state(): + set_table(None) + set_error(error_message) + ui.toast( + f"Unable to load table: {error_message}", + variant="negative", + timeout=5000, + ) + + # Queue up the state update on the render thread + render_queue(update_state) + + # Start our own thread loading the table + threading.Thread(target=load_table).start() + + return [ + # Our form displaying input from the user + ui.form( + ui.flex( + ui.text_field( + default_value="https://media.githubusercontent.com/media/deephaven/examples/main/DeNiro/csv/deniro.csv", + label="Enter URL", + label_position="side", + name="url", + flex_grow=1, + ), + ui.button(f"Load Table", type="submit"), + gap=10, + ), + on_submit=handle_submit, + ), + ( + # Display a hint if the table is not loaded yet and we don't have an error + ui.illustrated_message( + ui.heading("Enter URL above"), + ui.content("Enter a URL of a CSV above and click 'Load' to load it"), + ) + if error is None and table is None + else None + ), + # The loaded table. Doesn't show anything if it is not loaded yet + table, + # An error message if there is an error + ( + ui.illustrated_message( + ui.icon("vsWarning"), + ui.heading("Error loading table"), + ui.content(f"{error}"), + ) + if error != None + else None + ), + ] + + +my_loader = csv_loader() +``` + +## API Reference + +```{eval-rst} +.. dhautofunction:: deephaven.ui.toast +``` diff --git a/plugins/ui/docs/sidebar.json b/plugins/ui/docs/sidebar.json index 9bb422216..2bcf34c54 100644 --- a/plugins/ui/docs/sidebar.json +++ b/plugins/ui/docs/sidebar.json @@ -205,6 +205,10 @@ "label": "time_field", "path": "components/time_field.md" }, + { + "label": "toast", + "path": "components/toast.md" + }, { "label": "toggle_button", "path": "components/toggle_button.md" diff --git a/plugins/ui/src/deephaven/ui/_internal/EventContext.py b/plugins/ui/src/deephaven/ui/_internal/EventContext.py new file mode 100644 index 000000000..6fdad81aa --- /dev/null +++ b/plugins/ui/src/deephaven/ui/_internal/EventContext.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import threading +from typing import ( + Any, + Callable, + Dict, + Optional, + Generator, +) +from contextlib import contextmanager +from .NoContextException import NoContextException + +OnEventCallable = Callable[[str, Dict[str, Any]], None] +""" +Callable that is called when an event is queued up. +""" + +_local_data = threading.local() + + +def get_event_context() -> EventContext: + """ + Gets the currently active context, or throws NoContextException if none is set. + + Returns: + The active EventContext, or throws if none is present. + """ + try: + return _local_data.event_context + except AttributeError: + raise NoContextException("No context set") + + +def _set_event_context(context: Optional[EventContext]): + """ + Set the current context for the thread. Can be set to None to unset the context for a thread. + """ + if context is None: + del _local_data.event_context + else: + _local_data.event_context = context + + +class EventContext: + _on_send_event: OnEventCallable + """ + The callback to call when sending an event. + """ + + def __init__( + self, + on_send_event: OnEventCallable, + ): + """ + Create a new event context. + + Args: + on_send_event: The callback to call when sending an event. + """ + + self._on_send_event = on_send_event + + @contextmanager + def open(self) -> Generator[EventContext, None, None]: + """ + Opens this context. + + Returns: + A context manager to manage EventContext resources. + """ + old_context: Optional[EventContext] = None + try: + old_context = get_event_context() + except NoContextException: + pass + _set_event_context(self) + yield self + _set_event_context(old_context) + + def send_event(self, name: str, params: Dict[str, Any]) -> None: + """ + Send an event to the client. + + Args: + name: The name of the event. + params: The params of the event. + """ + self._on_send_event(name, params) diff --git a/plugins/ui/src/deephaven/ui/_internal/NoContextException.py b/plugins/ui/src/deephaven/ui/_internal/NoContextException.py new file mode 100644 index 000000000..b3ff96057 --- /dev/null +++ b/plugins/ui/src/deephaven/ui/_internal/NoContextException.py @@ -0,0 +1,2 @@ +class NoContextException(Exception): + pass diff --git a/plugins/ui/src/deephaven/ui/_internal/RenderContext.py b/plugins/ui/src/deephaven/ui/_internal/RenderContext.py index bc6b3516e..3a2a797b3 100644 --- a/plugins/ui/src/deephaven/ui/_internal/RenderContext.py +++ b/plugins/ui/src/deephaven/ui/_internal/RenderContext.py @@ -20,6 +20,7 @@ from deephaven.liveness_scope import LivenessScope from contextlib import contextmanager from dataclasses import dataclass +from .NoContextException import NoContextException logger = logging.getLogger(__name__) @@ -127,10 +128,6 @@ def _should_retain_value(value: ValueWithLiveness[T | None]) -> bool: _local_data = threading.local() -class NoContextException(Exception): - pass - - def get_context() -> RenderContext: """ Gets the currently active context, or throws NoContextException if none is set. diff --git a/plugins/ui/src/deephaven/ui/_internal/__init__.py b/plugins/ui/src/deephaven/ui/_internal/__init__.py index bb9fd353f..c5e6cc59a 100644 --- a/plugins/ui/src/deephaven/ui/_internal/__init__.py +++ b/plugins/ui/src/deephaven/ui/_internal/__init__.py @@ -1,3 +1,8 @@ +from .EventContext import ( + EventContext, + OnEventCallable, + get_event_context, +) from .RenderContext import ( RenderContext, StateKey, diff --git a/plugins/ui/src/deephaven/ui/components/__init__.py b/plugins/ui/src/deephaven/ui/components/__init__.py index 3e5377487..4c73da134 100644 --- a/plugins/ui/src/deephaven/ui/components/__init__.py +++ b/plugins/ui/src/deephaven/ui/components/__init__.py @@ -61,6 +61,7 @@ from .text_area import text_area from .text_field import text_field from .time_field import time_field +from .toast import toast from .toggle_button import toggle_button from .view import view @@ -132,6 +133,7 @@ "text_area", "text_field", "time_field", + "toast", "toggle_button", "view", ] diff --git a/plugins/ui/src/deephaven/ui/components/toast.py b/plugins/ui/src/deephaven/ui/components/toast.py new file mode 100644 index 000000000..72ecfc289 --- /dev/null +++ b/plugins/ui/src/deephaven/ui/components/toast.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from ..hooks import use_send_event + +from typing import Callable +from .._internal.utils import dict_to_react_props +from ..types import ToastVariant + +_TOAST_EVENT = "toast.event" + + +def toast( + message: str, + *, + variant: ToastVariant = "neutral", + action_label: str | None = None, + on_action: Callable[[], None] | None = None, + should_close_on_action: bool | None = None, + on_close: Callable[[], None] | None = None, + timeout: int | None = None, + id: str | None = None, +) -> None: + """ + Toasts display brief, temporary notifications of actions, errors, or other events in an application. + + Args: + message: The message to display in the toast. + variant: The variant of the toast. Defaults to "neutral". + action_label: The label for the action button with the toast. If provided, an action button will be displayed. + on_action: Handler that is called when the action button is pressed. + should_close_on_action: Whether the toast should automatically close when an action is performed. + on_close: Handler that is called when the toast is closed, either by the user or after a timeout. + timeout: A timeout to automatically close the toast after, in milliseconds. + id: The element's unique identifier. + + Returns: + None + """ + params = dict_to_react_props(locals()) + send_event = use_send_event() + send_event(_TOAST_EVENT, params) diff --git a/plugins/ui/src/deephaven/ui/hooks/__init__.py b/plugins/ui/src/deephaven/ui/hooks/__init__.py index 4401ed101..2591820a2 100644 --- a/plugins/ui/src/deephaven/ui/hooks/__init__.py +++ b/plugins/ui/src/deephaven/ui/hooks/__init__.py @@ -1,5 +1,6 @@ from .use_callback import use_callback from .use_effect import use_effect +from .use_send_event import use_send_event from .use_memo import use_memo from .use_state import use_state from .use_ref import use_ref @@ -18,6 +19,7 @@ __all__ = [ "use_callback", "use_effect", + "use_send_event", "use_memo", "use_state", "use_ref", diff --git a/plugins/ui/src/deephaven/ui/hooks/use_send_event.py b/plugins/ui/src/deephaven/ui/hooks/use_send_event.py new file mode 100644 index 000000000..6ae7e7797 --- /dev/null +++ b/plugins/ui/src/deephaven/ui/hooks/use_send_event.py @@ -0,0 +1,12 @@ +from .._internal import OnEventCallable, get_event_context + + +def use_send_event() -> OnEventCallable: + """ + Returns a callback function for sending an event. + + Returns: + A callback function that sends an event. + """ + context = get_event_context() + return context.send_event diff --git a/plugins/ui/src/deephaven/ui/object_types/ElementMessageStream.py b/plugins/ui/src/deephaven/ui/object_types/ElementMessageStream.py index 7b2d9c10a..e3ec5744e 100644 --- a/plugins/ui/src/deephaven/ui/object_types/ElementMessageStream.py +++ b/plugins/ui/src/deephaven/ui/object_types/ElementMessageStream.py @@ -20,7 +20,13 @@ from ..elements import Element from ..renderer import NodeEncoder, Renderer, RenderedNode from ..renderer.NodeEncoder import CALLABLE_KEY -from .._internal import RenderContext, StateUpdateCallable, ExportedRenderState +from .._internal import ( + RenderContext, + StateUpdateCallable, + ExportedRenderState, + EventContext, +) +from .EventEncoder import EventEncoder from .ErrorCode import ErrorCode logger = logging.getLogger(__name__) @@ -63,6 +69,11 @@ class ElementMessageStream(MessageStream): Encoder to use to encode the document. """ + _event_encoder: EventEncoder + """ + Encoder to use to encode events. + """ + _message_id: int """ The next message ID to use. @@ -83,6 +94,11 @@ class ElementMessageStream(MessageStream): Render context for this element """ + _event_context: EventContext + """ + Event context for this element + """ + _renderer: Renderer """ Renderer for this element @@ -162,7 +178,11 @@ def __init__(self, element: Element, connection: MessageStream): self._manager = JSONRPCResponseManager() self._dispatcher = self._make_dispatcher() self._encoder = NodeEncoder(separators=(",", ":")) + self._event_encoder = EventEncoder( + self._serialize_callables, separators=(",", ":") + ) self._context = RenderContext(self._queue_state_update, self._queue_callable) + self._event_context = EventContext(self._send_event) self._renderer = Renderer(self._context) self._update_queue = Queue() self._callable_queue = Queue() @@ -203,7 +223,7 @@ def _process_callable_queue(self) -> None: Process any queued callables, then re-renders the element if it is dirty. """ try: - with self._exec_context: + with self._exec_context, self._event_context.open(): with self._render_lock: self._render_thread = threading.current_thread() self._render_state = _RenderState.RENDERING @@ -372,6 +392,24 @@ def _set_state(self, state: ExportedRenderState) -> None: self._context.import_state(state) self._mark_dirty() + def _serialize_callables(self, node: Any) -> Any: + """ + Serialize a callable. + + Args: + node: The node to serialize + """ + if callable(node): + new_id = f"tempCb{self._next_temp_callable_id}" + self._next_temp_callable_id += 1 + self._temp_callable_dict[new_id] = node + return { + CALLABLE_KEY: new_id, + } + raise TypeError( + f"A Deephaven UI callback returned a non-serializable value. Object of type {type(node).__name__} is not JSON serializable" + ) + def _call_callable(self, callable_id: str, args: Any) -> Any: """ Call a callable by its ID. @@ -390,20 +428,8 @@ def _call_callable(self, callable_id: str, args: Any) -> Any: return result = fn(*args) - def serialize_callables(node: Any) -> Any: - if callable(node): - new_id = f"tempCb{self._next_temp_callable_id}" - self._next_temp_callable_id += 1 - self._temp_callable_dict[new_id] = node - return { - CALLABLE_KEY: new_id, - } - raise TypeError( - f"A Deephaven UI callback returned a non-serializable value. Object of type {type(node).__name__} is not JSON serializable" - ) - try: - return json.dumps(result, default=serialize_callables) + return json.dumps(result, default=self._serialize_callables) except Exception as e: # This is shown to the user in the Python console # The stack trace from logger.exception is useless to the user @@ -481,3 +507,16 @@ def _send_document_error(self, error: Exception, stack_trace: str) -> None: ) payload = json.dumps(request) self._connection.on_data(payload.encode(), []) + + def _send_event(self, name: str, params: dict[str, Any]) -> None: + """ + Send an event to the client. + + Args: + name: The name of the event + params: The params of the event + """ + encoded_params = self._event_encoder.encode(params) + request = self._make_notification("event", name, encoded_params) + payload = json.dumps(request) + self._connection.on_data(payload.encode(), []) diff --git a/plugins/ui/src/deephaven/ui/object_types/EventEncoder.py b/plugins/ui/src/deephaven/ui/object_types/EventEncoder.py new file mode 100644 index 000000000..b65f71a93 --- /dev/null +++ b/plugins/ui/src/deephaven/ui/object_types/EventEncoder.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import json +from typing import Any, Callable + + +class EventEncoder(json.JSONEncoder): + """ + Encode an event in JSON. + """ + + _convert_callable: Callable[[Any], Any] + """ + Function that will be called to serialize callables. + """ + + def __init__( + self, + convert_callable: Callable[[Any], Any], + *args: Any, + **kwargs: Any, + ): + """ + Create a new EventEncoder. + + Args: + convert_callable: A function that will be called to serialize callables + *args: Arguments to pass to the JSONEncoder constructor + **kwargs: Args to pass to the JSONEncoder constructor + """ + super().__init__(*args, **kwargs) + self._convert_callable = convert_callable + + def default(self, o: Any): + if callable(o): + return self._convert_callable(o) + else: + return super().default(o) diff --git a/plugins/ui/src/deephaven/ui/types/types.py b/plugins/ui/src/deephaven/ui/types/types.py index c8eeee38c..329e3797d 100644 --- a/plugins/ui/src/deephaven/ui/types/types.py +++ b/plugins/ui/src/deephaven/ui/types/types.py @@ -559,3 +559,6 @@ class DateRange(TypedDict): """ End value for the date range. """ + + +ToastVariant = Literal["positive", "negative", "neutral", "info"] diff --git a/plugins/ui/src/js/src/events/Toast.ts b/plugins/ui/src/js/src/events/Toast.ts new file mode 100644 index 000000000..c9e68c48e --- /dev/null +++ b/plugins/ui/src/js/src/events/Toast.ts @@ -0,0 +1,33 @@ +import { ToastQueue, ToastOptions } from '@deephaven/components'; + +export const TOAST_EVENT = 'toast.event'; + +export type ToastVariant = 'positive' | 'negative' | 'neutral' | 'info'; + +export type ToastParams = ToastOptions & { + message: string; + variant: ToastVariant; +}; + +export function Toast(params: ToastParams): void { + const { message, variant, ...options } = params; + + switch (variant) { + case 'positive': + ToastQueue.positive(message, options); + break; + case 'negative': + ToastQueue.negative(message, options); + break; + case 'neutral': + ToastQueue.neutral(message, options); + break; + case 'info': + ToastQueue.info(message, options); + break; + default: + throw new Error(`Unknown toast variant: ${variant}`); + } +} + +export default Toast; diff --git a/plugins/ui/src/js/src/widget/WidgetHandler.tsx b/plugins/ui/src/js/src/widget/WidgetHandler.tsx index c4b518605..3ebe4d1a1 100644 --- a/plugins/ui/src/js/src/widget/WidgetHandler.tsx +++ b/plugins/ui/src/js/src/widget/WidgetHandler.tsx @@ -35,6 +35,7 @@ import { WidgetError, METHOD_DOCUMENT_ERROR, METHOD_DOCUMENT_UPDATED, + METHOD_EVENT, } from './WidgetTypes'; import DocumentHandler from './DocumentHandler'; import { @@ -47,6 +48,7 @@ import WidgetStatusContext, { } from '../layout/WidgetStatusContext'; import WidgetErrorView from './WidgetErrorView'; import ReactPanel from '../layout/ReactPanel'; +import Toast, { TOAST_EVENT } from '../events/Toast'; const log = Log.module('@deephaven/js-plugin-ui/WidgetHandler'); @@ -298,11 +300,48 @@ function WidgetHandler({ }); }); + jsonClient.addMethod(METHOD_EVENT, (params: [string, string]) => { + log.debug2(METHOD_EVENT, params); + const [name, payload] = params; + try { + const eventParams = JSON.parse(payload, (_, value) => { + // Need to re-hydrate any callables that are defined + if (isCallableNode(value)) { + const callableId = value[CALLABLE_KEY]; + log.debug2('Registering callableId', callableId); + return wrapCallable( + jsonClient, + callableId, + callableFinalizationRegistry + ); + } + return value; + }); + switch (name) { + case TOAST_EVENT: + Toast(eventParams); + break; + default: + throw new Error(`Unknown event ${name}`); + } + } catch (e) { + throw new Error( + `Error parsing event ${name} with payload ${payload}: ${e}` + ); + } + }); + return () => { jsonClient.rejectAllPendingRequests('Widget was changed'); }; }, - [jsonClient, onDataChange, parseDocument, sendSetState] + [ + jsonClient, + onDataChange, + parseDocument, + sendSetState, + callableFinalizationRegistry, + ] ); /** diff --git a/plugins/ui/src/js/src/widget/WidgetTypes.ts b/plugins/ui/src/js/src/widget/WidgetTypes.ts index 70388541d..23a8d58f5 100644 --- a/plugins/ui/src/js/src/widget/WidgetTypes.ts +++ b/plugins/ui/src/js/src/widget/WidgetTypes.ts @@ -71,3 +71,6 @@ export const METHOD_DOCUMENT_UPDATED = 'documentUpdated'; /** Message containing a document error */ export const METHOD_DOCUMENT_ERROR = 'documentError'; + +/** Message containing an event */ +export const METHOD_EVENT = 'event';