Skip to content

Commit

Permalink
feat: Add embed-widget
Browse files Browse the repository at this point in the history
- Loads the widget and displays it if there is a widget plugin that matches
  - Displays an error if no plugin is found that can display the type of widget, or a widget with that name is not found
- Fixes deephaven#1629
  • Loading branch information
mofojed committed Dec 6, 2023
1 parent 1b8a33e commit d06ade9
Show file tree
Hide file tree
Showing 15 changed files with 173 additions and 79 deletions.
2 changes: 0 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

39 changes: 39 additions & 0 deletions packages/components/src/ErrorBoundary.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import React from 'react';
import { render } from '@testing-library/react';
import ErrorBoundary, { ErrorBoundaryProps } from './ErrorBoundary';

function ThrowComponent(): JSX.Element {
throw new Error('Test error');
}

function makeWrapper({
children = 'Hello World',
className,
onError = jest.fn(),
fallback,
}: Partial<ErrorBoundaryProps> = {}) {
return render(
<ErrorBoundary className={className} fallback={fallback} onError={onError}>
{children}
</ErrorBoundary>
);
}

it('should render the children if there is no error', () => {
const onError = jest.fn();
const { getByText } = makeWrapper({ onError });
expect(getByText('Hello World')).toBeInTheDocument();
expect(onError).not.toHaveBeenCalled();
});

it('should render the fallback if there is an error', () => {
const onError = jest.fn();
const error = new Error('Test error');
const { getByText } = makeWrapper({
children: <ThrowComponent />,
fallback: <div>Fallback</div>,
onError,
});
expect(getByText('Fallback')).toBeInTheDocument();
expect(onError).toHaveBeenCalledWith(error, expect.anything());
});
70 changes: 70 additions & 0 deletions packages/components/src/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import Log from '@deephaven/log';
import React, { Component, ReactNode } from 'react';
import LoadingOverlay from './LoadingOverlay';

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

export interface ErrorBoundaryProps {
/** Children to catch errors from */
children: ReactNode;

/** Classname to wrap the error message with */
className?: string;

/** Callback for when an error occurs */
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;

/** Custom fallback element */
fallback?: ReactNode;
}

export interface ErrorBoundaryState {
error?: Error;
}

/**
* Error boundary for catching render errors in React. Displays an error message if an error is caught by default, or you can specify a fallback component to render.
* https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary
*/
export class ErrorBoundary extends Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { error };
}

constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { error: undefined };
}

componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
const { onError } = this.props;
log.error('Error caught by ErrorBoundary', error, errorInfo);
onError?.(error, errorInfo);
}

render(): ReactNode {
const { children, className, fallback } = this.props;
const { error } = this.state;
if (error != null) {
if (fallback != null) {
return fallback;
}

return (
<div className={className}>
<LoadingOverlay
errorMessage={`${error}`}
isLoading={false}
isLoaded={false}
/>
</div>
);
}
return children;
}
}

export default ErrorBoundary;
1 change: 1 addition & 0 deletions packages/components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export { default as DraggableItemList } from './DraggableItemList';
export * from './DraggableItemList';
export { default as DragUtils } from './DragUtils';
export { default as EditableItemList } from './EditableItemList';
export * from './ErrorBoundary';
export { default as HierarchicalCheckboxMenu } from './HierarchicalCheckboxMenu';
export * from './HierarchicalCheckboxMenu';
export * from './ItemList';
Expand Down
2 changes: 1 addition & 1 deletion packages/dashboard-core-plugins/src/ChartPanelPlugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ export const ChartPanelPlugin = forwardRef(
chartTheme,
connection,
metadata as ChartPanelMetadata,
fetch as unknown as () => Promise<Figure>,
fetch as () => Promise<Figure>,
panelState
);
},
Expand Down
2 changes: 1 addition & 1 deletion packages/dashboard-core-plugins/src/GridPanelPlugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export const GridPanelPlugin = forwardRef(
(props: WidgetPanelProps, ref: React.Ref<IrisGridPanel>) => {
const { localDashboardId, fetch } = props;
const hydratedProps = useHydrateGrid(
fetch as unknown as () => Promise<Table>,
fetch as () => Promise<Table>,
localDashboardId
);

Expand Down
2 changes: 1 addition & 1 deletion packages/dashboard-core-plugins/src/PandasPanelPlugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export const PandasPanelPlugin = forwardRef(
(props: WidgetPanelProps, ref: React.Ref<PandasPanel>) => {
const { localDashboardId, fetch } = props;
const hydratedProps = useHydrateGrid(
fetch as unknown as () => Promise<Table>,
fetch as () => Promise<Table>,
localDashboardId
);

Expand Down
1 change: 0 additions & 1 deletion packages/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@
"dependencies": {
"@deephaven/components": "file:../components",
"@deephaven/golden-layout": "file:../golden-layout",
"@deephaven/jsapi-bootstrap": "file:../jsapi-bootstrap",
"@deephaven/log": "file:../log",
"@deephaven/react-hooks": "file:../react-hooks",
"@deephaven/redux": "file:../redux",
Expand Down
1 change: 0 additions & 1 deletion packages/dashboard/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
"references": [
{ "path": "../components" },
{ "path": "../golden-layout" },
{ "path": "../jsapi-bootstrap" },
{ "path": "../log" },
{ "path": "../react-hooks" },
{ "path": "../redux" },
Expand Down
58 changes: 27 additions & 31 deletions packages/embed-widget/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,18 @@
import React, { useEffect, useMemo, useState } from 'react';
import { ContextMenuRoot, LoadingOverlay } from '@deephaven/components'; // Use the loading spinner from the Deephaven components package
import type { dh as DhType, IdeConnection } from '@deephaven/jsapi-types';
import {
ContextMenuRoot,
ErrorBoundary,
LoadingOverlay,
} from '@deephaven/components'; // Use the loading spinner from the Deephaven components package
import type { VariableDefinition } from '@deephaven/jsapi-types';
import Log from '@deephaven/log';
import './App.scss'; // Styles for in this app
import { useApi } from '@deephaven/jsapi-bootstrap';
import { useConnection } from '@deephaven/jsapi-components';
import EmbeddedWidget, { EmbeddedWidgetType } from './EmbeddedWidget';
import { fetchVariableDefinition } from '@deephaven/jsapi-utils';
import { WidgetView } from '@deephaven/plugin';

const log = Log.module('EmbedWidget.App');

/**
* Load an existing Deephaven widget with the connection provided
* @param dh JSAPI instance
* @param connection The Deephaven Connection object
* @param name Name of the widget to load
* @returns Deephaven widget
*/
async function loadWidget(
dh: DhType,
connection: IdeConnection,
name: string
): Promise<EmbeddedWidgetType> {
log.info(`Fetching widget ${name}...`);

const definition = await connection.getVariableDefinition(name);
const fetch = () => connection.getObject(definition);
return { definition, fetch };
}

/**
* A functional React component that displays a Deephaven figure using the @deephaven/chart package.
* It will attempt to open and display the figure specified with the `name` parameter, expecting it to be present on the server.
Expand All @@ -38,14 +23,13 @@ async function loadWidget(
*/
function App(): JSX.Element {
const [error, setError] = useState<string>();
const [embeddedWidget, setEmbeddedWidget] = useState<EmbeddedWidgetType>();
const [definition, setDefinition] = useState<VariableDefinition>();
const [isLoading, setIsLoading] = useState(true);
const searchParams = useMemo(
() => new URLSearchParams(window.location.search),
[]
);
const connection = useConnection();
const dh = useApi();

useEffect(
function initializeApp() {
Expand All @@ -60,10 +44,9 @@ function App(): JSX.Element {

log.debug('Loading widget', name, '...');

// Load the widget.
const newWidget = await loadWidget(dh, connection, name);
const newDefinition = await fetchVariableDefinition(connection, name);

setEmbeddedWidget(newWidget);
setDefinition(newDefinition);

log.debug('Widget successfully loaded!');
} catch (e: unknown) {
Expand All @@ -74,14 +57,27 @@ function App(): JSX.Element {
}
initApp();
},
[dh, connection, searchParams]
[connection, searchParams]
);

const isLoaded = embeddedWidget != null;
const isLoaded = definition != null;

const fetch = useMemo(() => {
if (definition == null) {
return async () => {
throw new Error('Definition is null');
};
}
return () => connection.getObject(definition);
}, [connection, definition]);

return (
<div className="App">
{isLoaded && <EmbeddedWidget widget={embeddedWidget} />}
{isLoaded && (
<ErrorBoundary>
<WidgetView type={definition.type} fetch={fetch} />
</ErrorBoundary>
)}
{!isLoaded && (
<LoadingOverlay
isLoaded={isLoaded}
Expand Down
38 changes: 0 additions & 38 deletions packages/embed-widget/src/EmbeddedWidget.tsx

This file was deleted.

1 change: 0 additions & 1 deletion packages/jsapi-types/src/dh.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1071,7 +1071,6 @@ export interface IdeConnection
subscribeToFieldUpdates: (
param: (changes: VariableChanges) => void
) => () => void;
getVariableDefinition: (name: string) => Promise<VariableDefinition>;
}

export interface ItemDetails {
Expand Down
31 changes: 31 additions & 0 deletions packages/plugin/src/ObjectView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import React, { useMemo } from 'react';
import usePlugins from './usePlugins';
import { isWidgetPlugin } from './PluginTypes';

export type WidgetViewProps = {
/** Fetch function to return the widget */
fetch: () => Promise<unknown>;

/** Type of the widget */
type: string;
};

export function WidgetView({ fetch, type }: WidgetViewProps): JSX.Element {
const plugins = usePlugins();
const plugin = useMemo(
() =>
[...plugins.values()]
.filter(isWidgetPlugin)
.find(p => [p.supportedTypes].flat().includes(type)),
[plugins, type]
);

if (plugin != null) {
const Component = plugin.component;
return <Component fetch={fetch} />;
}

throw new Error(`Unknown widget type '${type}'`);
}

export default WidgetView;
3 changes: 1 addition & 2 deletions packages/plugin/src/PluginTypes.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { BaseThemeType } from '@deephaven/components';
import { type Widget } from '@deephaven/jsapi-types';
import {
type EventEmitter,
type ItemContainer,
Expand Down Expand Up @@ -105,7 +104,7 @@ export function isDashboardPlugin(
}

export interface WidgetComponentProps {
fetch: () => Promise<Widget>;
fetch: () => Promise<unknown>;
}

export interface WidgetPanelProps extends WidgetComponentProps {
Expand Down
1 change: 1 addition & 0 deletions packages/plugin/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './ObjectView';
export * from './PluginsContext';
export * from './PluginTypes';
export * from './PluginUtils';
Expand Down

0 comments on commit d06ade9

Please sign in to comment.