/* * 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 {frame} from '@xh/hoist/cmp/layout'; import { managed, Persistable, PersistableState, PersistenceProvider, PlainObject, RefreshMode, RenderMode, TaskObserver, XH } from '@xh/hoist/core'; import {DashContainerViewModel} from '@xh/hoist/desktop/cmp/dash/container/DashContainerViewModel'; import {convertIconToHtml, ResolvedIconProps} from '@xh/hoist/icon'; import {GoldenLayout} from '@xh/hoist/kit/golden-layout'; import {action, bindable, makeObservable, observable, runInAction} from '@xh/hoist/mobx'; import {wait} from '@xh/hoist/promise'; import {isOmitted} from '@xh/hoist/utils/impl'; import {debounced, ensureUniqueBy, throwIf} from '@xh/hoist/utils/js'; import {createObservableRef} from '@xh/hoist/utils/react'; import { cloneDeep, defaultsDeep, find, isEqual, isFinite, isNil, last, partition, reject, startCase } from 'lodash'; import {createRoot} from 'react-dom/client'; import {DashConfig, DashModel} from '../'; import {DashViewState} from '../DashViewModel'; import {DashContainerViewSpec} from './DashContainerViewSpec'; import {dashContainerContextMenu} from './impl/DashContainerContextMenu'; import {dashContainerMenuButton} from './impl/DashContainerMenuButton'; import { convertGLToState, convertStateToGL, getViewModelId, goldenLayoutConfig } from './impl/DashContainerUtils'; import {showContextMenu} from '@xh/hoist/kit/blueprint'; /** * Configuration for a {@link DashContainerModel} - a tab-and-stack based dashboard layout * with draggable, resizable views powered by GoldenLayout. * * See the dash package README (`desktop/cmp/dash/README.md`) for architecture and usage. * * @see DashContainerModel * @see DashViewSpec */ export interface DashContainerConfig extends DashConfig< DashContainerViewSpec, DashContainerViewState > { /** Strategy for rendering DashContainerViews. Can also be set per-view in `viewSpecs`*/ renderMode?: RenderMode; /** Strategy for refreshing DashContainerViews. Can also be set per-view in `viewSpecs`*/ refreshMode?: RefreshMode; /** True to include a button in each stack header showing the dash context menu. */ showMenuButton?: boolean; /** Between items in pixels. */ margin?: number; /** * Custom settings to be passed to the GoldenLayout instance. * @see http://golden-layout.com/docs/Config.html */ goldenLayoutSettings?: PlainObject; } export interface DashContainerModelDefaults { margin?: number; showMenuButton?: boolean; } // TODO - review other state inserted by library, determine if we want to model here export interface DashContainerViewState { type: 'row' | 'column' | 'stack' | 'view'; id?: string; content?: DashContainerViewState[]; title?: string; width?: number | string; height?: number | string; state?: PlainObject; } /** * Model for a DashContainer, representing its contents and layout state. * * This model provides support for managing dash views, adding new views on the fly, * and tracking / loading state. * * State should be structured as nested arrays of container objects, according to * GoldenLayout's content config. Supported container types are `row`, `column` and `stack`. * Child containers and views should be provided as an array under the `content` key. * * + `row` lay out its children horizontally. * + `column` lays out its children vertically. * + `stack` lays out its children as tabs. `stacks` can only contain `views` (more below) * * The children of `row` and `column` containers can be sized by providing width or height values. * Numeric values represent relative sizes, expressed as a percentage of the available space. * Pixel values can be provided as a string (e.g. '100px'), which will be converted to a relative * size at parse time. Any unaccounted for space will be divided equally across the remaining children. * * We differ from GoldenLayout by offering a new type `view`. These should be configured as * id references to the provided DashContainerViewSpec, e.g. `{type: `view`, id: ViewSpec.id}`. * Use instead of the `component` and `react-component` types provided by GoldenLayout. * * Note that loading state will destroy and reinitialize all components - do so sparingly! * * @example * ``` * [{ * type: 'row', * content: [ * // The first child of this row has pixel width of '200px'. * // The column will take the remaining width. * { * type: 'stack', * width: '200px', * content: [ * {type: 'view', id: 'viewId'}, * {type: 'view', id: 'viewId'} * ] * }, * { * type: 'column', * content: [ * // Relative height of 40%. The remaining 60% will be split equally by the other views. * {type: 'view', id: 'viewId', height: 40}, * {type: 'view', id: 'viewId'}, * {type: 'view', id: 'viewId'} * ] * } * ] * }] * ``` * * @see http://golden-layout.com/docs/ItemConfig.html * @see http://golden-layout.com/tutorials/getting-started-react.html */ export class DashContainerModel extends DashModel implements Persistable<{state: DashContainerViewState[]}> { /** App-level defaults for DashContainerModel. Instance config takes precedence. */ static defaults: DashContainerModelDefaults = { margin: 6, showMenuButton: false }; //--------------------- // Settable State //---------------------- @bindable showMenuButton: boolean; //----------------------------- // Public properties //----------------------------- renderMode: RenderMode; refreshMode: RefreshMode; goldenLayoutSettings: PlainObject; margin: number; get isEmpty(): boolean { return this.goldenLayout && this.viewModels.length === 0; } //--------------------------- // Implementation properties //---------------------------- @observable.ref goldenLayout: GoldenLayout; containerRef = createObservableRef(); @managed loadingStateTask = TaskObserver.trackLast(); private isDestroyingGoldenLayout = false; constructor({ viewSpecs, viewSpecDefaults, initialState = [], renderMode = 'lazy', refreshMode = 'onShowLazy', layoutLocked = false, contentLocked = false, renameLocked = false, showMenuButton = DashContainerModel.defaults.showMenuButton, margin = DashContainerModel.defaults.margin, goldenLayoutSettings, persistWith = null, emptyText = 'No views have been added to the container.', addViewButtonText = 'Add View', extraMenuItems }: DashContainerConfig) { 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, allowRemove: true, allowRename: true }); }); this.restoreState = {initialState, layoutLocked, contentLocked, renameLocked}; this.renderMode = renderMode; this.refreshMode = refreshMode; this.layoutLocked = layoutLocked; this.contentLocked = contentLocked; this.renameLocked = renameLocked; this.showMenuButton = showMenuButton; this.margin = margin; this.goldenLayoutSettings = goldenLayoutSettings; this.emptyText = emptyText; this.addViewButtonText = addViewButtonText; this.extraMenuItems = extraMenuItems; this.state = initialState; if (persistWith) { PersistenceProvider.create({ persistOptions: { path: 'dashContainer', settleTime: 1000, ...persistWith }, target: this }); } // Initialize GoldenLayout with initial state once ref is ready this.addReaction( { track: () => [this.containerRef.current, this.layoutLocked] as const, run: ([ref, locked]) => { // This reaction is intended to run when ref becomes available. // It's a no-op if ref *removed* due to component re-render. if (ref) this.loadStateAsync(this.state); } }, { track: () => this.viewState, run: () => this.updateState() } ); } /** * 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 async restoreDefaultsAsync() { const {restoreState} = this; this.layoutLocked = restoreState.layoutLocked; this.contentLocked = restoreState.contentLocked; this.renameLocked = restoreState.renameLocked; await this.loadStateAsync(restoreState.initialState); } /** * Load state into the DashContainer, recreating its layout and contents. * * 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. */ async loadStateAsync(state: DashContainerViewState[]) { const ids = new Set(), stateWithViewModelIds = this.withIds(state, ids); const [keep, remove] = partition(this.viewModels, viewModel => ids.has(viewModel.id)); // Always save a reference to the state, even if the container is not yet rendered. // Allows ref reaction on this class to loop back and apply it once GL is ready. runInAction(() => { this.state = stateWithViewModelIds; this.viewModels = keep; XH.safeDestroy(remove); }); // DOM required from this point on to recreate GL with new state. const containerEl = this.containerRef.current; if (!containerEl) return; // Use of async below requires we check after each wait to ensure we haven't re-rendered // again with a new ref. Bail out if so - ref reaction will re-enter and complete the job. const refIsStale = () => this.containerRef.current !== containerEl; return ( wait() .thenAction(() => { if (refIsStale()) return; this.destroyGoldenLayout(); this.goldenLayout = this.createGoldenLayout(containerEl, stateWithViewModelIds); }) // Since React v18, it's necessary to wait a short while for ViewModels to be available. .wait(500) .then(() => { if (refIsStale()) return; this.refreshActiveViews(); this.updateTabHeaders(); }) .linkTo(this.loadingStateTask) ); } /** * Add a view to the container. * * @param specId - DashContainerViewSpec id to add to the container * @param container - GoldenLayout container to add it to. If not provided, will be added to the root container. * @param index - An optional index that determines at which position the new item should be added. */ addView(specId: string, container?: any, index?: number) { const {goldenLayout} = this; if (!goldenLayout) return; const viewSpec = this.getViewSpec(specId), instances = this.getItemsBySpecId(specId); throwIf( !viewSpec, `Trying to add non-existent or omitted DashContainerViewSpec. specId=${specId}` ); throwIf( !viewSpec.allowAdd, `Trying to add DashContainerViewSpec with allowAdd=false. specId=${specId}` ); throwIf( viewSpec.unique && instances.length, `Trying to add multiple instances of a DashContainerViewSpec with unique=true. specId=${specId}` ); if (!container) container = goldenLayout.root.contentItems[0]; if (!isFinite(index)) index = container.contentItems.length; container.addChild(goldenLayoutConfig(viewSpec, this.genViewId(specId)), index); const stack = container.isStack ? container : last(container.contentItems); wait(1).then(() => this.onStackActiveItemChange(stack)); } /** * Remove a view from the container. * @param id - DashContainerViewModel id to remove from the container */ removeView(id: string) { const view = this.getItemByViewModel(id); if (!view) return; view.parent.removeChild(view); } /** * Initiate field renaming for a given view * @param id - DashContainerViewModel id to rename */ renameView(id: string) { const view = this.getItemByViewModel(id); if (!view) return; this.showTitleForm(view.tab.element, this.getViewModel(id)); } onResize() { this.goldenLayout?.updateSize(); } getViewSpec(id: string): DashContainerViewSpec { return this.viewSpecs.find(it => it.id === id); } getViewModel(id: string): DashContainerViewModel { return find(this.viewModels, {id}); } //------------------------ // Persistable Interface //------------------------ getPersistableState(): PersistableState<{state: DashContainerViewState[]}> { return new PersistableState({state: this.state}); } setPersistableState(persistableState: PersistableState<{state: DashContainerViewState[]}>) { const {state} = persistableState.value; if (state) this.loadStateAsync(state); } //------------------------ // Implementation //------------------------ private updateState() { const {goldenLayout, containerRef} = this; if (!goldenLayout?.isInitialised || !containerRef.current) return; // If the layout becomes completely empty, ensure we have our minimal empty layout if (!goldenLayout.root.contentItems.length) { this.loadStateAsync([]); return; } this.updateTabHeaders(); this.publishState(); } @debounced(100) private publishState() { const {goldenLayout} = this; if (!goldenLayout) return; try { const newState = convertGLToState(goldenLayout, this); if (!isEqual(this.state, newState)) { runInAction(() => (this.state = newState)); } } catch (e) { this.logWarn('Failed to convert GL to state', e); } } private onItemDestroyed(item) { if (!item.isComponent || this.isDestroyingGoldenLayout) return; const id = getViewModelId(item); if (id) this.removeViewModel(id); } //----------------- // Items //----------------- // Get all items currently rendered in the container private getItems() { const {goldenLayout} = this; if (!goldenLayout) return []; return goldenLayout.root.getItemsByType('component'); } // Get all view instances with a given DashViewSpec.id private getItemsBySpecId(id: string) { return this.getItems().filter(it => it.config.component === id); } // Get the view instance with the given DashContainerViewModel.id private getItemByViewModel(id: string) { return this.getItems().find(it => it.instance?._reactComponent?.props?.viewModelId === id); } //----------------- // Views //----------------- get viewState() { const ret = {}; this.viewModels.forEach(({id, icon, title, viewState}) => { ret[id] = {icon, title, viewState}; }); return ret; } @action private addViewModel(viewModel: DashContainerViewModel) { this.viewModels = [...this.viewModels, viewModel]; } @action private removeViewModel(id: string) { const viewModel = this.getViewModel(id); XH.safeDestroy(viewModel); this.viewModels = reject(this.viewModels, {id}); } //----------------- // Context Menu //----------------- private onStackCreated(stack) { // Listen to active item change to support RenderMode stack.on('activeContentItemChanged', () => this.onStackActiveItemChange(stack)); // Add menu button to stack header, being sure to preserve any controls that GL has installed const controlsContainerEl = stack.header.controlsContainer, menuContainerEl = document.createElement('div'); controlsContainerEl.appendChild(menuContainerEl); const menuRoot = createRoot(menuContainerEl); menuRoot.render(dashContainerMenuButton({dashContainerModel: this, stack})); // Add context menu listener for adding components. // Replace any handler set by a previous render so we never stack handlers. const headerEl = stack.header.element as HTMLElement & { _xhContextMenuHandler?: EventListener; }; if (headerEl._xhContextMenuHandler) { headerEl.removeEventListener('contextmenu', headerEl._xhContextMenuHandler); } const handler: EventListener = e => { this.showContextMenu(e as MouseEvent, headerEl, stack); // Match the original `return false` from the jQuery handler: // prevents the browser default menu and stops propagation. e.preventDefault(); e.stopPropagation(); }; headerEl.addEventListener('contextmenu', handler); headerEl._xhContextMenuHandler = handler; } private showContextMenu( e: MouseEvent, target: HTMLElement, stack: any, viewModel?: DashContainerViewModel, index?: number ) { if (this.contentLocked) return; // If event does not contain co-ordinates, fallback to showing context menu below target let offset = {left: e.clientX, top: e.clientY}; if (isNil(offset.left) || isNil(offset.top)) { const rect = target.getBoundingClientRect(); offset = { left: rect.left + window.scrollX, top: rect.top + window.scrollY + 30 }; } const menu = dashContainerContextMenu({ stack, viewModel, index, dashContainerModel: this, contextMenuEvent: e }); showContextMenu(menu, offset); } //----------------- // Active View //----------------- private refreshActiveViews() { if (!this.goldenLayout) return; const stacks = this.goldenLayout.root.getItemsByType('stack'); stacks.forEach(stack => this.onStackActiveItemChange(stack)); } private onStackActiveItemChange(stack: any) { if (!this.goldenLayout) return; const items = stack.getItemsByType('component'), activeItem = stack.getActiveContentItem(); items.forEach(item => { const id = getViewModelId(item), viewModel = this.getViewModel(id), isActive = item === activeItem; if (viewModel) viewModel.isActive = isActive; }); } //----------------- // Tab Headers //----------------- private updateTabHeaders() { const items = this.getItems(); items.forEach(item => { const viewModel = this.getViewModel(getViewModelId(item)); if (!viewModel) return; const tabEl = item.tab.element as HTMLElement & { _xhContextMenuHandler?: EventListener; }, stack = item.parent, titleEl = this.getTitleElement(tabEl) as HTMLElement & { _xhDblClickHandler?: EventListener; }, iconSelector = 'svg.svg-inline--fa', viewSpec = this.getViewSpec(item.config.component), {icon} = viewModel; // Replace any prior contextmenu handler so we never stack listeners. if (tabEl._xhContextMenuHandler) { tabEl.removeEventListener('contextmenu', tabEl._xhContextMenuHandler); } const ctxHandler: EventListener = e => { const index = stack.contentItems.indexOf(item); this.showContextMenu(e as MouseEvent, tabEl, stack, viewModel, index); // stopPropagation is critical here: without it, the event // bubbles to the stack header's contextmenu handler which // would replace this tab-specific menu with the stack-level // "Add view" menu and lose the Remove/Rename/Refresh items. e.preventDefault(); e.stopPropagation(); }; tabEl.addEventListener('contextmenu', ctxHandler); tabEl._xhContextMenuHandler = ctxHandler; // Reconcile title text - GL rebuilds tabs from its own config on e.g. drag/drop, // which does not track runtime title changes (renames) made on the view model. titleEl.textContent = viewModel.fullTitle; if (icon) { const currentIcon = tabEl.querySelector(iconSelector) as HTMLElement | null, currentIconType = currentIcon?.dataset.icon ?? null, newIconType = (icon.props as ResolvedIconProps).iconName; if (currentIconType !== newIconType) { const iconSvg = convertIconToHtml(icon); if (currentIcon) currentIcon.remove(); titleEl.insertAdjacentHTML('beforebegin', iconSvg); } } if (viewSpec.allowRename) { this.insertTitleForm(tabEl, viewModel); if (titleEl._xhDblClickHandler) { titleEl.removeEventListener('dblclick', titleEl._xhDblClickHandler); } const dblClickHandler: EventListener = () => this.showTitleForm(tabEl, viewModel); titleEl.addEventListener('dblclick', dblClickHandler); titleEl._xhDblClickHandler = dblClickHandler; } }); } private insertTitleForm(tabEl: HTMLElement, viewModel: DashContainerViewModel) { if (tabEl.querySelector('.title-form')) return; // Create and insert form right after the title element. const titleEl = this.getTitleElement(tabEl); titleEl.insertAdjacentHTML( 'afterend', `
` ); const formEl = tabEl.querySelector('.title-form') as HTMLFormElement, inputEl = formEl.querySelector('input') as HTMLInputElement; inputEl.addEventListener('blur', () => this.hideTitleForm(tabEl)); formEl.addEventListener('submit', e => { e.preventDefault(); const title = inputEl.value; if (title.length) { viewModel.title = title; } this.hideTitleForm(tabEl); }); } private showTitleForm(tabEl: HTMLElement, viewModel: DashContainerViewModel) { if (this.renameLocked) return; const inputEl = tabEl.querySelector('.title-form input') as HTMLInputElement, currentTitle = viewModel.title; tabEl.classList.add('show-title-form'); inputEl.value = currentTitle; inputEl.focus(); inputEl.select(); } private hideTitleForm(tabEl: HTMLElement) { tabEl.classList.remove('show-title-form'); } //----------------- // Misc //----------------- private createGoldenLayout(containerEl: HTMLElement, state: DashViewState[]): GoldenLayout { const {viewSpecs} = this, ret = new GoldenLayout( { content: convertStateToGL(cloneDeep(state), this), settings: { // Remove icons by default showPopoutIcon: false, showMaximiseIcon: false, showCloseIcon: false, // Respect layoutLocked reorderEnabled: !this.layoutLocked, ...this.goldenLayoutSettings }, dimensions: { borderWidth: this.margin, headerHeight: 25 } }, containerEl ); // Register components viewSpecs.forEach(viewSpec => { ret.registerComponent(viewSpec.id, data => { const {viewModelId, title, viewState} = data; let model = this.viewModels.find(it => it.id === viewModelId); if (model) { // Reused on a fresh GL generation (e.g. saved-view switch, restoreDefaults). // Apply incoming values with replace semantics, matching newly-created models // below - `title` arrives pre-resolved to the state title or spec default. model.setViewState(viewState); model.title = title; } else { model = new DashContainerViewModel({ id: viewModelId, viewSpec, title, viewState, containerModel: this }); model.addReaction({ track: () => model.fullTitle, run: () => { // Item lookup requires a mounted react component and can miss during // a GL (re)build - loadStateAsync calls updateTabHeaders to cover. const item = this.getItemByViewModel(viewModelId); if (!item?.tab) return; this.getTitleElement(item.tab.element).textContent = model.fullTitle; } }); this.addViewModel(model); } return frame({className: 'xh-dash-tab', ref: model.viewRef}); }); }); ret.on('stateChanged', () => this.updateState()); ret.on('itemDestroyed', item => this.onItemDestroyed(item)); ret.on('stackCreated', stack => this.onStackCreated(stack)); ret.init(); return ret; } private getTitleElement(tabEl: HTMLElement): HTMLElement { return tabEl.querySelector('.lm_title') as HTMLElement; } /** * Generate and assign viewModelIds to each view in the provided state. * Mutates existingIds to track used IDs. */ private withIds( state: DashContainerViewState[], existingIds: Set ): DashContainerViewStateWithViewModelId[] { if (!state) return state; return state.map(curState => { if (curState.type !== 'view') { return { ...curState, content: this.withIds(curState.content, existingIds) }; } const viewModelId = this.genViewId(curState.id, existingIds); existingIds.add(viewModelId); return {...curState, viewModelId}; }); } @action private destroyGoldenLayout() { // onItemDestroyed will be called for each item. Flag to avoid removing viewModels that // could be re-used on next generation of GL. this.isDestroyingGoldenLayout = true; try { XH.safeDestroy(this.goldenLayout); this.goldenLayout = null; } finally { this.isDestroyingGoldenLayout = false; } } override destroy() { this.destroyGoldenLayout(); XH.safeDestroy(this.viewModels); super.destroy(); } } interface DashContainerViewStateWithViewModelId extends DashContainerViewState { viewModelId?: string; }