Skip to content

Latest commit

 

History

History
477 lines (382 loc) · 16.9 KB

README.md

File metadata and controls

477 lines (382 loc) · 16.9 KB

ViewManager Package

Overview

The /cmp/viewmanager/ package provides a system for saving, loading, and managing named bundles of component state. This enables users to create, share, and switch between different configurations of complex UI components like grids, dashboards, or filter sets.

Key features:

  • Save and restore component state as named "views"
  • Support for private, shared, and global views
  • Auto-save capability for owned views
  • Pinned views for quick access
  • Persistence via JsonBlob backend storage

The cross-platform ViewManagerModel lives here in /cmp/ with the intent of supporting both desktop and mobile UI components. Currently, only the desktop implementation exists (/desktop/cmp/viewmanager/), which renders the controls for selecting, saving, and managing views.

Architecture

ViewManagerModel
├── type: string                  # Discriminator for view type (e.g., 'portfolioGridView')
├── view: View                    # Currently active view
├── views: ViewInfo[]             # Available saved views
├── autoSave: boolean             # User's auto-save preference
├── userPinned: Record            # User's pin preferences
└── Methods:
    ├── selectViewAsync()         # Switch to a view
    ├── saveAsync()               # Save pending changes
    ├── saveAsAsync()             # Create new view
    ├── resetAsync()              # Discard pending changes
    └── deleteViewsAsync()        # Remove views

View
├── info: ViewInfo               # Metadata (null for default)
├── value: Partial<T>            # Persisted component state
├── name, group, description     # Display properties
├── isDefault, isGlobal, isShared, isOwned
└── token: string                # Unique identifier

ViewInfo
├── token, type, name            # Identity
├── owner, isShared, isGlobal    # Ownership/visibility
├── dateCreated, lastUpdated     # Timestamps
└── isPinned, isUserPinned       # Quick-access state

ViewManagerModel

The central model for managing views.

Creating a ViewManagerModel

ViewManagerModels are created via an async factory (createAsync) that loads available views from the server before returning. Create all VMMs your app needs within AppModel.initAsync() - this ensures they have loaded their available state as early as possible, so the first components and models that are constructed can be initialized correctly with the persisted state they need.

Hold VMMs outside AppModel: Keep the created models in a dedicated module rather than as properties of AppModel itself. Consumers can then import them directly. Reaching a VMM through AppModel requires the consumer to import AppModel - which commonly imports, directly or transitively, the very components that consume it. Where it does, the result is a circular import, and one that surfaces as a runtime failure rather than a build error.

// viewManagers.ts
import {ViewManagerModel} from '@xh/hoist/cmp/viewmanager';
import type {InitContext} from '@xh/hoist/core';

class ViewManagers {
    portfolioGridView: ViewManagerModel;

    async initAsync(ctx: InitContext) {
        this.portfolioGridView = await ViewManagerModel.createAsync(
            {
                type: 'portfolioGridView',
                typeDisplayName: 'view',
                enableGlobal: true,
                enableSharing: true
            },
            ctx
        );
    }
}

export const viewManagers = new ViewManagers();
// AppModel.ts
class AppModel extends HoistAppModel {
    override async initAsync(ctx: InitContext) {
        await viewManagers.initAsync(ctx);
    }
}

AppModel still drives and awaits creation, so the ordering guarantee above is unchanged - it simply no longer owns the references.

Use specific property names for the created models, generally matching the type - avoid overly generic names like viewManager. Apps commonly have multiple VMMs for different parts of the UI, and clear naming keeps them distinct.

Binding the viewManager() component: Because the models are no longer on a model in the component hierarchy, viewManager() cannot resolve them via its default context lookup. Pass the model explicitly: viewManager({model: viewManagers.portfolioGridView}).

Configuration

Property Type Description
type string Unique discriminator for this class of views (required)
typeDisplayName string User-facing name (e.g., "view", "dashboard"). Defaults to type.
instance string For multiple managers of same type in one app
enableDefault boolean Allow selecting in-code default state
enableAutoSave boolean Allow auto-save for owned views
enableGlobal boolean Enable global (admin-managed) views
enableSharing boolean Allow sharing personal views
manageGlobal boolean false to hide global view management even if the server allows it. Defaults to the server's answer - see Global views.
preserveUnsavedChanges boolean Preserve pending changes across refresh
defaultDisplayName string Label for in-code default option
globalDisplayName string Label for global views
initialViewSpec (views) => ViewInfo Function to select initial view

Binding Components

Components connect to ViewManagerModel via their persistWith config:

class PortfolioGridModel extends HoistModel {
    @managed gridModel: GridModel;

    constructor() {
        super();
        this.gridModel = new GridModel({
            columns: [...],
            persistWith: {
                viewManagerModel: viewManagers.portfolioGridView,
                path: 'gridState'
            }
        });
    }
}

Multiple components can bind to the same ViewManagerModel. Hoist components that support persistWith use a preconfigured default path, so you only need to set path explicitly when persisting multiple instances of the same type of model within a single VMM:

const vmModel = viewManagers.portfolioGridView;

// Different model types use their own default paths ('grid', 'filterChooser', etc.)
this.gridModel = new GridModel({
    persistWith: {viewManagerModel: vmModel}           // path defaults to 'grid'
});
this.filterChooserModel = new FilterChooserModel({
    persistWith: {viewManagerModel: vmModel}           // path defaults to 'filterChooser'
});

// Two grids in the same view - need explicit paths to distinguish
this.portfolioGridModel = new GridModel({
    persistWith: {viewManagerModel: vmModel, path: 'portfolioGrid'}
});
this.ordersGridModel = new GridModel({
    persistWith: {viewManagerModel: vmModel, path: 'ordersGrid'}
});

Working with Views

Most applications interact with ViewManagerModel indirectly - via persistWith bindings and the built-in ViewManager UI component. The API below is available for building custom extensions or programmatic view management, but is not commonly needed.

const vmModel = viewManagers.portfolioGridView;

// Get current view
vmModel.view;           // View instance
vmModel.view.name;      // "My Portfolio View"
vmModel.view.isOwned;   // true

// Check for pending changes
vmModel.isValueDirty;   // Has unsaved changes?
vmModel.isViewSavable;  // Can current view be saved?

// Switch views
await vmModel.selectViewAsync('tokenXYZ');
await vmModel.selectViewAsync(viewInfo);

// Save current changes
await vmModel.saveAsync();

// Create new view
await vmModel.saveAsAsync({
    name: 'My New View',
    description: 'Custom grid layout',
    group: 'Reports',
    isShared: false,
    isGlobal: false,
    value: vmModel.getValue()
});

// Discard pending changes
await vmModel.resetAsync();

// Delete views
await vmModel.deleteViewsAsync([viewInfo1, viewInfo2]);

Sharing and Visibility

Views support three ownership/visibility levels. Any or all of the sharing modes can be disabled via configuration.

Private views (isOwned: true, isShared: false) - Owned by a user, visible only to them. This is the default when a user creates a new view.

Shared views (isOwned: true, isShared: true) - Owned by a user who has opted to make them discoverable by other users. Other users can find and select shared views via the management dialog, but shared views do not appear on other users' menus by default - users must explicitly pin them for quick access. Only the owner can edit a shared view. Requires enableSharing: true.

Global views (isGlobal: true, owner: null) - Not owned by any user and visible to all. Global views are typically curated by administrators and represent canonical/standard configurations. They appear pinned on all users' menus by default (users can unpin them). Requires enableGlobal: true.

The server decides who can create or edit global views. Hoist Core v42+ enforces this in JsonBlobService, using roles from the xhJsonBlobConfig.globalWriteRoles soft config. The config is keyed by view type, with * as the fallback, and defaults to HOIST_ADMIN only:

{
  "globalWriteRoles": {
    "portfolioGridView": ["MANAGE_GRID_VIEWS"],
    "*": ["HOIST_ADMIN"]
  }
}

The server returns its answer for the current user with the model's views, and ViewManagerModel.manageGlobal reflects it - apps don't need to repeat the role on the client. The manageGlobal config can only restrict: set it to false to hide global view management on a particular model. That's mainly useful when several models share a type, since the server applies one role list per type. With Hoist Core before v42, the server sends no answer and manageGlobal falls back to the config value, defaulting to false.

See the hoist-core JsonBlob documentation for the full server-side access rules.

const vmModel = await ViewManagerModel.createAsync({
    type: 'portfolioGridView',
    enableGlobal: true,
    enableSharing: true,
    globalDisplayName: 'Acme Corp'  // Customizable label (default "global")
});

// Access views by category
vmModel.ownedViews;     // Views owned by current user
vmModel.sharedViews;    // Views shared *with* current user (not owned by them)
vmModel.globalViews;    // Global views
vmModel.pinnedViews;    // All views in user's pinned list
vmModel.views;          // All available views

Pinning

Pinning controls which views appear directly in the ViewManager menu for quick access. Views that are not pinned can still be selected via the management dialog.

Default pinning behavior depends on the view type:

  • Global views - pinned by default for all users
  • Owned views - pinned by default for the owner
  • Shared views (from other users) - not pinned by default, must be explicitly pinned

Users can override the default pinning for any view:

// Pin/unpin a view
vmModel.userPin(viewInfo);
vmModel.userUnpin(viewInfo);

// Check pin state
viewInfo.isPinned;       // Effective pinned state (user preference or default)
viewInfo.isUserPinned;   // User's explicit preference, or null if using default

Auto-Save

When enabled, changes to owned views are automatically saved after a debounce period:

// User preference for auto-save (opt-in per user)
vmModel.autoSave;           // boolean
vmModel.autoSave = true;

// Check if current view can be auto-saved
vmModel.isViewAutoSavable;  // Owned + autoSave enabled + not impersonating
vmModel.autoSaveUnavailableReason;  // Explanation if not available

Auto-save only applies to the user's own views - global and shared views from other users cannot be auto-saved.

TaskObservers

For masking UI during view operations:

// Link to mask during view switches
mask({bind: vmModel.selectTask})

// Link to mask during saves
mask({bind: vmModel.saveTask})

// Check any loading state
vmModel.isLoading;

View

A named bundle of persisted state.

View Properties

Property Type Description
name string Display name
description string Optional description
group string Optional grouping category
token string Unique identifier (null for default)
value Partial<T> Persisted component state
isDefault boolean Is this the in-code default?
isOwned boolean Owned by current user?
isShared boolean Shared with other users?
isGlobal boolean Global (admin-managed)?
lastUpdated number Last update timestamp

Default View

The "default" view is a special view that represents the in-code initial state of all bound components. Unlike saved views, it is not persisted as data anywhere - it is derived from the code-defined defaults of the bound models (e.g., a GridModel's initial column configuration). It has no token and no info.

The default view is enabled via enableDefault: true (the default) and appears as an option in the ViewManager menu, labeled with defaultDisplayName (default "Default") + typeDisplayName.

vmModel.view.isDefault;  // true when on the in-code default
vmModel.view.token;      // null for the default view

Disabling the default view (enableDefault: false) is useful when you want all users to always work from a saved view definition. This means the default view configuration can be updated by saving a new version - no software release is required. When the default view is disabled, you must provide an initialViewSpec function, and at least one global view should exist to provide an initial selection for new users:

const vmModel = await ViewManagerModel.createAsync({
    type: 'portfolioGridView',
    enableDefault: false,
    initialViewSpec: views => {
        return views.find(v => v.isGlobal && v.name === 'Standard') ??
               views.find(v => v.isGlobal) ??
               views[0];
    }
});

ViewInfo

Metadata about a saved view (excluding the actual value).

ViewInfo Properties

Property Type Description
token string Unique identifier
type string View type discriminator
name string Display name
description string Optional description
owner string Username of owner (null for global)
group string Optional grouping category
isGlobal boolean Global (no owner)?
isShared boolean Shared by owner?
isOwned boolean Owned by current user?
isEditable boolean Can current user edit?
isPinned boolean In user's pinned list?
dateCreated number Creation timestamp
lastUpdated number Last update timestamp
lastUpdatedBy string Username of last updater

Common Patterns

Create Multiple View Managers

// viewManagers.ts
class ViewManagers {
    portfolioGridView: ViewManagerModel;
    portfolioDashboard: ViewManagerModel;

    async initAsync(ctx: InitContext) {
        [this.portfolioGridView, this.portfolioDashboard] = await Promise.all([
            ViewManagerModel.createAsync(
                {type: 'portfolioGridView'},
                ctx
            ),
            ViewManagerModel.createAsync(
                {
                    type: 'portfolioDashboard',
                    typeDisplayName: 'dashboard',
                    enableSharing: false
                },
                ctx
            )
        ]);
    }
}

export const viewManagers = new ViewManagers();

Bind Grid State

class PortfolioGridModel extends HoistModel {
    @managed gridModel: GridModel;

    constructor() {
        super();
        this.gridModel = new GridModel({
            columns: [...],
            sortBy: 'symbol',
            persistWith: {
                viewManagerModel: viewManagers.portfolioGridView,
                path: 'grid',
                persistColumns: true,
                persistSort: true,
                persistGrouping: true
            }
        });
    }
}

Note that columns added to the code after a view was saved are hidden by default when persisting to a ViewManager, so that a software release does not add columns to views users have curated. They remain available via the column chooser, and the in-code default view continues to show them. See Newly Added Columns to adjust this behavior via hideNewColumns.

React to View Changes

class MyModelClass extends HoistModel {
    @computed
    get showGlobalViewBanner(): boolean {
        return this.portfolioGridViewManager.view?.isGlobal;
    }
}

Related Packages

  • /core/ - PersistenceProvider, ViewManagerProvider interfaces
  • /svc/ - JsonBlobService for backend storage of views
  • /desktop/cmp/viewmanager/ - Desktop ViewManager UI component