diff --git a/CHANGELOG.md b/CHANGELOG.md index 151300f3e..5cb56c51e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,17 @@ * Added new CSS variables `--xh-intent-danger-text-color` (and others). Consider using these when styling text with Hoist intent colors to enhance legibility in dark mode. +### 🎁 New Features + +* DashCanvas: + * supports dragging and dropping widgets in from an external container. + * supports new compacting strategy: 'wrap' +* new elementFactory tags: `fieldset`, `legend` + +### 📚 Libraries + +* react-grid-layout `2.1 → 2.2.2` + ## 79.0.0 - 2026-01-05 ### 💥 Breaking Changes diff --git a/cmp/grid/GridModel.ts b/cmp/grid/GridModel.ts index eaa8d8e45..c3429977e 100644 --- a/cmp/grid/GridModel.ts +++ b/cmp/grid/GridModel.ts @@ -1442,9 +1442,9 @@ export class GridModel extends HoistModel { /** * Begin an inline editing session. - * @param record - StoreRecord/ID to edit. If unspecified, the first selected StoreRecord + * @param opts.record - StoreRecord/ID to edit. If unspecified, the first selected StoreRecord * will be used, if any, or the first overall StoreRecord in the grid. - * @param colId - ID of column on which to start editing. If unspecified, the first + * @param opts.colId - ID of column on which to start editing. If unspecified, the first * editable column will be used. */ async beginEditAsync(opts: {record?: StoreRecordOrId; colId?: string} = {}) { diff --git a/cmp/layout/CollapsibleSet.scss b/cmp/layout/CollapsibleSet.scss new file mode 100644 index 000000000..7ae875db5 --- /dev/null +++ b/cmp/layout/CollapsibleSet.scss @@ -0,0 +1,49 @@ +/* + * 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-set { + &--collapsed { + 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 { + border-color: var(--xh-intent-primary-trans2); + &.xh-collapsible-set--enabled { + border-color: var(--xh-intent-primary); + } + } + + &.xh-collapsible-set--intent-success { + border-color: var(--xh-intent-success-trans2); + &.xh-collapsible-set--enabled { + border-color: var(--xh-intent-success); + } + } + + &.xh-collapsible-set--intent-warning { + border-color: var(--xh-intent-warning-trans2); + &.xh-collapsible-set--enabled { + border-color: var(--xh-intent-warning); + } + } + + &.xh-collapsible-set--intent-danger { + border-color: var(--xh-intent-danger-trans2); + &.xh-collapsible-set--enabled { + border-color: var(--xh-intent-danger); + } + } +} diff --git a/cmp/layout/CollapsibleSet.ts b/cmp/layout/CollapsibleSet.ts new file mode 100644 index 000000000..5e8ba946a --- /dev/null +++ b/cmp/layout/CollapsibleSet.ts @@ -0,0 +1,135 @@ +/* + * 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 {XH, hoistCmp} 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'; +import {collapsibleSetButton as desktopCollapsibleSetButtonImpl} from '@xh/hoist/dynamics/desktop'; +import {collapsibleSetButton as mobileCollapsibleSetButtonImpl} from '@xh/hoist/dynamics/mobile'; + +import './CollapsibleSet.scss'; + +export interface CollapsibleSetProps + extends FieldsetHTMLAttributes, HoistProps, TestSupportProps, LayoutProps { + icon?: ReactElement; + label: ReactNode; + tooltip?: JSX.Element | string; + intent?: Intent; + clickHandler?: () => void; + collapsed?: boolean; + hideItemCount?: boolean; + renderMode?: RenderMode; +} + +export const [CollapsibleSet, collapsibleSet] = hoistCmp.withFactory({ + displayName: 'CollapsibleSet', + model: false, + className: 'xh-collapsible-set', + render({ + icon, + label, + tooltip, + intent, + collapsed, + children, + hideItemCount, + className, + disabled, + display = 'flex', + flexDirection = 'column', + flexWrap = 'wrap', + renderMode = 'unmountOnHide', + ...rest + }) { + // Note `model` destructured off of non-layout props to avoid setting + // model as a bogus DOM attribute. This low-level component may easily be passed one from + // a parent that has not properly managed its own props. + let [layoutProps, {model, testId, ...restProps}] = splitLayoutProps(rest); + + restProps = mergeDeep( + {style: {display, flexDirection, flexWrap, ...layoutProps}}, + {[TEST_ID]: testId}, + restProps + ); + + const [isCollapsed, setIsCollapsed] = useState(collapsed === true), + [expandCount, setExpandCount] = useState(!collapsed ? 1 : 0), + items = castArray(children), + itemCount = hideItemCount === true ? '' : ` (${items.length})`, + classes = []; + + if (isCollapsed) { + classes.push('xh-collapsible-set--collapsed'); + } else { + classes.push('xh-collapsible-set--expanded'); + } + + if (disabled) { + classes.push('xh-collapsible-set--disabled'); + } else { + classes.push('xh-collapsible-set--enabled'); + } + + if (intent) { + classes.push(`xh-collapsible-set--intent-${intent}`); + } else { + classes.push(`xh-collapsible-set--intent-none`); + } + + const btn = XH.isMobileApp + ? mobileCollapsibleSetButtonImpl + : desktopCollapsibleSetButtonImpl; + + let content; + switch (renderMode) { + case 'always': + content = items; + if (isCollapsed) { + classes.push('xh-collapsible-set--collapsed--render-mode--always'); + } + break; + + case 'lazy': + content = isCollapsed && !expandCount ? [] : items; + if (isCollapsed) { + classes.push('xh-collapsible-set--collapsed--render-mode--lazy'); + } + break; + + // unmountOnHide + default: + content = isCollapsed ? [] : items; + break; + } + + return fieldset({ + className: classNames(className, classes), + items: [ + btn({ + icon, + text: `${label}${itemCount}`, + tooltip, + intent, + clickHandler: val => { + setIsCollapsed(val); + setExpandCount(expandCount + 1); + }, + collapsed: isCollapsed, + disabled + }), + ...content + ], + disabled, + ...restProps + }); + } +}); 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'); diff --git a/cmp/tab/TabContainerModel.ts b/cmp/tab/TabContainerModel.ts index c6f56931a..1ec9ef573 100644 --- a/cmp/tab/TabContainerModel.ts +++ b/cmp/tab/TabContainerModel.ts @@ -123,7 +123,7 @@ export class TabContainerModel extends HoistModel { /** * @param config - TabContainer configuration. - * @param [depth] - Depth in hierarchy of nested TabContainerModels. Not for application use. + * @param depth - Depth in hierarchy of nested TabContainerModels. Not for application use. */ constructor( { 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. */ diff --git a/desktop/appcontainer/AppContainer.ts b/desktop/appcontainer/AppContainer.ts index 10e24b0df..a825d2d7b 100644 --- a/desktop/appcontainer/AppContainer.ts +++ b/desktop/appcontainer/AppContainer.ts @@ -27,6 +27,7 @@ import {zoneMapperDialog as zoneMapper} from '@xh/hoist/desktop/cmp/zoneGrid/imp import {useContextMenu, useHotkeys} from '@xh/hoist/desktop/hooks'; import {DynamicTabSwitcherModel, installDesktopImpls} from '@xh/hoist/dynamics/desktop'; import {inspectorPanel} from '@xh/hoist/inspector/InspectorPanel'; +import {collapsibleSetButton} from '@xh/hoist/desktop/cmp/button/CollapsibleSetButton'; import {blueprintProvider} from '@xh/hoist/kit/blueprint'; import {consumeEvent} from '@xh/hoist/utils/js'; import {elementFromContent, useOnMount} from '@xh/hoist/utils/react'; @@ -46,6 +47,7 @@ import {toastSource} from './ToastSource'; import {versionBar} from './VersionBar'; installDesktopImpls({ + collapsibleSetButton, tabContainerImpl, dockContainerImpl, storeFilterFieldImpl, diff --git a/desktop/cmp/button/CollapsibleSetButton.ts b/desktop/cmp/button/CollapsibleSetButton.ts new file mode 100644 index 000000000..c759be9ca --- /dev/null +++ b/desktop/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 {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 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, + 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/dash/canvas/DashCanvas.ts b/desktop/cmp/dash/canvas/DashCanvas.ts index d26a9ef96..d385192bd 100644 --- a/desktop/cmp/dash/canvas/DashCanvas.ts +++ b/desktop/cmp/dash/canvas/DashCanvas.ts @@ -10,7 +10,7 @@ import ReactGridLayout, { 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 { @@ -62,7 +62,11 @@ export const [DashCanvas, dashCanvas] = hoistCmp.withFactory({ render({className, model, rglOptions, testId}, ref) { const isDraggable = !model.layoutLocked, isResizable = !model.layoutLocked, - {width, containerRef, mounted} = useContainerWidth(); + {width, containerRef, mounted} = useContainerWidth(), + defaultDroppedItemDims = { + w: Math.floor(model.columns / 3), + h: Math.floor(model.columns / 3) + }; return refreshContextView({ model: model.refreshContextModel, @@ -98,7 +102,20 @@ export const [DashCanvas, dashCanvas] = hoistCmp.withFactory({ resizeConfig: { enabled: isResizable }, - compactor: getCompactor(model.compact, false, false), + dropConfig: { + enabled: model.contentLocked ? false : model.allowsDrop, + defaultItem: defaultDroppedItemDims, + onDragOver: (evt: DragEvent) => model.onDropDragOver(evt) + }, + onDrop: ( + layout: LayoutItem[], + layoutItem: LayoutItem, + evt: Event + ) => model.onDrop(layout, layoutItem, evt), + compactor: + model.compact === 'wrap' + ? wrapCompactor + : getCompactor(model.compact, false, false), onLayoutChange: (layout: LayoutItem[]) => model.onRglLayoutChange(layout), onResizeStart: () => (model.isResizing = true), @@ -116,7 +133,7 @@ export const [DashCanvas, dashCanvas] = hoistCmp.withFactory({ ), width }), - emptyContainerOverlay({omit: !mounted}) + emptyContainerOverlay({omit: !mounted || !model.showAddViewButtonWhenEmpty}) ], [TEST_ID]: testId }) diff --git a/desktop/cmp/dash/canvas/DashCanvasModel.ts b/desktop/cmp/dash/canvas/DashCanvasModel.ts index e8e95dbdc..552f436ee 100644 --- a/desktop/cmp/dash/canvas/DashCanvasModel.ts +++ b/desktop/cmp/dash/canvas/DashCanvasModel.ts @@ -4,6 +4,7 @@ * * Copyright © 2026 Extremely Heavy Industries Inc. */ +import {wait} from '@xh/hoist/promise'; import type {LayoutItem} from 'react-grid-layout'; import {Persistable, PersistableState, PersistenceProvider, XH} from '@xh/hoist/core'; import {required} from '@xh/hoist/data'; @@ -17,6 +18,7 @@ import {createObservableRef} from '@xh/hoist/utils/react'; import { defaultsDeep, find, + omit, uniqBy, times, without, @@ -41,11 +43,12 @@ 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 properties indicating the desired width, height (in grid units), + * and offset (in pixels) of the dropping placeholder. The method's signature is the same as + * the `onDropDragOver` prop of ReactGridLayout. + * Returning `false` will prevent the dropping placeholder from being shown, and prevents a drop. + * Returning `void` will use the default behavior, which is to size the placeholder as per the + * `dropConfig.defaultItem` specification. + */ + onDropDragOver?: (e: DragEvent) => OnDropDragOverResult; + + /** + * Whether an overlay with an Add View button should be rendered + * when the canvas is empty. Default true. + */ + showAddViewButtonWhenEmpty?: boolean; } export interface DashCanvasItemState { @@ -76,6 +108,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. @@ -89,16 +131,21 @@ export class DashCanvasModel //------------------------------ @bindable columns: number; @bindable rowHeight: number; - @bindable compact: 'vertical' | 'horizontal'; + @bindable compact: 'vertical' | 'horizontal' | 'wrap'; @bindable.ref margin: [number, number]; // [x, y] @bindable.ref containerPadding: [number, number]; // [x, y] @bindable showGridBackground: boolean; @bindable rglHeight: number; + @bindable showAddViewButtonWhenEmpty: boolean; //----------------------------- // Public properties //----------------------------- + DROPPING_ELEM_ID = '__dropping-elem__'; maxRows: number; + allowsDrop: boolean; + onDropDone: (viewModel: DashCanvasViewModel) => void; + draggedInView: DashCanvasItemState; /** Current number of rows in canvas */ get rows(): number { @@ -118,21 +165,27 @@ 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); + + // `dashCanvasView` will not be found if `it` is a dropping element. + 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({ @@ -152,7 +205,11 @@ export class DashCanvasModel maxRows = Infinity, containerPadding = margin, extraMenuItems, - showGridBackground = false + showGridBackground = false, + showAddViewButtonWhenEmpty = true, + allowsDrop = false, + onDropDone, + onDropDragOver }: DashCanvasConfig) { super(); makeObservable(this); @@ -200,6 +257,10 @@ export class DashCanvasModel this.addViewButtonText = addViewButtonText; this.extraMenuItems = extraMenuItems; this.showGridBackground = showGridBackground; + this.showAddViewButtonWhenEmpty = showAddViewButtonWhenEmpty; + this.allowsDrop = allowsDrop; + this.onDropDone = onDropDone; + if (onDropDragOver) this.onDropDragOver = onDropDragOver; this.loadState(initialState); this.state = this.buildState(); @@ -337,6 +398,59 @@ export class DashCanvasModel this.getView(id)?.ensureVisible(); } + onDrop(rglLayout: LayoutItem[], layoutItem: LayoutItem, 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 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 + }); + + // 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; + + // 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(evt: DragEvent): OnDropDragOverResult { + if (!this.draggedInView) return false; + + return { + w: this.draggedInView.layout.w, + h: this.draggedInView.layout.h + }; + } + + getViewsBySpecId(id) { + return this.viewModels.filter(it => it.viewSpec.id === id); + } + //------------------------ // Persistable Interface //------------------------ @@ -413,6 +527,12 @@ export class DashCanvasModel 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 + // 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); } @@ -496,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..9a881112f --- /dev/null +++ b/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWell.scss @@ -0,0 +1,34 @@ +.xh-dash-canvas-widget-well { + padding: 0 var(--xh-pad-half-px); + + .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); + } + + .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); + text-wrap-mode: nowrap; + cursor: grab; + + &.is-dragging { + cursor: grabbing; + // lighten background color of left behind placeholder + // when dragging + opacity: 0.25; + } + + &:active { + 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 new file mode 100644 index 000000000..56d44ea82 --- /dev/null +++ b/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWell.ts @@ -0,0 +1,135 @@ +/* + * 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, 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 {collapsibleSet} from '@xh/hoist/cmp/layout/CollapsibleSet'; + +import './DashCanvasWidgetWell.scss'; + +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` */ + flexDirection?: 'row' | 'column'; +} + +/** + * 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. + * + * Typically, an app developer would place this inside a collapsible panel to the side of + * a DashCanvas. + */ +export const [DashCanvasWidgetWell, dashCanvasWidgetWell] = + hoistCmp.withFactory({ + displayName: 'DashCanvasWidgetWell', + model: creates(DashCanvasWidgetWellModel), + className: 'xh-dash-canvas-widget-well', + render({dashCanvasModel, flexDirection, className, testId}) { + if (!dashCanvasModel) return; + + 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), + testId + }); + } + }); + +//--------------------------- +// 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, flexDirection): 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 collapsibleSet({ + icon, + collapsed: false, + label, + flexDirection, + 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/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 + ); + } + }); diff --git a/package.json b/package.json index 599768ee1..69a1f031a 100644 --- a/package.json +++ b/package.json @@ -62,11 +62,11 @@ "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", - "react-grid-layout": "2.1.1", + "react-grid-layout": "2.2.2", "react-markdown": "~10.1.0", "react-onsenui": "~1.13.2", "react-popper": "~2.3.0", diff --git a/yarn.lock b/yarn.lock index b91b344ee..43ef919f3 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" @@ -6538,10 +6545,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.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== +react-grid-layout@2.2.2: + version "2.2.2" + resolved "https://registry.yarnpkg.com/react-grid-layout/-/react-grid-layout-2.2.2.tgz#8fa1802ffafc21c5aeb087b75809acaac53071a4" + integrity sha512-yNo9pxQWoxHWRAwHGSVT4DEGELYPyQ7+q9lFclb5jcqeFzva63/2F72CryS/jiTIr/SBIlTaDdyjqH+ODg8oBw== dependencies: clsx "^2.1.1" fast-equals "^4.0.3"