Skip to content

Commit e1a9971

Browse files
authored
feat: DH-19000: Persist deephaven UI table client-side state (#1152)
1 parent 62fbb87 commit e1a9971

11 files changed

Lines changed: 297 additions & 105 deletions

package-lock.json

Lines changed: 3 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

plugins/ui/src/js/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
"classnames": "^2.5.1",
6565
"fast-json-patch": "^3.1.1",
6666
"json-rpc-2.0": "^1.6.0",
67+
"memoizee": "^0.4.17",
6768
"nanoid": "^5.0.7",
6869
"react-markdown": "^8.0.7",
6970
"react-redux": "^7.x",

plugins/ui/src/js/src/elements/UITable/UITable.tsx

Lines changed: 124 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import React, { useCallback, useEffect, useMemo, useState } from 'react';
1+
import React, {
2+
useCallback,
3+
useEffect,
4+
useMemo,
5+
useRef,
6+
useState,
7+
} from 'react';
28
import { useSelector } from 'react-redux';
39
import classNames from 'classnames';
410
import {
@@ -8,6 +14,10 @@ import {
814
type IrisGridContextMenuData,
915
IrisGridProps,
1016
IrisGridUtils,
17+
IrisGridCacheUtils,
18+
IrisGridState,
19+
type DehydratedIrisGridState,
20+
type DehydratedGridState,
1121
} from '@deephaven/iris-grid';
1222
import {
1323
ColorValues,
@@ -18,12 +28,12 @@ import {
1828
viewStyleProps,
1929
} from '@deephaven/components';
2030
import { useApi } from '@deephaven/jsapi-bootstrap';
21-
import { TableUtils } from '@deephaven/jsapi-utils';
2231
import type { dh as DhType } from '@deephaven/jsapi-types';
2332
import Log from '@deephaven/log';
2433
import { getSettings, RootState } from '@deephaven/redux';
25-
import { GridMouseHandler } from '@deephaven/grid';
34+
import { GridMouseHandler, GridState } from '@deephaven/grid';
2635
import { EMPTY_ARRAY, ensureArray } from '@deephaven/utils';
36+
import { usePersistentState } from '@deephaven/plugin';
2737
import {
2838
DatabarConfig,
2939
FormattingRule,
@@ -275,6 +285,35 @@ export function UITable({
275285
model.setColorMap(colorMap);
276286
}
277287

288+
const [dehydratedState, setDehydratedState] = usePersistentState<
289+
(DehydratedIrisGridState & DehydratedGridState) | undefined
290+
>(undefined, { type: 'UITable', version: 1 });
291+
const initialState = useRef(dehydratedState);
292+
293+
const memoizedStateFn = useMemo(
294+
() => IrisGridCacheUtils.makeMemoizedCombinedGridStateDehydrator(),
295+
[]
296+
);
297+
298+
const onStateChange = useCallback(
299+
(irisGridState: IrisGridState, gridState: GridState) => {
300+
if (model == null) {
301+
return;
302+
}
303+
setDehydratedState(memoizedStateFn(model, irisGridState, gridState));
304+
},
305+
[memoizedStateFn, model, setDehydratedState]
306+
);
307+
308+
const initialHydratedState = useMemo(() => {
309+
if (model && initialState.current != null) {
310+
return {
311+
...utils.hydrateIrisGridState(model, initialState.current),
312+
...IrisGridUtils.hydrateGridState(model, initialState.current),
313+
};
314+
}
315+
}, [model, utils]);
316+
278317
const hydratedSorts = useMemo(() => {
279318
if (sorts !== undefined && columns !== undefined) {
280319
log.debug('Hydrating sorts', sorts);
@@ -401,59 +440,92 @@ export function UITable({
401440
[contextMenu, alwaysFetchColumns]
402441
);
403442

404-
const irisGridProps = useMemo(
405-
() =>
406-
({
407-
mouseHandlers,
408-
alwaysFetchColumns,
409-
showSearchBar,
410-
sorts: hydratedSorts,
411-
quickFilters: hydratedQuickFilters,
412-
isFilterBarShown: showQuickFilters,
413-
reverseType: reverse
414-
? TableUtils.REVERSE_TYPE.POST_SORT
415-
: TableUtils.REVERSE_TYPE.NONE,
416-
density,
417-
settings: { ...settings, showExtraGroupColumn: showGroupingColumn },
418-
onContextMenu,
419-
aggregationSettings: {
420-
aggregations:
421-
aggregations != null
422-
? ensureArray(aggregations).map(agg => {
423-
if (agg.cols != null && agg.ignore_cols != null) {
424-
throw new Error(
425-
'Cannot specify both cols and ignore_cols in a UI table aggregation'
426-
);
427-
}
428-
return {
429-
operation: getAggregationOperation(agg.agg),
430-
selected: ensureArray(agg.cols ?? agg.ignore_cols ?? []),
431-
// If agg.cols is set, we don't want to invert
432-
// If it is not set, then the only other options are ignore_cols or neither
433-
// In both cases, we want to invert since we are either ignoring, or selecting all as [] inverted
434-
invert: agg.cols == null,
435-
};
436-
})
437-
: [],
438-
showOnTop: aggregationsPosition === 'top',
439-
},
440-
}) satisfies Partial<IrisGridProps>,
441-
[
443+
const irisGridServerProps = useMemo(() => {
444+
const props = {
442445
mouseHandlers,
443446
alwaysFetchColumns,
444447
showSearchBar,
445-
showQuickFilters,
446-
hydratedSorts,
447-
hydratedQuickFilters,
448+
sorts: hydratedSorts,
449+
quickFilters: hydratedQuickFilters,
450+
isFilterBarShown: showQuickFilters,
448451
reverse,
449452
density,
450-
settings,
451-
showGroupingColumn,
453+
settings: { ...settings, showExtraGroupColumn: showGroupingColumn },
452454
onContextMenu,
453-
aggregations,
454-
aggregationsPosition,
455-
]
456-
);
455+
aggregationSettings: {
456+
aggregations:
457+
aggregations != null
458+
? ensureArray(aggregations).map(agg => {
459+
if (agg.cols != null && agg.ignore_cols != null) {
460+
throw new Error(
461+
'Cannot specify both cols and ignore_cols in a UI table aggregation'
462+
);
463+
}
464+
return {
465+
operation: getAggregationOperation(agg.agg),
466+
selected: ensureArray(agg.cols ?? agg.ignore_cols ?? []),
467+
// If agg.cols is set, we don't want to invert
468+
// If it is not set, then the only other options are ignore_cols or neither
469+
// In both cases, we want to invert since we are either ignoring, or selecting all as [] inverted
470+
invert: agg.cols == null,
471+
};
472+
})
473+
: [],
474+
showOnTop: aggregationsPosition === 'top',
475+
},
476+
} satisfies Partial<IrisGridProps>;
477+
478+
// Remove any explicit undefined values so we can use client state if available
479+
(
480+
Object.entries(props) as [
481+
keyof typeof props,
482+
(typeof props)[keyof typeof props],
483+
][]
484+
).forEach(([key, value]) => {
485+
if (value === undefined) {
486+
delete props[key];
487+
}
488+
});
489+
490+
return props;
491+
}, [
492+
mouseHandlers,
493+
alwaysFetchColumns,
494+
showSearchBar,
495+
showQuickFilters,
496+
hydratedSorts,
497+
hydratedQuickFilters,
498+
reverse,
499+
density,
500+
settings,
501+
showGroupingColumn,
502+
onContextMenu,
503+
aggregations,
504+
aggregationsPosition,
505+
]);
506+
507+
const initialIrisGridServerProps = useRef(irisGridServerProps);
508+
509+
/**
510+
* We want to set the props based on a combination of server state and client state.
511+
* If the server state is the same as its initial state, then we are rehydrating and
512+
* the client state should take precedence.
513+
* Otherwise, we have received changes from the server and we should use those over client state.
514+
* In the future we may want to do a smarter merge of these.
515+
*/
516+
const mergedIrisGridProps = useMemo(() => {
517+
if (initialIrisGridServerProps.current === irisGridServerProps) {
518+
return {
519+
...irisGridServerProps,
520+
...initialHydratedState,
521+
};
522+
}
523+
524+
return {
525+
...initialHydratedState,
526+
...irisGridServerProps,
527+
};
528+
}, [irisGridServerProps, initialHydratedState]);
457529

458530
return model ? (
459531
<div
@@ -464,8 +536,9 @@ export function UITable({
464536
<IrisGrid
465537
ref={ref => setIrisGrid(ref)}
466538
model={model}
539+
onStateChange={onStateChange}
467540
// eslint-disable-next-line react/jsx-props-no-spreading
468-
{...irisGridProps}
541+
{...mergedIrisGridProps}
469542
/>
470543
</div>
471544
) : null;

plugins/ui/src/js/src/layout/ReactPanel.test.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ function makeReactPanelManager({
3434
onClose = jest.fn(),
3535
onOpen = jest.fn(),
3636
getPanelId = jest.fn(() => mockPanelId),
37+
onDataChange = jest.fn(),
38+
getInitialData = jest.fn(() => []),
3739
title = 'test title',
3840
}: Partial<ReactPanelProps> & Partial<ReactPanelManager> = {}) {
3941
return (
@@ -43,6 +45,8 @@ function makeReactPanelManager({
4345
metadata,
4446
onClose,
4547
onOpen,
48+
onDataChange,
49+
getInitialData,
4650
}}
4751
>
4852
<ReactPanel title={title}>{children}</ReactPanel>

plugins/ui/src/js/src/layout/ReactPanel.tsx

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
1+
import React, {
2+
useCallback,
3+
useEffect,
4+
useMemo,
5+
useRef,
6+
useState,
7+
} from 'react';
28
import ReactDOM from 'react-dom';
39
import { nanoid } from 'nanoid';
410
import {
@@ -15,6 +21,7 @@ import {
1521
LoadingOverlay,
1622
} from '@deephaven/components';
1723
import Log from '@deephaven/log';
24+
import { PersistentStateProvider } from '@deephaven/plugin';
1825
import PortalPanel from './PortalPanel';
1926
import { ReactPanelControl, useReactPanel } from './ReactPanelManager';
2027
import { ReactPanelProps } from './LayoutUtils';
@@ -89,10 +96,17 @@ function ReactPanel({
8996
UNSAFE_className,
9097
}: Props): JSX.Element | null {
9198
const layoutManager = useLayoutManager();
92-
const { metadata, onClose, onOpen, panelId } = useReactPanel();
99+
const { metadata, onClose, onOpen, panelId, onDataChange, getInitialData } =
100+
useReactPanel();
93101
const portalManager = usePortalPanelManager();
94102
const portal = portalManager.get(panelId);
95103
const panelTitle = title ?? metadata?.name ?? '';
104+
const [initialData, setInitialData] = useState(getInitialData());
105+
const onErrorReset = useCallback(() => {
106+
// Not EMPTY_ARRAY, because we always want to trigger a re-render
107+
// in case a panel is reloaded and errors again
108+
setInitialData([]);
109+
}, []);
96110

97111
// Tracks whether the panel is open and that we have emitted the onOpen event
98112
const isPanelOpenRef = useRef(false);
@@ -234,12 +248,19 @@ function ReactPanel({
234248
rowGap={rowGap}
235249
columnGap={columnGap}
236250
>
237-
<ReactPanelErrorBoundary>
251+
<ReactPanelErrorBoundary onReset={onErrorReset}>
238252
{/**
239253
* Don't render the children if there's an error with the widget. If there's an error with the widget, we can assume the children won't render properly,
240254
* but we still want the panels to appear so things don't disappear/jump around.
241255
*/}
242-
{renderedChildren ?? null}
256+
<PersistentStateProvider
257+
initialState={initialData}
258+
onChange={onDataChange}
259+
>
260+
{React.Children.map(renderedChildren, child =>
261+
React.cloneElement(child as React.ReactElement)
262+
)}
263+
</PersistentStateProvider>
243264
</ReactPanelErrorBoundary>
244265
</Flex>
245266
</View>

0 commit comments

Comments
 (0)