/* * 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 {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'; 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, observeResize, throwIf} from '@xh/hoist/utils/js'; import {isOmitted} from '@xh/hoist/utils/impl'; import {createObservableRef} from '@xh/hoist/utils/react'; import { defaultsDeep, find, omit, uniqBy, times, without, some, sortBy, pick, isEqual, startCase, partition, keyBy, compact } from 'lodash'; /** * Configuration for a {@link DashCanvasModel} - a grid-based dashboard layout with * drag-and-drop positioning and resizing of views. * * See the dash package README (`desktop/cmp/dash/README.md`) for architecture and usage. * * @see DashCanvasModel * @see DashCanvasViewSpec */ export interface DashCanvasConfig extends DashConfig { /** * Total number of columns (x coordinates for views correspond with column numbers). * Default `12`. */ columns?: number; /** * Height of each row in pixels (y coordinates for views correspond with row numbers). * Default `50`. */ rowHeight?: number; /** * Compaction strategy for condensing empty space. Use `'wrap'` with caution - it only * works well if all items are 1 row high. Default `'vertical'`. */ compact?: boolean | 'vertical' | 'horizontal' | 'wrap'; /** Gap between items [x, y] in pixels. Default `[10, 10]`. */ margin?: [number, number]; /** Padding inside the container [x, y] in pixels. Defaults to same as `margin`. */ containerPadding?: [number, number]; /** Maximum number of rows permitted for this container. Default `Infinity`. */ maxRows?: number; /** Show grid lines behind widgets. Default `false`. */ showGridBackground?: boolean; /** Accept external drag-and-drop from a {@link DashCanvasWidgetChooser} or similar. Default `false`. */ allowsDrop?: boolean; /** Callback fired after a view is successfully dropped onto the canvas. */ onDropDone?: (viewModel: DashCanvasViewModel) => void; /** * Optional callback to customize the drop placeholder shown when an item is dragged over * the canvas. Return `{w, h}` (in grid units) to size the placeholder, or `false` to * prevent the drop. Return `void` to use the default placeholder size. */ onDropDragOver?: (e: DragEvent) => OnDropDragOverResult; /** Show an Add View button overlay when the canvas is empty. Default `true`. */ showAddViewButtonWhenEmpty?: boolean; } export interface DashCanvasModelDefaults { columns?: number; containerPadding?: [number, number] | null; margin?: [number, number]; maxRows?: number; rowHeight?: number; showGridBackground?: boolean; } /** Serializable state for a single widget on a DashCanvas, including its layout and view config. */ export interface DashCanvasItemState { layout: DashCanvasItemLayout; title?: string; viewSpecId: string; state?: DashViewState; } /** Grid position and size of a single widget on a DashCanvas, in column/row units. */ export interface DashCanvasItemLayout { x: number; y: number; w: number; h: number; } /** Return type for {@link DashCanvasConfig.onDropDragOver}. */ 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. */ export class DashCanvasModel extends DashModel implements Persistable<{state: DashCanvasItemState[]}> { /** App-level defaults for DashCanvasModel. Instance config takes precedence. */ static defaults: DashCanvasModelDefaults = { columns: 12, containerPadding: null, margin: [10, 10], maxRows: Infinity, rowHeight: 50, showGridBackground: false }; //----------------------------- // Settable State //------------------------------ @bindable columns: number; @bindable rowHeight: number; @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 //----------------------------- maxRows: number; allowsDrop: boolean; onDropDone: (viewModel: DashCanvasViewModel) => void; /** The view currently being dragged in from an external source (e.g. a DashCanvasWidgetChooser). */ @observable.ref draggedInView: DashCanvasItemState; /** Current number of rows in the canvas. */ get rows(): number { return this.layout.reduce((prev, cur) => Math.max(prev, cur.y + cur.h), 0); } /** True if the canvas has no widgets. */ get isEmpty(): boolean { return this.layout.length === 0; } //---------------------------- // Implementation properties //---------------------------- @observable.ref layout: any[] = []; ref = createObservableRef(); isResizing: boolean; private _onDropDragOverFn: DashCanvasConfig['onDropDragOver']; get rglLayout() { 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({ viewSpecs, viewSpecDefaults, initialState = [], layoutLocked = false, contentLocked = false, renameLocked = false, persistWith = null, emptyText = 'No widgets have been added.', addViewButtonText = 'Add Widget', columns = DashCanvasModel.defaults.columns, rowHeight = DashCanvasModel.defaults.rowHeight, compact = 'vertical', margin = DashCanvasModel.defaults.margin, maxRows = DashCanvasModel.defaults.maxRows, containerPadding = DashCanvasModel.defaults.containerPadding ?? margin, extraMenuItems, showGridBackground = DashCanvasModel.defaults.showGridBackground, showAddViewButtonWhenEmpty = true, allowsDrop = false, onDropDone, onDropDragOver }: DashCanvasConfig) { super(); makeObservable(this); viewSpecs = viewSpecs.filter(it => !isOmitted(it)); ensureUniqueBy(viewSpecs, 'id'); this.viewSpecs = viewSpecs.map(cfg => { return defaultsDeep({}, cfg, viewSpecDefaults, { title: startCase(cfg.id), omit: false, unique: false, allowAdd: true, allowDuplicate: true, allowRemove: true, allowRename: true, height: 5, width: 5, hidePanelHeader: false, hideMenuButton: false, autoHeight: false }); }); this.restoreState = { initialState, layoutLocked, contentLocked, renameLocked, columns, rowHeight, compact, margin, maxRows, containerPadding }; this.layoutLocked = layoutLocked; this.contentLocked = contentLocked; this.renameLocked = renameLocked; this.columns = columns; this.rowHeight = rowHeight; this.maxRows = maxRows; this.containerPadding = containerPadding; this.margin = margin; this.compact = compact === true ? 'vertical' : compact === false ? null : compact; this.emptyText = emptyText; this.addViewButtonText = addViewButtonText; this.extraMenuItems = extraMenuItems; this.showGridBackground = showGridBackground; this.showAddViewButtonWhenEmpty = showAddViewButtonWhenEmpty; this.allowsDrop = allowsDrop; this.onDropDone = onDropDone; this._onDropDragOverFn = onDropDragOver; this.loadState(initialState); // Initialize `state` directly - when initialState is empty, loadState above is a no-op // (setLayout early-returns) and the viewState reaction below does not yet exist, but the // PersistenceProvider must capture a well-formed (not undefined) default state on create. this.state = this.buildState(); if (persistWith) { PersistenceProvider.create({ persistOptions: { path: 'dashCanvas', settleTime: 1000, ...persistWith }, target: this }); } this.addReaction({ track: () => this.viewState, run: () => (this.state = this.buildState()), fireImmediately: true }); // 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} ); } }); } /** Remove all views from the canvas. */ @action clear() { const {viewModels} = this; this.viewModels = []; this.setLayout([]); XH.safeDestroy(viewModels); } /** * Restore the initial state as specified by the application at construction time. This is the * state without any persisted state or user changes applied. * * This method will clear the persistent state saved for this component, if any. */ @action restoreDefaults() { const {restoreState} = this; this.layoutLocked = restoreState.layoutLocked; this.contentLocked = restoreState.contentLocked; this.renameLocked = restoreState.renameLocked; this.columns = restoreState.columns; this.rowHeight = restoreState.rowHeight; this.loadState(restoreState.initialState); } /** * Add a view to the canvas. * @param specId - ID of the DashCanvasViewSpec to add. * @param opts - optional title, state, dimensions, and position. `position` accepts a view * ID in addition to the enumerated values - the new view will take that view's position. */ @action addView( specId: string, opts: { title?: string; position?: 'first' | 'last' | 'nextAvailable' | string; state?: any; width?: number; height?: number; } = {} ): DashCanvasViewModel { const {title, position = 'nextAvailable', state, width, height} = opts; const layout = { ...this.getLayoutFromPosition(position, specId), w: width, h: height }; return this.addViewInternal(specId, {title, layout, state}); } /** * Remove a view from the canvas. * @param id - DashCanvasViewModel ID to remove. */ @action removeView(id: string) { const removeLayout = this.getViewLayout(id), removeView = this.getView(id); this.setLayout(without(this.layout, removeLayout)); this.viewModels = without(this.viewModels, removeView); XH.safeDestroy(removeView); } /** * Replace a view with a different view spec, keeping the existing layout position and size. * @param id - ID of the view model to replace. * @param newSpecId - ID of the view spec to insert. */ @action replaceView(id: string, newSpecId: string) { const layout = this.getViewLayout(id); this.removeView(id); this.addViewInternal(newSpecId, {layout}); } /** Prompt the user to rename a view. No-op if renaming is locked or disallowed by the spec. */ renameView(id: string) { const view = this.getView(id), allowRename = view?.viewSpec?.allowRename && !this.renameLocked; if (!allowRename) return; XH.prompt({ message: `Rename '${view.title}' to`, title: 'Rename...', icon: Icon.edit(), input: { initialValue: view.title, rules: [required] } }).then(newName => { if (newName) view.title = newName; }); } /** Scroll a view into the visible area of the canvas. */ ensureViewVisible(id: string) { this.getView(id)?.ensureVisible(); } /** * Handle a completed drop from react-grid-layout. Creates the new view from `draggedInView` * and places it at the drop location. Called by the DashCanvas component - not typically * called directly by application code. */ 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 DashCanvasWidgetChooser or similar component.` ); const droppingItem: any = rglLayout.find(it => it.i === RGL_DROPPING_ITEM_ID); if (!droppingItem) { // if `onDropDragOver` returned false, we won't have a dropping item // and we cancel the drop this.setDraggedInView(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.setDraggedInView(null); this.onRglLayoutChange(rglLayout); this.onDropDone?.(newViewModel); }); } /** Set the view to be created on the next drop. Called by DashCanvasWidgetChooser on drag start. */ @action setDraggedInView(view?: DashCanvasItemState) { this.draggedInView = view; } /** * Handler for drag-over during an external drop. If a custom `onDropDragOver` was provided via * config, it is called. Otherwise returns the placeholder size from `draggedInView`, or `false` * if no view is being dragged. */ onDropDragOver(evt: DragEvent): OnDropDragOverResult { if (this._onDropDragOverFn) return this._onDropDragOverFn(evt); if (!this.draggedInView) return false; return { w: this.draggedInView.layout.w, h: this.draggedInView.layout.h }; } /** Return all current view models matching the given view spec ID. */ getViewsBySpecId(id: string): DashCanvasViewModel[] { return this.viewModels.filter(it => it.viewSpec.id === id); } /** * Load the given state array into the canvas, replacing the current set of views and layout. * Applications can call this directly when they already hold a `DashCanvasItemState[]` and * want to avoid constructing a `PersistableState` wrapper. * * Note this applies full replace (not patch) semantics, at both levels: views not present in * the given state are removed, and entries fully replace each matched view's state - omitted * properties (e.g. `title`, `state`) reset to their defaults. */ @action loadState(state: DashCanvasItemState[]) { const ids = new Set(), stateWithIds = state.map(it => { const id = this.genViewId(it.viewSpecId, ids); ids.add(id); return {id, ...it}; }); const [keep, remove] = partition(this.viewModels, viewModel => ids.has(viewModel.id)), existingViewModelsById = keyBy(keep, 'id'); XH.safeDestroy(remove); this.viewModels = compact( stateWithIds.map(it => { const existingViewModel = existingViewModelsById[it.id]; if (existingViewModel) { // Loading state over an existing view applies replace semantics - omitted // values reset to their defaults (required by e.g. restoreDefaults). For // viewState the default is nullish (widget defaults), but title must reset // to the spec title rather than wipe, matching DashViewModel construction. existingViewModel.setViewState(it.state); existingViewModel.title = it.title ?? existingViewModel.viewSpec.title; return existingViewModel; } // Fail gracefully on unknown viewSpecId - persisted state could ref. an obsolete widget. if (!this.hasSpec(it.viewSpecId)) { this.logWarn( `Unknown viewSpecId [${it.viewSpecId}] found in state - skipping.` ); return null; } return new DashCanvasViewModel({ id: it.id, viewSpec: this.getSpec(it.viewSpecId), title: it.title, viewState: it.state, containerModel: this }); }) ); this.setLayout(stateWithIds.map(it => ({i: it.id, ...it.layout}))); } //------------------------ // Persistable Interface //------------------------ getPersistableState(): PersistableState<{state: DashCanvasItemState[]}> { return new PersistableState({state: this.state}); } setPersistableState(persistableState: PersistableState<{state: DashCanvasItemState[]}>) { const {state} = persistableState.value; if (state) this.loadState(state); } //------------------------ // Implementation //------------------------ private rglResizeObserver: ResizeObserver; private getLayoutFromPosition(position: string, specId: string) { switch (position) { case 'first': return {x: 0, y: -1}; case 'last': return {x: 0, y: this.rows}; case 'nextAvailable': return this.getNextAvailablePosition(this.getSpec(specId)); default: { const previousView = this.getViewLayout(position); throwIf( !previousView, `Position must be either 'first', 'last', 'nextAvailable' or a valid viewId` ); const {x, y} = previousView; return {x, y}; } } } @action private addViewInternal(specId: string, {layout, title, state, previousViewId}: any) { const viewSpec = this.getSpec(specId), instances = this.getViewsBySpecId(specId); throwIf( !viewSpec, `Trying to add non-existent or omitted DashCanvasViewSpec. id=${specId}` ); throwIf( !viewSpec.allowAdd, `Trying to add DashCanvasViewSpec with allowAdd=false. id=${specId}` ); throwIf( viewSpec.unique && instances.length, `Trying to add multiple instances of a DashCanvasViewSpec with unique=true. id=${specId}` ); const id = this.genViewId(viewSpec.id), model = new DashCanvasViewModel({ id, viewSpec, viewState: state, title, containerModel: this }), prevLayout = previousViewId ? this.getViewLayout(previousViewId) : null, x = prevLayout?.x ?? layout?.x ?? 0, y = prevLayout?.y ?? layout?.y ?? this.rows, h = layout?.h ?? viewSpec.height ?? viewSpec.minHeight ?? 1, w = layout?.w ?? viewSpec.width ?? viewSpec.minWidth ?? 1; this.setLayout([...this.layout, {i: id, x, y, h, w}]); this.viewModels = [...this.viewModels, model]; return model; } 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 === RGL_DROPPING_ITEM_ID)) return; this.setLayout(rglLayout); } @action private setLayout(layout: LayoutItem[]) { layout = sortBy(layout, 'i'); if (isEqual(layout, this.layout)) return; this.layout = layout; this.state = this.buildState(); } private buildState(): DashCanvasItemState[] { const {viewState} = this; return this.layout.map(it => { const {i: viewId, x, y, w, h} = it, state = viewState[viewId]; return { layout: {x, y, w, h}, ...state }; }); } @computed.struct private get viewState() { const ret = {}; this.viewModels.forEach(({id, viewSpec, title, viewState}) => { ret[id] = { viewSpecId: viewSpec.id, title, state: viewState }; }); return ret; } private getView(id: string) { return find(this.viewModels, {id}); } private getViewLayout(id: string) { return find(this.layout, {i: id}); } private setViewLayout(layout) { this.setLayout(uniqBy([layout, ...this.layout], 'i')); } private getSpec(id) { return find(this.viewSpecs, {id}); } private hasSpec(id) { return some(this.viewSpecs, {id}); } private getNextAvailablePosition({ width, height, startX = 0, startY = 0, defaultX = 0, endY = null }: any) { const {rows, columns} = this, occupied = times(columns, () => Array(rows).fill(false)); // Fill 2D array 'occupied' with true / false if coordinate is occupied for (let item of this.layout) { for (let y = item.y; y < item.y + item.h; y++) { for (let x = item.x; x < item.x + item.w; x++) { occupied[x][y] = true; } } } const checkPosition = (originX, originY) => { for (let y = originY; y < originY + height; y++) { for (let x = originX; x < originX + width; x++) { if (y === rows) return true; if (occupied[x][y]) return false; } } return true; }; // Traverse 2D array of coordinates, and check if view fits for (let y = startY; y < (endY ?? rows); y++) { for (let x = y === startY ? startX : 0; x < columns; x++) { if (x + width > columns) break; if (checkPosition(x, y)) { return {x, y}; } } } return {x: defaultX, y: endY ?? rows}; } } /** * Sentinel layout item ID hardcoded by react-grid-layout for the temporary placeholder element * shown while an external item is being dragged over the canvas. Used internally to detect * and filter RGL's in-flight drop placeholder from layout change events. */ const RGL_DROPPING_ITEM_ID = '__dropping-elem__';