,
[
mouseHandlers,
@@ -151,6 +175,7 @@ export function UITable({
hydratedSorts,
hydratedQuickFilters,
settings,
+ onContextMenu,
]
);
@@ -159,8 +184,12 @@ export function UITable({
return model ? (
- {/* eslint-disable-next-line react/jsx-props-no-spreading */}
-
+ setIrisGrid(ref)}
+ model={model}
+ // eslint-disable-next-line react/jsx-props-no-spreading
+ {...irisGridProps}
+ />
) : null;
}
diff --git a/plugins/ui/src/js/src/elements/UITable/UITableContextMenuHandler.ts b/plugins/ui/src/js/src/elements/UITable/UITableContextMenuHandler.ts
new file mode 100644
index 000000000..e0698e5d4
--- /dev/null
+++ b/plugins/ui/src/js/src/elements/UITable/UITableContextMenuHandler.ts
@@ -0,0 +1,68 @@
+import { GridPoint, ModelIndex } from '@deephaven/grid';
+import type { ResolvableContextAction } from '@deephaven/components';
+import {
+ IrisGridModel,
+ IrisGridType,
+ IrisGridContextMenuHandler,
+} from '@deephaven/iris-grid';
+import type { dh as DhType } from '@deephaven/jsapi-types';
+import { UITableProps, wrapContextActions } from './UITableUtils';
+
+/**
+ * Context menu handler for UITable.
+ */
+class UITableContextMenuHandler extends IrisGridContextMenuHandler {
+ private model: IrisGridModel;
+
+ private contextMenuItems: UITableProps['contextMenu'];
+
+ private contextColumnHeaderItems: UITableProps['contextHeaderMenu'];
+
+ constructor(
+ dh: typeof DhType,
+ irisGrid: IrisGridType,
+ model: IrisGridModel,
+ contextMenuItems: UITableProps['contextMenu'],
+ contextColumnHeaderItems: UITableProps['contextHeaderMenu']
+ ) {
+ super(irisGrid, dh);
+ this.order -= 1; // Make it just above the default handler priority
+ this.irisGrid = irisGrid;
+ this.model = model;
+ this.contextMenuItems = contextMenuItems;
+ this.contextColumnHeaderItems = contextColumnHeaderItems;
+ }
+
+ getHeaderActions(
+ modelIndex: ModelIndex,
+ gridPoint: GridPoint
+ ): ResolvableContextAction[] {
+ const { irisGrid, contextColumnHeaderItems, model } = this;
+
+ const { column: columnIndex } = gridPoint;
+ const modelColumn = irisGrid.getModelColumn(columnIndex);
+
+ if (!contextColumnHeaderItems || modelColumn == null) {
+ return super.getHeaderActions(modelIndex, gridPoint);
+ }
+
+ const { columns } = model;
+
+ const sourceCell = model.sourceForCell(modelColumn, 0);
+ const { column: sourceColumn } = sourceCell;
+ const column = columns[sourceColumn];
+
+ return [
+ ...super.getHeaderActions(modelIndex, gridPoint),
+ ...wrapContextActions(contextColumnHeaderItems, {
+ value: null,
+ valueText: null,
+ rowIndex: null,
+ columnIndex: sourceColumn,
+ column,
+ }),
+ ];
+ }
+}
+
+export default UITableContextMenuHandler;
diff --git a/plugins/ui/src/js/src/elements/UITable/UITableUtils.tsx b/plugins/ui/src/js/src/elements/UITable/UITableUtils.tsx
index fccd6e0ff..f72b7da53 100644
--- a/plugins/ui/src/js/src/elements/UITable/UITableUtils.tsx
+++ b/plugins/ui/src/js/src/elements/UITable/UITableUtils.tsx
@@ -1,7 +1,24 @@
import type { dh } from '@deephaven/jsapi-types';
-import { ColumnName, DehydratedSort, RowIndex } from '@deephaven/iris-grid';
+import {
+ ColumnName,
+ DehydratedSort,
+ IrisGridContextMenuData,
+ RowIndex,
+} from '@deephaven/iris-grid';
+import type {
+ ContextAction,
+ ResolvableContextAction,
+} from '@deephaven/components';
+import { ensureArray } from '@deephaven/utils';
import { ELEMENT_KEY, ElementNode, isElementNode } from '../utils/ElementUtils';
-import { ELEMENT_NAME, ElementName } from '../model/ElementConstants';
+
+import { getIcon } from '../utils/IconElementUtils';
+import {
+ ELEMENT_NAME,
+ ELEMENT_PREFIX,
+ ElementName,
+ ElementPrefix,
+} from '../model/ElementConstants';
export type CellData = {
type: string;
@@ -18,6 +35,26 @@ export type ColumnIndex = number;
export type RowDataMap = Record;
+export interface UIContextItemParams {
+ value: unknown;
+ text_value: string | null;
+ column_name: string;
+ is_column_header: boolean;
+ is_row_header: boolean;
+}
+
+export type UIContextItem = Omit & {
+ action?: (params: UIContextItemParams) => void;
+
+ actions?: ResolvableUIContextItem[];
+};
+
+type ResolvableUIContextItem =
+ | UIContextItem
+ | ((
+ params: UIContextItemParams
+ ) => Promise);
+
export type UITableProps = {
table: dh.WidgetExportedObject;
onCellPress?: (cellIndex: [ColumnIndex, RowIndex], data: CellData) => void;
@@ -39,6 +76,8 @@ export type UITableProps = {
frozenColumns?: string[];
hiddenColumns?: string[];
columnGroups?: dh.ColumnGroup[];
+ contextMenu?: ResolvableUIContextItem | ResolvableUIContextItem[];
+ contextHeaderMenu?: ResolvableUIContextItem | ResolvableUIContextItem[];
};
export type UITableNode = Required<
@@ -51,3 +90,64 @@ export function isUITable(obj: unknown): obj is UITableNode {
(obj as UITableNode)[ELEMENT_KEY] === ELEMENT_NAME.uiTable
);
}
+
+function wrapUIContextItem(
+ item: UIContextItem,
+ data: Omit
+): ContextAction {
+ return {
+ group: 999999, // Default to the end of the menu
+ ...item,
+ icon: item.icon
+ ? getIcon(`${ELEMENT_PREFIX.icon}${item.icon}` as ElementPrefix['icon'])
+ : undefined,
+ action: item.action
+ ? () => {
+ item.action?.({
+ value: data.value,
+ text_value: data.valueText,
+ column_name: data.column.name,
+ is_column_header: data.rowIndex == null,
+ is_row_header: data.columnIndex == null,
+ });
+ }
+ : undefined,
+ actions: item.actions ? wrapContextActions(item.actions, data) : undefined,
+ } satisfies ContextAction;
+}
+
+function wrapUIContextItems(
+ items: UIContextItem | UIContextItem[],
+ data: Omit
+): ContextAction[] {
+ return ensureArray(items).map(item => wrapUIContextItem(item, data));
+}
+
+/**
+ * Wraps context item actions from the server so they are called with the cell info.
+ * @param items The context items from the server
+ * @param data The context menu data to use for the context items
+ * @returns Context items with the UI actions wrapped so they receive the cell info
+ */
+export function wrapContextActions(
+ items: ResolvableUIContextItem | ResolvableUIContextItem[],
+ data: Omit
+): ResolvableContextAction[] {
+ return ensureArray(items).map(item => {
+ if (typeof item === 'function') {
+ return async () =>
+ wrapUIContextItems(
+ (await item({
+ value: data.value,
+ text_value: data.valueText,
+ column_name: data.column.name,
+ is_column_header: data.rowIndex == null,
+ is_row_header: data.columnIndex == null,
+ })) ?? [],
+ data
+ );
+ }
+
+ return wrapUIContextItem(item, data);
+ });
+}
diff --git a/plugins/ui/src/js/src/layout/ReactPanel.test.tsx b/plugins/ui/src/js/src/layout/ReactPanel.test.tsx
index f776149cf..a30e72c80 100644
--- a/plugins/ui/src/js/src/layout/ReactPanel.test.tsx
+++ b/plugins/ui/src/js/src/layout/ReactPanel.test.tsx
@@ -1,6 +1,10 @@
import React from 'react';
import { render, within } from '@testing-library/react';
-import { LayoutUtils, useListener } from '@deephaven/dashboard';
+import {
+ LayoutUtils,
+ WidgetDescriptor,
+ useListener,
+} from '@deephaven/dashboard';
import { TestUtils } from '@deephaven/utils';
import ReactPanel from './ReactPanel';
import {
@@ -8,10 +12,17 @@ import {
ReactPanelManagerContext,
} from './ReactPanelManager';
import { ReactPanelProps } from './LayoutUtils';
-import PortalPanelManager from './PortalPanelManager';
-import PortalPanelManagerContext from './PortalPanelManagerContext';
+import PortalPanelManagerContext, {
+ PortalPanelMap,
+} from './PortalPanelManagerContext';
+import WidgetStatusContext, { WidgetStatus } from './WidgetStatusContext';
const mockPanelId = 'test-panel-id';
+const defaultDescriptor = { name: 'test-name', type: 'test-type' };
+const defaultStatus: WidgetStatus = {
+ status: 'ready',
+ descriptor: defaultDescriptor,
+};
beforeEach(() => {
jest.clearAllMocks();
@@ -19,7 +30,7 @@ beforeEach(() => {
function makeReactPanelManager({
children,
- metadata = { name: 'test-name', type: 'test-type' },
+ metadata = defaultDescriptor,
onClose = jest.fn(),
onOpen = jest.fn(),
getPanelId = jest.fn(() => mockPanelId),
@@ -41,23 +52,32 @@ function makeReactPanelManager({
function makeTestComponent({
children,
- metadata = { name: 'test-name', type: 'test-type' },
+ metadata = defaultDescriptor,
onClose = jest.fn(),
onOpen = jest.fn(),
getPanelId = jest.fn(() => mockPanelId),
+ portals = new Map(),
+ status = defaultStatus,
title = 'test title',
-}: Partial & Partial = {}) {
+}: Partial &
+ Partial & {
+ metadata?: WidgetDescriptor;
+ portals?: PortalPanelMap;
+ status?: WidgetStatus;
+ } = {}) {
return (
-
- {makeReactPanelManager({
- children,
- metadata,
- onClose,
- onOpen,
- getPanelId,
- title,
- })}
-
+
+
+ {makeReactPanelManager({
+ children,
+ metadata,
+ onClose,
+ onOpen,
+ getPanelId,
+ title,
+ })}
+
+
);
}
@@ -181,14 +201,13 @@ it('does not call openComponent or setActiveContentItem if panel already exists
const metadata = { type: 'bar' };
const children = 'hello';
const { rerender } = render(
-
- {makeReactPanelManager({
- children,
- onOpen,
- onClose,
- metadata,
- })}
-
+ makeTestComponent({
+ children,
+ onOpen,
+ onClose,
+ metadata,
+ portals,
+ })
);
expect(LayoutUtils.openComponent).not.toHaveBeenCalled();
expect(LayoutUtils.closeComponent).not.toHaveBeenCalled();
@@ -200,14 +219,13 @@ it('does not call openComponent or setActiveContentItem if panel already exists
// Now check that it focuses it if it's called after the metadata changes
rerender(
-
- {makeReactPanelManager({
- children: 'world',
- onOpen,
- onClose,
- metadata: { type: 'baz' },
- })}
-
+ makeTestComponent({
+ children: 'world',
+ onOpen,
+ onClose,
+ metadata: { type: 'baz' },
+ portals,
+ })
);
expect(LayoutUtils.openComponent).not.toHaveBeenCalled();
@@ -274,22 +292,36 @@ it('catches an error thrown by children, renders error view', () => {
const portals = new Map([[mockPanelId, portal]]);
const { rerender } = render(
-
- {makeReactPanelManager({
- children: ,
- })}
-
+ makeTestComponent({
+ children: ,
+ portals,
+ })
);
const { getByText } = within(portal);
- expect(getByText('Error: test error')).toBeDefined();
+ expect(getByText('test error')).toBeDefined();
rerender(
-
- {makeReactPanelManager({
- children: Hello
,
- })}
-
+ makeTestComponent({
+ children: Hello
,
+ portals,
+ })
);
expect(getByText('Hello')).toBeDefined();
});
+
+it('displays an error if the widget is in an error state', () => {
+ const error = new Error('test error');
+ const portal = document.createElement('div');
+ const portals = new Map([[mockPanelId, portal]]);
+ const status: WidgetStatus = {
+ status: 'error',
+ descriptor: defaultDescriptor,
+ error,
+ };
+
+ render(makeTestComponent({ portals, status }));
+
+ const { getByText } = within(portal);
+ expect(getByText('test error')).toBeDefined();
+});
diff --git a/plugins/ui/src/js/src/layout/ReactPanel.tsx b/plugins/ui/src/js/src/layout/ReactPanel.tsx
index af908c754..cf3fdebd0 100644
--- a/plugins/ui/src/js/src/layout/ReactPanel.tsx
+++ b/plugins/ui/src/js/src/layout/ReactPanel.tsx
@@ -7,13 +7,7 @@ import {
useLayoutManager,
useListener,
} from '@deephaven/dashboard';
-import {
- View,
- ViewProps,
- Flex,
- FlexProps,
- ErrorBoundary,
-} from '@deephaven/components';
+import { View, ViewProps, Flex, FlexProps } from '@deephaven/components';
import Log from '@deephaven/log';
import PortalPanel from './PortalPanel';
import { ReactPanelControl, useReactPanel } from './ReactPanelManager';
@@ -21,7 +15,9 @@ import { ReactPanelProps } from './LayoutUtils';
import { useParentItem } from './ParentItemContext';
import { ReactPanelContext } from './ReactPanelContext';
import { usePortalPanelManager } from './PortalPanelManagerContext';
-import ReactPanelContentOverlay from './ReactPanelContentOverlay';
+import ReactPanelErrorBoundary from './ReactPanelErrorBoundary';
+import useWidgetStatus from './useWidgetStatus';
+import WidgetErrorView from '../widget/WidgetErrorView';
const log = Log.module('@deephaven/js-plugin-ui/ReactPanel');
@@ -172,6 +168,7 @@ function ReactPanel({
},
[parent, metadata, onOpen, panelId, title]
);
+ const widgetStatus = useWidgetStatus();
return portal
? ReactDOM.createPortal(
@@ -205,11 +202,19 @@ function ReactPanel({
rowGap={rowGap}
columnGap={columnGap}
>
- {/* Have an ErrorBoundary around the children to display an error in the panel if there's any errors thrown when rendering the children */}
- {children}
+
+ {/**
+ * Don't render the children if there's an error with the widget. If there's an error with the widget, we can assume the children won't render properly,
+ * but we still want the panels to appear so things don't disappear/jump around.
+ */}
+ {widgetStatus.status === 'error' ? (
+
+ ) : (
+ children
+ )}
+
-
,
portal,
contentKey
diff --git a/plugins/ui/src/js/src/layout/ReactPanelContentOverlay.tsx b/plugins/ui/src/js/src/layout/ReactPanelContentOverlay.tsx
deleted file mode 100644
index 5e1c693c4..000000000
--- a/plugins/ui/src/js/src/layout/ReactPanelContentOverlay.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import React from 'react';
-import { usePanelContentOverlay } from './usePanelContentOverlay';
-
-/** A panel that uses the ReactPanelContentOverlayContext and if that content is set, renders it in a view with a partially transparent background */
-export function ReactPanelContentOverlay(): JSX.Element | null {
- const overlayContent = usePanelContentOverlay();
- return overlayContent != null ? (
- {overlayContent}
- ) : null;
-}
-
-export default ReactPanelContentOverlay;
diff --git a/plugins/ui/src/js/src/layout/ReactPanelContentOverlayContext.tsx b/plugins/ui/src/js/src/layout/ReactPanelContentOverlayContext.tsx
deleted file mode 100644
index ceb248ec2..000000000
--- a/plugins/ui/src/js/src/layout/ReactPanelContentOverlayContext.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-import { createContext } from 'react';
-
-/** Context that defined a ReactNode to overlay on top of the content in a ReactPanel */
-export const ReactPanelContentOverlayContext =
- createContext(null);
-
-export default ReactPanelContentOverlayContext;
diff --git a/plugins/ui/src/js/src/layout/ReactPanelErrorBoundary.tsx b/plugins/ui/src/js/src/layout/ReactPanelErrorBoundary.tsx
new file mode 100644
index 000000000..ed7444b67
--- /dev/null
+++ b/plugins/ui/src/js/src/layout/ReactPanelErrorBoundary.tsx
@@ -0,0 +1,59 @@
+import Log from '@deephaven/log';
+import React, { Component, ReactNode } from 'react';
+import WidgetErrorView from '../widget/WidgetErrorView';
+
+const log = Log.module('ReactPanelErrorBoundary');
+
+export interface ReactPanelErrorBoundaryProps {
+ /** Children to catch errors from. Error will reset when the children have been updated. */
+ children: ReactNode;
+}
+
+export interface ReactPanelErrorBoundaryState {
+ /** Currently displayed error. Reset when children are updated. */
+ error?: Error;
+}
+
+/**
+ * Error boundary for catching render errors in React. Displays an error message until the children have updated.
+ */
+export class ReactPanelErrorBoundary extends Component<
+ ReactPanelErrorBoundaryProps,
+ ReactPanelErrorBoundaryState
+> {
+ static getDerivedStateFromError(error: Error): ReactPanelErrorBoundaryState {
+ return { error };
+ }
+
+ constructor(props: ReactPanelErrorBoundaryProps) {
+ super(props);
+ this.state = { error: undefined };
+ }
+
+ componentDidUpdate(
+ prevProps: Readonly,
+ prevState: Readonly
+ ): void {
+ const { children } = this.props;
+ if (prevProps.children !== children && prevState.error != null) {
+ log.debug(
+ 'ReactPanelErrorBoundary clearing previous error',
+ prevState.error,
+ children
+ );
+ this.setState({ error: undefined });
+ }
+ }
+
+ componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
+ log.error('Error caught by ErrorBoundary', error, errorInfo);
+ }
+
+ render(): ReactNode {
+ const { children } = this.props;
+ const { error } = this.state;
+ return error != null ? : children;
+ }
+}
+
+export default ReactPanelErrorBoundary;
diff --git a/plugins/ui/src/js/src/layout/WidgetStatusContext.tsx b/plugins/ui/src/js/src/layout/WidgetStatusContext.tsx
new file mode 100644
index 000000000..c2c7874c6
--- /dev/null
+++ b/plugins/ui/src/js/src/layout/WidgetStatusContext.tsx
@@ -0,0 +1,28 @@
+import { WidgetDescriptor } from '@deephaven/dashboard';
+import { createContext } from 'react';
+
+export type WidgetStatusLoading = {
+ status: 'loading';
+ descriptor: WidgetDescriptor;
+};
+
+export type WidgetStatusError = {
+ status: 'error';
+ descriptor: WidgetDescriptor;
+ error: NonNullable;
+};
+
+export type WidgetStatusReady = {
+ status: 'ready';
+ descriptor: WidgetDescriptor;
+};
+
+export type WidgetStatus =
+ | WidgetStatusLoading
+ | WidgetStatusError
+ | WidgetStatusReady;
+
+/** Status of the widget within this context */
+export const WidgetStatusContext = createContext(null);
+
+export default WidgetStatusContext;
diff --git a/plugins/ui/src/js/src/layout/usePanelContentOverlay.ts b/plugins/ui/src/js/src/layout/usePanelContentOverlay.ts
deleted file mode 100644
index 8b3aeb457..000000000
--- a/plugins/ui/src/js/src/layout/usePanelContentOverlay.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { useContext } from 'react';
-import { ReactPanelContentOverlayContext } from './ReactPanelContentOverlayContext';
-
-/**
- * Gets the overlay content from the nearest panel context.
- * @returns The overlay content or null if not in a panel
- */
-export function usePanelContentOverlay(): React.ReactNode | null {
- return useContext(ReactPanelContentOverlayContext);
-}
-
-export default usePanelContentOverlay;
diff --git a/plugins/ui/src/js/src/layout/useWidgetStatus.ts b/plugins/ui/src/js/src/layout/useWidgetStatus.ts
new file mode 100644
index 000000000..4a00521b9
--- /dev/null
+++ b/plugins/ui/src/js/src/layout/useWidgetStatus.ts
@@ -0,0 +1,12 @@
+import { useContextOrThrow } from '@deephaven/react-hooks';
+import { WidgetStatus, WidgetStatusContext } from './WidgetStatusContext';
+
+/**
+ * Gets the widget status from the closest WidgetStatusContext.
+ * @returns Widget status or throws an error if WidgetStatusContext is not set
+ */
+export function useWidgetStatus(): WidgetStatus {
+ return useContextOrThrow(WidgetStatusContext);
+}
+
+export default useWidgetStatus;
diff --git a/plugins/ui/src/js/src/styles.scss b/plugins/ui/src/js/src/styles.scss
index 17ea7d971..4d48aa843 100644
--- a/plugins/ui/src/js/src/styles.scss
+++ b/plugins/ui/src/js/src/styles.scss
@@ -31,27 +31,6 @@
}
}
-.dh-react-panel-overlay {
- background-color: bg-opacity(80);
- backdrop-filter: blur(5px);
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- padding: $spacer;
- display: flex;
- flex-direction: column;
- justify-content: center;
- align-items: center;
- z-index: 1000;
-
- .ui-widget-error-view {
- width: 100%;
- overflow: auto;
- }
-}
-
.ui-text-wrap-balance {
text-wrap: balance;
}
diff --git a/plugins/ui/src/js/src/widget/WidgetErrorView.tsx b/plugins/ui/src/js/src/widget/WidgetErrorView.tsx
index 115e7ef1c..6cf78847a 100644
--- a/plugins/ui/src/js/src/widget/WidgetErrorView.tsx
+++ b/plugins/ui/src/js/src/widget/WidgetErrorView.tsx
@@ -21,7 +21,7 @@ import {
} from './WidgetUtils';
/** Component that display an error message. Will automatically show a button for more info and an action button if the error has an Action defined */
-function WidgetErrorView({
+export function WidgetErrorView({
error,
}: {
error: NonNullable;
diff --git a/plugins/ui/src/js/src/widget/WidgetHandler.test.tsx b/plugins/ui/src/js/src/widget/WidgetHandler.test.tsx
index ae6288872..208c2d744 100644
--- a/plugins/ui/src/js/src/widget/WidgetHandler.test.tsx
+++ b/plugins/ui/src/js/src/widget/WidgetHandler.test.tsx
@@ -1,6 +1,8 @@
import React from 'react';
import { act, render } from '@testing-library/react';
import { useWidget } from '@deephaven/jsapi-bootstrap';
+import { dh } from '@deephaven/jsapi-types';
+import { TestUtils } from '@deephaven/utils';
import WidgetHandler, { WidgetHandlerProps } from './WidgetHandler';
import { DocumentHandlerProps } from './DocumentHandler';
import {
@@ -11,10 +13,14 @@ import {
} from './WidgetTestUtils';
const mockApi = { Widget: { EVENT_MESSAGE: 'message' } };
-let mockWidgetWrapper: ReturnType = {
- widget: null,
+const defaultWidgetWrapper: ReturnType = {
+ widget: TestUtils.createMockProxy({
+ getDataAsString: jest.fn(() => ''),
+ exportedObjects: [],
+ }),
error: null,
};
+let mockWidgetWrapper: ReturnType = defaultWidgetWrapper;
jest.mock('@deephaven/jsapi-bootstrap', () => ({
useApi: jest.fn(() => mockApi),
useWidget: jest.fn(() => mockWidgetWrapper),
@@ -43,7 +49,7 @@ function makeWidgetHandler({
}
beforeEach(() => {
- mockWidgetWrapper = { widget: null, error: null };
+ mockWidgetWrapper = defaultWidgetWrapper;
mockDocumentHandler.mockClear();
});
diff --git a/plugins/ui/src/js/src/widget/WidgetHandler.tsx b/plugins/ui/src/js/src/widget/WidgetHandler.tsx
index 43f665322..e4184cfd1 100644
--- a/plugins/ui/src/js/src/widget/WidgetHandler.tsx
+++ b/plugins/ui/src/js/src/widget/WidgetHandler.tsx
@@ -36,8 +36,10 @@ import {
} from './WidgetTypes';
import DocumentHandler from './DocumentHandler';
import { getComponentForElement, wrapCallable } from './WidgetUtils';
+import WidgetStatusContext, {
+ WidgetStatus,
+} from '../layout/WidgetStatusContext';
import WidgetErrorView from './WidgetErrorView';
-import ReactPanelContentOverlayContext from '../layout/ReactPanelContentOverlayContext';
const log = Log.module('@deephaven/js-plugin-ui/WidgetHandler');
@@ -322,33 +324,31 @@ function WidgetHandler({
[jsonClient, initialData, sendSetState, updateExportedObjects, widget]
);
- const errorView = useMemo(() => {
+ const renderedDocument = useMemo(() => {
+ if (document != null) {
+ return document;
+ }
if (error != null) {
+ // If there's an error and the document hasn't rendered yet, explicitly show an error view
return ;
}
return null;
- }, [error]);
+ }, [document, error]);
- const contentOverlay = useMemo(() => {
- // We only show it as an overlay if there's already a document to show
- // If there isn't, then we'll just render this as the document so it forces a panel to open
- if (errorView != null && document != null) {
- return errorView;
+ const widgetStatus: WidgetStatus = useMemo(() => {
+ if (error != null) {
+ return { status: 'error', descriptor: widgetDescriptor, error };
}
- return null;
- }, [document, errorView]);
-
- const renderedDocument = useMemo(() => {
- if (document != null) {
- return document;
+ if (renderedDocument != null) {
+ return { status: 'ready', descriptor: widgetDescriptor };
}
- return errorView;
- }, [document, errorView]);
+ return { status: 'loading', descriptor: widgetDescriptor };
+ }, [error, renderedDocument, widgetDescriptor]);
return useMemo(
() =>
- renderedDocument != null ? (
-
+ renderedDocument ? (
+
{renderedDocument}
-
+
) : null,
[
- contentOverlay,
widgetDescriptor,
renderedDocument,
initialData,
onClose,
onDataChange,
+ widgetStatus,
]
);
}
diff --git a/plugins/ui/src/ui.schema.json b/plugins/ui/src/ui.schema.json
index 82cccb99b..e4fa98d86 100644
--- a/plugins/ui/src/ui.schema.json
+++ b/plugins/ui/src/ui.schema.json
@@ -60,6 +60,16 @@
"type": "array",
"prefixItems": [{ "type": "object" }],
"items": false
+ },
+ "callCallableParams": {
+ "type": "array",
+ "prefixItems": [{ "type": "string" }, { "type": "array" }],
+ "items": false
+ },
+ "closeCallableParams": {
+ "type": "array",
+ "prefixItems": [{ "type": "string" }],
+ "items": false
}
},
"type": "object",
@@ -67,8 +77,15 @@
"jsonrpc": "2.0",
"method": {
"anyOf": [
- { "enum": ["documentUpdated", "documentError"] },
- { "pattern": "^cb_(0-9)+_(0-9)+$" }
+ {
+ "enum": [
+ "documentUpdated",
+ "documentError",
+ "setState",
+ "callCallable",
+ "closeCallable"
+ ]
+ }
]
},
"allOf": [
@@ -111,15 +128,24 @@
{
"if": {
"properties": {
- "method": { "pattern": "^cb_(0-9)+_(0-9)+$" }
+ "method": { "pattern": "callCallable" }
}
},
"then": {
"properties": {
- "params": {
- "type": "array",
- "items": { "type": "any" }
- }
+ "params": { "$ref": "#/defs/callCallableParams" }
+ }
+ }
+ },
+ {
+ "if": {
+ "properties": {
+ "method": { "pattern": "closeCallable" }
+ }
+ },
+ "then": {
+ "properties": {
+ "params": { "$ref": "#/defs/closeCallableParams" }
}
}
}
diff --git a/ruff.toml b/ruff.toml
index 96bed7376..b2c9ff227 100644
--- a/ruff.toml
+++ b/ruff.toml
@@ -1,5 +1,8 @@
[lint]
-select = ["ANN001"]
+select = ["ANN001", "TID251"]
[lint.per-file-ignores]
-"**/{test,matplotlib,json,plotly}/*" = ["ANN001"]
\ No newline at end of file
+"**/{test,matplotlib,json,plotly}/*" = ["ANN001"]
+
+[lint.flake8-tidy-imports.banned-api]
+"numbers".msg = "Import from numbers is likely an accident. `float` includes `int` and is likely the desired type."
\ No newline at end of file
diff --git a/tests/ui.spec.ts b/tests/ui.spec.ts
index 232233169..b07180fc5 100644
--- a/tests/ui.spec.ts
+++ b/tests/ui.spec.ts
@@ -24,7 +24,6 @@ test('boom component shows an error in a panel', async ({ page }) => {
await expect(
page.locator(selector.REACT_PANEL_VISIBLE).getByText('BOOM!')
).toBeVisible();
- await expect(page.locator(selector.REACT_PANEL_OVERLAY)).not.toBeVisible();
});
test('boom counter component shows error overlay after clicking the button twice', async ({
@@ -43,12 +42,10 @@ test('boom counter component shows error overlay after clicking the button twice
await expect(btn).toBeVisible();
btn.click();
- const overlayLocator = page.locator(selector.REACT_PANEL_OVERLAY);
-
await expect(
- overlayLocator.getByText('ValueError', { exact: true })
+ panelLocator.getByText('ValueError', { exact: true })
).toBeVisible();
- await expect(overlayLocator.getByText('BOOM! Value too big.')).toBeVisible();
+ await expect(panelLocator.getByText('BOOM! Value too big.')).toBeVisible();
});
test('UI all components render', async ({ page }) => {