Skip to content

Commit f8f8d61

Browse files
authored
feat: Mount layout panels inside the main react tree (#1229)
This will be useful for any providers/contexts we want to use without needing to wrap each panel in the provider and synchronize them (works w/ redux, becomes painful with react providers like the spectrum provider) ### Tested interactions - Opening panels (tables and charts) by creating the object in a query - Creating a different panel w/ the same name as an existing panel so the panel contents get replaced - Reloading the page and checking layout persisted - Rearranging panels - Resizing panels - Opening notebook in preview mode/regular mode - Promoting notebooks from file explorer and by double clicking the preview tab header - Maximize/minimize stacks (groups of panels) - Running code from notebooks - Opening panels from global panels menu - Opening panels from console panels menu - Opening/focusing panel from pill button in console after creating - Double clicking on the pill button in the console after creating a table/chart - Linker
1 parent 600ecd5 commit f8f8d61

5 files changed

Lines changed: 99 additions & 24 deletions

File tree

packages/dashboard/src/DashboardLayout.tsx

Lines changed: 18 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,10 @@ import type {
1212
ItemConfigType,
1313
ReactComponentConfig,
1414
} from '@deephaven/golden-layout';
15-
import { ApiContext, useApi } from '@deephaven/jsapi-bootstrap';
1615
import Log from '@deephaven/log';
1716
import { usePrevious } from '@deephaven/react-hooks';
1817
import { RootState } from '@deephaven/redux';
19-
import { Provider, useDispatch, useSelector, useStore } from 'react-redux';
18+
import { useDispatch, useSelector } from 'react-redux';
2019
import PanelManager, { ClosedPanels } from './PanelManager';
2120
import PanelErrorBoundary from './PanelErrorBoundary';
2221
import LayoutUtils from './layout/LayoutUtils';
@@ -80,7 +79,6 @@ export function DashboardLayout({
8079
hydrate = hydrateDefault,
8180
dehydrate = dehydrateDefault,
8281
}: DashboardLayoutProps): JSX.Element {
83-
const defaultDh = useApi();
8482
const dispatch = useDispatch();
8583
const data =
8684
useSelector<RootState>(state => getDashboardData(state, id)) ??
@@ -93,10 +91,12 @@ export function DashboardLayout({
9391
(data as DashboardData)?.closed ?? []
9492
);
9593
const [isDashboardInitialized, setIsDashboardInitialized] = useState(false);
94+
const [layoutChildren, setLayoutChildren] = useState(
95+
layout.getReactChildren()
96+
);
9697

9798
const hydrateMap = useMemo(() => new Map(), []);
9899
const dehydrateMap = useMemo(() => new Map(), []);
99-
const store = useStore();
100100
const registerComponent = useCallback(
101101
(
102102
name: string,
@@ -120,21 +120,12 @@ export function DashboardLayout({
120120

121121
// Props supplied by GoldenLayout
122122
// eslint-disable-next-line react/prop-types
123-
const { dh, glContainer, glEventHub } = props;
123+
const { glContainer, glEventHub } = props;
124124
return (
125-
// Enterprise should be able to override the JSAPI
126-
// for each panel via the props
127-
<ApiContext.Provider value={dh ?? defaultDh}>
128-
<Provider store={store}>
129-
<PanelErrorBoundary
130-
glContainer={glContainer}
131-
glEventHub={glEventHub}
132-
>
133-
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
134-
<CType {...props} ref={ref} />
135-
</PanelErrorBoundary>
136-
</Provider>
137-
</ApiContext.Provider>
125+
<PanelErrorBoundary glContainer={glContainer} glEventHub={glEventHub}>
126+
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
127+
<CType {...props} ref={ref} />
128+
</PanelErrorBoundary>
138129
);
139130
}
140131

@@ -144,7 +135,7 @@ export function DashboardLayout({
144135
dehydrateMap.set(name, componentDehydrate);
145136
return cleanup;
146137
},
147-
[defaultDh, hydrate, dehydrate, hydrateMap, dehydrateMap, layout, store]
138+
[hydrate, dehydrate, hydrateMap, dehydrateMap, layout]
148139
);
149140
const hydrateComponent = useCallback(
150141
(name, props) => (hydrateMap.get(name) ?? FALLBACK_CALLBACK)(props, id),
@@ -209,6 +200,8 @@ export function DashboardLayout({
209200
setLastConfig(dehydratedLayoutConfig);
210201

211202
onLayoutChange(dehydratedLayoutConfig);
203+
204+
setLayoutChildren(layout.getReactChildren());
212205
}
213206
}, [
214207
dehydrateComponent,
@@ -257,6 +250,10 @@ export function DashboardLayout({
257250
item.element.addClass(cssClass);
258251
}, []);
259252

253+
const handleReactChildrenChange = useCallback(() => {
254+
setLayoutChildren(layout.getReactChildren());
255+
}, [layout]);
256+
260257
useListener(layout, 'stateChanged', handleLayoutStateChanged);
261258
useListener(layout, 'itemPickedUp', handleLayoutItemPickedUp);
262259
useListener(layout, 'itemDropped', handleLayoutItemDropped);
@@ -266,6 +263,7 @@ export function DashboardLayout({
266263
PanelEvent.TITLE_CHANGED,
267264
handleLayoutStateChanged
268265
);
266+
useListener(layout, 'reactChildrenChanged', handleReactChildrenChange);
269267

270268
const previousLayoutConfig = usePrevious(layoutConfig);
271269
useEffect(
@@ -305,6 +303,7 @@ export function DashboardLayout({
305303
return (
306304
<>
307305
{isDashboardEmpty && emptyDashboard}
306+
{layoutChildren}
308307
{React.Children.map(children, child =>
309308
child != null
310309
? React.cloneElement(child as ReactElement, {

packages/dashboard/src/DashboardPlugin.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import type {
1212
EventEmitter,
1313
Container,
1414
} from '@deephaven/golden-layout';
15-
import type { dh as DhType } from '@deephaven/jsapi-types';
1615
import PanelManager from './PanelManager';
1716

1817
/**
@@ -66,7 +65,6 @@ export function isWrappedComponent<
6665
}
6766

6867
export type PanelProps = {
69-
dh?: DhType;
7068
glContainer: Container;
7169
glEventHub: EventEmitter;
7270
};

packages/golden-layout/src/LayoutManager.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@ export default class LayoutManager extends EventEmitter {
102102
private _dragSources: DragSource[] = [];
103103
private _updatingColumnsResponsive = false;
104104
private _firstLoad = true;
105+
private _reactChildMap = new Map<string, React.ReactNode>();
106+
private _reactChildren: React.ReactNode = null;
105107

106108
width: number | null = null;
107109
height: number | null = null;
@@ -369,6 +371,47 @@ export default class LayoutManager extends EventEmitter {
369371
this.emit('initialised');
370372
}
371373

374+
/**
375+
* Adds a react child to the layout manager
376+
* @param id Unique panel id
377+
* @param element The React element
378+
*/
379+
addReactChild(id: string, element: React.ReactNode) {
380+
this._reactChildMap.set(id, element);
381+
this._reactChildren = [...this._reactChildMap.values()];
382+
this.emit('reactChildrenChanged');
383+
}
384+
385+
/**
386+
* Removes a react child from the layout manager
387+
* Only removes if the elements for the panelId has not been replaced by a different element
388+
* @param id Unique panel id
389+
* @param element The React element
390+
*/
391+
removeReactChild(id: string, element: React.ReactNode) {
392+
const mapElem = this._reactChildMap.get(id);
393+
if (mapElem === element) {
394+
// If an element was replaced it may be destroyed after the other is created
395+
// In that case, the new element would be removed
396+
// Make sure the element being removed is the current element associated with its id
397+
this._reactChildMap.delete(id);
398+
this._reactChildren = [...this._reactChildMap.values()];
399+
this.emit('reactChildrenChanged');
400+
}
401+
}
402+
403+
/**
404+
* Gets the react children in the layout
405+
*
406+
* Used in @deephaven/dashboard to mount the react elements
407+
* inside the app's React tree
408+
*
409+
* @returns The react children to mount for this layout manager
410+
*/
411+
getReactChildren() {
412+
return this._reactChildren;
413+
}
414+
372415
/**
373416
* Updates the layout managers size
374417
* @param width width in pixels

packages/golden-layout/src/utils/EventEmitter.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ class EventEmitter {
8888
*/
8989
unbind(eventName: string, callback?: Function, context?: unknown) {
9090
if (!this._mSubscriptions[eventName]) {
91-
throw new Error('No subscribtions to unsubscribe for event ' + eventName);
91+
throw new Error('No subscriptions to unsubscribe for event ' + eventName);
9292
}
9393

9494
let bUnbound = false;

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

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,14 @@ export default class ReactComponentHandler {
1515
private _container: ItemContainer<ReactComponentConfig>;
1616

1717
private _reactComponent: React.Component | null = null;
18+
private _portalComponent: React.ReactPortal | null = null;
1819
private _originalComponentWillUpdate: Function | null = null;
1920
private _initialState: unknown;
2021
private _reactClass: React.ComponentClass;
2122

2223
constructor(container: ItemContainer<ReactComponentConfig>, state?: unknown) {
2324
this._reactComponent = null;
25+
this._portalComponent = null;
2426
this._originalComponentWillUpdate = null;
2527
this._container = container;
2628
this._initialState = state;
@@ -29,14 +31,44 @@ export default class ReactComponentHandler {
2931
this._container.on('destroy', this._destroy, this);
3032
}
3133

34+
/**
35+
* Gets the unique key to use for the react component
36+
* @returns Unique key for the component
37+
*/
38+
_key(): string {
39+
const id = this._container._config.id;
40+
if (!id) {
41+
throw new Error('Cannot mount panel without id');
42+
}
43+
44+
// If addId is called multiple times, an element can have multiple IDs in golden-layout
45+
// We don't use it, but changing the type requires many changes and a separate PR
46+
if (Array.isArray(id)) {
47+
return id.join(',');
48+
}
49+
50+
return id;
51+
}
52+
3253
/**
3354
* Creates the react class and component and hydrates it with
3455
* the initial state - if one is present
3556
*
3657
* By default, react's getInitialState will be used
58+
*
59+
* Creates a portal so the non-react golden-layout code still works,
60+
* but also allows us to mount the React components in the app's tree
61+
* instead of separate sibling root trees
3762
*/
3863
_render() {
39-
ReactDOM.render(this._getReactComponent(), this._container.getElement()[0]);
64+
const key = this._key();
65+
this._portalComponent = ReactDOM.createPortal(
66+
this._getReactComponent(),
67+
this._container.getElement()[0],
68+
key
69+
);
70+
71+
this._container.layoutManager.addReactChild(key, this._portalComponent);
4072
}
4173

4274
/**
@@ -67,7 +99,10 @@ export default class ReactComponentHandler {
6799
* Removes the component from the DOM and thus invokes React's unmount lifecycle
68100
*/
69101
_destroy() {
70-
ReactDOM.unmountComponentAtNode(this._container.getElement()[0]);
102+
this._container.layoutManager.removeReactChild(
103+
this._key(),
104+
this._portalComponent
105+
);
71106
this._container.off('open', this._render, this);
72107
this._container.off('destroy', this._destroy, this);
73108
}

0 commit comments

Comments
 (0)