Repository: deephaven/deephaven-plugins (language: python)

Issue:
ui.dashboard - make dashboards have options like locking them from being re-arranged, or not saving dashboard_override changes so that browser refreshes reset local state
- make dashboards have options like locking them from being re-arranged, or not saving dashboard_override changes so that browser refreshes reset local state

ui.dashboard spec: https://github.com/deephaven/deephaven-plugins/blob/main/plugins/ui/DESIGN.md#uidashboard
- [ ] root (just displaying content and opening a dashboard)
- [ ] label
- [ ] description
- [ ] background_color
- [ ] filters
- [ ] links
- [ ] settings
- [ ] on_focus, on_blur, on_hide, on_show, on_open, on_close
ui.panel - Define a panel that contains other components and options
- [x] spec - Spec out ui.panel
- [x] children - Specify the content of the ui.panel and display it

Spec is defined: https://github.com/deephaven/deephaven-plugins/blob/main/plugins/ui/DESIGN.md#uipanel
- [ ] label
- [ ] description
- [ ] height, width
- [ ] background_color
- [ ] tab_background_color
- [ ] is_closable
- [ ] on_focus, on_blur, on_hide, on_show, on_open, on_close


Discussion / hints:

Fix patch:
diff --git a/package-lock.json b/package-lock.json
index 0ef179932..60220235b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -31237,6 +31237,7 @@
         "@deephaven/icons": "^0.60.0",
         "@deephaven/iris-grid": "^0.60.0",
         "@deephaven/jsapi-bootstrap": "^0.60.0",
+        "@deephaven/jsapi-components": "^0.60.0",
         "@deephaven/jsapi-types": "^0.60.0",
         "@deephaven/log": "^0.60.0",
         "@deephaven/plugin": "^0.60.0",
@@ -34534,6 +34535,7 @@
         "@deephaven/icons": "^0.60.0",
         "@deephaven/iris-grid": "^0.60.0",
         "@deephaven/jsapi-bootstrap": "^0.60.0",
+        "@deephaven/jsapi-components": "^0.60.0",
         "@deephaven/jsapi-types": "^0.60.0",
         "@deephaven/log": "^0.60.0",
         "@deephaven/plugin": "^0.60.0",
diff --git a/plugins/ui/src/deephaven/ui/__init__.py b/plugins/ui/src/deephaven/ui/__init__.py
index e5f10fb93..936adbbcf 100644
--- a/plugins/ui/src/deephaven/ui/__init__.py
+++ b/plugins/ui/src/deephaven/ui/__init__.py
@@ -16,4 +16,5 @@
 class UIRegistration(Registration):
     @classmethod
     def register_into(cls, callback: Callback) -> None:
+        callback.register(DashboardType)
         callback.register(ElementType)
diff --git a/plugins/ui/src/deephaven/ui/components/__init__.py b/plugins/ui/src/deephaven/ui/components/__init__.py
index e5db0b60a..95b918345 100644
--- a/plugins/ui/src/deephaven/ui/components/__init__.py
+++ b/plugins/ui/src/deephaven/ui/components/__init__.py
@@ -4,6 +4,11 @@
 from .panel import panel
 from .spectrum import *
 from .table import table
+from .dashboard import dashboard
+from .row import row
+from .column import column
+from .stack import stack
+
 from . import html
 
 
@@ -12,9 +17,11 @@
     "button",
     "button_group",
     "checkbox",
+    "column",
     "component",
     "content",
     "contextual_help",
+    "dashboard",
     "flex",
     "form",
     "fragment",
@@ -28,8 +35,10 @@
     "item",
     "panel",
     "range_slider",
+    "row",
     "slider",
     "spectrum_element",
+    "stack",
     "switch",
     "table",
     "tab_list",
diff --git a/plugins/ui/src/deephaven/ui/components/column.py b/plugins/ui/src/deephaven/ui/components/column.py
new file mode 100644
index 000000000..d976523c2
--- /dev/null
+++ b/plugins/ui/src/deephaven/ui/components/column.py
@@ -0,0 +1,18 @@
+from __future__ import annotations
+
+from typing import Any
+from ..elements import BaseElement
+
+
+def column(*children: Any, width: float | None = None, **kwargs: Any):
+    """
+    A column is a container that can be used to group elements.
+    Each element will be placed below its prior sibling.
+
+    Args:
+        children: Elements to render in the column.
+        width: The percent width of the column relative to other children of its parent. If not provided, the column will be sized automatically.
+    """
+    return BaseElement(
+        "deephaven.ui.components.Column", *children, width=width, **kwargs
+    )
diff --git a/plugins/ui/src/deephaven/ui/components/dashboard.py b/plugins/ui/src/deephaven/ui/components/dashboard.py
new file mode 100644
index 000000000..a6c4c0595
--- /dev/null
+++ b/plugins/ui/src/deephaven/ui/components/dashboard.py
@@ -0,0 +1,15 @@
+from __future__ import annotations
+
+from typing import Any
+from ..elements import DashboardElement, FunctionElement
+
+
+def dashboard(element: FunctionElement):
+    """
+    A dashboard is the container for an entire layout.
+
+    Args:
+        element: Element to render as the dashboard.
+                 The element should render a layout that contains 1 root column or row.
+    """
+    return DashboardElement(element)
diff --git a/plugins/ui/src/deephaven/ui/components/row.py b/plugins/ui/src/deephaven/ui/components/row.py
new file mode 100644
index 000000000..46700867b
--- /dev/null
+++ b/plugins/ui/src/deephaven/ui/components/row.py
@@ -0,0 +1,18 @@
+from __future__ import annotations
+
+from typing import Any
+from ..elements import BaseElement
+
+
+def row(*children: Any, height: float | None = None, **kwargs: Any):
+    """
+    A row is a container that can be used to group elements.
+    Each element will be placed to the right of its prior sibling.
+
+    Args:
+        children: Elements to render in the row.
+        height: The percent height of the row relative to other children of its parent. If not provided, the row will be sized automatically.
+    """
+    return BaseElement(
+        "deephaven.ui.components.Row", *children, height=height, **kwargs
+    )
diff --git a/plugins/ui/src/deephaven/ui/components/stack.py b/plugins/ui/src/deephaven/ui/components/stack.py
new file mode 100644
index 000000000..9687145e9
--- /dev/null
+++ b/plugins/ui/src/deephaven/ui/components/stack.py
@@ -0,0 +1,30 @@
+from __future__ import annotations
+
+from typing import Any
+from ..elements import BaseElement
+
+
+def stack(
+    *children: Any,
+    height: float | None = None,
+    width: float | None = None,
+    activeItemIndex: int | None = None,
+    **kwargs: Any,
+):
+    """
+    A stack is a container that can be used to group elements which creates a set of tabs.
+    Each element will get a tab and only one element can be visible at a time.
+
+    Args:
+        children: Elements to render in the row.
+        height: The percent height of the stack relative to other children of its parent. If not provided, the stack will be sized automatically.
+        width: The percent width of the stack relative to other children of its parent. If not provided, the stack will be sized automatically.
+    """
+    return BaseElement(
+        "deephaven.ui.components.Stack",
+        *children,
+        height=height,
+        width=width,
+        activeItemIndex=activeItemIndex,
+        **kwargs,
+    )
diff --git a/plugins/ui/src/deephaven/ui/elements/DashboardElement.py b/plugins/ui/src/deephaven/ui/elements/DashboardElement.py
new file mode 100644
index 000000000..af8310eb1
--- /dev/null
+++ b/plugins/ui/src/deephaven/ui/elements/DashboardElement.py
@@ -0,0 +1,12 @@
+from __future__ import annotations
+
+import logging
+from .BaseElement import BaseElement
+from .FunctionElement import FunctionElement
+
+logger = logging.getLogger(__name__)
+
+
+class DashboardElement(BaseElement):
+    def __init__(self, element: FunctionElement):
+        super().__init__("deephaven.ui.components.Dashboard", element)
diff --git a/plugins/ui/src/deephaven/ui/elements/__init__.py b/plugins/ui/src/deephaven/ui/elements/__init__.py
index 9003ffad9..01aa1c109 100644
--- a/plugins/ui/src/deephaven/ui/elements/__init__.py
+++ b/plugins/ui/src/deephaven/ui/elements/__init__.py
@@ -1,6 +1,14 @@
 from .Element import Element, PropsType
 from .BaseElement import BaseElement
+from .DashboardElement import DashboardElement
 from .FunctionElement import FunctionElement
 from .UITable import UITable
 
-__all__ = ["BaseElement", "Element", "FunctionElement", "PropsType", "UITable"]
+__all__ = [
+    "BaseElement",
+    "DashboardElement",
+    "Element",
+    "FunctionElement",
+    "PropsType",
+    "UITable",
+]
diff --git a/plugins/ui/src/deephaven/ui/object_types/DashboardType.py b/plugins/ui/src/deephaven/ui/object_types/DashboardType.py
new file mode 100644
index 000000000..371e7d5e7
--- /dev/null
+++ b/plugins/ui/src/deephaven/ui/object_types/DashboardType.py
@@ -0,0 +1,17 @@
+from ..elements import DashboardElement
+from .._internal import get_component_name
+from .ElementMessageStream import ElementMessageStream
+from .ElementType import ElementType
+
+
+class DashboardType(ElementType):
+    """
+    Defines the Dashboard type for the Deephaven plugin system.
+    """
+
+    @property
+    def name(self) -> str:
+        return "deephaven.ui.Dashboard"
+
+    def is_type(self, obj: any) -> bool:
+        return isinstance(obj, DashboardElement)
diff --git a/plugins/ui/src/deephaven/ui/object_types/__init__.py b/plugins/ui/src/deephaven/ui/object_types/__init__.py
index accae10cb..68f88a560 100644
--- a/plugins/ui/src/deephaven/ui/object_types/__init__.py
+++ b/plugins/ui/src/deephaven/ui/object_types/__init__.py
@@ -1,2 +1,3 @@
+from .DashboardType import DashboardType
 from .ElementMessageStream import ElementMessageStream
 from .ElementType import ElementType
diff --git a/plugins/ui/src/js/__mocks__/@deephaven/dashboard.js b/plugins/ui/src/js/__mocks__/@deephaven/dashboard.js
new file mode 100644
index 000000000..c7bb10dc1
--- /dev/null
+++ b/plugins/ui/src/js/__mocks__/@deephaven/dashboard.js
@@ -0,0 +1,16 @@
+// Mock LayoutUtils, useListener, and PanelEvent from @deephaven/dashboard package
+const mockLayout = { root: { contentItems: [] }, eventHub: {} };
+
+const DashboardActual = jest.requireActual('@deephaven/dashboard');
+module.exports = {
+  ...DashboardActual,
+  LayoutUtils: {
+    getComponentName: jest.fn(),
+    openComponent: jest.fn(),
+    closeComponent: jest.fn(),
+  },
+  useLayoutManager: jest.fn(() => mockLayout),
+  useListener: jest.fn(),
+  __esModule: true,
+  default: jest.fn(),
+};
diff --git a/plugins/ui/src/js/package.json b/plugins/ui/src/js/package.json
index 44723b570..e140045ef 100644
--- a/plugins/ui/src/js/package.json
+++ b/plugins/ui/src/js/package.json
@@ -48,14 +48,15 @@
     "@deephaven/icons": "^0.60.0",
     "@deephaven/iris-grid": "^0.60.0",
     "@deephaven/jsapi-bootstrap": "^0.60.0",
+    "@deephaven/jsapi-components": "^0.60.0",
     "@deephaven/jsapi-types": "^0.60.0",
     "@deephaven/log": "^0.60.0",
     "@deephaven/plugin": "^0.60.0",
     "@deephaven/react-hooks": "^0.60.0",
     "@deephaven/utils": "^0.60.0",
     "@fortawesome/react-fontawesome": "^0.2.0",
-    "shortid": "^2.2.16",
-    "json-rpc-2.0": "^1.6.0"
+    "json-rpc-2.0": "^1.6.0",
+    "shortid": "^2.2.16"
   },
   "publishConfig": {
     "access": "public"
diff --git a/plugins/ui/src/js/src/DashboardPlugin.tsx b/plugins/ui/src/js/src/DashboardPlugin.tsx
index 383278f05..338ea88de 100644
--- a/plugins/ui/src/js/src/DashboardPlugin.tsx
+++ b/plugins/ui/src/js/src/DashboardPlugin.tsx
@@ -5,8 +5,11 @@ import {
   LayoutManagerContext,
   PanelEvent,
   useListener,
+  useDashboardPluginData,
+  emitCreateDashboard,
 } from '@deephaven/dashboard';
 import Log from '@deephaven/log';
+import { useConnection } from '@deephaven/jsapi-components';
 import { DeferredApiBootstrap } from '@deephaven/jsapi-bootstrap';
 import { Widget } from '@deephaven/jsapi-types';
 import type { VariableDefinition } from '@deephaven/jsapi-types';
@@ -16,37 +19,48 @@ import PortalPanel from './PortalPanel';
 import WidgetHandler from './WidgetHandler';
 
 const NAME_ELEMENT = 'deephaven.ui.Element';
+const DASHBOARD_ELEMENT = 'deephaven.ui.Dashboard';
 
 const log = Log.module('@deephaven/js-plugin-ui.DashboardPlugin');
 
+/**
+ * The data stored in redux when the user creates a ui.dashboard.
+ */
+interface DashboardPluginData {
+  type: string;
+  title: string;
+  id: string;
+  metadata: Record<string, unknown>;
+}
+
 export function DashboardPlugin({
   id,
   layout,
   registerComponent,
 }: DashboardPluginComponentProps): JSX.Element | null {
+  const connection = useConnection();
+  const [pluginData] = useDashboardPluginData(
+    id,
+    DASHBOARD_ELEMENT
+  ) as unknown as [DashboardPluginData];
+
   // Keep track of the widgets we've got opened.
   const [widgetMap, setWidgetMap] = useState<
     ReadonlyMap<string, WidgetWrapper>
   >(new Map());
-  const handlePanelOpen = useCallback(
+
+  const handleWidgetOpen = useCallback(
     ({
-      dragEvent,
       fetch,
-      metadata = {},
+      metadata,
       panelId: widgetId = shortid.generate(),
       widget,
     }: {
-      dragEvent?: React.DragEvent;
       fetch: () => Promise<Widget>;
-      metadata?: Record<string, unknown>;
+      metadata: Record<string, unknown>;
       panelId?: string;
       widget: VariableDefinition;
     }) => {
-      const { type } = widget;
-      if ((type as string) !== NAME_ELEMENT) {
-        // Only want to listen for Element panels trying to be opened
-        return;
-      }
       log.info('Opening widget with ID', widgetId, metadata);
       setWidgetMap(prevWidgetMap => {
         const newWidgetMap = new Map<string, WidgetWrapper>(prevWidgetMap);
@@ -70,6 +84,91 @@ export function DashboardPlugin({
     []
   );
 
+  const handleDashboardOpen = useCallback(
+    ({
+      widget,
+      metadata,
+    }: {
+      widget: VariableDefinition;
+      metadata: Record<string, unknown>;
+    }) => {
+      const { id: dashboardId, type, title = 'Untitled' } = widget;
+      if (dashboardId == null) {
+        log.error("Can't open dashboard without an ID", widget);
+        return;
+      }
+      log.debug('Emitting create dashboard event for', widget);
+      emitCreateDashboard(layout.eventHub, {
+        pluginId: DASHBOARD_ELEMENT,
+        title,
+        data: {
+          type,
+          title,
+          id: dashboardId,
+          metadata,
+        } satisfies DashboardPluginData,
+      });
+    },
+    [layout.eventHub]
+  );
+
+  const handlePanelOpen = useCallback(
+    ({
+      fetch,
+      panelId: widgetId = shortid.generate(),
+      widget,
+      metadata = {},
+    }: {
+      fetch: () => Promise<Widget>;
+      panelId?: string;
+      widget: VariableDefinition;
+      metadata: Record<string, unknown>;
+    }) => {
+      const { type } = widget;
+
+      switch (type) {
+        case NAME_ELEMENT: {
+          handleWidgetOpen({ fetch, panelId: widgetId, widget, metadata });
+          break;
+        }
+        case DASHBOARD_ELEMENT: {
+          handleDashboardOpen({ widget, metadata });
+          break;
+        }
+        default: {
+          log.error('Unknown widget type', type);
+        }
+      }
+    },
+    [handleDashboardOpen, handleWidgetOpen]
+  );
+
+  useEffect(
+    function loadDashboard() {
+      if (pluginData == null) {
+        return;
+      }
+
+      log.info('Loading dashboard', pluginData);
+
+      setWidgetMap(prevWidgetMap => {
+        const newWidgetMap = new Map<string, WidgetWrapper>(prevWidgetMap);
+        // We need to create a new definition object, otherwise the layout will think it's already open
+        // Can't use a spread operator because the widget definition uses property accessors
+
+        newWidgetMap.set(id, {
+          definition: pluginData,
+          fetch: () =>
+            connection.getObject(pluginData) as unknown as Promise<Widget>,
+          id,
+          metadata: {},
+        });
+        return newWidgetMap;
+      });
+    },
+    [connection, pluginData, id]
+  );
+
   const handlePanelClose = useCallback((panelId: string) => {
     setWidgetMap(prevWidgetMap => {
       if (!prevWidgetMap.has(panelId)) {
diff --git a/plugins/ui/src/js/src/DocumentUtils.tsx b/plugins/ui/src/js/src/DocumentUtils.tsx
index ab8c9c404..8d05e06a8 100644
--- a/plugins/ui/src/js/src/DocumentUtils.tsx
+++ b/plugins/ui/src/js/src/DocumentUtils.tsx
@@ -2,6 +2,7 @@ import React from 'react';
 import { WidgetDefinition } from '@deephaven/dashboard';
 import ReactPanel from './ReactPanel';
 import { MixedPanelsError, NoChildrenError } from './errors';
+import Dashboard from './layout/Dashboard';
 
 /**
  * Convert the children passed as the Document root to a valid root node, or throw if it's an invalid root configuration.
@@ -25,14 +26,26 @@ export function getRootChildren(
   if (childrenArray.length === 0) {
     throw new NoChildrenError('No children to render');
   }
-  const childPanelCount = childrenArray.filter(
+
+  const panelCount = childrenArray.filter(
     child => child?.type === ReactPanel
   ).length;
-  if (childPanelCount > 0 && childPanelCount !== childrenArray.length) {
-    throw new MixedPanelsError('Cannot mix panel and non-panel elements');
+
+  const dashboardCount = childrenArray.filter(
+    child => child?.type === Dashboard
+  ).length;
+
+  const nonLayoutCount = childrenArray.length - panelCount - dashboardCount;
+
+  if (nonLayoutCount > 0 && nonLayoutCount !== childrenArray.length) {
+    throw new MixedPanelsError('Cannot mix layout and non-layout elements');
+  }
+
+  if (panelCount > 0 && dashboardCount > 0) {
+    throw new MixedPanelsError('Cannot mix Panel and Dashboard elements');
   }
 
-  if (childPanelCount === 0) {
+  if (nonLayoutCount === childrenArray.length) {
     // Just wrap it in a panel
     return (
       <ReactPanel
@@ -44,7 +57,7 @@ export function getRootChildren(
     );
   }
 
-  // It's already got panels defined, just return it
+  // It's already got layout defined, just return it
   return children;
 }
 
diff --git a/plugins/ui/src/js/src/PanelUtils.ts b/plugins/ui/src/js/src/PanelUtils.ts
deleted file mode 100644
index 3cfbf17b9..000000000
--- a/plugins/ui/src/js/src/PanelUtils.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import React from 'react';
-import { ELEMENT_KEY, ElementNode, isElementNode } from './ElementUtils';
-
-export const PANEL_ELEMENT_NAME = 'deephaven.ui.components.Panel';
-
-export type PanelElementType = typeof PANEL_ELEMENT_NAME;
-
-export type ReactPanelProps = React.PropsWithChildren<{
-  /** Title of the panel */
-  title?: string;
-}>;
-
-/**
- * Describes a panel element that can be rendered in the UI.
- * Will be placed in the current dashboard, or within a user created dashboard if specified.
- */
-export type PanelElementNode = ElementNode<PanelElementType, ReactPanelProps>;
-
-/**
- * Check if an object is a PanelElementNode
- * @param obj Object to check
- * @returns True if the object is a PanelElementNode
- */
-export function isPanelElementNode(obj: unknown): obj is PanelElementNode {
-  return (
-    isElementNode(obj) &&
-    (obj as ElementNode)[ELEMENT_KEY] === PANEL_ELEMENT_NAME
-  );
-}
diff --git a/plugins/ui/src/js/src/ReactPanel.tsx b/plugins/ui/src/js/src/ReactPanel.tsx
index 25a35d4a6..57a2796c9 100644
--- a/plugins/ui/src/js/src/ReactPanel.tsx
+++ b/plugins/ui/src/js/src/ReactPanel.tsx
@@ -10,7 +10,8 @@ import {
 import Log from '@deephaven/log';
 import PortalPanel from './PortalPanel';
 import { useReactPanelManager } from './ReactPanelManager';
-import { ReactPanelProps } from './PanelUtils';
+import { ReactPanelProps } from './layout/LayoutUtils';
+import { useParentItem } from './layout/ParentItemContext';
 
 const log = Log.module('@deephaven/js-plugin-ui/ReactPanel');
 
@@ -25,6 +26,7 @@ function ReactPanel({ children, title }: ReactPanelProps) {
   const [element, setElement] = useState<HTMLElement>();
   const isPanelOpenRef = useRef(false);
   const openedMetadataRef = useRef<Record<string, unknown>>();
+  const parent = useParentItem();
 
   log.debug2('Rendering panel', panelId);
 
@@ -32,12 +34,12 @@ function ReactPanel({ children, title }: ReactPanelProps) {
     () => () => {
       if (isPanelOpenRef.current) {
         log.debug('Closing panel', panelId);
-        LayoutUtils.closeComponent(layoutManager.root, { id: panelId });
+        LayoutUtils.closeComponent(parent, { id: panelId });
         isPanelOpenRef.current = false;
         onClose(panelId);
       }
     },
-    [layoutManager, onClose, panelId]
+    [parent, onClose, panelId]
   );
 
   const handlePanelClosed = useCallback(
@@ -76,15 +78,14 @@ function ReactPanel({ children, title }: ReactPanelProps) {
         id: panelId,
       };
 
-      const { root } = layoutManager;
-      LayoutUtils.openComponent({ root, config });
+      LayoutUtils.openComponent({ root: parent, config });
       log.debug('Opened panel', panelId, config);
       isPanelOpenRef.current = true;
       openedMetadataRef.current = metadata;
 
       onOpen(panelId);
     }
-  }, [layoutManager, metadata, onOpen, panelId, title]);
+  }, [parent, metadata, onOpen, panelId, title]);
 
   return element ? ReactDOM.createPortal(children, element) : null;
 }
diff --git a/plugins/ui/src/js/src/WidgetUtils.tsx b/plugins/ui/src/js/src/WidgetUtils.tsx
index 17fde478a..d753fd6b9 100644
--- a/plugins/ui/src/js/src/WidgetUtils.tsx
+++ b/plugins/ui/src/js/src/WidgetUtils.tsx
@@ -14,9 +14,19 @@ import { isIconElementNode } from './IconElementUtils';
 import IconElementView from './IconElementView';
 import { isUITable } from './UITableUtils';
 import UITable from './UITable';
-import { isPanelElementNode } from './PanelUtils';
+import {
+  isColumnElementNode,
+  isDashboardElementNode,
+  isPanelElementNode,
+  isRowElementNode,
+  isStackElementNode,
+} from './layout/LayoutUtils';
 import ReactPanel from './ReactPanel';
 import ObjectView from './ObjectView';
+import Row from './layout/Row';
+import Stack from './layout/Stack';
+import Column from './layout/Column';
+import Dashboard from './layout/Dashboard';
 
 export function getComponentForElement(element: ElementNode): React.ReactNode {
   // Need to convert the children of the element if they are exported objects to an ObjectView
@@ -59,6 +69,18 @@ export function getComponentForElement(element: ElementNode): React.ReactNode {
     // eslint-disable-next-line react/jsx-no-useless-fragment
     return <>{newElement.props?.children}</>;
   }
+  if (isRowElementNode(newElement)) {
+    return <Row {...newElement.props} />;
+  }
+  if (isColumnElementNode(newElement)) {
+    return <Column {...newElement.props} />;
+  }
+  if (isStackElementNode(newElement)) {
+    return <Stack {...newElement.props} />;
+  }
+  if (isDashboardElementNode(newElement)) {
+    return <Dashboard {...newElement.props} />;
+  }
 
   return newElement.props?.children;
 }
diff --git a/plugins/ui/src/js/src/layout/Column.tsx b/plugins/ui/src/js/src/layout/Column.tsx
new file mode 100644
index 000000000..159ca8244
--- /dev/null
+++ b/plugins/ui/src/js/src/layout/Column.tsx
@@ -0,0 +1,38 @@
+import React, { useEffect, useMemo } from 'react';
+import { useLayoutManager } from '@deephaven/dashboard';
+import type { RowOrColumn } from '@deephaven/golden-layout';
+import type { ColumnElementProps } from './LayoutUtils';
+import { ParentItemContext, useParentItem } from './ParentItemContext';
+
+function Column({ children, width }: ColumnElementProps): JSX.Element | null {
+  const layoutManager = useLayoutManager();
+  const parent = useParentItem();
+
+  const column = useMemo(() => {
+    const newColumn = layoutManager.createContentItem(
+      {
+        type: 'column',
+        width,
+      },
+      parent
+    );
+
+    // The 3rd param prevents golden-layout from calling setSize
+    // until we've mounted all of the rows and columns
+    (parent as RowOrColumn).addChild(newColumn, undefined, true);
+
+    return newColumn;
+  }, [layoutManager, parent, width]);
+
+  useEffect(() => {
+    column.setSize();
+  }, [column]);
+
+  return (
+    <ParentItemContext.Provider value={column}>
+      {children}
+    </ParentItemContext.Provider>
+  );
+}
+
+export default Column;
diff --git a/plugins/ui/src/js/src/layout/Dashboard.tsx b/plugins/ui/src/js/src/layout/Dashboard.tsx
new file mode 100644
index 000000000..bf437842b
--- /dev/null
+++ b/plugins/ui/src/js/src/layout/Dashboard.tsx
@@ -0,0 +1,15 @@
+import React from 'react';
+import type { DashboardElementProps } from './LayoutUtils';
+import { ParentItemContext, useParentItem } from './ParentItemContext';
+
+function Dashboard({ children }: DashboardElementProps): JSX.Element | null {
+  const parent = useParentItem();
+
+  return (
+    <ParentItemContext.Provider value={parent}>
+      {children}
+    </ParentItemContext.Provider>
+  );
+}
+
+export default Dashboard;
diff --git a/plugins/ui/src/js/src/layout/LayoutUtils.ts b/plugins/ui/src/js/src/layout/LayoutUtils.ts
new file mode 100644
index 000000000..26c513b58
--- /dev/null
+++ b/plugins/ui/src/js/src/layout/LayoutUtils.ts
@@ -0,0 +1,133 @@
+import React from 'react';
+import type { RowOrColumn, Stack, Root } from '@deephaven/golden-layout';
+import { ELEMENT_KEY, ElementNode, isElementNode } from '../ElementUtils';
+
+export const PANEL_ELEMENT_NAME = 'deephaven.ui.components.Panel';
+export const ROW_ELEMENT_NAME = 'deephaven.ui.components.Row';
+export const COLUMN_ELEMENT_NAME = 'deephaven.ui.components.Column';
+export const STACK_ELEMENT_NAME = 'deephaven.ui.components.Stack';
+export const DASHBOARD_ELEMENT_NAME = 'deephaven.ui.components.Dashboard';
+
+export type PanelElementType = typeof PANEL_ELEMENT_NAME;
+export type RowElementType = typeof ROW_ELEMENT_NAME;
+export type ColumnElementType = typeof COLUMN_ELEMENT_NAME;
+export type StackElementType = typeof STACK_ELEMENT_NAME;
+export type DashboardElementType = typeof DASHBOARD_ELEMENT_NAME;
+
+export type GoldenLayoutParent = RowOrColumn | Stack | Root;
+
+export type ReactPanelProps = React.PropsWithChildren<{
+  /** Title of the panel */
+  title?: string;
+}>;
+
+/**
+ * Describes a panel element that can be rendered in the UI.
+ * Will be placed in the current dashboard, or within a user created dashboard if specified.
+ */
+export type PanelElementNode = ElementNode<PanelElementType, ReactPanelProps>;
+
+/**
+ * Check if an object is a PanelElementNode
+ * @param obj Object to check
+ * @returns True if the object is a PanelElementNode
+ */
+export function isPanelElementNode(obj: unknown): obj is PanelElementNode {
+  return (
+    isElementNode(obj) &&
+    (obj as ElementNode)[ELEMENT_KEY] === PANEL_ELEMENT_NAME
+  );
+}
+
+export type RowElementProps = React.PropsWithChildren<{
+  height?: number;
+}>;
+
+/**
+ * Describes a row element that can be rendered in the UI.
+ */
+export type RowElementNode = ElementNode<RowElementType, RowElementProps>;
+
+/**
+ * Check if an object is a RowElementNode
+ * @param obj Object to check
+ * @returns True if the object is a RowElementNode
+ */
+export function isRowElementNode(obj: unknown): obj is RowElementNode {
+  return (
+    isElementNode(obj) && (obj as ElementNode)[ELEMENT_KEY] === ROW_ELEMENT_NAME
+  );
+}
+
+export type ColumnElementProps = React.PropsWithChildren<{
+  width?: number;
+}>;
+
+/**
+ * Describes a column element that can be rendered in the UI.
+ */
+export type ColumnElementNode = ElementNode<
+  ColumnElementType,
+  ColumnElementProps
+>;
+
+/**
+ * Check if an object is a ColumnElementNode
+ * @param obj Object to check
+ * @returns True if the object is a ColumnElementNode
+ */
+export function isColumnElementNode(obj: unknown): obj is ColumnElementNode {
+  return (
+    isElementNode(obj) &&
+    (obj as ElementNode)[ELEMENT_KEY] === COLUMN_ELEMENT_NAME
+  );
+}
+
+export type StackElementProps = React.PropsWithChildren<{
+  height?: number;
+  width?: number;
+  activeItemIndex?: number;
+}>;
+
+/**
+ * Describes a stack element that can be rendered in the UI.
+ */
+export type StackElementNode = ElementNode<StackElementType, StackElementProps>;
+
+/**
+ * Check if an object is a StackElementNode
+ * @param obj Object to check
+ * @returns True if the object is a StackElementNode
+ */
+export function isStackElementNode(obj: unknown): obj is StackElementNode {
+  return (
+    isElementNode(obj) &&
+    (obj as ElementNode)[ELEMENT_KEY] === STACK_ELEMENT_NAME
+  );
+}
+
+export type DashboardElementProps = React.PropsWithChildren<
+  Record<string, never>
+>;
+
+/**
+ * Describes a dashboard element that can be rendered in the UI.
+ */
+export type DashboardElementNode = ElementNode<
+  DashboardElementType,
+  DashboardElementProps
+>;
+
+/**
+ * Check if an object is a DashboardElementNode
+ * @param obj Object to check
+ * @returns True if the object is a DashboardElementNode
+ */
+export function isDashboardElementNode(
+  obj: unknown
+): obj is DashboardElementNode {
+  return (
+    isElementNode(obj) &&
+    (obj as ElementNode)[ELEMENT_KEY] === DASHBOARD_ELEMENT_NAME
+  );
+}
diff --git a/plugins/ui/src/js/src/layout/ParentItemContext.ts b/plugins/ui/src/js/src/layout/ParentItemContext.ts
new file mode 100644
index 000000000..78bef44d8
--- /dev/null
+++ b/plugins/ui/src/js/src/layout/ParentItemContext.ts
@@ -0,0 +1,18 @@
+import { createContext, useContext, useMemo } from 'react';
+import { useLayoutManager } from '@deephaven/dashboard';
+import type { ContentItem } from '@deephaven/golden-layout';
+
+export const ParentItemContext = createContext<ContentItem | null>(null);
+
+export function useParentItem() {
+  const layoutManager = useLayoutManager();
+  const parentContextItem = useContext(ParentItemContext);
+  const parentItem = useMemo(
+    () =>
+      parentContextItem ??
+      layoutManager.root.contentItems[0] ??
+      layoutManager.root,
+    [layoutManager.root, parentContextItem]
+  );
+  return parentItem;
+}
diff --git a/plugins/ui/src/js/src/layout/Row.tsx b/plugins/ui/src/js/src/layout/Row.tsx
new file mode 100644
index 000000000..8ba4b969e
--- /dev/null
+++ b/plugins/ui/src/js/src/layout/Row.tsx
@@ -0,0 +1,37 @@
+import React, { useEffect, useMemo } from 'react';
+import { useLayoutManager } from '@deephaven/dashboard';
+import type { RowOrColumn } from '@deephaven/golden-layout';
+import type { RowElementProps } from './LayoutUtils';
+import { ParentItemContext, useParentItem } from './ParentItemContext';
+
+function Row({ children, height }: RowElementProps): JSX.Element | null {
+  const layoutManager = useLayoutManager();
+  const parent = useParentItem();
+  const row = useMemo(() => {
+    const newRow = layoutManager.createContentItem(
+      {
+        type: 'row',
+        height,
+      },
+      parent
+    );
+
+    // The 3rd param prevents golden-layout from calling setSize
+    // until we've mounted all of the rows and columns
+    (parent as RowOrColumn).addChild(newRow, undefined, true);
+
+    return newRow;
+  }, [layoutManager, parent, height]);
+
+  useEffect(() => {
+    row.setSize();
+  }, [row]);
+
+  return (
+    <ParentItemContext.Provider value={row}>
+      {children}
+    </ParentItemContext.Provider>
+  );
+}
+
+export default Row;
diff --git a/plugins/ui/src/js/src/layout/Stack.tsx b/plugins/ui/src/js/src/layout/Stack.tsx
new file mode 100644
index 000000000..dbb084b09
--- /dev/null
+++ b/plugins/ui/src/js/src/layout/Stack.tsx
@@ -0,0 +1,46 @@
+import React, { useEffect, useMemo } from 'react';
+import { useLayoutManager } from '@deephaven/dashboard';
+import type { Stack as StackType, RowOrColumn } from '@deephaven/golden-layout';
+import type { StackElementProps } from './LayoutUtils';
+import { ParentItemContext, useParentItem } from './ParentItemContext';
+
+function Stack({
+  children,
+  height,
+  width,
+  activeItemIndex,
+}: StackElementProps): JSX.Element | null {
+  const layoutManager = useLayoutManager();
+  const parent = useParentItem();
+  const stack = useMemo(() => {
+    const newStack = layoutManager.createContentItem(
+      {
+        type: 'stack',
+        height,
+        width,
+        activeItemIndex,
+      },
+      parent
+    );
+
+    // The 3rd param prevents golden-layout from calling setSize
+    // until we've mounted all of the rows and columns
+    (parent as RowOrColumn).addChild(newStack, undefined, true);
+
+    return newStack as StackType;
+  }, [layoutManager, parent, height, width, activeItemIndex]);
+
+  useEffect(() => {
+    if (activeItemIndex != null) {
+      stack.setActiveContentItem(stack.contentItems[activeItemIndex]);
+    }
+  }, [activeItemIndex, parent, stack]);
+
+  return (
+    <ParentItemContext.Provider value={stack}>
+      {children}
+    </ParentItemContext.Provider>
+  );
+}
+
+export default Stack;
diff --git a/plugins/ui/src/js/vite.config.ts b/plugins/ui/src/js/vite.config.ts
index 8a8fd9d9c..f64b5e9f1 100644
--- a/plugins/ui/src/js/vite.config.ts
+++ b/plugins/ui/src/js/vite.config.ts
@@ -24,6 +24,7 @@ export default defineConfig(({ mode }) => ({
         '@deephaven/icons',
         '@deephaven/iris-grid',
         '@deephaven/jsapi-bootstrap',
+        '@deephaven/jsapi-components',
         '@deephaven/log',
         '@deephaven/plugin',
       ],


Test patch:
diff --git a/plugins/ui/src/js/src/DocumentHandler.test.tsx b/plugins/ui/src/js/src/DocumentHandler.test.tsx
index 758536030..13c7d894b 100644
--- a/plugins/ui/src/js/src/DocumentHandler.test.tsx
+++ b/plugins/ui/src/js/src/DocumentHandler.test.tsx
@@ -3,7 +3,11 @@ import { WidgetDefinition } from '@deephaven/dashboard';
 import { TestUtils } from '@deephaven/utils';
 import { render } from '@testing-library/react';
 import DocumentHandler, { DocumentHandlerProps } from './DocumentHandler';
-import { PANEL_ELEMENT_NAME, ReactPanelProps } from './PanelUtils';
+import {
+  DASHBOARD_ELEMENT_NAME,
+  PANEL_ELEMENT_NAME,
+  ReactPanelProps,
+} from './layout/LayoutUtils';
 import { MixedPanelsError, NoChildrenError } from './errors';
 import { getComponentForElement } from './WidgetUtils';
 
@@ -81,3 +85,19 @@ it('should render multiple panels', () => {
   render(makeDocumentHandler({ children }));
   expect(mockReactPanel).toHaveBeenCalledTimes(2);
 });
+
+it('should throw an error if the document mixes dashboard and non-dashboard elements', () => {
+  const children = makeDocument([
+    makeElement(PANEL_ELEMENT_NAME),
+    makeElement(DASHBOARD_ELEMENT_NAME),
+  ]);
+  expect(() => render(makeDocumentHandler({ children }))).toThrow(
+    MixedPanelsError
+  );
+});
+
+it('should render a dashboard', () => {
+  const children = makeDocument([makeElement(DASHBOARD_ELEMENT_NAME)]);
+  render(makeDocumentHandler({ children }));
+  expect(mockReactPanel).toHaveBeenCalledTimes(0);
+});
diff --git a/plugins/ui/src/js/src/PanelUtils.test.ts b/plugins/ui/src/js/src/PanelUtils.test.ts
deleted file mode 100644
index 319434d5b..000000000
--- a/plugins/ui/src/js/src/PanelUtils.test.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import { PANEL_ELEMENT_NAME, isPanelElementNode } from './PanelUtils';
-
-describe('isPanelElementNode', () => {
-  test.each([
-    [{ props: { title: 'test' } }, false],
-    [{ props: { title: 'test' }, __dhElemName: 'a different name' }, false],
-    [{ props: { title: 'test' }, __dhElemName: PANEL_ELEMENT_NAME }, true],
-  ])(`isPanelElementNode(%s)`, (element, result) => {
-    expect(isPanelElementNode(element)).toBe(result);
-  });
-});
diff --git a/plugins/ui/src/js/src/ReactPanel.test.tsx b/plugins/ui/src/js/src/ReactPanel.test.tsx
index 0ec339558..dccc2e6d9 100644
--- a/plugins/ui/src/js/src/ReactPanel.test.tsx
+++ b/plugins/ui/src/js/src/ReactPanel.test.tsx
@@ -6,25 +6,7 @@ import {
   ReactPanelManager,
   ReactPanelManagerContext,
 } from './ReactPanelManager';
-import { ReactPanelProps } from './PanelUtils';
-
-// Mock LayoutUtils, useListener, and PanelEvent from @deephaven/dashboard package
-const mockLayout = { root: {}, eventHub: {} };
-jest.mock('@deephaven/dashboard', () => {
-  const DashboardActual = jest.requireActual('@deephaven/dashboard');
-  return {
-    ...DashboardActual,
-    LayoutUtils: {
-      getComponentName: jest.fn(),
-      openComponent: jest.fn(),
-      closeComponent: jest.fn(),
-    },
-    useLayoutManager: jest.fn(() => mockLayout),
-    useListener: jest.fn(),
-    __esModule: true,
-    default: jest.fn(),
-  };
-});
+import { ReactPanelProps } from './layout/LayoutUtils';
 
 const mockPanelId = 'test-panel-id';
 jest.mock('shortid', () => jest.fn(() => mockPanelId));
diff --git a/plugins/ui/src/js/src/layout/LayoutUtils.test.ts b/plugins/ui/src/js/src/layout/LayoutUtils.test.ts
new file mode 100644
index 000000000..28a10160f
--- /dev/null
+++ b/plugins/ui/src/js/src/layout/LayoutUtils.test.ts
@@ -0,0 +1,56 @@
+import {
+  PANEL_ELEMENT_NAME,
+  ROW_ELEMENT_NAME,
+  COLUMN_ELEMENT_NAME,
+  isPanelElementNode,
+  isRowElementNode,
+  isColumnElementNode,
+  isStackElementNode,
+  STACK_ELEMENT_NAME,
+} from './LayoutUtils';
+
+describe('isPanelElementNode', () => {
+  test.each([
+    [{ props: { title: 'test' } }, false],
+    [{ props: { title: 'test' }, __dhElemName: 'a different name' }, false],
+    [{ props: { title: 'test' }, __dhElemName: PANEL_ELEMENT_NAME }, true],
+  ])(`isPanelElementNode(%s)`, (element, result) => {
+    expect(isPanelElementNode(element)).toBe(result);
+  });
+});
+
+describe('isRowElementNode', () => {
+  test.each([
+    [{ props: { height: 100 } }, false],
+    [{ props: { height: 100 }, __dhElemName: 'a different name' }, false],
+    [{ props: { height: 100 }, __dhElemName: ROW_ELEMENT_NAME }, true],
+  ])(`isRowElementNode(%s)`, (element, result) => {
+    expect(isRowElementNode(element)).toBe(result);
+  });
+});
+
+describe('isColumnElementNode', () => {
+  test.each([
+    [{ props: { width: 100 } }, false],
+    [{ props: { width: 100 }, __dhElemName: 'a different name' }, false],
+    [{ props: { width: 100 }, __dhElemName: COLUMN_ELEMENT_NAME }, true],
+  ])(`isColumnElementNode(%s)`, (element, result) => {
+    expect(isColumnElementNode(element)).toBe(result);
+  });
+});
+
+describe('isStackElementNode', () => {
+  test.each([
+    [{ props: { height: 100, width: 100 } }, false],
+    [
+      { props: { height: 100, width: 100 }, __dhElemName: 'a different name' },
+      false,
+    ],
+    [
+      { props: { height: 100, width: 100 }, __dhElemName: STACK_ELEMENT_NAME },
+      true,
+    ],
+  ])(`isStackElementNode(%s)`, (element, result) => {
+    expect(isStackElementNode(element)).toBe(result);
+  });
+});