From 071d645cb44f55787029ace3cf71f7515a4062bd Mon Sep 17 00:00:00 2001 From: Akshat Jawne <69530774+AkshatJawne@users.noreply.github.com> Date: Fri, 24 May 2024 11:23:28 -0600 Subject: [PATCH 1/2] docs: action_menu (#490) Resolves https://github.com/deephaven/deephaven-plugins/issues/482 Changes Implemented: - Added documentation for `action_menu` introduced in https://github.com/deephaven/deephaven-plugins/pull/448 --- .../src/deephaven/ui/components/__init__.py | 1 + .../deephaven/ui/components/action_menu.py | 202 +++++++++++++++++- .../src/deephaven/ui/components/combo_box.py | 6 +- .../deephaven/ui/components/date_picker.py | 3 +- .../ui/src/deephaven/ui/components/panel.py | 2 +- .../ui/components/spectrum/__init__.py | 1 + .../ui/components/spectrum/combo_box.py | 2 +- .../ui/components/spectrum/date_picker.py | 1 - .../ui/components/spectrum/events.py | 1 + .../ui/components/spectrum/layout.py | 2 + plugins/ui/src/deephaven/ui/types/types.py | 1 + 11 files changed, 209 insertions(+), 13 deletions(-) diff --git a/plugins/ui/src/deephaven/ui/components/__init__.py b/plugins/ui/src/deephaven/ui/components/__init__.py index 39b43c0a1..8b080b622 100644 --- a/plugins/ui/src/deephaven/ui/components/__init__.py +++ b/plugins/ui/src/deephaven/ui/components/__init__.py @@ -12,6 +12,7 @@ from .stack import stack from .picker import picker from .section import section +from .action_menu import action_menu from .item import item from .list_view import list_view from .list_action_group import list_action_group diff --git a/plugins/ui/src/deephaven/ui/components/action_menu.py b/plugins/ui/src/deephaven/ui/components/action_menu.py index 6a451996e..f3048c344 100644 --- a/plugins/ui/src/deephaven/ui/components/action_menu.py +++ b/plugins/ui/src/deephaven/ui/components/action_menu.py @@ -1,13 +1,203 @@ -from ..elements import BaseElement +from __future__ import annotations +from numbers import Number +from typing import Callable, Iterable +from .item import Item +from .section import SectionElement -# TODO: pydocs for action_menu #482 -def action_menu(*children, **props): +from .spectrum.events import TriggerType +from ..types import Key, ActionKey, ActionMenuDirection +from ..elements import BaseElement, Element + +from .spectrum import ( + Alignment, + AlignSelf, + CSSProperties, + DimensionValue, + JustifySelf, + LayoutFlex, + Position, +) + + +def action_menu( + *children: Item | SectionElement, + is_disabled: bool | None = None, + is_quiet: bool | None = None, + auto_focus: bool | None = None, + disabled_keys: Iterable[Key] | None = None, + align: Alignment | None = "start", + direction: ActionMenuDirection | None = "bottom", + should_flip: bool | None = True, + close_on_select: bool | None = True, + trigger: TriggerType | None = "press", + is_open: bool | None = None, + default_open: bool | None = None, + on_action: Callable[[ActionKey], None] | None = None, + on_open_change: Callable[[bool], None] | None = None, + flex: LayoutFlex | None = None, + flex_grow: Number | None = None, + flex_shrink: Number | None = None, + flex_basis: DimensionValue | None = None, + align_self: AlignSelf | None = None, + justify_self: JustifySelf | None = None, + order: Number | None = None, + grid_area: str | None = None, + grid_row: str | None = None, + grid_row_start: str | None = None, + grid_row_end: str | None = None, + grid_column: str | None = None, + grid_column_start: str | None = None, + grid_column_end: str | None = None, + margin: DimensionValue | None = None, + margin_top: DimensionValue | None = None, + margin_bottom: DimensionValue | None = None, + margin_start: DimensionValue | None = None, + margin_end: DimensionValue | None = None, + margin_x: DimensionValue | None = None, + margin_y: DimensionValue | None = None, + width: DimensionValue | None = None, + height: DimensionValue | None = None, + min_width: DimensionValue | None = None, + min_height: DimensionValue | None = None, + max_width: DimensionValue | None = None, + max_height: DimensionValue | None = None, + position: Position | None = None, + top: DimensionValue | None = None, + bottom: DimensionValue | None = None, + start: DimensionValue | None = None, + end: DimensionValue | None = None, + left: DimensionValue | None = None, + right: DimensionValue | None = None, + z_index: Number | None = None, + is_hidden: bool | None = None, + id: str | None = None, + aria_label: str | None = None, + aria_labelledby: str | None = None, + aria_describedby: str | None = None, + aria_details: str | None = None, + UNSAFE_class_name: str | None = None, + UNSAFE_style: CSSProperties | None = None, +) -> Element: """ ActionMenu combines an ActionButton with a Menu for simple "more actions" use cases. Args: - children: A list of Item or primitive elements. - **props: Any other ActionMenu prop. + children: The contents of the collection. + is_disabled: Whether the button is disabled. + is_quiet: Whether the button should be displayed with a quiet style. + auto_focus: Whether the element should receive focus on render. + disabled_keys: The item keys that are disabled. These items cannot be selected, focused, or otherwise interacted with. + align: Alignment of the menu relative to the trigger. + direction: Where the Menu opens relative to its trigger. + should_flip: Whether the menu should automatically flip direction when space is limited. + close_on_select: Whether the Menu closes when a selection is made. + trigger: How the menu is triggered. + is_open: Whether the overlay is open by default (controlled). + default_open: Whether the overlay is open by default (uncontrolled). + on_action: Handler that is called when an item is selected. + on_open_change: Handler that is called when the overlay's open state changes. + flex: When used in a flex layout, specifies how the element will grow or shrink to fit the space available. + flex_grow: When used in a flex layout, specifies how the element will grow to fit the space available. + flex_shrink: When used in a flex layout, specifies how the element will shrink to fit the space available. + flex_basis: When used in a flex layout, specifies the initial main size of the element. + align_self: Overrides the alignItems property of a flex or grid container. + justify_self: Species how the element is justified inside a flex or grid container. + order: The layout order for the element within a flex or grid container. + grid_area: When used in a grid layout specifies, specifies the named grid area that the element should be placed in within the grid. + grid_row: When used in a grid layout, specifies the row the element should be placed in within the grid. + grid_column: When used in a grid layout, specifies the column the element should be placed in within the grid. + grid_row_start: When used in a grid layout, specifies the starting row to span within the grid. + grid_row_end: When used in a grid layout, specifies the ending row to span within the grid. + grid_column_start: When used in a grid layout, specifies the starting column to span within the grid. + grid_column_end: When used in a grid layout, specifies the ending column to span within the grid. + margin: The margin for all four sides of the element. + margin_top: The margin for the top side of the element. + margin_bottom: The margin for the bottom side of the element. + margin_start: The margin for the logical start side of the element, depending on layout direction. + margin_end: The margin for the logical end side of the element, depending on layout direction. + margin_x: The margin for the left and right sides of the element. + margin_y: The margin for the top and bottom sides of the element. + width: The width of the element. + height: The height of the element. + min_width: The minimum width of the element. + min_height: The minimum height of the element. + max_width: The maximum width of the element. + max_height: The maximum height of the element. + position: Specifies how the element is position. + top: The top position of the element. + bottom: The bottom position of the element. + left: The left position of the element. + right: The right position of the element. + start: The logical start position of the element, depending on layout direction. + end: The logical end position of the element, depending on layout direction. + z_index: The stacking order for the element + is_hidden: Hides the element. + id: The unique identifier of the element. + aria-label: Defines a string value that labels the current element. + aria-labelledby: Identifies the element (or elements) that labels the current element. + aria-describedby: Identifies the element (or elements) that describes the object. + aria-details: Identifies the element (or elements) that provide a detailed, extended description for the object. + UNSAFE_class_name: Set the CSS className for the element. Only use as a last resort. Use style props instead. + UNSAFE_style: Set the inline style for the element. Only use as a last resort. Use style props instead. """ - return BaseElement(f"deephaven.ui.components.ActionMenu", *children, **props) + return BaseElement( + f"deephaven.ui.components.ActionMenu", + *children, + is_disabled=is_disabled, + is_quiet=is_quiet, + auto_focus=auto_focus, + disabled_keys=disabled_keys, + align=align, + direction=direction, + should_flip=should_flip, + close_on_select=close_on_select, + trigger=trigger, + is_open=is_open, + default_open=default_open, + on_action=on_action, + on_open_change=on_open_change, + flex=flex, + flex_grow=flex_grow, + flex_shrink=flex_shrink, + flex_basis=flex_basis, + align_self=align_self, + justify_self=justify_self, + order=order, + grid_area=grid_area, + grid_row=grid_row, + grid_row_start=grid_row_start, + grid_row_end=grid_row_end, + grid_column=grid_column, + grid_column_start=grid_column_start, + grid_column_end=grid_column_end, + margin=margin, + margin_top=margin_top, + margin_bottom=margin_bottom, + margin_start=margin_start, + margin_end=margin_end, + margin_x=margin_x, + margin_y=margin_y, + width=width, + height=height, + min_width=min_width, + min_height=min_height, + max_width=max_width, + max_height=max_height, + position=position, + top=top, + bottom=bottom, + start=start, + end=end, + left=left, + right=right, + z_index=z_index, + is_hidden=is_hidden, + id=id, + aria_label=aria_label, + aria_labelledby=aria_labelledby, + aria_describedby=aria_describedby, + aria_details=aria_details, + UNSAFE_class_name=UNSAFE_class_name, + UNSAFE_style=UNSAFE_style, + ) diff --git a/plugins/ui/src/deephaven/ui/components/combo_box.py b/plugins/ui/src/deephaven/ui/components/combo_box.py index 22c857102..2f357ea65 100644 --- a/plugins/ui/src/deephaven/ui/components/combo_box.py +++ b/plugins/ui/src/deephaven/ui/components/combo_box.py @@ -13,15 +13,15 @@ Position, CSSProperties, LabelPosition, - Alignment, ValidationBehavior, NecessityIndicator, ValidationState, MenuTriggerAction, Align, - Direction, + MenuDirection, LoadingState, FormValue, + Alignment, ) from deephaven.table import Table, PartitionedTable @@ -48,7 +48,7 @@ def combo_box( menu_trigger: MenuTriggerAction | None = "input", is_quiet: bool | None = None, align: Align | None = "end", - direction: Direction | None = "bottom", + direction: MenuDirection | None = "bottom", loading_state: LoadingState | None = None, should_flip: bool = True, menu_width: DimensionValue | None = None, diff --git a/plugins/ui/src/deephaven/ui/components/date_picker.py b/plugins/ui/src/deephaven/ui/components/date_picker.py index 98160cf42..5532348a8 100644 --- a/plugins/ui/src/deephaven/ui/components/date_picker.py +++ b/plugins/ui/src/deephaven/ui/components/date_picker.py @@ -14,13 +14,14 @@ AriaPressed, CSSProperties, LabelPosition, - Alignment, ValidationBehavior, NecessityIndicator, ValidationState, PageBehavior, HourCycle, + Alignment, ) + from ..hooks import use_memo from ..elements import Element, BaseElement from .._internal.utils import ( diff --git a/plugins/ui/src/deephaven/ui/components/panel.py b/plugins/ui/src/deephaven/ui/components/panel.py index 82da15bec..d3c374e29 100644 --- a/plugins/ui/src/deephaven/ui/components/panel.py +++ b/plugins/ui/src/deephaven/ui/components/panel.py @@ -3,7 +3,7 @@ from typing import Any from ..elements import BaseElement from .._internal.utils import create_props -from .spectrum.layout import ( +from .spectrum import ( Direction, Wrap, JustifyContent, diff --git a/plugins/ui/src/deephaven/ui/components/spectrum/__init__.py b/plugins/ui/src/deephaven/ui/components/spectrum/__init__.py index 028446059..f35a4d42c 100644 --- a/plugins/ui/src/deephaven/ui/components/spectrum/__init__.py +++ b/plugins/ui/src/deephaven/ui/components/spectrum/__init__.py @@ -8,3 +8,4 @@ from .flex import * from .date_picker import * from .combo_box import * +from .layout import * diff --git a/plugins/ui/src/deephaven/ui/components/spectrum/combo_box.py b/plugins/ui/src/deephaven/ui/components/spectrum/combo_box.py index 3268a263d..6b0a88ef4 100644 --- a/plugins/ui/src/deephaven/ui/components/spectrum/combo_box.py +++ b/plugins/ui/src/deephaven/ui/components/spectrum/combo_box.py @@ -2,7 +2,7 @@ MenuTriggerAction = Literal["focus", "input", "manual"] Align = Literal["start", "end"] -Direction = Literal["bottom", "top"] +MenuDirection = Literal["bottom", "top"] LoadingState = Literal[ "loading", "sorting", "loadingMore", "error", "idle", "filtering" ] diff --git a/plugins/ui/src/deephaven/ui/components/spectrum/date_picker.py b/plugins/ui/src/deephaven/ui/components/spectrum/date_picker.py index 3c8be378d..5fbe7c3bf 100644 --- a/plugins/ui/src/deephaven/ui/components/spectrum/date_picker.py +++ b/plugins/ui/src/deephaven/ui/components/spectrum/date_picker.py @@ -3,6 +3,5 @@ PageBehavior = Literal["single", "visible"] HourCycle = Literal[12, 24] ValidationBehavior = Literal["aria", "native"] -Alignment = Literal["start", "end"] NecessityIndicator = Literal["label", "icon"] ValidationState = Literal["valid", "invalid"] diff --git a/plugins/ui/src/deephaven/ui/components/spectrum/events.py b/plugins/ui/src/deephaven/ui/components/spectrum/events.py index eaf61adee..65e2d44ce 100644 --- a/plugins/ui/src/deephaven/ui/components/spectrum/events.py +++ b/plugins/ui/src/deephaven/ui/components/spectrum/events.py @@ -95,6 +95,7 @@ class PressEvent(TypedDict): PointerType = Literal["mouse", "touch", "pen", "keyboard", "virtual"] PressEventType = Literal["pressstart", "pressend", "pressup", "press"] +TriggerType = Literal["press", "longPress"] StaticColor = Literal["white", "black"] ButtonType = Literal["button", "submit", "reset"] diff --git a/plugins/ui/src/deephaven/ui/components/spectrum/layout.py b/plugins/ui/src/deephaven/ui/components/spectrum/layout.py index 83f8073a0..82a1d1ac1 100644 --- a/plugins/ui/src/deephaven/ui/components/spectrum/layout.py +++ b/plugins/ui/src/deephaven/ui/components/spectrum/layout.py @@ -104,6 +104,8 @@ "stretch", ] +Alignment = Literal["start", "end"] + Number = Union[int, float] LayoutFlex = Union[str, Number, bool] diff --git a/plugins/ui/src/deephaven/ui/types/types.py b/plugins/ui/src/deephaven/ui/types/types.py index ee6948a87..c719d141c 100644 --- a/plugins/ui/src/deephaven/ui/types/types.py +++ b/plugins/ui/src/deephaven/ui/types/types.py @@ -105,6 +105,7 @@ class RowDataValue(CellData): SelectionMode = Literal["SINGLE", "MULTIPLE"] Sentinel = Any TransformedData = Any +ActionMenuDirection = Literal["bottom", "top", "left", "right", "start", "end"] StringSortDirection = Literal["ASC", "DESC"] TableSortDirection = Union[StringSortDirection, SortDirection] # Stringable is a type that is naturally convertible to a string From df587a8cf88257324fd13ef69215d5224657c509 Mon Sep 17 00:00:00 2001 From: Mike Bender Date: Fri, 24 May 2024 14:30:05 -0400 Subject: [PATCH 2/2] fix: Reset state when new instance of widget created (#486) - We were using the same initial data regardless of how long the widget was opened - We want to keep the same WidgetHandler and panelIds so that panels open in the same spot - Want to start with a fresh state - Freeze the initialData whenever the fetched widget is updated - Fixes #401 - Tested using the steps in the description --- .../src/js/src/widget/WidgetHandler.test.tsx | 222 ++++++++++++++++-- .../ui/src/js/src/widget/WidgetHandler.tsx | 5 +- .../ui/src/js/src/widget/WidgetTestUtils.ts | 34 +++ 3 files changed, 242 insertions(+), 19 deletions(-) diff --git a/plugins/ui/src/js/src/widget/WidgetHandler.test.tsx b/plugins/ui/src/js/src/widget/WidgetHandler.test.tsx index adf999a7a..c95bc8c75 100644 --- a/plugins/ui/src/js/src/widget/WidgetHandler.test.tsx +++ b/plugins/ui/src/js/src/widget/WidgetHandler.test.tsx @@ -4,9 +4,10 @@ import type { dh } from '@deephaven/jsapi-types'; import WidgetHandler, { WidgetHandlerProps } from './WidgetHandler'; import { DocumentHandlerProps } from './DocumentHandler'; import { - makeDocumentUpdatedJsonRpcString, makeWidget, makeWidgetDescriptor, + makeWidgetEventDocumentUpdated, + makeWidgetEventJsonRpcResponse, } from './WidgetTestUtils'; const mockApi = { Widget: { EVENT_MESSAGE: 'message' } }; @@ -26,8 +27,16 @@ function makeWidgetHandler({ fetch = () => Promise.resolve(makeWidget()), widget = makeWidgetDescriptor(), onClose = jest.fn(), + initialData = undefined, }: Partial = {}) { - return ; + return ( + + ); } beforeEach(() => { @@ -48,45 +57,65 @@ it('updates the document when event is received', async () => { const widget = makeWidgetDescriptor(); const cleanup = jest.fn(); const mockAddEventListener = jest.fn(() => cleanup); + const mockSendMessage = jest.fn(); + const initialData = { state: { fiz: 'baz' } }; const initialDocument = { foo: 'bar' }; const widgetObject = makeWidget({ addEventListener: mockAddEventListener, - getDataAsString: jest.fn(() => - makeDocumentUpdatedJsonRpcString(initialDocument) - ), + getDataAsString: jest.fn(() => ''), + sendMessage: mockSendMessage, }); - const { unmount } = render(makeWidgetHandler({ widget, fetch })); + const { unmount } = render(makeWidgetHandler({ widget, fetch, initialData })); expect(fetch).toHaveBeenCalledTimes(1); expect(mockAddEventListener).not.toHaveBeenCalled(); expect(mockDocumentHandler).not.toHaveBeenCalled(); + expect(mockSendMessage).not.toHaveBeenCalled(); await act(async () => { fetchResolve!(widgetObject); await fetchPromise; }); expect(mockAddEventListener).toHaveBeenCalledTimes(1); + expect(mockDocumentHandler).not.toHaveBeenCalled(); + + expect(mockSendMessage).toHaveBeenCalledWith( + JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'setState', + params: [initialData.state], + }), + [] + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const listener = (mockAddEventListener.mock.calls[0] as any)[1]; + + // Send the initial document + await act(async () => { + // Respond to the setState call first + listener(makeWidgetEventJsonRpcResponse(1)); + + // Then send the initial document update + listener(makeWidgetEventDocumentUpdated(initialDocument)); + }); + expect(mockDocumentHandler).toHaveBeenCalledWith( expect.objectContaining({ widget, children: initialDocument, + initialData, }) ); + const updatedDocument = { FOO: 'BAR' }; + mockDocumentHandler.mockClear(); - const updatedDocument = { fiz: 'baz' }; - - act(() => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (mockAddEventListener.mock.calls[0] as any)[1]({ - detail: { - getDataAsString: jest.fn(() => - makeDocumentUpdatedJsonRpcString(updatedDocument) - ), - exportedObjects: [], - }, - }); + // Send the updated document + await act(async () => { + listener(makeWidgetEventDocumentUpdated(updatedDocument)); }); expect(mockDocumentHandler).toHaveBeenCalledWith( expect.objectContaining({ @@ -99,3 +128,160 @@ it('updates the document when event is received', async () => { unmount(); expect(cleanup).toHaveBeenCalledTimes(1); }); + +it('updates the initial data only when fetch has changed', async () => { + let fetchResolve1: (value: dh.Widget | PromiseLike) => void; + const fetchPromise1 = new Promise(resolve => { + fetchResolve1 = resolve; + }); + const fetch1 = jest.fn(() => fetchPromise1); + const widget1 = makeWidgetDescriptor(); + const cleanup = jest.fn(); + const addEventListener = jest.fn(() => cleanup); + const sendMessage = jest.fn(); + const onClose = jest.fn(); + const data1 = { state: { fiz: 'baz' } }; + const document1 = { foo: 'bar' }; + const widgetObject1 = makeWidget({ + addEventListener, + getDataAsString: jest.fn(() => ''), + sendMessage, + }); + + const { rerender, unmount } = render( + makeWidgetHandler({ + widget: widget1, + fetch: fetch1, + initialData: data1, + onClose, + }) + ); + expect(fetch1).toHaveBeenCalledTimes(1); + expect(addEventListener).not.toHaveBeenCalled(); + expect(mockDocumentHandler).not.toHaveBeenCalled(); + expect(sendMessage).not.toHaveBeenCalled(); + await act(async () => { + fetchResolve1!(widgetObject1); + await fetchPromise1; + }); + + expect(addEventListener).toHaveBeenCalledTimes(1); + expect(mockDocumentHandler).not.toHaveBeenCalled(); + + expect(sendMessage).toHaveBeenCalledWith( + JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'setState', + params: [data1.state], + }), + [] + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let listener = (addEventListener.mock.calls[0] as any)[1]; + + // Send the initial document + await act(async () => { + // Respond to the setState call first + listener(makeWidgetEventJsonRpcResponse(1)); + + // Then send the initial document update + listener(makeWidgetEventDocumentUpdated(document1)); + }); + + expect(mockDocumentHandler).toHaveBeenCalledWith( + expect.objectContaining({ + widget: widget1, + children: document1, + initialData: data1, + }) + ); + + let fetchResolve2: (value: dh.Widget | PromiseLike) => void; + const fetchPromise2 = new Promise(resolve => { + fetchResolve2 = resolve; + }); + const widget2 = makeWidgetDescriptor(); + const document2 = { FOO: 'BAR' }; + const data2 = { state: { FIZ: 'BAZ' } }; + const fetch2 = jest.fn(() => fetchPromise2); + const widgetObject2 = makeWidget({ + addEventListener, + getDataAsString: jest.fn(() => ''), + sendMessage, + }); + + addEventListener.mockClear(); + mockDocumentHandler.mockClear(); + sendMessage.mockClear(); + fetch1.mockClear(); + + // Re-render with just initial data change. It should not set the state again + rerender( + makeWidgetHandler({ + widget: widget1, + fetch: fetch1, + initialData: data2, + onClose, + }) + ); + + expect(fetch1).not.toHaveBeenCalled(); + expect(sendMessage).not.toHaveBeenCalled(); + + // Re-render with the widget descriptor changed, it should set the state with the updated data + rerender( + makeWidgetHandler({ + widget: widget2, + fetch: fetch2, + initialData: data2, + onClose, + }) + ); + + await act(async () => { + fetchResolve2!(widgetObject2); + await fetchPromise2; + }); + + expect(fetch2).toHaveBeenCalledTimes(1); + // Should have been called when the widget was updated + expect(cleanup).toHaveBeenCalledTimes(1); + cleanup.mockClear(); + + // eslint-disable-next-line prefer-destructuring, @typescript-eslint/no-explicit-any + listener = (addEventListener.mock.calls[0] as any)[1]; + + expect(sendMessage).toHaveBeenCalledWith( + JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'setState', + params: [data2.state], + }), + [] + ); + expect(sendMessage).toHaveBeenCalledTimes(1); + + // Send the initial document + await act(async () => { + // Respond to the setState call first + listener(makeWidgetEventJsonRpcResponse(1)); + + // Then send the initial document update + listener(makeWidgetEventDocumentUpdated(document2)); + }); + + expect(mockDocumentHandler).toHaveBeenCalledWith( + expect.objectContaining({ + widget: widget1, + children: document2, + initialData: data2, + }) + ); + + expect(cleanup).not.toHaveBeenCalled(); + unmount(); + expect(cleanup).toHaveBeenCalledTimes(1); +}); diff --git a/plugins/ui/src/js/src/widget/WidgetHandler.tsx b/plugins/ui/src/js/src/widget/WidgetHandler.tsx index f390c98a3..918ba0bf1 100644 --- a/plugins/ui/src/js/src/widget/WidgetHandler.tsx +++ b/plugins/ui/src/js/src/widget/WidgetHandler.tsx @@ -67,7 +67,10 @@ function WidgetHandler({ const [widget, setWidget] = useState(); const [document, setDocument] = useState(); const [error, setError] = useState(); - const [initialData] = useState(initialDataProp); + + // We want to update the initial data if the widget changes, as we'll need to re-fetch the widget and want to start with a fresh state. + // eslint-disable-next-line react-hooks/exhaustive-deps + const initialData = useMemo(() => initialDataProp, [widget]); // When we fetch a widget, the client is then responsible for the exported objects. // These objects could stay alive even after the widget is closed if we wanted to, diff --git a/plugins/ui/src/js/src/widget/WidgetTestUtils.ts b/plugins/ui/src/js/src/widget/WidgetTestUtils.ts index 278c0e50b..a452944ff 100644 --- a/plugins/ui/src/js/src/widget/WidgetTestUtils.ts +++ b/plugins/ui/src/js/src/widget/WidgetTestUtils.ts @@ -1,6 +1,7 @@ import { WidgetDescriptor } from '@deephaven/dashboard'; import { TestUtils } from '@deephaven/utils'; import type { dh } from '@deephaven/jsapi-types'; +import { WidgetMessageEvent } from './WidgetTypes'; export function makeDocumentUpdatedJsonRpc( document: Record = {} @@ -12,12 +13,43 @@ export function makeDocumentUpdatedJsonRpc( }; } +export function makeJsonRpcResponseString(id: number, result = ''): string { + return JSON.stringify({ + jsonrpc: '2.0', + id, + result, + }); +} + export function makeDocumentUpdatedJsonRpcString( document: Record = {} ): string { return JSON.stringify(makeDocumentUpdatedJsonRpc(document)); } +export function makeWidgetEvent(data = ''): WidgetMessageEvent { + return new CustomEvent('message', { + detail: { + getDataAsBase64: () => '', + getDataAsString: () => data, + exportedObjects: [], + }, + }); +} + +export function makeWidgetEventJsonRpcResponse( + id: number, + response = '' +): WidgetMessageEvent { + return makeWidgetEvent(makeJsonRpcResponseString(id, response)); +} + +export function makeWidgetEventDocumentUpdated( + document: Record = {} +): WidgetMessageEvent { + return makeWidgetEvent(makeDocumentUpdatedJsonRpcString(document)); +} + export function makeWidgetDescriptor({ id = 'widget-id', type = 'widget-type', @@ -34,10 +66,12 @@ export function makeWidget({ addEventListener = jest.fn(() => jest.fn()), getDataAsString = () => makeDocumentUpdatedJsonRpcString(), exportedObjects = [], + sendMessage = jest.fn(), }: Partial = {}): dh.Widget { return TestUtils.createMockProxy({ addEventListener, getDataAsString, exportedObjects, + sendMessage, }); }