Skip to content

Commit d698759

Browse files
authored
feat: Table plugin support for GridWidgetPlugin (#2478)
Also fixes selection and panelState reactivity (for GridWidget, not IrisGridPanel). Could fix for IrisGridPanel here as well if desired. Passes the panel name as `tableName` since tables do not actually have names. Mimics the `panel` prop passed to plugins to give `irisGrid` and `getTableName` which might have been previously used. Should keep most things backwards compatible unless there were other items from the panel being used. One concern I have is the filters from plugins are completely invisible to users once they are applied. They don't go to quick filters or anywhere else, just applied with no visual. This probably makes sense as a separate ticket though.
1 parent bc16dbd commit d698759

6 files changed

Lines changed: 314 additions & 15 deletions

File tree

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

Lines changed: 44 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useMemo, useRef } from 'react';
1+
import { useCallback, useMemo, useRef, useState } from 'react';
22
import {
33
type WidgetComponentProps,
44
usePersistentState,
@@ -18,13 +18,14 @@ import { useSelector } from 'react-redux';
1818
import { getSettings, type RootState } from '@deephaven/redux';
1919
import { LoadingOverlay } from '@deephaven/components';
2020
import { useLayoutManager, useListener } from '@deephaven/dashboard';
21-
import { getErrorMessage } from '@deephaven/utils';
21+
import { assertNotNull, getErrorMessage } from '@deephaven/utils';
2222
import { useApi } from '@deephaven/jsapi-bootstrap';
23-
import { type GridState } from '@deephaven/grid';
23+
import { type GridRange, type GridState } from '@deephaven/grid';
2424
import { useIrisGridModel } from './useIrisGridModel';
2525
import useDashboardColumnFilters from './useDashboardColumnFilters';
2626
import { InputFilterEvent } from './events';
2727
import useGridLinker from './useGridLinker';
28+
import { useTablePlugin } from './useTablePlugin';
2829

2930
export function GridWidgetPlugin({
3031
fetch,
@@ -33,6 +34,8 @@ export function GridWidgetPlugin({
3334
const { eventHub } = useLayoutManager();
3435

3536
const fetchResult = useIrisGridModel(fetch);
37+
const model =
38+
fetchResult.status === 'success' ? fetchResult.model : undefined;
3639

3740
const dh = useApi();
3841
const irisGridUtils = useMemo(() => new IrisGridUtils(dh), [dh]);
@@ -90,19 +93,19 @@ export function GridWidgetPlugin({
9093
);
9194

9295
const inputFilters = useDashboardColumnFilters(
93-
fetchResult.status === 'success' ? fetchResult.model.columns : null,
94-
fetchResult.status === 'success' &&
95-
isIrisGridTableModelTemplate(fetchResult.model)
96-
? fetchResult.model.table
96+
model?.columns ?? null,
97+
model != null && isIrisGridTableModelTemplate(model)
98+
? model.table
9799
: undefined
98100
);
99101

100102
const irisGridRef = useRef<IrisGridType | null>(null);
101103

102-
const linkerProps = useGridLinker(
103-
fetchResult.status === 'success' ? fetchResult.model : null,
104-
irisGridRef.current
105-
);
104+
const { alwaysFetchColumns: linkerAlwaysFetchColumns, ...linkerProps } =
105+
useGridLinker(
106+
fetchResult.status === 'success' ? fetchResult.model : null,
107+
irisGridRef.current
108+
);
106109

107110
const handleClearAllFilters = useCallback(() => {
108111
if (irisGridRef.current == null) {
@@ -117,6 +120,28 @@ export function GridWidgetPlugin({
117120
handleClearAllFilters
118121
);
119122

123+
const [selection, setSelection] = useState<readonly GridRange[]>([]);
124+
125+
const {
126+
Plugin,
127+
customFilters,
128+
alwaysFetchColumns: filterFetchColumns,
129+
onContextMenu,
130+
} = useTablePlugin({
131+
model,
132+
irisGridRef,
133+
irisGridUtils,
134+
selectedRanges: selection,
135+
});
136+
137+
const alwaysFetchColumns = useMemo(() => {
138+
const columnSet = new Set([
139+
...linkerAlwaysFetchColumns,
140+
...filterFetchColumns,
141+
]);
142+
return [...columnSet];
143+
}, [linkerAlwaysFetchColumns, filterFetchColumns]);
144+
120145
if (fetchResult.status === 'loading') {
121146
return <LoadingOverlay isLoading />;
122147
}
@@ -130,20 +155,26 @@ export function GridWidgetPlugin({
130155
);
131156
}
132157

133-
const { model } = fetchResult;
158+
assertNotNull(model, 'Model should be defined when fetch is successful');
134159

135160
return (
136161
<IrisGrid
137162
ref={irisGridRef}
138163
model={model}
139164
settings={settings}
140165
onStateChange={handleIrisGridChange}
166+
onSelectionChanged={setSelection}
167+
onContextMenu={onContextMenu}
141168
inputFilters={inputFilters}
169+
customFilters={customFilters}
142170
// eslint-disable-next-line react/jsx-props-no-spreading
143171
{...linkerProps}
172+
alwaysFetchColumns={alwaysFetchColumns}
144173
// eslint-disable-next-line react/jsx-props-no-spreading
145174
{...hydratedState}
146-
/>
175+
>
176+
{Plugin}
177+
</IrisGrid>
147178
);
148179
}
149180

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { forwardRef, useMemo } from 'react';
2+
import {
3+
type TablePluginProps,
4+
type TablePluginElement,
5+
} from '@deephaven/plugin';
6+
import { type IrisGridType } from '@deephaven/iris-grid';
7+
import {
8+
LayoutUtils,
9+
useLayoutManager,
10+
usePanelId,
11+
} from '@deephaven/dashboard';
12+
import useLoadTablePlugin from './useLoadTablePlugin';
13+
14+
export const TablePluginWrapper = forwardRef(
15+
(
16+
{
17+
name,
18+
model,
19+
filter,
20+
fetchColumns,
21+
selectedRanges,
22+
irisGridRef,
23+
pluginState,
24+
onStateChange,
25+
}: Pick<
26+
TablePluginProps,
27+
| 'model'
28+
| 'filter'
29+
| 'fetchColumns'
30+
| 'selectedRanges'
31+
| 'pluginState'
32+
| 'onStateChange'
33+
> & {
34+
name: string;
35+
irisGridRef: React.MutableRefObject<IrisGridType | null>;
36+
},
37+
ref: React.Ref<TablePluginElement>
38+
): JSX.Element | null => {
39+
const loadPlugin = useLoadTablePlugin();
40+
const Plugin = useMemo(() => loadPlugin(name), [loadPlugin, name]);
41+
42+
const layoutManager = useLayoutManager();
43+
const panelId = usePanelId();
44+
const panelName = useMemo(() => {
45+
if (panelId == null) {
46+
return 'unknown';
47+
}
48+
49+
const panelItem = LayoutUtils.getContentItemById(
50+
layoutManager.root,
51+
panelId
52+
);
53+
54+
return panelItem?.config.title ?? 'unknown';
55+
}, [layoutManager.root, panelId]);
56+
57+
const panel = useMemo(
58+
() => ({
59+
irisGrid: irisGridRef,
60+
getTableName: () => panelName,
61+
}),
62+
[irisGridRef, panelName]
63+
);
64+
65+
return (
66+
<div className="iris-grid-plugin">
67+
<Plugin
68+
ref={ref}
69+
filter={filter}
70+
fetchColumns={fetchColumns}
71+
model={model}
72+
table={model.table}
73+
tableName={panelName}
74+
selectedRanges={selectedRanges}
75+
onStateChange={onStateChange}
76+
pluginState={pluginState}
77+
// Mimic the panel containing `irisGrid.current` for backwards compatibility
78+
// since we don't have an IrisGridPanel to use here.
79+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
80+
// @ts-ignore
81+
panel={panel}
82+
/>
83+
</div>
84+
);
85+
}
86+
);
87+
88+
TablePluginWrapper.displayName = 'TablePluginWrapper';
89+
90+
export default TablePluginWrapper;
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { useCallback, useMemo, useRef, useState } from 'react';
2+
import { usePersistentState, type TablePluginElement } from '@deephaven/plugin';
3+
import {
4+
type InputFilter,
5+
type IrisGridModel,
6+
type IrisGridProps,
7+
type IrisGridUtils,
8+
isIrisGridTableModelTemplate,
9+
type IrisGridType,
10+
type IrisGridContextMenuData,
11+
} from '@deephaven/iris-grid';
12+
import { type GridRange } from '@deephaven/grid';
13+
import { TablePluginWrapper } from './TablePluginWrapper';
14+
15+
interface UseTablePluginProps {
16+
/**
17+
* The IrisGrid model for this plugin.
18+
* Currently only IrisGridTableModelTemplate types are supported.
19+
* Other IrisGrid model types will be ignored for now.
20+
*/
21+
model: IrisGridModel | undefined;
22+
/**
23+
* A reference to the IrisGrid component instance.
24+
*/
25+
irisGridRef: React.MutableRefObject<IrisGridType | null>;
26+
/**
27+
* A IrisGridUtils instance.
28+
*/
29+
irisGridUtils: IrisGridUtils;
30+
/**
31+
* The currently selected ranges in the grid.
32+
*/
33+
selectedRanges: readonly GridRange[] | undefined;
34+
}
35+
36+
/**
37+
* Hook to get a TablePlugin component and the IrisGrid props derived from the plugin.
38+
* The returned props should be passed to the IrisGrid component or merged with other sources
39+
* of the same props.
40+
* @param props The properties for the table plugin. The props object itself does not need to be memoized,
41+
* but the values inside it should be stable to avoid unnecessary re-renders.
42+
* @returns Object containing `Plugin` key which is the Plugin component.
43+
* The remaining object keys are IrisGrid props associated with the plugin.
44+
*/
45+
export function useTablePlugin({
46+
model,
47+
irisGridRef,
48+
irisGridUtils,
49+
selectedRanges,
50+
}: UseTablePluginProps): {
51+
Plugin: JSX.Element | null;
52+
} & Pick<
53+
IrisGridProps,
54+
'customFilters' | 'alwaysFetchColumns' | 'onContextMenu'
55+
> {
56+
const [pluginFilters, setPluginFilters] = useState<InputFilter[]>([]);
57+
const customFilters = useMemo(
58+
() =>
59+
model != null && isIrisGridTableModelTemplate(model)
60+
? irisGridUtils.getFiltersFromInputFilters(
61+
model.table.columns,
62+
pluginFilters,
63+
model.formatter.timeZone
64+
)
65+
: [],
66+
[model, irisGridUtils, pluginFilters]
67+
);
68+
const [alwaysFetchColumns, setAlwaysFetchColumns] = useState<string[]>([]);
69+
const pluginRef = useRef<TablePluginElement | null>(null);
70+
const [pluginState, setPluginState] = usePersistentState<unknown>(undefined, {
71+
version: 1,
72+
// pluginName will be undefined on first call when re-hydrating,
73+
// so use a constant type to avoid re-hydration issues with the persistent state type
74+
type: 'GridWidgetTablePluginState',
75+
});
76+
77+
const Plugin = useMemo(
78+
() =>
79+
model != null &&
80+
isIrisGridTableModelTemplate(model) &&
81+
model.table.pluginName != null ? (
82+
<TablePluginWrapper
83+
ref={pluginRef}
84+
name={model.table.pluginName}
85+
model={model}
86+
filter={setPluginFilters}
87+
fetchColumns={setAlwaysFetchColumns}
88+
selectedRanges={selectedRanges}
89+
irisGridRef={irisGridRef}
90+
pluginState={pluginState}
91+
onStateChange={setPluginState}
92+
/>
93+
) : null,
94+
[model, selectedRanges, irisGridRef, pluginState, setPluginState]
95+
);
96+
97+
const onContextMenu = useCallback(
98+
(data: IrisGridContextMenuData) => pluginRef.current?.getMenu?.(data) ?? [],
99+
[]
100+
);
101+
102+
return {
103+
Plugin,
104+
customFilters,
105+
alwaysFetchColumns,
106+
onContextMenu,
107+
};
108+
}
109+
110+
export default useTablePlugin;

packages/dashboard/src/layout/LayoutUtils.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,3 +167,41 @@ describe('isEqual', () => {
167167
).toBe(false);
168168
});
169169
});
170+
171+
describe('getContentItemById', () => {
172+
it('finds item with the specified ID', () => {
173+
const root = makeContentItem('column');
174+
const needle1 = Object.assign(makeContentItem('component'), {
175+
config: { id: 'needle1' },
176+
});
177+
const needle2 = Object.assign(makeContentItem('component'), {
178+
config: { id: 'needle2' },
179+
});
180+
root.addChild(needle1 as ContentItem);
181+
root.addChild(needle2 as ContentItem);
182+
183+
const found = LayoutUtils.getContentItemById(
184+
root as ContentItem,
185+
'needle2'
186+
);
187+
expect(found).toEqual(needle2);
188+
});
189+
190+
it('returns null if item with the specified ID not found', () => {
191+
const root = makeContentItem('column');
192+
const needle1 = Object.assign(makeContentItem('component'), {
193+
config: { id: 'needle1' },
194+
});
195+
const needle2 = Object.assign(makeContentItem('component'), {
196+
config: { id: 'needle2' },
197+
});
198+
root.addChild(needle1 as ContentItem);
199+
root.addChild(needle2 as ContentItem);
200+
201+
const found = LayoutUtils.getContentItemById(
202+
root as ContentItem,
203+
'noItemFound'
204+
);
205+
expect(found).toBeNull();
206+
});
207+
});

packages/dashboard/src/layout/LayoutUtils.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,36 @@ class LayoutUtils {
240240
return null;
241241
}
242242

243+
/**
244+
* Gets a content item by its ID
245+
* @param item Golden layout content item to search for the content item. Typically the root.
246+
* @param searchId the ID
247+
*/
248+
static getContentItemById(
249+
item: ContentItem,
250+
searchId: string | string[]
251+
): ContentItem | null {
252+
if (item.config?.id === searchId) {
253+
return item;
254+
}
255+
256+
if (item.contentItems == null) {
257+
return null;
258+
}
259+
260+
for (let i = 0; i < item.contentItems.length; i += 1) {
261+
const contentItem = this.getContentItemById(
262+
item.contentItems[i],
263+
searchId
264+
);
265+
if (contentItem) {
266+
return contentItem;
267+
}
268+
}
269+
270+
return null;
271+
}
272+
243273
/**
244274
* Gets the first stack which contains a contentItem with the given config values
245275
* @param item Golden layout content item to search for the stack

0 commit comments

Comments
 (0)