Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ jest.mock('@deephaven/dashboard', () => ({
useLayoutManager: jest.fn(() => mockGoldenLayout),
useDashboardId: jest.fn(() => 'test'),
useDhId: jest.fn(() => ({})),
usePersistentState: jest.fn(initialValue => [initialValue, jest.fn()]),
}));

const MockIrisGrid: React.FC & jest.Mock = jest.fn(() => (
Expand Down
11 changes: 6 additions & 5 deletions packages/dashboard-core-plugins/src/GridWidgetPlugin.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import {
type WidgetComponentProps,
usePersistentState,
} from '@deephaven/plugin';
import { type WidgetComponentProps } from '@deephaven/plugin';
import { type dh as DhType } from '@deephaven/jsapi-types';
import {
type DehydratedGridState,
Expand All @@ -17,7 +14,11 @@ import {
import { useSelector } from 'react-redux';
import { getSettings, type RootState } from '@deephaven/redux';
import { LoadingOverlay } from '@deephaven/components';
import { useLayoutManager, useListener } from '@deephaven/dashboard';
import {
useLayoutManager,
useListener,
usePersistentState,
} from '@deephaven/dashboard';
import { assertNotNull, getErrorMessage } from '@deephaven/utils';
import { useApi } from '@deephaven/jsapi-bootstrap';
import { type GridRange, type GridState } from '@deephaven/grid';
Expand Down
3 changes: 2 additions & 1 deletion packages/dashboard-core-plugins/src/useTablePlugin.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { usePersistentState, type TablePluginElement } from '@deephaven/plugin';
import { usePersistentState } from '@deephaven/dashboard';
import { type TablePluginElement } from '@deephaven/plugin';
import {
type InputFilter,
type IrisGridModel,
Expand Down
108 changes: 106 additions & 2 deletions packages/dashboard/src/Dashboard.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,22 @@
import React from 'react';
import { render, type RenderResult } from '@testing-library/react';
import {
act,
render,
type RenderResult,
screen,
waitFor,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ApiContext } from '@deephaven/jsapi-bootstrap';
import { type LayoutManager } from '@deephaven/golden-layout';
import Dashboard, { type DashboardProps } from './Dashboard';
import { usePersistentState } from './usePersistentState';
import { useDashboardPanel } from './layout';
import {
assertIsDashboardPluginProps,
type DashboardPluginComponentProps,
} from './DashboardPlugin';
import PanelEvent from './PanelEvent';

const mockDispatch = jest.fn();
jest.mock('react-redux', () => ({
Expand All @@ -18,7 +33,7 @@ function makeDashboard({
layoutConfig,
layoutSettings,
onGoldenLayoutChange,
onLayoutConfigChange,
onLayoutConfigChange = jest.fn(),
}: DashboardProps = {}): RenderResult {
return render(
<ApiContext.Provider value={dh}>
Expand All @@ -39,3 +54,92 @@ function makeDashboard({
it('mounts and unmounts properly', () => {
makeDashboard();
});

function TestComponent() {
const [state, setState] = usePersistentState('initial', {
type: 'test',
version: 1,
});

return (
<button
type="button"
onClick={() => {
setState('updated');
}}
>
{state}
</button>
);
}

TestComponent.displayName = 'TestComponent';

function TestPlugin(props: Partial<DashboardPluginComponentProps>) {
assertIsDashboardPluginProps(props);

useDashboardPanel({
componentName: TestComponent.displayName,
component: TestComponent,
supportedTypes: ['test'],
dashboardProps: props,
});
return null;
}

it('saves state with usePersistentState hook', async () => {
const user = userEvent.setup();
const onLayoutConfigChange = jest.fn();
let gl: LayoutManager | null = null;
const onGoldenLayoutChange = (newGl: LayoutManager) => {
gl = newGl;
};
const { unmount } = makeDashboard({
onGoldenLayoutChange,
onLayoutConfigChange,
children: <TestPlugin />,
});

act(() =>
gl!.eventHub.emit(PanelEvent.OPEN, {
widget: { type: 'test' },
})
);
expect(screen.getByText('initial')).toBeInTheDocument();

const waitRAF = () =>
act(
() =>
new Promise(resolve => {
requestAnimationFrame(resolve);
})
);

// Golden Layout throttles stateChanged events to once per RAF
// The test is running too fast and getting batched into one RAF
// so we need to wait for the initial stateChanged to fire
await waitRAF();

onLayoutConfigChange.mockClear();
await user.click(screen.getByText('initial'));
await waitFor(async () =>
expect(await screen.findByText('updated')).toBeInTheDocument()
);

// Need to wait here as well because the stateChanged event fires on the next frame
// which seems to happen after the unmount without this wait
await waitRAF();

act(() => unmount());

expect(onLayoutConfigChange).toHaveBeenCalledTimes(1);

// Remount and verify state is restored
makeDashboard({
layoutConfig: onLayoutConfigChange.mock.calls[0][0],
onLayoutConfigChange,
children: <TestPlugin />,
});

await waitFor(() => expect(screen.getByText('updated')).toBeInTheDocument());
});
23 changes: 15 additions & 8 deletions packages/dashboard/src/DashboardLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ type DashboardLayoutProps = React.PropsWithChildren<{
emptyDashboard?: React.ReactNode;

/** Component to wrap each panel with */
panelWrapper?: ComponentType<React.PropsWithChildren<DehydratedPanelProps>>;
panelWrapper?: ComponentType<React.PropsWithChildren<PanelProps>>;
}>;

/**
Expand All @@ -89,7 +89,7 @@ export function DashboardLayout({
onLayoutInitialized = DEFAULT_CALLBACK,
hydrate = hydrateDefault,
dehydrate = dehydrateDefault,
panelWrapper = DashboardPanelWrapper,
panelWrapper,
}: DashboardLayoutProps): JSX.Element {
const dispatch = useDispatch();
const data =
Expand Down Expand Up @@ -141,22 +141,29 @@ export function DashboardLayout({
*/
const hasRef = canHaveRef(CType);

const innerElem = hasRef ? (
// eslint-disable-next-line react/jsx-props-no-spreading
<CType {...props} ref={ref} />
) : (
// eslint-disable-next-line react/jsx-props-no-spreading
<CType {...props} />
);

// Props supplied by GoldenLayout
const { glContainer, glEventHub } = props;
const panelId = LayoutUtils.getIdFromContainer(glContainer);
return (
<PanelErrorBoundary glContainer={glContainer} glEventHub={glEventHub}>
<PanelIdContext.Provider value={panelId as string | null}>
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<PanelWrapperType {...props}>
{hasRef ? (
// eslint-disable-next-line react/jsx-props-no-spreading
<CType {...props} ref={ref} />
<DashboardPanelWrapper {...props}>
{PanelWrapperType == null ? (
innerElem
) : (
// eslint-disable-next-line react/jsx-props-no-spreading
<CType {...props} />
<PanelWrapperType {...props}>{innerElem}</PanelWrapperType>
)}
</PanelWrapperType>
</DashboardPanelWrapper>
</PanelIdContext.Provider>
</PanelErrorBoundary>
);
Expand Down
40 changes: 36 additions & 4 deletions packages/dashboard/src/DashboardPanelWrapper.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,42 @@
import React, { type PropsWithChildren } from 'react';
import { useCallback, useState, type PropsWithChildren } from 'react';
import Log from '@deephaven/log';
import { PersistentStateProvider } from './PersistentStateContext';
import type { PanelProps } from './DashboardPlugin';

const log = Log.module('DashboardPanelWrapper');

export function DashboardPanelWrapper({
glContainer,
children,
}: PropsWithChildren<object>): JSX.Element {
// eslint-disable-next-line react/jsx-no-useless-fragment
return <>{children}</>;
}: PropsWithChildren<PanelProps>): JSX.Element {
const handleDataChange = useCallback(
(data: unknown) => {
glContainer.setPersistedState(data);
},
[glContainer]
);

const { persistedState } = glContainer.getConfig();

// Use a state initializer so we can warn once if the persisted state is invalid
const [initialPersistedState] = useState(() => {
if (persistedState != null && !Array.isArray(persistedState)) {
log.warn(
`Persisted state is type ${typeof persistedState}. Expected array. Setting to empty array.`
);
return [];
}
return persistedState ?? [];
});

return (
<PersistentStateProvider
initialState={initialPersistedState}
onChange={handleDataChange}
>
{children}
</PersistentStateProvider>
);
}

export default DashboardPanelWrapper;
Loading
Loading