From 8540cd445572d5f6c34b149967fd66cc2c06ad95 Mon Sep 17 00:00:00 2001 From: Matthew Runyon Date: Mon, 9 Oct 2023 20:39:54 -0500 Subject: [PATCH 01/14] WIP --- package-lock.json | 31 ++++++++++ package.json | 1 + packages/dashboard/package.json | 1 + packages/dashboard/src/DashboardLayout.tsx | 10 +++- packages/plugin/src/PluginUtils.tsx | 69 ++++++++++++++++++++++ 5 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 packages/plugin/src/PluginUtils.tsx diff --git a/package-lock.json b/package-lock.json index 31f444ee75..9f5cf01802 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": "^18.2.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": "18.2.2", + "resolved": "https://registry.npmjs.org/@types/react-is/-/react-is-18.2.2.tgz", + "integrity": "sha512-bNmRDADVsOivYLvqYQATYRbf60SlK++spu97SK65pSCjdtuTqczFexBQtOK+gQdG6cqOsvQZ3mR12ueEoaq5iA==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, "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", @@ -28258,6 +28268,7 @@ "lodash.ismatch": "^4.1.1", "lodash.throttle": "^4.1.1", "prop-types": "^15.7.2", + "react-is": "^18.2.0", "shortid": "^2.2.16" }, "devDependencies": { @@ -28339,6 +28350,11 @@ "@types/lodash": "*" } }, + "packages/dashboard/node_modules/react-is": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==" + }, "packages/embed-chart": { "name": "@deephaven/embed-chart", "version": "0.49.1", @@ -30244,6 +30260,7 @@ "lodash.ismatch": "^4.1.1", "lodash.throttle": "^4.1.1", "prop-types": "^15.7.2", + "react-is": "^18.2.0", "shortid": "^2.2.16" }, "dependencies": { @@ -30256,6 +30273,11 @@ "requires": { "@types/lodash": "*" } + }, + "react-is": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==" } } }, @@ -35494,6 +35516,15 @@ "@types/react": "*" } }, + "@types/react-is": { + "version": "18.2.2", + "resolved": "https://registry.npmjs.org/@types/react-is/-/react-is-18.2.2.tgz", + "integrity": "sha512-bNmRDADVsOivYLvqYQATYRbf60SlK++spu97SK65pSCjdtuTqczFexBQtOK+gQdG6cqOsvQZ3mR12ueEoaq5iA==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, "@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..38c6f18254 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": "^18.2.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/dashboard/package.json b/packages/dashboard/package.json index 4559747be6..7c29e3c0f9 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -33,6 +33,7 @@ "lodash.ismatch": "^4.1.1", "lodash.throttle": "^4.1.1", "prop-types": "^15.7.2", + "react-is": "^18.2.0", "shortid": "^2.2.16" }, "peerDependencies": { diff --git a/packages/dashboard/src/DashboardLayout.tsx b/packages/dashboard/src/DashboardLayout.tsx index 2b5aa80492..f489056a97 100644 --- a/packages/dashboard/src/DashboardLayout.tsx +++ b/packages/dashboard/src/DashboardLayout.tsx @@ -6,6 +6,7 @@ import React, { useMemo, useState, } from 'react'; +import * as ReactIs from 'react-is'; import PropTypes from 'prop-types'; import GoldenLayout from '@deephaven/golden-layout'; import type { @@ -140,13 +141,20 @@ export function DashboardLayout({ CType.WrappedComponent.prototype.isReactComponent != null) || (CType.prototype != null && CType.prototype.isReactComponent != null); + const isForwardRef = + !isWrappedComponent(CType) && ReactIs.isForwardRef(CType); + + const hasRef = isClassComponent || isForwardRef; + + console.log(hasRef); + // Props supplied by GoldenLayout const { glContainer, glEventHub } = props; return ( {/* eslint-disable-next-line react/jsx-props-no-spreading */} - {isClassComponent ? ( + {hasRef ? ( // eslint-disable-next-line react/jsx-props-no-spreading ) : ( diff --git a/packages/plugin/src/PluginUtils.tsx b/packages/plugin/src/PluginUtils.tsx new file mode 100644 index 0000000000..95845b72be --- /dev/null +++ b/packages/plugin/src/PluginUtils.tsx @@ -0,0 +1,69 @@ +import { isValidElement } from 'react'; +import { vsPreview } from '@deephaven/icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { + type PluginModule, + isDashboardPlugin, + SupportedType, +} from './PluginTypes'; + +function normalizeSupportedTypes( + supportedTypes: + | (SupportedType | string) + | (SupportedType | string)[] + | undefined +): SupportedType[] { + if (supportedTypes == null) { + return []; + } + + if (typeof supportedTypes === 'string') { + return [{ type: supportedTypes }]; + } + + if (!Array.isArray(supportedTypes)) { + return [supportedTypes]; + } + + return supportedTypes.map(supportedType => { + if (typeof supportedType === 'string') { + return { type: supportedType }; + } + return supportedType; + }); +} + +export function pluginSupportsType( + plugin: PluginModule | undefined, + type: string +): boolean { + if (plugin == null || !isDashboardPlugin(plugin)) { + return false; + } + + const supportedTypes = normalizeSupportedTypes(plugin.supportedTypes); + return supportedTypes.some(supportedType => supportedType.type === type); +} + +export function getIconForType( + plugin: PluginModule | undefined, + type: string +): React.ReactElement { + const defaultIcon = ; + if (plugin == null || !isDashboardPlugin(plugin)) { + return defaultIcon; + } + + const supportedTypes = normalizeSupportedTypes(plugin.supportedTypes); + const supportedType = supportedTypes.find(p => p.type === type); + + if (supportedType == null || supportedType.icon == null) { + return defaultIcon; + } + + if (isValidElement(supportedType.icon)) { + return supportedType.icon; + } + + return ; +} From 5621d159bc3ed82135c01d777bb21d15d7c5d74e Mon Sep 17 00:00:00 2001 From: Matthew Runyon Date: Tue, 10 Oct 2023 12:22:34 -0500 Subject: [PATCH 02/14] ElementPlugin and convert pandas to element plugin --- package-lock.json | 48 ++++++---- package.json | 2 +- .../app-utils/src/components/AppBootstrap.tsx | 4 +- .../src/components/PluginsBootstrap.tsx | 4 +- packages/code-studio/src/index.tsx | 2 + packages/console/src/Console.tsx | 18 ++++ .../src/console-history/ConsoleHistory.tsx | 68 +++++++------- .../console-history/ConsoleHistoryItem.tsx | 54 ++++++----- .../src/ElementPlugin.tsx | 94 +++++++++++++++++++ .../src/ElementPluginConfig.ts | 10 ++ .../src/PandasPlugin.tsx | 36 +++---- .../src/PandasPluginConfig.ts | 9 +- packages/dashboard-core-plugins/src/index.ts | 1 + .../src/panels/ConsolePanel.tsx | 24 ++++- .../src/panels/IrisGridPanel.tsx | 42 ++++----- .../src/useHydrateGrid.ts | 11 ++- packages/dashboard/package.json | 2 +- packages/dashboard/src/DashboardLayout.tsx | 5 +- packages/plugin/package.json | 8 +- packages/plugin/src/PluginTypes.ts | 32 +++++++ packages/plugin/src/PluginUtils.tsx | 51 ++-------- packages/plugin/src/index.ts | 1 + packages/redux/src/selectors.ts | 5 + 23 files changed, 355 insertions(+), 176 deletions(-) create mode 100644 packages/dashboard-core-plugins/src/ElementPlugin.tsx create mode 100644 packages/dashboard-core-plugins/src/ElementPluginConfig.ts diff --git a/package-lock.json b/package-lock.json index 9f5cf01802..d7a7943b73 100644 --- a/package-lock.json +++ b/package-lock.json @@ -85,7 +85,7 @@ "@types/react": "^17.0.2", "@types/react-beautiful-dnd": "^13.1.2", "@types/react-dom": "^17.0.9", - "@types/react-is": "^18.2.2", + "@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", @@ -8726,12 +8726,12 @@ } }, "node_modules/@types/react-is": { - "version": "18.2.2", - "resolved": "https://registry.npmjs.org/@types/react-is/-/react-is-18.2.2.tgz", - "integrity": "sha512-bNmRDADVsOivYLvqYQATYRbf60SlK++spu97SK65pSCjdtuTqczFexBQtOK+gQdG6cqOsvQZ3mR12ueEoaq5iA==", + "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": "*" + "@types/react": "^17" } }, "node_modules/@types/react-plotly.js": { @@ -28268,7 +28268,6 @@ "lodash.ismatch": "^4.1.1", "lodash.throttle": "^4.1.1", "prop-types": "^15.7.2", - "react-is": "^18.2.0", "shortid": "^2.2.16" }, "devDependencies": { @@ -28281,6 +28280,7 @@ "peerDependencies": { "react": "^17.0.0", "react-dom": "^17.0.0", + "react-is": "^17.0.0", "react-redux": "^7.2.4" } }, @@ -28351,9 +28351,10 @@ } }, "packages/dashboard/node_modules/react-is": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", - "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==" + "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", @@ -28670,11 +28671,17 @@ "license": "Apache-2.0", "dependencies": { "@deephaven/components": "file:../components", + "@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": { @@ -30260,7 +30267,6 @@ "lodash.ismatch": "^4.1.1", "lodash.throttle": "^4.1.1", "prop-types": "^15.7.2", - "react-is": "^18.2.0", "shortid": "^2.2.16" }, "dependencies": { @@ -30275,9 +30281,10 @@ } }, "react-is": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", - "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==" + "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 } } }, @@ -30521,8 +30528,11 @@ "version": "file:packages/plugin", "requires": { "@deephaven/components": "file:../components", + "@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": { @@ -35517,12 +35527,12 @@ } }, "@types/react-is": { - "version": "18.2.2", - "resolved": "https://registry.npmjs.org/@types/react-is/-/react-is-18.2.2.tgz", - "integrity": "sha512-bNmRDADVsOivYLvqYQATYRbf60SlK++spu97SK65pSCjdtuTqczFexBQtOK+gQdG6cqOsvQZ3mR12ueEoaq5iA==", + "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": "*" + "@types/react": "^17" } }, "@types/react-plotly.js": { diff --git a/package.json b/package.json index 38c6f18254..edf80f7449 100644 --- a/package.json +++ b/package.json @@ -96,7 +96,7 @@ "@types/react": "^17.0.2", "@types/react-beautiful-dnd": "^13.1.2", "@types/react-dom": "^17.0.9", - "@types/react-is": "^18.2.2", + "@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..d3905f228f 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, + ElementPluginConfig, } = dashboardCorePlugins; return [ GridPluginConfig, @@ -51,6 +52,7 @@ async function getCorePlugins() { FilterPluginConfig, MarkdownPluginConfig, LinkerPluginConfig, + ElementPluginConfig, ]; } diff --git a/packages/console/src/Console.tsx b/packages/console/src/Console.tsx index e09f07d010..fda3cd75bd 100644 --- a/packages/console/src/Console.tsx +++ b/packages/console/src/Console.tsx @@ -36,6 +36,7 @@ import { CommandHistoryStorage, CommandHistoryStorageItem, } from './command-history'; +import { ObjectIcon } from './common'; const log = Log.module('Console'); @@ -79,6 +80,8 @@ interface ConsoleProps { * (file:File) => Promise */ unzip: (file: File) => Promise; + supportsType(type: string): boolean; + iconForType(type: string): ReactElement; } interface ConsoleState { @@ -105,6 +108,15 @@ interface ConsoleState { isPrintStdOutEnabled: boolean; isClosePanelsOnDisconnectEnabled: boolean; } + +function defaultSupportsType(): boolean { + return true; +} + +function defaultIconForType(type: string): ReactElement { + 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..040f949d0d 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'; @@ -18,8 +18,10 @@ const log = Log.module('ConsoleHistoryItem'); interface ConsoleHistoryItemProps { item: ConsoleHistoryActionItem; language: string; - openObject: (object: VariableDefinition) => void; + openObject(object: VariableDefinition): void; disabled?: boolean; + supportsType(type: string): boolean; + iconForType(type: string): ReactElement; } class ConsoleHistoryItem extends PureComponent< @@ -53,7 +55,7 @@ class ConsoleHistoryItem extends PureComponent< } render(): ReactElement { - const { disabled, item, language } = this.props; + const { disabled, item, language, supportsType, iconForType } = this.props; const { disabledObjects, result } = item; const hasCommand = item.command != null && item.command !== ''; @@ -77,28 +79,30 @@ class ConsoleHistoryItem extends PureComponent< if (changes) { const { created, updated } = changes; - [...created, ...updated].forEach(object => { - hasButtons = true; - const { title } = object; - const key = `${title}`; - const btnDisabled = - disabled === undefined || - disabled || - (disabledObjects ?? []).indexOf(key) >= 0; - const element = ( - - ); - resultElements.push(element); - }); + [...created, ...updated] + // .filter(object => supportsType(object.type)) + .forEach(object => { + hasButtons = true; + const { title } = object; + const key = `${title}`; + const btnDisabled = + disabled === undefined || + disabled || + (disabledObjects ?? []).indexOf(key) >= 0; + const element = ( + + ); + resultElements.push(element); + }); } // If the error has an associated command, we'll actually get a separate ERROR item printed out, so only print an error if there isn't an associated command diff --git a/packages/dashboard-core-plugins/src/ElementPlugin.tsx b/packages/dashboard-core-plugins/src/ElementPlugin.tsx new file mode 100644 index 0000000000..351c475958 --- /dev/null +++ b/packages/dashboard-core-plugins/src/ElementPlugin.tsx @@ -0,0 +1,94 @@ +import { useMemo, useCallback, type ComponentType, useEffect } from 'react'; +import type { ReactComponentConfig } from '@deephaven/golden-layout'; +import shortid from 'shortid'; +import { + assertIsDashboardPluginProps, + DashboardPluginComponentProps, + DehydratedDashboardPanelProps, + PanelEvent, + PanelOpenEventDetail, + LayoutUtils, + useListener, +} from '@deephaven/dashboard'; +import { usePlugins } from '@deephaven/app-utils'; +import { isElementPlugin } from '@deephaven/plugin'; + +export function ElementPlugin( + props: DashboardPluginComponentProps +): JSX.Element | null { + const plugins = usePlugins(); + const supportedTypes = useMemo(() => { + const typeMap = new Map(); + plugins.forEach(plugin => { + if (!isElementPlugin(plugin)) { + return; + } + + [plugin.supportedTypes].flat().forEach(supportedType => { + if (supportedType != null && supportedType !== '') { + typeMap.set(supportedType, plugin.component); + } + }); + }); + + 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 component = supportedTypes.get(type); + if (component == 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: component.displayName ?? '', + props: panelProps, + title: name, + id: panelId, + }; + + const { root } = layout; + LayoutUtils.openComponent({ root, config, dragEvent }); + }, + [id, layout, supportedTypes] + ); + + useEffect(() => { + const deregisterFns = [...plugins.values()] + .filter(isElementPlugin) + .map(plugin => registerComponent(plugin.name, plugin.component)); + + 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 ElementPlugin; diff --git a/packages/dashboard-core-plugins/src/ElementPluginConfig.ts b/packages/dashboard-core-plugins/src/ElementPluginConfig.ts new file mode 100644 index 0000000000..76fb3fc9b6 --- /dev/null +++ b/packages/dashboard-core-plugins/src/ElementPluginConfig.ts @@ -0,0 +1,10 @@ +import { PluginType, DashboardPlugin } from '@deephaven/plugin'; +import ElementPlugin from './ElementPlugin'; + +const ElementPluginConfig: DashboardPlugin = { + name: 'ElementPlugin', + type: PluginType.DASHBOARD_PLUGIN, + component: ElementPlugin, +}; + +export default ElementPluginConfig; diff --git a/packages/dashboard-core-plugins/src/PandasPlugin.tsx b/packages/dashboard-core-plugins/src/PandasPlugin.tsx index c7d3594c6c..b815f9f637 100644 --- a/packages/dashboard-core-plugins/src/PandasPlugin.tsx +++ b/packages/dashboard-core-plugins/src/PandasPlugin.tsx @@ -1,28 +1,22 @@ -import { - assertIsDashboardPluginProps, - DashboardPluginComponentProps, - useDashboardPanel, -} from '@deephaven/dashboard'; -import { useApi } from '@deephaven/jsapi-bootstrap'; +import { DashboardPanelProps } from '@deephaven/dashboard'; +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: DashboardPanelProps, 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..8a966cae3e 100644 --- a/packages/dashboard-core-plugins/src/PandasPluginConfig.ts +++ b/packages/dashboard-core-plugins/src/PandasPluginConfig.ts @@ -1,10 +1,13 @@ -import { PluginType, DashboardPlugin } from '@deephaven/plugin'; +import { PluginType, ElementPlugin } from '@deephaven/plugin'; +import { dhPandas } from '@deephaven/icons'; import PandasPlugin from './PandasPlugin'; -const PandasPluginConfig: DashboardPlugin = { +const PandasPluginConfig: ElementPlugin = { name: 'PandasPlugin', - type: PluginType.DASHBOARD_PLUGIN, + type: PluginType.ELEMENT_PLUGIN, component: PandasPlugin, + supportedTypes: 'pandas.DataFrame', + icon: dhPandas, }; export default PandasPluginConfig; diff --git a/packages/dashboard-core-plugins/src/index.ts b/packages/dashboard-core-plugins/src/index.ts index 2fd12c776c..dc6d16360d 100644 --- a/packages/dashboard-core-plugins/src/index.ts +++ b/packages/dashboard-core-plugins/src/index.ts @@ -3,6 +3,7 @@ export { default as ChartPluginConfig } from './ChartPluginConfig'; export { default as ChartBuilderPlugin } from './ChartBuilderPlugin'; export { default as ChartBuilderPluginConfig } from './ChartBuilderPluginConfig'; export { default as ConsolePlugin } from './ConsolePlugin'; +export { default as ElementPluginConfig } from './ElementPluginConfig'; export { default as FilterPlugin } from './FilterPlugin'; export { default as FilterPluginConfig } from './FilterPluginConfig'; export { default as GridPlugin } from './GridPlugin'; diff --git a/packages/dashboard-core-plugins/src/panels/ConsolePanel.tsx b/packages/dashboard-core-plugins/src/panels/ConsolePanel.tsx index be543e3fb5..c1045baa84 100644 --- a/packages/dashboard-core-plugins/src/panels/ConsolePanel.tsx +++ b/packages/dashboard-core-plugins/src/panels/ConsolePanel.tsx @@ -15,11 +15,14 @@ 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 { getIconForType, pluginSupportsType } from '@deephaven/plugin'; import type { JSZipObject } from 'jszip'; import { ConsoleEvent } from '../events'; import Panel from './Panel'; @@ -55,6 +58,7 @@ interface ConsolePanelProps extends DashboardPanelProps { timeZone: string; unzip?: (file: File) => Promise; + plugins: DeephavenPluginModuleMap; } interface ConsolePanelState { @@ -86,6 +90,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 +314,19 @@ 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)); + return getIconForType(plugin, type); + } + render(): ReactElement { const { commandHistoryStorage, @@ -378,6 +397,8 @@ export class ConsolePanel extends PureComponent< timeZone={timeZone} objectMap={objectMap} unzip={unzip} + supportsType={this.supportsType} + iconForType={this.iconForType} /> )} @@ -390,13 +411,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..00e4d0cdad 100644 --- a/packages/dashboard-core-plugins/src/panels/IrisGridPanel.tsx +++ b/packages/dashboard-core-plugins/src/panels/IrisGridPanel.tsx @@ -137,25 +137,31 @@ 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 +227,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 +242,7 @@ export class IrisGridPanel extends PureComponent< static COMPONENT = 'IrisGridPanel'; - constructor(props: IrisGridPanelProps) { + constructor(props: IrisGridPanelPropsWithRedux) { super(props); this.handleAdvancedSettingsChange = @@ -1367,15 +1375,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/useHydrateGrid.ts b/packages/dashboard-core-plugins/src/useHydrateGrid.ts index bf806a82c4..741eb0b45b 100644 --- a/packages/dashboard-core-plugins/src/useHydrateGrid.ts +++ b/packages/dashboard-core-plugins/src/useHydrateGrid.ts @@ -1,5 +1,6 @@ import { useCallback } from 'react'; import { + DashboardPanelProps, DehydratedDashboardPanelProps, PanelHydrateFunction, } from '@deephaven/dashboard'; @@ -9,17 +10,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 7c29e3c0f9..0b6dc4e291 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -33,12 +33,12 @@ "lodash.ismatch": "^4.1.1", "lodash.throttle": "^4.1.1", "prop-types": "^15.7.2", - "react-is": "^18.2.0", "shortid": "^2.2.16" }, "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 f489056a97..f948881629 100644 --- a/packages/dashboard/src/DashboardLayout.tsx +++ b/packages/dashboard/src/DashboardLayout.tsx @@ -141,13 +141,12 @@ export function DashboardLayout({ CType.WrappedComponent.prototype.isReactComponent != null) || (CType.prototype != null && CType.prototype.isReactComponent != null); + // For some unknown reason, ReactIs.isForwardRef returns false, but comparing $$typeof directly works const isForwardRef = - !isWrappedComponent(CType) && ReactIs.isForwardRef(CType); + !isWrappedComponent(CType) && CType.$$typeof === ReactIs.ForwardRef; const hasRef = isClassComponent || isForwardRef; - console.log(hasRef); - // Props supplied by GoldenLayout const { glContainer, glEventHub } = props; return ( diff --git a/packages/plugin/package.json b/packages/plugin/package.json index dd05353d1b..7a8e459185 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -23,8 +23,14 @@ }, "dependencies": { "@deephaven/components": "file:../components", + "@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..75b35528bf 100644 --- a/packages/plugin/src/PluginTypes.ts +++ b/packages/plugin/src/PluginTypes.ts @@ -1,9 +1,11 @@ import type { BaseThemeType } from '@deephaven/components'; +import type { IconDefinition } from '@fortawesome/fontawesome-common-types'; import type { TablePluginComponent } from './TablePlugin'; export const PluginType = Object.freeze({ AUTH_PLUGIN: 'AuthPlugin', DASHBOARD_PLUGIN: 'DashboardPlugin', + ELEMENT_PLUGIN: 'ElementPlugin', TABLE_PLUGIN: 'TablePlugin', THEME_PLUGIN: 'ThemePlugin', }); @@ -76,6 +78,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 +92,32 @@ export function isDashboardPlugin( return 'type' in plugin && plugin.type === PluginType.DASHBOARD_PLUGIN; } +export interface ElementPlugin extends Plugin { + type: typeof PluginType.ELEMENT_PLUGIN; + /** + * This component is used any time a widget type supported by this plugin is created. + * The component will be wrapped in a default panel if necessary. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + component: React.ComponentType; + + /** + * The variable types that this plugin will handle. + */ + supportedTypes?: string | string[]; + + /** + * 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 isElementPlugin(plugin: PluginModule): plugin is ElementPlugin { + return 'type' in plugin && plugin.type === PluginType.ELEMENT_PLUGIN; +} + export interface TablePlugin extends Plugin { type: typeof PluginType.TABLE_PLUGIN; component: TablePluginComponent; diff --git a/packages/plugin/src/PluginUtils.tsx b/packages/plugin/src/PluginUtils.tsx index 95845b72be..522ef4fe0a 100644 --- a/packages/plugin/src/PluginUtils.tsx +++ b/packages/plugin/src/PluginUtils.tsx @@ -1,48 +1,17 @@ import { isValidElement } from 'react'; import { vsPreview } from '@deephaven/icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { - type PluginModule, - isDashboardPlugin, - SupportedType, -} from './PluginTypes'; - -function normalizeSupportedTypes( - supportedTypes: - | (SupportedType | string) - | (SupportedType | string)[] - | undefined -): SupportedType[] { - if (supportedTypes == null) { - return []; - } - - if (typeof supportedTypes === 'string') { - return [{ type: supportedTypes }]; - } - - if (!Array.isArray(supportedTypes)) { - return [supportedTypes]; - } - - return supportedTypes.map(supportedType => { - if (typeof supportedType === 'string') { - return { type: supportedType }; - } - return supportedType; - }); -} +import { type PluginModule, isElementPlugin } from './PluginTypes'; export function pluginSupportsType( plugin: PluginModule | undefined, type: string ): boolean { - if (plugin == null || !isDashboardPlugin(plugin)) { + if (plugin == null || !isElementPlugin(plugin)) { return false; } - const supportedTypes = normalizeSupportedTypes(plugin.supportedTypes); - return supportedTypes.some(supportedType => supportedType.type === type); + return [plugin.supportedTypes].flat().some(t => t === type); } export function getIconForType( @@ -50,20 +19,20 @@ export function getIconForType( type: string ): React.ReactElement { const defaultIcon = ; - if (plugin == null || !isDashboardPlugin(plugin)) { + if (plugin == null || !isElementPlugin(plugin)) { return defaultIcon; } - const supportedTypes = normalizeSupportedTypes(plugin.supportedTypes); - const supportedType = supportedTypes.find(p => p.type === type); + const supportsType = pluginSupportsType(plugin, type); + const { icon } = plugin; - if (supportedType == null || supportedType.icon == null) { + if (!supportsType || icon == null) { return defaultIcon; } - if (isValidElement(supportedType.icon)) { - return supportedType.icon; + if (isValidElement(icon)) { + return icon; } - return ; + 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/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; From b04ae3aaf9071e92301b05a070e0e401c9b3b02b Mon Sep 17 00:00:00 2001 From: Matthew Runyon Date: Tue, 10 Oct 2023 12:28:14 -0500 Subject: [PATCH 03/14] ElementPlugin -> WidgetPlugin --- packages/code-studio/src/index.tsx | 4 ++-- .../dashboard-core-plugins/src/ElementPluginConfig.ts | 10 ---------- .../dashboard-core-plugins/src/PandasPluginConfig.ts | 6 +++--- .../src/{ElementPlugin.tsx => WidgetLoaderPlugin.tsx} | 10 +++++----- .../src/WidgetLoaderPluginConfig.ts | 10 ++++++++++ packages/dashboard-core-plugins/src/index.ts | 2 +- packages/dashboard-core-plugins/src/useHydrateGrid.ts | 1 - packages/plugin/src/PluginTypes.ts | 10 +++++----- packages/plugin/src/PluginUtils.tsx | 6 +++--- 9 files changed, 29 insertions(+), 30 deletions(-) delete mode 100644 packages/dashboard-core-plugins/src/ElementPluginConfig.ts rename packages/dashboard-core-plugins/src/{ElementPlugin.tsx => WidgetLoaderPlugin.tsx} (92%) create mode 100644 packages/dashboard-core-plugins/src/WidgetLoaderPluginConfig.ts diff --git a/packages/code-studio/src/index.tsx b/packages/code-studio/src/index.tsx index d3905f228f..5daf81ceb7 100644 --- a/packages/code-studio/src/index.tsx +++ b/packages/code-studio/src/index.tsx @@ -42,7 +42,7 @@ async function getCorePlugins() { FilterPluginConfig, MarkdownPluginConfig, LinkerPluginConfig, - ElementPluginConfig, + WidgetLoaderPluginConfig, } = dashboardCorePlugins; return [ GridPluginConfig, @@ -52,7 +52,7 @@ async function getCorePlugins() { FilterPluginConfig, MarkdownPluginConfig, LinkerPluginConfig, - ElementPluginConfig, + WidgetLoaderPluginConfig, ]; } diff --git a/packages/dashboard-core-plugins/src/ElementPluginConfig.ts b/packages/dashboard-core-plugins/src/ElementPluginConfig.ts deleted file mode 100644 index 76fb3fc9b6..0000000000 --- a/packages/dashboard-core-plugins/src/ElementPluginConfig.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { PluginType, DashboardPlugin } from '@deephaven/plugin'; -import ElementPlugin from './ElementPlugin'; - -const ElementPluginConfig: DashboardPlugin = { - name: 'ElementPlugin', - type: PluginType.DASHBOARD_PLUGIN, - component: ElementPlugin, -}; - -export default ElementPluginConfig; diff --git a/packages/dashboard-core-plugins/src/PandasPluginConfig.ts b/packages/dashboard-core-plugins/src/PandasPluginConfig.ts index 8a966cae3e..17448ccb8b 100644 --- a/packages/dashboard-core-plugins/src/PandasPluginConfig.ts +++ b/packages/dashboard-core-plugins/src/PandasPluginConfig.ts @@ -1,10 +1,10 @@ -import { PluginType, ElementPlugin } from '@deephaven/plugin'; +import { PluginType, WidgetPlugin } from '@deephaven/plugin'; import { dhPandas } from '@deephaven/icons'; import PandasPlugin from './PandasPlugin'; -const PandasPluginConfig: ElementPlugin = { +const PandasPluginConfig: WidgetPlugin = { name: 'PandasPlugin', - type: PluginType.ELEMENT_PLUGIN, + type: PluginType.WIDGET_PLUGIN, component: PandasPlugin, supportedTypes: 'pandas.DataFrame', icon: dhPandas, diff --git a/packages/dashboard-core-plugins/src/ElementPlugin.tsx b/packages/dashboard-core-plugins/src/WidgetLoaderPlugin.tsx similarity index 92% rename from packages/dashboard-core-plugins/src/ElementPlugin.tsx rename to packages/dashboard-core-plugins/src/WidgetLoaderPlugin.tsx index 351c475958..706bb8f6f6 100644 --- a/packages/dashboard-core-plugins/src/ElementPlugin.tsx +++ b/packages/dashboard-core-plugins/src/WidgetLoaderPlugin.tsx @@ -11,16 +11,16 @@ import { useListener, } from '@deephaven/dashboard'; import { usePlugins } from '@deephaven/app-utils'; -import { isElementPlugin } from '@deephaven/plugin'; +import { isWidgetPlugin } from '@deephaven/plugin'; -export function ElementPlugin( +export function WidgetLoaderPlugin( props: DashboardPluginComponentProps ): JSX.Element | null { const plugins = usePlugins(); const supportedTypes = useMemo(() => { const typeMap = new Map(); plugins.forEach(plugin => { - if (!isElementPlugin(plugin)) { + if (!isWidgetPlugin(plugin)) { return; } @@ -75,7 +75,7 @@ export function ElementPlugin( useEffect(() => { const deregisterFns = [...plugins.values()] - .filter(isElementPlugin) + .filter(isWidgetPlugin) .map(plugin => registerComponent(plugin.name, plugin.component)); return () => { @@ -91,4 +91,4 @@ export function ElementPlugin( return null; } -export default ElementPlugin; +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.ts b/packages/dashboard-core-plugins/src/index.ts index dc6d16360d..d533976ba0 100644 --- a/packages/dashboard-core-plugins/src/index.ts +++ b/packages/dashboard-core-plugins/src/index.ts @@ -3,7 +3,6 @@ export { default as ChartPluginConfig } from './ChartPluginConfig'; export { default as ChartBuilderPlugin } from './ChartBuilderPlugin'; export { default as ChartBuilderPluginConfig } from './ChartBuilderPluginConfig'; export { default as ConsolePlugin } from './ConsolePlugin'; -export { default as ElementPluginConfig } from './ElementPluginConfig'; export { default as FilterPlugin } from './FilterPlugin'; export { default as FilterPluginConfig } from './FilterPluginConfig'; export { default as GridPlugin } from './GridPlugin'; @@ -14,6 +13,7 @@ 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 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/useHydrateGrid.ts b/packages/dashboard-core-plugins/src/useHydrateGrid.ts index 741eb0b45b..1413070a3e 100644 --- a/packages/dashboard-core-plugins/src/useHydrateGrid.ts +++ b/packages/dashboard-core-plugins/src/useHydrateGrid.ts @@ -1,6 +1,5 @@ import { useCallback } from 'react'; import { - DashboardPanelProps, DehydratedDashboardPanelProps, PanelHydrateFunction, } from '@deephaven/dashboard'; diff --git a/packages/plugin/src/PluginTypes.ts b/packages/plugin/src/PluginTypes.ts index 75b35528bf..43b3206b97 100644 --- a/packages/plugin/src/PluginTypes.ts +++ b/packages/plugin/src/PluginTypes.ts @@ -5,7 +5,7 @@ import type { TablePluginComponent } from './TablePlugin'; export const PluginType = Object.freeze({ AUTH_PLUGIN: 'AuthPlugin', DASHBOARD_PLUGIN: 'DashboardPlugin', - ELEMENT_PLUGIN: 'ElementPlugin', + WIDGET_PLUGIN: 'WidgetPlugin', TABLE_PLUGIN: 'TablePlugin', THEME_PLUGIN: 'ThemePlugin', }); @@ -92,8 +92,8 @@ export function isDashboardPlugin( return 'type' in plugin && plugin.type === PluginType.DASHBOARD_PLUGIN; } -export interface ElementPlugin extends Plugin { - type: typeof PluginType.ELEMENT_PLUGIN; +export interface WidgetPlugin extends Plugin { + type: typeof PluginType.WIDGET_PLUGIN; /** * This component is used any time a widget type supported by this plugin is created. * The component will be wrapped in a default panel if necessary. @@ -114,8 +114,8 @@ export interface ElementPlugin extends Plugin { icon?: IconDefinition | React.ReactElement; } -export function isElementPlugin(plugin: PluginModule): plugin is ElementPlugin { - return 'type' in plugin && plugin.type === PluginType.ELEMENT_PLUGIN; +export function isWidgetPlugin(plugin: PluginModule): plugin is WidgetPlugin { + return 'type' in plugin && plugin.type === PluginType.WIDGET_PLUGIN; } export interface TablePlugin extends Plugin { diff --git a/packages/plugin/src/PluginUtils.tsx b/packages/plugin/src/PluginUtils.tsx index 522ef4fe0a..1798450a15 100644 --- a/packages/plugin/src/PluginUtils.tsx +++ b/packages/plugin/src/PluginUtils.tsx @@ -1,13 +1,13 @@ import { isValidElement } from 'react'; import { vsPreview } from '@deephaven/icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { type PluginModule, isElementPlugin } from './PluginTypes'; +import { type PluginModule, isWidgetPlugin } from './PluginTypes'; export function pluginSupportsType( plugin: PluginModule | undefined, type: string ): boolean { - if (plugin == null || !isElementPlugin(plugin)) { + if (plugin == null || !isWidgetPlugin(plugin)) { return false; } @@ -19,7 +19,7 @@ export function getIconForType( type: string ): React.ReactElement { const defaultIcon = ; - if (plugin == null || !isElementPlugin(plugin)) { + if (plugin == null || !isWidgetPlugin(plugin)) { return defaultIcon; } From 28d8311b92ea6d29aebfc3b0b7b77dee4da2cddd Mon Sep 17 00:00:00 2001 From: Matthew Runyon Date: Tue, 10 Oct 2023 13:48:52 -0500 Subject: [PATCH 04/14] fix test failure --- packages/dashboard-core-plugins/src/index.test.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/dashboard-core-plugins/src/index.test.tsx b/packages/dashboard-core-plugins/src/index.test.tsx index 648871569c..e086cb9a21 100644 --- a/packages/dashboard-core-plugins/src/index.test.tsx +++ b/packages/dashboard-core-plugins/src/index.test.tsx @@ -14,7 +14,6 @@ import { GridPlugin, LinkerPlugin, MarkdownPlugin, - PandasPlugin, } from '.'; function makeConnection(): IdeConnection { @@ -40,7 +39,6 @@ it('handles mounting and unmount core plugins properly', () => { - undefined} /> From 9cfa7efd33b0f0ed45881949431980424f9513f5 Mon Sep 17 00:00:00 2001 From: Matthew Runyon Date: Thu, 12 Oct 2023 16:29:23 -0500 Subject: [PATCH 05/14] Address review comments --- package-lock.json | 2 + packages/console/src/Console.tsx | 4 +- .../console-history/ConsoleHistoryItem.tsx | 59 +++++++------ .../src/PandasPlugin.tsx | 3 +- .../src/PandasPluginConfig.ts | 1 + .../src/WidgetLoaderPlugin.tsx | 85 ++++++++++++++++++- .../src/panels/ConsolePanel.tsx | 7 +- .../src/panels/Panel.tsx | 18 +++- .../src/panels/WidgetPanel.tsx | 3 +- packages/dashboard/src/DashboardLayout.tsx | 31 +++---- .../{DashboardUtils.ts => DashboardUtils.tsx} | 31 ++++++- packages/jsapi-types/src/dh.types.ts | 14 +++ packages/plugin/package.json | 1 + packages/plugin/src/PluginTypes.ts | 47 +++++++++- packages/plugin/tsconfig.json | 1 + 15 files changed, 242 insertions(+), 65 deletions(-) rename packages/dashboard/src/{DashboardUtils.ts => DashboardUtils.tsx} (57%) diff --git a/package-lock.json b/package-lock.json index d7a7943b73..7d701fd1e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28671,6 +28671,7 @@ "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", @@ -30528,6 +30529,7 @@ "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", diff --git a/packages/console/src/Console.tsx b/packages/console/src/Console.tsx index fda3cd75bd..74bcdfca51 100644 --- a/packages/console/src/Console.tsx +++ b/packages/console/src/Console.tsx @@ -36,7 +36,6 @@ import { CommandHistoryStorage, CommandHistoryStorageItem, } from './command-history'; -import { ObjectIcon } from './common'; const log = Log.module('Console'); @@ -114,7 +113,8 @@ function defaultSupportsType(): boolean { } function defaultIconForType(type: string): ReactElement { - return ; + // eslint-disable-next-line react/jsx-no-useless-fragment + return <>; } export class Console extends PureComponent { diff --git a/packages/console/src/console-history/ConsoleHistoryItem.tsx b/packages/console/src/console-history/ConsoleHistoryItem.tsx index 040f949d0d..eb85d163f1 100644 --- a/packages/console/src/console-history/ConsoleHistoryItem.tsx +++ b/packages/console/src/console-history/ConsoleHistoryItem.tsx @@ -18,10 +18,12 @@ const log = Log.module('ConsoleHistoryItem'); interface ConsoleHistoryItemProps { item: ConsoleHistoryActionItem; language: string; - openObject(object: VariableDefinition): void; + openObject: (object: VariableDefinition) => void; disabled?: boolean; - supportsType(type: string): boolean; - iconForType(type: string): ReactElement; + // 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< @@ -55,7 +57,7 @@ class ConsoleHistoryItem extends PureComponent< } render(): ReactElement { - const { disabled, item, language, supportsType, iconForType } = this.props; + const { disabled, item, language, iconForType } = this.props; const { disabledObjects, result } = item; const hasCommand = item.command != null && item.command !== ''; @@ -79,30 +81,31 @@ class ConsoleHistoryItem extends PureComponent< if (changes) { const { created, updated } = changes; - [...created, ...updated] - // .filter(object => supportsType(object.type)) - .forEach(object => { - hasButtons = true; - const { title } = object; - const key = `${title}`; - const btnDisabled = - disabled === undefined || - disabled || - (disabledObjects ?? []).indexOf(key) >= 0; - const element = ( - - ); - resultElements.push(element); - }); + // 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; + const key = `${title}`; + const btnDisabled = + disabled === undefined || + disabled || + (disabledObjects ?? []).indexOf(key) >= 0; + const element = ( + + ); + resultElements.push(element); + }); } // If the error has an associated command, we'll actually get a separate ERROR item printed out, so only print an error if there isn't an associated command diff --git a/packages/dashboard-core-plugins/src/PandasPlugin.tsx b/packages/dashboard-core-plugins/src/PandasPlugin.tsx index b815f9f637..756ed0c3aa 100644 --- a/packages/dashboard-core-plugins/src/PandasPlugin.tsx +++ b/packages/dashboard-core-plugins/src/PandasPlugin.tsx @@ -1,10 +1,11 @@ import { DashboardPanelProps } from '@deephaven/dashboard'; +import { WidgetComponentProps } from '@deephaven/plugin'; import { forwardRef, useMemo } from 'react'; import { PandasPanel } from './panels'; import useHydrateGrid from './useHydrateGrid'; export const PandasPlugin = forwardRef( - (props: DashboardPanelProps, ref: React.Ref) => { + (props: WidgetComponentProps, ref: React.Ref) => { const hydrate = useHydrateGrid(); const { localDashboardId } = props; const hydratedProps = useMemo( diff --git a/packages/dashboard-core-plugins/src/PandasPluginConfig.ts b/packages/dashboard-core-plugins/src/PandasPluginConfig.ts index 17448ccb8b..c63e9ee413 100644 --- a/packages/dashboard-core-plugins/src/PandasPluginConfig.ts +++ b/packages/dashboard-core-plugins/src/PandasPluginConfig.ts @@ -4,6 +4,7 @@ import PandasPlugin from './PandasPlugin'; const PandasPluginConfig: WidgetPlugin = { name: 'PandasPlugin', + wrapWidget: false, type: PluginType.WIDGET_PLUGIN, component: PandasPlugin, supportedTypes: 'pandas.DataFrame', diff --git a/packages/dashboard-core-plugins/src/WidgetLoaderPlugin.tsx b/packages/dashboard-core-plugins/src/WidgetLoaderPlugin.tsx index 706bb8f6f6..a505d08708 100644 --- a/packages/dashboard-core-plugins/src/WidgetLoaderPlugin.tsx +++ b/packages/dashboard-core-plugins/src/WidgetLoaderPlugin.tsx @@ -1,4 +1,11 @@ -import { useMemo, useCallback, type ComponentType, useEffect } from 'react'; +import { + useMemo, + useCallback, + type ComponentType, + useEffect, + forwardRef, + useState, +} from 'react'; import type { ReactComponentConfig } from '@deephaven/golden-layout'; import shortid from 'shortid'; import { @@ -9,16 +16,76 @@ import { PanelOpenEventDetail, LayoutUtils, useListener, + PanelProps, + canHaveRef, } from '@deephaven/dashboard'; import { usePlugins } from '@deephaven/app-utils'; -import { isWidgetPlugin } from '@deephaven/plugin'; +import { + isWidgetPlugin, + type WidgetPlugin, + WidgetComponentProps, +} from '@deephaven/plugin'; +import { WidgetPanel } from './panels'; + +function wrapWidgetPlugin(plugin: WidgetPlugin) { + 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); +} export function WidgetLoaderPlugin( props: DashboardPluginComponentProps ): JSX.Element | null { const plugins = usePlugins(); const supportedTypes = useMemo(() => { - const typeMap = new Map(); + const typeMap = new Map>(); plugins.forEach(plugin => { if (!isWidgetPlugin(plugin)) { return; @@ -76,7 +143,17 @@ export function WidgetLoaderPlugin( useEffect(() => { const deregisterFns = [...plugins.values()] .filter(isWidgetPlugin) - .map(plugin => registerComponent(plugin.name, plugin.component)); + .map(plugin => { + const { wrapWidget = true } = plugin; + if (wrapWidget) { + return registerComponent(plugin.name, wrapWidgetPlugin(plugin)); + } + return registerComponent( + plugin.name, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + plugin.component as ComponentType + ); + }); return () => { deregisterFns.forEach(deregister => deregister()); diff --git a/packages/dashboard-core-plugins/src/panels/ConsolePanel.tsx b/packages/dashboard-core-plugins/src/panels/ConsolePanel.tsx index c1045baa84..f5889039c6 100644 --- a/packages/dashboard-core-plugins/src/panels/ConsolePanel.tsx +++ b/packages/dashboard-core-plugins/src/panels/ConsolePanel.tsx @@ -9,6 +9,7 @@ import { Console, ConsoleConstants, HeapUsage, + ObjectIcon, } from '@deephaven/console'; import { DashboardPanelProps, PanelEvent } from '@deephaven/dashboard'; import type { IdeSession, VariableDefinition } from '@deephaven/jsapi-types'; @@ -324,7 +325,11 @@ export class ConsolePanel extends PureComponent< iconForType(type: string): JSX.Element { const { plugins } = this.props; const plugin = [...plugins.values()].find(p => pluginSupportsType(p, type)); - return getIconForType(plugin, type); + if (plugin != null) { + return getIconForType(plugin, type); + } + // TODO: #1573 Remove this default and always return getIconForType + return ; } render(): ReactElement { 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/src/DashboardLayout.tsx b/packages/dashboard/src/DashboardLayout.tsx index f948881629..d7b899958a 100644 --- a/packages/dashboard/src/DashboardLayout.tsx +++ b/packages/dashboard/src/DashboardLayout.tsx @@ -6,7 +6,6 @@ import React, { useMemo, useState, } from 'react'; -import * as ReactIs from 'react-is'; import PropTypes from 'prop-types'; import GoldenLayout from '@deephaven/golden-layout'; import type { @@ -22,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'; @@ -29,7 +29,6 @@ import PanelEvent from './PanelEvent'; import { GLPropTypes, useListener } from './layout'; import { getDashboardData, updateDashboardData } from './redux'; import { - isWrappedComponent, PanelComponentType, PanelDehydrateFunction, PanelHydrateFunction, @@ -128,24 +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); - - // For some unknown reason, ReactIs.isForwardRef returns false, but comparing $$typeof directly works - const isForwardRef = - !isWrappedComponent(CType) && CType.$$typeof === ReactIs.ForwardRef; - - const hasRef = isClassComponent || isForwardRef; + /** + * 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; @@ -165,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.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/jsapi-types/src/dh.types.ts b/packages/jsapi-types/src/dh.types.ts index 365af150b7..20807f377b 100644 --- a/packages/jsapi-types/src/dh.types.ts +++ b/packages/jsapi-types/src/dh.types.ts @@ -82,6 +82,20 @@ export interface VariableDefinition { id?: string; } +export interface JsWidget extends Evented { + getDataAsBase64: () => string; + getDataAsU8: () => string; + 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 7a8e459185..ae51397be3 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -23,6 +23,7 @@ }, "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", diff --git a/packages/plugin/src/PluginTypes.ts b/packages/plugin/src/PluginTypes.ts index 43b3206b97..78cf84f9a1 100644 --- a/packages/plugin/src/PluginTypes.ts +++ b/packages/plugin/src/PluginTypes.ts @@ -1,4 +1,9 @@ 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'; @@ -69,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]; } @@ -92,17 +104,46 @@ 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; /** * This component is used any time a widget type supported by this plugin is created. * The component will be wrapped in a default panel if necessary. */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - component: React.ComponentType; + component: React.ComponentType; + + /** + * The title to display for widgets handled by the plugin. + * 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; + + /** + * Whether to wrap the widget in a default panel. + * Only applied if the widget is emitted directly from the server. + * Will not be applied if the widget is part of another widget such as a @deephaven/ui panel. + * + * @default true + */ + wrapWidget?: boolean; /** - * The variable types that this plugin will handle. + * The server widget types that this plugin will handle. */ supportedTypes?: string | string[]; 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" } ] From 1c9f6e8e834bf6180947d59236d49e54428baba0 Mon Sep 17 00:00:00 2001 From: Matthew Runyon Date: Thu, 12 Oct 2023 16:32:19 -0500 Subject: [PATCH 06/14] Add comment about widget loader plugin --- .../dashboard-core-plugins/src/WidgetLoaderPlugin.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/dashboard-core-plugins/src/WidgetLoaderPlugin.tsx b/packages/dashboard-core-plugins/src/WidgetLoaderPlugin.tsx index a505d08708..561762c0c8 100644 --- a/packages/dashboard-core-plugins/src/WidgetLoaderPlugin.tsx +++ b/packages/dashboard-core-plugins/src/WidgetLoaderPlugin.tsx @@ -80,6 +80,16 @@ function wrapWidgetPlugin(plugin: WidgetPlugin) { 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: DashboardPluginComponentProps ): JSX.Element | null { From 1dac8bc4e43b02ff08cfe95ffd48fdc2cffc735a Mon Sep 17 00:00:00 2001 From: Matthew Runyon Date: Thu, 12 Oct 2023 16:48:08 -0500 Subject: [PATCH 07/14] Add warning for multiple plugins registered for same widget type --- .../src/WidgetLoaderPlugin.tsx | 22 ++++++++++++------- .../src/panels/IrisGridPanel.tsx | 6 ++--- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/packages/dashboard-core-plugins/src/WidgetLoaderPlugin.tsx b/packages/dashboard-core-plugins/src/WidgetLoaderPlugin.tsx index 561762c0c8..542815fd91 100644 --- a/packages/dashboard-core-plugins/src/WidgetLoaderPlugin.tsx +++ b/packages/dashboard-core-plugins/src/WidgetLoaderPlugin.tsx @@ -20,13 +20,12 @@ import { canHaveRef, } from '@deephaven/dashboard'; import { usePlugins } from '@deephaven/app-utils'; -import { - isWidgetPlugin, - type WidgetPlugin, - WidgetComponentProps, -} from '@deephaven/plugin'; +import { isWidgetPlugin, type WidgetPlugin } from '@deephaven/plugin'; +import Log from '@deephaven/log'; import { WidgetPanel } from './panels'; +const log = Log.module('WidgetLoaderPlugin'); + function wrapWidgetPlugin(plugin: WidgetPlugin) { function Wrapper(props: PanelProps, ref: React.ForwardedRef) { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -95,7 +94,7 @@ export function WidgetLoaderPlugin( ): JSX.Element | null { const plugins = usePlugins(); const supportedTypes = useMemo(() => { - const typeMap = new Map>(); + const typeMap = new Map(); plugins.forEach(plugin => { if (!isWidgetPlugin(plugin)) { return; @@ -103,7 +102,14 @@ export function WidgetLoaderPlugin( [plugin.supportedTypes].flat().forEach(supportedType => { if (supportedType != null && supportedType !== '') { - typeMap.set(supportedType, plugin.component); + 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); } }); }); @@ -123,7 +129,7 @@ export function WidgetLoaderPlugin( }: PanelOpenEventDetail) => { const { id: widgetId, type } = widget; const name = widget.title ?? widget.name; - const component = supportedTypes.get(type); + const { component } = supportedTypes.get(type) ?? {}; if (component == null) { return; } diff --git a/packages/dashboard-core-plugins/src/panels/IrisGridPanel.tsx b/packages/dashboard-core-plugins/src/panels/IrisGridPanel.tsx index 00e4d0cdad..b0383f024b 100644 --- a/packages/dashboard-core-plugins/src/panels/IrisGridPanel.tsx +++ b/packages/dashboard-core-plugins/src/panels/IrisGridPanel.tsx @@ -141,12 +141,10 @@ export interface IrisGridPanelProps extends DashboardPanelProps { onStateChange?: (irisGridState: IrisGridState, gridState: GridState) => void; onPanelStateUpdate?: (panelState: PanelState) => void; - /** - * Override the default worker used by IrisGrid to download CSVs. - */ + /** Override the default worker used by IrisGrid to download CSVs. */ getDownloadWorker?: () => Promise; - // Load a plugin defined by the table + /** Load a plugin defined by the table */ loadPlugin: (pluginName: string) => TablePluginComponent; theme?: IrisGridThemeType; From b60a4229f5929b5e36fc48d8289b9cee1a048760 Mon Sep 17 00:00:00 2001 From: Matthew Runyon Date: Mon, 16 Oct 2023 14:05:38 -0500 Subject: [PATCH 08/14] Add panelComponent --- .../src/PandasPluginConfig.ts | 4 +++- .../src/WidgetLoaderPlugin.tsx | 12 ++++------- packages/plugin/src/PluginTypes.ts | 20 ++++++++++++------- 3 files changed, 20 insertions(+), 16 deletions(-) diff --git a/packages/dashboard-core-plugins/src/PandasPluginConfig.ts b/packages/dashboard-core-plugins/src/PandasPluginConfig.ts index c63e9ee413..d34828413a 100644 --- a/packages/dashboard-core-plugins/src/PandasPluginConfig.ts +++ b/packages/dashboard-core-plugins/src/PandasPluginConfig.ts @@ -4,9 +4,11 @@ import PandasPlugin from './PandasPlugin'; const PandasPluginConfig: WidgetPlugin = { name: 'PandasPlugin', - wrapWidget: false, + 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, }; diff --git a/packages/dashboard-core-plugins/src/WidgetLoaderPlugin.tsx b/packages/dashboard-core-plugins/src/WidgetLoaderPlugin.tsx index 542815fd91..ca58ca7aa5 100644 --- a/packages/dashboard-core-plugins/src/WidgetLoaderPlugin.tsx +++ b/packages/dashboard-core-plugins/src/WidgetLoaderPlugin.tsx @@ -50,7 +50,7 @@ function wrapWidgetPlugin(plugin: WidgetPlugin) { return ( { - const { wrapWidget = true } = plugin; - if (wrapWidget) { + const { panelComponent } = plugin; + if (panelComponent == null) { return registerComponent(plugin.name, wrapWidgetPlugin(plugin)); } - return registerComponent( - plugin.name, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - plugin.component as ComponentType - ); + return registerComponent(plugin.name, panelComponent); }); return () => { diff --git a/packages/plugin/src/PluginTypes.ts b/packages/plugin/src/PluginTypes.ts index 78cf84f9a1..911e36a089 100644 --- a/packages/plugin/src/PluginTypes.ts +++ b/packages/plugin/src/PluginTypes.ts @@ -119,13 +119,18 @@ export interface WidgetComponentProps { export interface WidgetPlugin extends Plugin { type: typeof PluginType.WIDGET_PLUGIN; /** - * This component is used any time a widget type supported by this plugin is created. - * The component will be wrapped in a default panel if necessary. + * 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 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`. @@ -134,13 +139,14 @@ export interface WidgetPlugin extends Plugin { title?: string; /** - * Whether to wrap the widget in a default panel. - * Only applied if the widget is emitted directly from the server. - * Will not be applied if the widget is part of another widget such as a @deephaven/ui panel. + * 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. * - * @default true + * See @deephaven/dashboard-core-plugins WidgetPanel for the component that should be used here. */ - wrapWidget?: boolean; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + panelComponent?: React.ComponentType; /** * The server widget types that this plugin will handle. From 9c9e1e7ff1c09b767cddb78fa087a0cec183b74a Mon Sep 17 00:00:00 2001 From: Matthew Runyon Date: Mon, 16 Oct 2023 14:29:01 -0500 Subject: [PATCH 09/14] Fix jsapi type --- packages/jsapi-types/src/dh.types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/jsapi-types/src/dh.types.ts b/packages/jsapi-types/src/dh.types.ts index 20807f377b..c9c3218c90 100644 --- a/packages/jsapi-types/src/dh.types.ts +++ b/packages/jsapi-types/src/dh.types.ts @@ -84,7 +84,7 @@ export interface VariableDefinition { export interface JsWidget extends Evented { getDataAsBase64: () => string; - getDataAsU8: () => string; + getDataAsU8: () => Uint8Array; getDataAsString: () => string; exportedObjects: { fetch: () => Promise
; From 683ed8667aa8cb4ca0f8dc5ff8e1e941e6ae16cc Mon Sep 17 00:00:00 2001 From: Matthew Runyon Date: Tue, 17 Oct 2023 17:38:00 -0500 Subject: [PATCH 10/14] Add tests --- .../src/WidgetLoaderPlugin.test.tsx | 274 ++++++++++++++++++ .../src/WidgetLoaderPlugin.tsx | 28 +- .../dashboard-core-plugins/src/index.test.tsx | 2 + packages/dashboard-core-plugins/src/index.ts | 1 + packages/golden-layout/src/controls/Header.ts | 3 +- packages/plugin/src/PluginTypes.ts | 10 +- 6 files changed, 298 insertions(+), 20 deletions(-) create mode 100644 packages/dashboard-core-plugins/src/WidgetLoaderPlugin.test.tsx 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..5871fcd52e --- /dev/null +++ b/packages/dashboard-core-plugins/src/WidgetLoaderPlugin.test.tsx @@ -0,0 +1,274 @@ +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; +} + +beforeEach(() => { + // GoldenLayout may trigger some console warns/errors since + // we're not running in a real DOM + // TestUtils.disableConsoleOutput(); +}); + +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], + [ + '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-two' }, + }) + ); + 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 index ca58ca7aa5..b618adb1e8 100644 --- a/packages/dashboard-core-plugins/src/WidgetLoaderPlugin.tsx +++ b/packages/dashboard-core-plugins/src/WidgetLoaderPlugin.tsx @@ -26,7 +26,9 @@ import { WidgetPanel } from './panels'; const log = Log.module('WidgetLoaderPlugin'); -function wrapWidgetPlugin(plugin: WidgetPlugin) { +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; @@ -90,7 +92,7 @@ function wrapWidgetPlugin(plugin: WidgetPlugin) { * @returns React element */ export function WidgetLoaderPlugin( - props: DashboardPluginComponentProps + props: Partial ): JSX.Element | null { const plugins = usePlugins(); const supportedTypes = useMemo(() => { @@ -129,8 +131,8 @@ export function WidgetLoaderPlugin( }: PanelOpenEventDetail) => { const { id: widgetId, type } = widget; const name = widget.title ?? widget.name; - const { component } = supportedTypes.get(type) ?? {}; - if (component == null) { + const plugin = supportedTypes.get(type); + if (plugin == null) { return; } const metadata = { id: widgetId, name, type }; @@ -144,7 +146,7 @@ export function WidgetLoaderPlugin( const config: ReactComponentConfig = { type: 'react-component', - component: component.displayName ?? '', + component: plugin.name, props: panelProps, title: name, id: panelId, @@ -157,15 +159,13 @@ export function WidgetLoaderPlugin( ); useEffect(() => { - const deregisterFns = [...plugins.values()] - .filter(isWidgetPlugin) - .map(plugin => { - const { panelComponent } = plugin; - if (panelComponent == null) { - return registerComponent(plugin.name, wrapWidgetPlugin(plugin)); - } - return registerComponent(plugin.name, panelComponent); - }); + 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()); diff --git a/packages/dashboard-core-plugins/src/index.test.tsx b/packages/dashboard-core-plugins/src/index.test.tsx index e086cb9a21..d0a5cd2cdc 100644 --- a/packages/dashboard-core-plugins/src/index.test.tsx +++ b/packages/dashboard-core-plugins/src/index.test.tsx @@ -14,6 +14,7 @@ import { GridPlugin, LinkerPlugin, MarkdownPlugin, + WidgetLoaderPlugin, } from '.'; function makeConnection(): IdeConnection { @@ -39,6 +40,7 @@ it('handles mounting and unmount core plugins properly', () => { + diff --git a/packages/dashboard-core-plugins/src/index.ts b/packages/dashboard-core-plugins/src/index.ts index d533976ba0..c1f9b7882b 100644 --- a/packages/dashboard-core-plugins/src/index.ts +++ b/packages/dashboard-core-plugins/src/index.ts @@ -13,6 +13,7 @@ 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'; 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/plugin/src/PluginTypes.ts b/packages/plugin/src/PluginTypes.ts index 911e36a089..7a72ebd21e 100644 --- a/packages/plugin/src/PluginTypes.ts +++ b/packages/plugin/src/PluginTypes.ts @@ -127,6 +127,11 @@ export interface WidgetPlugin extends Plugin { */ 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. @@ -148,11 +153,6 @@ export interface WidgetPlugin extends Plugin { // eslint-disable-next-line @typescript-eslint/no-explicit-any panelComponent?: React.ComponentType; - /** - * The server widget types that this plugin will handle. - */ - supportedTypes?: string | string[]; - /** * The icon to display next to the console button. * If a react node is provided (including a string), it will be rendered directly. From 5f1debd316e02ecb0b9bfea513131384ea5a0c34 Mon Sep 17 00:00:00 2001 From: Matthew Runyon Date: Tue, 17 Oct 2023 17:42:22 -0500 Subject: [PATCH 11/14] Improve test --- .../src/WidgetLoaderPlugin.test.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/dashboard-core-plugins/src/WidgetLoaderPlugin.test.tsx b/packages/dashboard-core-plugins/src/WidgetLoaderPlugin.test.tsx index 5871fcd52e..45c66d7a59 100644 --- a/packages/dashboard-core-plugins/src/WidgetLoaderPlugin.test.tsx +++ b/packages/dashboard-core-plugins/src/WidgetLoaderPlugin.test.tsx @@ -200,7 +200,13 @@ describe('WidgetLoaderPlugin', () => { it('Overrides plugins that handle the same widget type', async () => { const layoutManager = createAndMountDashboard([ - ['test-widget-plugin', testWidgetPlugin], + [ + 'test-widget-plugin', + { + ...testWidgetPlugin, + supportedTypes: ['test-widget', 'test-widget-a'], + }, + ], [ 'test-widget-plugin-two', { @@ -224,9 +230,10 @@ describe('WidgetLoaderPlugin', () => { act( () => layoutManager?.eventHub.emit(PanelEvent.OPEN, { - widget: { type: 'test-widget-two' }, + widget: { type: 'test-widget-a' }, }) ); + expect(screen.queryAllByText('TestWidget').length).toBe(1); expect(screen.queryAllByText('TestWidgetTwo').length).toBe(1); }); }); From 80116e2465e78a4db7394778f3ede1ddfcfea7fa Mon Sep 17 00:00:00 2001 From: Matthew Runyon Date: Wed, 18 Oct 2023 00:28:06 -0500 Subject: [PATCH 12/14] Add PluginUtils tests --- .../src/panels/ConsolePanel.tsx | 6 +- packages/plugin/src/PluginUtils.test.tsx | 60 +++++++++++++++++++ packages/plugin/src/PluginUtils.tsx | 10 +--- 3 files changed, 66 insertions(+), 10 deletions(-) create mode 100644 packages/plugin/src/PluginUtils.test.tsx diff --git a/packages/dashboard-core-plugins/src/panels/ConsolePanel.tsx b/packages/dashboard-core-plugins/src/panels/ConsolePanel.tsx index f5889039c6..25998a7dd6 100644 --- a/packages/dashboard-core-plugins/src/panels/ConsolePanel.tsx +++ b/packages/dashboard-core-plugins/src/panels/ConsolePanel.tsx @@ -23,7 +23,7 @@ import { RootState, } from '@deephaven/redux'; import { assertNotNull } from '@deephaven/utils'; -import { getIconForType, pluginSupportsType } from '@deephaven/plugin'; +import { getIconForPlugin, pluginSupportsType } from '@deephaven/plugin'; import type { JSZipObject } from 'jszip'; import { ConsoleEvent } from '../events'; import Panel from './Panel'; @@ -326,9 +326,9 @@ export class ConsolePanel extends PureComponent< const { plugins } = this.props; const plugin = [...plugins.values()].find(p => pluginSupportsType(p, type)); if (plugin != null) { - return getIconForType(plugin, type); + return getIconForPlugin(plugin); } - // TODO: #1573 Remove this default and always return getIconForType + // TODO: #1573 Remove this default and always return getIconForPlugin return ; } 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 index 1798450a15..6fa9024153 100644 --- a/packages/plugin/src/PluginUtils.tsx +++ b/packages/plugin/src/PluginUtils.tsx @@ -14,19 +14,15 @@ export function pluginSupportsType( return [plugin.supportedTypes].flat().some(t => t === type); } -export function getIconForType( - plugin: PluginModule | undefined, - type: string -): React.ReactElement { +export function getIconForPlugin(plugin: PluginModule): React.ReactElement { const defaultIcon = ; - if (plugin == null || !isWidgetPlugin(plugin)) { + if (!isWidgetPlugin(plugin)) { return defaultIcon; } - const supportsType = pluginSupportsType(plugin, type); const { icon } = plugin; - if (!supportsType || icon == null) { + if (icon == null) { return defaultIcon; } From 1a72b2af422185ceb00bafe5d41b337e008ba140 Mon Sep 17 00:00:00 2001 From: Matthew Runyon Date: Wed, 18 Oct 2023 00:57:31 -0500 Subject: [PATCH 13/14] Add DashboardUtils test --- .../dashboard/src/DashboardUtils.test.tsx | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 packages/dashboard/src/DashboardUtils.test.tsx 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); +}); From 74a62ae7ea06f2942f45844257b475b53500666a Mon Sep 17 00:00:00 2001 From: Matthew Runyon Date: Wed, 18 Oct 2023 16:19:36 -0500 Subject: [PATCH 14/14] Remove unused beforeEach block --- .../dashboard-core-plugins/src/WidgetLoaderPlugin.test.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/dashboard-core-plugins/src/WidgetLoaderPlugin.test.tsx b/packages/dashboard-core-plugins/src/WidgetLoaderPlugin.test.tsx index 45c66d7a59..77c12c3f6f 100644 --- a/packages/dashboard-core-plugins/src/WidgetLoaderPlugin.test.tsx +++ b/packages/dashboard-core-plugins/src/WidgetLoaderPlugin.test.tsx @@ -104,12 +104,6 @@ function createAndMountDashboard( return layoutManager; } -beforeEach(() => { - // GoldenLayout may trigger some console warns/errors since - // we're not running in a real DOM - // TestUtils.disableConsoleOutput(); -}); - describe('WidgetLoaderPlugin', () => { it('Mounts components that should be wrapped', async () => { const layoutManager = createAndMountDashboard();