diff --git a/packages/dashboard-core-plugins/src/WidgetLoaderPlugin.test.tsx b/packages/dashboard-core-plugins/src/WidgetLoaderPlugin.test.tsx index 125ca346f6..a17486f906 100644 --- a/packages/dashboard-core-plugins/src/WidgetLoaderPlugin.test.tsx +++ b/packages/dashboard-core-plugins/src/WidgetLoaderPlugin.test.tsx @@ -31,9 +31,11 @@ function TestWidgetTwo() { return
TestWidgetTwo
; } -function TestPanel() { +const TestPanel = React.forwardRef((props, ref) => { + React.useImperativeHandle(ref, () => ({})); return
TestPanel
; -} +}); +TestPanel.displayName = 'TestPanel'; class TestForwardRef extends React.PureComponent { render() { @@ -460,18 +462,17 @@ describe('middleware plugin chaining', () => { }); it('chains panel middleware around base panelComponent', async () => { - function TestPanelMiddleware({ - Component, - ...props - }: WidgetMiddlewarePanelProps) { - return ( -
- PanelMiddleware - {/* eslint-disable-next-line react/jsx-props-no-spreading */} - -
- ); - } + const TestPanelMiddleware = React.forwardRef< + unknown, + WidgetMiddlewarePanelProps + >(({ Component, ...props }, ref) => ( +
+ PanelMiddleware + {/* eslint-disable-next-line react/jsx-props-no-spreading */} + +
+ )); + TestPanelMiddleware.displayName = 'TestPanelMiddleware'; const panelMiddleware: WidgetMiddlewarePlugin = { name: 'panel-middleware', diff --git a/packages/plugin/src/PluginTypes.ts b/packages/plugin/src/PluginTypes.ts index 5f31e43556..f952df9248 100644 --- a/packages/plugin/src/PluginTypes.ts +++ b/packages/plugin/src/PluginTypes.ts @@ -170,8 +170,17 @@ export interface WidgetMiddlewarePanelProps /** * The next panel component in the middleware chain. * Middleware should render this component to continue the chain. + * + * This is ref-capable: middleware that transparently wraps a single inner + * panel should forward its own `ref` to this component. Golden-layout binds + * a ref to the registered panel to persist class-component state into its + * `componentState`; if a middleware swallows the ref, the wrapped panel's + * state (sorts, filters, column moves, etc.) is never serialized and is lost + * on reload. */ - Component: React.ComponentType>; + Component: React.ForwardRefExoticComponent< + WidgetPanelProps & React.RefAttributes + >; } /** @@ -207,8 +216,17 @@ export interface WidgetMiddlewarePlugin extends Plugin { /** * The middleware panel component that wraps the base panel component. * If omitted, only the component middleware will be applied. + * + * Must be a `React.forwardRef` component: middleware that transparently + * wraps a single inner panel has to forward its own `ref` to the wrapped + * `Component`. Golden-layout binds a ref to the registered panel to persist + * class-component state into its `componentState`; if a middleware swallows + * the ref, the wrapped panel's state (sorts, filters, column moves, etc.) is + * never serialized and is lost on reload. */ - panelComponent?: React.ComponentType>; + panelComponent?: React.ForwardRefExoticComponent< + WidgetMiddlewarePanelProps & React.RefAttributes + >; } /** diff --git a/packages/plugin/src/PluginUtils.test.tsx b/packages/plugin/src/PluginUtils.test.tsx index 3bfc79b345..71be0b1eda 100644 --- a/packages/plugin/src/PluginUtils.test.tsx +++ b/packages/plugin/src/PluginUtils.test.tsx @@ -32,6 +32,8 @@ import { registerPlugin, createChainedComponent, createChainedPanelComponent, + createPanelMiddleware, + createWidgetMiddleware, getWidgetDashboardPlugin, } from './PluginUtils'; @@ -814,15 +816,16 @@ describe('createChainedPanelComponent', () => { ); } - function PanelComp({ Component, ...props }: WidgetMiddlewarePanelProps) { - return ( + const PanelComp = React.forwardRef( + ({ Component, ...props }, ref) => (
{name} {/* eslint-disable-next-line react/jsx-props-no-spreading */} - +
- ); - } + ) + ); + PanelComp.displayName = `${name}PanelComp`; return { name, @@ -923,4 +926,173 @@ describe('createChainedPanelComponent', () => { expect(screen.getByTestId('table-panel-mw-panel')).toBeInTheDocument(); expect(screen.getByTestId('base-panel')).toBeInTheDocument(); }); + + it('forwards the golden-layout ref through middleware to the base panel', () => { + // A ref-capable base panel, mirroring a class panel golden-layout binds a + // ref to for componentState persistence. + const RefBasePanel = React.forwardRef<{ marker: string }, WidgetPanelProps>( + (props, ref) => { + React.useImperativeHandle(ref, () => ({ + marker: 'base-panel-instance', + })); + return
RefBasePanel
; + } + ); + RefBasePanel.displayName = 'RefBasePanel'; + + const mw = makePanelMiddleware('ref-mw'); + const Chained = createChainedPanelComponent(RefBasePanel, [ + mw, + ]) as React.ForwardRefExoticComponent< + WidgetPanelProps & React.RefAttributes + >; + + const panelProps = { + fetch: jest.fn(), + metadata: { type: 'test-type' }, + localDashboardId: 'test', + glContainer: {} as WidgetPanelProps['glContainer'], + glEventHub: {} as WidgetPanelProps['glEventHub'], + }; + + const ref = React.createRef<{ marker: string }>(); + render( + // eslint-disable-next-line react/jsx-props-no-spreading + + ); + + // The ref must reach the base panel through the middleware layer, otherwise + // golden-layout cannot persist the wrapped panel's state. + expect(screen.getByTestId('ref-mw-panel')).toBeInTheDocument(); + expect(ref.current).toEqual({ marker: 'base-panel-instance' }); + }); +}); + +describe('createPanelMiddleware', () => { + it('forwards the ref to the wrapped Component', () => { + const RefBasePanelBase = React.forwardRef< + { marker: string }, + WidgetPanelProps + >((props, ref) => { + React.useImperativeHandle(ref, () => ({ marker: 'base-instance' })); + return
RefBasePanel
; + }); + RefBasePanelBase.displayName = 'RefBasePanel'; + const RefBasePanel = RefBasePanelBase as React.ForwardRefExoticComponent< + WidgetPanelProps & React.RefAttributes + >; + + const Middleware = createPanelMiddleware(() => ({}), 'TestPanelMiddleware'); + + const ref = React.createRef<{ marker: string }>(); + render( + + ); + + expect(screen.getByTestId('base-panel')).toBeInTheDocument(); + expect(ref.current).toEqual({ marker: 'base-instance' }); + }); + + it('injects props and wraps the wrapped Component', () => { + let received: Record | undefined; + const BasePanelBase = React.forwardRef( + (props, ref) => { + received = props as unknown as Record; + return
BasePanel
; + } + ); + BasePanelBase.displayName = 'BasePanel'; + const BasePanel = BasePanelBase as React.ForwardRefExoticComponent< + WidgetPanelProps & React.RefAttributes + >; + + const marker = jest.fn(); + const Middleware = createPanelMiddleware(() => ({ + inject: { extraProp: marker }, + wrap: child =>
{child}
, + })); + + render( + + ); + + expect(screen.getByTestId('wrapper')).toBeInTheDocument(); + expect(screen.getByTestId('base-panel')).toBeInTheDocument(); + expect(received?.extraProp).toBe(marker); + }); + + it('passes the incoming props (minus Component) to the body hook', () => { + const useBody = jest.fn(() => ({})); + const BasePanelBase = React.forwardRef(() => ( +
BasePanel
+ )); + BasePanelBase.displayName = 'BasePanel'; + const BasePanel = BasePanelBase as React.ForwardRefExoticComponent< + WidgetPanelProps & React.RefAttributes + >; + const Middleware = createPanelMiddleware(useBody); + + render( + + ); + + expect(useBody).toHaveBeenCalledTimes(1); + const bodyProps = useBody.mock.calls[0][0] as Record; + expect(bodyProps).not.toHaveProperty('Component'); + expect(bodyProps).toMatchObject({ + localDashboardId: 'test', + metadata: { type: 'test-type' }, + }); + }); +}); + +describe('createWidgetMiddleware', () => { + it('injects props and wraps the wrapped Component', () => { + let received: Record | undefined; + function BaseWidget(props: WidgetComponentProps): JSX.Element { + received = props as unknown as Record; + return
BaseWidget
; + } + BaseWidget.displayName = 'BaseWidget'; + + const marker = jest.fn(); + const Middleware = createWidgetMiddleware(() => ({ + inject: { extraProp: marker }, + wrap: child =>
{child}
, + })); + + render( + + ); + + expect(screen.getByTestId('wrapper')).toBeInTheDocument(); + expect(screen.getByTestId('base-widget')).toBeInTheDocument(); + expect(received?.extraProp).toBe(marker); + }); }); diff --git a/packages/plugin/src/PluginUtils.tsx b/packages/plugin/src/PluginUtils.tsx index 73289b1513..cb98b1a375 100644 --- a/packages/plugin/src/PluginUtils.tsx +++ b/packages/plugin/src/PluginUtils.tsx @@ -13,9 +13,10 @@ import { type ElementPlugin, type ElementMap, type WidgetMiddlewarePlugin, + type WidgetMiddlewareComponentProps, + type WidgetMiddlewarePanelProps, type WidgetComponentProps, type WidgetPanelProps, - type WidgetMiddlewarePanelProps, isLegacyPlugin, isMultiPlugin, isPlugin, @@ -200,14 +201,24 @@ export function createChainedComponent( /** * Creates a panel component that chains middleware around a base panel component. * Each middleware panel wraps the next, with the base panel at the innermost layer. + * + * The chain forwards the `ref` injected by golden-layout (on the registered + * panel) all the way down to `basePanelComponent`. Golden-layout uses that ref + * to bind a class panel's React state into its serialized `componentState`; + * forwarding it keeps that persistence working through transparent middleware, + * so wrapped panels still restore sorts/filters/column moves on reload. */ export function createChainedPanelComponent( basePanelComponent: React.ComponentType>, middleware: WidgetMiddlewarePlugin[] ): React.ComponentType> { + type RefCapablePanel = React.ForwardRefExoticComponent< + WidgetPanelProps & React.RefAttributes + >; + // Filter to middleware that has a panelComponent and extract just the panel components type MiddlewareWithPanel = WidgetMiddlewarePlugin & { - panelComponent: React.ComponentType>; + panelComponent: NonNullable['panelComponent']>; }; const panelMiddleware = middleware.filter( (m): m is MiddlewareWithPanel => m.panelComponent != null @@ -230,20 +241,28 @@ export function createChainedPanelComponent( // Build the chain from inside out (base panel is innermost) return [...panelMiddleware] .reverse() - .reduce>>( - (WrappedPanel, middlewarePlugin) => { - const { panelComponent: MiddlewarePanelComponent } = middlewarePlugin; - const supported = [middlewarePlugin.supportedTypes].flat(); - - function ChainedPanel({ metadata, ...rest }: WidgetPanelProps) { + .reduce((WrappedPanel, middlewarePlugin) => { + const MiddlewarePanelComponent = middlewarePlugin.panelComponent; + const supported = [middlewarePlugin.supportedTypes].flat(); + + const ChainedPanel = React.forwardRef>( + ({ metadata, ...rest }, ref) => { + // Only forward the ref when golden-layout actually provides one. + // Spreading an empty object (rather than passing `ref={null}`) keeps + // a plain function-component base panel on a non-golden-layout render + // path from being handed a ref prop it can't accept. + const refProps = ref != null ? { ref } : {}; // Skip middleware if the widget type doesn't match its supportedTypes if (metadata?.type != null && !supported.includes(metadata.type)) { - // eslint-disable-next-line react/jsx-props-no-spreading - return ; + return ( + // eslint-disable-next-line react/jsx-props-no-spreading + + ); } return ( - // eslint-disable-next-line react/jsx-props-no-spreading ( /> ); } - ChainedPanel.displayName = `${ - middlewarePlugin.name - }Panel(${getComponentName(WrappedPanel, 'Panel')})`; - return ChainedPanel; - }, - basePanelComponent + ); + ChainedPanel.displayName = `${ + middlewarePlugin.name + }Panel(${getComponentName(WrappedPanel, 'Panel')})`; + return ChainedPanel; + }, basePanelComponent as RefCapablePanel); +} + +/** + * What a middleware body hook returns. Both fields are optional: + * + * - `inject`: extra props merged onto the wrapped `Component`, threaded down + * the middleware chain. Use it to forward IrisGrid-aware props (e.g. + * `transformModel`, `transformTableOptions`, `onModelChanged`) without + * hand-writing the widening cast on `Component`. + * - `wrap`: an optional wrapper placed *around* the wrapped component (e.g. a + * context provider). Receives the already-rendered child element and must + * return an element that renders it. + */ +export interface MiddlewareBodyResult { + inject?: Record; + wrap?: (child: React.ReactElement) => React.ReactElement; +} + +/** + * A hook implementing the body of a middleware. Receives the incoming props + * (without `Component`) and returns an optional set of props to inject plus an + * optional wrapper. The same body hook can back both a panel and a widget + * middleware (see {@link createPanelMiddleware} / {@link createWidgetMiddleware}), + * so a plugin expresses its behavior once. + * + * Type the `props` parameter as wide as the middleware needs (e.g. intersect + * with `IrisGridTableOptionsWidgetProps`) — the factory passes the runtime + * props through unchanged. + */ +export type MiddlewareBody

= (props: P) => MiddlewareBodyResult; + +/** + * Builds a panel-path middleware component from a single body hook, owning the + * `React.forwardRef` ceremony and ref forwarding that golden-layout state + * persistence depends on. + * + * The returned component is ref-capable and always forwards its `ref` to the + * wrapped `Component`, so a middleware author can never accidentally drop it + * (which would silently break `componentState` persistence — sorts, filters, + * column moves — on reload). The body hook only decides what to inject and how + * to wrap; it never sees the ref. + */ +export function createPanelMiddleware< + T = unknown, + P extends WidgetPanelProps = WidgetPanelProps, +>( + useBody: MiddlewareBody

, + displayName = 'PanelMiddleware' +): React.ForwardRefExoticComponent< + WidgetMiddlewarePanelProps & React.RefAttributes +> { + const PanelMiddleware = React.forwardRef< + unknown, + WidgetMiddlewarePanelProps + >(({ Component, ...rest }, ref) => { + const { inject, wrap } = useBody(rest as unknown as P); + const Next = Component as unknown as React.ForwardRefExoticComponent< + Record & React.RefAttributes + >; + const child = ( + // eslint-disable-next-line react/jsx-props-no-spreading + + ); + return wrap != null ? wrap(child) : child; + }); + PanelMiddleware.displayName = displayName; + return PanelMiddleware; +} + +/** + * Builds a widget-path middleware component from a single body hook. The widget + * path takes no ref, so this is a plain function component; otherwise it mirrors + * {@link createPanelMiddleware} (same `inject` / `wrap` contract), letting a + * plugin reuse one body hook for both paths. + */ +export function createWidgetMiddleware< + T = unknown, + P extends WidgetComponentProps = WidgetComponentProps, +>( + useBody: MiddlewareBody

, + displayName = 'WidgetMiddleware' +): React.ComponentType> { + function WidgetMiddleware({ + Component, + ...rest + }: WidgetMiddlewareComponentProps): React.ReactElement { + const { inject, wrap } = useBody(rest as unknown as P); + const Next = Component as unknown as React.ComponentType< + Record + >; + const child = ( + // eslint-disable-next-line react/jsx-props-no-spreading + ); + return wrap != null ? wrap(child) : child; + } + WidgetMiddleware.displayName = displayName; + return WidgetMiddleware; } export type PluginManifestPluginInfo = {