();
+ 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 0000000000..0f1c9c0cb6
--- /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 0000000000..2b78a20819
--- /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 2b0ff450a6..69c8292f62 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 dcd40c4fc4..92d9e8751d 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 8fcfd8d86f..d4e951287a 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 b8ede97897..1d0e4e00af 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)[]) => [
{DropdownFilter.PLACEHOLDER}
,
@@ -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 e6ebff54d1..e100ec5ef8 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 88c3c862dd..2c71e3a00d 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 760a5276e6..c30b4c02f2 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 832e6f4009..1c9896ea93 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 0000000000..c370d7beb3
--- /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 d0dcedaa34..294e3a4c26 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 76012ca914..97be252f0a 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 e4d64c84f2..592c27928b 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 917d159963..09d48607d8 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 ad0ff3e21b..c15eb98f50 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 026255f5fc..41c3fd3fd0 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 30c93a4a1c..9cecf91d58 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 54ab26ca09..4b01168ac9 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 7c2367c581..2b3e395d5d 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 0000000000..956a87d685
--- /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 0000000000..9ae97437b0
--- /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 5c29546667..308e8c6089 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 e688354ed5..a70b6921e6 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 0000000000..4e87ad75b3
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 0000000000..9893cff185
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 0000000000..7feebf253c
Binary files /dev/null and b/tests/styleguide.spec.ts-snapshots/xcomponents-webkit-linux.png differ