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

Issue:
Wrap layout components automatically when needed
Currently, you must fully specify the layout for a dashboard in order for it to work properly. This makes the code unnecessarily verbose.

## Current rules

### Root (dashboard component)
Must have exactly 1 child (either a column or row)

### Row/Column
Children must meet exactly one of the following conditions
1. Only stacks
2. Only row/column

### Stack
Must have only panels as children (row/column is valid, but doesn't make sense from a layout perspective)

## Improved rules

### Root (dashboard component)
- [ ] If the root does not have a singular row/column as its child, provide one. The opposite type of the children (e.g. wrap 3 rows w/ a column) or column by default

### Row/Column
- [ ] If children has some row/columns, wrap the other children in the proper row/column as needed (e.g. wrap children of a column in a row and vice versa)
- [ ] If children has no row/columns, wrap all children in stacks if needed
    - [ ] If children has some stacks, but not all, wrap the other children in stacks
    - [ ] If children has no stacks, wrap all children in stacks. `row(t1, t2)` seems more likely to be "split the row so both tables are visible" rather than "stack the tables together in the row". The stack component will then handle wrapping in panel

### Stack
- [ ] If children has row/column/stacks throw/warn 
- [ ] Wrap all children in panel as needed

Multiple row/columns at top level -> 1 top level row/column



Discussion / hints:
Related tickets:
- [#228](https://github.com/deephaven/deephaven-plugins/issues/228) Implicitly adding ui.stack
- [#230](https://github.com/deephaven/deephaven-plugins/issues/230) Default placement of new panels is inconsistent


Fix patch:
diff --git a/package-lock.json b/package-lock.json
index 883f3918a..4a4b0f71c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -30914,12 +30914,6 @@
         "node": ">=16"
       }
     },
-    "plugins/matplotlib/src/js/node_modules/react-is": {
-      "version": "17.0.2",
-      "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
-      "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
-      "peer": true
-    },
     "plugins/matplotlib/src/js/node_modules/redux-thunk": {
       "version": "2.4.1",
       "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.1.tgz",
@@ -31226,7 +31220,7 @@
     },
     "plugins/ui/src/js": {
       "name": "@deephaven/js-plugin-ui",
-      "version": "0.5.0",
+      "version": "0.6.0",
       "license": "Apache-2.0",
       "dependencies": {
         "@adobe/react-spectrum": "^3.29.0",
@@ -31745,6 +31739,12 @@
         "url": "https://github.com/sponsors/mysticatea"
       }
     },
+    "plugins/ui/src/js/node_modules/react-is": {
+      "version": "18.2.0",
+      "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz",
+      "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==",
+      "peer": true
+    },
     "plugins/ui/src/js/node_modules/redux-thunk": {
       "version": "2.4.1",
       "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.1.tgz",
@@ -34323,12 +34323,6 @@
           "resolved": "https://registry.npmjs.org/@deephaven/utils/-/utils-0.58.0.tgz",
           "integrity": "sha512-5zmnUYwAT6ognsZ7SjhEF6OS1xjF4nP3RixEO+CEZdBxen5Q8pZ2CjhPv2FvuqAUGEx+MhPq/yD1AXvzu3HITw=="
         },
-        "react-is": {
-          "version": "17.0.2",
-          "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
-          "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
-          "peer": true
-        },
         "redux-thunk": {
           "version": "2.4.1",
           "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.1.tgz",
@@ -34914,6 +34908,11 @@
           "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-6.0.2.tgz",
           "integrity": "sha512-8q3LsZjRezbFZ2PN+uP+Q7pnHUMmAOziU2vA2OwoFaKIXxlxl38IylhSSgUorWu/rf4er67w0ikBqjBFk/pomA=="
         },
+        "react-is": {
+          "version": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz",
+          "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==",
+          "peer": true
+        },
         "redux-thunk": {
           "version": "2.4.1",
           "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.1.tgz",
diff --git a/plugins/ui/examples/README.md b/plugins/ui/examples/README.md
index e3d40faa4..217e7f5af 100644
--- a/plugins/ui/examples/README.md
+++ b/plugins/ui/examples/README.md
@@ -333,11 +333,28 @@ In addition to creating components, you can also create dashboards that displays
 
 The dashboard layout elements available are:
 
+- `ui.dashboard`: Create a dashboard to contain other layout elements.
 - `ui.column`: Create a column of elements stacked vertically.
 - `ui.row`: Create a row of elements laid out horizontally.
-- `ui.stack`: Create a stack of panels on top of each other. You can use the panel tab to switch between panels in the stack.
+- `ui.stack`: Create a stack of panels on top of each other. You can use the panel tab to switch between panels in the stack. Only one panel in a stack is visible at a time.
 - `ui.panel`: Create a panel to wrap an element. Panels can be moved around a dashboard manually by dragging the panel tab.
 
+### ui.dashboard
+
+A dashboard should only contain 1 root layout element. If the component for the dashboard returns an array of elements, they will be wrapped in a single root element. If there are multiple child elements of a dashboard, they will be wrapped as follows:
+
+- If there are any rows, they will be wrapped in a column.
+- If there are no rows and any columns, they will be wrapped in a row.
+- If there are no rows or columns, they will be wrapped in a column.
+
+### ui.row and ui.column
+
+Rows and columns typically contain other rows and columns or stacks. If a row or column contains no other rows or columns, each element will be wrapped in a stack if needed. For example, if you create a row with two panels, those panels will be laid out side by side in their own stacks.
+
+### ui.stack
+
+Stacks are used to create a stack of panels on top of each other. Any elements in a stack will be wrapped in a panel if needed. It is recommended to provide the panels with a title because the automatically wrapped panels will receive a title of "Untitled".
+
 ## Basic Dashboard
 
 Putting that all together, we can create a dashboard with two tables across the top and one plot across the bottom:
diff --git a/plugins/ui/src/js/__mocks__/@deephaven/dashboard.js b/plugins/ui/src/js/__mocks__/@deephaven/dashboard.js
index c7bb10dc1..31e53b69b 100644
--- a/plugins/ui/src/js/__mocks__/@deephaven/dashboard.js
+++ b/plugins/ui/src/js/__mocks__/@deephaven/dashboard.js
@@ -1,5 +1,9 @@
 // Mock LayoutUtils, useListener, and PanelEvent from @deephaven/dashboard package
-const mockLayout = { root: { contentItems: [] }, eventHub: {} };
+const mockLayout = {
+  root: { contentItems: [], addChild: jest.fn() },
+  eventHub: {},
+  createContentItem: jest.fn(() => ({ setSize: jest.fn() })),
+};
 
 const DashboardActual = jest.requireActual('@deephaven/dashboard');
 module.exports = {
diff --git a/plugins/ui/src/js/src/DashboardPlugin.tsx b/plugins/ui/src/js/src/DashboardPlugin.tsx
index 0ec80ef85..1172f8c7f 100644
--- a/plugins/ui/src/js/src/DashboardPlugin.tsx
+++ b/plugins/ui/src/js/src/DashboardPlugin.tsx
@@ -9,6 +9,7 @@ import {
   emitCreateDashboard,
   WidgetDescriptor,
   PanelOpenEventDetail,
+  DEFAULT_DASHBOARD_ID,
 } from '@deephaven/dashboard';
 import Log from '@deephaven/log';
 import {
@@ -186,13 +187,17 @@ export function DashboardPlugin({
   const widgetHandlers = useMemo(
     () =>
       [...widgetMap.entries()].map(([widgetId, widget]) => (
-        <ErrorBoundary key={widgetId}>
+        // Fallback to an empty array in default dashboard so we don't display errors over code studio
+        <ErrorBoundary
+          key={widgetId}
+          fallback={id === DEFAULT_DASHBOARD_ID ? [] : null}
+        >
           <DeferredApiBootstrap widget={widget.widget}>
             <WidgetHandler widget={widget} onClose={handleWidgetClose} />
           </DeferredApiBootstrap>
         </ErrorBoundary>
       )),
-    [handleWidgetClose, widgetMap]
+    [handleWidgetClose, widgetMap, id]
   );
 
   return (
diff --git a/plugins/ui/src/js/src/layout/Column.tsx b/plugins/ui/src/js/src/layout/Column.tsx
index 159ca8244..3e1de2428 100644
--- a/plugins/ui/src/js/src/layout/Column.tsx
+++ b/plugins/ui/src/js/src/layout/Column.tsx
@@ -1,7 +1,10 @@
 import React, { useEffect, useMemo } from 'react';
 import { useLayoutManager } from '@deephaven/dashboard';
 import type { RowOrColumn } from '@deephaven/golden-layout';
-import type { ColumnElementProps } from './LayoutUtils';
+import {
+  normalizeColumnChildren,
+  type ColumnElementProps,
+} from './LayoutUtils';
 import { ParentItemContext, useParentItem } from './ParentItemContext';
 
 function Column({ children, width }: ColumnElementProps): JSX.Element | null {
@@ -28,9 +31,11 @@ function Column({ children, width }: ColumnElementProps): JSX.Element | null {
     column.setSize();
   }, [column]);
 
+  const normalizedChildren = normalizeColumnChildren(children);
+
   return (
     <ParentItemContext.Provider value={column}>
-      {children}
+      {normalizedChildren}
     </ParentItemContext.Provider>
   );
 }
diff --git a/plugins/ui/src/js/src/layout/Dashboard.tsx b/plugins/ui/src/js/src/layout/Dashboard.tsx
index bf437842b..9ce1fe0c1 100644
--- a/plugins/ui/src/js/src/layout/Dashboard.tsx
+++ b/plugins/ui/src/js/src/layout/Dashboard.tsx
@@ -1,13 +1,18 @@
 import React from 'react';
-import type { DashboardElementProps } from './LayoutUtils';
+import {
+  normalizeDashboardChildren,
+  type DashboardElementProps,
+} from './LayoutUtils';
 import { ParentItemContext, useParentItem } from './ParentItemContext';
 
 function Dashboard({ children }: DashboardElementProps): JSX.Element | null {
   const parent = useParentItem();
 
+  const normalizedChildren = normalizeDashboardChildren(children);
+
   return (
     <ParentItemContext.Provider value={parent}>
-      {children}
+      {normalizedChildren}
     </ParentItemContext.Provider>
   );
 }
diff --git a/plugins/ui/src/js/src/layout/LayoutUtils.ts b/plugins/ui/src/js/src/layout/LayoutUtils.ts
deleted file mode 100644
index 26c513b58..000000000
--- a/plugins/ui/src/js/src/layout/LayoutUtils.ts
+++ /dev/null
@@ -1,133 +0,0 @@
-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/LayoutUtils.tsx b/plugins/ui/src/js/src/layout/LayoutUtils.tsx
new file mode 100644
index 000000000..462fa1de8
--- /dev/null
+++ b/plugins/ui/src/js/src/layout/LayoutUtils.tsx
@@ -0,0 +1,262 @@
+import React, { isValidElement, Children } from 'react';
+import type {
+  RowOrColumn,
+  Stack as GLStack,
+  Root,
+} from '@deephaven/golden-layout';
+import { ELEMENT_KEY, ElementNode, isElementNode } from '../ElementUtils';
+import Column from './Column';
+import Row from './Row';
+import Stack from './Stack';
+import ReactPanel from '../ReactPanel';
+
+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 | GLStack | 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, unknown>
+>;
+
+/**
+ * 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
+  );
+}
+
+/**
+ * Normalizes the children of a dashboard element to create a fully defined layout.
+ * GoldenLayout requires 1 root element.
+ *
+ * If there are multiple root elements, then we wrap in a row or column depending on the children.
+ * If there are no layout elements, then we wrap in a column.
+ * If there are only columns, then we wrap in a row.
+ * If there are any rows (even if mixed with columns), then we wrap in a column.
+ * @param children Children to normalize
+ * @returns Normalized children
+ */
+export function normalizeDashboardChildren(
+  children: React.ReactNode
+): React.ReactNode {
+  const needsWrapper = Children.count(children) > 1;
+  const hasRows = Children.toArray(children).some(
+    child => isValidElement(child) && child.type === Row
+  );
+  const hasColumns = Children.toArray(children).some(
+    child => isValidElement(child) && child.type === Column
+  );
+
+  if (!hasRows && !hasColumns) {
+    return <Column>{children}</Column>;
+  }
+
+  if (needsWrapper) {
+    if (hasRows) {
+      return <Column>{children}</Column>;
+    }
+    if (hasColumns) {
+      return <Row>{children}</Row>;
+    }
+    return <Column>{children}</Column>;
+  }
+  return children;
+}
+
+/**
+ * Normalizes the children of a row element to create a fully defined layout.
+ * Rows can contain columns, stacks, or other rows.
+ *
+ * If there are no layout elements, we wrap each child in a stack.
+ * E.g. row(t1, t2) will become row(stack(t1), stack(t2))
+ *
+ * If there are layout elements, we wrap any non-layout elements in a column.
+ *
+ * @param children Children to normalize
+ * @returns Normalized children
+ */
+export function normalizeRowChildren(
+  children: React.ReactNode
+): React.ReactNode {
+  const hasLayoutElements = Children.toArray(children).some(
+    child =>
+      isValidElement(child) && (child.type === Row || child.type === Column)
+  );
+  return Children.map(children, child => {
+    if (isValidElement(child) && child.type !== Column && child.type !== Row) {
+      if (hasLayoutElements) {
+        return <Column>{child}</Column>;
+      }
+      if (child.type !== Stack) {
+        return <Stack>{child}</Stack>;
+      }
+    }
+    return child;
+  });
+}
+
+/**
+ * Normalizes the children of a column element to create a fully defined layout.
+ * Columns can contain rows, stacks, or other columns.
+ *
+ * If there are no layout elements, we wrap each child in a stack.
+ * E.g. column(t1, t2) will become column(stack(t1), stack(t2))
+ *
+ * If there are layout elements, we wrap any non-layout elements in a row.
+ *
+ * @param children Children to normalize
+ * @returns Normalized children
+ */
+export function normalizeColumnChildren(
+  children: React.ReactNode
+): React.ReactNode {
+  const hasLayoutElements = Children.toArray(children).some(
+    child =>
+      isValidElement(child) && (child.type === Row || child.type === Column)
+  );
+  return Children.map(children, child => {
+    if (isValidElement(child) && child.type !== Row && child.type !== Column) {
+      if (hasLayoutElements) {
+        return <Row>{child}</Row>;
+      }
+      if (child.type !== Stack) {
+        return <Stack>{child}</Stack>;
+      }
+    }
+    return child;
+  });
+}
+
+/**
+ * Normalizes the children of a stack element to create a fully defined layout.
+ * Stacks should only contain panels.
+ * If a child is not a panel, it will be wrapped in a panel with a default title.
+ *
+ * @param children Children to normalize
+ * @returns Normalized children
+ */
+export function normalizeStackChildren(
+  children: React.ReactNode
+): React.ReactNode {
+  return Children.map(children, child => {
+    if (isValidElement(child) && child.type !== ReactPanel) {
+      return <ReactPanel title="Untitled">{child}</ReactPanel>;
+    }
+    return child;
+  });
+}
diff --git a/plugins/ui/src/js/src/layout/Row.tsx b/plugins/ui/src/js/src/layout/Row.tsx
index 8ba4b969e..d837ab7ea 100644
--- a/plugins/ui/src/js/src/layout/Row.tsx
+++ b/plugins/ui/src/js/src/layout/Row.tsx
@@ -1,7 +1,7 @@
 import React, { useEffect, useMemo } from 'react';
 import { useLayoutManager } from '@deephaven/dashboard';
 import type { RowOrColumn } from '@deephaven/golden-layout';
-import type { RowElementProps } from './LayoutUtils';
+import { normalizeRowChildren, type RowElementProps } from './LayoutUtils';
 import { ParentItemContext, useParentItem } from './ParentItemContext';
 
 function Row({ children, height }: RowElementProps): JSX.Element | null {
@@ -27,9 +27,11 @@ function Row({ children, height }: RowElementProps): JSX.Element | null {
     row.setSize();
   }, [row]);
 
+  const normalizedChildren = normalizeRowChildren(children);
+
   return (
     <ParentItemContext.Provider value={row}>
-      {children}
+      {normalizedChildren}
     </ParentItemContext.Provider>
   );
 }
diff --git a/plugins/ui/src/js/src/layout/Stack.tsx b/plugins/ui/src/js/src/layout/Stack.tsx
index dbb084b09..26b373391 100644
--- a/plugins/ui/src/js/src/layout/Stack.tsx
+++ b/plugins/ui/src/js/src/layout/Stack.tsx
@@ -1,7 +1,7 @@
 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 { normalizeStackChildren, type StackElementProps } from './LayoutUtils';
 import { ParentItemContext, useParentItem } from './ParentItemContext';
 
 function Stack({
@@ -36,9 +36,11 @@ function Stack({
     }
   }, [activeItemIndex, parent, stack]);
 
+  const normalizedChildren = normalizeStackChildren(children);
+
   return (
     <ParentItemContext.Provider value={stack}>
-      {children}
+      {normalizedChildren}
     </ParentItemContext.Provider>
   );
 }


Test patch:
diff --git a/plugins/ui/src/js/src/layout/LayoutUtils.test.ts b/plugins/ui/src/js/src/layout/LayoutUtils.test.ts
deleted file mode 100644
index 28a10160f..000000000
--- a/plugins/ui/src/js/src/layout/LayoutUtils.test.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-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);
-  });
-});
diff --git a/plugins/ui/src/js/src/layout/LayoutUtils.test.tsx b/plugins/ui/src/js/src/layout/LayoutUtils.test.tsx
new file mode 100644
index 000000000..a76b15d3d
--- /dev/null
+++ b/plugins/ui/src/js/src/layout/LayoutUtils.test.tsx
@@ -0,0 +1,237 @@
+/* eslint-disable react/jsx-key */
+import React from 'react';
+import { TestUtils } from '@deephaven/utils';
+import Column from './Column';
+import {
+  PANEL_ELEMENT_NAME,
+  ROW_ELEMENT_NAME,
+  COLUMN_ELEMENT_NAME,
+  isPanelElementNode,
+  isRowElementNode,
+  isColumnElementNode,
+  isStackElementNode,
+  STACK_ELEMENT_NAME,
+  normalizeDashboardChildren,
+  normalizeColumnChildren,
+  normalizeRowChildren,
+  normalizeStackChildren,
+} from './LayoutUtils';
+import Row from './Row';
+import Stack from './Stack';
+import ReactPanel from '../ReactPanel';
+
+beforeEach(() => {
+  TestUtils.disableConsoleOutput();
+});
+
+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);
+  });
+});
+
+/**
+ * Deeply removes keys from a react node and its children.
+ * @param node The react node to remove keys from
+ * @returns React node without keys
+ */
+function removeReactKeys(node: React.ReactNode): React.ReactNode {
+  if (Array.isArray(node)) {
+    return node.map(removeReactKeys);
+  }
+
+  if (React.isValidElement(node)) {
+    return React.cloneElement(node, { ...node.props, key: null });
+  }
+
+  return node;
+}
+
+/**
+ * Compares 2 react nodes using an expect statement.
+ * Removes the key from any react elements before comparing.
+ * React.Children.map adds a key attribute to each child, which makes it difficult to compare the normalized children.
+ * @param a First react node
+ * @param b Second react node
+ */
+function compareReactNodes(a: React.ReactNode, b: React.ReactNode): void {
+  expect(removeReactKeys(a)).toEqual(removeReactKeys(b));
+}
+
+describe('normalizeDashboardChildren', () => {
+  test('wraps rows with a column', () => {
+    compareReactNodes(
+      normalizeDashboardChildren([<Row />, <Row />]),
+      <Column>
+        <Row />
+        <Row />
+      </Column>
+    );
+
+    compareReactNodes(
+      normalizeDashboardChildren([<Row />, <Stack />]),
+      <Column>
+        <Row />
+        <Stack />
+      </Column>
+    );
+  });
+
+  test('wraps columns with a row', () => {
+    compareReactNodes(
+      normalizeDashboardChildren([<Column />, <Column />]),
+      <Row>
+        <Column />
+        <Column />
+      </Row>
+    );
+
+    compareReactNodes(
+      normalizeDashboardChildren([<Column />, <Stack />]),
+      <Row>
+        <Column />
+        <Stack />
+      </Row>
+    );
+  });
+
+  test('wraps mixed rows and columns with a column', () => {
+    compareReactNodes(
+      normalizeDashboardChildren([<Row />, <Column />]),
+      <Column>
+        <Row />
+        <Column />
+      </Column>
+    );
+  });
+
+  test('does not wrap a single layout child', () => {
+    compareReactNodes(normalizeDashboardChildren(<Row />), <Row />);
+    compareReactNodes(normalizeDashboardChildren(<Column />), <Column />);
+  });
+
+  test('wraps a single non-layout child in a column', () => {
+    compareReactNodes(
+      normalizeDashboardChildren(<ReactPanel />),
+      <Column>
+        <ReactPanel />
+      </Column>
+    );
+  });
+});
+
+describe('normalizeColumnChildren', () => {
+  test('wraps children in rows when needed', () => {
+    compareReactNodes(
+      normalizeColumnChildren([<Column />, <Row />, <Stack />]),
+      [
+        <Column />,
+        <Row />,
+        <Row>
+          <Stack />
+        </Row>,
+      ]
+    );
+
+    compareReactNodes(normalizeColumnChildren([<Column />, <Stack />]), [
+      <Column />,
+      <Row>
+        <Stack />
+      </Row>,
+    ]);
+  });
+
+  test('wraps children in stacks when needed', () => {
+    compareReactNodes(normalizeColumnChildren(<Stack />), [<Stack />]);
+
+    compareReactNodes(normalizeColumnChildren([<Stack />, <ReactPanel />]), [
+      <Stack />,
+      <Stack>
+        <ReactPanel />
+      </Stack>,
+    ]);
+  });
+});
+
+describe('normalizeRowChildren', () => {
+  test('wraps children in columns when needed', () => {
+    compareReactNodes(normalizeRowChildren([<Column />, <Row />, <Stack />]), [
+      <Column />,
+      <Row />,
+      <Column>
+        <Stack />
+      </Column>,
+    ]);
+
+    compareReactNodes(normalizeRowChildren([<Row />, <Stack />]), [
+      <Row />,
+      <Column>
+        <Stack />
+      </Column>,
+    ]);
+  });
+
+  test('wraps children in stacks when needed', () => {
+    compareReactNodes(normalizeRowChildren(<Stack />), [<Stack />]);
+
+    compareReactNodes(normalizeRowChildren([<Stack />, <ReactPanel />]), [
+      <Stack />,
+      <Stack>
+        <ReactPanel />
+      </Stack>,
+    ]);
+  });
+});
+
+describe('normalizeStackChildren', () => {
+  test('wraps children in panels with Untitled default title when needed', () => {
+    compareReactNodes(normalizeStackChildren(<ReactPanel />), [<ReactPanel />]);
+
+    compareReactNodes(normalizeStackChildren([<ReactPanel />, <Row />]), [
+      <ReactPanel />,
+      <ReactPanel title="Untitled">
+        <Row />
+      </ReactPanel>,
+    ]);
+  });
+});