/* * 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 {fragment, strong, p, span} from '@xh/hoist/cmp/layout'; import { CallContext, CallContextLike, ExceptionHandlerOptions, HoistModel, LoadSpec, PlainObject, TaskObserver, Thunkable, XH } from '@xh/hoist/core'; import type {ViewManagerProvider, ReactionSpec} from '@xh/hoist/core'; import {genDisplayName} from '@xh/hoist/data'; import {fmtDateTime} from '@xh/hoist/format'; import {action, bindable, makeObservable, observable, comparer, runInAction} from '@xh/hoist/mobx'; import {ONE_SECOND, SECONDS} from '@xh/hoist/utils/datetime'; import {executeIfFunction, pluralize, throwIf} from '@xh/hoist/utils/js'; import { find, isEqual, isNil, isNull, isObject, isUndefined, lowerCase, remove, uniqBy } from 'lodash'; import {ReactNode} from 'react'; import {ViewInfo} from './ViewInfo'; import {View} from './View'; import {DataAccess} from './DataAccess'; export interface ViewCreateSpec { name: string; group: string; description: string; isShared: boolean; isGlobal: boolean; isPinned?: boolean; value: PlainObject; } export type ViewUpdateSpec = Partial>; export interface ViewUserState { currentView?: string; userPinned: Record; autoSave: boolean; } /** * Configuration for a {@link ViewManagerModel} - persists and manages named user views * (saved configurations) for grids, dashboards, or other stateful components. * * @see ViewManagerModel */ export interface ViewManagerConfig { /** * Required discriminator for the particular class of views to be loaded and managed by this * model. Used to set the `type` property on all JSONBlobs persisted by this model. * * Choose something descriptive and specific enough to be identifiable and allow for different * ViewManagers to be added to your app in the future - e.g. `portfolioGridView` or * `tradeBlotterDashboard`. */ type: string; /** * Optional user-facing qualifier (default "default") for the special in-code default view * option, if enabled. Will be prepended to `typeDisplayName`. * * A use case is to support a ViewManager persisted Dashboard, where the in-code default is an * empty layout, this config is set to "New", and the `typeDisplayName` is set to "Dashboard". * This results in a "New Dashboard" option in the menu, allowing users to quickly access a * blank dashboard to start building from scratch, while forcing a save-as to persist. */ defaultDisplayName?: string; /** True (default) to allow users to opt-in to auto-saving changes to their current view. */ enableAutoSave?: boolean; /** * True (default) to allow the user to select a special view from the menu that restores all * persisted objects to their in-code defaults. If not enabled, at least one globally shared * view should be added to provide an initial selection for users without any private views. */ enableDefault?: boolean; /** * True (default) to enable "global" views - i.e. views that are not owned by a user and are * available to all. At least some users should have `manageGlobal` set to true to allow * creation and management of these views. */ enableGlobal?: boolean; /** True (default) to allow users to share their views with other users. */ enableSharing?: boolean; /** * User-facing qualifier for labelling globally shared views - default "global". A use case * would be to set to the name of the company/team that manages these canonical views, e.g. * "Acme Corp". */ globalDisplayName?: string; /** * Function to determine the initial view for a user, when they have no prior view already * persisted. Called with a list of views available to the current user. * * Must be set when `enableDefault: false`. Developers should take care to return *some* view * in this case, if any are available. If no view is returned, the control will be forced to * fall back to the in-code default. */ initialViewSpec?: (views: ViewInfo[]) => ViewInfo; /** * Optional discriminator for the particular area of an app in which this instance of the * ViewManager appears, for apps that have multiple manager instances that load the same `type` * of views. A particular `currentView` and `pendingValue` will be maintained for each instance, * but all other options and the available library of views will be shared across the `type`. */ instance?: string; /** * True to allow the user to creat and manage Global views. Apps are expected to commonly set * this based on user roles - e.g. `XH.getUser().hasRole('MANAGE_GRID_VIEWS')`. */ manageGlobal?: Thunkable; /** * True (default) to save pending state to SessionStorage so that it can be restored across * browser refreshes. Unlike auto-save, this does not write to the database. */ preserveUnsavedChanges?: boolean; /** * User-facing display name for the type of views being managed - e.g. "report" or "dashboard". * Displayed in the `ViewManager` menu and associated management dialogs and prompts. * Defaulted from `type` if not provided. */ typeDisplayName?: string; /** * Optional render function to customize the BlueprintJS `menuItem` shown for each view in the * ViewManager menu. */ viewMenuItemFn?: (view: ViewInfo, model: ViewManagerModel) => ReactNode; } /** * ViewManagerModel coordinates the loading, saving, and management of user-defined bundles of * {@link Persistable} component/model state. * * - Models to be persisted are bound to this model via their `persistWith` config. One or more * models can be bound to a single ViewManagerModel, allowing a single view to capture the state * of multiple components - e.g. grouping and filtering options along with grid state. * - Views are persisted back to the server as JsonBlob objects. * - Views can be private to their owner, or optionally enabled for sharing to (all) other users. * - Views can be marked as pinned for quick access. * - See the desktop {@link ViewManager} component - the initial Hoist UI for this model. * * See the view manager package README (`cmp/viewmanager/README.md`) for architecture, * integration patterns, and access control configuration. */ export class ViewManagerModel extends HoistModel { override telemetryPrefix = 'xh.client.viewManager'; /** * Factory to create new instances of this model and await its initial load before binding to * any persistable component models. This ensures that bound models will have the expected * initial persisted state applied within their constructor, before their components have * rendered, and avoids thrashing of component state during initial load. * * To minimize the impact this async requirement has on the design and lifecycle of individual * components within an app, consider eagerly constructing any viewManagerModels required within * your `AppModel.initAsync` method and saving a reference to them there for component models * to then use when they are mounted. The VM model instances will then be "ready to go" and * usable within model constructors. (Initializing and referencing from one or more app * services would be another, similar option.) * * Note that this method may throw if the ViewManager cannot be initialized successfully, * but should generally fail quietly due to the early instantiation. */ static async createAsync( config: ViewManagerConfig, ctx?: CallContextLike ): Promise { const ret = new ViewManagerModel(config); await ret.initAsync(ctx); return ret; } /** Immutable configuration for this model. */ readonly type: string; readonly instance: string; readonly typeDisplayName: string; readonly defaultDisplayName: string; readonly globalDisplayName: string; readonly viewMenuItemFn: (view: ViewInfo, model: ViewManagerModel) => ReactNode; readonly enableAutoSave: boolean; readonly enableDefault: boolean; readonly enableGlobal: boolean; readonly enableSharing: boolean; readonly preserveUnsavedChanges: boolean; readonly manageGlobal: boolean; readonly initialViewSpec: (views: ViewInfo[]) => ViewInfo; /** Current view. Will not include uncommitted changes */ @observable.ref view: View = null; /** Loaded saved view library - both private and global */ @observable.ref views: ViewInfo[] = []; /** * Map of user's preferred pinned state for views. * * Note that the actual pinned state for the views is determined by this value, layered * over the default state of the views themselves. */ @observable.ref userPinned: Record = {}; /** * True if user has opted-in to automatically saving changes to personal views (if auto-save * generally available as per `enableAutoSave`). */ @bindable autoSave = false; /** * TaskObserver linked to {@link selectViewAsync}. If a change to the active view is likely to * require intensive layout/grid work, consider masking affected components with this task. */ selectTask: TaskObserver; /** TaskObserver linked to {@link saveAsync}. */ saveTask: TaskObserver; //----------------------- // Private, internal state. //------------------------- /** Unsaved changes on the current view.*/ @observable.ref private pendingValue: PendingValue = null; /** * Array of {@link ViewManagerProvider} instances bound to this model. Used to proactively push * state to the target components when the model's selected `value` changes. */ private providers: ViewManagerProvider[] = []; /** Data access for persisting views. */ private dataAccess: DataAccess; //--------------- // Getters //--------------- get isValueDirty(): boolean { return !!this.pendingValue; } get isViewSavable(): boolean { const {view, manageGlobal} = this; return view.isOwned || (view.isGlobal && manageGlobal); } get isViewAutoSavable(): boolean { const {enableAutoSave, autoSave, view} = this; return enableAutoSave && autoSave && view.isOwned && !XH.identityService.isImpersonating; } get autoSaveUnavailableReason(): string { const {view, isViewAutoSavable, typeDisplayName, globalDisplayName, defaultDisplayName} = this; if (isViewAutoSavable) return null; if (view.isGlobal) return `Cannot auto-save ${globalDisplayName} ${typeDisplayName}.`; if (view.isShared) return `Cannot auto-save shared ${typeDisplayName}.`; if (view.isDefault) return `Cannot auto-save ${defaultDisplayName} ${typeDisplayName}.`; if (XH.identityService.isImpersonating) return `Auto-save disabled during impersonation.`; return null; } get pinnedViews(): ViewInfo[] { return this.views.filter(it => it.isPinned); } /** Views owned by me */ get ownedViews(): ViewInfo[] { return this.views.filter(it => it.isOwned); } /** Views shared *with* me */ get sharedViews(): ViewInfo[] { return this.views.filter(it => it.isShared && !it.isOwned); } /** Global views */ get globalViews(): ViewInfo[] { return this.views.filter(it => it.isGlobal); } /** True if any async tasks are pending. */ get isLoading(): boolean { const {loadObserver, saveTask, selectTask} = this; return loadObserver.isPending || saveTask.isPending || selectTask.isPending; } /** * Use the static {@link createAsync} factory to create an instance of this model and await its * initial load before binding to persistable components. */ private constructor({ type, instance = 'default', typeDisplayName, defaultDisplayName = 'default', globalDisplayName = 'global', viewMenuItemFn, manageGlobal = false, enableAutoSave = true, enableDefault = true, enableGlobal = true, enableSharing = true, preserveUnsavedChanges = true, initialViewSpec = null }: ViewManagerConfig) { super(); makeObservable(this); throwIf( !enableDefault && !initialViewSpec, "ViewManagerModel requires 'initialViewSpec' if 'enableDefault' is false." ); this.type = type; this.instance = instance; this.typeDisplayName = lowerCase(typeDisplayName ?? genDisplayName(type)); this.defaultDisplayName = defaultDisplayName; this.globalDisplayName = globalDisplayName; this.viewMenuItemFn = viewMenuItemFn; this.manageGlobal = executeIfFunction(manageGlobal) ?? false; this.enableDefault = enableDefault; this.enableGlobal = enableGlobal; this.enableSharing = enableSharing; this.enableAutoSave = enableAutoSave; this.preserveUnsavedChanges = preserveUnsavedChanges; this.initialViewSpec = initialViewSpec; this.selectTask = TaskObserver.trackLast({ message: `Updating ${this.typeDisplayName}...` }); this.saveTask = TaskObserver.trackLast({ message: `Saving ${this.typeDisplayName}...` }); this.dataAccess = new DataAccess(this); } override async doLoadAsync(loadSpec: LoadSpec) { const {dataAccess, view} = this; await this.runner({loadSpec}) .span('refresh') .run(async ctx => { // 1) Update views and related state const {views, state} = await dataAccess.fetchDataAsync(ctx); if (loadSpec.isStale) return; runInAction(() => { this.views = views; this.userPinned = state.userPinned; this.autoSave = state.autoSave; }); // potentially fast-forward current view. if (!view.isDefault) { const latestInfo = find(views, {token: view.token}); if (latestInfo && latestInfo.lastUpdated > view.lastUpdated) { this.loadViewAsync(view.token, this.pendingValue, ctx); } } }) .catch(e => { if (loadSpec.isStale) return; this.handleException(e, {showAlert: false}); }); } async selectViewAsync( view: string | ViewInfo, opts = {alertUnsavedChanges: true} ): Promise { const token = isObject(view) ? view.token : view; // ensure any pending auto-save gets completed (spanned via its own root) if (this.isValueDirty && this.isViewAutoSavable) { await this.maybeAutoSaveAsync(); } // if still dirty, require confirm (kept outside the span - waits on user) if ( opts.alertUnsavedChanges && this.isValueDirty && this.view.isOwned && !(await this.confirmDiscardChangesAsync()) ) { return; } return this.runner() .span('selectView') .run(ctx => this.loadViewAsync(token, null, ctx)); } async saveAsAsync(spec: ViewCreateSpec): Promise { await this.runner() .span('saveAs') .run(async ctx => { const view = await this.dataAccess.createViewAsync(spec, ctx); this.noteSuccess(`Created ${view.typedName}`); this.setAsView(view); }); } //------------------------ // Saving/resetting //------------------------ async saveAsync(): Promise { if (!this.pendingValue || !this.isViewSavable || this.isLoading) { this.logError('Unexpected conditions for call to save, skipping'); return; } const {pendingValue, view, dataAccess} = this; if (!(await this.maybeConfirmSaveAsync(view, pendingValue))) { return; } await this.runner() .span('save') .run(async ctx => { const updated = await dataAccess .updateViewValueAsync(view, pendingValue.value, ctx) .linkTo(this.saveTask); this.setAsView(updated); this.noteSuccess(`Saved ${view.typedName}`); this.refreshAsync(); }); } async resetAsync(): Promise { return this.runner() .span('reset') .run(ctx => this.loadViewAsync(this.view.token, null, ctx)); } //-------------------------------- // Access for Provider/Components //-------------------------------- getValue(): Partial { return this.pendingValue ? this.pendingValue.value : this.view.value; } @action setValue(value: Partial) { const {view, pendingValue} = this; value = this.cleanState(value); if (!isEqual(value, view.value)) { this.pendingValue = { token: pendingValue ? pendingValue.token : view.token, baseUpdated: pendingValue ? pendingValue.baseUpdated : view.lastUpdated, value }; } else { this.pendingValue = null; } } //------------------ // Pinning //------------------ @action userPin(view: ViewInfo) { this.userPinned = {...this.userPinned, [view.token]: true}; } @action userUnpin(view: ViewInfo) { this.userPinned = {...this.userPinned, [view.token]: false}; } isUserPinned(view: ViewInfo): boolean | null { return this.userPinned[view.token]; } //----------------- // Management //----------------- /** * Validate a name for a view. * @param name - candidate name to validate * @param existing - existing view that will have the name. null if the name is for a new view. * @param isGlobal - true if the name is for a global view. */ async validateViewNameAsync( name: string, existing: ViewInfo, isGlobal: boolean ): Promise { const maxLength = 50; name = name?.trim(); if (!name) return 'Name is required'; if (name.length > maxLength) { return `Name cannot be longer than ${maxLength} characters`; } const views = isGlobal ? this.globalViews : this.ownedViews; if (views.some(view => view.name === name && view.token != existing?.token)) { return `A ${this.typeDisplayName} with name '${name}' already exists.`; } return null; } /** Update all aspects of a view's metadata.*/ async updateViewInfoAsync(view: ViewInfo, updates: ViewUpdateSpec): Promise> { return this.runner() .span('updateInfo') .run(ctx => this.dataAccess.updateViewInfoAsync(view, updates, ctx)); } async deleteViewsAsync(toDelete: ViewInfo[]): Promise { await this.runner() .span('delete') .run(async ctx => { let exception; try { await this.dataAccess.deleteViewsAsync(toDelete, ctx); } catch (e) { exception = e; } await this.refreshAsync(); const {views} = this; if ( toDelete.some(view => view.isCurrentView) && !views.some(view => view.isCurrentView) ) { await this.loadViewAsync(this.initialViewSpec?.(views)?.token, null, ctx); } if (exception) throw exception; }); } //------------------ // Internal //------------------ /** * Called by {@link ViewManagerProvider} to receive state changes from this model. * @internal */ registerProvider(provider: ViewManagerProvider) { this.providers.push(provider); } /** * Called by {@link ViewManagerProvider} to stop receiving state changes. * @internal */ unregisterProvider(provider: ViewManagerProvider) { remove(this.providers, provider); } //------------------ // Implementation //------------------ private async initAsync(ctx?: CallContextLike) { let {dataAccess, pendingValueStorageKey, enableDefault} = this, initialState: ViewUserState; await this.runner(ctx) .span('init') .run(async ctx => { // 1) Initialize views and related state const {views, state} = await dataAccess.fetchDataAsync(ctx); initialState = state; runInAction(() => { this.views = views; this.userPinned = state.userPinned; this.autoSave = state.autoSave; if (this.preserveUnsavedChanges) { this.pendingValue = XH.sessionStorageService.get(pendingValueStorageKey); } }); // 2) Select the initial view. let initialView: ViewInfo, initialTkn: string = initialState.currentView; if (isUndefined(initialTkn) || (isNull(initialTkn) && !enableDefault)) { // Token undefined (no prior view) or null (in-code default *had* been loaded) // but default no longer enabled - call initialViewSpec. initialView = this.initialViewSpec?.(views); } else if (!isNull(initialTkn)) { // Token provided - find the view, falling back to initialViewSpec if not found. initialView = find(views, {token: initialTkn}) ?? this.initialViewSpec?.(views); } else { // Token null - active signal to load in-code default. initialView = null; } // Note that the above routine failed to resolve a view, we will pass undefined here // and load the in-code default, even if not enabled. We have no other choice! await this.loadViewAsync(initialView?.token, this.pendingValue, ctx); }) .catch(e => { // Always ensure at least default view is installed (other state defaults are fine) this.runner() .span('fallbackLoad') .run(ctx => this.loadViewAsync(null, this.pendingValue, ctx)); this.handleException(e, {showAlert: false, logOnServer: true}); }); this.addReaction( this.preserveUnsavedChanges ? this.unsavedChangesReaction() : null, this.autoSaveReaction(), ...this.stateReactions(initialState) ); } private unsavedChangesReaction(): ReactionSpec { return { track: () => this.pendingValue, run: v => XH.sessionStorageService.set(this.pendingValueStorageKey, v) }; } private autoSaveReaction(): ReactionSpec { return { track: () => [this.pendingValue, this.autoSave], run: () => this.maybeAutoSaveAsync(), debounce: 2 * SECONDS }; } private stateReactions(initialState: ViewUserState): ReactionSpec[] { const updateState = (spanName: string, update: Partial) => this.runner() .span(spanName) .run(ctx => this.dataAccess.updateStateAsync(update, ctx)); return [ { track: () => this.userPinned, run: userPinned => updateState('updateUserPinned', {userPinned}), equals: comparer.structural, debounce: ONE_SECOND }, { track: () => this.autoSave, run: autoSave => updateState('updateAutoSave', {autoSave}) }, { track: () => this.view?.token, run: tkn => updateState('updateCurrentView', {currentView: tkn}), fireImmediately: this.view?.token !== initialState?.currentView } ]; } private async loadViewAsync( token: string, pendingValue: PendingValue = null, ctx: CallContext ): Promise { return this.dataAccess .fetchViewAsync(token, ctx) .thenAction(latest => { this.setAsView(latest, pendingValue?.token == token ? pendingValue : null); this.providers.forEach(it => it.pushStateToTarget()); }) .linkTo(this.selectTask); } private async maybeAutoSaveAsync() { const {pendingValue, isViewAutoSavable, view, dataAccess} = this; if (!isViewAutoSavable || !pendingValue) return; await this.runner() .span('autoSave') .run(async ctx => { try { const updated = await dataAccess .updateViewValueAsync(view, pendingValue.value, ctx) .linkTo(this.saveTask); this.setAsView(updated); } catch (e) { // TODO: How to alert but avoid for flaky or spam when user editing a deleted view // Keep count and alert server and user once at count n? XH.handleException(e, { message: `Failing AutoSave for ${view.info.typedName}`, showAlert: false, logOnServer: false }); } }); } @action private setAsView(view: View, pendingValue: PendingValue = null) { this.view = view; this.pendingValue = pendingValue; // Ensure we update meta-data as well. if (!view.isDefault) { this.views = uniqBy([view.info, ...this.views], 'token'); } // Ensure providers have a clean reference of the current view state. this.providers.forEach(it => it.read()); } private handleException(e, opts: ExceptionHandlerOptions = {}) { XH.handleException(e, opts); } private noteSuccess(msg: string) { XH.successToast(msg); } private get pendingValueStorageKey(): string { return `${this.type}_${this.instance}`; } /** * Stringify and parse to ensure that any value set here is valid, serializable JSON. */ private cleanState(state: Partial): Partial { if (isNil(state)) state = {}; return JSON.parse(JSON.stringify(state)); } private async confirmDiscardChangesAsync() { return XH.confirm({ message: `You have unsaved changes. Discard them and continue to switch ${pluralize(this.typeDisplayName)}?`, confirmProps: { text: 'Discard changes', intent: 'danger' }, cancelProps: { text: 'Cancel', autoFocus: true } }); } private async maybeConfirmSaveAsync(view: View, pendingValue: PendingValue) { // Get latest from server for reference (spanned independently of any user prompt below) const latest = await this.runner() .span('confirmSavePrep') .run(ctx => this.dataAccess.fetchViewAsync(view.token, ctx)), isGlobal = latest.isGlobal, isStale = latest.lastUpdated > pendingValue.baseUpdated; if (!isStale && !isGlobal) return true; const latestInfo = latest.info, {typeDisplayName, globalDisplayName} = this, msgs: ReactNode[] = [`Save ${view.typedName}?`]; if (isGlobal) { msgs.push( span( strong( `This is a ${globalDisplayName} ${typeDisplayName}. Changes will be visible to all users.` ) ) ); } if (isStale) { msgs.push( span( `This ${typeDisplayName} was updated by ${latestInfo.lastUpdatedBy} on ${fmtDateTime(latestInfo.lastUpdated)}. `, strong('Your change may override those changes.') ) ); } return XH.confirm({ message: fragment(msgs.map(m => p(m))), confirmProps: { text: isGlobal ? `Yes, update ${globalDisplayName} ${typeDisplayName}` : 'Yes, save changes', intent: 'primary', outlined: true, autoFocus: false }, cancelProps: { text: 'Cancel' } }); } } interface PendingValue { token: string; baseUpdated: number; value: Partial; }