Skip to content

Commit 9756457

Browse files
vbabichCopilot
andauthored
feat: DH-21476 Configurable TableOptions sidebar (#2688)
Pre-requisite for Pivot Builder. ### Summary: This PR introduces new extension seams for `@deephaven/iris-grid` (configurable Table Options sidebar + model transforms) as a prerequisite for Pivot Builder, and includes related grid/model robustness improvements (pending-operation scrim signaling, proxy-model swap handling, header separator behavior, and shared remote-module re-exports for plugins). ### Changes: - Add `transformTableOptions` + plugin `configPage` support for the IrisGrid Table Options sidebar, including ordering, duplicate detection, and an error boundary for plugin pages. - Add `transformModel` support so middleware/hosts can wrap or replace the built `IrisGridModel` (sync/async), plus new model events to support swap/loading workflows. Improve grid behavior for pivot-like scenarios (runtime swapping of renderer/metric calculator, model-changed signaling). ### Testing: - Middleware chaining can be tested using the example table middleware plugin - deephaven/deephaven-plugins#1365. The plugin wraps all table widgets and panels in a context, adds a label above the grid, and injects additional component props. - Table Options menu manipulation and model transforms can be tested using the Pivot Builder plugin - deephaven/deephaven-plugins#1351. The plugins have not been released yet, so they have to be served via Vite proxy in dev mode for testing. Use this branch to test in DHE - deephaven-ent/iris#4525. --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent b7574c0 commit 9756457

31 files changed

Lines changed: 1944 additions & 94 deletions

packages/code-studio/src/styleguide/Grids.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ import QuadrillionExample from './grid-examples/QuadrillionExample';
1515
import TreeExample from './grid-examples/TreeExample';
1616
import AsyncExample from './grid-examples/AsyncExample';
1717
import DataBarExample from './grid-examples/DataBarExample';
18+
import PluginErrorExample from './grid-examples/PluginErrorExample';
1819
import SampleSection from './SampleSection';
20+
import { SAMPLE_SECTION_E2E_IGNORE } from './constants';
1921

2022
function Grids(): ReactElement {
2123
const dh = useApi();
@@ -84,6 +86,16 @@ function Grids(): ReactElement {
8486
<SampleSection name="grids-iris-spacious" component={Flex} height={500}>
8587
<IrisGrid model={irisGridSpaciousModel} density="spacious" />
8688
</SampleSection>
89+
{/* e2e-only: exercises PluginTableOptionsErrorBoundary. Hidden from
90+
the section-count snapshot test via SAMPLE_SECTION_E2E_IGNORE. */}
91+
<SampleSection
92+
name="grids-iris-plugin-error"
93+
className={SAMPLE_SECTION_E2E_IGNORE}
94+
component={Flex}
95+
height={500}
96+
>
97+
<PluginErrorExample />
98+
</SampleSection>
8799
</ThemeContext.Provider>
88100
</div>
89101
);
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import React, { type ReactElement, useState } from 'react';
2+
import { MockTreeGridModel } from '@deephaven/grid';
3+
import {
4+
IrisGrid,
5+
type OptionItem,
6+
type TableOptionsTransform,
7+
} from '@deephaven/iris-grid';
8+
import { useApi } from '@deephaven/jsapi-bootstrap';
9+
import MockIrisGridTreeModel from '../MockIrisGridTreeModel';
10+
11+
/**
12+
* A plugin `configPage` that always throws, used to exercise
13+
* `PluginTableOptionsErrorBoundary` from an e2e test.
14+
*/
15+
function ThrowingConfigPage(): ReactElement {
16+
throw new Error('Intentional configPage error for error boundary test');
17+
}
18+
19+
/**
20+
* Appends a plugin Table Options item whose `configPage` throws on render so
21+
* the sidebar error boundary fallback can be snapshotted in e2e tests.
22+
*/
23+
const transformTableOptionsWithError: TableOptionsTransform = (
24+
defaults: readonly OptionItem[]
25+
) => [
26+
...defaults,
27+
{
28+
type: 'plugin:e2e:throwing-page',
29+
title: 'Throwing Plugin Page',
30+
configPage: ThrowingConfigPage,
31+
},
32+
];
33+
34+
/**
35+
* An IrisGrid whose Table Options include a plugin page that throws on render,
36+
* used to exercise `PluginTableOptionsErrorBoundary` from an e2e test.
37+
*/
38+
function PluginErrorExample(): ReactElement {
39+
const dh = useApi();
40+
const [model] = useState(
41+
() => new MockIrisGridTreeModel(dh, new MockTreeGridModel())
42+
);
43+
return (
44+
<IrisGrid
45+
model={model}
46+
density="regular"
47+
transformTableOptions={transformTableOptionsWithError}
48+
/>
49+
);
50+
}
51+
52+
export default PluginErrorExample;

packages/dashboard-core-plugins/src/GridWidgetPlugin.test.tsx

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,54 @@ it('mounts without crashing', async () => {
6161

6262
expect(queryByText('MockIrisGrid')).toBeInTheDocument();
6363
});
64+
65+
it('forwards the transformTableOptions prop to IrisGrid.transformTableOptions', async () => {
66+
MockIrisGrid.mockClear();
67+
const table = TestUtils.createMockProxy<DhType.Table>({ columns: [] });
68+
const fetch = jest.fn(() => Promise.resolve(table));
69+
const store = createMockStore();
70+
const transformTableOptions = jest.fn(defaults => defaults);
71+
72+
const { queryByText } = render(
73+
<Provider store={store}>
74+
<ApiContext.Provider value={dh}>
75+
<GridWidgetPlugin
76+
fetch={fetch}
77+
transformTableOptions={transformTableOptions}
78+
/>
79+
</ApiContext.Provider>
80+
</Provider>
81+
);
82+
83+
await waitFor(() => expect(queryByText('MockIrisGrid')).toBeInTheDocument());
84+
85+
const { calls } = MockIrisGrid.mock;
86+
expect(calls.length).toBeGreaterThan(0);
87+
const lastProps = calls[calls.length - 1][0];
88+
expect(lastProps.transformTableOptions).toBe(transformTableOptions);
89+
});
90+
91+
it('applies transformModel to the model passed to IrisGrid', async () => {
92+
MockIrisGrid.mockClear();
93+
const table = TestUtils.createMockProxy<DhType.Table>({ columns: [] });
94+
const fetch = jest.fn(() => Promise.resolve(table));
95+
const store = createMockStore();
96+
const transformedModel = TestUtils.createMockProxy({ columns: [] });
97+
const transformModel = jest.fn(() => transformedModel);
98+
99+
const { queryByText } = render(
100+
<Provider store={store}>
101+
<ApiContext.Provider value={dh}>
102+
<GridWidgetPlugin fetch={fetch} transformModel={transformModel} />
103+
</ApiContext.Provider>
104+
</Provider>
105+
);
106+
107+
await waitFor(() => expect(queryByText('MockIrisGrid')).toBeInTheDocument());
108+
109+
expect(transformModel).toHaveBeenCalledTimes(1);
110+
const { calls } = MockIrisGrid.mock;
111+
expect(calls.length).toBeGreaterThan(0);
112+
const lastProps = calls[calls.length - 1][0];
113+
expect(lastProps.model).toBe(transformedModel);
114+
});

packages/dashboard-core-plugins/src/GridWidgetPlugin.tsx

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useMemo, useRef, useState } from 'react';
1+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
22
import { type WidgetComponentProps } from '@deephaven/plugin';
33
import { type dh as DhType } from '@deephaven/jsapi-types';
44
import {
@@ -8,8 +8,12 @@ import {
88
IrisGridCacheUtils,
99
type IrisGridState,
1010
type IrisGridType,
11+
type IrisGridViewProps,
1112
IrisGridUtils,
1213
isIrisGridTableModelTemplate,
14+
type IrisGridModel,
15+
type IrisGridTableOptionsWidgetProps,
16+
type IrisGridModelWidgetProps,
1317
} from '@deephaven/iris-grid';
1418
import { useSelector } from 'react-redux';
1519
import { getSettings, type RootState } from '@deephaven/redux';
@@ -28,16 +32,46 @@ import { InputFilterEvent } from './events';
2832
import useGridLinker from './useGridLinker';
2933
import { useTablePlugin } from './useTablePlugin';
3034

35+
/**
36+
* Props that can only be supplied by IrisGrid-aware middleware wrapping
37+
* `GridWidgetPlugin` (regular plugins receive only `WidgetComponentProps`).
38+
* Grouped together so the middleware-only surface is explicit; the component
39+
* depends on `Partial` of this so each prop stays optional.
40+
*/
41+
export type GridWidgetPluginMiddlewareProps = IrisGridTableOptionsWidgetProps &
42+
IrisGridModelWidgetProps & {
43+
/**
44+
* View-concern overrides (theme, renderer, mouse handlers, metric
45+
* calculator) forwarded as a single bag to `<IrisGrid>`. Lets a plugin
46+
* contribute presentation without this host knowing each concern by name.
47+
*/
48+
irisGridProps?: Partial<IrisGridViewProps>;
49+
/** Called once the model is built, so middleware can observe it. */
50+
onModelChanged?: (model: IrisGridModel) => void;
51+
};
52+
3153
export function GridWidgetPlugin({
3254
fetch,
33-
}: WidgetComponentProps<DhType.Table>): JSX.Element | null {
55+
transformTableOptions,
56+
transformModel,
57+
irisGridProps,
58+
onModelChanged,
59+
}: WidgetComponentProps<DhType.Table> &
60+
Partial<GridWidgetPluginMiddlewareProps>): JSX.Element | null {
3461
const settings = useSelector(getSettings<RootState>);
3562
const { eventHub } = useLayoutManager();
3663

37-
const fetchResult = useIrisGridModel(fetch);
64+
const fetchResult = useIrisGridModel(fetch, transformModel);
3865
const model =
3966
fetchResult.status === 'success' ? fetchResult.model : undefined;
4067

68+
// Notify observers (e.g. middleware) when the built model changes.
69+
useEffect(() => {
70+
if (model != null) {
71+
onModelChanged?.(model);
72+
}
73+
}, [model, onModelChanged]);
74+
4175
const dh = useApi();
4276
const irisGridUtils = useMemo(() => new IrisGridUtils(dh), [dh]);
4377

@@ -171,6 +205,9 @@ export function GridWidgetPlugin({
171205
onContextMenu={onContextMenu}
172206
inputFilters={inputFilters}
173207
customFilters={customFilters}
208+
transformTableOptions={transformTableOptions}
209+
// eslint-disable-next-line react/jsx-props-no-spreading
210+
{...irisGridProps}
174211
// eslint-disable-next-line react/jsx-props-no-spreading
175212
{...linkerProps}
176213
alwaysFetchColumns={alwaysFetchColumns}

packages/dashboard-core-plugins/src/panels/IrisGridPanel.test.tsx

Lines changed: 94 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,13 @@ function makeIrisGridPanelWrapper(
6060
user = TestUtils.REGULAR_USER,
6161
client = new (dh as any).Client(),
6262
workspace = {},
63-
settings = { timeZone: 'America/New_York' }
63+
settings = { timeZone: 'America/New_York' },
64+
transformTableOptions:
65+
| ((defaults: readonly any[]) => readonly any[])
66+
| undefined = undefined,
67+
transformModel: ((model: any) => any) | undefined = undefined
6468
) {
65-
return render(
69+
const panel = (
6670
<IrisGridPanel
6771
makeModel={makeModel}
6872
metadata={metadata}
@@ -76,9 +80,11 @@ function makeIrisGridPanelWrapper(
7680
panelState={undefined}
7781
getDownloadWorker={() => undefined}
7882
loadPlugin={() => undefined}
79-
theme={undefined}
83+
transformTableOptions={transformTableOptions}
84+
transformModel={transformModel}
8085
/>
8186
);
87+
return render(panel);
8288
}
8389

8490
async function expectLoading(container) {
@@ -153,3 +159,88 @@ it('shows an error properly if table loading fails', async () => {
153159
);
154160
expect(msg).toBeTruthy();
155161
});
162+
163+
it('forwards the transformTableOptions prop to IrisGrid.transformTableOptions', async () => {
164+
MockIrisGrid.mockClear();
165+
const transformTableOptions = jest.fn(defaults => defaults);
166+
await act(() =>
167+
makeIrisGridPanelWrapper(
168+
undefined,
169+
undefined,
170+
undefined,
171+
undefined,
172+
undefined,
173+
undefined,
174+
undefined,
175+
undefined,
176+
undefined,
177+
undefined,
178+
transformTableOptions
179+
)
180+
);
181+
const { calls } = MockIrisGrid.mock;
182+
expect(calls.length).toBeGreaterThan(0);
183+
const lastProps = calls[calls.length - 1][0];
184+
expect(lastProps.transformTableOptions).toBe(transformTableOptions);
185+
});
186+
187+
it('passes undefined transformTableOptions when the prop is omitted', async () => {
188+
MockIrisGrid.mockClear();
189+
await act(() => makeIrisGridPanelWrapper());
190+
const { calls } = MockIrisGrid.mock;
191+
expect(calls.length).toBeGreaterThan(0);
192+
const lastProps = calls[calls.length - 1][0];
193+
expect(lastProps.transformTableOptions).toBeUndefined();
194+
});
195+
196+
it('applies transformModel to the built model before passing it to IrisGrid', async () => {
197+
MockIrisGrid.mockClear();
198+
const transformedModel = await makeMakeModel()();
199+
const transformModel = jest.fn(() => transformedModel);
200+
await act(() =>
201+
makeIrisGridPanelWrapper(
202+
undefined,
203+
undefined,
204+
undefined,
205+
undefined,
206+
undefined,
207+
undefined,
208+
undefined,
209+
undefined,
210+
undefined,
211+
undefined,
212+
undefined,
213+
transformModel
214+
)
215+
);
216+
expect(transformModel).toHaveBeenCalledTimes(1);
217+
const { calls } = MockIrisGrid.mock;
218+
expect(calls.length).toBeGreaterThan(0);
219+
const lastProps = calls[calls.length - 1][0];
220+
expect(lastProps.model).toBe(transformedModel);
221+
});
222+
223+
it('closes the base model if transformModel rejects', async () => {
224+
MockIrisGrid.mockClear();
225+
const baseModel = TestUtils.createMockProxy<any>({ close: jest.fn() });
226+
const makeModel = jest.fn(() => Promise.resolve(baseModel));
227+
const error = new Error('Transform failed');
228+
const transformModel = jest.fn(() => Promise.reject(error));
229+
const { container } = makeIrisGridPanelWrapper(
230+
makeModel,
231+
undefined,
232+
undefined,
233+
undefined,
234+
undefined,
235+
undefined,
236+
undefined,
237+
undefined,
238+
undefined,
239+
undefined,
240+
undefined,
241+
transformModel
242+
);
243+
await expectNotLoading(container);
244+
expect(transformModel).toHaveBeenCalledTimes(1);
245+
expect(baseModel.close).toHaveBeenCalledTimes(1);
246+
});

0 commit comments

Comments
 (0)