Skip to content

Commit 652c1a1

Browse files
authored
feat: DH-21093: Make usePersistentState available to all panels (#2595)
Removed `extendState` from GL because we did not use it (and don't in DHE). It also tripped me up because it mutates the existing object, so calling it does not trigger a state change for Dashboard which means it doesn't actually save any extended state on its own.
1 parent 656c090 commit 652c1a1

File tree

6 files changed

+195
-36
lines changed

6 files changed

+195
-36
lines changed

packages/dashboard/src/Dashboard.test.tsx

Lines changed: 106 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,22 @@
11
import React from 'react';
2-
import { render, type RenderResult } from '@testing-library/react';
2+
import {
3+
act,
4+
render,
5+
type RenderResult,
6+
screen,
7+
waitFor,
8+
} from '@testing-library/react';
9+
import userEvent from '@testing-library/user-event';
310
import { ApiContext } from '@deephaven/jsapi-bootstrap';
11+
import { type LayoutManager } from '@deephaven/golden-layout';
412
import Dashboard, { type DashboardProps } from './Dashboard';
13+
import { usePersistentState } from './usePersistentState';
14+
import { useDashboardPanel } from './layout';
15+
import {
16+
assertIsDashboardPluginProps,
17+
type DashboardPluginComponentProps,
18+
} from './DashboardPlugin';
19+
import PanelEvent from './PanelEvent';
520

621
const mockDispatch = jest.fn();
722
jest.mock('react-redux', () => ({
@@ -18,7 +33,7 @@ function makeDashboard({
1833
layoutConfig,
1934
layoutSettings,
2035
onGoldenLayoutChange,
21-
onLayoutConfigChange,
36+
onLayoutConfigChange = jest.fn(),
2237
}: DashboardProps = {}): RenderResult {
2338
return render(
2439
<ApiContext.Provider value={dh}>
@@ -39,3 +54,92 @@ function makeDashboard({
3954
it('mounts and unmounts properly', () => {
4055
makeDashboard();
4156
});
57+
58+
function TestComponent() {
59+
const [state, setState] = usePersistentState('initial', {
60+
type: 'test',
61+
version: 1,
62+
});
63+
64+
return (
65+
<button
66+
type="button"
67+
onClick={() => {
68+
setState('updated');
69+
}}
70+
>
71+
{state}
72+
</button>
73+
);
74+
}
75+
76+
TestComponent.displayName = 'TestComponent';
77+
78+
function TestPlugin(props: Partial<DashboardPluginComponentProps>) {
79+
assertIsDashboardPluginProps(props);
80+
81+
useDashboardPanel({
82+
componentName: TestComponent.displayName,
83+
component: TestComponent,
84+
supportedTypes: ['test'],
85+
dashboardProps: props,
86+
});
87+
return null;
88+
}
89+
90+
it('saves state with usePersistentState hook', async () => {
91+
const user = userEvent.setup();
92+
const onLayoutConfigChange = jest.fn();
93+
let gl: LayoutManager | null = null;
94+
const onGoldenLayoutChange = (newGl: LayoutManager) => {
95+
gl = newGl;
96+
};
97+
const { unmount } = makeDashboard({
98+
onGoldenLayoutChange,
99+
onLayoutConfigChange,
100+
children: <TestPlugin />,
101+
});
102+
103+
act(() =>
104+
gl!.eventHub.emit(PanelEvent.OPEN, {
105+
widget: { type: 'test' },
106+
})
107+
);
108+
expect(screen.getByText('initial')).toBeInTheDocument();
109+
110+
const waitRAF = () =>
111+
act(
112+
() =>
113+
new Promise(resolve => {
114+
requestAnimationFrame(resolve);
115+
})
116+
);
117+
118+
// Golden Layout throttles stateChanged events to once per RAF
119+
// The test is running too fast and getting batched into one RAF
120+
// so we need to wait for the initial stateChanged to fire
121+
await waitRAF();
122+
123+
onLayoutConfigChange.mockClear();
124+
await user.click(screen.getByText('initial'));
125+
await waitFor(async () =>
126+
expect(await screen.findByText('updated')).toBeInTheDocument()
127+
);
128+
129+
// Need to wait here as well because the stateChanged event fires on the next frame
130+
// which seems to happen after the unmount without this wait
131+
await waitRAF();
132+
133+
act(() => unmount());
134+
135+
expect(onLayoutConfigChange).toHaveBeenCalledTimes(1);
136+
137+
// Remount and verify state is restored
138+
makeDashboard({
139+
layoutConfig: onLayoutConfigChange.mock.calls[0][0],
140+
onLayoutConfigChange,
141+
children: <TestPlugin />,
142+
});
143+
144+
await waitFor(() => expect(screen.getByText('updated')).toBeInTheDocument());
145+
});

packages/dashboard/src/Dashboard.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export type DashboardProps = {
4747
dehydrate?: PanelDehydrateFunction;
4848

4949
/** Component to wrap each panel with */
50-
panelWrapper?: ComponentType;
50+
panelWrapper?: ComponentType<React.PropsWithChildren<PanelProps>>;
5151
};
5252

5353
export function Dashboard({

packages/dashboard/src/DashboardLayout.tsx

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ type DashboardLayoutProps = React.PropsWithChildren<{
7373
emptyDashboard?: React.ReactNode;
7474

7575
/** Component to wrap each panel with */
76-
panelWrapper?: ComponentType<React.PropsWithChildren<DehydratedPanelProps>>;
76+
panelWrapper?: ComponentType<React.PropsWithChildren<PanelProps>>;
7777
}>;
7878

7979
/**
@@ -89,7 +89,7 @@ export function DashboardLayout({
8989
onLayoutInitialized = DEFAULT_CALLBACK,
9090
hydrate = hydrateDefault,
9191
dehydrate = dehydrateDefault,
92-
panelWrapper = DashboardPanelWrapper,
92+
panelWrapper,
9393
}: DashboardLayoutProps): JSX.Element {
9494
const dispatch = useDispatch();
9595
const data =
@@ -141,22 +141,29 @@ export function DashboardLayout({
141141
*/
142142
const hasRef = canHaveRef(CType);
143143

144+
const innerElem = hasRef ? (
145+
// eslint-disable-next-line react/jsx-props-no-spreading
146+
<CType {...props} ref={ref} />
147+
) : (
148+
// eslint-disable-next-line react/jsx-props-no-spreading
149+
<CType {...props} />
150+
);
151+
144152
// Props supplied by GoldenLayout
145153
const { glContainer, glEventHub } = props;
146154
const panelId = LayoutUtils.getIdFromContainer(glContainer);
147155
return (
148156
<PanelErrorBoundary glContainer={glContainer} glEventHub={glEventHub}>
149157
<PanelIdContext.Provider value={panelId as string | null}>
150158
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
151-
<PanelWrapperType {...props}>
152-
{hasRef ? (
153-
// eslint-disable-next-line react/jsx-props-no-spreading
154-
<CType {...props} ref={ref} />
159+
<DashboardPanelWrapper {...props}>
160+
{PanelWrapperType == null ? (
161+
innerElem
155162
) : (
156163
// eslint-disable-next-line react/jsx-props-no-spreading
157-
<CType {...props} />
164+
<PanelWrapperType {...props}>{innerElem}</PanelWrapperType>
158165
)}
159-
</PanelWrapperType>
166+
</DashboardPanelWrapper>
160167
</PanelIdContext.Provider>
161168
</PanelErrorBoundary>
162169
);
Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,42 @@
1-
import React, { type PropsWithChildren } from 'react';
1+
import { useCallback, useState, type PropsWithChildren } from 'react';
2+
import Log from '@deephaven/log';
3+
import { PersistentStateProvider } from './PersistentStateContext';
4+
import type { PanelProps } from './DashboardPlugin';
5+
6+
const log = Log.module('DashboardPanelWrapper');
27

38
export function DashboardPanelWrapper({
9+
glContainer,
410
children,
5-
}: PropsWithChildren<object>): JSX.Element {
6-
// eslint-disable-next-line react/jsx-no-useless-fragment
7-
return <>{children}</>;
11+
}: PropsWithChildren<PanelProps>): JSX.Element {
12+
const handleDataChange = useCallback(
13+
(data: unknown) => {
14+
glContainer.setPersistedState(data);
15+
},
16+
[glContainer]
17+
);
18+
19+
const { persistedState } = glContainer.getConfig();
20+
21+
// Use a state initializer so we can warn once if the persisted state is invalid
22+
const [initialPersistedState] = useState(() => {
23+
if (persistedState != null && !Array.isArray(persistedState)) {
24+
log.warn(
25+
`Persisted state is type ${typeof persistedState}. Expected array. Setting to empty array.`
26+
);
27+
return [];
28+
}
29+
return persistedState ?? [];
30+
});
31+
32+
return (
33+
<PersistentStateProvider
34+
initialState={initialPersistedState}
35+
onChange={handleDataChange}
36+
>
37+
{children}
38+
</PersistentStateProvider>
39+
);
840
}
941

1042
export default DashboardPanelWrapper;

packages/golden-layout/src/container/ItemContainer.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,10 @@ export default class ItemContainer<
2626
tab?: Tab;
2727

2828
// This type is to make TS happy and allow ReactComponentConfig passed to container generic
29-
_config: C & { componentState: Record<string, unknown> };
29+
_config: C & {
30+
componentState: Record<string, unknown>;
31+
persistedState?: unknown;
32+
};
3033

3134
isHidden = false;
3235

@@ -192,21 +195,23 @@ export default class ItemContainer<
192195
}
193196

194197
/**
195-
* Merges the provided state into the current one
198+
* Notifies the layout manager of a stateupdate
196199
*
197200
* @param state
198201
*/
199-
extendState(state: string) {
200-
this.setState($.extend(true, this.getState(), state));
202+
setState(state: Record<string, unknown>) {
203+
this._config.componentState = state;
204+
this.parent.emitBubblingEvent('stateChanged');
201205
}
202206

203207
/**
204-
* Notifies the layout manager of a stateupdate
208+
* Sets persisted state for functional components
209+
* which do not have lifecycle methods to hook into.
205210
*
206-
* @param state
211+
* @param state The state to persist
207212
*/
208-
setState(state: Record<string, unknown>) {
209-
this._config.componentState = state;
213+
setPersistedState(state: unknown) {
214+
this._config.persistedState = state;
210215
this.parent.emitBubblingEvent('stateChanged');
211216
}
212217

packages/golden-layout/src/utils/ReactComponentHandler.tsx

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React from 'react';
1+
import React, { Component } from 'react';
22
import ReactDOM from 'react-dom';
33
import $ from 'jquery';
44
import type ItemContainer from '../container/ItemContainer';
@@ -21,16 +21,22 @@ export type GLPanelProps = {
2121
export default class ReactComponentHandler {
2222
private _container: ItemContainer<ReactComponentConfig>;
2323

24-
private _reactComponent: React.Component | null = null;
24+
private _reactComponent: React.ReactInstance | null = null;
2525
private _portalComponent: React.ReactPortal | null = null;
26-
private _originalComponentWillUpdate: Function | null = null;
26+
private _originalComponentWillUpdate:
27+
| ((
28+
nextProps: Readonly<any>,
29+
nextState: Readonly<{}>,
30+
nextContext: any
31+
) => void)
32+
| undefined = undefined;
2733
private _initialState: unknown;
2834
private _reactClass: React.ComponentClass;
2935

3036
constructor(container: ItemContainer<ReactComponentConfig>, state?: unknown) {
3137
this._reactComponent = null;
3238
this._portalComponent = null;
33-
this._originalComponentWillUpdate = null;
39+
this._originalComponentWillUpdate = undefined;
3440
this._container = container;
3541
this._initialState = state;
3642
this._reactClass = this._getReactClass();
@@ -87,18 +93,22 @@ export default class ReactComponentHandler {
8793
*
8894
* @param component The component instance created by the `ReactDOM.render` call in the `_render` method.
8995
*/
90-
_gotReactComponent(component: React.Component) {
96+
_gotReactComponent(component: React.ReactInstance) {
9197
if (!component) {
9298
return;
9399
}
94100

95101
this._reactComponent = component;
96-
this._originalComponentWillUpdate =
97-
this._reactComponent.componentWillUpdate || function () {};
98-
this._reactComponent.componentWillUpdate = this._onUpdate.bind(this);
99-
const state = this._container.getState();
100-
if (state) {
101-
this._reactComponent.setState(state);
102+
// Class components manipulate the lifecycle to hook into state changes
103+
// Functional components can save data with the usePersistentState hook
104+
if (this._reactComponent instanceof Component) {
105+
this._originalComponentWillUpdate =
106+
this._reactComponent.componentWillUpdate;
107+
this._reactComponent.componentWillUpdate = this._onUpdate.bind(this);
108+
const state = this._container.getState();
109+
if (state) {
110+
this._reactComponent.setState(state);
111+
}
102112
}
103113
}
104114

@@ -118,12 +128,13 @@ export default class ReactComponentHandler {
118128
* Hooks into React's state management and applies the componentstate
119129
* to GoldenLayout
120130
*/
121-
_onUpdate(nextProps: unknown, nextState: Record<string, unknown>) {
131+
_onUpdate(nextProps: Readonly<unknown>, nextState: Record<string, unknown>) {
122132
this._container.setState(nextState);
123133
this._originalComponentWillUpdate?.call(
124134
this._reactComponent,
125135
nextProps,
126-
nextState
136+
nextState,
137+
undefined
127138
);
128139
}
129140

0 commit comments

Comments
 (0)