Skip to content

Commit db97743

Browse files
committed
WIP persisting client state across reloads
1 parent 47900a5 commit db97743

7 files changed

Lines changed: 259 additions & 57 deletions

File tree

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

Lines changed: 107 additions & 49 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,8 @@ import {
814
type IrisGridContextMenuData,
915
IrisGridProps,
1016
IrisGridUtils,
17+
IrisGridState,
18+
DehydratedIrisGridState,
1119
} from '@deephaven/iris-grid';
1220
import {
1321
ColorValues,
@@ -37,6 +45,7 @@ import UITableContextMenuHandler, {
3745
} from './UITableContextMenuHandler';
3846
import UITableModel, { makeUiTableModel } from './UITableModel';
3947
import { UITableLayoutHints } from './JsTableProxy';
48+
import usePersistentState from '../hooks/usePersistentState';
4049

4150
const log = Log.module('@deephaven/js-plugin-ui/UITable');
4251

@@ -167,6 +176,8 @@ export function UITable({
167176
databars = EMPTY_ARRAY as unknown as DatabarConfig[],
168177
...userStyleProps
169178
}: UITableProps): JSX.Element | null {
179+
const id = useMemo(() => Math.random().toString(36).substr(2, 9), []);
180+
console.log('render table', id);
170181
const [throwError] = useThrowError();
171182

172183
// Margin looks wrong with ui.table, so we want to map margin to padding instead
@@ -275,6 +286,43 @@ export function UITable({
275286
model.setColorMap(colorMap);
276287
}
277288

289+
const prevState = useRef<IrisGridState | undefined>();
290+
const [dehydratedState, setDehydratedState] = usePersistentState<
291+
DehydratedIrisGridState | undefined
292+
>(undefined);
293+
const initialState = useRef(dehydratedState);
294+
295+
const onStateChange = useCallback(
296+
(newState: IrisGridState) => {
297+
if (
298+
model == null ||
299+
newState.metrics == null ||
300+
newState === prevState.current ||
301+
newState.sorts === prevState.current?.sorts
302+
) {
303+
return;
304+
}
305+
// console.log('onStateChange', newState);
306+
prevState.current = newState;
307+
setDehydratedState(
308+
utils.dehydrateIrisGridState(model, {
309+
...newState,
310+
metrics: {
311+
userColumnWidths: newState.metrics.userColumnWidths,
312+
userRowHeights: newState.metrics.userRowHeights,
313+
},
314+
})
315+
);
316+
},
317+
[model, setDehydratedState, utils]
318+
);
319+
320+
const hydratedState = useMemo(() => {
321+
if (model && initialState.current) {
322+
return utils.hydrateIrisGridState(model, initialState.current);
323+
}
324+
}, [model, utils]);
325+
278326
const hydratedSorts = useMemo(() => {
279327
if (sorts !== undefined && columns !== undefined) {
280328
log.debug('Hydrating sorts', sorts);
@@ -401,59 +449,66 @@ export function UITable({
401449
[contextMenu, alwaysFetchColumns]
402450
);
403451

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-
[
452+
const irisGridProps = useMemo(() => {
453+
const props = {
442454
mouseHandlers,
443455
alwaysFetchColumns,
444456
showSearchBar,
445-
showQuickFilters,
446-
hydratedSorts,
447-
hydratedQuickFilters,
448-
reverse,
457+
sorts: hydratedSorts,
458+
quickFilters: hydratedQuickFilters,
459+
isFilterBarShown: showQuickFilters,
460+
reverseType: reverse
461+
? TableUtils.REVERSE_TYPE.POST_SORT
462+
: TableUtils.REVERSE_TYPE.NONE,
449463
density,
450-
settings,
451-
showGroupingColumn,
464+
settings: { ...settings, showExtraGroupColumn: showGroupingColumn },
452465
onContextMenu,
453-
aggregations,
454-
aggregationsPosition,
455-
]
456-
);
466+
aggregationSettings: {
467+
aggregations:
468+
aggregations != null
469+
? ensureArray(aggregations).map(agg => {
470+
if (agg.cols != null && agg.ignore_cols != null) {
471+
throw new Error(
472+
'Cannot specify both cols and ignore_cols in a UI table aggregation'
473+
);
474+
}
475+
return {
476+
operation: getAggregationOperation(agg.agg),
477+
selected: ensureArray(agg.cols ?? agg.ignore_cols ?? []),
478+
// If agg.cols is set, we don't want to invert
479+
// If it is not set, then the only other options are ignore_cols or neither
480+
// In both cases, we want to invert since we are either ignoring, or selecting all as [] inverted
481+
invert: agg.cols == null,
482+
};
483+
})
484+
: [],
485+
showOnTop: aggregationsPosition === 'top',
486+
},
487+
} satisfies Partial<IrisGridProps>;
488+
489+
// Remove any explicit undefined values
490+
Object.entries(props).forEach(([key, value]) => {
491+
if (value === undefined) {
492+
delete props[key];
493+
}
494+
});
495+
496+
return props;
497+
}, [
498+
mouseHandlers,
499+
alwaysFetchColumns,
500+
showSearchBar,
501+
showQuickFilters,
502+
hydratedSorts,
503+
hydratedQuickFilters,
504+
reverse,
505+
density,
506+
settings,
507+
showGroupingColumn,
508+
onContextMenu,
509+
aggregations,
510+
aggregationsPosition,
511+
]);
457512

458513
return model ? (
459514
<div
@@ -464,6 +519,9 @@ export function UITable({
464519
<IrisGrid
465520
ref={ref => setIrisGrid(ref)}
466521
model={model}
522+
onStateChange={onStateChange}
523+
// eslint-disable-next-line react/jsx-props-no-spreading
524+
{...hydratedState}
467525
// eslint-disable-next-line react/jsx-props-no-spreading
468526
{...irisGridProps}
469527
/>
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import {
2+
useCallback,
3+
useContext,
4+
useState,
5+
type Dispatch,
6+
type SetStateAction,
7+
} from 'react';
8+
import { ReactPanelContext } from '../../layout/ReactPanelContext';
9+
10+
export default function usePersistentState<S>(
11+
initialState: S | (() => S)
12+
): [S, Dispatch<SetStateAction<S>>] {
13+
const context = useContext(ReactPanelContext);
14+
const initialPersistedState = context?.getInitialState() as S | undefined;
15+
const [state, setState] = useState(initialPersistedState ?? initialState);
16+
17+
console.log('usePersistentState', initialPersistedState, context?.isTracking);
18+
if (context && context.isTracking) {
19+
context?.addPanelState?.(state);
20+
}
21+
22+
const setter = useCallback(
23+
(newState: SetStateAction<S>) => {
24+
console.log('setter');
25+
setState(newState);
26+
context?.trigger?.();
27+
// });
28+
},
29+
[context]
30+
);
31+
32+
return [state, setter];
33+
}

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

Lines changed: 54 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 {
@@ -89,7 +95,8 @@ function ReactPanel({
8995
UNSAFE_className,
9096
}: Props): JSX.Element | null {
9197
const layoutManager = useLayoutManager();
92-
const { metadata, onClose, onOpen, panelId } = useReactPanel();
98+
const { metadata, onClose, onOpen, panelId, onDataChange, getInitialData } =
99+
useReactPanel();
93100
const portalManager = usePortalPanelManager();
94101
const portal = portalManager.get(panelId);
95102
const panelTitle = title ?? metadata?.name ?? '';
@@ -201,9 +208,50 @@ function ReactPanel({
201208
renderedChildren = children;
202209
}
203210

211+
const [render, setRender] = useState(0);
212+
213+
const persistentData = useRef({
214+
initial: getInitialData(),
215+
prev: [] as unknown[],
216+
next: [] as unknown[],
217+
});
218+
219+
console.log('panel render', renderedChildren);
220+
221+
const panelContextValue = useMemo(
222+
() => ({
223+
panelId,
224+
addPanelState(state: unknown) {
225+
persistentData.current.next.push(state);
226+
},
227+
initialStateIterator: persistentData.current.initial[Symbol.iterator](),
228+
getInitialState() {
229+
// eslint-disable-next-line react/no-this-in-sfc
230+
const { value, done } = this.initialStateIterator.next();
231+
return value;
232+
},
233+
trigger() {
234+
persistentData.current.next = [];
235+
// eslint-disable-next-line react/no-this-in-sfc
236+
this.isTracking = true;
237+
setRender(prev => prev + 1);
238+
},
239+
isTracking: false,
240+
}),
241+
[panelId]
242+
);
243+
244+
useEffect(() => {
245+
console.log('panel effect', persistentData.current.next.length);
246+
persistentData.current.prev = persistentData.current.next;
247+
persistentData.current.next = [];
248+
panelContextValue.isTracking = false;
249+
onDataChange(persistentData.current.prev);
250+
}, [render, panelContextValue, onDataChange]);
251+
204252
return portal
205253
? ReactDOM.createPortal(
206-
<ReactPanelContext.Provider value={panelId}>
254+
<ReactPanelContext.Provider value={panelContextValue}>
207255
<View
208256
height="100%"
209257
width="100%"
@@ -239,7 +287,9 @@ function ReactPanel({
239287
* 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,
240288
* but we still want the panels to appear so things don't disappear/jump around.
241289
*/}
242-
{renderedChildren ?? null}
290+
{React.Children.map(renderedChildren, child =>
291+
React.cloneElement(child as React.ReactElement)
292+
)}
243293
</ReactPanelErrorBoundary>
244294
</Flex>
245295
</View>
Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,22 @@
11
import { createContext, useContext } from 'react';
22

3+
type PanelContextType = {
4+
panelId: string;
5+
addPanelState: (state: unknown) => void;
6+
trigger: () => void;
7+
isTracking: boolean;
8+
getInitialState: () => unknown;
9+
};
10+
311
/**
412
* Context that holds the ID of the panel that we are currently in.
513
*/
6-
export const ReactPanelContext = createContext<string | null>(null);
14+
export const ReactPanelContext = createContext<PanelContextType | null>(null);
715

816
/**
917
* Gets the panel ID from the nearest panel context.
1018
* @returns The panel ID or null if not in a panel
1119
*/
1220
export function usePanelId(): string | null {
13-
return useContext(ReactPanelContext);
21+
return useContext(ReactPanelContext)?.panelId ?? null;
1422
}

0 commit comments

Comments
 (0)