diff --git a/package-lock.json b/package-lock.json index 428ac4915..7636df8e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3408,6 +3408,7 @@ "version": "1.8.0", "resolved": "https://registry.npmjs.org/@deephaven/test-utils/-/test-utils-1.8.0.tgz", "integrity": "sha512-Lcan+J/pV1DvqJJ91ohnWzClQh4HkFpW0CsZD5DCzyCUh3LV95D14uaY54s+jFoRTOeSbFB91eSIWb9Yp1FIFA==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=16" @@ -31982,6 +31983,7 @@ "remark-math": "^5.1.1" }, "devDependencies": { + "@deephaven/test-utils": "^1.8.0", "@types/memoizee": "^0.4.5", "@types/react": "^18.0.0", "react": "^18.0.0", diff --git a/plugins/ui/docs/architecture.md b/plugins/ui/docs/architecture.md index f5a8c3926..aeeaeac26 100644 --- a/plugins/ui/docs/architecture.md +++ b/plugins/ui/docs/architecture.md @@ -163,6 +163,6 @@ A component that is created on the server side runs through a few steps before i 1. [Element](https://github.com/deephaven/deephaven-plugins/blob/main/plugins/ui/src/deephaven/ui/elements/Element.py) - The basis for all UI components. Generally, a [FunctionElement](https://github.com/deephaven/deephaven-plugins/blob/main/plugins/ui/src/deephaven/ui/elements/FunctionElement.py) created by a script using the [@ui.component](https://github.com/deephaven/deephaven-plugins/blob/main/plugins/ui/src/deephaven/ui/components/make_component.py) decorator that does not run the function until it is rendered. The result can change depending on the context that it is rendered in (e.g., what "state" is set). 2. [ElementMessageStream](https://github.com/deephaven/deephaven-plugins/blob/main/plugins/ui/src/deephaven/ui/object_types/ElementMessageStream.py) - The `ElementMessageStream` is responsible for rendering one instance of an element in a specific rendering context and handling the server-client communication. The element is rendered to create a [RenderedNode](https://github.com/deephaven/deephaven-plugins/blob/main/plugins/ui/src/deephaven/ui/renderer/RenderedNode.py), which is an immutable representation of a rendered document. The `RenderedNode` is then encoded into JSON using [NodeEncoder](https://github.com/deephaven/deephaven-plugins/blob/main/plugins/ui/src/deephaven/ui/renderer/NodeEncoder.py), which pulls out all the non-serializable objects (such as Tables) and maps them to exported objects, and all the callables to be mapped to commands that JSON-RPC can accept. This is the final representation of the document sent to the client and ultimately handled by the `WidgetHandler`. -3. [DashboardPlugin](https://github.com/deephaven/deephaven-plugins/blob/main/plugins/ui/src/js/src/DashboardPlugin.tsx) - Client-side `DashboardPlugin` that listens for when a widget of type `Element` is opened and manages the `WidgetHandler` instances that are created for each widget. -4. [WidgetHandler](https://github.com/deephaven/deephaven-plugins/blob/main/plugins/ui/src/js/src/WidgetHandler.tsx) - Uses JSON-RPC communication with an `ElementMessageStream` instance to set the initial state, then load the initial rendered document and associated exported objects. Listens for any changes and updates the document accordingly. -5. [DocumentHandler](https://github.com/deephaven/deephaven-plugins/blob/main/plugins/ui/src/js/src/DocumentHandler.tsx) - Handles the root of a rendered document, laying out the appropriate panels or dashboard specified. +3. [UIWidgetPlugin](https://github.com/deephaven/deephaven-plugins/blob/main/plugins/ui/src/js/src/UIWidgetPlugin.tsx) - Client-side `WidgetPlugin` that listens for when a widget of type `Element` is opened and manages the `WidgetHandler` instances that are created for each widget. +4. [WidgetHandler](https://github.com/deephaven/deephaven-plugins/blob/main/plugins/ui/src/js/src/widget/WidgetHandler.tsx) - Uses JSON-RPC communication with an `ElementMessageStream` instance to set the initial state, then load the initial rendered document and associated exported objects. Listens for any changes and updates the document accordingly. +5. [DocumentHandler](https://github.com/deephaven/deephaven-plugins/blob/main/plugins/ui/src/js/src/widget/DocumentHandler.tsx) - Handles the root of a rendered document, laying out the appropriate panels or dashboard specified. diff --git a/plugins/ui/docs/components/dashboard.md b/plugins/ui/docs/components/dashboard.md index 7b56bee98..dd37dd623 100644 --- a/plugins/ui/docs/components/dashboard.md +++ b/plugins/ui/docs/components/dashboard.md @@ -232,6 +232,25 @@ dash_holy_grail = ui.dashboard( ) ``` +## Hiding Panel Headers + +By default, each panel in a dashboard displays a header bar with the panel title and controls. Setting `show_headers=False` removes all panel headers, giving a cleaner, borderless appearance useful for presentation-style dashboards. + +```python +from deephaven import ui + +dash_no_headers = ui.dashboard( + ui.row( + ui.panel("A", title="A"), + ui.panel("B", title="B"), + ), + show_headers=False, +) +``` + +> [!NOTE] +> When `show_headers=False`, users will not be able to drag panels to rearrange the layout, since the header is the drag handle. + ## Nested Dashboards Dashboards can be nested inside panels to create complex layouts with isolated drag-and-drop regions. Each nested dashboard creates its own independent layout that users can rearrange without affecting the parent dashboard. diff --git a/plugins/ui/docs/components/uri.md b/plugins/ui/docs/components/uri.md index 9715cecb7..ce780893c 100644 --- a/plugins/ui/docs/components/uri.md +++ b/plugins/ui/docs/components/uri.md @@ -60,6 +60,28 @@ list_view = ui.list_view( ) ``` +### Nesting a component from another query + +You can use `ui.resolve` to reference a `@ui.component` defined in another Persistent Query and nest it inside a panel in the current query. The resolved component is rendered by the web client, so the worker hosting the dashboard does not need to load the data or recompute the component. + +```py order=null +from deephaven import ui + +# Resolve a ui widget defined in another persistent query +remote_widget = ui.resolve("pq://DataServiceQuery/scope/my_widget") + + +@ui.component +def cross_query_dashboard(): + return ui.row( + ui.panel(remote_widget, title="Remote Widget"), + ui.panel("Local Content", title="Local"), + ) + + +my_dashboard = ui.dashboard(cross_query_dashboard()) +``` + ## URI Encoding If your URI contains any special characters, such as spaces or slashes, you must encode the URI components using standard URL encoding. This is because URIs are often used in web contexts where special characters can cause issues. You can use Python's built-in `urllib.parse.quote` function to encode your URIs. diff --git a/plugins/ui/docs/snapshots/1d8b6032316168e0cd14ff061a00e0d5.json b/plugins/ui/docs/snapshots/1d8b6032316168e0cd14ff061a00e0d5.json new file mode 100644 index 000000000..ba72ffc32 --- /dev/null +++ b/plugins/ui/docs/snapshots/1d8b6032316168e0cd14ff061a00e0d5.json @@ -0,0 +1 @@ +{"file":"components/dashboard.md","objects":{"dash_no_headers":{"type":"deephaven.ui.Dashboard","data":{"document":{"props":{"showHeaders":false,"children":{"__dhElemName":"deephaven.ui.components.Row","props":{"children":[{"__dhElemName":"deephaven.ui.components.Panel","props":{"title":"A","direction":"column","alignItems":"start","gap":"size-100","overflow":"auto","padding":"size-100","children":"A"}},{"__dhElemName":"deephaven.ui.components.Panel","props":{"title":"B","direction":"column","alignItems":"start","gap":"size-100","overflow":"auto","padding":"size-100","children":"B"}}]}}},"__dhElemName":"deephaven.ui.components.Dashboard"},"state":"{}"}}}} \ No newline at end of file diff --git a/plugins/ui/docs/snapshots/5ef4aa2cf2eb0aa5430fcfe3100c6ff7.json b/plugins/ui/docs/snapshots/5ef4aa2cf2eb0aa5430fcfe3100c6ff7.json new file mode 100644 index 000000000..50b885d82 --- /dev/null +++ b/plugins/ui/docs/snapshots/5ef4aa2cf2eb0aa5430fcfe3100c6ff7.json @@ -0,0 +1 @@ +{"file":"components/uri.md","objects":{"remote_widget":{"type":"deephaven.ui.Element","data":{"document":{"props":{"uri":"pq://DataServiceQuery/scope/my_widget"},"__dhElemName":"deephaven.ui.elements.UriElement"},"state":"{}"}},"my_dashboard":{"type":"deephaven.ui.Dashboard","data":{"document":{"props":{"showHeaders":true,"children":{"__dhElemName":"__main__.cross_query_dashboard","props":{"children":{"__dhElemName":"deephaven.ui.components.Row","props":{"children":[{"__dhElemName":"deephaven.ui.components.Panel","props":{"title":"Remote Widget","direction":"column","alignItems":"start","gap":"size-100","overflow":"auto","padding":"size-100","children":{"__dhElemName":"deephaven.ui.elements.UriElement","props":{"uri":"pq://DataServiceQuery/scope/my_widget"}}}},{"__dhElemName":"deephaven.ui.components.Panel","props":{"title":"Local","direction":"column","alignItems":"start","gap":"size-100","overflow":"auto","padding":"size-100","children":"Local Content"}}]}}}}},"__dhElemName":"deephaven.ui.components.Dashboard"},"state":"{}"}}}} \ No newline at end of file diff --git a/plugins/ui/src/deephaven/ui/components/dashboard.py b/plugins/ui/src/deephaven/ui/components/dashboard.py index 44ae34491..f91ffce1f 100644 --- a/plugins/ui/src/deephaven/ui/components/dashboard.py +++ b/plugins/ui/src/deephaven/ui/components/dashboard.py @@ -4,15 +4,20 @@ from ..elements import DashboardElement, FunctionElement -def dashboard(element: FunctionElement) -> DashboardElement: +def dashboard( + element: FunctionElement, + *, + show_headers: bool = True, +) -> DashboardElement: """ 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. + show_headers: Whether to show headers on the dashboard panels. Defaults to True. Returns: The rendered dashboard. """ - return DashboardElement(element) + return DashboardElement(element, show_headers=show_headers) diff --git a/plugins/ui/src/deephaven/ui/elements/DashboardElement.py b/plugins/ui/src/deephaven/ui/elements/DashboardElement.py index af8310eb1..d1a3b1e60 100644 --- a/plugins/ui/src/deephaven/ui/elements/DashboardElement.py +++ b/plugins/ui/src/deephaven/ui/elements/DashboardElement.py @@ -8,5 +8,14 @@ class DashboardElement(BaseElement): - def __init__(self, element: FunctionElement): - super().__init__("deephaven.ui.components.Dashboard", element) + def __init__( + self, + element: FunctionElement, + *, + show_headers: bool = True, + ): + super().__init__( + "deephaven.ui.components.Dashboard", + element, + show_headers=show_headers, + ) diff --git a/plugins/ui/src/js/__mocks__/@deephaven/plugin.js b/plugins/ui/src/js/__mocks__/@deephaven/plugin.js index e0ce22778..946fa84ee 100644 --- a/plugins/ui/src/js/__mocks__/@deephaven/plugin.js +++ b/plugins/ui/src/js/__mocks__/@deephaven/plugin.js @@ -1,8 +1,12 @@ // Mock @deephaven/plugin package +const React = require('react'); const PluginActual = jest.requireActual('@deephaven/plugin'); module.exports = { ...PluginActual, useDashboardPlugins: jest.fn(() => []), + // Mock usePersistentState to behave like useState. + // The real implementation requires FiberProvider which is internal to Dashboard. + usePersistentState: (initialState, _config) => React.useState(initialState), __esModule: true, }; diff --git a/plugins/ui/src/js/package.json b/plugins/ui/src/js/package.json index 84ed37a73..cab028b1a 100644 --- a/plugins/ui/src/js/package.json +++ b/plugins/ui/src/js/package.json @@ -29,6 +29,7 @@ "update-dh-packages": "node ../../../../tools/update-dh-packages.mjs" }, "devDependencies": { + "@deephaven/test-utils": "^1.8.0", "@types/memoizee": "^0.4.5", "@types/react": "^18.0.0", "react": "^18.0.0", diff --git a/plugins/ui/src/js/src/DashboardPlugin.tsx b/plugins/ui/src/js/src/DashboardPlugin.tsx index 1fd792990..b8e6319cc 100644 --- a/plugins/ui/src/js/src/DashboardPlugin.tsx +++ b/plugins/ui/src/js/src/DashboardPlugin.tsx @@ -1,5 +1,4 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { nanoid } from 'nanoid'; import { type DashboardPluginComponentProps, LayoutManagerContext, @@ -7,18 +6,14 @@ import { PanelEvent, useListener, useDashboardPluginData, - emitCreateDashboard, type WidgetDescriptor, - type PanelOpenEventDetail, DEFAULT_DASHBOARD_ID, useDashboardPanel, } from '@deephaven/dashboard'; import Log from '@deephaven/log'; import { DeferredApiBootstrap } from '@deephaven/jsapi-bootstrap'; -import { type dh } from '@deephaven/jsapi-types'; import { ErrorBoundary } from '@deephaven/components'; import { useDebouncedCallback } from '@deephaven/react-hooks'; -import styles from './styles.scss?inline'; import { type ReadonlyWidgetData, type WidgetDataUpdate, @@ -27,11 +22,6 @@ import { import PortalPanel from './layout/PortalPanel'; import PortalPanelManager from './layout/PortalPanelManager'; import DashboardWidgetHandler from './widget/DashboardWidgetHandler'; -import { - getPreservedData, - DASHBOARD_ELEMENT, - WIDGET_ELEMENT, -} from './widget/WidgetUtils'; import { usePanelId } from './layout/ReactPanelContext'; const PLUGIN_NAME = '@deephaven/js-plugin-ui.DashboardPlugin'; @@ -63,6 +53,13 @@ interface WidgetWrapper { data?: ReadonlyWidgetData; } +/** + * Handle legacy behaviour of an open widget being saved with the dashboard. + * + * Now UIWidgetPlugin is responsible for opening widgets in the dashboard. + * @param props Dashboard plugin props + * @returns Dashboard plugin content, which is responsible for handling legacy behaviour of an open widget being saved with the dashboard + */ function InnerDashboardPlugin( props: DashboardPluginComponentProps ): JSX.Element | null { @@ -78,66 +75,6 @@ function InnerDashboardPlugin( ReadonlyMap >(new Map()); - const handleWidgetOpen = useCallback( - ({ widgetId, widget }: { widgetId: string; widget: WidgetDescriptor }) => { - log.debug('Opening widget with ID', widgetId, widget); - setWidgetMap(prevWidgetMap => { - const newWidgetMap = new Map(prevWidgetMap); - const oldWidget = newWidgetMap.get(widgetId); - newWidgetMap.set(widgetId, { - id: widgetId, - widget, - data: getPreservedData(oldWidget?.data), - }); - return newWidgetMap; - }); - }, - [] - ); - - const handleDashboardOpen = useCallback( - ({ - widget, - dashboardId, - }: { - widget: WidgetDescriptor; - dashboardId: string; - }) => { - const { name: title } = widget; - log.debug('Emitting create dashboard event for', dashboardId, widget); - emitCreateDashboard(layout.eventHub, { - pluginId: PLUGIN_NAME, - title: title ?? 'Untitled', - data: { openWidgets: { [dashboardId]: { descriptor: widget } } }, - }); - }, - [layout.eventHub] - ); - - const handlePanelOpen = useCallback( - ({ - panelId: widgetId = nanoid(), - widget, - }: PanelOpenEventDetail) => { - const { type } = widget; - - switch (type) { - case WIDGET_ELEMENT: { - handleWidgetOpen({ widgetId, widget }); - break; - } - case DASHBOARD_ELEMENT: { - handleDashboardOpen({ widget, dashboardId: widgetId }); - break; - } - default: { - break; - } - } - }, - [handleDashboardOpen, handleWidgetOpen] - ); - useEffect( function loadInitialPluginData() { if (initialPluginData == null) { @@ -203,8 +140,6 @@ function InnerDashboardPlugin( }); }, []); - // TODO: We need to change up the event system for how objects are opened, since in this case it could be opening multiple panels - useListener(layout.eventHub, PanelEvent.OPEN, handlePanelOpen); useListener(layout.eventHub, PanelEvent.CLOSE, handlePanelClose); const sendPluginDataUpdate = useCallback( @@ -282,12 +217,18 @@ function InnerDashboardPlugin( return ( - {widgetHandlers} ); } +/** + * Dashboard plugin that registers the PortalPanel type for deephaven.ui + * + * It's also responsible for handling legacy behaviour, for old dashboards that may have opened a deephaven.ui widget previously. + * @param props Dashboard plugin props + * @returns Dashboard plugin + */ export function DashboardPlugin( props: DashboardPluginComponentProps ): JSX.Element | null { diff --git a/plugins/ui/src/js/src/UIComponent.tsx b/plugins/ui/src/js/src/UIComponent.tsx new file mode 100644 index 000000000..49af35f3d --- /dev/null +++ b/plugins/ui/src/js/src/UIComponent.tsx @@ -0,0 +1,66 @@ +import React, { useCallback, useMemo } from 'react'; +import { type UriVariableDescriptor } from '@deephaven/jsapi-bootstrap'; +import type { dh } from '@deephaven/jsapi-types'; +import { + usePersistentState, + type WidgetComponentProps, +} from '@deephaven/plugin'; +import { nanoid } from 'nanoid'; +import { type WidgetData, type WidgetDataUpdate } from './widget/WidgetTypes'; +import WidgetHandler from './widget/WidgetHandler'; + +type UIComponentProps = WidgetComponentProps & { + // Might be loading a URI resolved widget... + uri?: UriVariableDescriptor; +}; + +export function UIComponent(props: UIComponentProps): JSX.Element | null { + const { metadata: widgetDescriptor, uri, __dhId } = props; + + const [widgetData, setWidgetData] = usePersistentState< + WidgetData | undefined + >(undefined, { type: 'UIComponentWidgetData', version: 1 }); + + const id = useMemo( + () => __dhId ?? widgetDescriptor?.id ?? nanoid(), + [__dhId, widgetDescriptor] + ); + + const handleDataChange = useCallback( + (data: WidgetDataUpdate) => { + setWidgetData(oldData => ({ ...oldData, ...data })); + }, + [setWidgetData] + ); + + const descriptor = uri ?? widgetDescriptor; + if (descriptor == null) { + throw new Error('No widget descriptor'); + } + + const renderEmptyDocument = useCallback( + () => ( + // Single-panel or first-time-load case. Returning a fragment causes + // `getRootChildren` to wrap it in a `DefaultPanelContent`, which renders + // a `LoadingOverlay` while the widget status is `loading`. + // eslint-disable-next-line react/jsx-no-useless-fragment + <> + ), + // We only want to update this callback when the descriptor changes, not + // every time the widgetData (panelIds) changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + [descriptor] + ); + + return ( + + ); +} + +export default UIComponent; diff --git a/plugins/ui/src/js/src/UIWidget.tsx b/plugins/ui/src/js/src/UIWidget.tsx new file mode 100644 index 000000000..38877248d --- /dev/null +++ b/plugins/ui/src/js/src/UIWidget.tsx @@ -0,0 +1,19 @@ +import type { dh } from '@deephaven/jsapi-types'; +import { type WidgetComponentProps } from '@deephaven/plugin'; +import UIComponent from './UIComponent'; +import PortalPanel from './layout/PortalPanel'; + +type UIWidgetProps = WidgetComponentProps; + +export function UIWidget(props: UIWidgetProps): JSX.Element | null { + const { metadata: widgetDescriptor } = props; + if (widgetDescriptor?.type === PortalPanel.displayName) { + // PortalPanel was used by the legacy DashboardPlugin to render elements. We just ignore them here. + return null; + } + + // eslint-disable-next-line react/jsx-props-no-spreading + return ; +} + +export default UIWidget; diff --git a/plugins/ui/src/js/src/UIWidgetPlugin.ts b/plugins/ui/src/js/src/UIWidgetPlugin.ts new file mode 100644 index 000000000..6de23bd2d --- /dev/null +++ b/plugins/ui/src/js/src/UIWidgetPlugin.ts @@ -0,0 +1,16 @@ +import { type WidgetPlugin, PluginType } from '@deephaven/plugin'; +import { vsGraph } from '@deephaven/icons'; +import type { dh } from '@deephaven/jsapi-types'; +import { DASHBOARD_ELEMENT, WIDGET_ELEMENT } from './widget/WidgetUtils'; +import PortalPanel from './layout/PortalPanel'; +import UIWidget from './UIWidget'; + +export const UIWidgetPlugin: WidgetPlugin = { + name: '@deephaven/js-plugin-ui', + type: PluginType.WIDGET_PLUGIN, + supportedTypes: [WIDGET_ELEMENT, DASHBOARD_ELEMENT, PortalPanel.displayName], + component: UIWidget, + icon: vsGraph, +}; + +export default UIWidgetPlugin; diff --git a/plugins/ui/src/js/src/index.ts b/plugins/ui/src/js/src/index.ts index bd84ac498..ec3c08e3e 100644 --- a/plugins/ui/src/js/src/index.ts +++ b/plugins/ui/src/js/src/index.ts @@ -1 +1,25 @@ -export * from './DashboardPlugin'; +import { PluginType } from '@deephaven/plugin'; +import { UIWidgetPlugin } from './UIWidgetPlugin'; +import { DashboardPlugin } from './DashboardPlugin'; +import styles from './styles.scss?inline'; + +// We need to inject the styles into the document when we're loaded... we only want to do this once. +const styleElement = document.createElement('style'); +styleElement.textContent = styles; +document.head.appendChild(styleElement); + +const UIDashboardPlugin = { + name: '@deephaven/js-plugin-ui.DashboardPlugin', + type: PluginType.DASHBOARD_PLUGIN, + component: DashboardPlugin, +}; + +const UIMultiPlugin = { + name: '@deephaven/js-plugin-ui', + type: PluginType.MULTI_PLUGIN, + plugins: [UIWidgetPlugin, UIDashboardPlugin], +}; + +export { DashboardPlugin }; + +export default UIMultiPlugin; diff --git a/plugins/ui/src/js/src/layout/Dashboard.tsx b/plugins/ui/src/js/src/layout/Dashboard.tsx index 20ca561ed..84b7662a7 100644 --- a/plugins/ui/src/js/src/layout/Dashboard.tsx +++ b/plugins/ui/src/js/src/layout/Dashboard.tsx @@ -1,6 +1,7 @@ import React from 'react'; +import { usePanelId as useLayoutPanelId } from '@deephaven/dashboard'; import { type ElementIdProps, type DashboardElementProps } from './LayoutUtils'; -import { usePanelId } from './ReactPanelContext'; +import { usePanelId as useReactPanelId } from './ReactPanelContext'; import NestedDashboard from './NestedDashboard'; import DashboardContent from './DashboardContent'; @@ -12,12 +13,14 @@ import DashboardContent from './DashboardContent'; */ function Dashboard({ children, - __dhId, + ...otherProps }: DashboardElementProps & ElementIdProps): JSX.Element | null { - const contextPanelId = usePanelId(); - const isNested = contextPanelId != null; + const contextPanelId = useLayoutPanelId(); + const reactPanelId = useReactPanelId(); + const isNested = contextPanelId != null || reactPanelId != null; if (isNested) { - return {children}; + // eslint-disable-next-line react/jsx-props-no-spreading + return {children}; } return {children}; diff --git a/plugins/ui/src/js/src/layout/NestedDashboard.tsx b/plugins/ui/src/js/src/layout/NestedDashboard.tsx index 24480fba2..d281f73d6 100644 --- a/plugins/ui/src/js/src/layout/NestedDashboard.tsx +++ b/plugins/ui/src/js/src/layout/NestedDashboard.tsx @@ -1,10 +1,15 @@ -import React, { type PropsWithChildren, useState } from 'react'; +import React, { type PropsWithChildren, useMemo, useState } from 'react'; import { Dashboard as DHCDashboard } from '@deephaven/dashboard'; import { useDashboardPlugins } from '@deephaven/plugin'; import NestedDashboardContent from './NestedDashboardContent'; import { type ElementIdProps } from './LayoutUtils'; -type NestedDashboardProps = PropsWithChildren; +type NestedDashboardProps = PropsWithChildren & { + /** + * Whether to show the headers on panels in this dashboard. Defaults to `true` + */ + showHeaders?: boolean; +}; /** * A dashboard that can be nested inside a panel. @@ -12,15 +17,23 @@ type NestedDashboardProps = PropsWithChildren; */ function NestedDashboard({ children, + showHeaders = true, __dhId, }: NestedDashboardProps): JSX.Element { const plugins = useDashboardPlugins(); const [layoutInitialized, setLayoutInitialized] = useState(false); + const layoutSettings = useMemo( + () => ({ hasHeaders: showHeaders }), + [showHeaders] + ); return (
{/* DHCDashboard creates GoldenLayout and provides LayoutManagerContext */} - setLayoutInitialized(true)}> + setLayoutInitialized(true)} + layoutSettings={layoutSettings} + > {plugins} {layoutInitialized && ( diff --git a/plugins/ui/src/js/src/layout/ReactPanel.tsx b/plugins/ui/src/js/src/layout/ReactPanel.tsx index 7f0121a59..b41f08d6f 100644 --- a/plugins/ui/src/js/src/layout/ReactPanel.tsx +++ b/plugins/ui/src/js/src/layout/ReactPanel.tsx @@ -15,6 +15,7 @@ import { useListener, type PersistentState, PersistentStateProvider, + usePanelId as useLayoutPanelId, } from '@deephaven/dashboard'; import { View, @@ -102,7 +103,8 @@ function ReactPanel({ useReactPanel(); const portalManager = usePortalPanelManager(); const portal = portalManager.get(panelId); - const panelTitle = title ?? metadata?.name ?? ''; + const panelTitle = + title ?? (typeof metadata === 'string' ? metadata : metadata?.name ?? ''); const [initialData, setInitialData] = useState( getInitialData() as PersistentState[] ); @@ -127,6 +129,10 @@ function ReactPanel({ const contentKey = useMemo(() => nanoid(), [metadata]); const parent = useParentItem(); + const layoutPanelId = useLayoutPanelId(); + // If we're opening these panels at the top level, they should be closable. + // We may make this settable as a prop on the panel in the future + const isClosable = layoutPanelId == null; const contextPanelId = usePanelId(); if (contextPanelId != null) { throw new NestedPanelError( @@ -180,6 +186,7 @@ function ReactPanel({ props: { metadata }, title: panelTitle, id: panelId, + isClosable, }; LayoutUtils.openComponent({ root: parent, config }); @@ -206,7 +213,7 @@ function ReactPanel({ LayoutUtils.renameComponent(parent, itemConfig, panelTitle); } }, - [parent, metadata, onOpen, panelId, panelTitle] + [isClosable, parent, metadata, onOpen, panelId, panelTitle] ); const widgetStatus = useWidgetStatus(); diff --git a/plugins/ui/src/js/src/layout/ReactPanelManager.ts b/plugins/ui/src/js/src/layout/ReactPanelManager.ts index 62d13c326..bf1f13d58 100644 --- a/plugins/ui/src/js/src/layout/ReactPanelManager.ts +++ b/plugins/ui/src/js/src/layout/ReactPanelManager.ts @@ -1,4 +1,5 @@ import { type PanelProps } from '@deephaven/dashboard'; +import { type UriVariableDescriptor } from '@deephaven/jsapi-bootstrap'; import { useContextOrThrow } from '@deephaven/react-hooks'; import { createContext, useCallback, useMemo } from 'react'; @@ -11,7 +12,7 @@ export interface ReactPanelManager { * Updating the metadata will cause the panel to be re-opened, or replaced if it is closed. * Can also be used for rehydration. */ - metadata: PanelProps['metadata']; + metadata: PanelProps['metadata'] | UriVariableDescriptor; /** Triggered when a panel is opened */ onOpen: (panelId: string) => void; @@ -46,7 +47,7 @@ export interface ReactPanelControl { * Updating the metadata will cause the panel to be re-opened, or replaced if it is closed. * Can also be used for rehydration. */ - metadata: PanelProps['metadata']; + metadata: PanelProps['metadata'] | UriVariableDescriptor; /** Must be called when the panel is opened */ onOpen: () => void; diff --git a/plugins/ui/src/js/src/layout/WidgetStatusContext.tsx b/plugins/ui/src/js/src/layout/WidgetStatusContext.tsx index ca5447594..05189241b 100644 --- a/plugins/ui/src/js/src/layout/WidgetStatusContext.tsx +++ b/plugins/ui/src/js/src/layout/WidgetStatusContext.tsx @@ -1,20 +1,21 @@ import { type WidgetDescriptor } from '@deephaven/dashboard'; +import { type UriVariableDescriptor } from '@deephaven/jsapi-bootstrap'; import { createContext } from 'react'; export type WidgetStatusLoading = { status: 'loading'; - descriptor: WidgetDescriptor; + descriptor: WidgetDescriptor | UriVariableDescriptor; }; export type WidgetStatusError = { status: 'error'; - descriptor: WidgetDescriptor; + descriptor: WidgetDescriptor | UriVariableDescriptor; error: NonNullable; }; export type WidgetStatusReady = { status: 'ready'; - descriptor: WidgetDescriptor; + descriptor: WidgetDescriptor | UriVariableDescriptor; }; export type WidgetStatus = diff --git a/plugins/ui/src/js/src/layout/usePanelManager.ts b/plugins/ui/src/js/src/layout/usePanelManager.ts index 67a9ca73f..a130bc76b 100644 --- a/plugins/ui/src/js/src/layout/usePanelManager.ts +++ b/plugins/ui/src/js/src/layout/usePanelManager.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { nanoid } from 'nanoid'; import { type WidgetDescriptor } from '@deephaven/dashboard'; +import { type UriVariableDescriptor } from '@deephaven/jsapi-bootstrap'; import Log from '@deephaven/log'; import { EMPTY_ARRAY, EMPTY_FUNCTION } from '@deephaven/utils'; import { type ReactPanelManager } from './ReactPanelManager'; @@ -16,7 +17,7 @@ const EMPTY_OBJECT = Object.freeze({}); export interface UsePanelManagerProps { /** Definition of the widget used to create this document. Used for titling panels if necessary. */ - widget: WidgetDescriptor; + widget: WidgetDescriptor | UriVariableDescriptor; /** * Data state to use when loading the widget. @@ -60,6 +61,14 @@ export function usePanelManager({ // We may need to check if we need to close this widget if all panels are closed const [isPanelsDirty, setPanelsDirty] = useState(false); + const id = useMemo( + () => + typeof widget === 'string' + ? widget + : widget.id ?? widget.name ?? widget.type, + [widget] + ); + const handleOpen = useCallback( (panelId: string) => { if (panelIds.current.includes(panelId)) { @@ -115,20 +124,15 @@ export function usePanelManager({ // Check if all the panels in this widget are closed // We do it outside of the `handleClose` function in case a new panel opens up in the same render cycle - log.debug2( - 'Widget', - widget.id, - 'open panel count', - panelIds.current.length - ); + log.debug2('Widget', id, 'open panel count', panelIds.current.length); if (panelIds.current.length === 0) { - log.debug('Widget', widget.id, 'closed all panels, triggering onClose'); + log.debug('Widget', id, 'closed all panels, triggering onClose'); onClose?.(); } else { onDataChange({ ...widgetData, panelIds: [...panelIds.current] }); } }, - [isPanelsDirty, widget.id, onClose, onDataChange, widgetData] + [isPanelsDirty, id, onClose, onDataChange, widgetData] ); const getPanelId = useCallback(() => { diff --git a/plugins/ui/src/js/src/styles.scss b/plugins/ui/src/js/src/styles.scss index 32871796a..3be7a1e3d 100644 --- a/plugins/ui/src/js/src/styles.scss +++ b/plugins/ui/src/js/src/styles.scss @@ -22,7 +22,8 @@ position: relative; } -.dh-react-panel { +.dh-react-panel, +.dh-default-panel-content { // using grid to allow the panel to grow to fill the container // without having to set the width/height explicitly // so that overflow will include it's padding @@ -89,6 +90,37 @@ border: none; } } + + // remove padding around single child nested dashboards in react panels + // (the .dh-nested-dashboard wrapper provides its own padding/border below) + &:has(> .dh-inner-react-panel > .dh-nested-dashboard:only-child) { + padding: 0 !important; // important required to override inline spectrum style + } +} + +.dh-react-panel, +.dh-default-panel-content, +.dh-panel { + .dh-nested-dashboard { + padding: var(--spectrum-global-dimension-size-100); + .dashboard-container { + border: 1px solid var(--dh-color-bg); + } + } +} + +// When the panel header is hidden (e.g. embed-widget-app or iframe widget mode), +// the outermost dh-nested-dashboard should not have extra padding/border. +// We detect this via GoldenLayout's DOM: .lm_header[display:none] precedes .lm_items. +// If a dh-nested-dashboard is itself nested inside another, keep the +// padding and border so inner dashboards remain visually distinct. +.lm_header[style*='display: none'] ~ .lm_items { + .dh-nested-dashboard:not(.dh-nested-dashboard .dh-nested-dashboard) { + padding: 0; + > .dashboard-container { + border: none; + } + } } .ui-text-wrap-balance { diff --git a/plugins/ui/src/js/src/widget/DashboardWidgetHandler.tsx b/plugins/ui/src/js/src/widget/DashboardWidgetHandler.tsx index 9ac2ee3bc..ee73be07e 100644 --- a/plugins/ui/src/js/src/widget/DashboardWidgetHandler.tsx +++ b/plugins/ui/src/js/src/widget/DashboardWidgetHandler.tsx @@ -5,6 +5,8 @@ import React, { useCallback } from 'react'; import Log from '@deephaven/log'; import { type WidgetDataUpdate, type WidgetId } from './WidgetTypes'; import WidgetHandler, { type WidgetHandlerProps } from './WidgetHandler'; +import { WIDGET_ELEMENT } from './WidgetUtils'; +import ReactPanel from '../layout/ReactPanel'; const log = Log.module('@deephaven/js-plugin-ui/DashboardWidgetHandler'); @@ -39,11 +41,38 @@ function DashboardWidgetHandler({ [onDataChange, id] ); + const { initialData, widgetDescriptor } = otherProps; + + const renderEmptyDocument = useCallback(() => { + // Document hasn't been initialized yet. Display a loading spinner if applicable. + if ( + typeof widgetDescriptor === 'object' && + widgetDescriptor.type === WIDGET_ELEMENT + ) { + // Rehydration. Mount ReactPanels for each panelId in the initial data + // so loading spinners or widget errors are shown + if (initialData?.panelIds != null && initialData.panelIds.length > 0) { + // Do not add a key here + // When the real document mounts, it doesn't use keys and will cause a remount + // which triggers the DocumentHandler to think the panels were closed and messes up the layout + // eslint-disable-next-line react/jsx-key + return initialData.panelIds.map(() => ); + } + // Default to a single panel so we can immediately show a loading spinner + return ; + } + + return null; + // We only want to update this callback when the widgetDescriptor changes, not every time the initialData changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [widgetDescriptor]); + return ( diff --git a/plugins/ui/src/js/src/widget/DefaultPanelContent.tsx b/plugins/ui/src/js/src/widget/DefaultPanelContent.tsx new file mode 100644 index 000000000..8a3f788b0 --- /dev/null +++ b/plugins/ui/src/js/src/widget/DefaultPanelContent.tsx @@ -0,0 +1,119 @@ +import React from 'react'; +import classNames from 'classnames'; +import { + View, + type ViewProps, + Flex, + type FlexProps, + LoadingOverlay, +} from '@deephaven/components'; +import useWidgetStatus from '../layout/useWidgetStatus'; +import WidgetErrorView from './WidgetErrorView'; + +type DefaultPanelContentProps = React.PropsWithChildren< + Pick< + ViewProps, + | 'backgroundColor' + | 'padding' + | 'paddingTop' + | 'paddingBottom' + | 'paddingStart' + | 'paddingEnd' + | 'paddingX' + | 'paddingY' + | 'overflow' + | 'UNSAFE_style' + | 'UNSAFE_className' + > & + Pick< + FlexProps, + | 'wrap' + | 'direction' + | 'justifyContent' + | 'alignContent' + | 'alignItems' + | 'gap' + | 'rowGap' + | 'columnGap' + > +>; + +/** + * Default content wrapper used when a deephaven.ui widget is opened directly + * (e.g. via the WidgetPlugin) without rendering its own GoldenLayout panel. + * This is used both when the root children are bare (non-layout) elements and + * when the root is a single `ui.panel` whose hosting `WidgetPanel` already + * provides a layout slot. The core `WidgetPanel` does not provide any padding + * or react to the widget's loading/error state, so this wrapper supplies + * those behaviors and forwards layout/style props to mirror `ReactPanel`. + */ +function DefaultPanelContent({ + children, + backgroundColor, + direction = 'column', + wrap, + overflow = 'auto', + justifyContent, + alignContent, + alignItems = 'start', + gap = 'size-100', + rowGap, + columnGap, + padding = 'size-100', + paddingTop, + paddingBottom, + paddingStart, + paddingEnd, + paddingX, + paddingY, + UNSAFE_style, + UNSAFE_className, +}: DefaultPanelContentProps): JSX.Element { + const widgetStatus = useWidgetStatus(); + + let content: React.ReactNode; + if (widgetStatus.status === 'loading') { + content = ; + } else if (widgetStatus.status === 'error') { + content = ; + } else { + content = children; + } + + return ( + + + {content} + + + ); +} + +export default DefaultPanelContent; diff --git a/plugins/ui/src/js/src/widget/DocumentHandler.test.tsx b/plugins/ui/src/js/src/widget/DocumentHandler.test.tsx index 047219531..787604614 100644 --- a/plugins/ui/src/js/src/widget/DocumentHandler.test.tsx +++ b/plugins/ui/src/js/src/widget/DocumentHandler.test.tsx @@ -7,6 +7,7 @@ import { type ReactPanelProps } from '../layout/LayoutUtils'; import { MixedPanelsError, NoChildrenError } from '../errors'; import { getComponentForElement } from './WidgetUtils'; import { ELEMENT_NAME } from '../elements/model/ElementConstants'; +import WidgetStatusContext from '../layout/WidgetStatusContext'; const mockReactPanel = jest.fn((props: ReactPanelProps) => (
ReactPanel
@@ -35,15 +36,22 @@ function makeDocument(children: React.ReactNode = []): React.ReactNode { }); } +const mockWidgetStatus = { + status: 'ready' as const, + descriptor: TestUtils.createMockProxy({}), +}; + function makeDocumentHandler({ children = makeDocument(), widget = TestUtils.createMockProxy({}), onClose = jest.fn(), }: Partial = {}) { return ( - - {children} - + + + {children} + + ); } @@ -72,10 +80,12 @@ it('should throw an error if the document mixes panel and non-panel elements', ( ); }); -it('should combine multiple single elements into one panel', () => { +it('should render multiple single elements directly without wrapping in a panel', () => { const children = makeDocument([makeElement('foo'), makeElement('bar')]); render(makeDocumentHandler({ children })); - expect(mockReactPanel).toHaveBeenCalledTimes(1); + // When not nested (no PanelIdContext), non-layout children are rendered directly + // without being wrapped in a ReactPanel (WidgetPlugin handles the panel) + expect(mockReactPanel).toHaveBeenCalledTimes(0); }); it('should render multiple panels', () => { diff --git a/plugins/ui/src/js/src/widget/DocumentHandler.tsx b/plugins/ui/src/js/src/widget/DocumentHandler.tsx index 616f54e52..e3a907eb5 100644 --- a/plugins/ui/src/js/src/widget/DocumentHandler.tsx +++ b/plugins/ui/src/js/src/widget/DocumentHandler.tsx @@ -1,5 +1,6 @@ import React from 'react'; -import { type WidgetDescriptor } from '@deephaven/dashboard'; +import { type WidgetDescriptor, usePanelId } from '@deephaven/dashboard'; +import { type UriVariableDescriptor } from '@deephaven/jsapi-bootstrap'; import Log from '@deephaven/log'; import { ReactPanelManagerContext } from '../layout/ReactPanelManager'; import { usePanelManager } from '../layout/usePanelManager'; @@ -10,7 +11,7 @@ const log = Log.module('@deephaven/js-plugin-ui/DocumentHandler'); export type DocumentHandlerProps = React.PropsWithChildren<{ /** Definition of the widget used to create this document. Used for titling panels if necessary. */ - widget: WidgetDescriptor; + widget: WidgetDescriptor | UriVariableDescriptor; /** * Data state to use when loading the widget. @@ -40,6 +41,10 @@ function DocumentHandler({ }: DocumentHandlerProps): JSX.Element { log.debug('Rendering document', widget); + // We can tell if we're opened by the DashboardPlugin or the WidgetPlugin or nested by checking the context ID + const contextPanelId = usePanelId(); + const isNested = contextPanelId != null; + const panelManager = usePanelManager({ widget, initialData, @@ -49,7 +54,7 @@ function DocumentHandler({ return ( - {getRootChildren(children, widget)} + {getRootChildren(children, widget, isNested)} ); } diff --git a/plugins/ui/src/js/src/widget/DocumentUtils.tsx b/plugins/ui/src/js/src/widget/DocumentUtils.tsx index 7ed65091d..54a614280 100644 --- a/plugins/ui/src/js/src/widget/DocumentUtils.tsx +++ b/plugins/ui/src/js/src/widget/DocumentUtils.tsx @@ -1,8 +1,10 @@ import React from 'react'; import { type WidgetDescriptor } from '@deephaven/dashboard'; +import { type UriVariableDescriptor } from '@deephaven/jsapi-bootstrap'; import ReactPanel from '../layout/ReactPanel'; import { MixedPanelsError, NoChildrenError } from '../errors'; import Dashboard from '../layout/Dashboard'; +import DefaultPanelContent from './DefaultPanelContent'; /** * Convert the children passed as the Document root to a valid root node, or throw if it's an invalid root configuration. @@ -12,11 +14,13 @@ import Dashboard from '../layout/Dashboard'; * * @param children Root children of the document. * @param widget Descriptor of the widget used to create this document. Used for titling panels if necessary. + * @param isNested Whether this document is nested inside a panel. * @returns The children, wrapped in a panel if necessary. */ export function getRootChildren( children: React.ReactNode, - widget: WidgetDescriptor + widget: WidgetDescriptor | UriVariableDescriptor, + isNested = false ): React.ReactNode { if (children == null) { return null; @@ -45,13 +49,32 @@ export function getRootChildren( throw new MixedPanelsError('Cannot mix Panel and Dashboard elements'); } + if (panelCount > 0 && isNested) { + if (panelCount === 1 && childrenArray.length === 1) { + // The widget consists of a single `ui.panel` and is hosted by an outer + // panel (e.g. the core `WidgetPanel` from the WidgetPlugin). Opening + // another GoldenLayout panel here would result in a panel-tab nested + // inside another panel-tab. Render the panel's children inline using + // the panel's layout/style props instead. + const panelElement = childrenArray[0] as React.ReactElement; + const { children: panelChildren, ...panelProps } = panelElement.props; + return ( + // eslint-disable-next-line react/jsx-props-no-spreading + + {panelChildren} + + ); + } + // Wrap it in a dashboard so it can be rendered properly + return {children}; + } + if (nonLayoutCount === childrenArray.length) { - // Just wrap it in a panel - return ( - - {children} - - ); + // Bare (non-layout) children. The widget is hosted by an outer panel + // (e.g. the core WidgetPanel) which doesn't provide padding or react to + // the widget's loading/error state. Wrap with DefaultPanelContent to + // supply those behaviors. + return {children}; } // It's already got layout defined, just return it diff --git a/plugins/ui/src/js/src/widget/WidgetHandler.tsx b/plugins/ui/src/js/src/widget/WidgetHandler.tsx index 487dcdd70..68d097e26 100644 --- a/plugins/ui/src/js/src/widget/WidgetHandler.tsx +++ b/plugins/ui/src/js/src/widget/WidgetHandler.tsx @@ -16,8 +16,11 @@ import { JSONRPCServer, JSONRPCServerAndClient, } from 'json-rpc-2.0'; -import { useLayoutManager, type WidgetDescriptor } from '@deephaven/dashboard'; -import { useWidget } from '@deephaven/jsapi-bootstrap'; +import { type WidgetDescriptor } from '@deephaven/dashboard'; +import { + type UriVariableDescriptor, + useWidget, +} from '@deephaven/jsapi-bootstrap'; import type { dh } from '@deephaven/jsapi-types'; import Log from '@deephaven/log'; import { usePluginsElementMap } from '@deephaven/plugin'; @@ -44,15 +47,12 @@ import DocumentHandler from './DocumentHandler'; import { transformNode, getComponentForElement, - WIDGET_ELEMENT, wrapCallable, - DASHBOARD_ELEMENT, } from './WidgetUtils'; import WidgetStatusContext, { type WidgetStatus, } from '../layout/WidgetStatusContext'; import WidgetErrorView from './WidgetErrorView'; -import ReactPanel from '../layout/ReactPanel'; import Toast, { TOAST_EVENT } from '../events/Toast'; import Navigate, { NAVIGATE_EVENT, QUERY_PARAM } from '../events/Navigate'; import UriExportedObject from './UriExportedObject'; @@ -62,7 +62,7 @@ const log = Log.module('@deephaven/js-plugin-ui/WidgetHandler'); export interface WidgetHandlerProps { /** Widget for this to handle */ - widgetDescriptor: WidgetDescriptor; + widgetDescriptor: WidgetDescriptor | UriVariableDescriptor; /** Widget ID maintained by the DashboardPlugin */ id: string; @@ -75,6 +75,9 @@ export interface WidgetHandlerProps { /** Triggered when the data in the widget changes. Only the changed data is provided. */ onDataChange?: (data: WidgetDataUpdate) => void; + + /** What to render when the document is empty and in a loading state */ + renderEmptyDocument?: () => JSX.Element | JSX.Element[] | null; } function WidgetHandler({ @@ -83,11 +86,10 @@ function WidgetHandler({ widgetDescriptor, initialData: initialDataProp, id, + renderEmptyDocument: renderEmptyDocumentProp, }: WidgetHandlerProps): JSX.Element | null { - const layoutManager = useLayoutManager(); const { widget, error: widgetError } = useWidget(widgetDescriptor); const [isLoading, setIsLoading] = useState(true); - const [prevWidget, setPrevWidget] = useState(widget); const [prevWidgetDescriptor, setPrevWidgetDescriptor] = useState(widgetDescriptor); // Cannot use usePrevious to change setIsLoading @@ -98,16 +100,6 @@ function WidgetHandler({ setIsLoading(true); } - if (widget !== prevWidget) { - setPrevWidget(widget); - if (widget != null && widget.type === DASHBOARD_ELEMENT) { - log.info( - 'Dashboard widget has changed, removing previous elements from layout' - ); - layoutManager.root.contentItems.forEach(item => item.remove()); - } - } - if (widgetError != null && isLoading) { setIsLoading(false); } @@ -222,29 +214,19 @@ function WidgetHandler({ * Renders an empty document. This is used when the widget is loading or has an error. */ () => { - // Document hasn't been initialized yet. Display a loading spinner if applicable. - if (widgetDescriptor.type === WIDGET_ELEMENT) { - // Rehydration. Mount ReactPanels for each panelId in the initial data - // so loading spinners or widget errors are shown - if (initialData?.panelIds != null && initialData.panelIds.length > 0) { - // Do not add a key here - // When the real document mounts, it doesn't use keys and will cause a remount - // which triggers the DocumentHandler to think the panels were closed and messes up the layout - // eslint-disable-next-line react/jsx-key - return initialData.panelIds.map(() => ); - } - // Default to a single panel so we can immediately show a loading spinner - return ; - } if (error != null) { // If there's an error and the document hasn't rendered yet (mostly applies to dashboards), explicitly show an error view return ; } + const result = renderEmptyDocumentProp?.(); + if (result != null) { + return result; + } // Dashboards should not have a default document. It breaks its render flow return null; }, - [error, initialData, widgetDescriptor] + [error, renderEmptyDocumentProp] ); const [uriObjectMap] = useState>(new Map()); diff --git a/tests/app.d/tests.app b/tests/app.d/tests.app index 31e450cea..5ec8304ef 100644 --- a/tests/app.d/tests.app +++ b/tests/app.d/tests.app @@ -18,4 +18,4 @@ file_11=ag_grid.py file_12=theme_demo.py file_13=ui_nested_dashboard.py file_14=ui_query_params.py - +file_15=ui_home_screen.py diff --git a/tests/app.d/ui_home_screen.py b/tests/app.d/ui_home_screen.py new file mode 100644 index 000000000..30d77e3bb --- /dev/null +++ b/tests/app.d/ui_home_screen.py @@ -0,0 +1,216 @@ +# ruff: noqa +# This example creates a bunch of dashboards, then creates a "home page" that displays a menu of the dashboards on the left, and the embedded dashboard on the right. +# Simply run this code snippet, then navigate to http://localhost:10000/iframe/widget/?name=home +# Creating example dashboards +from deephaven.time import dh_now +from deephaven import time_table, ui +import deephaven.plot.express as dx + + +# The control panel contain UI elements to set filtering +@ui.component +def control_panel(filter_type, set_filter_type, dates, set_dates, value, set_value): + return ui.panel( + ui.radio_group( + ui.radio("Date"), + ui.radio("Value"), + value=filter_type, + on_change=set_filter_type, + label="Filter type", + ), + ui.date_range_picker(label="Date filter", value=dates, on_change=set_dates), + ui.picker( + "A", "B", selected_key=value, on_change=set_value, label="Value filter" + ), + title="Controls", + ) + + +# The create dashboard component contains the state variables and returns the ui.row +@ui.component +def create_dashboard(start_date, end_date, table): + # State to choose the filter type + filter_type, set_filter_type = ui.use_state("Date") + + # State variable to filter by Value column + value, set_value = ui.use_state("A") + + # State variable to filter by Date column + dates, set_dates = ui.use_state({"start": start_date, "end": end_date}) + start = dates["start"] + end = dates["end"] + + # handler to filter the table + def handle_filter(filter_type, start, end, value, table): + if filter_type == "Date": + return table.where("Date >= start && Date < end") + else: + return table.where("Value=value") + + # memoize table operations and plots + filtered_table = ui.use_memo( + lambda start=start, end=end: handle_filter( + filter_type, start, end, value, table + ), + [filter_type, start, end, value, table], + ) + plot = ui.use_memo( + lambda: dx.line(filtered_table, x="Date", y="Row"), [filtered_table] + ) + + # This row will be the root layout for the dashboard + return ui.row( + control_panel(filter_type, set_filter_type, dates, set_dates, value, set_value), + ui.column( + ui.stack( + ui.panel(filtered_table, title="Filter table"), + ui.panel(table, title="Original table"), + active_item_index=0, + ), + ui.panel(plot, title="Plot"), + ), + ) + + +SECONDS_IN_DAY = 86400 +today = dh_now() +_table = time_table("PT1s").update_view( + [ + "Date=today.plusSeconds(SECONDS_IN_DAY*i)", + "Value=i%2==0 ? `A` : `B`", + "Row=i", + ] +) +_example_dashboard = ui.dashboard( + create_dashboard(today, today.plusSeconds(SECONDS_IN_DAY * 10), _table) +) + +_dash_2x1 = ui.dashboard(ui.row(ui.panel("A", title="A"), ui.panel("B", title="B"))) +_dash_1x2 = ui.dashboard(ui.column(ui.panel("A", title="A"), ui.panel("B", title="B"))) +_dash_2x2 = ui.dashboard( + ui.row( + ui.column(ui.panel("A", title="A"), ui.panel("C", title="C")), + ui.column(ui.panel("B", title="B"), ui.panel("D", title="D")), + ) +) +_dash_3x1 = ui.dashboard( + ui.row(ui.panel("A", title="A"), ui.panel("B", title="B"), ui.panel("C", title="C")) +) +_dash_stack = ui.dashboard( + ui.stack( + ui.panel("A", title="A"), ui.panel("B", title="B"), ui.panel("C", title="C") + ) +) +_dash_stack_nested = ui.dashboard( + ui.stack( + ui.panel( + ui.tabs(ui.tab("A1 content", title="A1"), ui.tab("A2 content", title="A2")), + title="A", + ), + ui.panel( + ui.tabs(ui.tab("B1 content", title="B1"), ui.tab("B2 content", title="B2")), + title="B", + ), + ) +) +_dash_layout_stack = ui.dashboard( + ui.row( + ui.stack( + ui.panel("A", title="A"), ui.panel("B", title="B"), ui.panel("C", title="C") + ), + ui.panel("D", title="D"), + ui.panel("E", title="E"), + ) +) +_double_dash = ui.dashboard( + ui.row( + ui.panel( + "In this example dashboard, we show how one can make a dashboard with no headers, display UI panel on the left and display another dashboard in a panel on the right. This could be another home screen." + ), + ui.panel(_example_dashboard), + ), + show_headers=False, +) +# End of creating dashboards + + +from deephaven import ui +from typing import Callable + +simple_dashboards = {} +for i in range(0, 10): + simple_dashboards[f"Dashboard {i}"] = ui.dashboard( + ui.panel(ui.heading(f"Dashboard {i}")) + ) + +layout_dashboards = { + "Row split (2x1)": _dash_2x1, + "Column split (1x2)": _dash_1x2, + "2x2": _dash_2x2, + "3x1": _dash_3x1, + "Basic stack": _dash_stack, + "Nested stack": _dash_stack_nested, + "Stack in a layout": _dash_layout_stack, +} + +complex_dashboards = { + "Example Dashboard": _example_dashboard, + "Nested Dashboard": _double_dash, +} + + +@ui.component +def dashboard_list(dashboards, on_select: Callable[[any], None]): + return ui.flex( + map( + lambda k: ui.button( + k, on_press=lambda: on_select(dashboards[k]), variant="ghost" + ), + dashboards, + ), + direction="Column", + ) + + +@ui.component +def dashboard_menu(on_select: Callable[[any], None]): + return ui.accordion( + ui.disclosure( + "Simple dashboards", dashboard_list(simple_dashboards, on_select=on_select) + ), + ui.disclosure( + "Layout dashboards", dashboard_list(layout_dashboards, on_select=on_select) + ), + ui.disclosure( + "Complex dashboards", + dashboard_list(complex_dashboards, on_select=on_select), + ), + ) + + +@ui.component +def home_screen(): + active_dashboard, set_active_dashboard = ui.use_state(None) + + return ui.dashboard( + ui.row( + ui.column( + ui.panel(dashboard_menu(on_select=set_active_dashboard)), width=20 + ), + ui.panel( + ui.view( + active_dashboard, + key=None if active_dashboard is None else id(active_dashboard), + width="100%", + height="100%", + ) + ), + ), + show_headers=False, + ) + + +# Show the home screen nested within itself for funsies +complex_dashboards["Nested Homescreen"] = home_screen() + +ui_home_screen = home_screen() diff --git a/tests/express.spec.ts b/tests/express.spec.ts index 7584d10e9..23f81ceae 100644 --- a/tests/express.spec.ts +++ b/tests/express.spec.ts @@ -64,8 +64,14 @@ test('Calendar line chart loads', async ({ page }) => { test('Chart image loads', async ({ page }) => { await gotoPage(page, ''); - await openPanel(page, 'line_plot_img', SELECTORS.REACT_PANEL_VISIBLE); - await expect(page.locator(SELECTORS.REACT_PANEL_VISIBLE)).toHaveScreenshot(); + await openPanel( + page, + 'line_plot_img', + SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE + ); + await expect( + page.locator(SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE) + ).toHaveScreenshot(); }); test('Bar chart on x loads', async ({ page }) => { @@ -105,13 +111,13 @@ test('Candlestick chart loads', async ({ page }) => { }); test('Titles fig loads', async ({ page }) => { - await gotoPage(page, ''); - await openPanel(page, 'titles_fig', '.js-plotly-plot'); - await expect(page.locator('.iris-chart-panel')).toHaveScreenshot(); + await gotoPage(page, ''); + await openPanel(page, 'titles_fig', '.js-plotly-plot'); + await expect(page.locator('.iris-chart-panel')).toHaveScreenshot(); }); test('Subplots fig loads', async ({ page }) => { await gotoPage(page, ''); await openPanel(page, 'keep_subplot_titles_fig', '.js-plotly-plot'); await expect(page.locator('.iris-chart-panel')).toHaveScreenshot(); -}); \ No newline at end of file +}); diff --git a/tests/theme.spec.ts b/tests/theme.spec.ts index 1f079472f..92312d60e 100644 --- a/tests/theme.spec.ts +++ b/tests/theme.spec.ts @@ -61,7 +61,7 @@ test.describe('Theme switching', () => { `/iframe/widget/?name=theme_demo&theme=@deephaven/js-plugin-theme-pack_${encodedTheme}` ); - await expect(page.locator(SELECTORS.REACT_PANEL)).toHaveCount(4, { + await expect(page.locator(SELECTORS.REACT_PANEL_VISIBLE)).toHaveCount(4, { timeout: 30000, }); await waitForLoad(page); diff --git a/tests/ui.spec.ts b/tests/ui.spec.ts index e60c32e5a..de1e4d9fb 100644 --- a/tests/ui.spec.ts +++ b/tests/ui.spec.ts @@ -5,19 +5,30 @@ import { SELECTORS, generateVarName, pasteInMonaco, + waitForLoad, } from './utils'; test('UI loads', async ({ page }) => { await gotoPage(page, ''); - await openPanel(page, 'ui_component', SELECTORS.REACT_PANEL_VISIBLE); - await expect(page.locator(SELECTORS.REACT_PANEL_VISIBLE)).toHaveScreenshot(); + await openPanel( + page, + 'ui_component', + SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE + ); + await expect( + page.locator(SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE) + ).toHaveScreenshot(); }); test('UI updates when interacting with it', async ({ page }) => { await gotoPage(page, ''); - await openPanel(page, 'ui_component', SELECTORS.REACT_PANEL_VISIBLE); + await openPanel( + page, + 'ui_component', + SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE + ); - const panelLocator = page.locator(SELECTORS.REACT_PANEL_VISIBLE); + const panelLocator = page.locator(SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE); ( await panelLocator.getByRole('button', { @@ -34,7 +45,9 @@ test('UI updates when interacting with it', async ({ page }) => { ).toBeVisible(); await panelLocator.getByRole('textbox', { name: 'Greeting' }).fill('goodbye'); await expect(panelLocator.getByText('You typed goodbye')).toBeVisible(); - await expect(page.locator(SELECTORS.REACT_PANEL_VISIBLE)).toHaveScreenshot(); + await expect( + page.locator(SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE) + ).toHaveScreenshot(); }); test('UI state resets when re-opening', async ({ page }) => { @@ -47,7 +60,7 @@ test('UI state resets when re-opening', async ({ page }) => { await pasteInMonaco(consoleInput, command); await page.keyboard.press('Enter'); - const panelLocator = page.locator(SELECTORS.REACT_PANEL_VISIBLE); + const panelLocator = page.locator(SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE); ( await panelLocator.getByRole('button', { name: 'You pressed me 0 times', @@ -77,15 +90,17 @@ test('UI state resets when re-opening', async ({ page }) => { test('boom component shows an error in a panel', async ({ page }) => { await gotoPage(page, ''); - await openPanel(page, 'ui_boom', SELECTORS.REACT_PANEL_VISIBLE); - await expect(page.locator(SELECTORS.REACT_PANEL_VISIBLE)).toBeVisible(); + await openPanel(page, 'ui_boom', SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE); + await expect( + page.locator(SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE) + ).toBeVisible(); await expect( page - .locator(SELECTORS.REACT_PANEL_VISIBLE) + .locator(SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE) .getByText('Exception', { exact: true }) ).toBeVisible(); await expect( - page.locator(SELECTORS.REACT_PANEL_VISIBLE).getByText('BOOM!') + page.locator(SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE).getByText('BOOM!') ).toBeVisible(); }); @@ -93,9 +108,13 @@ test('boom counter component shows error overlay after clicking the button twice page, }) => { await gotoPage(page, ''); - await openPanel(page, 'ui_boom_counter', SELECTORS.REACT_PANEL_VISIBLE); + await openPanel( + page, + 'ui_boom_counter', + SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE + ); - const panelLocator = page.locator(SELECTORS.REACT_PANEL_VISIBLE); + const panelLocator = page.locator(SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE); let btn = await panelLocator.getByRole('button', { name: 'Count is 0' }); await expect(btn).toBeVisible(); @@ -113,7 +132,7 @@ test('boom counter component shows error overlay after clicking the button twice test('Using keys for lists works', async ({ page }) => { await gotoPage(page, ''); - await openPanel(page, 'ui_cells', SELECTORS.REACT_PANEL_VISIBLE); + await openPanel(page, 'ui_cells', SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE); // setup cells await page.getByRole('button', { name: 'Add cell' }).click(); @@ -131,27 +150,39 @@ test('Using keys for lists works', async ({ page }) => { }); test('UI all components render 1', async ({ page }) => { - await gotoPage(page, ''); - await openPanel(page, 'ui_render_all1', SELECTORS.REACT_PANEL_VISIBLE); - await expect(page.locator(SELECTORS.REACT_PANEL_VISIBLE)).toHaveScreenshot(); + await gotoPage(page, '/iframe/widget/?name=ui_render_all1'); + await waitForLoad(page); + await expect( + page.locator(SELECTORS.DASHBOARD_ELEMENT_VISIBLE) + ).toHaveScreenshot(); }); test('UI all components render 2', async ({ page }) => { - await gotoPage(page, ''); - await openPanel(page, 'ui_render_all2', SELECTORS.REACT_PANEL_VISIBLE); - await expect(page.locator(SELECTORS.REACT_PANEL_VISIBLE)).toHaveScreenshot(); + await gotoPage(page, '/iframe/widget/?name=ui_render_all2'); + await waitForLoad(page); + await expect( + page.locator(SELECTORS.DASHBOARD_ELEMENT_VISIBLE) + ).toHaveScreenshot(); }); test('UI all components render 3', async ({ page }) => { - await gotoPage(page, ''); - await openPanel(page, 'ui_render_all3', SELECTORS.REACT_PANEL_VISIBLE); - await expect(page.locator(SELECTORS.REACT_PANEL_VISIBLE)).toHaveScreenshot(); + await gotoPage(page, '/iframe/widget/?name=ui_render_all3'); + await waitForLoad(page); + await expect( + page.locator(SELECTORS.DASHBOARD_ELEMENT_VISIBLE) + ).toHaveScreenshot(); }); test('UI markdown renders code correctly', async ({ page }) => { await gotoPage(page, ''); - await openPanel(page, 'markdown_code', SELECTORS.REACT_PANEL_VISIBLE); - await expect(page.locator(SELECTORS.REACT_PANEL_VISIBLE)).toHaveScreenshot(); + await openPanel( + page, + 'markdown_code', + SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE + ); + await expect( + page.locator(SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE) + ).toHaveScreenshot(); }); // Tests flex components render as expected @@ -185,18 +216,20 @@ test.describe('UI flex components', () => { ].forEach(i => { test(i.name, async ({ page }) => { await gotoPage(page, ''); - await openPanel(page, i.name, SELECTORS.REACT_PANEL_VISIBLE); + await openPanel(page, i.name, SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE); // need to wait for plots to be loaded before taking screenshot // easiest way to check that is if the traces are present if (i.traces > 0) { await expect( - await page.locator(SELECTORS.REACT_PANEL_VISIBLE).locator('.trace') + await page + .locator(SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE) + .locator('.trace') ).toHaveCount(i.traces); } await expect( - page.locator(SELECTORS.REACT_PANEL_VISIBLE) + page.locator(SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE) ).toHaveScreenshot(); }); }); @@ -233,18 +266,20 @@ test.describe('UI grid components', () => { ].forEach(i => { test(i.name, async ({ page }) => { await gotoPage(page, ''); - await openPanel(page, i.name, SELECTORS.REACT_PANEL_VISIBLE); + await openPanel(page, i.name, SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE); // need to wait for plots to be loaded before taking screenshot // easiest way to check that is if the traces are present if (i.traces > 0) { await expect( - await page.locator(SELECTORS.REACT_PANEL_VISIBLE).locator('.trace') + await page + .locator(SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE) + .locator('.trace') ).toHaveCount(i.traces); } await expect( - page.locator(SELECTORS.REACT_PANEL_VISIBLE) + page.locator(SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE) ).toHaveScreenshot(); }); }); diff --git a/tests/ui.spec.ts-snapshots/UI-all-components-render-1-1-chromium-linux.png b/tests/ui.spec.ts-snapshots/UI-all-components-render-1-1-chromium-linux.png index 5643bb754..7632b4709 100644 Binary files a/tests/ui.spec.ts-snapshots/UI-all-components-render-1-1-chromium-linux.png and b/tests/ui.spec.ts-snapshots/UI-all-components-render-1-1-chromium-linux.png differ diff --git a/tests/ui.spec.ts-snapshots/UI-all-components-render-1-1-firefox-linux.png b/tests/ui.spec.ts-snapshots/UI-all-components-render-1-1-firefox-linux.png index 35b9c4ed9..71de14079 100644 Binary files a/tests/ui.spec.ts-snapshots/UI-all-components-render-1-1-firefox-linux.png and b/tests/ui.spec.ts-snapshots/UI-all-components-render-1-1-firefox-linux.png differ diff --git a/tests/ui.spec.ts-snapshots/UI-all-components-render-1-1-webkit-linux.png b/tests/ui.spec.ts-snapshots/UI-all-components-render-1-1-webkit-linux.png index 71d0a6b68..8b1c85d28 100644 Binary files a/tests/ui.spec.ts-snapshots/UI-all-components-render-1-1-webkit-linux.png and b/tests/ui.spec.ts-snapshots/UI-all-components-render-1-1-webkit-linux.png differ diff --git a/tests/ui.spec.ts-snapshots/UI-all-components-render-2-1-chromium-linux.png b/tests/ui.spec.ts-snapshots/UI-all-components-render-2-1-chromium-linux.png index be1196193..f06da11ad 100644 Binary files a/tests/ui.spec.ts-snapshots/UI-all-components-render-2-1-chromium-linux.png and b/tests/ui.spec.ts-snapshots/UI-all-components-render-2-1-chromium-linux.png differ diff --git a/tests/ui.spec.ts-snapshots/UI-all-components-render-2-1-firefox-linux.png b/tests/ui.spec.ts-snapshots/UI-all-components-render-2-1-firefox-linux.png index 14cd62e40..c2c851504 100644 Binary files a/tests/ui.spec.ts-snapshots/UI-all-components-render-2-1-firefox-linux.png and b/tests/ui.spec.ts-snapshots/UI-all-components-render-2-1-firefox-linux.png differ diff --git a/tests/ui.spec.ts-snapshots/UI-all-components-render-2-1-webkit-linux.png b/tests/ui.spec.ts-snapshots/UI-all-components-render-2-1-webkit-linux.png index 045beb3d7..3c8563607 100644 Binary files a/tests/ui.spec.ts-snapshots/UI-all-components-render-2-1-webkit-linux.png and b/tests/ui.spec.ts-snapshots/UI-all-components-render-2-1-webkit-linux.png differ diff --git a/tests/ui.spec.ts-snapshots/UI-all-components-render-3-1-chromium-linux.png b/tests/ui.spec.ts-snapshots/UI-all-components-render-3-1-chromium-linux.png index 1d1579346..99dcde473 100644 Binary files a/tests/ui.spec.ts-snapshots/UI-all-components-render-3-1-chromium-linux.png and b/tests/ui.spec.ts-snapshots/UI-all-components-render-3-1-chromium-linux.png differ diff --git a/tests/ui.spec.ts-snapshots/UI-all-components-render-3-1-firefox-linux.png b/tests/ui.spec.ts-snapshots/UI-all-components-render-3-1-firefox-linux.png index da5941595..2fb8ae88a 100644 Binary files a/tests/ui.spec.ts-snapshots/UI-all-components-render-3-1-firefox-linux.png and b/tests/ui.spec.ts-snapshots/UI-all-components-render-3-1-firefox-linux.png differ diff --git a/tests/ui.spec.ts-snapshots/UI-all-components-render-3-1-webkit-linux.png b/tests/ui.spec.ts-snapshots/UI-all-components-render-3-1-webkit-linux.png index 9f4eef4df..cebe71395 100644 Binary files a/tests/ui.spec.ts-snapshots/UI-all-components-render-3-1-webkit-linux.png and b/tests/ui.spec.ts-snapshots/UI-all-components-render-3-1-webkit-linux.png differ diff --git a/tests/ui_dialog.spec.ts b/tests/ui_dialog.spec.ts index 0c528f69d..20297c17b 100644 --- a/tests/ui_dialog.spec.ts +++ b/tests/ui_dialog.spec.ts @@ -6,10 +6,10 @@ test.describe('UI dialog components', () => { ['my_popover', 'my_tray'].forEach(name => { test(name, async ({ page }) => { await gotoPage(page, ''); - await openPanel(page, name, SELECTORS.REACT_PANEL_VISIBLE); + await openPanel(page, name, SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE); await expect( - page.locator(SELECTORS.REACT_PANEL_VISIBLE) + page.locator(SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE) ).toHaveScreenshot(); }); }); @@ -17,7 +17,7 @@ test.describe('UI dialog components', () => { ['my_modal', 'my_fullscreen', 'my_fullscreen_takeover'].forEach(name => { test(name, async ({ page }) => { await gotoPage(page, ''); - await openPanel(page, name, SELECTORS.REACT_PANEL_VISIBLE); + await openPanel(page, name, SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE); await expect(page).toHaveScreenshot(); }); diff --git a/tests/ui_embed_widget.spec.ts b/tests/ui_embed_widget.spec.ts index fdde717e1..f0941f2f6 100644 --- a/tests/ui_embed_widget.spec.ts +++ b/tests/ui_embed_widget.spec.ts @@ -3,7 +3,9 @@ import { gotoPage, SELECTORS, waitForLoad } from './utils'; test('UI single panel loads in embed widget', async ({ page }) => { await gotoPage(page, '/iframe/widget/?name=ui_component'); - await expect(page.locator(SELECTORS.REACT_PANEL_VISIBLE)).toBeVisible(); + await expect( + page.locator(SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE) + ).toBeVisible(); await waitForLoad(page); await expect(page).toHaveScreenshot(); }); diff --git a/tests/ui_embed_widget.spec.ts-snapshots/UI-multi-panel-loads-in-embed-widget-1-chromium-linux.png b/tests/ui_embed_widget.spec.ts-snapshots/UI-multi-panel-loads-in-embed-widget-1-chromium-linux.png index 1a97bd92b..4ae0b3ef9 100644 Binary files a/tests/ui_embed_widget.spec.ts-snapshots/UI-multi-panel-loads-in-embed-widget-1-chromium-linux.png and b/tests/ui_embed_widget.spec.ts-snapshots/UI-multi-panel-loads-in-embed-widget-1-chromium-linux.png differ diff --git a/tests/ui_embed_widget.spec.ts-snapshots/UI-multi-panel-loads-in-embed-widget-1-firefox-linux.png b/tests/ui_embed_widget.spec.ts-snapshots/UI-multi-panel-loads-in-embed-widget-1-firefox-linux.png index 14e0326d9..4ae2ae863 100644 Binary files a/tests/ui_embed_widget.spec.ts-snapshots/UI-multi-panel-loads-in-embed-widget-1-firefox-linux.png and b/tests/ui_embed_widget.spec.ts-snapshots/UI-multi-panel-loads-in-embed-widget-1-firefox-linux.png differ diff --git a/tests/ui_embed_widget.spec.ts-snapshots/UI-multi-panel-loads-in-embed-widget-1-webkit-linux.png b/tests/ui_embed_widget.spec.ts-snapshots/UI-multi-panel-loads-in-embed-widget-1-webkit-linux.png index 6a98fab1e..ee0ee2a90 100644 Binary files a/tests/ui_embed_widget.spec.ts-snapshots/UI-multi-panel-loads-in-embed-widget-1-webkit-linux.png and b/tests/ui_embed_widget.spec.ts-snapshots/UI-multi-panel-loads-in-embed-widget-1-webkit-linux.png differ diff --git a/tests/ui_home_screen.spec.ts b/tests/ui_home_screen.spec.ts new file mode 100644 index 000000000..3b9b277e4 --- /dev/null +++ b/tests/ui_home_screen.spec.ts @@ -0,0 +1,23 @@ +import { expect, test } from '@playwright/test'; +import { openPanel, gotoPage, SELECTORS } from './utils'; + +test.describe('Homescreen', () => { + test('homescreen dashboard list visible', async ({ page }) => { + await gotoPage(page, ''); + await openPanel( + page, + 'ui_home_screen', + SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE, + true + ); + + // Get the outer panel (first match) for screenshots + const outerPanel = page + .locator(SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE) + .first(); + await expect(outerPanel).toBeVisible(); + + // The nested dashboard should contain the dashboard list for the home screen + await expect(outerPanel.getByText('Simple dashboards')).toBeVisible(); + }); +}); diff --git a/tests/ui_loading.spec.ts b/tests/ui_loading.spec.ts index ce4c8e999..0e9655f65 100644 --- a/tests/ui_loading.spec.ts +++ b/tests/ui_loading.spec.ts @@ -8,45 +8,60 @@ test('slow multi-panel shows 1 loader immediately and multiple after loading', a await openPanel( page, 'ui_slow_multi_panel', - SELECTORS.REACT_PANEL_VISIBLE, + SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE, false ); - const locator = page.locator(SELECTORS.REACT_PANEL); + const widgetLocator = page.locator(SELECTORS.WIDGET_LOADER_ELEMENT); // 1 loader should show up - await expect(locator.locator('.loading-spinner')).toHaveCount(1); + await expect(widgetLocator.locator('.loading-spinner')).toHaveCount(1); // Then disappear and show 3 panels - await expect(locator.locator('.loading-spinner')).toHaveCount(0); - await expect(locator).toHaveCount(3); - await expect(locator.getByText('Hello')).toHaveCount(1); - await expect(locator.getByText('World')).toHaveCount(1); - await expect(locator.getByText('Go BOOM!')).toHaveCount(1); + await expect(widgetLocator.locator('.loading-spinner')).toHaveCount(0); + const panelLocator = page.locator(SELECTORS.REACT_PANEL); + await expect(panelLocator).toHaveCount(3); + await expect(panelLocator.getByText('Hello')).toHaveCount(1); + await expect(panelLocator.getByText('World')).toHaveCount(1); + await expect(panelLocator.getByText('Go BOOM!')).toHaveCount(1); }); test('slow multi-panel shows loaders on element Reload', async ({ page }) => { await gotoPage(page, ''); - await openPanel(page, 'ui_slow_multi_panel', SELECTORS.REACT_PANEL_VISIBLE); - const locator = page.locator(SELECTORS.REACT_PANEL); - await expect(locator).toHaveCount(3); - await locator.getByText('Go BOOM!').click(); - await expect(locator.getByText('ValueError', { exact: true })).toHaveCount(3); - await expect(locator.getByText('BOOM!')).toHaveCount(3); - await locator.locator(':visible').getByText('Reload').first().click(); - // Loaders should show up - await expect(locator.locator('.loading-spinner')).toHaveCount(3); + await openPanel( + page, + 'ui_slow_multi_panel', + SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE + ); + const panelLocator = page.locator(SELECTORS.REACT_PANEL); + await expect(panelLocator).toHaveCount(3); + await panelLocator.getByText('Go BOOM!').click(); + await expect( + panelLocator.getByText('ValueError', { exact: true }) + ).toHaveCount(3); + await expect(panelLocator.getByText('BOOM!')).toHaveCount(3); + await panelLocator.locator(':visible').getByText('Reload').first().click(); + + const widgetLocator = page.locator(SELECTORS.WIDGET_LOADER_ELEMENT); + // Loader should show up + await expect(widgetLocator.locator('.loading-spinner')).toHaveCount(3); // Then disappear and show components again - await expect(locator.locator('.loading-spinner')).toHaveCount(0); - await expect(locator.getByText('Hello')).toHaveCount(1); - await expect(locator.getByText('World')).toHaveCount(1); - await expect(locator.getByText('Go BOOM!')).toHaveCount(1); + await expect(widgetLocator.locator('.loading-spinner')).toHaveCount(0); + + await expect(panelLocator).toHaveCount(3); + await expect(panelLocator.getByText('Hello')).toHaveCount(1); + await expect(panelLocator.getByText('World')).toHaveCount(1); + await expect(panelLocator.getByText('Go BOOM!')).toHaveCount(1); }); test('slow multi-panel shows loaders on page reload', async ({ page }) => { await gotoPage(page, ''); - await openPanel(page, 'ui_slow_multi_panel', SELECTORS.REACT_PANEL_VISIBLE); + await openPanel( + page, + 'ui_slow_multi_panel', + SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE + ); await page.reload(); - const locator = page.locator(SELECTORS.REACT_PANEL); + const locator = page.locator(SELECTORS.WIDGET_LOADER_ELEMENT); // Loader should show up - await expect(locator.locator('.loading-spinner')).toHaveCount(3); + await expect(locator.locator('.loading-spinner')).toHaveCount(1); // Then disappear and show error again await expect(locator.locator('.loading-spinner')).toHaveCount(0); await expect(locator.getByText('Hello')).toHaveCount(1); diff --git a/tests/ui_nested_dashboard.spec.ts b/tests/ui_nested_dashboard.spec.ts index ad71a24e9..f8cc43368 100644 --- a/tests/ui_nested_dashboard.spec.ts +++ b/tests/ui_nested_dashboard.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { openPanel, gotoPage, SELECTORS, waitForLoad } from './utils'; +import { openPanel, gotoPage, SELECTORS } from './utils'; test.describe('Nested Dashboards', () => { test('renders a dashboard inside a panel', async ({ page }) => { @@ -7,12 +7,14 @@ test.describe('Nested Dashboards', () => { await openPanel( page, 'ui_nested_dashboard', - SELECTORS.REACT_PANEL_VISIBLE, + SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE, true ); // Get the outer panel (first match) for screenshots - const outerPanel = page.locator(SELECTORS.REACT_PANEL_VISIBLE).first(); + const outerPanel = page + .locator(SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE) + .first(); await expect(outerPanel).toBeVisible(); // The nested dashboard should contain interior panels with content @@ -27,11 +29,13 @@ test.describe('Nested Dashboards', () => { await openPanel( page, 'ui_nested_dashboard_interactive', - SELECTORS.REACT_PANEL_VISIBLE, + SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE, true ); - const outerPanel = page.locator(SELECTORS.REACT_PANEL_VISIBLE).first(); + const outerPanel = page + .locator(SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE) + .first(); // Interact with a button inside the nested dashboard const button = outerPanel.getByRole('button', { @@ -49,11 +53,13 @@ test.describe('Nested Dashboards', () => { await openPanel( page, 'ui_deeply_nested_dashboard', - SELECTORS.REACT_PANEL_VISIBLE, + SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE, true ); - const outerPanel = page.locator(SELECTORS.REACT_PANEL_VISIBLE).first(); + const outerPanel = page + .locator(SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE) + .first(); await expect(outerPanel).toBeVisible(); // Should see content from multiple levels of nesting diff --git a/tests/ui_nested_dashboard.spec.ts-snapshots/Nested-Dashboards-renders-a-dashboard-inside-a-panel-1-chromium-linux.png b/tests/ui_nested_dashboard.spec.ts-snapshots/Nested-Dashboards-renders-a-dashboard-inside-a-panel-1-chromium-linux.png index 2aa9c0ad6..528ee9f46 100644 Binary files a/tests/ui_nested_dashboard.spec.ts-snapshots/Nested-Dashboards-renders-a-dashboard-inside-a-panel-1-chromium-linux.png and b/tests/ui_nested_dashboard.spec.ts-snapshots/Nested-Dashboards-renders-a-dashboard-inside-a-panel-1-chromium-linux.png differ diff --git a/tests/ui_nested_dashboard.spec.ts-snapshots/Nested-Dashboards-renders-a-dashboard-inside-a-panel-1-firefox-linux.png b/tests/ui_nested_dashboard.spec.ts-snapshots/Nested-Dashboards-renders-a-dashboard-inside-a-panel-1-firefox-linux.png index 30b525479..f666c0a60 100644 Binary files a/tests/ui_nested_dashboard.spec.ts-snapshots/Nested-Dashboards-renders-a-dashboard-inside-a-panel-1-firefox-linux.png and b/tests/ui_nested_dashboard.spec.ts-snapshots/Nested-Dashboards-renders-a-dashboard-inside-a-panel-1-firefox-linux.png differ diff --git a/tests/ui_nested_dashboard.spec.ts-snapshots/Nested-Dashboards-renders-a-dashboard-inside-a-panel-1-webkit-linux.png b/tests/ui_nested_dashboard.spec.ts-snapshots/Nested-Dashboards-renders-a-dashboard-inside-a-panel-1-webkit-linux.png index 57655e3c4..5b5f80573 100644 Binary files a/tests/ui_nested_dashboard.spec.ts-snapshots/Nested-Dashboards-renders-a-dashboard-inside-a-panel-1-webkit-linux.png and b/tests/ui_nested_dashboard.spec.ts-snapshots/Nested-Dashboards-renders-a-dashboard-inside-a-panel-1-webkit-linux.png differ diff --git a/tests/ui_plotly.spec.ts b/tests/ui_plotly.spec.ts index c3cf5cd35..bd125a9ef 100644 --- a/tests/ui_plotly.spec.ts +++ b/tests/ui_plotly.spec.ts @@ -6,10 +6,10 @@ test.describe('plotly works in deephaven.ui', () => { ['ui_basic_fig', 'ui_px_fig', 'ui_dx_fig'].forEach(name => { test(name, async ({ page }) => { await gotoPage(page, ''); - await openPanel(page, name, SELECTORS.REACT_PANEL_VISIBLE); + await openPanel(page, name, SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE); await expect( - page.locator(SELECTORS.REACT_PANEL_VISIBLE) + page.locator(SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE) ).toHaveScreenshot(); }); }); diff --git a/tests/ui_query_params.spec.ts b/tests/ui_query_params.spec.ts index 06b1c9e8f..c04ed481a 100644 --- a/tests/ui_query_params.spec.ts +++ b/tests/ui_query_params.spec.ts @@ -4,18 +4,26 @@ import { openPanel, gotoPage, SELECTORS } from './utils'; test.describe('UI query params', () => { test('displays query params from URL', async ({ page }) => { await gotoPage(page, '?page=2&sort=asc'); - await openPanel(page, 'ui_query_params', SELECTORS.REACT_PANEL_VISIBLE); + await openPanel( + page, + 'ui_query_params', + SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE + ); - const panel = page.locator(SELECTORS.REACT_PANEL_VISIBLE); + const panel = page.locator(SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE); await expect(panel.getByText('page=2')).toBeVisible(); await expect(panel.getByText('sort=asc')).toBeVisible(); }); test('displays no query params when URL has none', async ({ page }) => { await gotoPage(page, ''); - await openPanel(page, 'ui_query_params', SELECTORS.REACT_PANEL_VISIBLE); + await openPanel( + page, + 'ui_query_params', + SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE + ); - const panel = page.locator(SELECTORS.REACT_PANEL_VISIBLE); + const panel = page.locator(SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE); await expect(panel.getByText('No query params')).toBeVisible(); }); @@ -24,10 +32,10 @@ test.describe('UI query params', () => { await openPanel( page, 'ui_query_param_single', - SELECTORS.REACT_PANEL_VISIBLE + SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE ); - const panel = page.locator(SELECTORS.REACT_PANEL_VISIBLE); + const panel = page.locator(SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE); await expect(panel.getByText('page=5')).toBeVisible(); }); @@ -36,18 +44,22 @@ test.describe('UI query params', () => { await openPanel( page, 'ui_query_param_single', - SELECTORS.REACT_PANEL_VISIBLE + SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE ); - const panel = page.locator(SELECTORS.REACT_PANEL_VISIBLE); + const panel = page.locator(SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE); await expect(panel.getByText('page=None')).toBeVisible(); }); test('set_query_param updates the URL', async ({ page }) => { await gotoPage(page, ''); - await openPanel(page, 'ui_set_query_param', SELECTORS.REACT_PANEL_VISIBLE); + await openPanel( + page, + 'ui_set_query_param', + SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE + ); - const panel = page.locator(SELECTORS.REACT_PANEL_VISIBLE); + const panel = page.locator(SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE); await expect(panel.getByText('counter=0')).toBeVisible(); await panel.getByRole('button', { name: 'Increment (current: 0)' }).click(); @@ -64,9 +76,13 @@ test.describe('UI query params', () => { test('supports multi-value query params', async ({ page }) => { await gotoPage(page, '?tag=python&tag=java'); - await openPanel(page, 'ui_query_params', SELECTORS.REACT_PANEL_VISIBLE); + await openPanel( + page, + 'ui_query_params', + SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE + ); - const panel = page.locator(SELECTORS.REACT_PANEL_VISIBLE); + const panel = page.locator(SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE); await expect(panel.getByText('tag=python')).toBeVisible(); await expect(panel.getByText('tag=java')).toBeVisible(); }); diff --git a/tests/ui_table.spec.ts b/tests/ui_table.spec.ts index 99c5ed4fc..0d8ee64f7 100644 --- a/tests/ui_table.spec.ts +++ b/tests/ui_table.spec.ts @@ -1,7 +1,5 @@ import { expect, test } from '@playwright/test'; -import { openPanel, gotoPage, clickGridRow } from './utils'; - -const REACT_PANEL_VISIBLE = '.dh-react-panel:visible'; +import { SELECTORS, openPanel, gotoPage, clickGridRow } from './utils'; test.describe('UI table', () => { [ @@ -36,18 +34,24 @@ test.describe('UI table', () => { ].forEach(name => { test(name, async ({ page }) => { await gotoPage(page, ''); - await openPanel(page, name, REACT_PANEL_VISIBLE); + await openPanel(page, name, SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE); - await expect(page.locator(REACT_PANEL_VISIBLE)).toHaveScreenshot(); + await expect( + page.locator(SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE) + ).toHaveScreenshot(); }); }); }); test('UI table responds to prop changes', async ({ page }) => { await gotoPage(page, ''); - await openPanel(page, 'toggle_table', REACT_PANEL_VISIBLE); + await openPanel( + page, + 'toggle_table', + SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE + ); - const locator = page.locator(REACT_PANEL_VISIBLE); + const locator = page.locator(SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE); await expect(locator).toHaveScreenshot(); @@ -61,9 +65,11 @@ test('UI table responds to prop changes', async ({ page }) => { test('UI table on_selection_change', async ({ page }) => { await gotoPage(page, ''); - await openPanel(page, 't_selection', REACT_PANEL_VISIBLE); + await openPanel(page, 't_selection', SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE); - const locator = page.locator(`${REACT_PANEL_VISIBLE} .iris-grid`); + const locator = page.locator( + `${SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE} .iris-grid` + ); await clickGridRow(locator, 3); await expect(page.getByText('Selection: CAT/NYPE')).toBeVisible(); diff --git a/tests/utils.ts b/tests/utils.ts index cb7962dc2..fd46ebebb 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -5,6 +5,12 @@ export const SELECTORS = { REACT_PANEL: '.dh-react-panel', REACT_PANEL_VISIBLE: '.dh-react-panel:visible', REACT_PANEL_OVERLAY: '.dh-react-panel-overlay', + WIDGET_LOADER_ELEMENT: '.dh-panel.widget-loader-deephaven\\.ui\\.Element', + WIDGET_LOADER_ELEMENT_VISIBLE: + '.dh-panel.widget-loader-deephaven\\.ui\\.Element:visible', + DASHBOARD_ELEMENT: '.dh-panel.widget-loader-deephaven\\.ui\\.Dashboard', + DASHBOARD_ELEMENT_VISIBLE: + '.dh-panel.widget-loader-deephaven\\.ui\\.Dashboard:visible', }; const ROW_HEIGHT = 19;