diff --git a/package-lock.json b/package-lock.json index b0d22cdf6..e9396ae09 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29227,14 +29227,16 @@ "react-window": "^1.8.6" }, "devDependencies": { - "@deephaven/mocks": "file:../mocks" + "@deephaven/mocks": "file:../mocks", + "react-redux": "^7.2.4" }, "engines": { "node": ">=10" }, "peerDependencies": { "react": ">=16.8.0", - "react-dom": ">=16.8.0" + "react-dom": ">=16.8.0", + "react-is": ">=16.8.0" } }, "packages/console": { @@ -29304,7 +29306,6 @@ "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0", - "react-is": ">=16.8.0", "react-redux": "^7.2.4" } }, @@ -29739,12 +29740,6 @@ "@types/lodash": "*" } }, - "packages/dashboard/node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "peer": true - }, "packages/embed-chart": { "name": "@deephaven/embed-chart", "version": "0.85.0", @@ -31668,6 +31663,7 @@ "popper.js": "^1.16.1", "prop-types": "^15.7.2", "react-beautiful-dnd": "^13.1.0", + "react-redux": "^7.2.4", "react-transition-group": "^4.4.2", "react-virtualized-auto-sizer": "1.0.6", "react-window": "^1.8.6" @@ -31729,12 +31725,6 @@ "requires": { "@types/lodash": "*" } - }, - "react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "peer": true } } }, diff --git a/packages/code-studio/src/main/AppMainContainer.tsx b/packages/code-studio/src/main/AppMainContainer.tsx index eb96bb33d..fd2c6000b 100644 --- a/packages/code-studio/src/main/AppMainContainer.tsx +++ b/packages/code-studio/src/main/AppMainContainer.tsx @@ -30,6 +30,7 @@ import { DashboardUtils, DEFAULT_DASHBOARD_ID, DehydratedDashboardPanelProps, + emitPanelOpen, getAllDashboardsData, getDashboardData, listenForCreateDashboard, @@ -75,8 +76,9 @@ import { copyToClipboard, PromiseUtils, EMPTY_ARRAY, + assertNotNull, } from '@deephaven/utils'; -import GoldenLayout from '@deephaven/golden-layout'; +import GoldenLayout, { EventHub } from '@deephaven/golden-layout'; import type { ItemConfig } from '@deephaven/golden-layout'; import { type PluginModuleMap, getDashboardPlugins } from '@deephaven/plugin'; import { @@ -394,10 +396,15 @@ export class AppMainContainer extends Component< this.emitLayoutEvent(PanelEvent.REOPEN_LAST); } - emitLayoutEvent(event: string, ...args: unknown[]): void { + getActiveEventHub(): EventHub { const { activeTabKey } = this.state; const layout = this.dashboardLayouts.get(activeTabKey); - layout?.eventHub.emit(event, ...args); + assertNotNull(layout, 'No active layout found'); + return layout.eventHub; + } + + emitLayoutEvent(event: string, ...args: unknown[]): void { + this.getActiveEventHub().emit(event, ...args); } handleCancelResetLayoutPrompt(): void { @@ -702,10 +709,10 @@ export class AppMainContainer extends Component< dragEvent?: WindowMouseEvent ): void { const { connection } = this.props; - this.emitLayoutEvent(PanelEvent.OPEN, { + emitPanelOpen(this.getActiveEventHub(), { + widget: getVariableDescriptor(widget), dragEvent, fetch: async () => connection?.getObject(widget), - widget: getVariableDescriptor(widget), }); } diff --git a/packages/code-studio/src/styleguide/StyleGuide.tsx b/packages/code-studio/src/styleguide/StyleGuide.tsx index 336b8ab9c..387064405 100644 --- a/packages/code-studio/src/styleguide/StyleGuide.tsx +++ b/packages/code-studio/src/styleguide/StyleGuide.tsx @@ -39,6 +39,7 @@ import SpectrumComparison from './SpectrumComparison'; import Pickers from './Pickers'; import ListViews from './ListViews'; import ErrorViews from './ErrorViews'; +import XComponents from './XComponents'; const stickyProps = { position: 'sticky', @@ -134,13 +135,14 @@ function StyleGuide(): React.ReactElement { + + - ); diff --git a/packages/code-studio/src/styleguide/XComponents.tsx b/packages/code-studio/src/styleguide/XComponents.tsx new file mode 100644 index 000000000..a67204c85 --- /dev/null +++ b/packages/code-studio/src/styleguide/XComponents.tsx @@ -0,0 +1,123 @@ +import React, { useState } from 'react'; +import { + XComponentMapProvider, + createXComponent, + Button, +} from '@deephaven/components'; +import SampleSection from './SampleSection'; + +type FooComponentProps = { value: string }; + +function FooComponent({ value }: FooComponentProps) { + return ( + + ); +} +FooComponent.displayName = 'FooComponent'; + +// Create an XComponent from FooComponent to allow for replacement +const XFooComponent = createXComponent(FooComponent); + +function NestedFooComponent({ value }: FooComponentProps) { + // We're using the XComponent version so this panel can be replaced if it is mapped from a parent context to a replacement + return ; +} + +function MultiFooComponent({ value }: FooComponentProps) { + // Show multiple instances getting replaced + return ( +
+ + +
+ ); +} + +// What we're replacing the XFooComponent with. +function ReverseFooComponent({ value }: FooComponentProps) { + return ( + + ); +} + +/** + * Some examples showing usage of XComponents. + */ +export function XComponents(): JSX.Element { + const [value, setValue] = useState('hello'); + + return ( + +

XComponents

+

+ XComponents are a way to replace a component with another component + without needing to pass props all the way down the component tree. This + can be useful in cases where we have a component deep down in the + component tree that we want to replace with a different component, but + don't want to have to provide props at the top level just to hook + into that. +
+ Below is a component that is simply a button displaying the text + inputted in the input field. We will replace this component with a new + component that reverses the text, straight up, then in a nested + scenario, and then multiple instances. +

+
+ +
+
+
+ Original Component +
+ +
+ + Replaced with Reverse +
+ + + +
+
+
+ Nested component replaced +
+ + {/* The `FooComponent` that gets replaced is from within the `NestedFooComponent` */} + + +
+
+
+ Multiple Components replaced +
+ + + +
+
+
+
+ ); +} + +export default XComponents; diff --git a/packages/components/package.json b/packages/components/package.json index 6f6ea2c06..4cab11821 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -53,10 +53,12 @@ }, "peerDependencies": { "react": ">=16.8.0", - "react-dom": ">=16.8.0" + "react-dom": ">=16.8.0", + "react-is": ">=16.8.0" }, "devDependencies": { - "@deephaven/mocks": "file:../mocks" + "@deephaven/mocks": "file:../mocks", + "react-redux": "^7.2.4" }, "files": [ "dist", diff --git a/packages/components/src/ComponentUtils.test.tsx b/packages/components/src/ComponentUtils.test.tsx new file mode 100644 index 000000000..bd347b81a --- /dev/null +++ b/packages/components/src/ComponentUtils.test.tsx @@ -0,0 +1,57 @@ +import React, { PropsWithChildren } from 'react'; +// We only use react-redux from tests in @deephaven/components, so it is only added as a devDependency +import { connect } from 'react-redux'; +import { + canHaveRef, + isClassComponent, + isWrappedComponent, + isForwardRefComponentType, +} from './ComponentUtils'; + +function TestComponent() { + return
Test
; +} + +class TestClass extends React.PureComponent> { + render() { + return
Test
; + } +} + +test('isForwardRefComponent', () => { + expect(isForwardRefComponentType(TestComponent)).toBe(false); + expect(isForwardRefComponentType(React.forwardRef(TestComponent))).toBe(true); + expect(isForwardRefComponentType(TestClass)).toBe(false); + expect(isForwardRefComponentType(connect(null, null)(TestComponent))).toBe( + false + ); + expect(isForwardRefComponentType(connect(null, null)(TestClass))).toBe(false); +}); + +test('isClassComponent', () => { + expect(isClassComponent(TestComponent)).toBe(false); + expect(isClassComponent(TestClass)).toBe(true); + expect(isClassComponent(React.forwardRef(TestComponent))).toBe(false); + expect(isClassComponent(connect(null, null)(TestComponent))).toBe(false); + expect(isClassComponent(connect(null, null)(TestClass))).toBe(true); +}); + +test('isWrappedComponent', () => { + expect(isWrappedComponent(TestComponent)).toBe(false); + expect(isWrappedComponent(TestClass)).toBe(false); + expect(isWrappedComponent(connect(null, null)(TestComponent))).toBe(true); + expect(isWrappedComponent(React.forwardRef(TestComponent))).toBe(false); + expect(isWrappedComponent(connect(null, null)(TestClass))).toBe(true); +}); + +test('canHaveRef', () => { + const forwardedType = React.forwardRef(TestComponent); + + expect(canHaveRef(TestComponent)).toBe(false); + expect(canHaveRef(forwardedType)).toBe(true); + expect(canHaveRef(TestClass)).toBe(true); + expect(canHaveRef(connect(null, null)(TestClass))).toBe(true); + expect( + canHaveRef(connect(null, null, null, { forwardRef: true })(TestClass)) + ).toBe(true); +}); diff --git a/packages/components/src/ComponentUtils.ts b/packages/components/src/ComponentUtils.ts new file mode 100644 index 000000000..c63e66372 --- /dev/null +++ b/packages/components/src/ComponentUtils.ts @@ -0,0 +1,80 @@ +import React, { + ComponentType, + ForwardRefExoticComponent, + RefAttributes, +} from 'react'; +import { ForwardRef } from 'react-is'; + +export type Props = Record | RefAttributes; + +/** + * Type that represents a component that has been wrapped by redux. + */ +export type WrappedComponentType< + P extends Props, + C extends ComponentType

, +> = ComponentType

& { + WrappedComponent: C; +}; + +/** + * Checks if a component is a wrapped component. + * @param Component The component to check + * @returns Whether the component is a wrapped component or not + */ +export function isWrappedComponent

>( + Component: React.ComponentType

+): Component is WrappedComponentType { + return ( + (Component as WrappedComponentType)?.WrappedComponent !== undefined + ); +} + +/** + * Checks if a component is a class component. + * @param Component The component to check + * @returns Whether the component is a class component or not + */ +export function isClassComponent

( + Component: React.ComponentType

+): Component is React.ComponentClass

{ + if ( + isWrappedComponent(Component) && + isClassComponent(Component.WrappedComponent) + ) { + return true; + } + return ( + (Component as React.ComponentClass

).prototype != null && + (Component as React.ComponentClass

).prototype.isReactComponent != null + ); +} + +/** + * Checks if a component is a forward ref component. + * @param Component The component to check + * @returns Whether the component is a forward ref component or not + */ +export function isForwardRefComponentType

( + Component: ComponentType

+): Component is ForwardRefExoticComponent

{ + return ( + !isWrappedComponent(Component) && + // Do a check right on the `$$typeof` the component. The `isForwardRef` function in `react-is` checks against a `Component` instance, whereas + // we want to check against a `ComponentType` which is the class/function that defines a component. + '$$typeof' in Component && + Component.$$typeof === ForwardRef + ); +} + +/** + * Checks if a component can have a ref. Helps silence react dev errors + * if a ref is passed to a functional component without forwardRef. + * @param Component The component to check if it can take a ref + * @returns Whether the component can have a ref or not + */ +export function canHaveRef

( + Component: ComponentType

| WrappedComponentType> +): boolean { + return isClassComponent(Component) || isForwardRefComponentType(Component); +} diff --git a/packages/components/src/XComponent.test.tsx b/packages/components/src/XComponent.test.tsx new file mode 100644 index 000000000..af1946d3f --- /dev/null +++ b/packages/components/src/XComponent.test.tsx @@ -0,0 +1,191 @@ +/* eslint-disable max-classes-per-file */ +import React from 'react'; +import { render } from '@testing-library/react'; +import { createXComponent } from './XComponent'; +import { XComponentMapProvider } from './XComponentMap'; + +function MyComponent({ children }: { children?: React.ReactNode } = {}) { + return ( +

+ MyComponent +
{children}
+
+ ); +} + +function FooComponent({ foo = 'foo' }: { foo: string }) { + return
{foo}
; +} + +const MyRefComponent = React.forwardRef< + HTMLDivElement, + { children?: React.ReactNode } +>(function MyRefComponent({ children }, ref) { + return ( +
+ MyRefComponent +
{children}
+
+ ); +}); + +class MyClassComponent extends React.Component { + public foo = 'bar'; + + render() { + return
MyClassComponent
; + } +} + +const XMyComponent = createXComponent(MyComponent); +const XFooComponent = createXComponent(FooComponent); +const XMyRefComponent = createXComponent(MyRefComponent); +const XMyClassComponent = createXComponent(MyClassComponent); + +function MyReplacementComponent() { + return
MyReplacementComponent
; +} + +function MyReplacementWrapperComponent({ + children, +}: { children?: React.ReactNode } = {}) { + return ( +
+
MyReplacementWrapperComponent
+ {children} +
+ ); +} + +function ReverseFooComponent({ foo }: { foo: string }) { + return
{foo.split('').reverse().join('')}
; +} + +const MyReplacementRefComponent = React.forwardRef< + HTMLDivElement, + { children?: React.ReactNode } +>(function MyReplacementRefComponent({ children }, ref) { + return ( +
+ MyReplacementRefComponent +
{children}
+
+ ); +}); + +class MyReplacementClassComponent extends React.Component { + public foo = 'baz'; + + render() { + return
MyReplacementClassComponent
; + } +} + +describe('ExtendableComponent', () => { + it('should render the original component', () => { + const { getByText } = render(); + expect(getByText('MyComponent')).toBeInTheDocument(); + }); + + it('should render the replacement component', () => { + const { getByText } = render( + + + + ); + expect(getByText('MyReplacementComponent')).toBeInTheDocument(); + }); + + it('should render the original component inside the replacement component', () => { + const { getByText } = render( + + + + ); + expect(getByText('MyReplacementWrapperComponent')).toBeInTheDocument(); + expect(getByText('MyComponent')).toBeInTheDocument(); + }); + + it('should render the original component with props', () => { + const { getByText } = render(); + expect(getByText('bar')).toBeInTheDocument(); + }); + + it('should render the replacement component with props', () => { + const { getByText } = render( + + + + ); + expect(getByText('rab')).toBeInTheDocument(); + }); + + it('should render the original ref component', () => { + const ref = React.createRef(); + const { getByText } = render(); + expect(getByText('MyRefComponent')).toBeInTheDocument(); + expect(ref.current).toBeInTheDocument(); + expect(ref.current?.getAttribute('data-testid')).toBe('my-ref-component'); + }); + + it('should render the replacement ref component', () => { + const ref = React.createRef(); + const { getByText } = render( + + + + ); + expect(getByText('MyReplacementRefComponent')).toBeInTheDocument(); + expect(ref.current).toBeInTheDocument(); + expect(ref.current?.getAttribute('data-testid')).toBe( + 'my-replacement-ref-component' + ); + }); + + it('should render the original class component', () => { + const ref = React.createRef(); + const { getByText } = render(); + expect(getByText('MyClassComponent')).toBeInTheDocument(); + }); + + it('should render the replacement class component', () => { + const { getByText } = render( + + + + ); + expect(getByText('MyReplacementClassComponent')).toBeInTheDocument(); + }); + + it('should render the original class component with the ref', () => { + const ref = React.createRef(); + const { getByText } = render(); + expect(getByText('MyClassComponent')).toBeInTheDocument(); + expect(ref.current).toBeInstanceOf(MyClassComponent); + expect(ref.current?.foo).toBe('bar'); + }); + + it('should render the replacement class component with the ref', () => { + const ref = React.createRef(); + const { getByText } = render( + + + + ); + expect(getByText('MyReplacementClassComponent')).toBeInTheDocument(); + expect(ref.current).toBeInstanceOf(MyReplacementClassComponent); + expect(ref.current?.foo).toBe('baz'); + }); +}); diff --git a/packages/components/src/XComponent.tsx b/packages/components/src/XComponent.tsx new file mode 100644 index 000000000..0f1c9c0cb --- /dev/null +++ b/packages/components/src/XComponent.tsx @@ -0,0 +1,71 @@ +import React, { ComponentType, forwardRef } from 'react'; +import { canHaveRef } from './ComponentUtils'; +import { useXComponent, XComponentType } from './XComponentMap'; + +/** + * Helper function that will wrap the provided component, and return an ExtendableComponent type. + * Whenever that ExtendableComponent is used, it will check if there is a replacement component for the provided component on the context. + * If there is, it will use that component instead of the provided component. + * This is a similar concept to how swizzling is done in Docusaurus or obj-c, but for any React component. + * + * Usage: + * + * ```tsx + * function MyComponent() { + * return
MyComponent
; + * } + * + * const XMyComponent = extendableComponent(MyComponent); + * + * function MyReplacementComponent() { + * return
MyReplacementComponent
; + * } + * + * // Will render MyComponent + * + * + * // Will render MyReplacementComponent + * + * + * ``` + * + * Is useful in cases where we have a component deep down in the component tree that we want to replace with a different component, but don't want to + * have to provide props at the top level just to hook into that. + * + * @param Component The component to wrap + * @returns The wrapped component + */ +export function createXComponent

>( + Component: React.ComponentType

+): XComponentType

{ + let forwardedRefComponent: XComponentType

; + function XComponent( + props: P, + ref: React.ForwardedRef> + ): JSX.Element { + const ReplacementComponent = useXComponent(forwardedRefComponent); + return canHaveRef(Component) ? ( + // eslint-disable-next-line react/jsx-props-no-spreading + + ) : ( + // eslint-disable-next-line react/jsx-props-no-spreading + + ); + } + + // Add the display name so this appears as a tag in the React DevTools + // Need to add it here, and then when it's wrapped with the `forwardRef` it will automatically get the display name of the original component + XComponent.displayName = `XComponent(${ + Component.displayName ?? Component.name + })`; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + forwardedRefComponent = forwardRef(XComponent) as any; + + forwardedRefComponent.Original = Component; + forwardedRefComponent.isXComponent = true; + + return forwardedRefComponent; +} + +export default createXComponent; diff --git a/packages/components/src/XComponentMap.ts b/packages/components/src/XComponentMap.ts new file mode 100644 index 000000000..2b78a2081 --- /dev/null +++ b/packages/components/src/XComponentMap.ts @@ -0,0 +1,29 @@ +import React, { useContext } from 'react'; + +/** Type for an extended component. Can fetch the original component using `.Original` */ +export type XComponentType

> = + React.ForwardRefExoticComponent< + React.PropsWithoutRef

& React.RefAttributes + > & { + Original: React.ComponentType

; + isXComponent: boolean; + }; + +export const XComponentMapContext = React.createContext( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + new Map, React.ComponentType>() +); + +export const XComponentMapProvider = XComponentMapContext.Provider; + +/** + * Use the replacement component for the provided component if it exists, or just return the provided component. + * @param Component Component to check if there's a replacement for + * @returns The replacement component if it exists, otherwise the original component + */ +export function useXComponent

>( + Component: XComponentType

+): React.ComponentType

{ + const ctx = useContext(XComponentMapContext); + return ctx.get(Component) ?? Component.Original; +} diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 2b0ff450a..69c8292f6 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -9,6 +9,7 @@ export { default as CardFlip } from './CardFlip'; export * from './context-actions'; export { default as Collapse } from './Collapse'; export { default as Checkbox } from './Checkbox'; +export * from './ComponentUtils'; export { default as CopyButton } from './CopyButton'; export { default as CustomTimeSelect } from './CustomTimeSelect'; export * from './DateTimeInput'; @@ -56,3 +57,5 @@ export { default as TimeSlider } from './TimeSlider'; export { default as ToastNotification } from './ToastNotification'; export * from './UIConstants'; export { default as UISwitch } from './UISwitch'; +export * from './XComponent'; +export * from './XComponentMap'; diff --git a/packages/dashboard-core-plugins/src/ChartPanelPlugin.tsx b/packages/dashboard-core-plugins/src/ChartPanelPlugin.tsx index dcd40c4fc..92d9e8751 100644 --- a/packages/dashboard-core-plugins/src/ChartPanelPlugin.tsx +++ b/packages/dashboard-core-plugins/src/ChartPanelPlugin.tsx @@ -75,6 +75,7 @@ async function createChartModel( if (metadata.type === dh.VariableType.FIGURE) { const descriptor = { + ...metadata, name: figureName, type: dh.VariableType.FIGURE, }; @@ -87,6 +88,7 @@ async function createChartModel( } const descriptor = { + ...metadata, name: tableName, type: dh.VariableType.TABLE, }; diff --git a/packages/dashboard-core-plugins/src/WidgetLoaderPlugin.tsx b/packages/dashboard-core-plugins/src/WidgetLoaderPlugin.tsx index 8fcfd8d86..d4e951287 100644 --- a/packages/dashboard-core-plugins/src/WidgetLoaderPlugin.tsx +++ b/packages/dashboard-core-plugins/src/WidgetLoaderPlugin.tsx @@ -5,12 +5,11 @@ import { assertIsDashboardPluginProps, DashboardPluginComponentProps, DehydratedDashboardPanelProps, - PanelEvent, PanelOpenEventDetail, LayoutUtils, - useListener, PanelProps, canHaveRef, + usePanelOpenListener, } from '@deephaven/dashboard'; import Log from '@deephaven/log'; import { @@ -19,6 +18,7 @@ import { type WidgetPlugin, } from '@deephaven/plugin'; import { WidgetPanel } from './panels'; +import { WidgetPanelDescriptor } from './panels/WidgetPanelTypes'; const log = Log.module('WidgetLoaderPlugin'); @@ -30,12 +30,17 @@ export function WrapWidgetPlugin( const C = plugin.component as any; const { metadata } = props; + const panelDescriptor: WidgetPanelDescriptor = { + ...metadata, + type: metadata?.type ?? plugin.type, + name: metadata?.name ?? 'Widget', + }; + const hasRef = canHaveRef(C); return ( @@ -156,7 +161,7 @@ export function WidgetLoaderPlugin( /** * Listen for panel open events so we know when to open a panel */ - useListener(layout.eventHub, PanelEvent.OPEN, handlePanelOpen); + usePanelOpenListener(layout.eventHub, handlePanelOpen); return null; } diff --git a/packages/dashboard-core-plugins/src/controls/dropdown-filter/DropdownFilter.tsx b/packages/dashboard-core-plugins/src/controls/dropdown-filter/DropdownFilter.tsx index b8ede9789..1d0e4e00a 100644 --- a/packages/dashboard-core-plugins/src/controls/dropdown-filter/DropdownFilter.tsx +++ b/packages/dashboard-core-plugins/src/controls/dropdown-filter/DropdownFilter.tsx @@ -37,7 +37,7 @@ export interface DropdownFilterColumn { export interface DropdownFilterProps { column: DropdownFilterColumn; - columns: DropdownFilterColumn[]; + columns: readonly DropdownFilterColumn[]; onSourceMouseEnter: () => void; onSourceMouseLeave: () => void; disableLinking: boolean; @@ -47,7 +47,7 @@ export interface DropdownFilterProps { settingsError: string; source: LinkPoint; value: string | null; - values: (string | null)[]; + values: readonly (string | null)[]; onChange: (change: { column: Partial | null; isValueShown?: boolean; @@ -159,7 +159,7 @@ export class DropdownFilter extends Component< dropdownRef: RefObject; getCompatibleColumns = memoize( - (source: LinkPoint, columns: DropdownFilterColumn[]) => + (source: LinkPoint, columns: readonly DropdownFilterColumn[]) => source != null ? columns.filter( ({ type }) => @@ -200,10 +200,11 @@ export class DropdownFilter extends Component< ); getSelectedOptionIndex = memoize( - (values: (string | null)[], value: string | null) => values.indexOf(value) + (values: readonly (string | null)[], value: string | null) => + values.indexOf(value) ); - getValueOptions = memoize((values: (string | null)[]) => [ + getValueOptions = memoize((values: readonly (string | null)[]) => [ , @@ -218,19 +219,21 @@ export class DropdownFilter extends Component< )), ]); - getItemLabel = memoizee((columns: DropdownFilterColumn[], index: number) => { - const { name, type } = columns[index]; + getItemLabel = memoizee( + (columns: readonly DropdownFilterColumn[], index: number) => { + const { name, type } = columns[index]; - if ( - (index > 0 && columns[index - 1].name === name) || - (index < columns.length - 1 && columns[index + 1].name === name) - ) { - const shortType = type.substring(type.lastIndexOf('.') + 1); - return `${name} (${shortType})`; - } + if ( + (index > 0 && columns[index - 1].name === name) || + (index < columns.length - 1 && columns[index + 1].name === name) + ) { + const shortType = type.substring(type.lastIndexOf('.') + 1); + return `${name} (${shortType})`; + } - return name; - }); + return name; + } + ); handleColumnChange(eventTargetValue: string): void { const value = eventTargetValue; diff --git a/packages/dashboard-core-plugins/src/panels/ChartPanel.tsx b/packages/dashboard-core-plugins/src/panels/ChartPanel.tsx index e6ebff54d..e100ec5ef 100644 --- a/packages/dashboard-core-plugins/src/panels/ChartPanel.tsx +++ b/packages/dashboard-core-plugins/src/panels/ChartPanel.tsx @@ -65,6 +65,7 @@ import { isChartPanelTableMetadata, } from './ChartPanelUtils'; import { ColumnSelectionValidator } from '../linker/ColumnSelectionValidator'; +import { WidgetPanelDescriptor } from './WidgetPanelTypes'; const log = Log.module('ChartPanel'); const UPDATE_MODEL_DEBOUNCE = 150; @@ -458,6 +459,24 @@ export class ChartPanel extends Component { })) ); + getWidgetPanelDescriptor = memoize( + (metadata: ChartPanelProps['metadata']): WidgetPanelDescriptor => { + let name = 'Chart'; + if (isChartPanelTableMetadata(metadata)) { + name = metadata.table; + } else if (isChartPanelFigureMetadata(metadata)) { + name = metadata.figure ?? name; + } else { + name = metadata.name ?? name; + } + return { + ...metadata, + type: 'Chart', + name, + }; + } + ); + startListeningToSource(table: dh.Table): void { log.debug('startListeningToSource', table); const { model } = this.state; @@ -1046,14 +1065,6 @@ export class ChartPanel extends Component { isLoaded, isLoading, } = this.state; - let name; - if (isChartPanelTableMetadata(metadata)) { - name = metadata.table; - } else if (isChartPanelFigureMetadata(metadata)) { - name = metadata.figure; - } else { - name = metadata.name; - } const inputFilterMap = this.getInputFilterColumnMap( columnMap, inputFilters @@ -1081,6 +1092,7 @@ export class ChartPanel extends Component { error != null ? `Unable to open chart. ${error}` : undefined; const isWaitingForFilter = waitingInputMap.size > 0; const isSelectingColumn = columnMap.size > 0 && isLinkerActive; + const descriptor = this.getWidgetPanelDescriptor(metadata); return ( { isDisconnected={isDisconnected} isLoading={isLoading} isLoaded={isLoaded} - widgetName={name ?? undefined} - widgetType="Chart" + descriptor={descriptor} >

{ + const name = getTableNameFromMetadata(metadata); + return { + type: 'Table', + displayType: 'Table', + ...metadata, + name, + description, + }; + } + ); + initModel(): void { this.setState({ isModelReady: false, isLoading: true, error: null }); const { makeModel } = this.props; @@ -712,7 +729,7 @@ export class IrisGridPanel extends PureComponent< this.setState( () => null, () => { - const { glEventHub, inputFilters } = this.props; + const { glEventHub, inputFilters, metadata } = this.props; const table = this.getTableName(); const { panelState } = this.state; const sourcePanelId = LayoutUtils.getIdFromPanel(this); @@ -726,6 +743,7 @@ export class IrisGridPanel extends PureComponent< } glEventHub.emit(IrisGridEvent.CREATE_CHART, { metadata: { + ...metadata, settings, sourcePanelId, table, @@ -1235,13 +1253,16 @@ export class IrisGridPanel extends PureComponent< } = this.state; const errorMessage = error != null ? `Unable to open table. ${error}` : undefined; - const name = getTableNameFromMetadata(metadata); const description = model?.description ?? undefined; const pluginState = panelState?.pluginState ?? null; const childrenContent = children ?? this.getPluginContent(Plugin, model, pluginState); const { permissions } = user; const { canCopy, canDownloadCsv } = permissions; + const widgetPanelDescriptor = this.getWidgetPanelDescriptor( + metadata, + description + ); return ( ( )} > diff --git a/packages/dashboard-core-plugins/src/panels/IrisGridPanelTooltip.tsx b/packages/dashboard-core-plugins/src/panels/IrisGridPanelTooltip.tsx index 88c3c862d..2c71e3a00 100644 --- a/packages/dashboard-core-plugins/src/panels/IrisGridPanelTooltip.tsx +++ b/packages/dashboard-core-plugins/src/panels/IrisGridPanelTooltip.tsx @@ -1,19 +1,14 @@ import React, { ReactElement } from 'react'; -import { GLPropTypes } from '@deephaven/dashboard'; -import type { ComponentConfig, Container } from '@deephaven/golden-layout'; import { IrisGridModel } from '@deephaven/iris-grid'; -import PropTypes from 'prop-types'; import WidgetPanelTooltip from './WidgetPanelTooltip'; +import { WidgetPanelTooltipProps } from './WidgetPanelTypes'; -interface IrisGridPanelTooltipProps { +type IrisGridPanelTooltipProps = WidgetPanelTooltipProps & { model?: IrisGridModel; - widgetName: string; - glContainer: Container; - description?: string; -} +}; function IrisGridPanelTooltip(props: IrisGridPanelTooltipProps): ReactElement { - const { model, widgetName, glContainer, description } = props; + const { model } = props; const rowCount = (model?.rowCount ?? 0) - @@ -26,12 +21,8 @@ function IrisGridPanelTooltip(props: IrisGridPanelTooltipProps): ReactElement { const formattedcolumnCount = model?.displayString(columnCount, 'long'); return ( - + // eslint-disable-next-line react/jsx-props-no-spreading +
Number of Columns @@ -43,14 +34,4 @@ function IrisGridPanelTooltip(props: IrisGridPanelTooltipProps): ReactElement { ); } -IrisGridPanelTooltip.propTypes = { - glContainer: GLPropTypes.Container.isRequired, - widgetName: PropTypes.string.isRequired, - description: PropTypes.string, -}; - -IrisGridPanelTooltip.defaultProps = { - description: null, -}; - export default IrisGridPanelTooltip; diff --git a/packages/dashboard-core-plugins/src/panels/Panel.tsx b/packages/dashboard-core-plugins/src/panels/Panel.tsx index 760a5276e..c30b4c02f 100644 --- a/packages/dashboard-core-plugins/src/panels/Panel.tsx +++ b/packages/dashboard-core-plugins/src/panels/Panel.tsx @@ -21,7 +21,7 @@ import type { ReactComponentConfig, Tab, } from '@deephaven/golden-layout'; -import { assertNotNull } from '@deephaven/utils'; +import { assertNotNull, EMPTY_ARRAY } from '@deephaven/utils'; import Log from '@deephaven/log'; import type { dh } from '@deephaven/jsapi-types'; import { ConsoleEvent, InputFilterEvent, TabEvent } from '../events'; @@ -41,30 +41,30 @@ interface PanelProps { children: ReactNode; glContainer: Container; glEventHub: EventEmitter; - className: string; - onFocus: FocusEventHandler; - onBlur: FocusEventHandler; - onTab: (tab: Tab) => void; - onTabClicked: (e: MouseEvent) => void; - onClearAllFilters: (...args: unknown[]) => void; - onHide: (...args: unknown[]) => void; - onResize: (...args: unknown[]) => void; - onSessionClose: (session: dh.IdeSession) => void; - onSessionOpen: ( + className?: string; + onFocus?: FocusEventHandler; + onBlur?: FocusEventHandler; + onTab?: (tab: Tab) => void; + onTabClicked?: (e: MouseEvent) => void; + onClearAllFilters?: (...args: unknown[]) => void; + onHide?: (...args: unknown[]) => void; + onResize?: (...args: unknown[]) => void; + onSessionClose?: (session: dh.IdeSession) => void; + onSessionOpen?: ( session: dh.IdeSession, { language, sessionId }: { language: string; sessionId: string } ) => void; - onBeforeShow: (...args: unknown[]) => void; - onShow: (...args: unknown[]) => void; - onTabBlur: (...args: unknown[]) => void; - onTabFocus: (...args: unknown[]) => void; - renderTabTooltip: () => ReactNode; - additionalActions: ContextAction[]; - errorMessage: string; - isLoading: boolean; - isLoaded: boolean; - isClonable: boolean; - isRenamable: boolean; + onBeforeShow?: (...args: unknown[]) => void; + onShow?: (...args: unknown[]) => void; + onTabBlur?: (...args: unknown[]) => void; + onTabFocus?: (...args: unknown[]) => void; + renderTabTooltip?: () => ReactNode; + additionalActions?: ContextAction[]; + errorMessage?: string; + isLoading?: boolean; + isLoaded?: boolean; + isClonable?: boolean; + isRenamable?: boolean; } interface PanelState { @@ -78,30 +78,6 @@ interface PanelState { * Focus, Resize, Show, Session open/close, client disconnect/reconnect. */ class Panel extends PureComponent { - static defaultProps = { - className: '', - onTab: (): void => undefined, - onTabClicked: (): void => undefined, - onClearAllFilters: (): void => undefined, - onFocus: (): void => undefined, - onBlur: (): void => undefined, - onHide: (): void => undefined, - onResize: (): void => undefined, - onSessionClose: (): void => undefined, - onSessionOpen: (): void => undefined, - onBeforeShow: (): void => undefined, - onShow: (): void => undefined, - onTabBlur: (): void => undefined, - onTabFocus: (): void => undefined, - renderTabTooltip: null, - additionalActions: [], - errorMessage: null, - isLoading: false, - isLoaded: true, - isClonable: false, - isRenamable: false, - }; - constructor(props: PanelProps) { super(props); @@ -193,17 +169,17 @@ class Panel extends PureComponent { this.forceUpdate(); const { onTab } = this.props; - onTab(tab); + onTab?.(tab); } handleTabClicked(e: MouseEvent): void { const { onTabClicked } = this.props; - onTabClicked(e); + onTabClicked?.(e); } handleClearAllFilters(...args: unknown[]): void { const { onClearAllFilters } = this.props; - onClearAllFilters(...args); + onClearAllFilters?.(...args); } handleFocus(event: FocusEvent): void { @@ -211,27 +187,27 @@ class Panel extends PureComponent { glEventHub.emit(PanelEvent.FOCUS, componentPanel ?? this); const { onFocus } = this.props; - onFocus(event); + onFocus?.(event); } handleBlur(event: FocusEvent): void { const { onBlur } = this.props; - onBlur(event); + onBlur?.(event); } handleHide(...args: unknown[]): void { const { onHide } = this.props; - onHide(...args); + onHide?.(...args); } handleResize(...args: unknown[]): void { const { onResize } = this.props; - onResize(...args); + onResize?.(...args); } handleSessionClosed(session: dh.IdeSession): void { const { onSessionClose } = this.props; - onSessionClose(session); + onSessionClose?.(session); } handleSessionOpened( @@ -239,27 +215,27 @@ class Panel extends PureComponent { params: { language: string; sessionId: string } ): void { const { onSessionOpen } = this.props; - onSessionOpen(session, params); + onSessionOpen?.(session, params); } handleBeforeShow(...args: unknown[]): void { const { onBeforeShow } = this.props; - onBeforeShow(...args); + onBeforeShow?.(...args); } handleShow(...args: unknown[]): void { const { onShow } = this.props; - onShow(...args); + onShow?.(...args); } handleTabBlur(...args: unknown[]): void { const { onTabBlur } = this.props; - onTabBlur(...args); + onTabBlur?.(...args); } handleTabFocus(...args: unknown[]): void { const { onTabFocus } = this.props; - onTabFocus(...args); + onTabFocus?.(...args); } handleRenameCancel(): void { @@ -314,8 +290,12 @@ class Panel extends PureComponent { }; } - getAdditionActions = memoize( - (actions: ContextAction[], isClonable: boolean, isRenamable: boolean) => { + getAdditionalActions = memoize( + ( + actions: readonly ContextAction[], + isClonable: boolean, + isRenamable: boolean + ) => { const additionalActions = []; if (isClonable) { additionalActions.push(this.getCloneAction()); @@ -334,13 +314,14 @@ class Panel extends PureComponent { renderTabTooltip, glContainer, glEventHub, - additionalActions, + additionalActions = EMPTY_ARRAY, errorMessage, - isLoaded, - isLoading, - isClonable, - isRenamable, + isLoaded = true, + isLoading = false, + isClonable = false, + isRenamable = false, } = this.props; + const { tab: glTab } = glContainer; const { showRenameDialog, title, isWithinPanel } = this.state; @@ -364,7 +345,7 @@ class Panel extends PureComponent { ReactNode; - description: string; - - onFocus: () => void; - onBlur: () => void; - onHide: () => void; - onClearAllFilters: () => void; - onResize: () => void; - onSessionClose: (...args: unknown[]) => void; - onSessionOpen: (...args: unknown[]) => void; - onShow: () => void; - onTabBlur: () => void; - onTabFocus: () => void; - onTabClicked: () => void; -} + className?: string; + errorMessage?: string; + isClonable?: boolean; + isDisconnected?: boolean; + isLoading?: boolean; + isLoaded?: boolean; + isRenamable?: boolean; + showTabTooltip?: boolean; + + renderTabTooltip?: () => ReactNode; + + onFocus?: () => void; + onBlur?: () => void; + onHide?: () => void; + onClearAllFilters?: () => void; + onResize?: () => void; + onSessionClose?: (...args: unknown[]) => void; + onSessionOpen?: (...args: unknown[]) => void; + onShow?: () => void; + onTabBlur?: () => void; + onTabFocus?: () => void; + onTabClicked?: () => void; +}; interface WidgetPanelState { isClientDisconnected: boolean; @@ -55,29 +56,12 @@ interface WidgetPanelState { class WidgetPanel extends PureComponent { static defaultProps = { className: '', - errorMessage: null, isClonable: true, isDisconnected: false, isLoading: false, isLoaded: true, isRenamable: true, showTabTooltip: true, - widgetName: 'Widget', - widgetType: 'Widget', - renderTabTooltip: null, - description: '', - - onFocus: (): void => undefined, - onBlur: (): void => undefined, - onHide: (): void => undefined, - onClearAllFilters: (): void => undefined, - onResize: (): void => undefined, - onSessionClose: (): void => undefined, - onSessionOpen: (): void => undefined, - onShow: (): void => undefined, - onTabBlur: (): void => undefined, - onTabFocus: (): void => undefined, - onTabClicked: (): void => undefined, }; constructor(props: WidgetPanelProps) { @@ -97,19 +81,19 @@ class WidgetPanel extends PureComponent { } handleCopyName(): void { - const { widgetName } = this.props; - copyToClipboard(widgetName); + const { descriptor } = this.props; + copyToClipboard(descriptor.name); } getErrorMessage(): string | undefined { - const { errorMessage } = this.props; + const { descriptor, errorMessage } = this.props; const { isClientDisconnected, isPanelDisconnected, isWidgetDisconnected, isWaitingForReconnect, } = this.state; - if (errorMessage) { + if (errorMessage != null && errorMessage !== '') { return `${errorMessage}`; } if (isClientDisconnected && isPanelDisconnected && isWaitingForReconnect) { @@ -119,36 +103,31 @@ class WidgetPanel extends PureComponent { return 'Disconnected from server.'; } if (isPanelDisconnected) { - const { widgetName, widgetType } = this.props; - return `Variable "${widgetName}" not set.\n${widgetType} does not exist yet.`; + const { name, type } = descriptor; + return `Variable "${name}" not set.\n${type} does not exist yet.`; } if (isWidgetDisconnected) { - const { widgetName } = this.props; - return `${widgetName} is unavailable.`; + return `${descriptor.name} is unavailable.`; } return undefined; } getCachedRenderTabTooltip = memoize( - ( - showTabTooltip: boolean, - glContainer: Container, - widgetType: string, - widgetName: string, - description: string - ) => + (showTabTooltip: boolean, descriptor: WidgetPanelDescriptor) => showTabTooltip - ? () => ( - - ) - : null + ? () => + : undefined ); + getCachedActions = memoize((descriptor: WidgetPanelDescriptor) => [ + { + title: `Copy ${descriptor.displayType ?? descriptor.type} Name`, + group: ContextActions.groups.medium, + order: 20, + action: this.handleCopyName, + }, + ]); + handleSessionClosed(...args: unknown[]): void { const { onSessionClose } = this.props; // The session has closed and we won't be able to reconnect, as this widget isn't persisted @@ -156,12 +135,12 @@ class WidgetPanel extends PureComponent { isPanelDisconnected: true, isWaitingForReconnect: false, }); - onSessionClose(...args); + onSessionClose?.(...args); } handleSessionOpened(...args: unknown[]): void { const { onSessionOpen } = this.props; - onSessionOpen(...args); + onSessionOpen?.(...args); } render(): ReactElement { @@ -169,6 +148,7 @@ class WidgetPanel extends PureComponent { children, className, componentPanel, + descriptor, isLoaded, isLoading, glContainer, @@ -176,11 +156,8 @@ class WidgetPanel extends PureComponent { isDisconnected, isClonable, isRenamable, - showTabTooltip, + showTabTooltip = false, renderTabTooltip, - widgetType, - widgetName, - description, onClearAllFilters, onHide, @@ -198,22 +175,9 @@ class WidgetPanel extends PureComponent { const errorMessage = this.getErrorMessage(); const doRenderTabTooltip = renderTabTooltip ?? - this.getCachedRenderTabTooltip( - showTabTooltip, - glContainer, - widgetType, - widgetName, - description - ); - - const additionalActions = [ - { - title: `Copy ${widgetType} Name`, - group: ContextActions.groups.medium, - order: 20, - action: this.handleCopyName, - }, - ]; + this.getCachedRenderTabTooltip(showTabTooltip, descriptor); + + const additionalActions = this.getCachedActions(descriptor); return ( { } } -export default WidgetPanel; +const XWidgetPanel = createXComponent(WidgetPanel); + +export default XWidgetPanel; diff --git a/packages/dashboard-core-plugins/src/panels/WidgetPanelTooltip.tsx b/packages/dashboard-core-plugins/src/panels/WidgetPanelTooltip.tsx index 832e6f400..1c9896ea9 100644 --- a/packages/dashboard-core-plugins/src/panels/WidgetPanelTooltip.tsx +++ b/packages/dashboard-core-plugins/src/panels/WidgetPanelTooltip.tsx @@ -1,39 +1,30 @@ -import React, { ReactNode, ReactElement } from 'react'; -import PropTypes from 'prop-types'; -import { CopyButton } from '@deephaven/components'; -import { GLPropTypes, LayoutUtils } from '@deephaven/dashboard'; +import React, { ReactElement } from 'react'; +import { CopyButton, createXComponent } from '@deephaven/components'; import './WidgetPanelTooltip.scss'; -import type { Container } from '@deephaven/golden-layout'; +import { WidgetPanelTooltipProps } from './WidgetPanelTypes'; -interface WidgetPanelTooltipProps { - glContainer: Container; - widgetType: string; - widgetName: string; - description: string; - children: ReactNode; -} function WidgetPanelTooltip(props: WidgetPanelTooltipProps): ReactElement { - const { widgetType, widgetName, glContainer, description, children } = props; - const panelTitle = LayoutUtils.getTitleFromContainer(glContainer); + const { children, descriptor } = props; + const { name, type, description, displayName } = descriptor; return (
- {widgetType} Name + {type} Name
- {widgetName} + {name}
- {widgetName !== panelTitle && ( + {name !== displayName && Boolean(displayName) && ( <> Display Name - {panelTitle} + {displayName} )} - {description && ( + {Boolean(description) && (
{description}
)} {children} @@ -41,19 +32,6 @@ function WidgetPanelTooltip(props: WidgetPanelTooltipProps): ReactElement { ); } -WidgetPanelTooltip.propTypes = { - glContainer: GLPropTypes.Container.isRequired, - widgetType: PropTypes.string, - widgetName: PropTypes.string, - description: PropTypes.string, - children: PropTypes.node, -}; - -WidgetPanelTooltip.defaultProps = { - widgetType: '', - widgetName: '', - description: null, - children: null, -}; +const XWidgetPanelTooltip = createXComponent(WidgetPanelTooltip); -export default WidgetPanelTooltip; +export default XWidgetPanelTooltip; diff --git a/packages/dashboard-core-plugins/src/panels/WidgetPanelTypes.ts b/packages/dashboard-core-plugins/src/panels/WidgetPanelTypes.ts new file mode 100644 index 000000000..c370d7beb --- /dev/null +++ b/packages/dashboard-core-plugins/src/panels/WidgetPanelTypes.ts @@ -0,0 +1,26 @@ +import { ReactNode } from 'react'; + +export type WidgetPanelDescriptor = { + /** Type of the widget. */ + type: string; + + /** Name of the widget. */ + name: string; + + /** Display name of the widget. May be different than the assigned name. */ + displayName?: string; + + /** Display type of the widget. May be different than the assigned type. */ + displayType?: string; + + /** Description of the widget. */ + description?: string; +}; + +export type WidgetPanelTooltipProps = { + /** A descriptor of the widget. */ + descriptor: WidgetPanelDescriptor; + + /** Children to render within this tooltip */ + children?: ReactNode; +}; diff --git a/packages/dashboard-core-plugins/src/panels/index.ts b/packages/dashboard-core-plugins/src/panels/index.ts index d0dcedaa3..294e3a4c2 100644 --- a/packages/dashboard-core-plugins/src/panels/index.ts +++ b/packages/dashboard-core-plugins/src/panels/index.ts @@ -18,6 +18,7 @@ export { default as NotebookPanel } from './NotebookPanel'; export { default as PandasPanel } from './PandasPanel'; export * from './PandasPanel'; export { default as Panel } from './Panel'; +export * from './WidgetPanelTypes'; export { default as WidgetPanel } from './WidgetPanel'; export { default as WidgetPanelTooltip } from './WidgetPanelTooltip'; export { default as MockFileStorage } from './MockFileStorage'; diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json index 76012ca91..97be252f0 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -37,7 +37,6 @@ "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0", - "react-is": ">=16.8.0", "react-redux": "^7.2.4" }, "devDependencies": { diff --git a/packages/dashboard/src/DashboardPlugin.ts b/packages/dashboard/src/DashboardPlugin.ts index e4d64c84f..592c27928 100644 --- a/packages/dashboard/src/DashboardPlugin.ts +++ b/packages/dashboard/src/DashboardPlugin.ts @@ -14,6 +14,8 @@ import type { import PanelManager from './PanelManager'; import { WidgetDescriptor } from './PanelEvent'; +export { isWrappedComponent } from '@deephaven/components'; + /** * Panel components can provide static props that provide meta data about the * panel. @@ -52,14 +54,7 @@ export type PanelComponentType< C extends ComponentType

= ComponentType

, > = (ComponentType

| WrappedComponentType) & PanelStaticMetaData; -export function isWrappedComponent< - P extends PanelProps, - C extends ComponentType

, ->(type: PanelComponentType): type is WrappedComponentType { - return (type as WrappedComponentType)?.WrappedComponent !== undefined; -} - -export type PanelMetadata = Partial; +export type PanelMetadata = WidgetDescriptor; export type PanelProps = GLPanelProps & { metadata?: PanelMetadata; diff --git a/packages/dashboard/src/DashboardUtils.tsx b/packages/dashboard/src/DashboardUtils.tsx index 917d15996..09d48607d 100644 --- a/packages/dashboard/src/DashboardUtils.tsx +++ b/packages/dashboard/src/DashboardUtils.tsx @@ -1,12 +1,11 @@ -import { ForwardRef } from 'react-is'; import { DehydratedDashboardPanelProps, DehydratedPanelConfig, - isWrappedComponent, - PanelComponentType, PanelConfig, } from './DashboardPlugin'; +export { canHaveRef } from '@deephaven/components'; + /** * Dehydrate an existing panel to allow it to be serialized/saved. * Just takes what's in the panels `metadata` in the props and `panelState` in @@ -54,29 +53,6 @@ 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/dashboard/src/PanelEvent.ts b/packages/dashboard/src/PanelEvent.ts index ad0ff3e21..c15eb98f5 100644 --- a/packages/dashboard/src/PanelEvent.ts +++ b/packages/dashboard/src/PanelEvent.ts @@ -1,4 +1,4 @@ -import { DragEvent } from 'react'; +import { makeEventFunctions } from '@deephaven/golden-layout'; export type WidgetDescriptor = { type: string; @@ -7,16 +7,29 @@ export type WidgetDescriptor = { }; export type PanelOpenEventDetail = { - dragEvent?: DragEvent; - fetch?: () => Promise; + /** + * Opening the widget was triggered by dragging from a list, such as the Panels dropdown. + * The coordinates are used as the starting location for the drag, where we will show the panel until the user drops it in the dashboard. + */ + dragEvent?: MouseEvent; + + /** ID of the panel to re-use. Will replace any existing panel with this ID. Otherwise a new panel is opened with a randomly generated ID. */ panelId?: string; + + /** Descriptor of the widget. */ widget: WidgetDescriptor; + + /** + * Function to fetch the instance of the widget + * @deprecated Use `useWidget` hook with the `widget` descriptor instead + */ + fetch?: () => Promise; }; /** * Events emitted by panels and to control panels */ -export default Object.freeze({ +export const PanelEvent = Object.freeze({ // Panel has received focus FOCUS: 'PanelEvent.FOCUS', @@ -58,3 +71,13 @@ export default Object.freeze({ // Panel is dropped DROPPED: 'PanelEvent.DROPPED', }); + +export const { + listen: listenForPanelOpen, + emit: emitPanelOpen, + useListener: usePanelOpenListener, +} = makeEventFunctions(PanelEvent.OPEN); + +// TODO (#2147): Add the rest of the event functions here. Need to create the correct types for all of them. + +export default PanelEvent; diff --git a/packages/dashboard/src/index.ts b/packages/dashboard/src/index.ts index 026255f5f..41c3fd3fd 100644 --- a/packages/dashboard/src/index.ts +++ b/packages/dashboard/src/index.ts @@ -14,6 +14,5 @@ export * from './layout'; export * from './redux'; export * from './PanelManager'; export * from './PanelEvent'; -export { default as PanelEvent } from './PanelEvent'; export { default as PanelErrorBoundary } from './PanelErrorBoundary'; export { default as PanelManager } from './PanelManager'; diff --git a/packages/dashboard/src/layout/LayoutUtils.ts b/packages/dashboard/src/layout/LayoutUtils.ts index 30c93a4a1..9cecf91d5 100644 --- a/packages/dashboard/src/layout/LayoutUtils.ts +++ b/packages/dashboard/src/layout/LayoutUtils.ts @@ -1,4 +1,3 @@ -import { DragEvent } from 'react'; import deepEqual from 'fast-deep-equal'; import { nanoid } from 'nanoid'; import isMatch from 'lodash.ismatch'; @@ -524,7 +523,7 @@ class LayoutUtils { replaceConfig?: Partial; createNewStack?: boolean; focusElement?: string; - dragEvent?: DragEvent; + dragEvent?: MouseEvent; } = {}): void { // attempt to retain focus after dom manipulation, which can break focus const maintainFocusElement = document.activeElement; diff --git a/packages/dashboard/src/layout/useDashboardPanel.ts b/packages/dashboard/src/layout/useDashboardPanel.ts index 54ab26ca0..4b01168ac 100644 --- a/packages/dashboard/src/layout/useDashboardPanel.ts +++ b/packages/dashboard/src/layout/useDashboardPanel.ts @@ -9,9 +9,8 @@ import { PanelDehydrateFunction, PanelHydrateFunction, } from '../DashboardPlugin'; -import PanelEvent, { PanelOpenEventDetail } from '../PanelEvent'; +import { PanelOpenEventDetail, usePanelOpenListener } from '../PanelEvent'; import LayoutUtils from './LayoutUtils'; -import useListener from './useListener'; import usePanelRegistration from './usePanelRegistration'; /** @@ -88,7 +87,7 @@ export function useDashboardPanel< /** * Listen for panel open events so we know when to open a panel */ - useListener(layout.eventHub, PanelEvent.OPEN, handlePanelOpen); + usePanelOpenListener(layout.eventHub, handlePanelOpen); } export default useDashboardPanel; diff --git a/packages/embed-widget/src/App.tsx b/packages/embed-widget/src/App.tsx index 7c2367c58..2b3e395d5 100644 --- a/packages/embed-widget/src/App.tsx +++ b/packages/embed-widget/src/App.tsx @@ -27,12 +27,12 @@ import { import Log from '@deephaven/log'; import { useDashboardPlugins } from '@deephaven/plugin'; import { - PanelEvent, getAllDashboardsData, listenForCreateDashboard, CreateDashboardPayload, setDashboardPluginData, stopListenForCreateDashboard, + emitPanelOpen, } from '@deephaven/dashboard'; import { getVariableDescriptor, @@ -190,7 +190,7 @@ function App(): JSX.Element { } setHasEmittedWidget(true); - goldenLayout.eventHub.emit(PanelEvent.OPEN, { + emitPanelOpen(goldenLayout.eventHub, { fetch, widget: getVariableDescriptor(definition), }); diff --git a/packages/golden-layout/src/utils/EventUtils.test.ts b/packages/golden-layout/src/utils/EventUtils.test.ts new file mode 100644 index 000000000..956a87d68 --- /dev/null +++ b/packages/golden-layout/src/utils/EventUtils.test.ts @@ -0,0 +1,138 @@ +import { renderHook } from '@testing-library/react-hooks'; +import EventEmitter from './EventEmitter'; +import { + listenForEvent, + makeListenFunction, + makeEmitFunction, + makeEventFunctions, + makeUseListenerFunction, +} from './EventUtils'; + +function makeEventEmitter(): EventEmitter { + return { + on: jest.fn(), + off: jest.fn(), + emit: jest.fn(), + } as unknown as EventEmitter; +} + +describe('EventUtils', () => { + const eventEmitter = makeEventEmitter(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('listenForEvent', () => { + const event = 'test'; + const handler = jest.fn(); + const remove = listenForEvent(eventEmitter, event, handler); + expect(eventEmitter.on).toHaveBeenCalledWith(event, handler); + expect(eventEmitter.off).not.toHaveBeenCalled(); + jest.clearAllMocks(); + remove(); + expect(eventEmitter.on).not.toHaveBeenCalled(); + expect(eventEmitter.off).toHaveBeenCalledWith(event, handler); + }); + + it('makeListenFunction', () => { + const event = 'test'; + const listen = makeListenFunction(event); + const handler = jest.fn(); + listen(eventEmitter, handler); + expect(eventEmitter.on).toHaveBeenCalledWith(event, handler); + }); + + it('makeEmitFunction', () => { + const event = 'test'; + const emit = makeEmitFunction(event); + const payload = { test: 'test' }; + emit(eventEmitter, payload); + expect(eventEmitter.emit).toHaveBeenCalledWith(event, payload); + }); + + describe('makeUseListenerFunction', () => { + it('adds listener on mount, removes on unmount', () => { + const event = 'test'; + const useListener = makeUseListenerFunction(event); + const handler = jest.fn(); + const { unmount } = renderHook(() => useListener(eventEmitter, handler)); + expect(eventEmitter.on).toHaveBeenCalledWith(event, handler); + expect(eventEmitter.off).not.toHaveBeenCalled(); + jest.clearAllMocks(); + unmount(); + expect(eventEmitter.on).not.toHaveBeenCalledWith(event, handler); + expect(eventEmitter.off).toHaveBeenCalledWith(event, handler); + }); + + it('adds listener on handler change, removes old listener', () => { + const event = 'test'; + const useListener = makeUseListenerFunction(event); + const handler1 = jest.fn(); + const handler2 = jest.fn(); + const { rerender } = renderHook( + ({ handler }) => useListener(eventEmitter, handler), + { initialProps: { handler: handler1 } } + ); + expect(eventEmitter.on).toHaveBeenCalledWith(event, handler1); + expect(eventEmitter.off).not.toHaveBeenCalled(); + jest.clearAllMocks(); + rerender({ handler: handler2 }); + expect(eventEmitter.on).toHaveBeenCalledWith(event, handler2); + expect(eventEmitter.off).toHaveBeenCalledWith(event, handler1); + }); + + it('re-adds the listener on emitter change', () => { + const event = 'test'; + const useListener = makeUseListenerFunction(event); + const handler = jest.fn(); + const eventEmitter2 = makeEventEmitter(); + const { rerender, unmount } = renderHook( + ({ eventEmitter, handler }) => useListener(eventEmitter, handler), + { initialProps: { eventEmitter, handler } } + ); + expect(eventEmitter.on).toHaveBeenCalledWith(event, handler); + expect(eventEmitter.off).not.toHaveBeenCalled(); + jest.clearAllMocks(); + rerender({ eventEmitter: eventEmitter2, handler }); + expect(eventEmitter.on).not.toHaveBeenCalled(); + expect(eventEmitter.off).toHaveBeenCalledWith(event, handler); + expect(eventEmitter2.on).toHaveBeenCalledWith(event, handler); + + jest.clearAllMocks(); + unmount(); + expect(eventEmitter.on).not.toHaveBeenCalled(); + expect(eventEmitter.off).not.toHaveBeenCalled(); + expect(eventEmitter2.on).not.toHaveBeenCalled(); + expect(eventEmitter2.off).toHaveBeenCalledWith(event, handler); + }); + }); + + describe('makeEventFunctions', () => { + const event = 'test'; + const { listen, emit, useListener } = makeEventFunctions(event); + const handler = jest.fn(); + + it('listen', () => { + listen(eventEmitter, handler); + expect(eventEmitter.on).toHaveBeenCalledWith(event, handler); + expect(eventEmitter.off).not.toHaveBeenCalled(); + }); + + it('emit', () => { + const payload = { test: 'test' }; + emit(eventEmitter, payload); + expect(eventEmitter.emit).toHaveBeenCalledWith(event, payload); + }); + + it('useListener', () => { + const { unmount } = renderHook(() => useListener(eventEmitter, handler)); + expect(eventEmitter.on).toHaveBeenCalledWith(event, handler); + expect(eventEmitter.off).not.toHaveBeenCalled(); + jest.clearAllMocks(); + unmount(); + expect(eventEmitter.on).not.toHaveBeenCalledWith(event, handler); + expect(eventEmitter.off).toHaveBeenCalledWith(event, handler); + }); + }); +}); diff --git a/packages/golden-layout/src/utils/EventUtils.ts b/packages/golden-layout/src/utils/EventUtils.ts new file mode 100644 index 000000000..9ae97437b --- /dev/null +++ b/packages/golden-layout/src/utils/EventUtils.ts @@ -0,0 +1,79 @@ +import EventEmitter from './EventEmitter'; +import { useEffect } from 'react'; + +export type EventListenerRemover = () => void; +export type EventListenFunction = ( + eventEmitter: EventEmitter, + handler: (p: TPayload) => void +) => EventListenerRemover; + +export type EventEmitFunction = ( + eventEmitter: EventEmitter, + payload: TPayload +) => void; + +export type EventListenerHook = ( + eventEmitter: EventEmitter, + handler: (p: TPayload) => void +) => void; + +/** + * Listen for an event + * @param eventEmitter The event emitter to listen to + * @param event The event to listen for + * @param handler The handler to call when the event is emitted + * @returns A function to stop listening for the event + */ +export function listenForEvent( + eventEmitter: EventEmitter, + event: string, + handler: (p: TPayload) => void +): EventListenerRemover { + eventEmitter.on(event, handler); + return () => { + eventEmitter.off(event, handler); + }; +} + +export function makeListenFunction( + event: string +): EventListenFunction { + return (eventEmitter, handler) => + listenForEvent(eventEmitter, event, handler); +} + +export function makeEmitFunction( + event: string +): EventEmitFunction { + return (eventEmitter, payload) => { + eventEmitter.emit(event, payload); + }; +} + +export function makeUseListenerFunction( + event: string +): EventListenerHook { + return (eventEmitter, handler) => { + useEffect( + () => listenForEvent(eventEmitter, event, handler), + [eventEmitter, handler] + ); + }; +} + +/** + * Create listener, emitter, and hook functions for an event + * @param event Name of the event to create functions for + * @returns Listener, Emitter, and Hook functions for the event + */ +export function makeEventFunctions(event: string): { + listen: EventListenFunction; + emit: EventEmitFunction; + useListener: EventListenerHook; +} { + return { + listen: makeListenFunction(event), + emit: makeEmitFunction(event), + useListener: makeUseListenerFunction(event), + }; +} diff --git a/packages/golden-layout/src/utils/index.ts b/packages/golden-layout/src/utils/index.ts index 5c2954666..308e8c608 100644 --- a/packages/golden-layout/src/utils/index.ts +++ b/packages/golden-layout/src/utils/index.ts @@ -6,3 +6,4 @@ export { default as ReactComponentHandler } from './ReactComponentHandler'; export * from './ConfigMinifier'; export { default as BubblingEvent } from './BubblingEvent'; export { default as EventHub } from './EventHub'; +export * from './EventUtils'; diff --git a/tests/styleguide.spec.ts b/tests/styleguide.spec.ts index e688354ed..a70b6921e 100644 --- a/tests/styleguide.spec.ts +++ b/tests/styleguide.spec.ts @@ -38,13 +38,14 @@ const sampleSectionIds: string[] = [ 'sample-section-grids-tree', 'sample-section-grids-iris', 'sample-section-charts', + 'sample-section-error-views', + 'sample-section-xcomponents', 'sample-section-spectrum-buttons', 'sample-section-spectrum-collections', 'sample-section-spectrum-content', 'sample-section-spectrum-forms', 'sample-section-spectrum-overlays', 'sample-section-spectrum-well', - 'sample-section-error-views', ]; const buttonSectionIds: string[] = [ 'sample-section-buttons-regular', diff --git a/tests/styleguide.spec.ts-snapshots/xcomponents-chromium-linux.png b/tests/styleguide.spec.ts-snapshots/xcomponents-chromium-linux.png new file mode 100644 index 000000000..4e87ad75b Binary files /dev/null and b/tests/styleguide.spec.ts-snapshots/xcomponents-chromium-linux.png differ diff --git a/tests/styleguide.spec.ts-snapshots/xcomponents-firefox-linux.png b/tests/styleguide.spec.ts-snapshots/xcomponents-firefox-linux.png new file mode 100644 index 000000000..9893cff18 Binary files /dev/null and b/tests/styleguide.spec.ts-snapshots/xcomponents-firefox-linux.png differ diff --git a/tests/styleguide.spec.ts-snapshots/xcomponents-webkit-linux.png b/tests/styleguide.spec.ts-snapshots/xcomponents-webkit-linux.png new file mode 100644 index 000000000..7feebf253 Binary files /dev/null and b/tests/styleguide.spec.ts-snapshots/xcomponents-webkit-linux.png differ