diff --git a/package-lock.json b/package-lock.json index 9392a357d9..fac28af5cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -85,6 +85,7 @@ "@types/react": "^17.0.2", "@types/react-beautiful-dnd": "^13.1.2", "@types/react-dom": "^17.0.9", + "@types/react-is": "^17.0.2", "@types/react-plotly.js": "^2.6.0", "@types/react-router-dom": "^5.1.2", "@types/react-test-renderer": "^17.0.1", @@ -8724,6 +8725,15 @@ "@types/react": "*" } }, + "node_modules/@types/react-is": { + "version": "17.0.5", + "resolved": "https://registry.npmjs.org/@types/react-is/-/react-is-17.0.5.tgz", + "integrity": "sha512-mTTgVYfA8fLtyyuppoO7hQJWLV5TNivNKle5vBryoOwlA5158/4UgcZFOx59po/zC0K2ZBEv/IRATlxdVJRVQA==", + "dev": true, + "dependencies": { + "@types/react": "^17" + } + }, "node_modules/@types/react-plotly.js": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/@types/react-plotly.js/-/react-plotly.js-2.6.0.tgz", @@ -28275,6 +28285,7 @@ "peerDependencies": { "react": "^17.0.0", "react-dom": "^17.0.0", + "react-is": "^17.0.0", "react-redux": "^7.2.4" } }, @@ -28344,6 +28355,12 @@ "@types/lodash": "*" } }, + "packages/dashboard/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "peer": true + }, "packages/embed-chart": { "name": "@deephaven/embed-chart", "version": "0.50.0", @@ -28659,11 +28676,18 @@ "license": "Apache-2.0", "dependencies": { "@deephaven/components": "file:../components", + "@deephaven/golden-layout": "file:../golden-layout", + "@deephaven/icons": "file:../icons", "@deephaven/iris-grid": "file:../iris-grid", - "@deephaven/jsapi-types": "file:../jsapi-types" + "@deephaven/jsapi-types": "file:../jsapi-types", + "@fortawesome/fontawesome-common-types": "^6.1.1", + "@fortawesome/react-fontawesome": "^0.2.0" }, "engines": { "node": ">=16" + }, + "peerDependencies": { + "react": "^17.x" } }, "packages/plugin-utils": { @@ -30266,6 +30290,12 @@ "requires": { "@types/lodash": "*" } + }, + "react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "peer": true } } }, @@ -30509,8 +30539,12 @@ "version": "file:packages/plugin", "requires": { "@deephaven/components": "file:../components", + "@deephaven/golden-layout": "file:../golden-layout", + "@deephaven/icons": "file:../icons", "@deephaven/iris-grid": "file:../iris-grid", - "@deephaven/jsapi-types": "file:../jsapi-types" + "@deephaven/jsapi-types": "file:../jsapi-types", + "@fortawesome/fontawesome-common-types": "^6.1.1", + "@fortawesome/react-fontawesome": "^0.2.0" } }, "@deephaven/pouch-storage": { @@ -35504,6 +35538,15 @@ "@types/react": "*" } }, + "@types/react-is": { + "version": "17.0.5", + "resolved": "https://registry.npmjs.org/@types/react-is/-/react-is-17.0.5.tgz", + "integrity": "sha512-mTTgVYfA8fLtyyuppoO7hQJWLV5TNivNKle5vBryoOwlA5158/4UgcZFOx59po/zC0K2ZBEv/IRATlxdVJRVQA==", + "dev": true, + "requires": { + "@types/react": "^17" + } + }, "@types/react-plotly.js": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/@types/react-plotly.js/-/react-plotly.js-2.6.0.tgz", diff --git a/package.json b/package.json index 4e41456ff5..edf80f7449 100644 --- a/package.json +++ b/package.json @@ -96,6 +96,7 @@ "@types/react": "^17.0.2", "@types/react-beautiful-dnd": "^13.1.2", "@types/react-dom": "^17.0.9", + "@types/react-is": "^17.0.2", "@types/react-plotly.js": "^2.6.0", "@types/react-router-dom": "^5.1.2", "@types/react-test-renderer": "^17.0.1", diff --git a/packages/app-utils/src/components/AppBootstrap.tsx b/packages/app-utils/src/components/AppBootstrap.tsx index 00d3f59368..d0305d0125 100644 --- a/packages/app-utils/src/components/AppBootstrap.tsx +++ b/packages/app-utils/src/components/AppBootstrap.tsx @@ -5,7 +5,7 @@ import { RefreshTokenBootstrap, useBroadcastLoginListener, } from '@deephaven/jsapi-components'; -import { type DashboardPlugin } from '@deephaven/plugin'; +import { type Plugin } from '@deephaven/plugin'; import FontBootstrap from './FontBootstrap'; import PluginsBootstrap from './PluginsBootstrap'; import AuthBootstrap from './AuthBootstrap'; @@ -24,7 +24,7 @@ export type AppBootstrapProps = { pluginsUrl: string; /** The core plugins to load. */ - getCorePlugins?: () => Promise; + getCorePlugins?: () => Promise; /** Font class names to load. */ fontClassNames?: string[]; diff --git a/packages/app-utils/src/components/PluginsBootstrap.tsx b/packages/app-utils/src/components/PluginsBootstrap.tsx index a60dfd3966..5fdea74c82 100644 --- a/packages/app-utils/src/components/PluginsBootstrap.tsx +++ b/packages/app-utils/src/components/PluginsBootstrap.tsx @@ -1,4 +1,4 @@ -import { type DashboardPlugin } from '@deephaven/plugin'; +import { type Plugin } from '@deephaven/plugin'; import React, { createContext, useEffect, useState } from 'react'; import { PluginModuleMap, loadModulePlugins } from '../plugins'; @@ -11,7 +11,7 @@ export type PluginsBootstrapProps = { pluginsUrl: string; /** The core plugins to load. */ - getCorePlugins?: () => Promise; + getCorePlugins?: () => Promise; /** * The children to render wrapped with the PluginsContext. diff --git a/packages/code-studio/src/index.tsx b/packages/code-studio/src/index.tsx index 9205d0efac..5daf81ceb7 100644 --- a/packages/code-studio/src/index.tsx +++ b/packages/code-studio/src/index.tsx @@ -42,6 +42,7 @@ async function getCorePlugins() { FilterPluginConfig, MarkdownPluginConfig, LinkerPluginConfig, + WidgetLoaderPluginConfig, } = dashboardCorePlugins; return [ GridPluginConfig, @@ -51,6 +52,7 @@ async function getCorePlugins() { FilterPluginConfig, MarkdownPluginConfig, LinkerPluginConfig, + WidgetLoaderPluginConfig, ]; } diff --git a/packages/console/src/Console.tsx b/packages/console/src/Console.tsx index e09f07d010..74bcdfca51 100644 --- a/packages/console/src/Console.tsx +++ b/packages/console/src/Console.tsx @@ -79,6 +79,8 @@ interface ConsoleProps { * (file:File) => Promise */ unzip: (file: File) => Promise; + supportsType(type: string): boolean; + iconForType(type: string): ReactElement; } interface ConsoleState { @@ -105,6 +107,16 @@ interface ConsoleState { isPrintStdOutEnabled: boolean; isClosePanelsOnDisconnectEnabled: boolean; } + +function defaultSupportsType(): boolean { + return true; +} + +function defaultIconForType(type: string): ReactElement { + // eslint-disable-next-line react/jsx-no-useless-fragment + return <>; +} + export class Console extends PureComponent { static defaultProps = { statusBarChildren: null, @@ -117,6 +129,8 @@ export class Console extends PureComponent { objectMap: new Map(), disabled: false, unzip: null, + supportsType: defaultSupportsType, + iconForType: defaultIconForType, }; static LOG_THROTTLE = 500; @@ -951,6 +965,8 @@ export class Console extends PureComponent { timeZone, disabled, unzip, + supportsType, + iconForType, } = this.props; const { consoleHeight, @@ -1013,6 +1029,8 @@ export class Console extends PureComponent { openObject={openObject} language={language} disabled={disabled} + supportsType={supportsType} + iconForType={iconForType} /> {historyChildren} diff --git a/packages/console/src/console-history/ConsoleHistory.tsx b/packages/console/src/console-history/ConsoleHistory.tsx index 268684d3cd..4d5a8f22ba 100644 --- a/packages/console/src/console-history/ConsoleHistory.tsx +++ b/packages/console/src/console-history/ConsoleHistory.tsx @@ -1,7 +1,7 @@ /** * Console display for use in the Iris environment. */ -import React, { Component, ReactElement } from 'react'; +import { type ReactElement } from 'react'; import type { VariableDefinition } from '@deephaven/jsapi-types'; import ConsoleHistoryItem from './ConsoleHistoryItem'; @@ -13,43 +13,45 @@ interface ConsoleHistoryProps { language: string; openObject: (object: VariableDefinition) => void; disabled?: boolean; + supportsType(type: string): boolean; + iconForType(type: string): ReactElement; } -class ConsoleHistory extends Component< - ConsoleHistoryProps, - Record -> { - static defaultProps = { - disabled: false, - }; - - static itemKey(i: number, item: ConsoleHistoryActionItem): string { - return `${i}.${item.command}.${item.result && item.result.message}.${ - item.result && item.result.error - }`; - } - - render(): ReactElement { - const { disabled, items, language, openObject } = this.props; - const historyElements = []; - for (let i = 0; i < items.length; i += 1) { - const item = items[i]; - const historyElement = ( - - ); - historyElements.push(historyElement); - } +function itemKey(i: number, item: ConsoleHistoryActionItem): string { + return `${i}.${item.command}.${item.result && item.result.message}.${ + item.result && item.result.error + }`; +} - return ( -
{historyElements}
+function ConsoleHistory(props: ConsoleHistoryProps): ReactElement { + const { + disabled = false, + items, + language, + openObject, + supportsType, + iconForType, + } = props; + const historyElements = []; + for (let i = 0; i < items.length; i += 1) { + const item = items[i]; + const historyElement = ( + ); + historyElements.push(historyElement); } + + return ( +
{historyElements}
+ ); } export default ConsoleHistory; diff --git a/packages/console/src/console-history/ConsoleHistoryItem.tsx b/packages/console/src/console-history/ConsoleHistoryItem.tsx index 2746377ba9..eb85d163f1 100644 --- a/packages/console/src/console-history/ConsoleHistoryItem.tsx +++ b/packages/console/src/console-history/ConsoleHistoryItem.tsx @@ -6,7 +6,7 @@ import { Button } from '@deephaven/components'; import Log from '@deephaven/log'; import type { VariableDefinition } from '@deephaven/jsapi-types'; import classNames from 'classnames'; -import { Code, ObjectIcon } from '../common'; +import { Code } from '../common'; import ConsoleHistoryItemResult from './ConsoleHistoryItemResult'; import ConsoleHistoryResultInProgress from './ConsoleHistoryResultInProgress'; import ConsoleHistoryResultErrorMessage from './ConsoleHistoryResultErrorMessage'; @@ -20,6 +20,10 @@ interface ConsoleHistoryItemProps { language: string; openObject: (object: VariableDefinition) => void; disabled?: boolean; + // TODO: #1573 Remove this eslint disable + // eslint-disable-next-line react/no-unused-prop-types + supportsType: (type: string) => boolean; + iconForType: (type: string) => ReactElement; } class ConsoleHistoryItem extends PureComponent< @@ -53,7 +57,7 @@ class ConsoleHistoryItem extends PureComponent< } render(): ReactElement { - const { disabled, item, language } = this.props; + const { disabled, item, language, iconForType } = this.props; const { disabledObjects, result } = item; const hasCommand = item.command != null && item.command !== ''; @@ -77,6 +81,9 @@ class ConsoleHistoryItem extends PureComponent< if (changes) { const { created, updated } = changes; + // TODO: #1573 filter for supported types or change button kind + // based on if type is supported. Possibly a warn state for widgets + // that the UI doesn't have anything registered to support. [...created, ...updated].forEach(object => { hasButtons = true; const { title } = object; @@ -92,7 +99,7 @@ class ConsoleHistoryItem extends PureComponent< onClick={() => this.handleObjectClick(object)} className="btn-console-object" disabled={btnDisabled} - icon={} + icon={iconForType(object.type)} > {title} diff --git a/packages/dashboard-core-plugins/src/PandasPlugin.tsx b/packages/dashboard-core-plugins/src/PandasPlugin.tsx index c7d3594c6c..756ed0c3aa 100644 --- a/packages/dashboard-core-plugins/src/PandasPlugin.tsx +++ b/packages/dashboard-core-plugins/src/PandasPlugin.tsx @@ -1,28 +1,23 @@ -import { - assertIsDashboardPluginProps, - DashboardPluginComponentProps, - useDashboardPanel, -} from '@deephaven/dashboard'; -import { useApi } from '@deephaven/jsapi-bootstrap'; +import { DashboardPanelProps } from '@deephaven/dashboard'; +import { WidgetComponentProps } from '@deephaven/plugin'; +import { forwardRef, useMemo } from 'react'; import { PandasPanel } from './panels'; import useHydrateGrid from './useHydrateGrid'; -export function PandasPlugin( - props: DashboardPluginComponentProps -): JSX.Element | null { - assertIsDashboardPluginProps(props); - const dh = useApi(); - const hydrate = useHydrateGrid(); +export const PandasPlugin = forwardRef( + (props: WidgetComponentProps, ref: React.Ref) => { + const hydrate = useHydrateGrid(); + const { localDashboardId } = props; + const hydratedProps = useMemo( + () => hydrate(props, localDashboardId), + [hydrate, props, localDashboardId] + ); - useDashboardPanel({ - dashboardProps: props, - componentName: PandasPanel.COMPONENT, - component: PandasPanel, - supportedTypes: dh.VariableType.PANDAS, - hydrate, - }); + // eslint-disable-next-line react/jsx-props-no-spreading + return ; + } +); - return null; -} +PandasPlugin.displayName = 'PandasPlugin'; export default PandasPlugin; diff --git a/packages/dashboard-core-plugins/src/PandasPluginConfig.ts b/packages/dashboard-core-plugins/src/PandasPluginConfig.ts index 2d65ccff26..d34828413a 100644 --- a/packages/dashboard-core-plugins/src/PandasPluginConfig.ts +++ b/packages/dashboard-core-plugins/src/PandasPluginConfig.ts @@ -1,10 +1,16 @@ -import { PluginType, DashboardPlugin } from '@deephaven/plugin'; +import { PluginType, WidgetPlugin } from '@deephaven/plugin'; +import { dhPandas } from '@deephaven/icons'; import PandasPlugin from './PandasPlugin'; -const PandasPluginConfig: DashboardPlugin = { +const PandasPluginConfig: WidgetPlugin = { name: 'PandasPlugin', - type: PluginType.DASHBOARD_PLUGIN, + title: 'Pandas', + type: PluginType.WIDGET_PLUGIN, + // TODO: #1573 Replace with actual base component and not just the panel plugin component: PandasPlugin, + panelComponent: PandasPlugin, + supportedTypes: 'pandas.DataFrame', + icon: dhPandas, }; export default PandasPluginConfig; diff --git a/packages/dashboard-core-plugins/src/WidgetLoaderPlugin.test.tsx b/packages/dashboard-core-plugins/src/WidgetLoaderPlugin.test.tsx new file mode 100644 index 0000000000..77c12c3f6f --- /dev/null +++ b/packages/dashboard-core-plugins/src/WidgetLoaderPlugin.test.tsx @@ -0,0 +1,275 @@ +import React from 'react'; +import { render, screen, act } from '@testing-library/react'; +import { + PluginType, + type WidgetPlugin, + type WidgetComponentProps, +} from '@deephaven/plugin'; +import { Provider } from 'react-redux'; +import { Dashboard, PanelEvent } from '@deephaven/dashboard'; +import { createMockStore } from '@deephaven/redux'; +import { dh } from '@deephaven/jsapi-shim'; +import { ApiContext } from '@deephaven/jsapi-bootstrap'; +import { type IdeConnection } from '@deephaven/jsapi-types'; +import { ConnectionContext, PluginsContext } from '@deephaven/app-utils'; +import { + type LayoutManager, + EventEmitter, + type ItemContainer, +} from '@deephaven/golden-layout'; +import { assertNotNull } from '@deephaven/utils'; +import WidgetLoaderPlugin, { WrapWidgetPlugin } from './WidgetLoaderPlugin'; +import WidgetLoaderPluginConfig from './WidgetLoaderPluginConfig'; + +function TestWidget() { + return
TestWidget
; +} + +function TestWidgetTwo() { + return
TestWidgetTwo
; +} + +function TestPanel() { + return
TestPanel
; +} + +class TestForwardRef extends React.PureComponent { + render() { + return
TestForwardRef
; + } +} + +const testWidgetPlugin: WidgetPlugin = { + component: TestWidget, + name: 'widget', + title: 'Widget', + type: PluginType.WIDGET_PLUGIN, + supportedTypes: 'test-widget', +}; + +const testWidgetPluginWithPanel: WidgetPlugin = { + name: 'widget-with-panel', + title: 'Widget', + type: PluginType.WIDGET_PLUGIN, + supportedTypes: 'test-widget-panel', + component: TestWidget, + panelComponent: TestPanel, +}; + +const testWidgetRefPlugin: WidgetPlugin = { + component: TestForwardRef, + name: 'widget', + title: 'Widget', + type: PluginType.WIDGET_PLUGIN, + supportedTypes: 'test-widget-ref', +}; + +function makeConnection(): IdeConnection { + const connection = new dh.IdeConnection('http://mockserver'); + connection.getObject = jest.fn(); + return connection; +} + +const DEFAULT_PLUGINS = [ + ['test-widget-plugin', testWidgetPlugin], + ['test-widget-plugin-with-panel', testWidgetPluginWithPanel], + ['test-dashboard-plugin', WidgetLoaderPluginConfig], +] as [string, WidgetPlugin][]; + +function createAndMountDashboard( + plugins: [string, WidgetPlugin][] = DEFAULT_PLUGINS +) { + const store = createMockStore(); + const connection = makeConnection(); + let layoutManager: LayoutManager | undefined; + + render( + + + (plugins)}> + + { + layoutManager = newLayout; + }} + > + + + + + + + ); + assertNotNull(layoutManager); + return layoutManager; +} + +describe('WidgetLoaderPlugin', () => { + it('Mounts components that should be wrapped', async () => { + const layoutManager = createAndMountDashboard(); + + act( + () => + layoutManager?.eventHub.emit(PanelEvent.OPEN, { + widget: { type: 'test-widget' }, + }) + ); + expect(screen.queryAllByText('TestWidget').length).toBe(1); + }); + + it('Mounts components that should not be wrapped', async () => { + const layoutManager = createAndMountDashboard(); + + act( + () => + layoutManager?.eventHub.emit(PanelEvent.OPEN, { + widget: { type: 'test-widget-panel' }, + }) + ); + expect(screen.queryAllByText('TestPanel').length).toBe(1); + }); + + it('Handles plugins with multiple supported types', async () => { + const layoutManager = createAndMountDashboard([ + [ + 'test-widget-plugin-two', + { + name: 'test-widget-plugin-two', + type: PluginType.WIDGET_PLUGIN, + component: TestWidgetTwo, + supportedTypes: ['test-widget-two-a', 'test-widget-two-b'], + }, + ], + ]); + + act( + () => + layoutManager?.eventHub.emit(PanelEvent.OPEN, { + widget: { type: 'test-widget-two-a' }, + }) + ); + expect(screen.queryAllByText('TestWidgetTwo').length).toBe(1); + + act( + () => + layoutManager?.eventHub.emit(PanelEvent.OPEN, { + widget: { type: 'test-widget-two-b' }, + }) + ); + expect(screen.queryAllByText('TestWidgetTwo').length).toBe(2); + }); + + it('Ignores unknown widget types', async () => { + const layoutManager = createAndMountDashboard(); + + act( + () => + layoutManager?.eventHub.emit(PanelEvent.OPEN, { + widget: { type: 'unknown-widget' }, + }) + ); + expect(screen.queryAllByText('TestWidget').length).toBe(0); + expect(screen.queryAllByText('TestPanel').length).toBe(0); + }); + + it('Does not mount if the plugin does not have supportedTypes', async () => { + const layoutManager = createAndMountDashboard([ + [ + testWidgetPlugin.name, + { + ...testWidgetPlugin, + supportedTypes: undefined, + }, + ], + ]); + + act( + () => + layoutManager?.eventHub.emit(PanelEvent.OPEN, { + widget: { type: 'test-widget' }, + }) + ); + expect(screen.queryAllByText('TestWidget').length).toBe(0); + }); + + it('Overrides plugins that handle the same widget type', async () => { + const layoutManager = createAndMountDashboard([ + [ + 'test-widget-plugin', + { + ...testWidgetPlugin, + supportedTypes: ['test-widget', 'test-widget-a'], + }, + ], + [ + 'test-widget-plugin-two', + { + name: 'test-widget-plugin-two', + type: PluginType.WIDGET_PLUGIN, + component: TestWidgetTwo, + supportedTypes: 'test-widget', + }, + ], + ]); + + act( + () => + layoutManager?.eventHub.emit(PanelEvent.OPEN, { + widget: { type: 'test-widget' }, + }) + ); + expect(screen.queryAllByText('TestWidget').length).toBe(0); + expect(screen.queryAllByText('TestWidgetTwo').length).toBe(1); + + act( + () => + layoutManager?.eventHub.emit(PanelEvent.OPEN, { + widget: { type: 'test-widget-a' }, + }) + ); + expect(screen.queryAllByText('TestWidget').length).toBe(1); + expect(screen.queryAllByText('TestWidgetTwo').length).toBe(1); + }); +}); + +describe('component wrapper', () => { + it('should forward callback refs', () => { + let refObj; + const ref = jest.fn(r => { + refObj = r; + }); + const Wrapper = WrapWidgetPlugin(testWidgetRefPlugin); + render( + + ); + expect(ref).toBeCalledTimes(1); + expect(refObj).toBeInstanceOf(TestForwardRef); + }); + + it('should forward non-callback refs', () => { + const ref = React.createRef(); + const Wrapper = WrapWidgetPlugin(testWidgetRefPlugin); + render( + + ); + expect(ref.current).toBeInstanceOf(TestForwardRef); + }); + + it('should not error if no ref passed', () => { + const Wrapper = WrapWidgetPlugin(testWidgetRefPlugin); + render( + + ); + }); +}); diff --git a/packages/dashboard-core-plugins/src/WidgetLoaderPlugin.tsx b/packages/dashboard-core-plugins/src/WidgetLoaderPlugin.tsx new file mode 100644 index 0000000000..b618adb1e8 --- /dev/null +++ b/packages/dashboard-core-plugins/src/WidgetLoaderPlugin.tsx @@ -0,0 +1,183 @@ +import { + useMemo, + useCallback, + type ComponentType, + useEffect, + forwardRef, + useState, +} from 'react'; +import type { ReactComponentConfig } from '@deephaven/golden-layout'; +import shortid from 'shortid'; +import { + assertIsDashboardPluginProps, + DashboardPluginComponentProps, + DehydratedDashboardPanelProps, + PanelEvent, + PanelOpenEventDetail, + LayoutUtils, + useListener, + PanelProps, + canHaveRef, +} from '@deephaven/dashboard'; +import { usePlugins } from '@deephaven/app-utils'; +import { isWidgetPlugin, type WidgetPlugin } from '@deephaven/plugin'; +import Log from '@deephaven/log'; +import { WidgetPanel } from './panels'; + +const log = Log.module('WidgetLoaderPlugin'); + +export function WrapWidgetPlugin( + plugin: WidgetPlugin +): React.ForwardRefExoticComponent> { + function Wrapper(props: PanelProps, ref: React.ForwardedRef) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const C = plugin.component as any; + const { metadata } = props; + const [componentPanel, setComponentPanel] = useState(); + const refCallback = useCallback( + (e: ComponentType) => { + setComponentPanel(e); + if (typeof ref === 'function') { + ref(e); + } else if (ref != null) { + // eslint-disable-next-line no-param-reassign + ref.current = e; + } + }, + [ref] + ); + + const hasRef = canHaveRef(C); + + return ( + + {hasRef ? ( + + ) : ( + + )} + ) + + ); + } + + Wrapper.displayName = `WidgetLoaderPlugin(${ + plugin.component.displayName ?? plugin.name + })`; + + return forwardRef(Wrapper); +} + +/** + * Widget to automatically open any supported WidgetPlugin types as panels + * if the widget is emitted from the server as the result of executed code. + * + * Does not open panels for widgets that are not supported by any plugins. + * Does not open panels for widgets that are a component of a larger widget or UI element. + * + * @param props Dashboard plugin props + * @returns React element + */ +export function WidgetLoaderPlugin( + props: Partial +): JSX.Element | null { + const plugins = usePlugins(); + const supportedTypes = useMemo(() => { + const typeMap = new Map(); + plugins.forEach(plugin => { + if (!isWidgetPlugin(plugin)) { + return; + } + + [plugin.supportedTypes].flat().forEach(supportedType => { + if (supportedType != null && supportedType !== '') { + if (typeMap.has(supportedType)) { + log.warn( + `Multiple WidgetPlugins handling type ${supportedType}. Replacing ${typeMap.get( + supportedType + )?.name} with ${plugin.name} to handle ${supportedType}` + ); + } + typeMap.set(supportedType, plugin); + } + }); + }); + + return typeMap; + }, [plugins]); + + assertIsDashboardPluginProps(props); + const { id, layout, registerComponent } = props; + + const handlePanelOpen = useCallback( + ({ + dragEvent, + fetch, + panelId = shortid.generate(), + widget, + }: PanelOpenEventDetail) => { + const { id: widgetId, type } = widget; + const name = widget.title ?? widget.name; + const plugin = supportedTypes.get(type); + if (plugin == null) { + return; + } + const metadata = { id: widgetId, name, type }; + const panelProps: DehydratedDashboardPanelProps & { + fetch?: typeof fetch; + } = { + localDashboardId: id, + metadata, + fetch, + }; + + const config: ReactComponentConfig = { + type: 'react-component', + component: plugin.name, + props: panelProps, + title: name, + id: panelId, + }; + + const { root } = layout; + LayoutUtils.openComponent({ root, config, dragEvent }); + }, + [id, layout, supportedTypes] + ); + + useEffect(() => { + const deregisterFns = [...new Set(supportedTypes.values())].map(plugin => { + const { panelComponent } = plugin; + if (panelComponent == null) { + return registerComponent(plugin.name, WrapWidgetPlugin(plugin)); + } + return registerComponent(plugin.name, panelComponent); + }); + + return () => { + deregisterFns.forEach(deregister => deregister()); + }; + }); + + /** + * Listen for panel open events so we know when to open a panel + */ + useListener(layout.eventHub, PanelEvent.OPEN, handlePanelOpen); + + return null; +} + +export default WidgetLoaderPlugin; diff --git a/packages/dashboard-core-plugins/src/WidgetLoaderPluginConfig.ts b/packages/dashboard-core-plugins/src/WidgetLoaderPluginConfig.ts new file mode 100644 index 0000000000..707e396c6b --- /dev/null +++ b/packages/dashboard-core-plugins/src/WidgetLoaderPluginConfig.ts @@ -0,0 +1,10 @@ +import { PluginType, DashboardPlugin } from '@deephaven/plugin'; +import WidgetLoaderPlugin from './WidgetLoaderPlugin'; + +const WidgetLoaderPluginConfig: DashboardPlugin = { + name: 'WidgetLoaderPlugin', + type: PluginType.DASHBOARD_PLUGIN, + component: WidgetLoaderPlugin, +}; + +export default WidgetLoaderPluginConfig; diff --git a/packages/dashboard-core-plugins/src/index.test.tsx b/packages/dashboard-core-plugins/src/index.test.tsx index 648871569c..d0a5cd2cdc 100644 --- a/packages/dashboard-core-plugins/src/index.test.tsx +++ b/packages/dashboard-core-plugins/src/index.test.tsx @@ -14,7 +14,7 @@ import { GridPlugin, LinkerPlugin, MarkdownPlugin, - PandasPlugin, + WidgetLoaderPlugin, } from '.'; function makeConnection(): IdeConnection { @@ -40,7 +40,7 @@ it('handles mounting and unmount core plugins properly', () => { - undefined} /> + diff --git a/packages/dashboard-core-plugins/src/index.ts b/packages/dashboard-core-plugins/src/index.ts index 2fd12c776c..c1f9b7882b 100644 --- a/packages/dashboard-core-plugins/src/index.ts +++ b/packages/dashboard-core-plugins/src/index.ts @@ -13,6 +13,8 @@ export { default as MarkdownPlugin } from './MarkdownPlugin'; export { default as MarkdownPluginConfig } from './MarkdownPluginConfig'; export { default as PandasPlugin } from './PandasPlugin'; export { default as PandasPluginConfig } from './PandasPluginConfig'; +export { default as WidgetLoaderPlugin } from './WidgetLoaderPlugin'; +export { default as WidgetLoaderPluginConfig } from './WidgetLoaderPluginConfig'; export { default as ControlType } from './controls/ControlType'; export { default as LinkerUtils } from './linker/LinkerUtils'; export type { Link } from './linker/LinkerUtils'; diff --git a/packages/dashboard-core-plugins/src/panels/ConsolePanel.tsx b/packages/dashboard-core-plugins/src/panels/ConsolePanel.tsx index be543e3fb5..25998a7dd6 100644 --- a/packages/dashboard-core-plugins/src/panels/ConsolePanel.tsx +++ b/packages/dashboard-core-plugins/src/panels/ConsolePanel.tsx @@ -9,17 +9,21 @@ import { Console, ConsoleConstants, HeapUsage, + ObjectIcon, } from '@deephaven/console'; import { DashboardPanelProps, PanelEvent } from '@deephaven/dashboard'; import type { IdeSession, VariableDefinition } from '@deephaven/jsapi-types'; import { SessionWrapper } from '@deephaven/jsapi-utils'; import Log from '@deephaven/log'; import { + DeephavenPluginModuleMap, getCommandHistoryStorage, + getPlugins, getTimeZone, RootState, } from '@deephaven/redux'; import { assertNotNull } from '@deephaven/utils'; +import { getIconForPlugin, pluginSupportsType } from '@deephaven/plugin'; import type { JSZipObject } from 'jszip'; import { ConsoleEvent } from '../events'; import Panel from './Panel'; @@ -55,6 +59,7 @@ interface ConsolePanelProps extends DashboardPanelProps { timeZone: string; unzip?: (file: File) => Promise; + plugins: DeephavenPluginModuleMap; } interface ConsolePanelState { @@ -86,6 +91,8 @@ export class ConsolePanel extends PureComponent< this.handleSettingsChange = this.handleSettingsChange.bind(this); this.handleShow = this.handleShow.bind(this); this.handlePanelMount = this.handlePanelMount.bind(this); + this.supportsType = this.supportsType.bind(this); + this.iconForType = this.iconForType.bind(this); this.consoleRef = React.createRef(); @@ -308,6 +315,23 @@ export class ConsolePanel extends PureComponent< this.consoleRef.current?.updateDimensions(); } + supportsType(type: string): boolean { + const { plugins } = this.props; + return [...plugins.values()].some(plugin => + pluginSupportsType(plugin, type) + ); + } + + iconForType(type: string): JSX.Element { + const { plugins } = this.props; + const plugin = [...plugins.values()].find(p => pluginSupportsType(p, type)); + if (plugin != null) { + return getIconForPlugin(plugin); + } + // TODO: #1573 Remove this default and always return getIconForPlugin + return ; + } + render(): ReactElement { const { commandHistoryStorage, @@ -378,6 +402,8 @@ export class ConsolePanel extends PureComponent< timeZone={timeZone} objectMap={objectMap} unzip={unzip} + supportsType={this.supportsType} + iconForType={this.iconForType} /> )} @@ -390,13 +416,14 @@ const mapStateToProps = ( ownProps: { localDashboardId: string } ): Pick< ConsolePanelProps, - 'commandHistoryStorage' | 'sessionWrapper' | 'timeZone' + 'commandHistoryStorage' | 'sessionWrapper' | 'timeZone' | 'plugins' > => ({ commandHistoryStorage: getCommandHistoryStorage( state ) as CommandHistoryStorage, sessionWrapper: getDashboardSessionWrapper(state, ownProps.localDashboardId), timeZone: getTimeZone(state), + plugins: getPlugins(state), }); const ConnectedConsolePanel = connect(mapStateToProps, null, null, { diff --git a/packages/dashboard-core-plugins/src/panels/IrisGridPanel.tsx b/packages/dashboard-core-plugins/src/panels/IrisGridPanel.tsx index 0dc86aaa73..b0383f024b 100644 --- a/packages/dashboard-core-plugins/src/panels/IrisGridPanel.tsx +++ b/packages/dashboard-core-plugins/src/panels/IrisGridPanel.tsx @@ -137,25 +137,29 @@ export interface IrisGridPanelProps extends DashboardPanelProps { children?: ReactNode; panelState: LoadedPanelState | null; makeModel: () => IrisGridModel | Promise; + + onStateChange?: (irisGridState: IrisGridState, gridState: GridState) => void; + onPanelStateUpdate?: (panelState: PanelState) => void; + + /** Override the default worker used by IrisGrid to download CSVs. */ + getDownloadWorker?: () => Promise; + + /** Load a plugin defined by the table */ + loadPlugin: (pluginName: string) => TablePluginComponent; + + theme?: IrisGridThemeType; +} + +interface PropsFromRedux { inputFilters: InputFilter[]; links: Link[]; columnSelectionValidator?: ( panel: PanelComponent, tableColumn?: LinkColumn ) => boolean; - onStateChange?: (irisGridState: IrisGridState, gridState: GridState) => void; - onPanelStateUpdate?: (panelState: PanelState) => void; user: User; workspace: Workspace; settings: { timeZone: string }; - - // Retrieve a download worker for optimizing exporting tables - getDownloadWorker: () => Promise; - - // Load a plugin defined by the table - loadPlugin: (pluginName: string) => TablePluginComponent; - - theme: IrisGridThemeType; } interface IrisGridPanelState { @@ -221,8 +225,10 @@ function getTableNameFromMetadata(metadata: PanelMetadata | undefined): string { throw new Error(`Unable to determine table name from metadata: ${metadata}`); } +type IrisGridPanelPropsWithRedux = IrisGridPanelProps & PropsFromRedux; + export class IrisGridPanel extends PureComponent< - IrisGridPanelProps, + IrisGridPanelPropsWithRedux, IrisGridPanelState > { static defaultProps = { @@ -234,7 +240,7 @@ export class IrisGridPanel extends PureComponent< static COMPONENT = 'IrisGridPanel'; - constructor(props: IrisGridPanelProps) { + constructor(props: IrisGridPanelPropsWithRedux) { super(props); this.handleAdvancedSettingsChange = @@ -1367,15 +1373,7 @@ export class IrisGridPanel extends PureComponent< const mapStateToProps = ( state: RootState, { localDashboardId = DEFAULT_DASHBOARD_ID }: { localDashboardId?: string } -): Pick< - IrisGridPanelProps, - | 'columnSelectionValidator' - | 'inputFilters' - | 'links' - | 'settings' - | 'user' - | 'workspace' -> => ({ +) => ({ inputFilters: getInputFiltersForDashboard(state, localDashboardId), links: getLinksForDashboard(state, localDashboardId), columnSelectionValidator: getColumnSelectionValidatorForDashboard( diff --git a/packages/dashboard-core-plugins/src/panels/Panel.tsx b/packages/dashboard-core-plugins/src/panels/Panel.tsx index 5b02759ca0..237ba74ebc 100644 --- a/packages/dashboard-core-plugins/src/panels/Panel.tsx +++ b/packages/dashboard-core-plugins/src/panels/Panel.tsx @@ -1,5 +1,6 @@ import React, { Component, + ComponentType, FocusEvent, FocusEventHandler, PureComponent, @@ -32,7 +33,7 @@ import RenameDialog from './RenameDialog'; const log = Log.module('Panel'); interface PanelProps { - componentPanel: Component; + componentPanel?: ComponentType | Component; children: ReactNode; glContainer: Container; glEventHub: EventEmitter; @@ -125,7 +126,7 @@ class Panel extends PureComponent { } componentDidMount(): void { - const { componentPanel, glContainer, glEventHub } = this.props; + const { glContainer, glEventHub } = this.props; glContainer.on('resize', this.handleResize); glContainer.on('show', this.handleBeforeShow); @@ -141,8 +142,15 @@ class Panel extends PureComponent { InputFilterEvent.CLEAR_ALL_FILTERS, this.handleClearAllFilters ); + } - glEventHub.emit(PanelEvent.MOUNT, componentPanel); + componentDidUpdate(prevProps: PanelProps): void { + const { componentPanel, glEventHub } = this.props; + + // componentPanel ref could start undefined w/ WidgetLoaderPlugin wrapping panels + if (prevProps.componentPanel == null && componentPanel != null) { + glEventHub.emit(PanelEvent.MOUNT, componentPanel); + } } componentWillUnmount(): void { @@ -163,7 +171,9 @@ class Panel extends PureComponent { this.handleClearAllFilters ); - glEventHub.emit(PanelEvent.UNMOUNT, componentPanel); + if (componentPanel != null) { + glEventHub.emit(PanelEvent.UNMOUNT, componentPanel); + } } handleTab(tab: Tab): void { diff --git a/packages/dashboard-core-plugins/src/panels/WidgetPanel.tsx b/packages/dashboard-core-plugins/src/panels/WidgetPanel.tsx index a214fb149b..a1dbeeb509 100644 --- a/packages/dashboard-core-plugins/src/panels/WidgetPanel.tsx +++ b/packages/dashboard-core-plugins/src/panels/WidgetPanel.tsx @@ -1,5 +1,6 @@ import React, { Component, + ComponentType, PureComponent, ReactElement, ReactNode, @@ -15,7 +16,7 @@ import './WidgetPanel.scss'; interface WidgetPanelProps { children: ReactNode; - componentPanel: Component; + componentPanel?: ComponentType | Component; glContainer: Container; glEventHub: EventEmitter; diff --git a/packages/dashboard-core-plugins/src/useHydrateGrid.ts b/packages/dashboard-core-plugins/src/useHydrateGrid.ts index bf806a82c4..1413070a3e 100644 --- a/packages/dashboard-core-plugins/src/useHydrateGrid.ts +++ b/packages/dashboard-core-plugins/src/useHydrateGrid.ts @@ -9,17 +9,23 @@ import { Table } from '@deephaven/jsapi-types'; import { IrisGridModelFactory } from '@deephaven/iris-grid'; import { IrisGridPanelMetadata, + IrisGridPanelProps, isIrisGridPanelMetadata, isLegacyIrisGridPanelMetadata, } from './panels'; -export function useHydrateGrid(): PanelHydrateFunction { +export function useHydrateGrid< + P extends DehydratedDashboardPanelProps = DehydratedDashboardPanelProps, +>(): PanelHydrateFunction< + P, + P & Pick +> { const dh = useApi(); const connection = useConnection(); const loadPlugin = useLoadTablePlugin(); const hydrate = useCallback( - (hydrateProps: DehydratedDashboardPanelProps, id: string) => { + (hydrateProps: P, id: string) => { let metadata: IrisGridPanelMetadata; if (isIrisGridPanelMetadata(hydrateProps.metadata)) { metadata = hydrateProps.metadata; diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json index 4f6a543668..e6e67d8245 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -38,6 +38,7 @@ "peerDependencies": { "react": "^17.0.0", "react-dom": "^17.0.0", + "react-is": "^17.0.0", "react-redux": "^7.2.4" }, "devDependencies": { diff --git a/packages/dashboard/src/DashboardLayout.tsx b/packages/dashboard/src/DashboardLayout.tsx index 2b5aa80492..d7b899958a 100644 --- a/packages/dashboard/src/DashboardLayout.tsx +++ b/packages/dashboard/src/DashboardLayout.tsx @@ -21,6 +21,7 @@ import PanelManager, { ClosedPanels } from './PanelManager'; import PanelErrorBoundary from './PanelErrorBoundary'; import LayoutUtils from './layout/LayoutUtils'; import { + canHaveRef, dehydrate as dehydrateDefault, hydrate as hydrateDefault, } from './DashboardUtils'; @@ -28,7 +29,6 @@ import PanelEvent from './PanelEvent'; import { GLPropTypes, useListener } from './layout'; import { getDashboardData, updateDashboardData } from './redux'; import { - isWrappedComponent, PanelComponentType, PanelDehydrateFunction, PanelHydrateFunction, @@ -127,18 +127,12 @@ export function DashboardLayout({ const CType = componentType as any; const PanelWrapperType = panelWrapper; - /* - Checking for class components will let us silence the React warning - about assigning refs to function components not using forwardRef. - The ref is used to detect changes to class component state so we - can track changes to panelState. We should opt for more explicit - state changes in the future and in functional components. - */ - const isClassComponent = - (isWrappedComponent(CType) && - CType.WrappedComponent.prototype != null && - CType.WrappedComponent.prototype.isReactComponent != null) || - (CType.prototype != null && CType.prototype.isReactComponent != null); + /** + * The ref is used to detect changes to class component state so we + * can track changes to panelState. We should opt for more explicit + * state changes in the future and in functional components. + */ + const hasRef = canHaveRef(CType); // Props supplied by GoldenLayout const { glContainer, glEventHub } = props; @@ -146,7 +140,7 @@ export function DashboardLayout({ {/* eslint-disable-next-line react/jsx-props-no-spreading */} - {isClassComponent ? ( + {hasRef ? ( // eslint-disable-next-line react/jsx-props-no-spreading ) : ( @@ -158,6 +152,10 @@ export function DashboardLayout({ ); } + wrappedComponent.displayName = `DashboardWrapper(${ + componentType.displayName ?? name + })`; + const cleanup = layout.registerComponent( name, React.forwardRef(wrappedComponent) diff --git a/packages/dashboard/src/DashboardUtils.test.tsx b/packages/dashboard/src/DashboardUtils.test.tsx new file mode 100644 index 0000000000..10c032d991 --- /dev/null +++ b/packages/dashboard/src/DashboardUtils.test.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { canHaveRef } from './DashboardUtils'; +import { type PanelProps } from './DashboardPlugin'; + +test('canHaveRef', () => { + function TestComponent() { + return
Test
; + } + + class TestClass extends React.PureComponent { + render() { + return
Test
; + } + } + + expect(canHaveRef(TestComponent)).toBe(false); + expect(canHaveRef(React.forwardRef(TestComponent))).toBe(true); + expect(canHaveRef(TestClass)).toBe(true); + expect( + canHaveRef(connect(null, null, null, { forwardRef: true })(TestClass)) + ).toBe(true); +}); diff --git a/packages/dashboard/src/DashboardUtils.ts b/packages/dashboard/src/DashboardUtils.tsx similarity index 57% rename from packages/dashboard/src/DashboardUtils.ts rename to packages/dashboard/src/DashboardUtils.tsx index 815f39eecb..ccc8c0d088 100644 --- a/packages/dashboard/src/DashboardUtils.ts +++ b/packages/dashboard/src/DashboardUtils.tsx @@ -1,4 +1,10 @@ -import { DehydratedDashboardPanelProps, PanelConfig } from './DashboardPlugin'; +import { ForwardRef } from 'react-is'; +import { + DehydratedDashboardPanelProps, + isWrappedComponent, + PanelComponentType, + PanelConfig, +} from './DashboardPlugin'; /** * Dehydrate an existing panel to allow it to be serialized/saved. @@ -48,6 +54,29 @@ export function hydrate( }; } +/** + * Checks if a panel component can take a ref. Helps silence react dev errors + * if a ref is passed to a functional component without forwardRef. + * @param component The panel component to check if it can take a ref + * @returns Wheter the component can take a ref or not + */ +export function canHaveRef(component: PanelComponentType): boolean { + // Might be a redux connect wrapped component + const isClassComponent = + (isWrappedComponent(component) && + component.WrappedComponent.prototype != null && + component.WrappedComponent.prototype.isReactComponent != null) || + (component.prototype != null && + component.prototype.isReactComponent != null); + + const isForwardRef = + !isWrappedComponent(component) && + '$$typeof' in component && + component.$$typeof === ForwardRef; + + return isClassComponent || isForwardRef; +} + export default { dehydrate, hydrate, diff --git a/packages/golden-layout/src/controls/Header.ts b/packages/golden-layout/src/controls/Header.ts index 7406bfd5f9..20af2378c4 100644 --- a/packages/golden-layout/src/controls/Header.ts +++ b/packages/golden-layout/src/controls/Header.ts @@ -214,7 +214,8 @@ export default class Header extends EventEmitter { // makes sure dropped tabs are scrollintoview, removed any re-ordering this.tabs[this.parent.config.activeItemIndex ?? 0].element .get(0) - ?.scrollIntoView({ + ?.scrollIntoView?.({ + // Optional chain scrollIntoView call so tests do not error inline: 'nearest', }); diff --git a/packages/jsapi-types/src/dh.types.ts b/packages/jsapi-types/src/dh.types.ts index d8cd8bf65a..2985b4ca30 100644 --- a/packages/jsapi-types/src/dh.types.ts +++ b/packages/jsapi-types/src/dh.types.ts @@ -83,6 +83,20 @@ export interface VariableDefinition { id?: string; } +export interface JsWidget extends Evented { + getDataAsBase64: () => string; + getDataAsU8: () => Uint8Array; + getDataAsString: () => string; + exportedObjects: { + fetch: () => Promise; + }[]; + sendMessage: ( + message: string | ArrayBuffer | ArrayBufferView, + references?: unknown[] + ) => void; + close: () => void; +} + export interface LogItem { micros: number; logLevel: string; diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 967d902914..798e1aade7 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -23,8 +23,15 @@ }, "dependencies": { "@deephaven/components": "file:../components", + "@deephaven/golden-layout": "file:../golden-layout", + "@deephaven/icons": "file:../icons", "@deephaven/iris-grid": "file:../iris-grid", - "@deephaven/jsapi-types": "file:../jsapi-types" + "@deephaven/jsapi-types": "file:../jsapi-types", + "@fortawesome/fontawesome-common-types": "^6.1.1", + "@fortawesome/react-fontawesome": "^0.2.0" + }, + "peerDependencies": { + "react": "^17.x" }, "files": [ "dist" diff --git a/packages/plugin/src/PluginTypes.ts b/packages/plugin/src/PluginTypes.ts index 86b513dff7..7a72ebd21e 100644 --- a/packages/plugin/src/PluginTypes.ts +++ b/packages/plugin/src/PluginTypes.ts @@ -1,9 +1,16 @@ import type { BaseThemeType } from '@deephaven/components'; +import { type JsWidget } from '@deephaven/jsapi-types'; +import { + type EventEmitter, + type ItemContainer, +} from '@deephaven/golden-layout'; +import type { IconDefinition } from '@fortawesome/fontawesome-common-types'; import type { TablePluginComponent } from './TablePlugin'; export const PluginType = Object.freeze({ AUTH_PLUGIN: 'AuthPlugin', DASHBOARD_PLUGIN: 'DashboardPlugin', + WIDGET_PLUGIN: 'WidgetPlugin', TABLE_PLUGIN: 'TablePlugin', THEME_PLUGIN: 'ThemePlugin', }); @@ -67,7 +74,14 @@ export function isLegacyPlugin(plugin: unknown): plugin is LegacyPlugin { export type PluginModule = Plugin | LegacyPlugin; export interface Plugin { + /** + * The name of the plugin. This will be used as an identifier for the plugin and should be unique. + */ name: string; + + /** + * The type of plugin. + */ type: (typeof PluginType)[keyof typeof PluginType]; } @@ -76,6 +90,10 @@ export interface Plugin { */ export interface DashboardPlugin extends Plugin { type: typeof PluginType.DASHBOARD_PLUGIN; + /** + * The component to mount for the dashboard plugin. + * This component is used to initialize the plugin and will only be mounted to the dashboard once. + */ // eslint-disable-next-line @typescript-eslint/no-explicit-any component: React.ComponentType; } @@ -86,6 +104,67 @@ export function isDashboardPlugin( return 'type' in plugin && plugin.type === PluginType.DASHBOARD_PLUGIN; } +export interface WidgetComponentProps { + fetch: () => Promise; + metadata?: { + id?: string; + name?: string; + type?: string; + }; + localDashboardId: string; + glContainer: ItemContainer; + glEventHub: EventEmitter; +} + +export interface WidgetPlugin extends Plugin { + type: typeof PluginType.WIDGET_PLUGIN; + /** + * The component that can render the widget types the plugin supports. + * + * If the widget should be opened as a panel by itself (determined by the UI), + * then `panelComponent` will be used instead. + * The component will be wrapped in a default panel if `panelComponent` is not provided. + */ + component: React.ComponentType; + + /** + * The server widget types that this plugin will handle. + */ + supportedTypes: string | string[]; + + /** + * The title to display for widgets handled by the plugin. + * This is a user friendly name to denote the type of widget. + * Does not have to be unique across plugins. + * If not specified, the plugin name will be used as the title. + * + * A plugin may have a name of `@deehaven/pandas` and a title of `Pandas`. + * This way, the user will just see `Pandas panel` instead of `@deephaven/pandas panel`. + */ + title?: string; + + /** + * The component to use if the widget should be mounted as a panel. + * If omitted, the default panel will be used. + * This provides access to panel events such as onHide and onTabFocus. + * + * See @deephaven/dashboard-core-plugins WidgetPanel for the component that should be used here. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + panelComponent?: React.ComponentType; + + /** + * The icon to display next to the console button. + * If a react node is provided (including a string), it will be rendered directly. + * If no icon is specified, the default widget icon will be used. + */ + icon?: IconDefinition | React.ReactElement; +} + +export function isWidgetPlugin(plugin: PluginModule): plugin is WidgetPlugin { + return 'type' in plugin && plugin.type === PluginType.WIDGET_PLUGIN; +} + export interface TablePlugin extends Plugin { type: typeof PluginType.TABLE_PLUGIN; component: TablePluginComponent; diff --git a/packages/plugin/src/PluginUtils.test.tsx b/packages/plugin/src/PluginUtils.test.tsx new file mode 100644 index 0000000000..27bbac972e --- /dev/null +++ b/packages/plugin/src/PluginUtils.test.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { dhTruck, vsPreview } from '@deephaven/icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { DashboardPlugin, PluginType, type WidgetPlugin } from './PluginTypes'; +import { pluginSupportsType, getIconForPlugin } from './PluginUtils'; + +function TestWidget() { + return
TestWidget
; +} + +const widgetPlugin: WidgetPlugin = { + name: 'test-widget-plugin', + type: PluginType.WIDGET_PLUGIN, + component: TestWidget, + supportedTypes: ['test-widget', 'test-widget-two'], +}; + +const dashboardPlugin: DashboardPlugin = { + name: 'test-widget-plugin', + type: PluginType.DASHBOARD_PLUGIN, + component: TestWidget, +}; + +test('pluginSupportsType', () => { + expect(pluginSupportsType(widgetPlugin, 'test-widget')).toBe(true); + expect(pluginSupportsType(widgetPlugin, 'test-widget-two')).toBe(true); + expect(pluginSupportsType(widgetPlugin, 'test-widget-three')).toBe(false); + expect(pluginSupportsType(dashboardPlugin, 'test-widget')).toBe(false); + expect(pluginSupportsType(undefined, 'test-widget')).toBe(false); +}); + +const DEFAULT_ICON = ; + +describe('getIconForPlugin', () => { + test('default icon', () => { + expect(getIconForPlugin(widgetPlugin)).toEqual(DEFAULT_ICON); + }); + + test('default icon for non-widget plugin', () => { + expect(getIconForPlugin(dashboardPlugin)).toEqual(DEFAULT_ICON); + }); + + test('custom icon', () => { + const customIcon = ; + const customWidgetPlugin: WidgetPlugin = { + ...widgetPlugin, + icon: dhTruck, + }; + expect(getIconForPlugin(customWidgetPlugin)).toEqual(customIcon); + }); + + test('custom icon element', () => { + const customIcon =
Test
; + const customWidgetPlugin: WidgetPlugin = { + ...widgetPlugin, + icon: customIcon, + }; + expect(getIconForPlugin(customWidgetPlugin)).toEqual(customIcon); + }); +}); diff --git a/packages/plugin/src/PluginUtils.tsx b/packages/plugin/src/PluginUtils.tsx new file mode 100644 index 0000000000..6fa9024153 --- /dev/null +++ b/packages/plugin/src/PluginUtils.tsx @@ -0,0 +1,34 @@ +import { isValidElement } from 'react'; +import { vsPreview } from '@deephaven/icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { type PluginModule, isWidgetPlugin } from './PluginTypes'; + +export function pluginSupportsType( + plugin: PluginModule | undefined, + type: string +): boolean { + if (plugin == null || !isWidgetPlugin(plugin)) { + return false; + } + + return [plugin.supportedTypes].flat().some(t => t === type); +} + +export function getIconForPlugin(plugin: PluginModule): React.ReactElement { + const defaultIcon = ; + if (!isWidgetPlugin(plugin)) { + return defaultIcon; + } + + const { icon } = plugin; + + if (icon == null) { + return defaultIcon; + } + + if (isValidElement(icon)) { + return icon; + } + + return ; +} diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 4e055db1fe..6685a9de1d 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -1,2 +1,3 @@ export * from './PluginTypes'; export * from './TablePlugin'; +export * from './PluginUtils'; diff --git a/packages/plugin/tsconfig.json b/packages/plugin/tsconfig.json index 1e7fecd870..45ec8489bc 100644 --- a/packages/plugin/tsconfig.json +++ b/packages/plugin/tsconfig.json @@ -8,6 +8,7 @@ "exclude": ["node_modules", "src/**/*.test.*", "src/**/__mocks__/*"], "references": [ { "path": "../components" }, + { "path": "../golden-layout" }, { "path": "../iris-grid" }, { "path": "../jsapi-types" } ] diff --git a/packages/redux/src/selectors.ts b/packages/redux/src/selectors.ts index 1b784f5b2c..dfbbad7ae6 100644 --- a/packages/redux/src/selectors.ts +++ b/packages/redux/src/selectors.ts @@ -111,6 +111,11 @@ export const getActiveTool = ( store: State ): State['activeTool'] => store.activeTool; +/** + * @deprecated Use `usePlugins` hook instead or `PluginsContext` directly + * @param store Redux store + * @returns Plugins map + */ export const getPlugins = ( store: State ): State['plugins'] => store.plugins ?? EMPTY_MAP;