Skip to content
Open
2 changes: 2 additions & 0 deletions plugins/ui/docs/components/toast.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

Toasts display brief, temporary notifications of actions, errors, or other events in an application.

`ui.toast` must be called from the render thread, either while a `@ui.component` is rendering or from an event handler it triggers. Calling it from a background thread, such as a table listener, raises an error. To show a toast from off the render thread, queue it with the [`use_render_queue` hook](../hooks/use_render_queue.md). See [render cycle](../add-interactivity/render-cycle.md) for more details on how rendering works.

## Example

```python
Expand Down
2 changes: 1 addition & 1 deletion plugins/ui/src/deephaven/ui/components/toast.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from .._internal.EventContext import NoContextException
from ..types import ToastVariant

_TOAST_EVENT = "toast.event"
_TOAST_EVENT = "deephaven.ui.toast"


class ToastException(NoContextException):
Expand Down
3 changes: 3 additions & 0 deletions plugins/ui/src/deephaven/ui/hooks/_navigate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from __future__ import annotations

NAVIGATE_EVENT = "deephaven.ui.navigate"
6 changes: 2 additions & 4 deletions plugins/ui/src/deephaven/ui/hooks/use_navigate.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,10 @@
from urllib.parse import urlencode, urlsplit

from ..types import QueryParamsInput
from ._navigate import NAVIGATE_EVENT
from .use_send_event import use_send_event


_NAVIGATE_EVENT = "navigate.event"


def _normalize_path(path: str | None) -> str | None:
"""
Normalize a path: None passthrough, reject empty, prepend /.
Expand Down Expand Up @@ -170,6 +168,6 @@ def navigate(
)

payload = build_navigate_payload(path, query_params, fragment, replace)
send_event(_NAVIGATE_EVENT, payload)
send_event(NAVIGATE_EVENT, payload)

return navigate
6 changes: 2 additions & 4 deletions plugins/ui/src/deephaven/ui/hooks/use_set_query_param.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,11 @@
from urllib.parse import urlencode

from ..types import QueryParams
from ._navigate import NAVIGATE_EVENT
from .use_query_params import use_query_params
from .use_send_event import use_send_event


_NAVIGATE_EVENT = "navigate.event"


def _query_params_to_query_string(query_params: QueryParams) -> str:
"""
Convert a `QueryParams` dict to a URL query string.
Expand Down Expand Up @@ -89,6 +87,6 @@ def setter(value: None | str | list[str] = None, replace: bool = True) -> None:
)

payload = _build_navigate_payload(new_params, replace)
send_event(_NAVIGATE_EVENT, payload)
send_event(NAVIGATE_EVENT, payload)

return setter
43 changes: 43 additions & 0 deletions plugins/ui/src/js/src/elements/utils/EventUtils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
import { EMPTY_MAP } from '@deephaven/utils';
import Toast, { LEGACY_TOAST_EVENT, TOAST_EVENT } from '../../events/Toast';
import Navigate, {
LEGACY_NAVIGATE_EVENT,
NAVIGATE_EVENT,
} from '../../events/Navigate';
import { type UIEventHandler } from '../../events/EventPlugin';

export function getTargetName(target: EventTarget | null): string | undefined {
if (target instanceof Element) {
return (
Expand All @@ -7,4 +15,39 @@ export function getTargetName(target: EventTarget | null): string | undefined {
return undefined;
}

/**
* Widen a handler with a specific params type to the generic `UIEventHandler`
* signature. The params are decoded from the server payload, so they are not
* type checked at compile time.
*/
function asEventHandler<T>(handler: (params: T) => void): UIEventHandler {
return handler as (params: unknown) => void;
}

/**
* Map event names to their built-in handlers.
* Legacy (pre-namespacing) names are kept for compatibility with older servers.
*/
export const eventHandlerMap: Record<string, UIEventHandler> = {
[TOAST_EVENT]: asEventHandler(Toast),
[LEGACY_TOAST_EVENT]: asEventHandler(Toast),
[NAVIGATE_EVENT]: asEventHandler(Navigate),
[LEGACY_NAVIGATE_EVENT]: asEventHandler(Navigate),
};

/**
* Get the handler for an event sent from the server. Built-in handlers take
* precedence over handlers registered by plugins.
*
* @param name The name of the event
* @param eventMap Map of event names to handlers registered by plugins
* @returns The handler for the event, or null if there is no handler
*/
export function getHandlerForEvent(
name: string,
eventMap: ReadonlyMap<string, UIEventHandler> = EMPTY_MAP
): UIEventHandler | null {
return eventHandlerMap[name] ?? eventMap.get(name) ?? null;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's some collision risk here. We should probably namespace our events + throw warning if there is an eventMap collision rather than just silently override + have a note of that event namespacing in the docs somewhere.

}

export default getTargetName;
42 changes: 42 additions & 0 deletions plugins/ui/src/js/src/events/EventPlugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import {
type ElementPlugin,
isElementPlugin,
type PluginModuleExport,
} from '@deephaven/plugin';

/**
* A handler for an event sent from deephaven.ui via `use_send_event`.
* The params are the JSON-decoded payload of the event, with any callables
* re-hydrated into callable functions.
*/
export type UIEventHandler = (params: Record<string, unknown>) => void;

/** A mapping of event names to their handlers. */
export type UIEventMapping = Record<string, UIEventHandler>;

/**
* An event plugin is an {@link ElementPlugin} that additionally handles custom
* events sent from deephaven.ui via `use_send_event`. The `eventMapping`
* contains the event names as keys and the handlers as values.
*
* Event names should be namespaced with the plugin's package namespace to avoid
* collisions. Built-in events are namespaced with `deephaven.ui`.
*
* Because an event plugin is also an element plugin, the `mapping` property is
* still required. If the plugin only handles events and does not render any
* elements, set `mapping` to an empty object.
*/
export interface EventPlugin extends ElementPlugin {
eventMapping: UIEventMapping;
}

/** Type guard to check if the given plugin is an {@link EventPlugin}. */
export function isEventPlugin(
plugin: PluginModuleExport
): plugin is EventPlugin {
return (
isElementPlugin(plugin) &&
'eventMapping' in plugin &&
(plugin as Partial<EventPlugin>).eventMapping != null
);
}
5 changes: 4 additions & 1 deletion plugins/ui/src/js/src/events/Navigate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import Log from '@deephaven/log';
const log = Log.module('Navigate');

// Event types received from the server
export const NAVIGATE_EVENT = 'navigate.event';
export const NAVIGATE_EVENT = 'deephaven.ui.navigate';

/** Pre-namespacing event name, still emitted by older server versions */
export const LEGACY_NAVIGATE_EVENT = 'navigate.event';

/**
* Custom event dispatched after Navigate() changes the URL.
Expand Down
5 changes: 4 additions & 1 deletion plugins/ui/src/js/src/events/Toast.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { ToastQueue, type ToastOptions } from '@deephaven/components';

export const TOAST_EVENT = 'toast.event';
export const TOAST_EVENT = 'deephaven.ui.toast';

/** Pre-namespacing event name, still emitted by older server versions */
export const LEGACY_TOAST_EVENT = 'toast.event';

export type ToastVariant = 'positive' | 'negative' | 'neutral' | 'info';

Expand Down
51 changes: 51 additions & 0 deletions plugins/ui/src/js/src/events/usePluginsEventMap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { type PluginModuleMap } from '@deephaven/plugin';
import { getPluginsEventMap } from './usePluginsEventMap';

function makeEventPlugin(
name: string,
eventMapping: Record<string, (params: Record<string, unknown>) => void>
): [string, unknown] {
return [name, { name, type: 'ElementPlugin', mapping: {}, eventMapping }];
}

function makeElementPlugin(name: string): [string, unknown] {
return [name, { name, type: 'ElementPlugin', mapping: {} }];
}

it('extracts event handlers from event plugins', () => {
const handlerA = jest.fn();
const handlerB = jest.fn();
const plugins = new Map([
makeEventPlugin('plugin-a', { 'a.event': handlerA }),
makeElementPlugin('plugin-element'),
makeEventPlugin('plugin-b', { 'b.event': handlerB }),
]) as unknown as PluginModuleMap;

const eventMap = getPluginsEventMap(plugins);

expect(eventMap.size).toBe(2);
expect(eventMap.get('a.event')).toBe(handlerA);
expect(eventMap.get('b.event')).toBe(handlerB);
});

it('returns an empty map when there are no event plugins', () => {
const plugins = new Map([
makeElementPlugin('plugin-element'),
]) as unknown as PluginModuleMap;

expect(getPluginsEventMap(plugins).size).toBe(0);
});

it('uses the last registered handler and warns on duplicate event names', () => {
const first = jest.fn();
const second = jest.fn();
const plugins = new Map([
makeEventPlugin('plugin-a', { 'dup.event': first }),
makeEventPlugin('plugin-b', { 'dup.event': second }),
]) as unknown as PluginModuleMap;

const eventMap = getPluginsEventMap(plugins);

expect(eventMap.size).toBe(1);
expect(eventMap.get('dup.event')).toBe(second);
});
44 changes: 44 additions & 0 deletions plugins/ui/src/js/src/events/usePluginsEventMap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { useMemo } from 'react';
import { usePlugins } from '@deephaven/plugin';
import Log from '@deephaven/log';
import { type UIEventHandler, isEventPlugin } from './EventPlugin';

const log = Log.module('usePluginsEventMap');

/**
* Get a mapping of event names to their handlers from the given plugin map.
*
* If multiple plugins register a handler for the same event name, the last one
* registered wins and a warning is logged.
*
* @param pluginMap The plugin map to extract event plugins from.
* @returns A Map of event names to their handlers.
*/
export function getPluginsEventMap(
pluginMap: ReturnType<typeof usePlugins>
): Map<string, UIEventHandler> {
const eventMap = new Map<string, UIEventHandler>();
[...pluginMap.values()].filter(isEventPlugin).forEach(plugin => {
Object.entries(plugin.eventMapping).forEach(([name, handler]) => {
if (eventMap.has(name)) {
log.warn(
`Multiple plugins registered a handler for event "${name}". The last one registered will be used.`
);
}
eventMap.set(name, handler);
});
});
return eventMap;
}

/**
* Get all event handlers registered by {@link EventPlugin}s from the plugins
* context.
* @returns A Map of event names to their handlers.
*/
export function usePluginsEventMap(): Map<string, UIEventHandler> {
const plugins = usePlugins();
return useMemo(() => getPluginsEventMap(plugins), [plugins]);
}

export default usePluginsEventMap;
7 changes: 7 additions & 0 deletions plugins/ui/src/js/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,11 @@ const UIMultiPlugin = {

export { DashboardPlugin };

export {
type EventPlugin,
type UIEventHandler,
type UIEventMapping,
isEventPlugin,
} from './events/EventPlugin';

export default UIMultiPlugin;
Loading
Loading