diff --git a/plugins/ui/docs/components/toast.md b/plugins/ui/docs/components/toast.md index 394792612..36d0ae6ab 100644 --- a/plugins/ui/docs/components/toast.md +++ b/plugins/ui/docs/components/toast.md @@ -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 diff --git a/plugins/ui/src/deephaven/ui/components/toast.py b/plugins/ui/src/deephaven/ui/components/toast.py index d14b99c3a..6cf9bd3fc 100644 --- a/plugins/ui/src/deephaven/ui/components/toast.py +++ b/plugins/ui/src/deephaven/ui/components/toast.py @@ -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): diff --git a/plugins/ui/src/deephaven/ui/hooks/_navigate.py b/plugins/ui/src/deephaven/ui/hooks/_navigate.py new file mode 100644 index 000000000..214fc2829 --- /dev/null +++ b/plugins/ui/src/deephaven/ui/hooks/_navigate.py @@ -0,0 +1,3 @@ +from __future__ import annotations + +NAVIGATE_EVENT = "deephaven.ui.navigate" diff --git a/plugins/ui/src/deephaven/ui/hooks/use_navigate.py b/plugins/ui/src/deephaven/ui/hooks/use_navigate.py index 59e8afe99..92398a4ac 100644 --- a/plugins/ui/src/deephaven/ui/hooks/use_navigate.py +++ b/plugins/ui/src/deephaven/ui/hooks/use_navigate.py @@ -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 /. @@ -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 diff --git a/plugins/ui/src/deephaven/ui/hooks/use_set_query_param.py b/plugins/ui/src/deephaven/ui/hooks/use_set_query_param.py index f7d26618a..1d24ac6f3 100644 --- a/plugins/ui/src/deephaven/ui/hooks/use_set_query_param.py +++ b/plugins/ui/src/deephaven/ui/hooks/use_set_query_param.py @@ -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. @@ -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 diff --git a/plugins/ui/src/js/src/elements/utils/EventUtils.ts b/plugins/ui/src/js/src/elements/utils/EventUtils.ts index 2e3dda68c..e4551d14f 100644 --- a/plugins/ui/src/js/src/elements/utils/EventUtils.ts +++ b/plugins/ui/src/js/src/elements/utils/EventUtils.ts @@ -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 ( @@ -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(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 = { + [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 = EMPTY_MAP +): UIEventHandler | null { + return eventHandlerMap[name] ?? eventMap.get(name) ?? null; +} + export default getTargetName; diff --git a/plugins/ui/src/js/src/events/EventPlugin.ts b/plugins/ui/src/js/src/events/EventPlugin.ts new file mode 100644 index 000000000..3ab084db5 --- /dev/null +++ b/plugins/ui/src/js/src/events/EventPlugin.ts @@ -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) => void; + +/** A mapping of event names to their handlers. */ +export type UIEventMapping = Record; + +/** + * 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).eventMapping != null + ); +} diff --git a/plugins/ui/src/js/src/events/Navigate.ts b/plugins/ui/src/js/src/events/Navigate.ts index b3ea3bc95..da4977a3e 100644 --- a/plugins/ui/src/js/src/events/Navigate.ts +++ b/plugins/ui/src/js/src/events/Navigate.ts @@ -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. diff --git a/plugins/ui/src/js/src/events/Toast.ts b/plugins/ui/src/js/src/events/Toast.ts index 15c968d1f..6ac197b54 100644 --- a/plugins/ui/src/js/src/events/Toast.ts +++ b/plugins/ui/src/js/src/events/Toast.ts @@ -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'; diff --git a/plugins/ui/src/js/src/events/usePluginsEventMap.test.ts b/plugins/ui/src/js/src/events/usePluginsEventMap.test.ts new file mode 100644 index 000000000..bfab75da1 --- /dev/null +++ b/plugins/ui/src/js/src/events/usePluginsEventMap.test.ts @@ -0,0 +1,51 @@ +import { type PluginModuleMap } from '@deephaven/plugin'; +import { getPluginsEventMap } from './usePluginsEventMap'; + +function makeEventPlugin( + name: string, + eventMapping: Record) => 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); +}); diff --git a/plugins/ui/src/js/src/events/usePluginsEventMap.ts b/plugins/ui/src/js/src/events/usePluginsEventMap.ts new file mode 100644 index 000000000..99509fbbb --- /dev/null +++ b/plugins/ui/src/js/src/events/usePluginsEventMap.ts @@ -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 +): Map { + const eventMap = new Map(); + [...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 { + const plugins = usePlugins(); + return useMemo(() => getPluginsEventMap(plugins), [plugins]); +} + +export default usePluginsEventMap; diff --git a/plugins/ui/src/js/src/index.ts b/plugins/ui/src/js/src/index.ts index ec3c08e3e..356c867d6 100644 --- a/plugins/ui/src/js/src/index.ts +++ b/plugins/ui/src/js/src/index.ts @@ -22,4 +22,11 @@ const UIMultiPlugin = { export { DashboardPlugin }; +export { + type EventPlugin, + type UIEventHandler, + type UIEventMapping, + isEventPlugin, +} from './events/EventPlugin'; + export default UIMultiPlugin; diff --git a/plugins/ui/src/js/src/widget/WidgetHandler.test.tsx b/plugins/ui/src/js/src/widget/WidgetHandler.test.tsx index e583bdf7d..909a3539f 100644 --- a/plugins/ui/src/js/src/widget/WidgetHandler.test.tsx +++ b/plugins/ui/src/js/src/widget/WidgetHandler.test.tsx @@ -8,6 +8,7 @@ import { type Operation } from 'fast-json-patch'; import WidgetHandler, { type WidgetHandlerProps } from './WidgetHandler'; import { type DocumentHandlerProps } from './DocumentHandler'; import { type WidgetMessageEvent } from './WidgetTypes'; +import { LEGACY_NAVIGATE_EVENT, NAVIGATE_EVENT } from '../events/Navigate'; import { makeWidget, makeWidgetDescriptor, @@ -627,7 +628,7 @@ describe('navigate event handling', () => { await act(async () => { listener( - makeWidgetEventMethodEvent('navigate.event', { + makeWidgetEventMethodEvent(NAVIGATE_EVENT, { queryParams: 'page=1', }) ); @@ -639,12 +640,32 @@ describe('navigate event handling', () => { unmount(); }); + it('handles the legacy event name', async () => { + const { listener, unmount } = await setupWidgetWithListener(); + + await act(async () => { + listener( + makeWidgetEventMethodEvent(LEGACY_NAVIGATE_EVENT, { + queryParams: 'page=2&sort=name', + }) + ); + }); + + expect(window.history.replaceState).toHaveBeenCalledWith( + null, + '', + '/app/widget/local/dashboard?page=2&sort=name' + ); + + unmount(); + }); + it('uses pushState when replace=false', async () => { const { listener, unmount } = await setupWidgetWithListener(); await act(async () => { listener( - makeWidgetEventMethodEvent('navigate.event', { + makeWidgetEventMethodEvent(NAVIGATE_EVENT, { queryParams: 'page=1', replace: false, }) @@ -662,7 +683,7 @@ describe('navigate event handling', () => { await act(async () => { listener( - makeWidgetEventMethodEvent('navigate.event', { + makeWidgetEventMethodEvent(NAVIGATE_EVENT, { queryParams: 'page=2&sort=name', }) ); @@ -682,7 +703,7 @@ describe('navigate event handling', () => { await act(async () => { listener( - makeWidgetEventMethodEvent('navigate.event', { + makeWidgetEventMethodEvent(NAVIGATE_EVENT, { queryParams: '?foo=bar&baz=qux', }) ); @@ -707,7 +728,7 @@ describe('navigate event handling', () => { await act(async () => { listener( - makeWidgetEventMethodEvent('navigate.event', { + makeWidgetEventMethodEvent(NAVIGATE_EVENT, { queryParams: '', }) ); @@ -724,7 +745,7 @@ describe('navigate event handling', () => { await act(async () => { listener( - makeWidgetEventMethodEvent('navigate.event', { + makeWidgetEventMethodEvent(NAVIGATE_EVENT, { queryParams: 'page=2', }) ); @@ -749,7 +770,7 @@ describe('navigate event handling', () => { await act(async () => { listener( - makeWidgetEventMethodEvent('navigate.event', { + makeWidgetEventMethodEvent(NAVIGATE_EVENT, { queryParams: 'tag=python&tag=java', }) ); @@ -776,7 +797,7 @@ describe('navigate event handling', () => { await act(async () => { listener( - makeWidgetEventMethodEvent('navigate.event', { + makeWidgetEventMethodEvent(NAVIGATE_EVENT, { path: '/new-page', }) ); @@ -796,7 +817,7 @@ describe('navigate event handling', () => { await act(async () => { listener( - makeWidgetEventMethodEvent('navigate.event', { + makeWidgetEventMethodEvent(NAVIGATE_EVENT, { fragment: 'section-2', }) ); @@ -821,7 +842,7 @@ describe('navigate event handling', () => { await act(async () => { listener( - makeWidgetEventMethodEvent('navigate.event', { + makeWidgetEventMethodEvent(NAVIGATE_EVENT, { fragment: '', }) ); @@ -846,7 +867,7 @@ describe('navigate event handling', () => { await act(async () => { listener( - makeWidgetEventMethodEvent('navigate.event', { + makeWidgetEventMethodEvent(NAVIGATE_EVENT, { path: '/settings', queryParams: '?tab=1', fragment: 'top', @@ -869,7 +890,7 @@ describe('navigate event handling', () => { await act(async () => { listener( - makeWidgetEventMethodEvent('navigate.event', { + makeWidgetEventMethodEvent(NAVIGATE_EVENT, { path: '/page', queryParams: 'x=1', fragment: 'sec', @@ -926,3 +947,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) => 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(); + }); +}); diff --git a/plugins/ui/src/js/src/widget/WidgetHandler.tsx b/plugins/ui/src/js/src/widget/WidgetHandler.tsx index 326e1483c..8f769527e 100644 --- a/plugins/ui/src/js/src/widget/WidgetHandler.tsx +++ b/plugins/ui/src/js/src/widget/WidgetHandler.tsx @@ -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'; @@ -224,6 +224,7 @@ function WidgetHandler({ ); const pluginsElementMap = usePluginsElementMap(); + const pluginsEventMap = usePluginsEventMap(); const renderErrorDocument = useCallback( (docError: NonNullable) => { @@ -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}` @@ -485,7 +481,13 @@ function WidgetHandler({ jsonClient.rejectAllPendingRequests('Widget was changed'); }; }, - [jsonClient, onDataChange, callableFinalizationRegistry, sendSetState] + [ + jsonClient, + onDataChange, + callableFinalizationRegistry, + sendSetState, + pluginsEventMap, + ] ); /** diff --git a/plugins/ui/test/deephaven/ui/test_query_params.py b/plugins/ui/test/deephaven/ui/test_query_params.py index 14ac7bdc3..115c9bb38 100644 --- a/plugins/ui/test/deephaven/ui/test_query_params.py +++ b/plugins/ui/test/deephaven/ui/test_query_params.py @@ -146,7 +146,7 @@ def test_setter_sets_string_value(self): send_event_mock.assert_called_once() name, payload = send_event_mock.call_args[0] - self.assertEqual(name, "navigate.event") + self.assertEqual(name, "deephaven.ui.navigate") self.assertEqual(payload["queryParams"], "?page=2") self.assertNotIn("path", payload) self.assertNotIn("fragment", payload) diff --git a/plugins/ui/test/deephaven/ui/test_routing.py b/plugins/ui/test/deephaven/ui/test_routing.py index c9911c32f..4335b3a09 100644 --- a/plugins/ui/test/deephaven/ui/test_routing.py +++ b/plugins/ui/test/deephaven/ui/test_routing.py @@ -142,7 +142,7 @@ def test_navigate_path_only(self): mock.assert_called_once() name, payload = mock.call_args[0] - self.assertEqual(name, "navigate.event") + self.assertEqual(name, "deephaven.ui.navigate") self.assertEqual(payload["path"], "/dashboard") self.assertRaises(KeyError, lambda: payload["queryParams"]) self.assertRaises(KeyError, lambda: payload["fragment"]) diff --git a/templates/README.md b/templates/README.md index a30ea5637..6fe193fcf 100644 --- a/templates/README.md +++ b/templates/README.md @@ -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 /templates/