Skip to content

Commit

Permalink
feat: Mount layout panels inside the main react tree (#1229)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
mattrunyon authored May 17, 2023
1 parent 600ecd5 commit f8f8d61
Show file tree
Hide file tree
Showing 5 changed files with 99 additions and 24 deletions.
37 changes: 18 additions & 19 deletions packages/dashboard/src/DashboardLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,10 @@ import type {
ItemConfigType,
ReactComponentConfig,
} from '@deephaven/golden-layout';
import { ApiContext, useApi } from '@deephaven/jsapi-bootstrap';
import Log from '@deephaven/log';
import { usePrevious } from '@deephaven/react-hooks';
import { RootState } from '@deephaven/redux';
import { Provider, useDispatch, useSelector, useStore } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import PanelManager, { ClosedPanels } from './PanelManager';
import PanelErrorBoundary from './PanelErrorBoundary';
import LayoutUtils from './layout/LayoutUtils';
Expand Down Expand Up @@ -80,7 +79,6 @@ export function DashboardLayout({
hydrate = hydrateDefault,
dehydrate = dehydrateDefault,
}: DashboardLayoutProps): JSX.Element {
const defaultDh = useApi();
const dispatch = useDispatch();
const data =
useSelector<RootState>(state => getDashboardData(state, id)) ??
Expand All @@ -93,10 +91,12 @@ export function DashboardLayout({
(data as DashboardData)?.closed ?? []
);
const [isDashboardInitialized, setIsDashboardInitialized] = useState(false);
const [layoutChildren, setLayoutChildren] = useState(
layout.getReactChildren()
);

const hydrateMap = useMemo(() => new Map(), []);
const dehydrateMap = useMemo(() => new Map(), []);
const store = useStore();
const registerComponent = useCallback(
(
name: string,
Expand All @@ -120,21 +120,12 @@ export function DashboardLayout({

// Props supplied by GoldenLayout
// eslint-disable-next-line react/prop-types
const { dh, glContainer, glEventHub } = props;
const { glContainer, glEventHub } = props;
return (
// Enterprise should be able to override the JSAPI
// for each panel via the props
<ApiContext.Provider value={dh ?? defaultDh}>
<Provider store={store}>
<PanelErrorBoundary
glContainer={glContainer}
glEventHub={glEventHub}
>
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<CType {...props} ref={ref} />
</PanelErrorBoundary>
</Provider>
</ApiContext.Provider>
<PanelErrorBoundary glContainer={glContainer} glEventHub={glEventHub}>
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<CType {...props} ref={ref} />
</PanelErrorBoundary>
);
}

Expand All @@ -144,7 +135,7 @@ export function DashboardLayout({
dehydrateMap.set(name, componentDehydrate);
return cleanup;
},
[defaultDh, hydrate, dehydrate, hydrateMap, dehydrateMap, layout, store]
[hydrate, dehydrate, hydrateMap, dehydrateMap, layout]
);
const hydrateComponent = useCallback(
(name, props) => (hydrateMap.get(name) ?? FALLBACK_CALLBACK)(props, id),
Expand Down Expand Up @@ -209,6 +200,8 @@ export function DashboardLayout({
setLastConfig(dehydratedLayoutConfig);

onLayoutChange(dehydratedLayoutConfig);

setLayoutChildren(layout.getReactChildren());
}
}, [
dehydrateComponent,
Expand Down Expand Up @@ -257,6 +250,10 @@ export function DashboardLayout({
item.element.addClass(cssClass);
}, []);

const handleReactChildrenChange = useCallback(() => {
setLayoutChildren(layout.getReactChildren());
}, [layout]);

useListener(layout, 'stateChanged', handleLayoutStateChanged);
useListener(layout, 'itemPickedUp', handleLayoutItemPickedUp);
useListener(layout, 'itemDropped', handleLayoutItemDropped);
Expand All @@ -266,6 +263,7 @@ export function DashboardLayout({
PanelEvent.TITLE_CHANGED,
handleLayoutStateChanged
);
useListener(layout, 'reactChildrenChanged', handleReactChildrenChange);

const previousLayoutConfig = usePrevious(layoutConfig);
useEffect(
Expand Down Expand Up @@ -305,6 +303,7 @@ export function DashboardLayout({
return (
<>
{isDashboardEmpty && emptyDashboard}
{layoutChildren}
{React.Children.map(children, child =>
child != null
? React.cloneElement(child as ReactElement, {
Expand Down
2 changes: 0 additions & 2 deletions packages/dashboard/src/DashboardPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import type {
EventEmitter,
Container,
} from '@deephaven/golden-layout';
import type { dh as DhType } from '@deephaven/jsapi-types';
import PanelManager from './PanelManager';

/**
Expand Down Expand Up @@ -66,7 +65,6 @@ export function isWrappedComponent<
}

export type PanelProps = {
dh?: DhType;
glContainer: Container;
glEventHub: EventEmitter;
};
Expand Down
43 changes: 43 additions & 0 deletions packages/golden-layout/src/LayoutManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ export default class LayoutManager extends EventEmitter {
private _dragSources: DragSource[] = [];
private _updatingColumnsResponsive = false;
private _firstLoad = true;
private _reactChildMap = new Map<string, React.ReactNode>();
private _reactChildren: React.ReactNode = null;

width: number | null = null;
height: number | null = null;
Expand Down Expand Up @@ -369,6 +371,47 @@ export default class LayoutManager extends EventEmitter {
this.emit('initialised');
}

/**
* Adds a react child to the layout manager
* @param id Unique panel id
* @param element The React element
*/
addReactChild(id: string, element: React.ReactNode) {
this._reactChildMap.set(id, element);
this._reactChildren = [...this._reactChildMap.values()];
this.emit('reactChildrenChanged');
}

/**
* Removes a react child from the layout manager
* Only removes if the elements for the panelId has not been replaced by a different element
* @param id Unique panel id
* @param element The React element
*/
removeReactChild(id: string, element: React.ReactNode) {
const mapElem = this._reactChildMap.get(id);
if (mapElem === element) {
// If an element was replaced it may be destroyed after the other is created
// In that case, the new element would be removed
// Make sure the element being removed is the current element associated with its id
this._reactChildMap.delete(id);
this._reactChildren = [...this._reactChildMap.values()];
this.emit('reactChildrenChanged');
}
}

/**
* Gets the react children in the layout
*
* Used in @deephaven/dashboard to mount the react elements
* inside the app's React tree
*
* @returns The react children to mount for this layout manager
*/
getReactChildren() {
return this._reactChildren;
}

/**
* Updates the layout managers size
* @param width width in pixels
Expand Down
2 changes: 1 addition & 1 deletion packages/golden-layout/src/utils/EventEmitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ class EventEmitter {
*/
unbind(eventName: string, callback?: Function, context?: unknown) {
if (!this._mSubscriptions[eventName]) {
throw new Error('No subscribtions to unsubscribe for event ' + eventName);
throw new Error('No subscriptions to unsubscribe for event ' + eventName);
}

let bUnbound = false;
Expand Down
39 changes: 37 additions & 2 deletions packages/golden-layout/src/utils/ReactComponentHandler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,14 @@ export default class ReactComponentHandler {
private _container: ItemContainer<ReactComponentConfig>;

private _reactComponent: React.Component | null = null;
private _portalComponent: React.ReactPortal | null = null;
private _originalComponentWillUpdate: Function | null = null;
private _initialState: unknown;
private _reactClass: React.ComponentClass;

constructor(container: ItemContainer<ReactComponentConfig>, state?: unknown) {
this._reactComponent = null;
this._portalComponent = null;
this._originalComponentWillUpdate = null;
this._container = container;
this._initialState = state;
Expand All @@ -29,14 +31,44 @@ export default class ReactComponentHandler {
this._container.on('destroy', this._destroy, this);
}

/**
* Gets the unique key to use for the react component
* @returns Unique key for the component
*/
_key(): string {
const id = this._container._config.id;
if (!id) {
throw new Error('Cannot mount panel without id');
}

// If addId is called multiple times, an element can have multiple IDs in golden-layout
// We don't use it, but changing the type requires many changes and a separate PR
if (Array.isArray(id)) {
return id.join(',');
}

return id;
}

/**
* Creates the react class and component and hydrates it with
* the initial state - if one is present
*
* By default, react's getInitialState will be used
*
* Creates a portal so the non-react golden-layout code still works,
* but also allows us to mount the React components in the app's tree
* instead of separate sibling root trees
*/
_render() {
ReactDOM.render(this._getReactComponent(), this._container.getElement()[0]);
const key = this._key();
this._portalComponent = ReactDOM.createPortal(
this._getReactComponent(),
this._container.getElement()[0],
key
);

this._container.layoutManager.addReactChild(key, this._portalComponent);
}

/**
Expand Down Expand Up @@ -67,7 +99,10 @@ export default class ReactComponentHandler {
* Removes the component from the DOM and thus invokes React's unmount lifecycle
*/
_destroy() {
ReactDOM.unmountComponentAtNode(this._container.getElement()[0]);
this._container.layoutManager.removeReactChild(
this._key(),
this._portalComponent
);
this._container.off('open', this._render, this);
this._container.off('destroy', this._destroy, this);
}
Expand Down

0 comments on commit f8f8d61

Please sign in to comment.