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
37 changes: 37 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,8 @@
import { EMPTY_MAP } from '@deephaven/utils';
import Toast, { TOAST_EVENT } from '../../events/Toast';
import Navigate, { 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 +12,36 @@ 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
*/
export const eventHandlerMap: Record<string, UIEventHandler> = {
[TOAST_EVENT]: asEventHandler(Toast),
[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;
39 changes: 39 additions & 0 deletions plugins/ui/src/js/src/events/EventPlugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
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.
*
* 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
);
}
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;
71 changes: 71 additions & 0 deletions plugins/ui/src/js/src/widget/WidgetHandler.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -926,3 +926,74 @@ describe('popstate listener', () => {
removeEventListenerSpy.mockRestore();
});
});

describe('event plugin handling', () => {
async function setupWidgetWithPlugins(
pluginsValue: PluginModuleMap
): Promise<{
listener: (event: WidgetMessageEvent) => void;
unmount: () => void;
}> {
const widget = makeWidgetDescriptor();
const cleanup = jest.fn();
const mockAddEventListener = jest.fn(
(() => cleanup) as dh.Widget['addEventListener']
);
const initialData = { state: { test: 'value' } };
mockWidgetWrapper = {
widget: makeWidget({
addEventListener: mockAddEventListener,
getDataAsString: jest.fn(() => ''),
sendMessage: jest.fn(),
}),
error: null,
api: jest.fn() as unknown as typeof dh,
};

const { unmount } = render(
makeWidgetHandler({ widgetDescriptor: widget, initialData, pluginsValue })
);

const listener = mockAddEventListener.mock.calls[0][1];

await act(async () => {
listener(makeWidgetEventJsonRpcResponse(0));
});

return { listener, unmount };
}

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

it('dispatches a custom event to a registered event plugin handler', async () => {
const handler = jest.fn();
const plugins = makeEventPlugin('test-event-plugin', {
'test.event': handler,
});

const { listener, unmount } = await setupWidgetWithPlugins(plugins);

await act(async () => {
listener(makeWidgetEventMethodEvent('test.event', { foo: 'bar' }));
});

expect(handler).toHaveBeenCalledWith({ foo: 'bar' });

unmount();
});
});
26 changes: 14 additions & 12 deletions plugins/ui/src/js/src/widget/WidgetHandler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,17 +49,17 @@ import {
getComponentForElement,
wrapCallable,
} from './WidgetUtils';
import { getHandlerForEvent } from '../elements/utils/EventUtils';
import WidgetStatusContext, {
type WidgetStatus,
} from '../layout/WidgetStatusContext';
import WidgetErrorView from './WidgetErrorView';
import Toast, { TOAST_EVENT } from '../events/Toast';
import Navigate, {
NAVIGATE_EVENT,
type NavigateParams,
URL_CHANGED_EVENT,
} from '../events/Navigate';
import NavigateContext from '../events/NavigateContext';
import { usePluginsEventMap } from '../events/usePluginsEventMap';
import UriExportedObject from './UriExportedObject';
import applyJsonPatch from './WidgetJsonPatch';

Expand Down Expand Up @@ -224,6 +224,7 @@ function WidgetHandler({
);

const pluginsElementMap = usePluginsElementMap();
const pluginsEventMap = usePluginsEventMap();

const renderErrorDocument = useCallback(
(docError: NonNullable<unknown>) => {
Expand Down Expand Up @@ -464,16 +465,11 @@ function WidgetHandler({
}
return value;
});
switch (name) {
case TOAST_EVENT:
Toast(eventParams);
break;
case NAVIGATE_EVENT:
Navigate(eventParams);
break;
default:
throw new Error(`Unknown event ${name}`);
const handler = getHandlerForEvent(name, pluginsEventMap);
if (handler == null) {
throw new Error(`Unknown event ${name}`);
}
handler(eventParams);
} catch (e) {
throw new Error(
`Error parsing event ${name} with payload ${payload}: ${e}`
Expand All @@ -485,7 +481,13 @@ function WidgetHandler({
jsonClient.rejectAllPendingRequests('Widget was changed');
};
},
[jsonClient, onDataChange, callableFinalizationRegistry, sendSetState]
[
jsonClient,
onDataChange,
callableFinalizationRegistry,
sendSetState,
pluginsEventMap,
]
);

/**
Expand Down
6 changes: 4 additions & 2 deletions templates/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ In order to use these templates, you must have [cookiecutter](https://cookiecutt

There are two main ways to use these templates.
If you have this repository locally, you can run the following command from where you want to create your plugin:

```sh
cookiecutter <path/to/deephaven-plugins>/templates/<template name>
```

If you don't have this repository locally, you can run the following command:

```sh
cookiecutter gh:deephaven/deephaven-plugins --directory="templates/<template name>"
```
Expand All @@ -21,7 +23,7 @@ Use the widget plugin only if you must have full control over the messages sent
## element

This creates a basic element plugin for Deephaven.
An element plugin extends `deephaven.ui` with custom React components.
An element plugin extends `deephaven.ui` with custom React components and custom event handlers.
This template is recommended if you can use `deephaven.ui` and do not need full control over messaging.
Because element plugins are built on top of `deephaven.ui`, they are easier to use and require less boilerplate code than bidirectional widget plugins.

Expand All @@ -30,4 +32,4 @@ Because element plugins are built on top of `deephaven.ui`, they are easier to u
This creates a basic bidirectional widget plugin for Deephaven.
A bidirectional plugin can send and receive messages on both the client and server.
This template is recommended only if you must have full control over the messages sent between the client and server.
Widget plugins must manage their own messaging implementation, as only basic methods for sending messages between the client and server are provided.
Widget plugins must manage their own messaging implementation, as only basic methods for sending messages between the client and server are provided.
3 changes: 2 additions & 1 deletion templates/element/cookiecutter.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@
"__src_folder_name": "{{ cookiecutter.python_project_name }}",
"__name_pascal_case": "{{ cookiecutter.python_project_name.replace('_', ' ') .title().replace(' ', '') }}",
"__component_name": "{{ cookiecutter.python_project_name }}_component",
"__event_sender_name": "{{ cookiecutter.python_project_name }}_send_event",
"__registration_name": "{{ cookiecutter.__name_pascal_case }}Registration",
"__js_plugin_name_pascal_case": "{{ cookiecutter.javascript_project_name.replace('-', ' ') .title().replace(' ', '') }}",
"__js_plugin_view_obj": "{{ cookiecutter.__js_plugin_name_pascal_case }}View",
"__js_plugin_view_obj_style": "{{ cookiecutter.__js_plugin_view_obj }}Style",
"__js_plugin_obj": "{{ cookiecutter.__js_plugin_name_pascal_case }}Plugin",
"__element_name": "{{ cookiecutter.__py_namespace }}.{{ cookiecutter.__component_name }}"
}
}
Loading
Loading