From f4ede205c513c29148a9d770eddcec8a9c63dd77 Mon Sep 17 00:00:00 2001 From: Colin Rudd Date: Tue, 4 Nov 2025 14:15:09 -0500 Subject: [PATCH 01/27] Handle RGL dropping item in get rglLayout --- desktop/cmp/dash/canvas/DashCanvasModel.ts | 34 ++++++++++++---------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/desktop/cmp/dash/canvas/DashCanvasModel.ts b/desktop/cmp/dash/canvas/DashCanvasModel.ts index 566c27c84..9315f7d8a 100644 --- a/desktop/cmp/dash/canvas/DashCanvasModel.ts +++ b/desktop/cmp/dash/canvas/DashCanvasModel.ts @@ -106,21 +106,25 @@ export class DashCanvasModel private isLoadingState: boolean; get rglLayout() { - return this.layout.map(it => { - const dashCanvasView = this.getView(it.i), - {autoHeight, viewSpec} = dashCanvasView; - - return { - ...it, - resizeHandles: autoHeight - ? ['w', 'e'] - : ['s', 'w', 'e', 'n', 'sw', 'nw', 'se', 'ne'], - maxH: viewSpec.maxHeight, - minH: viewSpec.minHeight, - maxW: viewSpec.maxWidth, - minW: viewSpec.minWidth - }; - }); + return this.layout + .map(it => { + const dashCanvasView = this.getView(it.i); + if (!dashCanvasView) return null; + + const {autoHeight, viewSpec} = dashCanvasView; + + return { + ...it, + resizeHandles: autoHeight + ? ['w', 'e'] + : ['s', 'w', 'e', 'n', 'sw', 'nw', 'se', 'ne'], + maxH: viewSpec.maxHeight, + minH: viewSpec.minHeight, + maxW: viewSpec.maxWidth, + minW: viewSpec.minWidth + }; + }) + .filter(Boolean); } constructor({ From 0ed37228a2aa0b012e6d719f3b131f83df14f326 Mon Sep 17 00:00:00 2001 From: Colin Rudd Date: Fri, 7 Nov 2025 18:13:54 -0500 Subject: [PATCH 02/27] DashCanvas: Support dragging widget into and dropping to add --- desktop/cmp/dash/canvas/DashCanvas.ts | 2 +- desktop/cmp/dash/canvas/DashCanvasModel.ts | 35 +++++++++++++++++++++- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/desktop/cmp/dash/canvas/DashCanvas.ts b/desktop/cmp/dash/canvas/DashCanvas.ts index 8e3ae8813..15320edfd 100644 --- a/desktop/cmp/dash/canvas/DashCanvas.ts +++ b/desktop/cmp/dash/canvas/DashCanvas.ts @@ -98,7 +98,7 @@ export const [DashCanvas, dashCanvas] = hoistCmp.withFactory({ ), ...rglOptions }), - emptyContainerOverlay() + emptyContainerOverlay({omit: !model.showAddViewButtonWhenEmpty}) ], [TEST_ID]: testId }) diff --git a/desktop/cmp/dash/canvas/DashCanvasModel.ts b/desktop/cmp/dash/canvas/DashCanvasModel.ts index 9315f7d8a..1b2700687 100644 --- a/desktop/cmp/dash/canvas/DashCanvasModel.ts +++ b/desktop/cmp/dash/canvas/DashCanvasModel.ts @@ -50,6 +50,12 @@ export interface DashCanvasConfig extends DashConfig it.i === this.DROPPING_ELEM_ID); + droppingItem.i = newViewModel.id; + this.onRglLayoutChange(rglLayout); + return newViewModel; + } + /** * Remove a view from the DashCanvas * @param id - DashCanvasViewModel id to remove from the container @@ -390,6 +421,8 @@ export class DashCanvasModel onRglLayoutChange(rglLayout) { rglLayout = rglLayout.map(it => pick(it, ['i', 'x', 'y', 'w', 'h'])); + if (rglLayout.some(it => it.i === this.DROPPING_ELEM_ID)) return; + this.setLayout(rglLayout); } From 651eaa828cb5c3ffe6d208c16dea07af64521400 Mon Sep 17 00:00:00 2001 From: Colin Rudd Date: Thu, 4 Dec 2025 17:20:35 -0500 Subject: [PATCH 03/27] Add typings --- desktop/cmp/dash/canvas/DashCanvasModel.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/desktop/cmp/dash/canvas/DashCanvasModel.ts b/desktop/cmp/dash/canvas/DashCanvasModel.ts index 1b2700687..33dccf601 100644 --- a/desktop/cmp/dash/canvas/DashCanvasModel.ts +++ b/desktop/cmp/dash/canvas/DashCanvasModel.ts @@ -4,6 +4,7 @@ * * Copyright © 2025 Extremely Heavy Industries Inc. */ +import type {Layout} from 'react-grid-layout'; import {Persistable, PersistableState, PersistenceProvider, XH} from '@xh/hoist/core'; import {required} from '@xh/hoist/data'; import {DashCanvasViewModel, DashCanvasViewSpec, DashConfig, DashViewState, DashModel} from '../'; @@ -117,6 +118,8 @@ export class DashCanvasModel return this.layout .map(it => { const dashCanvasView = this.getView(it.i); + + // `dashCanvasView` will not be found if `it` is a dropping element. if (!dashCanvasView) return null; const {autoHeight, viewSpec} = dashCanvasView; @@ -280,19 +283,16 @@ export class DashCanvasModel opts: { title: string; state: any; - layout: { - x: number; - y: number; - w: number; - h: number; - }; + layout: DashCanvasItemLayout; }, - rglLayout: any[] + rglLayout: Layout[] ): DashCanvasViewModel { const newViewModel: DashCanvasViewModel = this.addViewInternal(specId, opts), droppingItem: any = rglLayout.find(it => it.i === this.DROPPING_ELEM_ID); + droppingItem.i = newViewModel.id; this.onRglLayoutChange(rglLayout); + return newViewModel; } @@ -419,8 +419,12 @@ export class DashCanvasModel return model; } - onRglLayoutChange(rglLayout) { + onRglLayoutChange(rglLayout: Layout[]) { rglLayout = rglLayout.map(it => pick(it, ['i', 'x', 'y', 'w', 'h'])); + + // Early out if RGL is changing layout as user is dragging droppable + // item around the canvas. This will be called again once dragging + // has stopped and user has dropped the item onto the canvas. if (rglLayout.some(it => it.i === this.DROPPING_ELEM_ID)) return; this.setLayout(rglLayout); From fb2ab47b91bca28a3d0da243611df576f76f0eb2 Mon Sep 17 00:00:00 2001 From: Colin Rudd Date: Fri, 5 Dec 2025 11:25:58 -0500 Subject: [PATCH 04/27] Make droppable a first class option on DashCanvas, not something to be implemented via rglOptions escape hatch. --- desktop/cmp/dash/canvas/DashCanvas.ts | 14 +++- desktop/cmp/dash/canvas/DashCanvasModel.ts | 95 +++++++++++++++++----- 2 files changed, 86 insertions(+), 23 deletions(-) diff --git a/desktop/cmp/dash/canvas/DashCanvas.ts b/desktop/cmp/dash/canvas/DashCanvas.ts index 15320edfd..fac118817 100644 --- a/desktop/cmp/dash/canvas/DashCanvas.ts +++ b/desktop/cmp/dash/canvas/DashCanvas.ts @@ -4,6 +4,12 @@ * * Copyright © 2025 Extremely Heavy Industries Inc. */ +import ReactGridLayout, { + type ReactGridLayoutProps, + type DragOverEvent, + type Layout, + WidthProvider +} from 'react-grid-layout'; import {showContextMenu} from '@xh/hoist/kit/blueprint'; import composeRefs from '@seznam/compose-react-refs'; import {div, vbox, vspacer} from '@xh/hoist/cmp/layout'; @@ -20,8 +26,6 @@ import '@xh/hoist/desktop/register'; import {Classes, overlay} from '@xh/hoist/kit/blueprint'; import {consumeEvent, TEST_ID} from '@xh/hoist/utils/js'; import classNames from 'classnames'; -import ReactGridLayout, {WidthProvider} from 'react-grid-layout'; -import type {ReactGridLayoutProps} from 'react-grid-layout'; import {DashCanvasModel} from './DashCanvasModel'; import {dashCanvasContextMenu} from './impl/DashCanvasContextMenu'; import {dashCanvasView} from './impl/DashCanvasView'; @@ -87,7 +91,7 @@ export const [DashCanvas, dashCanvas] = hoistCmp.withFactory({ draggableHandle: '.xh-dash-tab.xh-panel > .xh-panel__content > .xh-panel-header', draggableCancel: '.xh-button', - onLayoutChange: layout => model.onRglLayoutChange(layout), + onLayoutChange: (layout: Layout[]) => model.onRglLayoutChange(layout), onResizeStart: () => (model.isResizing = true), onResizeStop: () => (model.isResizing = false), items: model.viewModels.map(vm => @@ -96,6 +100,10 @@ export const [DashCanvas, dashCanvas] = hoistCmp.withFactory({ item: dashCanvasView({model: vm}) }) ), + isDroppable: model.droppable, + onDrop: (layout: Layout[], layoutItem: Layout, evt: Event) => + model.onDrop(layout, layoutItem, evt), + onDropDragOver: (evt: DragOverEvent) => model.onDropDragOver(evt), ...rglOptions }), emptyContainerOverlay({omit: !model.showAddViewButtonWhenEmpty}) diff --git a/desktop/cmp/dash/canvas/DashCanvasModel.ts b/desktop/cmp/dash/canvas/DashCanvasModel.ts index 33dccf601..01f75300f 100644 --- a/desktop/cmp/dash/canvas/DashCanvasModel.ts +++ b/desktop/cmp/dash/canvas/DashCanvasModel.ts @@ -4,7 +4,7 @@ * * Copyright © 2025 Extremely Heavy Industries Inc. */ -import type {Layout} from 'react-grid-layout'; +import type {DragOverEvent, Layout} from 'react-grid-layout'; import {Persistable, PersistableState, PersistenceProvider, XH} from '@xh/hoist/core'; import {required} from '@xh/hoist/data'; import {DashCanvasViewModel, DashCanvasViewSpec, DashConfig, DashViewState, DashModel} from '../'; @@ -17,6 +17,7 @@ import {createObservableRef} from '@xh/hoist/utils/react'; import { defaultsDeep, find, + omit, uniqBy, times, without, @@ -52,6 +53,25 @@ export interface DashCanvasConfig extends DashConfig void; + + /** + * Optional callback to invoke when an item is dragged over the canvas. This may be used to + * customize how the size of the dropping placeholder is calculated. The callback should + * return an object with optional 'w' and 'h' properties indicating the desired width and height + * (in grid units) of the dropping placeholder. If not provided, Hoist's own default logic will be used. + */ + onDropDragOver?: (e: DragOverEvent) => {w?: number; h?: number} | false | undefined; + /** * Whether an overlay with an Add View button should be rendered * when the canvas is empty. Default true. @@ -96,6 +116,9 @@ export class DashCanvasModel DROPPING_ELEM_ID = '__dropping-elem__'; maxRows: number; showAddViewButtonWhenEmpty: boolean; + droppable: boolean; + onDropDone: (viewModel: DashCanvasViewModel) => void; + draggedInView: DashCanvasItemState; /** Current number of rows in canvas */ get rows(): number { @@ -155,7 +178,10 @@ export class DashCanvasModel maxRows = Infinity, containerPadding = margin, extraMenuItems, - showAddViewButtonWhenEmpty = true + showAddViewButtonWhenEmpty = true, + droppable = false, + onDropDone, + onDropDragOver }: DashCanvasConfig) { super(); makeObservable(this); @@ -204,6 +230,10 @@ export class DashCanvasModel this.addViewButtonText = addViewButtonText; this.extraMenuItems = extraMenuItems; this.showAddViewButtonWhenEmpty = showAddViewButtonWhenEmpty; + this.droppable = droppable; + this.onDropDone = onDropDone; + // Override default onDropDragOver if provided + if (onDropDragOver) this.onDropDragOver = onDropDragOver; this.loadState(initialState); this.state = this.buildState(); @@ -278,24 +308,6 @@ export class DashCanvasModel return this.addViewInternal(specId, {title, layout, state}); } - @action dropViewIntoCanvas( - specId: string, - opts: { - title: string; - state: any; - layout: DashCanvasItemLayout; - }, - rglLayout: Layout[] - ): DashCanvasViewModel { - const newViewModel: DashCanvasViewModel = this.addViewInternal(specId, opts), - droppingItem: any = rglLayout.find(it => it.i === this.DROPPING_ELEM_ID); - - droppingItem.i = newViewModel.id; - this.onRglLayoutChange(rglLayout); - - return newViewModel; - } - /** * Remove a view from the DashCanvas * @param id - DashCanvasViewModel id to remove from the container @@ -347,6 +359,31 @@ export class DashCanvasModel this.getView(id)?.ensureVisible(); } + onDrop(rglLayout: Layout[], layoutItem: Layout, evt: Event) { + throwIf( + !this.draggedInView, + `No draggedInView set on DashCanvasModel prior to onDrop operation. + Typically a developer would set this in response to dragstart events from + a DashViewTray or similar component.` + ); + + const {viewSpecId, title, state} = this.draggedInView, + layout = omit(layoutItem, 'i'), + newViewModel = this.doDrop(viewSpecId, {title, state, layout}, rglLayout); + + this.draggedInView = null; + this.onDropDone?.(newViewModel); + } + + setDraggedInView(view?: DashCanvasItemState) { + this.draggedInView = view; + } + + onDropDragOver(e: DragOverEvent): {w?: number; h?: number} | false | undefined { + if (!this.draggedInView) return false; + return {w: 6, h: 6}; + } + //------------------------ // Persistable Interface //------------------------ @@ -362,6 +399,24 @@ export class DashCanvasModel //------------------------ // Implementation //------------------------ + @action + private doDrop( + specId: string, + opts: { + title: string; + state: any; + layout: DashCanvasItemLayout; + }, + rglLayout: Layout[] + ): DashCanvasViewModel { + const newViewModel: DashCanvasViewModel = this.addViewInternal(specId, opts), + droppingItem: any = rglLayout.find(it => it.i === this.DROPPING_ELEM_ID); + + droppingItem.i = newViewModel.id; + this.onRglLayoutChange(rglLayout); + return newViewModel; + } + private getLayoutFromPosition(position: string, specId: string) { switch (position) { case 'first': From d7e18d4c8f92ef33f58fbb4673fd1c6218a5dc52 Mon Sep 17 00:00:00 2001 From: Colin Rudd Date: Wed, 17 Dec 2025 14:18:24 -0500 Subject: [PATCH 05/27] Update react-grid-layout to v2.1.0 --- desktop/cmp/dash/canvas/DashCanvas.ts | 96 +++++++++++++--------- desktop/cmp/dash/canvas/DashCanvasModel.ts | 86 +++++++++++-------- package.json | 3 +- yarn.lock | 23 ++---- 4 files changed, 116 insertions(+), 92 deletions(-) diff --git a/desktop/cmp/dash/canvas/DashCanvas.ts b/desktop/cmp/dash/canvas/DashCanvas.ts index fac118817..be318c362 100644 --- a/desktop/cmp/dash/canvas/DashCanvas.ts +++ b/desktop/cmp/dash/canvas/DashCanvas.ts @@ -4,12 +4,15 @@ * * Copyright © 2025 Extremely Heavy Industries Inc. */ +import {DragEvent} from 'react'; import ReactGridLayout, { - type ReactGridLayoutProps, - type DragOverEvent, - type Layout, - WidthProvider + type LayoutItem, + type GridLayoutProps, + useContainerWidth, + noCompactor, + verticalCompactor } from 'react-grid-layout'; + import {showContextMenu} from '@xh/hoist/kit/blueprint'; import composeRefs from '@seznam/compose-react-refs'; import {div, vbox, vspacer} from '@xh/hoist/cmp/layout'; @@ -31,6 +34,7 @@ import {dashCanvasContextMenu} from './impl/DashCanvasContextMenu'; import {dashCanvasView} from './impl/DashCanvasView'; import 'react-grid-layout/css/styles.css'; +import 'react-resizable/css/styles.css'; import './DashCanvas.scss'; export interface DashCanvasProps extends HoistProps, TestSupportProps { @@ -40,7 +44,7 @@ export interface DashCanvasProps extends HoistProps, TestSuppor * {@link https://www.npmjs.com/package/react-grid-layout#grid-layout-props} * Note that some ReactGridLayout props are managed directly by DashCanvas and will be overridden if provided here. */ - rglOptions?: ReactGridLayoutProps; + rglOptions?: GridLayoutProps; } /** @@ -63,7 +67,7 @@ export const [DashCanvas, dashCanvas] = hoistCmp.withFactory({ const isDraggable = !model.layoutLocked, isResizable = !model.layoutLocked, [padX, padY] = model.containerPadding; - + const {width, containerRef, mounted} = useContainerWidth(); return refreshContextView({ model: model.refreshContextModel, item: div({ @@ -73,41 +77,51 @@ export const [DashCanvas, dashCanvas] = hoistCmp.withFactory({ isResizable ? `${className}--resizable` : null ), style: {padding: `${padY}px ${padX}px`}, - ref: composeRefs(ref, model.ref), + ref: composeRefs(ref, model.ref, containerRef), onContextMenu: e => onContextMenu(e, model), - items: [ - reactGridLayout({ - layout: model.rglLayout, - cols: model.columns, - rowHeight: model.rowHeight, - isDraggable, - isResizable, - compactType: model.compact ? 'vertical' : null, - margin: model.margin, - maxRows: model.maxRows, - containerPadding: [0, 0], // Workaround for https://github.com/react-grid-layout/react-grid-layout/issues/1990 - autoSize: true, - isBounded: true, - draggableHandle: - '.xh-dash-tab.xh-panel > .xh-panel__content > .xh-panel-header', - draggableCancel: '.xh-button', - onLayoutChange: (layout: Layout[]) => model.onRglLayoutChange(layout), - onResizeStart: () => (model.isResizing = true), - onResizeStop: () => (model.isResizing = false), - items: model.viewModels.map(vm => - div({ - key: vm.id, - item: dashCanvasView({model: vm}) - }) - ), - isDroppable: model.droppable, - onDrop: (layout: Layout[], layoutItem: Layout, evt: Event) => - model.onDrop(layout, layoutItem, evt), - onDropDragOver: (evt: DragOverEvent) => model.onDropDragOver(evt), - ...rglOptions - }), - emptyContainerOverlay({omit: !model.showAddViewButtonWhenEmpty}) - ], + items: mounted + ? [ + reactGridLayout({ + layout: model.rglLayout, + width, + gridConfig: { + cols: model.columns, + rowHeight: model.rowHeight, + margin: model.margin, + maxRows: model.maxRows + }, + dragConfig: { + enabled: isDraggable, + handle: '.xh-dash-tab.xh-panel > .xh-panel__content > .xh-panel-header', + cancel: '.xh-button', + bounded: true + }, + resizeConfig: { + enabled: isResizable + }, + dropConfig: { + enabled: model.droppable, + defaultItem: {w: 6, h: 6} + }, + compactor: model.compact ? verticalCompactor : noCompactor, + onLayoutChange: (layout: LayoutItem[]) => + model.onRglLayoutChange(layout), + onResizeStart: () => (model.isResizing = true), + onResizeStop: () => (model.isResizing = false), + items: model.viewModels.map(vm => + div({ + key: vm.id, + item: dashCanvasView({model: vm}) + }) + ), + onDropDragOver: (evt: DragEvent) => model.onDropDragOver(evt), + onDrop: (layout: LayoutItem[], layoutItem: LayoutItem, evt: Event) => + model.onDrop(layout, layoutItem, evt), + ...rglOptions + }), + emptyContainerOverlay({omit: !model.showAddViewButtonWhenEmpty}) + ] + : [], [TEST_ID]: testId }) }); @@ -155,4 +169,4 @@ const onContextMenu = (e, model) => { } }; -const reactGridLayout = elementFactory(WidthProvider(ReactGridLayout)); +const reactGridLayout = elementFactory(ReactGridLayout); diff --git a/desktop/cmp/dash/canvas/DashCanvasModel.ts b/desktop/cmp/dash/canvas/DashCanvasModel.ts index 01f75300f..cca4851ff 100644 --- a/desktop/cmp/dash/canvas/DashCanvasModel.ts +++ b/desktop/cmp/dash/canvas/DashCanvasModel.ts @@ -4,7 +4,9 @@ * * Copyright © 2025 Extremely Heavy Industries Inc. */ -import type {DragOverEvent, Layout} from 'react-grid-layout'; +import {wait} from '@xh/hoist/promise'; +import {DragEvent} from 'react'; +import type {LayoutItem} from 'react-grid-layout'; import {Persistable, PersistableState, PersistenceProvider, XH} from '@xh/hoist/core'; import {required} from '@xh/hoist/data'; import {DashCanvasViewModel, DashCanvasViewSpec, DashConfig, DashViewState, DashModel} from '../'; @@ -50,7 +52,7 @@ export interface DashCanvasConfig extends DashConfig {w?: number; h?: number} | false | undefined; + onDropDragOver?: (e: DragEvent) => + | { + w?: number; + h?: number; + dragOffsetX?: number; + dragOffsetY?: number; + } + | false + | void; /** * Whether an overlay with an Add View button should be rendered @@ -109,13 +120,13 @@ export class DashCanvasModel @bindable compact: boolean; @bindable.ref margin: [number, number]; // [x, y] @bindable.ref containerPadding: [number, number]; // [x, y] + @bindable showAddViewButtonWhenEmpty: boolean; //----------------------------- // Public properties //----------------------------- DROPPING_ELEM_ID = '__dropping-elem__'; maxRows: number; - showAddViewButtonWhenEmpty: boolean; droppable: boolean; onDropDone: (viewModel: DashCanvasViewModel) => void; draggedInView: DashCanvasItemState; @@ -176,7 +187,7 @@ export class DashCanvasModel compact = true, margin = [10, 10], maxRows = Infinity, - containerPadding = margin, + containerPadding = [0, 0], extraMenuItems, showAddViewButtonWhenEmpty = true, droppable = false, @@ -224,7 +235,6 @@ export class DashCanvasModel this.maxRows = maxRows; this.containerPadding = containerPadding; this.margin = margin; - this.containerPadding = containerPadding; this.compact = compact; this.emptyText = emptyText; this.addViewButtonText = addViewButtonText; @@ -359,7 +369,7 @@ export class DashCanvasModel this.getView(id)?.ensureVisible(); } - onDrop(rglLayout: Layout[], layoutItem: Layout, evt: Event) { + onDrop(rglLayout: LayoutItem[], layoutItem: LayoutItem, evt: Event) { throwIf( !this.draggedInView, `No draggedInView set on DashCanvasModel prior to onDrop operation. @@ -369,19 +379,44 @@ export class DashCanvasModel const {viewSpecId, title, state} = this.draggedInView, layout = omit(layoutItem, 'i'), - newViewModel = this.doDrop(viewSpecId, {title, state, layout}, rglLayout); + newViewModel: DashCanvasViewModel = this.addViewInternal(viewSpecId, { + title, + state, + layout + }), + droppingItem: any = rglLayout.find(it => it.i === this.DROPPING_ELEM_ID); + + // Change ID of dropping item to the new view's id + // so that the new view goes where the dropping item is. + droppingItem.i = newViewModel.id; - this.draggedInView = null; - this.onDropDone?.(newViewModel); + // must wait a tick for RGL to settle + wait().then(() => { + this.draggedInView = null; + this.onRglLayoutChange(rglLayout); + this.onDropDone?.(newViewModel); + }); } setDraggedInView(view?: DashCanvasItemState) { this.draggedInView = view; } - onDropDragOver(e: DragOverEvent): {w?: number; h?: number} | false | undefined { + onDropDragOver(evt: DragEvent): + | { + w?: number; + h?: number; + dragOffsetX?: number; + dragOffsetY?: number; + } + | false + | void { if (!this.draggedInView) return false; - return {w: 6, h: 6}; + + return { + w: this.draggedInView.layout.w, + h: this.draggedInView.layout.h + }; } //------------------------ @@ -399,24 +434,6 @@ export class DashCanvasModel //------------------------ // Implementation //------------------------ - @action - private doDrop( - specId: string, - opts: { - title: string; - state: any; - layout: DashCanvasItemLayout; - }, - rglLayout: Layout[] - ): DashCanvasViewModel { - const newViewModel: DashCanvasViewModel = this.addViewInternal(specId, opts), - droppingItem: any = rglLayout.find(it => it.i === this.DROPPING_ELEM_ID); - - droppingItem.i = newViewModel.id; - this.onRglLayoutChange(rglLayout); - return newViewModel; - } - private getLayoutFromPosition(position: string, specId: string) { switch (position) { case 'first': @@ -474,7 +491,7 @@ export class DashCanvasModel return model; } - onRglLayoutChange(rglLayout: Layout[]) { + onRglLayoutChange(rglLayout: LayoutItem[]) { rglLayout = rglLayout.map(it => pick(it, ['i', 'x', 'y', 'w', 'h'])); // Early out if RGL is changing layout as user is dragging droppable @@ -486,7 +503,7 @@ export class DashCanvasModel } @action - private setLayout(layout) { + private setLayout(layout: LayoutItem[]) { layout = sortBy(layout, 'i'); const layoutChanged = !isEqual(layout, this.layout); if (!layoutChanged) return; @@ -588,6 +605,7 @@ export class DashCanvasModel } } } + const checkPosition = (originX, originY) => { for (let y = originY; y < originY + height; y++) { for (let x = originX; x < originX + width; x++) { diff --git a/package.json b/package.json index cfd881f14..623289110 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ "react-beautiful-dnd": "~13.1.0", "react-dates": "~21.8.0", "react-dropzone": "~10.2.2", - "react-grid-layout": "1.5.0", + "react-grid-layout": "2.1.0", "react-markdown": "~10.1.0", "react-onsenui": "~1.13.2", "react-popper": "~2.3.0", @@ -93,7 +93,6 @@ "devDependencies": { "@types/react": "18.x", "@types/react-dom": "18.x", - "@types/react-grid-layout": "1.3.5", "@xh/hoist-dev-utils": "11.x", "ag-grid-community": "34.x", "ag-grid-react": "34.x", diff --git a/yarn.lock b/yarn.lock index deea22307..d82264dda 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1690,13 +1690,6 @@ resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.3.7.tgz#b89ddf2cd83b4feafcc4e2ea41afdfb95a0d194f" integrity sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ== -"@types/react-grid-layout@1.3.5": - version "1.3.5" - resolved "https://registry.yarnpkg.com/@types/react-grid-layout/-/react-grid-layout-1.3.5.tgz#f4b52bf27775290ee0523214be0987be14e66823" - integrity sha512-WH/po1gcEcoR6y857yAnPGug+ZhkF4PaTUxgAbwfeSH/QOgVSakKHBXoPGad/sEznmkiaK3pqHk+etdWisoeBQ== - dependencies: - "@types/react" "*" - "@types/react-redux@^7.1.20": version "7.1.34" resolved "https://registry.yarnpkg.com/@types/react-redux/-/react-redux-7.1.34.tgz#83613e1957c481521e6776beeac4fd506d11bd0e" @@ -2849,7 +2842,7 @@ clone-deep@^4.0.1: kind-of "^6.0.2" shallow-clone "^3.0.0" -clsx@^2.0.0, clsx@^2.1.1: +clsx@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999" integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA== @@ -6549,7 +6542,7 @@ react-dom@~18.2.0: loose-envify "^1.1.0" scheduler "^0.23.0" -react-draggable@^4.0.3, react-draggable@^4.4.5: +react-draggable@^4.0.3, react-draggable@^4.4.6: version "4.5.0" resolved "https://registry.yarnpkg.com/react-draggable/-/react-draggable-4.5.0.tgz#0b274ccb6965fcf97ed38fcf7e3cc223bc48cdf5" integrity sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw== @@ -6571,15 +6564,15 @@ react-fast-compare@^3.0.1: resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.2.tgz#929a97a532304ce9fee4bcae44234f1ce2c21d49" integrity sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ== -react-grid-layout@1.5.0: - version "1.5.0" - resolved "https://registry.yarnpkg.com/react-grid-layout/-/react-grid-layout-1.5.0.tgz#b6cc9412b58cf8226aebc0df7673d6fa782bdee2" - integrity sha512-WBKX7w/LsTfI99WskSu6nX2nbJAUD7GD6nIXcwYLyPpnslojtmql2oD3I2g5C3AK8hrxIarYT8awhuDIp7iQ5w== +react-grid-layout@2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/react-grid-layout/-/react-grid-layout-2.1.0.tgz#544a1a7df3411dbfb29a886d398ed2c52b6ce149" + integrity sha512-d2UOqsTokpua1iaVN6wpxHxum6OE3+DOEKFzDn3UEOsSHxnb9m4Lzwkh3FaNTvQd4Z/2gjcqt1dfy3AnBfZiQw== dependencies: - clsx "^2.0.0" + clsx "^2.1.1" fast-equals "^4.0.3" prop-types "^15.8.1" - react-draggable "^4.4.5" + react-draggable "^4.4.6" react-resizable "^3.0.5" resize-observer-polyfill "^1.5.1" From f7daaeb7136154a0be03f30a01c2d24bdc9a1d0e Mon Sep 17 00:00:00 2001 From: Colin Rudd Date: Wed, 17 Dec 2025 14:31:16 -0500 Subject: [PATCH 06/27] CHANGELOG.md entry --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e46137c2..15fe1aed5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ symmetry with `setColumnState()`. The prior method remains as an alias but is now deprecated and scheduled for removal in v82. +### 🎁 New Features + +* DashCanvas component now supports dragging and dropping widgets in from an external container. + ### 🐞 Bug Fixes * Fixed column chooser to display columns in the same order as they appear in the grid. @@ -21,6 +25,10 @@ * `GroupingChooserProps.popoverTitle` - use `editorTitle` * `RelativeTimestampProps.options` - provide directly as top-level props +### 📚 Libraries + +* react-grid-layout `1.5.0 → 2.1.0` + ## 78.1.4 - 2025-12-05 ### 🐞 Bug Fixes From 7bdcf9b8790c614de9f62b401fbf218062c7a4a6 Mon Sep 17 00:00:00 2001 From: Colin Rudd Date: Wed, 17 Dec 2025 14:59:12 -0500 Subject: [PATCH 07/27] `` not needed since ReactNode includes `Iterable` --- core/elem.ts | 6 +++--- desktop/cmp/panel/Panel.ts | 4 ++-- desktop/cmp/rest/RestGrid.ts | 9 ++++----- mobile/cmp/panel/Panel.ts | 4 ++-- 4 files changed, 11 insertions(+), 12 deletions(-) diff --git a/core/elem.ts b/core/elem.ts index c5a869b22..05b145f31 100644 --- a/core/elem.ts +++ b/core/elem.ts @@ -15,7 +15,7 @@ import { ReactElement, ReactNode } from 'react'; -import {PlainObject, Some, Thunkable} from './types/Types'; +import {PlainObject, Thunkable} from './types/Types'; /** * Alternative format for specifying React Elements in render functions. This type is designed to @@ -45,10 +45,10 @@ export type ElementSpec

= Omit & { // Enhanced attributes to support element factory //--------------------------------------------- /** Child Element(s). Equivalent provided as Rest Arguments to React.createElement.*/ - items?: Some; + items?: ReactNode; /** Equivalent to `items`, offered for code clarity when only one child is needed. */ - item?: Some; + item?: ReactNode; /** True to exclude the Element. */ omit?: Thunkable; diff --git a/desktop/cmp/panel/Panel.ts b/desktop/cmp/panel/Panel.ts index 216dc0130..d837a033d 100644 --- a/desktop/cmp/panel/Panel.ts +++ b/desktop/cmp/panel/Panel.ts @@ -81,13 +81,13 @@ export interface PanelProps extends HoistProps, Omit; + tbar?: ReactNode; /** * A toolbar to be docked at the bottom of the panel. * If specified as an array, items will be passed as children to a Toolbar component. */ - bbar?: Some; + bbar?: ReactNode; /** Title text added to the panel's header. */ title?: ReactNode; diff --git a/desktop/cmp/rest/RestGrid.ts b/desktop/cmp/rest/RestGrid.ts index b8a4d2363..b9c4fa67f 100644 --- a/desktop/cmp/rest/RestGrid.ts +++ b/desktop/cmp/rest/RestGrid.ts @@ -7,7 +7,7 @@ import {grid} from '@xh/hoist/cmp/grid'; import {fragment} from '@xh/hoist/cmp/layout'; -import {hoistCmp, HoistProps, PlainObject, Some, uses} from '@xh/hoist/core'; +import {hoistCmp, HoistProps, PlainObject, uses} from '@xh/hoist/core'; import {MaskProps} from '@xh/hoist/cmp/mask'; import {panel, PanelProps} from '@xh/hoist/desktop/cmp/panel'; import {toolbar} from '@xh/hoist/desktop/cmp/toolbar'; @@ -21,8 +21,7 @@ import {restGridToolbar} from './impl/RestGridToolbar'; import {RestGridModel} from './RestGridModel'; export interface RestGridProps - extends HoistProps, - Omit { + extends HoistProps, Omit { /** * This constitutes an 'escape hatch' for applications that need to get to the underlying * AG Grid API. Use with care - settings made here might be overwritten and/or interfere with @@ -34,7 +33,7 @@ export interface RestGridProps * Optional components rendered adjacent to the top toolbar's action buttons. * See also {@link tbar} to take full control of the toolbar. */ - extraToolbarItems?: Some | (() => Some); + extraToolbarItems?: ReactNode | (() => ReactNode); /** Classname to be passed to RestForm. */ formClassName?: string; @@ -51,7 +50,7 @@ export interface RestGridProps * configs `toolbarActions`, `filterFields`, and `showRefreshButton`. If specified as an array, * will be passed as children to a Toolbar component. */ - tbar?: Some; + tbar?: ReactNode; } export const [RestGrid, restGrid] = hoistCmp.withFactory({ diff --git a/mobile/cmp/panel/Panel.ts b/mobile/cmp/panel/Panel.ts index 1342e1a4a..3b0b67409 100644 --- a/mobile/cmp/panel/Panel.ts +++ b/mobile/cmp/panel/Panel.ts @@ -29,7 +29,7 @@ import {logWarn} from '@xh/hoist/utils/js'; export interface PanelProps extends HoistProps, Omit { /** A toolbar to be docked at the bottom of the panel. */ - bbar?: Some; + bbar?: ReactNode; /** CSS class name specific to the panel's header. */ headerClassName?: string; @@ -62,7 +62,7 @@ export interface PanelProps extends HoistProps, Omit { scrollable?: boolean; /** A toolbar to be docked at the top of the panel. */ - tbar?: Some; + tbar?: ReactNode; /** Title text added to the panel's header. */ title?: ReactNode; From 485cea6b2b6a605370f3410486b544f02a3c0d46 Mon Sep 17 00:00:00 2001 From: Colin Rudd Date: Wed, 17 Dec 2025 15:34:27 -0500 Subject: [PATCH 08/27] Remove unused argument `type` from `normalizeArgs` method. --- core/elem.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/elem.ts b/core/elem.ts index 05b145f31..6096ec403 100644 --- a/core/elem.ts +++ b/core/elem.ts @@ -126,7 +126,7 @@ export function elementFactory(component: C): ElementF export function elementFactory

(component: ReactComponent): ElementFactory

; export function elementFactory(component: ReactComponent): ElementFactory { const ret = function (...args) { - return createElement(component, normalizeArgs(args, component)); + return createElement(component, normalizeArgs(args)); }; ret.isElementFactory = true; return ret; @@ -135,7 +135,7 @@ export function elementFactory(component: ReactComponent): ElementFactory { //------------------------ // Implementation //------------------------ -function normalizeArgs(args: any[], type: any) { +function normalizeArgs(args: any[]) { const len = args.length; if (len === 0) return {}; if (len === 1) { From 9d77f347241df781d0219b0deab05e5e8f28ad37 Mon Sep 17 00:00:00 2001 From: Colin Rudd Date: Thu, 18 Dec 2025 15:21:23 -0500 Subject: [PATCH 09/27] Make not droppable if contentLocked. --- desktop/cmp/dash/canvas/DashCanvas.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/cmp/dash/canvas/DashCanvas.ts b/desktop/cmp/dash/canvas/DashCanvas.ts index be318c362..4d52bf954 100644 --- a/desktop/cmp/dash/canvas/DashCanvas.ts +++ b/desktop/cmp/dash/canvas/DashCanvas.ts @@ -100,7 +100,7 @@ export const [DashCanvas, dashCanvas] = hoistCmp.withFactory({ enabled: isResizable }, dropConfig: { - enabled: model.droppable, + enabled: model.contentLocked ? false : model.droppable, defaultItem: {w: 6, h: 6} }, compactor: model.compact ? verticalCompactor : noCompactor, From 1e0c6c6a308198fdbffa54cf1c2dcd91b812266e Mon Sep 17 00:00:00 2001 From: Colin Rudd Date: Thu, 18 Dec 2025 18:24:44 -0500 Subject: [PATCH 10/27] fix spreading of rglOptions --- desktop/cmp/dash/canvas/DashCanvas.ts | 28 +++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/desktop/cmp/dash/canvas/DashCanvas.ts b/desktop/cmp/dash/canvas/DashCanvas.ts index 4d52bf954..1ed042685 100644 --- a/desktop/cmp/dash/canvas/DashCanvas.ts +++ b/desktop/cmp/dash/canvas/DashCanvas.ts @@ -4,6 +4,7 @@ * * Copyright © 2025 Extremely Heavy Industries Inc. */ +import {omit} from 'lodash'; import {DragEvent} from 'react'; import ReactGridLayout, { type LayoutItem, @@ -66,8 +67,15 @@ export const [DashCanvas, dashCanvas] = hoistCmp.withFactory({ render({className, model, rglOptions, testId}, ref) { const isDraggable = !model.layoutLocked, isResizable = !model.layoutLocked, - [padX, padY] = model.containerPadding; - const {width, containerRef, mounted} = useContainerWidth(); + [padX, padY] = model.containerPadding, + topLevelRglOptions: Partial = omit(rglOptions ?? {}, [ + 'gridConfig', + 'dragConfig', + 'resizeConfig', + 'dropConfig' + ]), + {width, containerRef, mounted} = useContainerWidth(); + return refreshContextView({ model: model.refreshContextModel, item: div({ @@ -88,27 +96,31 @@ export const [DashCanvas, dashCanvas] = hoistCmp.withFactory({ cols: model.columns, rowHeight: model.rowHeight, margin: model.margin, - maxRows: model.maxRows + maxRows: model.maxRows, + ...(rglOptions?.gridConfig ?? {}) }, dragConfig: { enabled: isDraggable, handle: '.xh-dash-tab.xh-panel > .xh-panel__content > .xh-panel-header', cancel: '.xh-button', - bounded: true + bounded: true, + ...(rglOptions?.dragConfig ?? {}) }, resizeConfig: { - enabled: isResizable + enabled: isResizable, + ...(rglOptions?.resizeConfig ?? {}) }, dropConfig: { enabled: model.contentLocked ? false : model.droppable, - defaultItem: {w: 6, h: 6} + defaultItem: {w: 6, h: 6}, + ...(rglOptions?.dropConfig ?? {}) }, compactor: model.compact ? verticalCompactor : noCompactor, onLayoutChange: (layout: LayoutItem[]) => model.onRglLayoutChange(layout), onResizeStart: () => (model.isResizing = true), onResizeStop: () => (model.isResizing = false), - items: model.viewModels.map(vm => + children: model.viewModels.map(vm => div({ key: vm.id, item: dashCanvasView({model: vm}) @@ -117,7 +129,7 @@ export const [DashCanvas, dashCanvas] = hoistCmp.withFactory({ onDropDragOver: (evt: DragEvent) => model.onDropDragOver(evt), onDrop: (layout: LayoutItem[], layoutItem: LayoutItem, evt: Event) => model.onDrop(layout, layoutItem, evt), - ...rglOptions + ...topLevelRglOptions }), emptyContainerOverlay({omit: !model.showAddViewButtonWhenEmpty}) ] From d2a3610a9d9c55e9a75adfbd33d282c9ce9c22bf Mon Sep 17 00:00:00 2001 From: Colin Rudd Date: Thu, 18 Dec 2025 18:44:18 -0500 Subject: [PATCH 11/27] fix typing of rglOptions --- desktop/cmp/dash/canvas/DashCanvas.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/cmp/dash/canvas/DashCanvas.ts b/desktop/cmp/dash/canvas/DashCanvas.ts index 1ed042685..ee683d178 100644 --- a/desktop/cmp/dash/canvas/DashCanvas.ts +++ b/desktop/cmp/dash/canvas/DashCanvas.ts @@ -45,7 +45,7 @@ export interface DashCanvasProps extends HoistProps, TestSuppor * {@link https://www.npmjs.com/package/react-grid-layout#grid-layout-props} * Note that some ReactGridLayout props are managed directly by DashCanvas and will be overridden if provided here. */ - rglOptions?: GridLayoutProps; + rglOptions?: Partial; } /** From e77515d957279630aa687d05b6f421cdd9901b64 Mon Sep 17 00:00:00 2001 From: Colin Rudd Date: Tue, 23 Dec 2025 10:51:39 -0500 Subject: [PATCH 12/27] switch to "moduleResolution": "bundler", triggered by import of GridBackground from 'react-grid-layout/extras'; --- cmp/markdown/Markdown.ts | 2 +- kit/react-markdown/index.ts | 6 +++--- tsconfig.json | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cmp/markdown/Markdown.ts b/cmp/markdown/Markdown.ts index 153b1a91c..97e6fbcef 100644 --- a/cmp/markdown/Markdown.ts +++ b/cmp/markdown/Markdown.ts @@ -9,7 +9,7 @@ import {reactMarkdown} from '@xh/hoist/kit/react-markdown'; import {Options} from 'react-markdown'; import remarkBreaks from 'remark-breaks'; import remarkGfm from 'remark-gfm'; -import {PluggableList} from 'unified/lib'; +import type {PluggableList} from 'unified'; interface MarkdownProps extends HoistProps { /** Markdown formatted string to render. */ diff --git a/kit/react-markdown/index.ts b/kit/react-markdown/index.ts index 3d30de754..3e0fc4c27 100644 --- a/kit/react-markdown/index.ts +++ b/kit/react-markdown/index.ts @@ -4,8 +4,8 @@ * * Copyright © 2025 Extremely Heavy Industries Inc. */ -import {elementFactory} from '@xh/hoist/core'; -import ReactMarkdown from 'react-markdown'; +import {type ElementFactory, elementFactory} from '@xh/hoist/core'; +import ReactMarkdown, {type Options} from 'react-markdown'; export {ReactMarkdown}; -export const reactMarkdown = elementFactory(ReactMarkdown); +export const reactMarkdown: ElementFactory> = elementFactory(ReactMarkdown); diff --git a/tsconfig.json b/tsconfig.json index cb6080fe1..c4e9ca5fb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,7 @@ "lib": ["dom", "es2022"], "jsx": "react", - "moduleResolution": "Node", + "moduleResolution": "bundler", "skipLibCheck": true, "allowSyntheticDefaultImports": true, From 12a8c8b0e50c47f5bb90e9d544c11bafbf397dfe Mon Sep 17 00:00:00 2001 From: Colin Rudd Date: Tue, 23 Dec 2025 11:18:15 -0500 Subject: [PATCH 13/27] support 2 compact strategies + support toggle grid background --- desktop/cmp/dash/canvas/DashCanvas.ts | 30 ++++++++--- desktop/cmp/dash/canvas/DashCanvasModel.ts | 60 ++++++++++++++++++---- package.json | 2 +- yarn.lock | 8 +-- 4 files changed, 79 insertions(+), 21 deletions(-) diff --git a/desktop/cmp/dash/canvas/DashCanvas.ts b/desktop/cmp/dash/canvas/DashCanvas.ts index ee683d178..26a19a67e 100644 --- a/desktop/cmp/dash/canvas/DashCanvas.ts +++ b/desktop/cmp/dash/canvas/DashCanvas.ts @@ -10,14 +10,14 @@ import ReactGridLayout, { type LayoutItem, type GridLayoutProps, useContainerWidth, - noCompactor, - verticalCompactor + getCompactor } from 'react-grid-layout'; - +import {GridBackground, type GridBackgroundProps} from 'react-grid-layout/extras'; import {showContextMenu} from '@xh/hoist/kit/blueprint'; import composeRefs from '@seznam/compose-react-refs'; import {div, vbox, vspacer} from '@xh/hoist/cmp/layout'; import { + XH, elementFactory, hoistCmp, HoistProps, @@ -74,7 +74,12 @@ export const [DashCanvas, dashCanvas] = hoistCmp.withFactory({ 'resizeConfig', 'dropConfig' ]), - {width, containerRef, mounted} = useContainerWidth(); + {width, containerRef, mounted} = useContainerWidth(), + defaultDroppedItemDims = { + w: Math.floor(model.columns / 3), + h: Math.floor(model.columns / 3) + }, + gridBackgroundColor = XH.darkTheme ? '#2a2a2a' : '#f8f8f8'; return refreshContextView({ model: model.refreshContextModel, @@ -89,6 +94,18 @@ export const [DashCanvas, dashCanvas] = hoistCmp.withFactory({ onContextMenu: e => onContextMenu(e, model), items: mounted ? [ + gridBackground({ + className: 'xh-dash-canvas__grid-background', + omit: !model.showGridBackground, + width, + height: model.rglHeight, + cols: model.columns, + rowHeight: model.rowHeight, + margin: model.margin, + rows: 'auto', + color: gridBackgroundColor, + borderRadius: 0 + }), reactGridLayout({ layout: model.rglLayout, width, @@ -112,10 +129,10 @@ export const [DashCanvas, dashCanvas] = hoistCmp.withFactory({ }, dropConfig: { enabled: model.contentLocked ? false : model.droppable, - defaultItem: {w: 6, h: 6}, + defaultItem: defaultDroppedItemDims, ...(rglOptions?.dropConfig ?? {}) }, - compactor: model.compact ? verticalCompactor : noCompactor, + compactor: getCompactor(model.compact, false, false), onLayoutChange: (layout: LayoutItem[]) => model.onRglLayoutChange(layout), onResizeStart: () => (model.isResizing = true), @@ -182,3 +199,4 @@ const onContextMenu = (e, model) => { }; const reactGridLayout = elementFactory(ReactGridLayout); +const gridBackground = elementFactory(GridBackground); diff --git a/desktop/cmp/dash/canvas/DashCanvasModel.ts b/desktop/cmp/dash/canvas/DashCanvasModel.ts index cca4851ff..339a5ecaf 100644 --- a/desktop/cmp/dash/canvas/DashCanvasModel.ts +++ b/desktop/cmp/dash/canvas/DashCanvasModel.ts @@ -6,14 +6,14 @@ */ import {wait} from '@xh/hoist/promise'; import {DragEvent} from 'react'; -import type {LayoutItem} from 'react-grid-layout'; +import type {LayoutItem, CompactType} from 'react-grid-layout'; import {Persistable, PersistableState, PersistenceProvider, XH} from '@xh/hoist/core'; import {required} from '@xh/hoist/data'; import {DashCanvasViewModel, DashCanvasViewSpec, DashConfig, DashViewState, DashModel} from '../'; import '@xh/hoist/desktop/register'; import {Icon} from '@xh/hoist/icon'; import {action, makeObservable, computed, observable, bindable} from '@xh/hoist/mobx'; -import {ensureUniqueBy, throwIf} from '@xh/hoist/utils/js'; +import {ensureUniqueBy, observeResize, throwIf} from '@xh/hoist/utils/js'; import {isOmitted} from '@xh/hoist/utils/impl'; import {createObservableRef} from '@xh/hoist/utils/react'; import { @@ -43,8 +43,13 @@ export interface DashCanvasConfig extends DashConfig; /** Between items [x,y] in pixels. Default `[10, 10]`. */ margin?: [number, number]; @@ -62,7 +67,7 @@ export interface DashCanvasConfig extends DashConfig void; @@ -70,8 +75,13 @@ export interface DashCanvasConfig extends DashConfig | { @@ -88,6 +98,11 @@ export interface DashCanvasConfig extends DashConfig this.viewState, run: () => (this.state = this.buildState()) }); + + // Used to make the height of RGL available to the gridBackground component + this.addReaction({ + when: () => !!this.ref.current, + run: () => { + this.rglResizeObserver = observeResize( + rect => (this.rglHeight = rect.height), + this.ref.current.querySelector('.react-grid-layout'), + {debounce: 100} + ); + } + }); } /** Removes all views from the canvas */ @@ -434,6 +472,8 @@ export class DashCanvasModel //------------------------ // Implementation //------------------------ + private rglResizeObserver: ResizeObserver; + private getLayoutFromPosition(position: string, specId: string) { switch (position) { case 'first': diff --git a/package.json b/package.json index 623289110..e85233e57 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ "react-beautiful-dnd": "~13.1.0", "react-dates": "~21.8.0", "react-dropzone": "~10.2.2", - "react-grid-layout": "2.1.0", + "react-grid-layout": "2.1.1", "react-markdown": "~10.1.0", "react-onsenui": "~1.13.2", "react-popper": "~2.3.0", diff --git a/yarn.lock b/yarn.lock index d82264dda..953b649da 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6564,10 +6564,10 @@ react-fast-compare@^3.0.1: resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.2.tgz#929a97a532304ce9fee4bcae44234f1ce2c21d49" integrity sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ== -react-grid-layout@2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/react-grid-layout/-/react-grid-layout-2.1.0.tgz#544a1a7df3411dbfb29a886d398ed2c52b6ce149" - integrity sha512-d2UOqsTokpua1iaVN6wpxHxum6OE3+DOEKFzDn3UEOsSHxnb9m4Lzwkh3FaNTvQd4Z/2gjcqt1dfy3AnBfZiQw== +react-grid-layout@2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/react-grid-layout/-/react-grid-layout-2.1.1.tgz#3954897c7bf8741f9d3c39f8ce5d760917b30697" + integrity sha512-yIB96q80oUIDcZeML4arNAC8/0Bam9odjdnQwiAhtyL6DpUy69wyLpG0SSTf5oH69FbQn9VlXn6tNbBqBxZROA== dependencies: clsx "^2.1.1" fast-equals "^4.0.3" From 864b78938a7d22e34678483881444ccfca3e33cd Mon Sep 17 00:00:00 2001 From: Colin Rudd Date: Tue, 23 Dec 2025 11:42:05 -0500 Subject: [PATCH 14/27] comment removed as per Lee's suggestion --- desktop/cmp/dash/canvas/DashCanvasModel.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/desktop/cmp/dash/canvas/DashCanvasModel.ts b/desktop/cmp/dash/canvas/DashCanvasModel.ts index 339a5ecaf..c7fdafe08 100644 --- a/desktop/cmp/dash/canvas/DashCanvasModel.ts +++ b/desktop/cmp/dash/canvas/DashCanvasModel.ts @@ -268,7 +268,6 @@ export class DashCanvasModel this.showGridBackground = showGridBackground; this.droppable = droppable; this.onDropDone = onDropDone; - // Override default onDropDragOver if provided if (onDropDragOver) this.onDropDragOver = onDropDragOver; this.loadState(initialState); From 9d1427a08b11ac26baac2121463859a9c64164bf Mon Sep 17 00:00:00 2001 From: Colin Rudd Date: Tue, 23 Dec 2025 11:46:59 -0500 Subject: [PATCH 15/27] replace droppable with allowsDrop --- desktop/cmp/dash/canvas/DashCanvas.ts | 2 +- desktop/cmp/dash/canvas/DashCanvasModel.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/desktop/cmp/dash/canvas/DashCanvas.ts b/desktop/cmp/dash/canvas/DashCanvas.ts index 26a19a67e..c175a4a22 100644 --- a/desktop/cmp/dash/canvas/DashCanvas.ts +++ b/desktop/cmp/dash/canvas/DashCanvas.ts @@ -128,7 +128,7 @@ export const [DashCanvas, dashCanvas] = hoistCmp.withFactory({ ...(rglOptions?.resizeConfig ?? {}) }, dropConfig: { - enabled: model.contentLocked ? false : model.droppable, + enabled: model.contentLocked ? false : model.allowsDrop, defaultItem: defaultDroppedItemDims, ...(rglOptions?.dropConfig ?? {}) }, diff --git a/desktop/cmp/dash/canvas/DashCanvasModel.ts b/desktop/cmp/dash/canvas/DashCanvasModel.ts index c7fdafe08..32caea96d 100644 --- a/desktop/cmp/dash/canvas/DashCanvasModel.ts +++ b/desktop/cmp/dash/canvas/DashCanvasModel.ts @@ -64,7 +64,7 @@ export interface DashCanvasConfig extends DashConfig void; draggedInView: DashCanvasItemState; @@ -208,7 +208,7 @@ export class DashCanvasModel extraMenuItems, showAddViewButtonWhenEmpty = true, showGridBackground = false, - droppable = false, + allowsDrop = false, onDropDone, onDropDragOver }: DashCanvasConfig) { @@ -266,7 +266,7 @@ export class DashCanvasModel this.extraMenuItems = extraMenuItems; this.showAddViewButtonWhenEmpty = showAddViewButtonWhenEmpty; this.showGridBackground = showGridBackground; - this.droppable = droppable; + this.allowsDrop = allowsDrop; this.onDropDone = onDropDone; if (onDropDragOver) this.onDropDragOver = onDropDragOver; From 7b558ab8f1a6303320b4745b263f63a470c6333d Mon Sep 17 00:00:00 2001 From: Colin Rudd Date: Tue, 23 Dec 2025 13:26:09 -0500 Subject: [PATCH 16/27] create type OnDropDragOverResult + cancel drop if no RGL droppingItem --- desktop/cmp/dash/canvas/DashCanvasModel.ts | 45 +++++++++++----------- 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/desktop/cmp/dash/canvas/DashCanvasModel.ts b/desktop/cmp/dash/canvas/DashCanvasModel.ts index 32caea96d..e81768286 100644 --- a/desktop/cmp/dash/canvas/DashCanvasModel.ts +++ b/desktop/cmp/dash/canvas/DashCanvasModel.ts @@ -77,21 +77,11 @@ export interface DashCanvasConfig extends DashConfig - | { - w?: number; - h?: number; - dragOffsetX?: number; - dragOffsetY?: number; - } - | false - | void; + onDropDragOver?: (e: DragEvent) => OnDropDragOverResult; /** * Whether an overlay with an Add View button should be rendered @@ -119,6 +109,16 @@ export interface DashCanvasItemLayout { h: number; } +export type OnDropDragOverResult = + | { + w?: number; + h?: number; + dragOffsetX?: number; + dragOffsetY?: number; + } + | false + | void; + /** * Model for {@link DashCanvas}, managing all configurable options for the component and publishing * the observable state of its current widgets and their layout. @@ -414,14 +414,21 @@ export class DashCanvasModel a DashViewTray or similar component.` ); + const droppingItem: any = rglLayout.find(it => it.i === this.DROPPING_ELEM_ID); + if (!droppingItem) { + // if `onDropDragOver` returned false, we won't have a dropping item + // and we cancel the drop + this.draggedInView = null; + return; + } + const {viewSpecId, title, state} = this.draggedInView, layout = omit(layoutItem, 'i'), newViewModel: DashCanvasViewModel = this.addViewInternal(viewSpecId, { title, state, layout - }), - droppingItem: any = rglLayout.find(it => it.i === this.DROPPING_ELEM_ID); + }); // Change ID of dropping item to the new view's id // so that the new view goes where the dropping item is. @@ -439,15 +446,7 @@ export class DashCanvasModel this.draggedInView = view; } - onDropDragOver(evt: DragEvent): - | { - w?: number; - h?: number; - dragOffsetX?: number; - dragOffsetY?: number; - } - | false - | void { + onDropDragOver(evt: DragEvent): OnDropDragOverResult { if (!this.draggedInView) return false; return { From b8fbe5fc6630856c1f19919c81c821980f1e2440 Mon Sep 17 00:00:00 2001 From: Colin Rudd Date: Fri, 2 Jan 2026 10:30:02 -0500 Subject: [PATCH 17/27] Update qs dep to resolve this code analysis warning: "Warning:(65, 11) Dependency npm:qs:6.14.0 is vulnerable CVE-2025-15284 7.5 arrayLimit bypass in bracket notation allows DoS via memory exhaustion Results powered by Mend.io" --- package.json | 2 +- yarn.lock | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index e25d2a6f4..f9fa4e3b1 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ "moment": "~2.30.1", "numbro": "~2.5.0", "onsenui": "~2.12.8", - "qs": "~6.14.0", + "qs": "~6.14.1", "react-beautiful-dnd": "~13.1.0", "react-dates": "~21.8.0", "react-dropzone": "~10.2.2", diff --git a/yarn.lock b/yarn.lock index b91b344ee..67c9df773 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6430,6 +6430,13 @@ qs@~6.14.0: dependencies: side-channel "^1.1.0" +qs@~6.14.1: + version "6.14.1" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.14.1.tgz#a41d85b9d3902f31d27861790506294881871159" + integrity sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ== + dependencies: + side-channel "^1.1.0" + queue-microtask@^1.2.2: version "1.2.3" resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" From 685b9ee9247266bdd0ee5049b770b491c7e8389b Mon Sep 17 00:00:00 2001 From: Colin Rudd Date: Fri, 2 Jan 2026 13:02:22 -0500 Subject: [PATCH 18/27] Update to "react-grid-layout": "2.2.2" to get latest fixes, and consequently add wrap compact option, and use new dropConfig.onDragOver key. --- desktop/cmp/dash/canvas/DashCanvas.ts | 11 ++++++----- desktop/cmp/dash/canvas/DashCanvasModel.ts | 10 +++++----- package.json | 2 +- yarn.lock | 8 ++++---- 4 files changed, 16 insertions(+), 15 deletions(-) diff --git a/desktop/cmp/dash/canvas/DashCanvas.ts b/desktop/cmp/dash/canvas/DashCanvas.ts index f962c4fbc..c38dd1972 100644 --- a/desktop/cmp/dash/canvas/DashCanvas.ts +++ b/desktop/cmp/dash/canvas/DashCanvas.ts @@ -4,14 +4,13 @@ * * Copyright © 2025 Extremely Heavy Industries Inc. */ -import {DragEvent} from 'react'; import ReactGridLayout, { type LayoutItem, type GridLayoutProps, useContainerWidth, getCompactor } from 'react-grid-layout'; -import {GridBackground, type GridBackgroundProps} from 'react-grid-layout/extras'; +import {GridBackground, type GridBackgroundProps, wrapCompactor} from 'react-grid-layout/extras'; import composeRefs from '@seznam/compose-react-refs'; import {div, vbox, vspacer} from '@xh/hoist/cmp/layout'; import { @@ -106,15 +105,17 @@ export const [DashCanvas, dashCanvas] = hoistCmp.withFactory({ dropConfig: { enabled: model.contentLocked ? false : model.allowsDrop, defaultItem: defaultDroppedItemDims, - ...(rglOptions?.dropConfig ?? {}) + onDragOver: (evt: DragEvent) => model.onDropDragOver(evt) }, - onDropDragOver: (evt: DragEvent) => model.onDropDragOver(evt), onDrop: ( layout: LayoutItem[], layoutItem: LayoutItem, evt: Event ) => model.onDrop(layout, layoutItem, evt), - compactor: getCompactor(model.compact, false, false), + compactor: + model.compact === 'wrap' + ? wrapCompactor + : getCompactor(model.compact, false, false), onLayoutChange: (layout: LayoutItem[]) => model.onRglLayoutChange(layout), onResizeStart: () => (model.isResizing = true), diff --git a/desktop/cmp/dash/canvas/DashCanvasModel.ts b/desktop/cmp/dash/canvas/DashCanvasModel.ts index 4c93c1ec2..759ab1d92 100644 --- a/desktop/cmp/dash/canvas/DashCanvasModel.ts +++ b/desktop/cmp/dash/canvas/DashCanvasModel.ts @@ -4,7 +4,6 @@ * * Copyright © 2025 Extremely Heavy Industries Inc. */ -import {DragEvent} from 'react'; import {wait} from '@xh/hoist/promise'; import type {LayoutItem} from 'react-grid-layout'; import {Persistable, PersistableState, PersistenceProvider, XH} from '@xh/hoist/core'; @@ -44,11 +43,12 @@ export interface DashCanvasConfig extends DashConfig Date: Thu, 8 Jan 2026 10:25:13 -0500 Subject: [PATCH 19/27] 2 new html tags --- cmp/layout/Tags.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cmp/layout/Tags.ts b/cmp/layout/Tags.ts index 000fd4e5b..9a4ffbb12 100644 --- a/cmp/layout/Tags.ts +++ b/cmp/layout/Tags.ts @@ -29,6 +29,7 @@ export const a = elementFactory('a'); export const br = elementFactory('br'); export const code = elementFactory('code'); export const div = elementFactory('div'); +export const fieldset = elementFactory('fieldset'); export const form = elementFactory('form'); export const hr = elementFactory('hr'); export const h1 = elementFactory('h1'); @@ -36,6 +37,7 @@ export const h2 = elementFactory('h2'); export const h3 = elementFactory('h3'); export const h4 = elementFactory('h4'); export const label = elementFactory('label'); +export const legend = elementFactory('legend'); export const li = elementFactory('li'); export const nav = elementFactory('nav'); export const ol = elementFactory('ol'); From 4d2243b02d2a07ccb871acd31576e781f15a4208 Mon Sep 17 00:00:00 2001 From: Colin Rudd Date: Thu, 8 Jan 2026 14:27:24 -0500 Subject: [PATCH 20/27] new hoist components: DashCanvasWidgetWell CollapsibleFieldSet FieldsetCollapseButton --- desktop/cmp/button/FieldsetCollapseButton.ts | 44 +++++++ desktop/cmp/dash/canvas/DashCanvasModel.ts | 8 +- .../widgetwell/DashCanvasWidgetWell.scss | 26 ++++ .../canvas/widgetwell/DashCanvasWidgetWell.ts | 123 ++++++++++++++++++ .../widgetwell/DashCanvasWidgetWellModel.ts | 65 +++++++++ desktop/cmp/form/CollapsibleFieldset.scss | 14 ++ desktop/cmp/form/CollapsibleFieldset.ts | 65 +++++++++ 7 files changed, 341 insertions(+), 4 deletions(-) create mode 100644 desktop/cmp/button/FieldsetCollapseButton.ts create mode 100644 desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWell.scss create mode 100644 desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWell.ts create mode 100644 desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWellModel.ts create mode 100644 desktop/cmp/form/CollapsibleFieldset.scss create mode 100644 desktop/cmp/form/CollapsibleFieldset.ts diff --git a/desktop/cmp/button/FieldsetCollapseButton.ts b/desktop/cmp/button/FieldsetCollapseButton.ts new file mode 100644 index 000000000..ea5adb77b --- /dev/null +++ b/desktop/cmp/button/FieldsetCollapseButton.ts @@ -0,0 +1,44 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2026 Extremely Heavy Industries Inc. + */ + +import {type ReactElement, type ReactNode, useState} from 'react'; +import {hoistCmp, HoistProps} from '@xh/hoist/core'; +import {button} from '@xh/hoist/desktop/cmp/button'; +import {legend} from '@xh/hoist/cmp/layout'; +import {Icon} from '@xh/hoist/icon/Icon'; + +export interface FieldsetCollapseButtonProps extends HoistProps { + icon?: ReactElement; + text: ReactNode; + clickHandler?: (boolean) => void; + collapsed?: boolean; + disabled?: boolean; +} + +export const [FieldsetCollapseButton, fieldsetCollapseButton] = + hoistCmp.withFactory({ + displayName: 'FieldsetCollapseButton', + model: false, + render({icon, text, clickHandler, collapsed, disabled}) { + const [isCollapsed, setIsCollapsed] = useState(collapsed === true); + + return legend( + button({ + text, + icon, + rightIcon: isCollapsed ? Icon.angleDown() : Icon.angleUp(), + outlined: false, + disabled, + onClick: () => { + const val = !isCollapsed; + setIsCollapsed(val); + clickHandler?.(val); + } + }) + ); + } + }); diff --git a/desktop/cmp/dash/canvas/DashCanvasModel.ts b/desktop/cmp/dash/canvas/DashCanvasModel.ts index ce58c3917..552f436ee 100644 --- a/desktop/cmp/dash/canvas/DashCanvasModel.ts +++ b/desktop/cmp/dash/canvas/DashCanvasModel.ts @@ -447,6 +447,10 @@ export class DashCanvasModel }; } + getViewsBySpecId(id) { + return this.viewModels.filter(it => it.viewSpec.id === id); + } + //------------------------ // Persistable Interface //------------------------ @@ -612,10 +616,6 @@ export class DashCanvasModel return some(this.viewSpecs, {id}); } - private getViewsBySpecId(id) { - return this.viewModels.filter(it => it.viewSpec.id === id); - } - private getNextAvailablePosition({ width, height, diff --git a/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWell.scss b/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWell.scss new file mode 100644 index 000000000..59f5d92ad --- /dev/null +++ b/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWell.scss @@ -0,0 +1,26 @@ +.xh-dash-canvas-widget-well { + padding: 0 var(--xh-pad-px); + + .xh-collapsible-fieldset { + margin: var(--xh-pad-half-px) 0; + } + + .xh-dash-canvas-draggable-widget { + border: var(--xh-border-dotted); + background-color: var(--xh-bg-alt); + padding: var(--xh-pad-half-px); + margin: var(--xh-pad-half-px) 0; + cursor: grab; + + &.is-dragging { + cursor: grabbing; + // lighten background color of left behind placeholder + // when dragging + opacity: 0.25; + } + + &:active { + cursor: grabbing; + } + } +} diff --git a/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWell.ts b/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWell.ts new file mode 100644 index 000000000..18106d0f9 --- /dev/null +++ b/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWell.ts @@ -0,0 +1,123 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2025 Extremely Heavy Industries Inc. + */ + +import {uniqBy} from 'lodash'; +import classNames from 'classnames'; +import type {ReactElement} from 'react'; +import {div, vframe} from '@xh/hoist/cmp/layout'; +import {creates, hoistCmp, HoistProps, uses} from '@xh/hoist/core'; +import {DashCanvasModel, DashCanvasViewSpec} from '@xh/hoist/desktop/cmp/dash'; +import {DashCanvasWidgetWellModel} from '@xh/hoist/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWellModel'; +import {collapsibleFieldset} from '@xh/hoist/desktop/cmp/form/CollapsibleFieldset'; + +import './DashCanvasWidgetWell.scss'; + +export interface DashCanvasWidgetWellProps extends HoistProps { + /** DashCanvasModel for which this widget well should allow the user to add views from. */ + dashCanvasModel?: DashCanvasModel; +} + +/** + * Widget Well from which to add items to a DashCanvas by drag-and-drop. + * + * Available view specs are listed in their defined order, + * grouped by their 'groupName' property if present + */ +export const [DashCanvasWidgetWell, dashCanvasWidgetWell] = + hoistCmp.withFactory({ + displayName: 'DashCanvasWidgetWell', + model: creates(DashCanvasWidgetWellModel), + className: 'xh-dash-canvas-widget-well', + render({dashCanvasModel, className}) { + if (!dashCanvasModel) return; + + return vframe({ + className: classNames(className), + overflow: 'auto', + items: createDraggableItems(dashCanvasModel) + }); + } + }); + +//--------------------------- +// Implementation +//--------------------------- +const draggableWidget = hoistCmp.factory({ + displayName: 'DraggableWidget', + model: uses(DashCanvasWidgetWellModel), + render({model, viewSpec}) { + const {id, icon, title} = viewSpec as DashCanvasViewSpec; + return div({ + id: `draggableFor-${id}`, + className: 'xh-dash-canvas-draggable-widget', + draggable: true, + unselectable: 'on', + onDragStart: e => model.onDragStart(e), + onDragEnd: e => model.onDragEnd(e), + items: [icon, ' ', title] + }); + } +}); + +/** + * Used to create draggable items (for adding views) + * @internal + */ +function createDraggableItems(dashCanvasModel: DashCanvasModel): any[] { + if (!dashCanvasModel.ref.current) return []; + + const groupedItems = {}, + ungroupedItems = []; + + const addToGroup = (item, icon, groupName) => { + const group = groupedItems[groupName]; + if (group) { + group.push({item, icon}); + } else { + groupedItems[groupName] = [{item, icon}]; + } + }; + + dashCanvasModel.viewSpecs + .filter(viewSpec => { + return ( + viewSpec.allowAdd && + (!viewSpec.unique || !dashCanvasModel.getViewsBySpecId(viewSpec.id).length) + ); + }) + .forEach(viewSpec => { + const {groupName} = viewSpec, + item = draggableWidget({viewSpec}); + + if (groupName) { + addToGroup(item, viewSpec.icon, groupName); + } else { + ungroupedItems.push(item); + } + }); + + return [ + ...Object.keys(groupedItems).map(group => { + const label = group, + items = groupedItems[group], + sameIcons = + uniqBy<{item: ReactElement; icon: ReactElement}>( + items, + it => it.icon.props.iconName + ).length === 1, + icon = sameIcons ? items[0].icon : null; + + return collapsibleFieldset({ + icon, + collapsed: false, + label, + items: items.map(it => it.item) + }); + }), + ...ungroupedItems + ]; +} diff --git a/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWellModel.ts b/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWellModel.ts new file mode 100644 index 000000000..a9b3baab7 --- /dev/null +++ b/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWellModel.ts @@ -0,0 +1,65 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2025 Extremely Heavy Industries Inc. + */ +import {DragEvent} from 'react'; +import {DashCanvasModel} from '@xh/hoist/desktop/cmp/dash'; +import {HoistModel, managed} from '@xh/hoist/core'; +import '@xh/hoist/desktop/register'; +import {makeObservable, observable} from '@xh/hoist/mobx'; +import {runInAction} from 'mobx'; + +export class DashCanvasWidgetWellModel extends HoistModel { + @managed + @observable.ref + dashCanvasModel: DashCanvasModel; + + constructor() { + super(); + makeObservable(this); + } + + override onLinked() { + this.addReaction({ + track: () => this.componentProps, + run: () => + runInAction(() => (this.dashCanvasModel = this.componentProps.dashCanvasModel)), + fireImmediately: true + }); + } + + onDragStart(evt: DragEvent) { + const target = evt.target as HTMLElement; + if (!target) return; + + this.dashCanvasModel.showAddViewButtonWhenEmpty = false; + evt.dataTransfer.effectAllowed = 'move'; + target.classList.add('is-dragging'); + + const viewSpecId: string = target.getAttribute('id').split('draggableFor-')[1], + viewSpec = this.dashCanvasModel.viewSpecs.find(it => it.id === viewSpecId), + {width, height} = viewSpec, + widget = { + viewSpecId, + layout: { + x: 0, + y: 0, + w: width, + h: height + } + }; + + this.dashCanvasModel.setDraggedInView(widget); + } + + onDragEnd(evt: DragEvent) { + this.dashCanvasModel.showAddViewButtonWhenEmpty = true; + + const target = evt.target as HTMLElement; + if (!target) return; + + target.classList.remove('is-dragging'); + } +} diff --git a/desktop/cmp/form/CollapsibleFieldset.scss b/desktop/cmp/form/CollapsibleFieldset.scss new file mode 100644 index 000000000..4cb7fd439 --- /dev/null +++ b/desktop/cmp/form/CollapsibleFieldset.scss @@ -0,0 +1,14 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2026 Extremely Heavy Industries Inc. + */ + +.xh-collapsible-fieldset { + &--collapsed { + border-bottom: none; + border-left: none; + border-right: none; + } +} diff --git a/desktop/cmp/form/CollapsibleFieldset.ts b/desktop/cmp/form/CollapsibleFieldset.ts new file mode 100644 index 000000000..2fd950cb7 --- /dev/null +++ b/desktop/cmp/form/CollapsibleFieldset.ts @@ -0,0 +1,65 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2026 Extremely Heavy Industries Inc. + */ + +import {fieldsetCollapseButton} from '@xh/hoist/desktop/cmp/button/FieldsetCollapseButton'; +import classNames from 'classnames'; +import {castArray} from 'lodash'; +import {type FieldsetHTMLAttributes, type ReactElement, type ReactNode, useState} from 'react'; +import {hoistCmp, HoistProps} from '@xh/hoist/core'; +import {fieldset} from '@xh/hoist/cmp/layout'; + +import './CollapsibleFieldset.scss'; + +export interface CollapsibleFieldsetProps + extends FieldsetHTMLAttributes, HoistProps { + icon?: ReactElement; + label: ReactNode; + clickHandler?: () => void; + collapsed?: boolean; + hideItemCount?: boolean; +} + +export const [CollapsibleFieldset, collapsibleFieldset] = + hoistCmp.withFactory({ + displayName: 'FieldsetCollapseButton', + model: false, + className: 'xh-collapsible-fieldset', + render({icon, label, collapsed, children, hideItemCount, className, disabled, ...rest}) { + const [isCollapsed, setIsCollapsed] = useState(collapsed === true), + items = castArray(children), + itemCount = hideItemCount === true ? '' : ` (${items.length})`, + classes = []; + + if (isCollapsed) { + classes.push('xh-collapsible-fieldset--collapsed'); + } else { + classes.push('xh-collapsible-fieldset--expanded'); + } + + if (disabled) { + classes.push('xh-collapsible-fieldset--disabled'); + } else { + classes.push('xh-collapsible-fieldset--enabled'); + } + + return fieldset({ + className: classNames(className, classes), + items: [ + fieldsetCollapseButton({ + icon, + text: `${label}${itemCount}`, + clickHandler: val => setIsCollapsed(val), + collapsed: isCollapsed, + disabled + }), + ...(isCollapsed ? [] : items) + ], + disabled, + ...rest + }); + } + }); From 38208f1e84d11b72738cc5484315cf1a92910277 Mon Sep 17 00:00:00 2001 From: Colin Rudd Date: Thu, 8 Jan 2026 14:42:31 -0500 Subject: [PATCH 21/27] add to docs. --- desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWell.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWell.ts b/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWell.ts index 18106d0f9..54908ebd1 100644 --- a/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWell.ts +++ b/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWell.ts @@ -25,7 +25,10 @@ export interface DashCanvasWidgetWellProps extends HoistProps { * Widget Well from which to add items to a DashCanvas by drag-and-drop. * * Available view specs are listed in their defined order, - * grouped by their 'groupName' property if present + * grouped by their 'groupName' property if present. + * + * Typically, an app developer would place this inside a collapsible panel to the side of + * a DashCanvas. */ export const [DashCanvasWidgetWell, dashCanvasWidgetWell] = hoistCmp.withFactory({ From 6d8fa289107ff5755cad1c250e0efcf103cc32eb Mon Sep 17 00:00:00 2001 From: Colin Rudd Date: Fri, 9 Jan 2026 15:39:24 -0500 Subject: [PATCH 22/27] Rename CollapsibleFieldset to CollapsibleBox, + add support for intent & tooltip --- desktop/cmp/button/CollapsibleBoxButton.ts | 57 ++++++++++++ desktop/cmp/button/FieldsetCollapseButton.ts | 44 ---------- .../widgetwell/DashCanvasWidgetWell.scss | 2 +- .../canvas/widgetwell/DashCanvasWidgetWell.ts | 4 +- desktop/cmp/form/CollapsibleBox.scss | 42 +++++++++ desktop/cmp/form/CollapsibleBox.ts | 86 +++++++++++++++++++ desktop/cmp/form/CollapsibleFieldset.scss | 14 --- desktop/cmp/form/CollapsibleFieldset.ts | 65 -------------- 8 files changed, 188 insertions(+), 126 deletions(-) create mode 100644 desktop/cmp/button/CollapsibleBoxButton.ts delete mode 100644 desktop/cmp/button/FieldsetCollapseButton.ts create mode 100644 desktop/cmp/form/CollapsibleBox.scss create mode 100644 desktop/cmp/form/CollapsibleBox.ts delete mode 100644 desktop/cmp/form/CollapsibleFieldset.scss delete mode 100644 desktop/cmp/form/CollapsibleFieldset.ts diff --git a/desktop/cmp/button/CollapsibleBoxButton.ts b/desktop/cmp/button/CollapsibleBoxButton.ts new file mode 100644 index 000000000..cdc96410a --- /dev/null +++ b/desktop/cmp/button/CollapsibleBoxButton.ts @@ -0,0 +1,57 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2026 Extremely Heavy Industries Inc. + */ + +import {type ReactElement, type ReactNode, type JSX, useState} from 'react'; +import {tooltip as bpTooltip} from '@xh/hoist/kit/blueprint'; +import {hoistCmp} from '@xh/hoist/core'; +import type {Intent, HoistProps} from '@xh/hoist/core'; +import {button} from '@xh/hoist/desktop/cmp/button'; +import {legend} from '@xh/hoist/cmp/layout'; +import {Icon} from '@xh/hoist/icon/Icon'; + +export interface CollapsibleBoxButtonProps extends HoistProps { + icon?: ReactElement; + text: ReactNode; + tooltip?: JSX.Element | string; + clickHandler?: (boolean) => void; + intent?: Intent; + collapsed?: boolean; + disabled?: boolean; +} + +export const [CollapsibleBoxButton, collapsibleBoxButton] = + hoistCmp.withFactory({ + displayName: 'CollapsibleBoxButton', + model: false, + render({icon, text, tooltip, intent, clickHandler, collapsed, disabled}) { + const [isCollapsed, setIsCollapsed] = useState(collapsed === true), + btn = button({ + text, + icon, + rightIcon: isCollapsed ? Icon.angleDown() : Icon.angleUp(), + outlined: isCollapsed && !intent, + minimal: !intent || (intent && !isCollapsed), + intent, + disabled, + onClick: () => { + const val = !isCollapsed; + setIsCollapsed(val); + clickHandler?.(val); + } + }); + + return legend( + tooltip + ? bpTooltip({ + item: btn, + content: tooltip, + intent + }) + : btn + ); + } + }); diff --git a/desktop/cmp/button/FieldsetCollapseButton.ts b/desktop/cmp/button/FieldsetCollapseButton.ts deleted file mode 100644 index ea5adb77b..000000000 --- a/desktop/cmp/button/FieldsetCollapseButton.ts +++ /dev/null @@ -1,44 +0,0 @@ -/* - * This file belongs to Hoist, an application development toolkit - * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) - * - * Copyright © 2026 Extremely Heavy Industries Inc. - */ - -import {type ReactElement, type ReactNode, useState} from 'react'; -import {hoistCmp, HoistProps} from '@xh/hoist/core'; -import {button} from '@xh/hoist/desktop/cmp/button'; -import {legend} from '@xh/hoist/cmp/layout'; -import {Icon} from '@xh/hoist/icon/Icon'; - -export interface FieldsetCollapseButtonProps extends HoistProps { - icon?: ReactElement; - text: ReactNode; - clickHandler?: (boolean) => void; - collapsed?: boolean; - disabled?: boolean; -} - -export const [FieldsetCollapseButton, fieldsetCollapseButton] = - hoistCmp.withFactory({ - displayName: 'FieldsetCollapseButton', - model: false, - render({icon, text, clickHandler, collapsed, disabled}) { - const [isCollapsed, setIsCollapsed] = useState(collapsed === true); - - return legend( - button({ - text, - icon, - rightIcon: isCollapsed ? Icon.angleDown() : Icon.angleUp(), - outlined: false, - disabled, - onClick: () => { - const val = !isCollapsed; - setIsCollapsed(val); - clickHandler?.(val); - } - }) - ); - } - }); diff --git a/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWell.scss b/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWell.scss index 59f5d92ad..1607f51c2 100644 --- a/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWell.scss +++ b/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWell.scss @@ -1,7 +1,7 @@ .xh-dash-canvas-widget-well { padding: 0 var(--xh-pad-px); - .xh-collapsible-fieldset { + .xh-collapsible-box { margin: var(--xh-pad-half-px) 0; } diff --git a/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWell.ts b/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWell.ts index 54908ebd1..7debd748f 100644 --- a/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWell.ts +++ b/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWell.ts @@ -12,7 +12,7 @@ import {div, vframe} from '@xh/hoist/cmp/layout'; import {creates, hoistCmp, HoistProps, uses} from '@xh/hoist/core'; import {DashCanvasModel, DashCanvasViewSpec} from '@xh/hoist/desktop/cmp/dash'; import {DashCanvasWidgetWellModel} from '@xh/hoist/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWellModel'; -import {collapsibleFieldset} from '@xh/hoist/desktop/cmp/form/CollapsibleFieldset'; +import {collapsibleBox} from '@xh/hoist/desktop/cmp/form/CollapsibleBox'; import './DashCanvasWidgetWell.scss'; @@ -114,7 +114,7 @@ function createDraggableItems(dashCanvasModel: DashCanvasModel): any[] { ).length === 1, icon = sameIcons ? items[0].icon : null; - return collapsibleFieldset({ + return collapsibleBox({ icon, collapsed: false, label, diff --git a/desktop/cmp/form/CollapsibleBox.scss b/desktop/cmp/form/CollapsibleBox.scss new file mode 100644 index 000000000..cd2f8876a --- /dev/null +++ b/desktop/cmp/form/CollapsibleBox.scss @@ -0,0 +1,42 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2026 Extremely Heavy Industries Inc. + */ + +.xh-collapsible-box { + &--collapsed { + border-bottom: none; + border-left: none; + border-right: none; + } + + &.xh-collapsible-box--intent-primary { + border-color: var(--xh-intent-primary-trans2); + &.xh-collapsible-box--enabled { + border-color: var(--xh-intent-primary); + } + } + + &.xh-collapsible-box--intent-success { + border-color: var(--xh-intent-success-trans2); + &.xh-collapsible-box--enabled { + border-color: var(--xh-intent-success); + } + } + + &.xh-collapsible-box--intent-warning { + border-color: var(--xh-intent-warning-trans2); + &.xh-collapsible-box--enabled { + border-color: var(--xh-intent-warning); + } + } + + &.xh-collapsible-box--intent-danger { + border-color: var(--xh-intent-danger-trans2); + &.xh-collapsible-box--enabled { + border-color: var(--xh-intent-danger); + } + } +} diff --git a/desktop/cmp/form/CollapsibleBox.ts b/desktop/cmp/form/CollapsibleBox.ts new file mode 100644 index 000000000..bcbc8fd66 --- /dev/null +++ b/desktop/cmp/form/CollapsibleBox.ts @@ -0,0 +1,86 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2026 Extremely Heavy Industries Inc. + */ + +import classNames from 'classnames'; +import {castArray} from 'lodash'; +import {type FieldsetHTMLAttributes, type ReactElement, type ReactNode, useState} from 'react'; +import {hoistCmp} from '@xh/hoist/core'; +import type {HoistProps, Intent} from '@xh/hoist/core'; +import {fieldset} from '@xh/hoist/cmp/layout'; +import {collapsibleBoxButton} from '@xh/hoist/desktop/cmp/button/CollapsibleBoxButton'; + +import './CollapsibleBox.scss'; + +export interface CollapsibleBoxProps + extends FieldsetHTMLAttributes, HoistProps { + icon?: ReactElement; + label: ReactNode; + tooltip?: JSX.Element | string; + intent?: Intent; + clickHandler?: () => void; + collapsed?: boolean; + hideItemCount?: boolean; +} + +export const [CollapsibleBox, collapsibleBox] = hoistCmp.withFactory({ + displayName: 'FieldsetCollapseButton', + model: false, + className: 'xh-collapsible-box', + render({ + icon, + label, + tooltip, + intent, + collapsed, + children, + hideItemCount, + className, + disabled, + ...rest + }) { + const [isCollapsed, setIsCollapsed] = useState(collapsed === true), + items = castArray(children), + itemCount = hideItemCount === true ? '' : ` (${items.length})`, + classes = []; + + if (isCollapsed) { + classes.push('xh-collapsible-box--collapsed'); + } else { + classes.push('xh-collapsible-box--expanded'); + } + + if (disabled) { + classes.push('xh-collapsible-box--disabled'); + } else { + classes.push('xh-collapsible-box--enabled'); + } + + if (intent) { + classes.push(`xh-collapsible-box--intent-${intent}`); + } else { + classes.push(`xh-collapsible-box--intent-none`); + } + + return fieldset({ + className: classNames(className, classes), + items: [ + collapsibleBoxButton({ + icon, + text: `${label}${itemCount}`, + tooltip, + intent, + clickHandler: val => setIsCollapsed(val), + collapsed: isCollapsed, + disabled + }), + ...(isCollapsed ? [] : items) + ], + disabled, + ...rest + }); + } +}); diff --git a/desktop/cmp/form/CollapsibleFieldset.scss b/desktop/cmp/form/CollapsibleFieldset.scss deleted file mode 100644 index 4cb7fd439..000000000 --- a/desktop/cmp/form/CollapsibleFieldset.scss +++ /dev/null @@ -1,14 +0,0 @@ -/* - * This file belongs to Hoist, an application development toolkit - * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) - * - * Copyright © 2026 Extremely Heavy Industries Inc. - */ - -.xh-collapsible-fieldset { - &--collapsed { - border-bottom: none; - border-left: none; - border-right: none; - } -} diff --git a/desktop/cmp/form/CollapsibleFieldset.ts b/desktop/cmp/form/CollapsibleFieldset.ts deleted file mode 100644 index 2fd950cb7..000000000 --- a/desktop/cmp/form/CollapsibleFieldset.ts +++ /dev/null @@ -1,65 +0,0 @@ -/* - * This file belongs to Hoist, an application development toolkit - * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) - * - * Copyright © 2026 Extremely Heavy Industries Inc. - */ - -import {fieldsetCollapseButton} from '@xh/hoist/desktop/cmp/button/FieldsetCollapseButton'; -import classNames from 'classnames'; -import {castArray} from 'lodash'; -import {type FieldsetHTMLAttributes, type ReactElement, type ReactNode, useState} from 'react'; -import {hoistCmp, HoistProps} from '@xh/hoist/core'; -import {fieldset} from '@xh/hoist/cmp/layout'; - -import './CollapsibleFieldset.scss'; - -export interface CollapsibleFieldsetProps - extends FieldsetHTMLAttributes, HoistProps { - icon?: ReactElement; - label: ReactNode; - clickHandler?: () => void; - collapsed?: boolean; - hideItemCount?: boolean; -} - -export const [CollapsibleFieldset, collapsibleFieldset] = - hoistCmp.withFactory({ - displayName: 'FieldsetCollapseButton', - model: false, - className: 'xh-collapsible-fieldset', - render({icon, label, collapsed, children, hideItemCount, className, disabled, ...rest}) { - const [isCollapsed, setIsCollapsed] = useState(collapsed === true), - items = castArray(children), - itemCount = hideItemCount === true ? '' : ` (${items.length})`, - classes = []; - - if (isCollapsed) { - classes.push('xh-collapsible-fieldset--collapsed'); - } else { - classes.push('xh-collapsible-fieldset--expanded'); - } - - if (disabled) { - classes.push('xh-collapsible-fieldset--disabled'); - } else { - classes.push('xh-collapsible-fieldset--enabled'); - } - - return fieldset({ - className: classNames(className, classes), - items: [ - fieldsetCollapseButton({ - icon, - text: `${label}${itemCount}`, - clickHandler: val => setIsCollapsed(val), - collapsed: isCollapsed, - disabled - }), - ...(isCollapsed ? [] : items) - ], - disabled, - ...rest - }); - } - }); From a013eecb1dc0df0123205580be785ab85366603c Mon Sep 17 00:00:00 2001 From: Colin Rudd Date: Fri, 9 Jan 2026 18:18:28 -0500 Subject: [PATCH 23/27] Default to flex box rendering for collapsibleBox, not block. --- desktop/cmp/form/CollapsibleBox.ts | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/desktop/cmp/form/CollapsibleBox.ts b/desktop/cmp/form/CollapsibleBox.ts index bcbc8fd66..eb938fbc9 100644 --- a/desktop/cmp/form/CollapsibleBox.ts +++ b/desktop/cmp/form/CollapsibleBox.ts @@ -9,14 +9,16 @@ import classNames from 'classnames'; import {castArray} from 'lodash'; import {type FieldsetHTMLAttributes, type ReactElement, type ReactNode, useState} from 'react'; import {hoistCmp} from '@xh/hoist/core'; -import type {HoistProps, Intent} from '@xh/hoist/core'; +import type {HoistProps, Intent, LayoutProps, TestSupportProps} from '@xh/hoist/core'; import {fieldset} from '@xh/hoist/cmp/layout'; import {collapsibleBoxButton} from '@xh/hoist/desktop/cmp/button/CollapsibleBoxButton'; +import {TEST_ID, mergeDeep} from '@xh/hoist/utils/js'; +import {splitLayoutProps} from '@xh/hoist/utils/react'; import './CollapsibleBox.scss'; export interface CollapsibleBoxProps - extends FieldsetHTMLAttributes, HoistProps { + extends FieldsetHTMLAttributes, HoistProps, TestSupportProps, LayoutProps { icon?: ReactElement; label: ReactNode; tooltip?: JSX.Element | string; @@ -40,8 +42,22 @@ export const [CollapsibleBox, collapsibleBox] = hoistCmp.withFactory(collapsed === true), items = castArray(children), itemCount = hideItemCount === true ? '' : ` (${items.length})`, @@ -80,7 +96,7 @@ export const [CollapsibleBox, collapsibleBox] = hoistCmp.withFactory Date: Fri, 9 Jan 2026 18:19:27 -0500 Subject: [PATCH 24/27] Support flexDirection prop on DashCanvasWidgetWell --- .../widgetwell/DashCanvasWidgetWell.scss | 14 +++++++++--- .../canvas/widgetwell/DashCanvasWidgetWell.ts | 22 +++++++++++++------ 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWell.scss b/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWell.scss index 1607f51c2..ee2dc0c32 100644 --- a/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWell.scss +++ b/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWell.scss @@ -1,15 +1,17 @@ .xh-dash-canvas-widget-well { - padding: 0 var(--xh-pad-px); + padding: 0 var(--xh-pad-half-px); .xh-collapsible-box { - margin: var(--xh-pad-half-px) 0; + padding: 0 var(--xh-pad-half-px) var(--xh-pad-half-px) var(--xh-pad-half-px); + margin: var(--xh-pad-half-px); } .xh-dash-canvas-draggable-widget { border: var(--xh-border-dotted); background-color: var(--xh-bg-alt); padding: var(--xh-pad-half-px); - margin: var(--xh-pad-half-px) 0; + margin: var(--xh-pad-half-px); + text-wrap-mode: nowrap; cursor: grab; &.is-dragging { @@ -23,4 +25,10 @@ cursor: grabbing; } } + + &--row { + .xh-dash-canvas-draggable-widget { + align-self: flex-start; + } + } } diff --git a/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWell.ts b/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWell.ts index 7debd748f..8b4094ece 100644 --- a/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWell.ts +++ b/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWell.ts @@ -8,7 +8,7 @@ import {uniqBy} from 'lodash'; import classNames from 'classnames'; import type {ReactElement} from 'react'; -import {div, vframe} from '@xh/hoist/cmp/layout'; +import {div, frame} from '@xh/hoist/cmp/layout'; import {creates, hoistCmp, HoistProps, uses} from '@xh/hoist/core'; import {DashCanvasModel, DashCanvasViewSpec} from '@xh/hoist/desktop/cmp/dash'; import {DashCanvasWidgetWellModel} from '@xh/hoist/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWellModel'; @@ -19,6 +19,8 @@ import './DashCanvasWidgetWell.scss'; export interface DashCanvasWidgetWellProps extends HoistProps { /** DashCanvasModel for which this widget well should allow the user to add views from. */ dashCanvasModel?: DashCanvasModel; + /** Defaults to `column` */ + flexDirection?: 'row' | 'column'; } /** @@ -35,13 +37,18 @@ export const [DashCanvasWidgetWell, dashCanvasWidgetWell] = displayName: 'DashCanvasWidgetWell', model: creates(DashCanvasWidgetWellModel), className: 'xh-dash-canvas-widget-well', - render({dashCanvasModel, className}) { + render({dashCanvasModel, flexDirection, className}) { if (!dashCanvasModel) return; - return vframe({ - className: classNames(className), - overflow: 'auto', - items: createDraggableItems(dashCanvasModel) + const classes = []; + if (flexDirection === 'row') classes.push('xh-dash-canvas-widget-well--row'); + + return frame({ + className: classNames(className, classes), + overflowY: 'auto', + flexDirection: flexDirection || 'column', + flexWrap: flexDirection === 'row' ? 'wrap' : 'nowrap', + items: createDraggableItems(dashCanvasModel, flexDirection) }); } }); @@ -70,7 +77,7 @@ const draggableWidget = hoistCmp.factory({ * Used to create draggable items (for adding views) * @internal */ -function createDraggableItems(dashCanvasModel: DashCanvasModel): any[] { +function createDraggableItems(dashCanvasModel: DashCanvasModel, flexDirection): any[] { if (!dashCanvasModel.ref.current) return []; const groupedItems = {}, @@ -118,6 +125,7 @@ function createDraggableItems(dashCanvasModel: DashCanvasModel): any[] { icon, collapsed: false, label, + flexDirection, items: items.map(it => it.item) }); }), From e4ef65265ef31ab77c20fbf15befe6da67cbea96 Mon Sep 17 00:00:00 2001 From: Colin Rudd Date: Sat, 10 Jan 2026 06:20:52 -0500 Subject: [PATCH 25/27] add testId support --- .../cmp/dash/canvas/widgetwell/DashCanvasWidgetWell.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWell.ts b/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWell.ts index 8b4094ece..c38daec59 100644 --- a/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWell.ts +++ b/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWell.ts @@ -9,14 +9,14 @@ import {uniqBy} from 'lodash'; import classNames from 'classnames'; import type {ReactElement} from 'react'; import {div, frame} from '@xh/hoist/cmp/layout'; -import {creates, hoistCmp, HoistProps, uses} from '@xh/hoist/core'; +import {creates, hoistCmp, HoistProps, TestSupportProps, uses} from '@xh/hoist/core'; import {DashCanvasModel, DashCanvasViewSpec} from '@xh/hoist/desktop/cmp/dash'; import {DashCanvasWidgetWellModel} from '@xh/hoist/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWellModel'; import {collapsibleBox} from '@xh/hoist/desktop/cmp/form/CollapsibleBox'; import './DashCanvasWidgetWell.scss'; -export interface DashCanvasWidgetWellProps extends HoistProps { +export interface DashCanvasWidgetWellProps extends HoistProps, TestSupportProps { /** DashCanvasModel for which this widget well should allow the user to add views from. */ dashCanvasModel?: DashCanvasModel; /** Defaults to `column` */ @@ -37,7 +37,7 @@ export const [DashCanvasWidgetWell, dashCanvasWidgetWell] = displayName: 'DashCanvasWidgetWell', model: creates(DashCanvasWidgetWellModel), className: 'xh-dash-canvas-widget-well', - render({dashCanvasModel, flexDirection, className}) { + render({dashCanvasModel, flexDirection, className, testId}) { if (!dashCanvasModel) return; const classes = []; @@ -48,7 +48,8 @@ export const [DashCanvasWidgetWell, dashCanvasWidgetWell] = overflowY: 'auto', flexDirection: flexDirection || 'column', flexWrap: flexDirection === 'row' ? 'wrap' : 'nowrap', - items: createDraggableItems(dashCanvasModel, flexDirection) + items: createDraggableItems(dashCanvasModel, flexDirection), + testId }); } }); From 233821d83b424e407ee2ac8606fed5b85bd0ca5d Mon Sep 17 00:00:00 2001 From: Colin Rudd Date: Mon, 12 Jan 2026 14:45:32 -0500 Subject: [PATCH 26/27] support collapsibleSet on mobile by creating a dynamic button --- .../layout/CollapsibleSet.scss | 18 +++--- .../layout/CollapsibleSet.ts | 32 ++++++----- desktop/appcontainer/AppContainer.ts | 2 + ...leBoxButton.ts => CollapsibleSetButton.ts} | 8 +-- .../widgetwell/DashCanvasWidgetWell.scss | 2 +- .../canvas/widgetwell/DashCanvasWidgetWell.ts | 4 +- dynamics/desktop.ts | 2 + dynamics/mobile.ts | 2 + mobile/appcontainer/AppContainer.ts | 2 + mobile/cmp/button/CollapsibleSetButton.ts | 57 +++++++++++++++++++ 10 files changed, 99 insertions(+), 30 deletions(-) rename desktop/cmp/form/CollapsibleBox.scss => cmp/layout/CollapsibleSet.scss (68%) rename desktop/cmp/form/CollapsibleBox.ts => cmp/layout/CollapsibleSet.ts (72%) rename desktop/cmp/button/{CollapsibleBoxButton.ts => CollapsibleSetButton.ts} (89%) create mode 100644 mobile/cmp/button/CollapsibleSetButton.ts diff --git a/desktop/cmp/form/CollapsibleBox.scss b/cmp/layout/CollapsibleSet.scss similarity index 68% rename from desktop/cmp/form/CollapsibleBox.scss rename to cmp/layout/CollapsibleSet.scss index cd2f8876a..3358b5b20 100644 --- a/desktop/cmp/form/CollapsibleBox.scss +++ b/cmp/layout/CollapsibleSet.scss @@ -5,37 +5,37 @@ * Copyright © 2026 Extremely Heavy Industries Inc. */ -.xh-collapsible-box { +.xh-collapsible-set { &--collapsed { border-bottom: none; border-left: none; border-right: none; } - &.xh-collapsible-box--intent-primary { + &.xh-collapsible-set--intent-primary { border-color: var(--xh-intent-primary-trans2); - &.xh-collapsible-box--enabled { + &.xh-collapsible-set--enabled { border-color: var(--xh-intent-primary); } } - &.xh-collapsible-box--intent-success { + &.xh-collapsible-set--intent-success { border-color: var(--xh-intent-success-trans2); - &.xh-collapsible-box--enabled { + &.xh-collapsible-set--enabled { border-color: var(--xh-intent-success); } } - &.xh-collapsible-box--intent-warning { + &.xh-collapsible-set--intent-warning { border-color: var(--xh-intent-warning-trans2); - &.xh-collapsible-box--enabled { + &.xh-collapsible-set--enabled { border-color: var(--xh-intent-warning); } } - &.xh-collapsible-box--intent-danger { + &.xh-collapsible-set--intent-danger { border-color: var(--xh-intent-danger-trans2); - &.xh-collapsible-box--enabled { + &.xh-collapsible-set--enabled { border-color: var(--xh-intent-danger); } } diff --git a/desktop/cmp/form/CollapsibleBox.ts b/cmp/layout/CollapsibleSet.ts similarity index 72% rename from desktop/cmp/form/CollapsibleBox.ts rename to cmp/layout/CollapsibleSet.ts index eb938fbc9..8c725c841 100644 --- a/desktop/cmp/form/CollapsibleBox.ts +++ b/cmp/layout/CollapsibleSet.ts @@ -8,16 +8,17 @@ import classNames from 'classnames'; import {castArray} from 'lodash'; import {type FieldsetHTMLAttributes, type ReactElement, type ReactNode, useState} from 'react'; -import {hoistCmp} from '@xh/hoist/core'; +import {XH, hoistCmp} from '@xh/hoist/core'; import type {HoistProps, Intent, LayoutProps, TestSupportProps} from '@xh/hoist/core'; import {fieldset} from '@xh/hoist/cmp/layout'; -import {collapsibleBoxButton} from '@xh/hoist/desktop/cmp/button/CollapsibleBoxButton'; import {TEST_ID, mergeDeep} from '@xh/hoist/utils/js'; import {splitLayoutProps} from '@xh/hoist/utils/react'; +import {collapsibleSetButton as desktopCollapsibleSetButtonImpl} from '@xh/hoist/dynamics/desktop'; +import {collapsibleSetButton as mobileCollapsibleSetButtonImpl} from '@xh/hoist/dynamics/mobile'; -import './CollapsibleBox.scss'; +import './CollapsibleSet.scss'; -export interface CollapsibleBoxProps +export interface CollapsibleSetProps extends FieldsetHTMLAttributes, HoistProps, TestSupportProps, LayoutProps { icon?: ReactElement; label: ReactNode; @@ -28,10 +29,10 @@ export interface CollapsibleBoxProps hideItemCount?: boolean; } -export const [CollapsibleBox, collapsibleBox] = hoistCmp.withFactory({ - displayName: 'FieldsetCollapseButton', +export const [CollapsibleSet, collapsibleSet] = hoistCmp.withFactory({ + displayName: 'CollapsibleSet', model: false, - className: 'xh-collapsible-box', + className: 'xh-collapsible-set', render({ icon, label, @@ -64,27 +65,30 @@ export const [CollapsibleBox, collapsibleBox] = hoistCmp.withFactory({ - displayName: 'CollapsibleBoxButton', +export const [CollapsibleSetButton, collapsibleSetButton] = + hoistCmp.withFactory({ + displayName: 'CollapsibleSetButton', model: false, render({icon, text, tooltip, intent, clickHandler, collapsed, disabled}) { const [isCollapsed, setIsCollapsed] = useState(collapsed === true), diff --git a/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWell.scss b/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWell.scss index ee2dc0c32..9a881112f 100644 --- a/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWell.scss +++ b/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWell.scss @@ -1,7 +1,7 @@ .xh-dash-canvas-widget-well { padding: 0 var(--xh-pad-half-px); - .xh-collapsible-box { + .xh-collapsible-set { padding: 0 var(--xh-pad-half-px) var(--xh-pad-half-px) var(--xh-pad-half-px); margin: var(--xh-pad-half-px); } diff --git a/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWell.ts b/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWell.ts index c38daec59..56d44ea82 100644 --- a/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWell.ts +++ b/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWell.ts @@ -12,7 +12,7 @@ import {div, frame} from '@xh/hoist/cmp/layout'; import {creates, hoistCmp, HoistProps, TestSupportProps, uses} from '@xh/hoist/core'; import {DashCanvasModel, DashCanvasViewSpec} from '@xh/hoist/desktop/cmp/dash'; import {DashCanvasWidgetWellModel} from '@xh/hoist/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWellModel'; -import {collapsibleBox} from '@xh/hoist/desktop/cmp/form/CollapsibleBox'; +import {collapsibleSet} from '@xh/hoist/cmp/layout/CollapsibleSet'; import './DashCanvasWidgetWell.scss'; @@ -122,7 +122,7 @@ function createDraggableItems(dashCanvasModel: DashCanvasModel, flexDirection): ).length === 1, icon = sameIcons ? items[0].icon : null; - return collapsibleBox({ + return collapsibleSet({ icon, collapsed: false, label, diff --git a/dynamics/desktop.ts b/dynamics/desktop.ts index ab6517436..aeb54478d 100644 --- a/dynamics/desktop.ts +++ b/dynamics/desktop.ts @@ -15,6 +15,7 @@ * * See the platform specific AppContainer where these implementations are actually provided. */ +export let collapsibleSetButton = null; export let ColChooserModel = null; export let ColumnHeaderFilterModel = null; export let ModalSupportModel = null; @@ -37,6 +38,7 @@ export let DynamicTabSwitcherModel = null; * Not for Application use. */ export function installDesktopImpls(impls) { + collapsibleSetButton = impls.collapsibleSetButton; ColChooserModel = impls.ColChooserModel; ColumnHeaderFilterModel = impls.ColumnHeaderFilterModel; ModalSupportModel = impls.ModalSupportModel; diff --git a/dynamics/mobile.ts b/dynamics/mobile.ts index 5f4e76b73..24415ace9 100644 --- a/dynamics/mobile.ts +++ b/dynamics/mobile.ts @@ -15,6 +15,7 @@ * * See the platform specific AppContainer where these implementations are actually provided. */ +export let collapsibleSetButton = null; export let ColChooserModel = null; export let colChooser = null; export let zoneMapper = null; @@ -30,6 +31,7 @@ export let maskImpl = null; * Not for Application use. */ export function installMobileImpls(impls) { + collapsibleSetButton = impls.collapsibleSetButton; ColChooserModel = impls.ColChooserModel; colChooser = impls.colChooser; zoneMapper = impls.zoneMapper; diff --git a/mobile/appcontainer/AppContainer.ts b/mobile/appcontainer/AppContainer.ts index c26c6ee67..e25325a8a 100644 --- a/mobile/appcontainer/AppContainer.ts +++ b/mobile/appcontainer/AppContainer.ts @@ -18,6 +18,7 @@ import {pinPadImpl} from '@xh/hoist/mobile/cmp/pinpad/impl/PinPad'; import {storeFilterFieldImpl} from '@xh/hoist/mobile/cmp/store/impl/StoreFilterField'; import {tabContainerImpl} from '@xh/hoist/mobile/cmp/tab/impl/TabContainer'; import {zoneMapper} from '@xh/hoist/mobile/cmp/zoneGrid/impl/ZoneMapper'; +import {collapsibleSetButton} from '@xh/hoist/mobile/cmp/button/CollapsibleSetButton'; import {elementFromContent, useOnMount} from '@xh/hoist/utils/react'; import {isEmpty} from 'lodash'; import {aboutDialog} from './AboutDialog'; @@ -34,6 +35,7 @@ import {toastSource} from './ToastSource'; import {versionBar} from './VersionBar'; installMobileImpls({ + collapsibleSetButton, tabContainerImpl, storeFilterFieldImpl, pinPadImpl, diff --git a/mobile/cmp/button/CollapsibleSetButton.ts b/mobile/cmp/button/CollapsibleSetButton.ts new file mode 100644 index 000000000..33e6cbf0d --- /dev/null +++ b/mobile/cmp/button/CollapsibleSetButton.ts @@ -0,0 +1,57 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2026 Extremely Heavy Industries Inc. + */ + +import {type ReactElement, type ReactNode, type JSX, useState} from 'react'; +import {tooltip as bpTooltip} from '@xh/hoist/kit/blueprint'; +import {fragment} from '@xh/hoist/cmp/layout'; +import {hoistCmp} from '@xh/hoist/core'; +import type {Intent, HoistProps} from '@xh/hoist/core'; +import {button} from '@xh/hoist/mobile/cmp/button'; +import {legend} from '@xh/hoist/cmp/layout'; +import {Icon} from '@xh/hoist/icon/Icon'; + +export interface CollapsibleSetButtonProps extends HoistProps { + icon?: ReactElement; + text: ReactNode; + tooltip?: JSX.Element | string; + clickHandler?: (boolean) => void; + intent?: Intent; + collapsed?: boolean; + disabled?: boolean; +} + +export const [CollapsibleSetButton, collapsibleSetButton] = + hoistCmp.withFactory({ + displayName: 'CollapsibleSetButton', + model: false, + render({icon, text, tooltip, intent, clickHandler, collapsed, disabled}) { + const [isCollapsed, setIsCollapsed] = useState(collapsed === true), + btn = button({ + text: fragment(text, isCollapsed ? Icon.angleDown() : Icon.angleUp()), + icon, + outlined: isCollapsed && !intent, + minimal: !intent || (intent && !isCollapsed), + intent, + disabled, + onClick: () => { + const val = !isCollapsed; + setIsCollapsed(val); + clickHandler?.(val); + } + }); + + return legend( + tooltip + ? bpTooltip({ + item: btn, + content: tooltip, + intent + }) + : btn + ); + } + }); From a57713359f6a69accad64ad06f800036830e1ef8 Mon Sep 17 00:00:00 2001 From: Colin Rudd Date: Mon, 12 Jan 2026 15:36:24 -0500 Subject: [PATCH 27/27] Add support for renderMode --- cmp/layout/CollapsibleSet.scss | 7 +++++++ cmp/layout/CollapsibleSet.ts | 35 +++++++++++++++++++++++++++++++--- core/enums/RenderMode.ts | 2 +- 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/cmp/layout/CollapsibleSet.scss b/cmp/layout/CollapsibleSet.scss index 3358b5b20..7ae875db5 100644 --- a/cmp/layout/CollapsibleSet.scss +++ b/cmp/layout/CollapsibleSet.scss @@ -10,6 +10,13 @@ border-bottom: none; border-left: none; border-right: none; + + &--render-mode--always, + &--render-mode--lazy { + > *:not(:first-child) { + display: none; + } + } } &.xh-collapsible-set--intent-primary { diff --git a/cmp/layout/CollapsibleSet.ts b/cmp/layout/CollapsibleSet.ts index 8c725c841..5e8ba946a 100644 --- a/cmp/layout/CollapsibleSet.ts +++ b/cmp/layout/CollapsibleSet.ts @@ -9,7 +9,7 @@ import classNames from 'classnames'; import {castArray} from 'lodash'; import {type FieldsetHTMLAttributes, type ReactElement, type ReactNode, useState} from 'react'; import {XH, hoistCmp} from '@xh/hoist/core'; -import type {HoistProps, Intent, LayoutProps, TestSupportProps} from '@xh/hoist/core'; +import type {HoistProps, Intent, LayoutProps, RenderMode, TestSupportProps} from '@xh/hoist/core'; import {fieldset} from '@xh/hoist/cmp/layout'; import {TEST_ID, mergeDeep} from '@xh/hoist/utils/js'; import {splitLayoutProps} from '@xh/hoist/utils/react'; @@ -27,6 +27,7 @@ export interface CollapsibleSetProps clickHandler?: () => void; collapsed?: boolean; hideItemCount?: boolean; + renderMode?: RenderMode; } export const [CollapsibleSet, collapsibleSet] = hoistCmp.withFactory({ @@ -46,6 +47,7 @@ export const [CollapsibleSet, collapsibleSet] = hoistCmp.withFactory(collapsed === true), + [expandCount, setExpandCount] = useState(!collapsed ? 1 : 0), items = castArray(children), itemCount = hideItemCount === true ? '' : ` (${items.length})`, classes = []; @@ -85,6 +88,29 @@ export const [CollapsibleSet, collapsibleSet] = hoistCmp.withFactory setIsCollapsed(val), + clickHandler: val => { + setIsCollapsed(val); + setExpandCount(expandCount + 1); + }, collapsed: isCollapsed, disabled }), - ...(isCollapsed ? [] : items) + ...content ], disabled, ...restProps diff --git a/core/enums/RenderMode.ts b/core/enums/RenderMode.ts index b6467aace..a71558cd6 100644 --- a/core/enums/RenderMode.ts +++ b/core/enums/RenderMode.ts @@ -7,7 +7,7 @@ /** * Options for how contents should be rendered by their parent container. - * Used by {@link TabContainerModel}, {@link DashContainerModel}, and {@link PanelModel}. + * Used by {@link TabContainerModel}, {@link DashContainerModel}, {@link PanelModel}, and {@link CollapsibleSet}. */ export const RenderMode = Object.freeze({ /** Always render contents when the parent container is rendered, even if inactive. */