From 4f774d3753716edf2d0bb357ba11aede81772b0d Mon Sep 17 00:00:00 2001 From: Greg Solomon Date: Wed, 18 Dec 2024 09:02:36 -0500 Subject: [PATCH 1/8] Add test for ability to disable global views --- CHANGELOG.md | 1 + cmp/viewmanager/ViewManagerModel.ts | 21 +++- .../cmp/viewmanager/dialog/ManageDialog.ts | 10 +- .../viewmanager/dialog/ManageDialogModel.ts | 38 +++--- desktop/cmp/viewmanager/dialog/ViewPanel.ts | 110 +++++++++--------- .../cmp/viewmanager/dialog/ViewPanelModel.ts | 2 +- 6 files changed, 94 insertions(+), 88 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aedc310ce..2565f6e6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ * Handle delete and update collisions more gracefully. * Support for `settleTime`, * Improved management UI Dialog. + * Support for "global" views. * New `SessionStorageService` and associated persistence provider provides support for saving tab local data across reloads. diff --git a/cmp/viewmanager/ViewManagerModel.ts b/cmp/viewmanager/ViewManagerModel.ts index 849662044..264d2f69d 100644 --- a/cmp/viewmanager/ViewManagerModel.ts +++ b/cmp/viewmanager/ViewManagerModel.ts @@ -21,7 +21,7 @@ import { import type {ViewManagerProvider} from '@xh/hoist/core'; import {genDisplayName} from '@xh/hoist/data'; import {fmtDateTime} from '@xh/hoist/format'; -import {action, bindable, makeObservable, observable, runInAction, when} from '@xh/hoist/mobx'; +import {action, bindable, makeObservable, observable, when} from '@xh/hoist/mobx'; import {olderThan, SECONDS} from '@xh/hoist/utils/datetime'; import {executeIfFunction, pluralize, throwIf} from '@xh/hoist/utils/js'; import {find, isEmpty, isEqual, isNil, isObject, lowerCase, pickBy} from 'lodash'; @@ -43,6 +43,11 @@ export interface ViewManagerConfig { */ enableDefault?: boolean; + /** + * True (default) to enable "global" views - i.e. views that are not owned by a user and are available to all. + */ + enableGlobal?: boolean; + /** * True (default) to allow users to share their views with other users. */ @@ -145,6 +150,7 @@ export class ViewManagerModel extends HoistModel { readonly globalDisplayName: string; readonly enableAutoSave: boolean; readonly enableDefault: boolean; + readonly enableGlobal: boolean; readonly enableSharing: boolean; readonly manageGlobal: boolean; readonly settleTime: number; @@ -272,6 +278,7 @@ export class ViewManagerModel extends HoistModel { manageGlobal = false, enableAutoSave = true, enableDefault = true, + enableGlobal = true, enableSharing = true, settleTime = 1000, initialViewSpec = null @@ -290,6 +297,7 @@ export class ViewManagerModel extends HoistModel { this.persistWith = persistWith; this.manageGlobal = executeIfFunction(manageGlobal) ?? false; this.enableDefault = enableDefault; + this.enableGlobal = enableGlobal; this.enableSharing = enableSharing; this.enableAutoSave = enableAutoSave; this.settleTime = settleTime; @@ -310,7 +318,7 @@ export class ViewManagerModel extends HoistModel { // 1) Update all view info const views = await this.api.fetchViewInfosAsync(); if (loadSpec.isStale) return; - runInAction(() => (this.views = views)); + this.setViews(views); // 2) Update active view if needed. const {view} = this; @@ -444,7 +452,7 @@ export class ViewManagerModel extends HoistModel { private async initAsync() { try { const views = await this.api.fetchViewInfosAsync(); - runInAction(() => (this.views = views)); + this.setViews(views); if (this.persistWith) { this.initPersist(this.persistWith); @@ -469,6 +477,11 @@ export class ViewManagerModel extends HoistModel { }); } + @action + private setViews(views: ViewInfo[]) { + this.views = this.enableGlobal ? views : views.filter(view => !view.isGlobal); + } + private async loadViewAsync( info: ViewInfo, pendingValue: PendingValue = null @@ -510,7 +523,7 @@ export class ViewManagerModel extends HoistModel { this.pendingValue = pendingValue; // Ensure we update meta-data as well. if (!view.isDefault) { - this.views = this.views.map(v => (v.token === view.token ? view.info : v)); + this.setViews(this.views.map(v => (v.token === view.token ? view.info : v))); } } diff --git a/desktop/cmp/viewmanager/dialog/ManageDialog.ts b/desktop/cmp/viewmanager/dialog/ManageDialog.ts index df2bf4539..f20b66177 100644 --- a/desktop/cmp/viewmanager/dialog/ManageDialog.ts +++ b/desktop/cmp/viewmanager/dialog/ManageDialog.ts @@ -9,7 +9,7 @@ import {grid, GridModel} from '@xh/hoist/cmp/grid'; import {div, filler, hframe, placeholder, vframe} from '@xh/hoist/cmp/layout'; import {storeFilterField} from '@xh/hoist/cmp/store'; import {tabContainer} from '@xh/hoist/cmp/tab'; -import {hoistCmp, uses} from '@xh/hoist/core'; +import {hoistCmp, uses, UsesSpec} from '@xh/hoist/core'; import {button, refreshButton} from '@xh/hoist/desktop/cmp/button'; import {panel} from '@xh/hoist/desktop/cmp/panel'; import {toolbar, toolbarSep} from '@xh/hoist/desktop/cmp/toolbar'; @@ -27,16 +27,16 @@ import {viewPanel} from './ViewPanel'; export const manageDialog = hoistCmp.factory({ displayName: 'ManageDialog', className: 'xh-view-manager__manage-dialog', - model: uses(() => ManageDialogModel), + model: uses(() => ManageDialogModel) as UsesSpec, render({model, className}) { if (!model.isOpen) return null; - const {typeDisplayName, updateTask, loadTask, selectedViews} = model, + const {updateTask, loadTask, selectedViews, viewManagerModel} = model, count = selectedViews.length; return dialog({ - title: `Manage ${capitalize(pluralize(typeDisplayName))}`, + title: `Manage ${capitalize(pluralize(viewManagerModel.typeDisplayName))}`, icon: Icon.gear(), className, isOpen: true, @@ -103,7 +103,7 @@ export const viewsGrid = hoistCmp.factory({ const placeholderPanel = hoistCmp.factory({ render({model}) { - return placeholder(Icon.gears(), `Select a ${model.typeDisplayName}`); + return placeholder(Icon.gears(), `Select a ${model.viewManagerModel.typeDisplayName}`); } }); diff --git a/desktop/cmp/viewmanager/dialog/ManageDialogModel.ts b/desktop/cmp/viewmanager/dialog/ManageDialogModel.ts index 742676ff5..875b6c75a 100644 --- a/desktop/cmp/viewmanager/dialog/ManageDialogModel.ts +++ b/desktop/cmp/viewmanager/dialog/ManageDialogModel.ts @@ -67,22 +67,6 @@ export class ManageDialogModel extends HoistModel { return this.gridModel.selectedRecords.map(it => it.data.view) as ViewInfo[]; } - get manageGlobal(): boolean { - return this.viewManagerModel.manageGlobal; - } - - get typeDisplayName(): string { - return this.viewManagerModel.typeDisplayName; - } - - get globalDisplayName(): string { - return this.viewManagerModel.globalDisplayName; - } - - get enableSharing(): boolean { - return this.viewManagerModel.enableSharing; - } - constructor(viewManagerModel: ViewManagerModel) { super(); makeObservable(this); @@ -176,7 +160,8 @@ export class ManageDialogModel extends HoistModel { } private async doDeleteAsync(views: ViewInfo[]) { - const {viewManagerModel, typeDisplayName} = this, + const {viewManagerModel} = this, + {typeDisplayName} = viewManagerModel, count = views.length; if (!count) return; @@ -250,7 +235,7 @@ export class ManageDialogModel extends HoistModel { } private createGridModel(type: 'owned' | 'global' | 'shared'): GridModel { - const {typeDisplayName, globalDisplayName} = this; + const {typeDisplayName, globalDisplayName} = this.viewManagerModel; const modifier = type == 'owned' ? `personal` : type == 'global' ? globalDisplayName : 'shared'; @@ -320,10 +305,11 @@ export class ManageDialogModel extends HoistModel { } private createTabContainerModel(): TabContainerModel { - const view = this.typeDisplayName, + const {globalDisplayName, typeDisplayName} = this.viewManagerModel, + view = typeDisplayName, views = pluralize(view), - globalViews = `${this.globalDisplayName} ${views}`, - {enableSharing} = this.viewManagerModel; + globalViews = `${globalDisplayName} ${views}`, + {enableGlobal, enableSharing} = this.viewManagerModel; return new TabContainerModel({ tabs: [ @@ -350,7 +336,8 @@ export class ManageDialogModel extends HoistModel { Icon.globe(), `This tab shows ${globalViews} available to everyone. ${capitalize(globalViews)} can be pinned by default so they appear automatically in everyone's menu, but you can choose which ${views} you would like to see by pinning/unpinning them at any time.` ) - }) + }), + omit: !enableGlobal }, { id: 'shared', @@ -369,21 +356,22 @@ export class ManageDialogModel extends HoistModel { private get ownedTabTitle(): ReactNode { return hbox( - `My ${startCase(pluralize(this.typeDisplayName))}`, + `My ${startCase(pluralize(this.viewManagerModel.typeDisplayName))}`, badge(this.ownedGridModel.store.allCount) ); } private get globalTabTitle(): ReactNode { + const {globalDisplayName, typeDisplayName} = this.viewManagerModel; return hbox( - `${startCase(this.globalDisplayName)} ${startCase(pluralize(this.typeDisplayName))}`, + `${startCase(globalDisplayName)} ${startCase(pluralize(typeDisplayName))}`, badge(this.globalGridModel.store.allCount) ); } private get sharedTabTitle(): ReactNode { return hbox( - `Shared ${startCase(pluralize(this.typeDisplayName))}`, + `Shared ${startCase(pluralize(this.viewManagerModel.typeDisplayName))}`, badge(this.sharedGridModel.store.allCount) ); } diff --git a/desktop/cmp/viewmanager/dialog/ViewPanel.ts b/desktop/cmp/viewmanager/dialog/ViewPanel.ts index f069a3a2a..37789ab69 100644 --- a/desktop/cmp/viewmanager/dialog/ViewPanel.ts +++ b/desktop/cmp/viewmanager/dialog/ViewPanel.ts @@ -104,58 +104,62 @@ const formButtons = hoistCmp.factory({ {readonly} = formModel, {isPinned} = view; - return formModel.isDirty - ? hbox({ - justifyContent: 'center', - items: [ - button({ - text: 'Save Changes', - icon: Icon.check(), - intent: 'success', - minimal: false, - disabled: !formModel.isValid, - onClick: () => model.saveAsync() - }), - hspacer(), - button({ - icon: Icon.reset(), - tooltip: 'Revert changes', - minimal: false, - onClick: () => formModel.reset() - }) - ] - }) - : vbox({ - style: {gap: 10, alignItems: 'center'}, - items: [ - button({ - text: isPinned ? 'Unpin from your Menu' : 'Pin to your Menu', - icon: Icon.pin({ - prefix: isPinned ? 'fas' : 'far', - className: isPinned ? 'xh-yellow' : null - }), - width: 200, - outlined: true, - onClick: () => parent.togglePinned([view]) - }), - button({ - text: `Promote to ${capitalize(parent.globalDisplayName)} ${parent.typeDisplayName}`, - icon: Icon.globe(), - width: 200, - outlined: true, - omit: readonly || view.isGlobal || !parent.manageGlobal, - onClick: () => parent.makeGlobalAsync(view) - }), - button({ - text: 'Delete', - icon: Icon.delete(), - width: 200, - outlined: true, - intent: 'danger', - omit: readonly, - onClick: () => parent.deleteAsync([view]) - }) - ] - }); + if (formModel.isDirty) { + return hbox({ + justifyContent: 'center', + items: [ + button({ + text: 'Save Changes', + icon: Icon.check(), + intent: 'success', + minimal: false, + disabled: !formModel.isValid, + onClick: () => model.saveAsync() + }), + hspacer(), + button({ + icon: Icon.reset(), + tooltip: 'Revert changes', + minimal: false, + onClick: () => formModel.reset() + }) + ] + }); + } + + const {enableGlobal, globalDisplayName, manageGlobal, typeDisplayName} = + parent.viewManagerModel; + return vbox({ + style: {gap: 10, alignItems: 'center'}, + items: [ + button({ + text: isPinned ? 'Unpin from your Menu' : 'Pin to your Menu', + icon: Icon.pin({ + prefix: isPinned ? 'fas' : 'far', + className: isPinned ? 'xh-yellow' : null + }), + width: 200, + outlined: true, + onClick: () => parent.togglePinned([view]) + }), + button({ + text: `Promote to ${capitalize(globalDisplayName)} ${typeDisplayName}`, + icon: Icon.globe(), + width: 200, + outlined: true, + omit: readonly || view.isGlobal || !enableGlobal || !manageGlobal, + onClick: () => parent.makeGlobalAsync(view) + }), + button({ + text: 'Delete', + icon: Icon.delete(), + width: 200, + outlined: true, + intent: 'danger', + omit: readonly, + onClick: () => parent.deleteAsync([view]) + }) + ] + }); } }); diff --git a/desktop/cmp/viewmanager/dialog/ViewPanelModel.ts b/desktop/cmp/viewmanager/dialog/ViewPanelModel.ts index aabfbfedd..5b04d567c 100644 --- a/desktop/cmp/viewmanager/dialog/ViewPanelModel.ts +++ b/desktop/cmp/viewmanager/dialog/ViewPanelModel.ts @@ -44,7 +44,7 @@ export class ViewPanelModel extends HoistModel { const {formModel} = this; formModel.init({ ...view, - owner: view.owner ?? capitalize(parent.globalDisplayName) + owner: view.owner ?? capitalize(parent.viewManagerModel.globalDisplayName) }); formModel.readonly = !view.isEditable; } From 23dc52c18f233dc0a1bb00149b1646d041fb41ab Mon Sep 17 00:00:00 2001 From: Greg Solomon Date: Wed, 18 Dec 2024 10:35:48 -0500 Subject: [PATCH 2/8] Auto-select default view when. user deletes active view --- cmp/viewmanager/ViewManagerModel.ts | 28 +++++++++++++++++-- core/exception/ExceptionHandler.ts | 3 +- .../viewmanager/dialog/ManageDialogModel.ts | 6 +--- .../cmp/viewmanager/dialog/ViewMultiPanel.ts | 6 ++-- 4 files changed, 32 insertions(+), 11 deletions(-) diff --git a/cmp/viewmanager/ViewManagerModel.ts b/cmp/viewmanager/ViewManagerModel.ts index 264d2f69d..07694b1e7 100644 --- a/cmp/viewmanager/ViewManagerModel.ts +++ b/cmp/viewmanager/ViewManagerModel.ts @@ -5,7 +5,7 @@ * Copyright © 2024 Extremely Heavy Industries Inc. */ -import {fragment, strong, p, span} from '@xh/hoist/cmp/layout'; +import {fragment, strong, p, span, div, ul, li} from '@xh/hoist/cmp/layout'; import { ExceptionHandlerOptions, HoistModel, @@ -24,7 +24,7 @@ import {fmtDateTime} from '@xh/hoist/format'; import {action, bindable, makeObservable, observable, when} from '@xh/hoist/mobx'; import {olderThan, SECONDS} from '@xh/hoist/utils/datetime'; import {executeIfFunction, pluralize, throwIf} from '@xh/hoist/utils/js'; -import {find, isEmpty, isEqual, isNil, isObject, lowerCase, pickBy} from 'lodash'; +import {find, isEmpty, isEqual, isNil, isObject, lowerCase, partition, pickBy} from 'lodash'; import {ReactNode} from 'react'; import {ViewInfo} from './ViewInfo'; import {View} from './View'; @@ -446,6 +446,30 @@ export class ViewManagerModel extends HoistModel { return null; } + async deleteViewsAsync(views: ViewInfo[]): Promise { + const results = await Promise.allSettled(views.map(v => this.api.deleteViewAsync(v))), + outcome = results.map((result, idx) => ({result, view: views[idx]})), + [succeeded, failed] = partition(outcome, ({result}) => result.status === 'fulfilled'); + + if (!isEmpty(failed)) { + XH.handleException( + {errors: failed.map(({result}) => result.status === 'rejected' && result.reason)}, + { + message: div( + `Failed to delete ${pluralize(this.typeDisplayName, failed.length, true)}:`, + ul(failed.map(({view}) => li(view.name))) + ) + } + ); + } + + await this.refreshAsync(); + + if (succeeded.some(({view}) => view.token === this.view?.token)) { + await this.loadViewAsync(this.initialViewSpec?.(this.views)); + } + } + //------------------ // Implementation //------------------ diff --git a/core/exception/ExceptionHandler.ts b/core/exception/ExceptionHandler.ts index c51d32d82..1d838851a 100644 --- a/core/exception/ExceptionHandler.ts +++ b/core/exception/ExceptionHandler.ts @@ -4,6 +4,7 @@ * * Copyright © 2024 Extremely Heavy Industries Inc. */ +import {ReactNode} from 'react'; import {Exception} from './Exception'; import {fragment, span} from '@xh/hoist/cmp/layout'; import {logDebug, logError, logWarn, stripTags} from '@xh/hoist/utils/js'; @@ -13,7 +14,7 @@ import {HoistException, PlainObject, XH} from '../'; export interface ExceptionHandlerOptions { /** Text (ideally user-friendly) describing the error. */ - message?: string; + message?: ReactNode; /** Title for an alert dialog, if shown. */ title?: string; diff --git a/desktop/cmp/viewmanager/dialog/ManageDialogModel.ts b/desktop/cmp/viewmanager/dialog/ManageDialogModel.ts index 875b6c75a..1b4015f66 100644 --- a/desktop/cmp/viewmanager/dialog/ManageDialogModel.ts +++ b/desktop/cmp/viewmanager/dialog/ManageDialogModel.ts @@ -193,11 +193,7 @@ export class ManageDialogModel extends HoistModel { }); if (!confirmed) return; - for (const view of views) { - await viewManagerModel.api.deleteViewAsync(view); - } - - await viewManagerModel.refreshAsync(); + await viewManagerModel.deleteViewsAsync(views); await this.refreshAsync(); } diff --git a/desktop/cmp/viewmanager/dialog/ViewMultiPanel.ts b/desktop/cmp/viewmanager/dialog/ViewMultiPanel.ts index cbca28dea..e718c0ba5 100644 --- a/desktop/cmp/viewmanager/dialog/ViewMultiPanel.ts +++ b/desktop/cmp/viewmanager/dialog/ViewMultiPanel.ts @@ -6,7 +6,7 @@ */ import {placeholder, vbox, vframe, vspacer} from '@xh/hoist/cmp/layout'; -import {hoistCmp, uses} from '@xh/hoist/core'; +import {hoistCmp, uses, UsesSpec} from '@xh/hoist/core'; import {button} from '@xh/hoist/desktop/cmp/button'; import {panel} from '@xh/hoist/desktop/cmp/panel'; import {Icon} from '@xh/hoist/icon'; @@ -15,7 +15,7 @@ import {every, isEmpty, some} from 'lodash'; import {ManageDialogModel} from './ManageDialogModel'; export const viewMultiPanel = hoistCmp.factory({ - model: uses(() => ManageDialogModel), + model: uses(() => ManageDialogModel) as UsesSpec, render({model}) { const views = model.selectedViews; if (isEmpty(views)) return null; @@ -25,7 +25,7 @@ export const viewMultiPanel = hoistCmp.factory({ className: 'xh-view-manager__manage-dialog__form', item: placeholder( Icon.gears(), - `${views.length} selected ${pluralize(model.typeDisplayName)}`, + `${views.length} selected ${pluralize(model.viewManagerModel.typeDisplayName)}`, vspacer(), buttons() ) From 6edaac066602f63ccd63464b3e9df0549e7acce2 Mon Sep 17 00:00:00 2001 From: Greg Solomon Date: Wed, 18 Dec 2024 10:43:00 -0500 Subject: [PATCH 3/8] Disable auto-save menu option when global view is selected --- cmp/viewmanager/ViewManagerModel.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/cmp/viewmanager/ViewManagerModel.ts b/cmp/viewmanager/ViewManagerModel.ts index 07694b1e7..9118b186c 100644 --- a/cmp/viewmanager/ViewManagerModel.ts +++ b/cmp/viewmanager/ViewManagerModel.ts @@ -225,6 +225,7 @@ export class ViewManagerModel extends HoistModel { return ( enableAutoSave && autoSave && + !view.isGlobal && !view.isShared && !view.isDefault && !XH.identityService.isImpersonating From 55483a8ab3b726a8176378faf4b3227895302341 Mon Sep 17 00:00:00 2001 From: Greg Solomon Date: Wed, 18 Dec 2024 10:59:16 -0500 Subject: [PATCH 4/8] Ensure menu is closed when popover menu button is no longer visible --- desktop/cmp/viewmanager/ViewManager.ts | 5 ++++- desktop/cmp/viewmanager/ViewManagerLocalModel.ts | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/desktop/cmp/viewmanager/ViewManager.ts b/desktop/cmp/viewmanager/ViewManager.ts index 4a27c4455..58c8cd466 100644 --- a/desktop/cmp/viewmanager/ViewManager.ts +++ b/desktop/cmp/viewmanager/ViewManager.ts @@ -12,6 +12,7 @@ import {ViewManagerModel} from '@xh/hoist/cmp/viewmanager'; import {button, ButtonProps} from '@xh/hoist/desktop/cmp/button'; import {Icon} from '@xh/hoist/icon'; import {popover} from '@xh/hoist/kit/blueprint'; +import {useOnVisibleChange} from '@xh/hoist/utils/react'; import {startCase} from 'lodash'; import {viewMenu} from './ViewMenu'; import {ViewManagerLocalModel} from './ViewManagerLocalModel'; @@ -67,6 +68,7 @@ export const [ViewManager, viewManager] = hoistCmp.withFactory save = saveButton({model: locModel, mode: showSaveButton, ...saveButtonProps}), revert = revertButton({model: locModel, mode: showRevertButton, ...revertButtonProps}), menu = popover({ + disabled: !locModel.isVisible, // Prevent orphaned popover menu item: menuButton({model: locModel, ...menuButtonProps}), content: viewMenu({model: locModel}), placement: 'bottom-start', @@ -75,7 +77,8 @@ export const [ViewManager, viewManager] = hoistCmp.withFactory return fragment( hbox({ className, - items: buttonSide == 'left' ? [revert, save, menu] : [menu, save, revert] + items: buttonSide == 'left' ? [revert, save, menu] : [menu, save, revert], + ref: useOnVisibleChange(isVisible => (locModel.isVisible = isVisible)) }), manageDialog({model: locModel.manageDialogModel}), saveAsDialog({model: locModel.saveAsDialogModel}) diff --git a/desktop/cmp/viewmanager/ViewManagerLocalModel.ts b/desktop/cmp/viewmanager/ViewManagerLocalModel.ts index 7e98cdedc..925594192 100644 --- a/desktop/cmp/viewmanager/ViewManagerLocalModel.ts +++ b/desktop/cmp/viewmanager/ViewManagerLocalModel.ts @@ -6,6 +6,7 @@ */ import {HoistModel, managed} from '@xh/hoist/core'; +import {bindable} from '@xh/hoist/mobx'; import {ManageDialogModel} from './dialog/ManageDialogModel'; import {SaveAsDialogModel} from './dialog/SaveAsDialogModel'; import {ViewManagerModel} from '@xh/hoist/cmp/viewmanager'; @@ -19,6 +20,9 @@ export class ViewManagerLocalModel extends HoistModel { @managed readonly saveAsDialogModel: SaveAsDialogModel; + @bindable + isVisible = true; + constructor(parent: ViewManagerModel) { super(); this.parent = parent; From b8147cdbaa10919536e6a6d6acd5025250b7806c Mon Sep 17 00:00:00 2001 From: Greg Solomon Date: Wed, 18 Dec 2024 11:10:54 -0500 Subject: [PATCH 5/8] Omit Share control and Shared Views tab from ViewManager's ManageDialog when enableSharing = false --- cmp/viewmanager/ViewManagerModel.ts | 4 +++- desktop/cmp/viewmanager/dialog/ManageDialogModel.ts | 3 ++- desktop/cmp/viewmanager/dialog/ViewPanel.ts | 5 +++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/cmp/viewmanager/ViewManagerModel.ts b/cmp/viewmanager/ViewManagerModel.ts index 9118b186c..2546fbf27 100644 --- a/cmp/viewmanager/ViewManagerModel.ts +++ b/cmp/viewmanager/ViewManagerModel.ts @@ -504,7 +504,9 @@ export class ViewManagerModel extends HoistModel { @action private setViews(views: ViewInfo[]) { - this.views = this.enableGlobal ? views : views.filter(view => !view.isGlobal); + this.views = views.filter( + view => (this.enableGlobal || !view.isGlobal) && (this.enableSharing || !view.isShared) + ); } private async loadViewAsync( diff --git a/desktop/cmp/viewmanager/dialog/ManageDialogModel.ts b/desktop/cmp/viewmanager/dialog/ManageDialogModel.ts index 1b4015f66..3fa921240 100644 --- a/desktop/cmp/viewmanager/dialog/ManageDialogModel.ts +++ b/desktop/cmp/viewmanager/dialog/ManageDialogModel.ts @@ -344,7 +344,8 @@ export class ManageDialogModel extends HoistModel { Icon.users(), `This tab shows ${views} shared by other ${XH.appName} users. You can pin these ${views} to add them to your menu and access them directly. Only the owner will be able to save changes to a shared ${view}, but you can save as a copy to make it your own.` ) - }) + }), + omit: !enableSharing } ] }); diff --git a/desktop/cmp/viewmanager/dialog/ViewPanel.ts b/desktop/cmp/viewmanager/dialog/ViewPanel.ts index 37789ab69..77d140287 100644 --- a/desktop/cmp/viewmanager/dialog/ViewPanel.ts +++ b/desktop/cmp/viewmanager/dialog/ViewPanel.ts @@ -27,7 +27,8 @@ export const viewPanel = hoistCmp.factory({ const {view} = model; if (!view) return null; - const {isGlobal, lastUpdated, lastUpdatedBy, isEditable} = view; + const {isGlobal, lastUpdated, lastUpdatedBy, isEditable} = view, + {enableSharing} = model.parent.viewManagerModel; return panel({ item: form({ @@ -74,7 +75,7 @@ export const viewPanel = hoistCmp.factory({ inline: true, item: switchInput(), readonlyRenderer: v => (v ? 'Yes' : 'No'), - omit: isGlobal || !isEditable + omit: !enableSharing || isGlobal || !isEditable }), formField({ field: 'isDefaultPinned', From 935c1e72b43273e7dc8d3100f845e5f3a4df23c1 Mon Sep 17 00:00:00 2001 From: Greg Solomon Date: Wed, 18 Dec 2024 11:26:26 -0500 Subject: [PATCH 6/8] Call makeObservable from ViewManagerLocalModel.constructor --- desktop/cmp/viewmanager/ViewManagerLocalModel.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/desktop/cmp/viewmanager/ViewManagerLocalModel.ts b/desktop/cmp/viewmanager/ViewManagerLocalModel.ts index 925594192..f2bfa8f9c 100644 --- a/desktop/cmp/viewmanager/ViewManagerLocalModel.ts +++ b/desktop/cmp/viewmanager/ViewManagerLocalModel.ts @@ -6,7 +6,7 @@ */ import {HoistModel, managed} from '@xh/hoist/core'; -import {bindable} from '@xh/hoist/mobx'; +import {bindable, makeObservable} from '@xh/hoist/mobx'; import {ManageDialogModel} from './dialog/ManageDialogModel'; import {SaveAsDialogModel} from './dialog/SaveAsDialogModel'; import {ViewManagerModel} from '@xh/hoist/cmp/viewmanager'; @@ -25,6 +25,7 @@ export class ViewManagerLocalModel extends HoistModel { constructor(parent: ViewManagerModel) { super(); + makeObservable(this); this.parent = parent; this.manageDialogModel = new ManageDialogModel(parent); this.saveAsDialogModel = new SaveAsDialogModel(parent); From 4f7a78c2f7227bf81f9d2d406083df5131b3dc3e Mon Sep 17 00:00:00 2001 From: Greg Solomon Date: Wed, 18 Dec 2024 14:40:49 -0500 Subject: [PATCH 7/8] Changes from CR --- cmp/viewmanager/ViewManagerModel.ts | 58 +++------ cmp/viewmanager/ViewToBlobApi.ts | 38 ++++-- core/exception/ExceptionHandler.ts | 3 +- .../cmp/viewmanager/dialog/ManageDialog.ts | 6 +- .../viewmanager/dialog/ManageDialogModel.ts | 123 ++++++++++-------- .../cmp/viewmanager/dialog/ViewMultiPanel.ts | 6 +- 6 files changed, 126 insertions(+), 108 deletions(-) diff --git a/cmp/viewmanager/ViewManagerModel.ts b/cmp/viewmanager/ViewManagerModel.ts index 2546fbf27..bfe3a5d22 100644 --- a/cmp/viewmanager/ViewManagerModel.ts +++ b/cmp/viewmanager/ViewManagerModel.ts @@ -5,7 +5,7 @@ * Copyright © 2024 Extremely Heavy Industries Inc. */ -import {fragment, strong, p, span, div, ul, li} from '@xh/hoist/cmp/layout'; +import {fragment, strong, p, span} from '@xh/hoist/cmp/layout'; import { ExceptionHandlerOptions, HoistModel, @@ -24,7 +24,8 @@ import {fmtDateTime} from '@xh/hoist/format'; import {action, bindable, makeObservable, observable, when} from '@xh/hoist/mobx'; import {olderThan, SECONDS} from '@xh/hoist/utils/datetime'; import {executeIfFunction, pluralize, throwIf} from '@xh/hoist/utils/js'; -import {find, isEmpty, isEqual, isNil, isObject, lowerCase, partition, pickBy} from 'lodash'; +import {find, isEmpty, isEqual, isNil, isObject, lowerCase, pickBy} from 'lodash'; +import {runInAction} from 'mobx'; import {ReactNode} from 'react'; import {ViewInfo} from './ViewInfo'; import {View} from './View'; @@ -44,7 +45,8 @@ export interface ViewManagerConfig { enableDefault?: boolean; /** - * True (default) to enable "global" views - i.e. views that are not owned by a user and are available to all. + * True (default) to enable "global" views - i.e. views that are not owned by a user and are + * available to all. */ enableGlobal?: boolean; @@ -222,14 +224,7 @@ export class ViewManagerModel extends HoistModel { get isViewAutoSavable(): boolean { const {enableAutoSave, autoSave, view} = this; - return ( - enableAutoSave && - autoSave && - !view.isGlobal && - !view.isShared && - !view.isDefault && - !XH.identityService.isImpersonating - ); + return enableAutoSave && autoSave && view.isOwned && !XH.identityService.isImpersonating; } get autoSaveUnavailableReason(): string { @@ -319,7 +314,7 @@ export class ViewManagerModel extends HoistModel { // 1) Update all view info const views = await this.api.fetchViewInfosAsync(); if (loadSpec.isStale) return; - this.setViews(views); + runInAction(() => (this.views = views)); // 2) Update active view if needed. const {view} = this; @@ -447,28 +442,22 @@ export class ViewManagerModel extends HoistModel { return null; } - async deleteViewsAsync(views: ViewInfo[]): Promise { - const results = await Promise.allSettled(views.map(v => this.api.deleteViewAsync(v))), - outcome = results.map((result, idx) => ({result, view: views[idx]})), - [succeeded, failed] = partition(outcome, ({result}) => result.status === 'fulfilled'); - - if (!isEmpty(failed)) { - XH.handleException( - {errors: failed.map(({result}) => result.status === 'rejected' && result.reason)}, - { - message: div( - `Failed to delete ${pluralize(this.typeDisplayName, failed.length, true)}:`, - ul(failed.map(({view}) => li(view.name))) - ) - } - ); + async deleteViewsAsync(toDelete: ViewInfo[]): Promise { + let exception; + try { + await this.api.deleteViewsAsync(toDelete); + } catch (e) { + exception = e; } await this.refreshAsync(); + const {views} = this; - if (succeeded.some(({view}) => view.token === this.view?.token)) { - await this.loadViewAsync(this.initialViewSpec?.(this.views)); + if (toDelete.some(view => view.isCurrentView) && !views.some(view => view.isCurrentView)) { + await this.loadViewAsync(this.initialViewSpec?.(views)); } + + if (exception) throw exception; } //------------------ @@ -477,7 +466,7 @@ export class ViewManagerModel extends HoistModel { private async initAsync() { try { const views = await this.api.fetchViewInfosAsync(); - this.setViews(views); + runInAction(() => (this.views = views)); if (this.persistWith) { this.initPersist(this.persistWith); @@ -502,13 +491,6 @@ export class ViewManagerModel extends HoistModel { }); } - @action - private setViews(views: ViewInfo[]) { - this.views = views.filter( - view => (this.enableGlobal || !view.isGlobal) && (this.enableSharing || !view.isShared) - ); - } - private async loadViewAsync( info: ViewInfo, pendingValue: PendingValue = null @@ -550,7 +532,7 @@ export class ViewManagerModel extends HoistModel { this.pendingValue = pendingValue; // Ensure we update meta-data as well. if (!view.isDefault) { - this.setViews(this.views.map(v => (v.token === view.token ? view.info : v))); + this.views = this.views.map(v => (v.token === view.token ? view.info : v)); } } diff --git a/cmp/viewmanager/ViewToBlobApi.ts b/cmp/viewmanager/ViewToBlobApi.ts index 260b9c83f..463d73740 100644 --- a/cmp/viewmanager/ViewToBlobApi.ts +++ b/cmp/viewmanager/ViewToBlobApi.ts @@ -7,7 +7,7 @@ import {PlainObject, XH} from '@xh/hoist/core'; import {pluralize, throwIf} from '@xh/hoist/utils/js'; -import {omit, pick} from 'lodash'; +import {isEmpty, omit, pick} from 'lodash'; import {ViewInfo} from './ViewInfo'; import {View} from './View'; import {ViewManagerModel} from './ViewManagerModel'; @@ -50,7 +50,13 @@ export class ViewToBlobApi { type: model.type, includeValue: false }); - return blobs.map(b => new ViewInfo(b, model)); + return blobs + .map(b => new ViewInfo(b, model)) + .filter( + view => + (model.enableGlobal || !view.isGlobal) && + (model.enableSharing || !view.isShared) + ); } catch (e) { throw XH.exception({ message: `Unable to fetch ${pluralize(model.typeDisplayName)}`, @@ -151,21 +157,31 @@ export class ViewToBlobApi { } } - /** Delete a view. */ - async deleteViewAsync(view: ViewInfo) { - try { - this.ensureEditable(view); - await XH.jsonBlobService.archiveAsync(view.token); - this.trackChange('Deleted View', view); - } catch (e) { - throw XH.exception({message: `Unable to delete ${view.typedName}`, cause: e}); + async deleteViewsAsync(views: ViewInfo[]) { + views.forEach(v => this.ensureEditable(v)); + const results = await Promise.allSettled( + views.map(v => XH.jsonBlobService.archiveAsync(v.token)) + ), + outcome = results.map((result, idx) => ({result, view: views[idx]})), + failed = outcome.filter(({result}) => result.status === 'rejected') as Array<{ + result: PromiseRejectedResult; + view: ViewInfo; + }>; + + this.trackChange(`Deleted ${pluralize('View', views.length - failed.length, true)}`); + + if (!isEmpty(failed)) { + throw XH.exception({ + message: `Failed to delete ${pluralize(this.model.typeDisplayName, failed.length, true)}: ${failed.map(({view}) => view.name).join(', ')}`, + cause: failed.map(({result}) => result.reason) + }); } } //------------------ // Implementation //------------------ - private trackChange(message: string, v: View | ViewInfo) { + private trackChange(message: string, v?: View | ViewInfo) { XH.track({ message, category: 'Views', diff --git a/core/exception/ExceptionHandler.ts b/core/exception/ExceptionHandler.ts index 1d838851a..c51d32d82 100644 --- a/core/exception/ExceptionHandler.ts +++ b/core/exception/ExceptionHandler.ts @@ -4,7 +4,6 @@ * * Copyright © 2024 Extremely Heavy Industries Inc. */ -import {ReactNode} from 'react'; import {Exception} from './Exception'; import {fragment, span} from '@xh/hoist/cmp/layout'; import {logDebug, logError, logWarn, stripTags} from '@xh/hoist/utils/js'; @@ -14,7 +13,7 @@ import {HoistException, PlainObject, XH} from '../'; export interface ExceptionHandlerOptions { /** Text (ideally user-friendly) describing the error. */ - message?: ReactNode; + message?: string; /** Title for an alert dialog, if shown. */ title?: string; diff --git a/desktop/cmp/viewmanager/dialog/ManageDialog.ts b/desktop/cmp/viewmanager/dialog/ManageDialog.ts index f20b66177..fe823b21c 100644 --- a/desktop/cmp/viewmanager/dialog/ManageDialog.ts +++ b/desktop/cmp/viewmanager/dialog/ManageDialog.ts @@ -9,7 +9,7 @@ import {grid, GridModel} from '@xh/hoist/cmp/grid'; import {div, filler, hframe, placeholder, vframe} from '@xh/hoist/cmp/layout'; import {storeFilterField} from '@xh/hoist/cmp/store'; import {tabContainer} from '@xh/hoist/cmp/tab'; -import {hoistCmp, uses, UsesSpec} from '@xh/hoist/core'; +import {hoistCmp, uses} from '@xh/hoist/core'; import {button, refreshButton} from '@xh/hoist/desktop/cmp/button'; import {panel} from '@xh/hoist/desktop/cmp/panel'; import {toolbar, toolbarSep} from '@xh/hoist/desktop/cmp/toolbar'; @@ -24,10 +24,10 @@ import {viewPanel} from './ViewPanel'; /** * Default management dialog for ViewManager. */ -export const manageDialog = hoistCmp.factory({ +export const manageDialog = hoistCmp.factory({ displayName: 'ManageDialog', className: 'xh-view-manager__manage-dialog', - model: uses(() => ManageDialogModel) as UsesSpec, + model: uses(() => ManageDialogModel), render({model, className}) { if (!model.isOpen) return null; diff --git a/desktop/cmp/viewmanager/dialog/ManageDialogModel.ts b/desktop/cmp/viewmanager/dialog/ManageDialogModel.ts index 3fa921240..1d2885c7a 100644 --- a/desktop/cmp/viewmanager/dialog/ManageDialogModel.ts +++ b/desktop/cmp/viewmanager/dialog/ManageDialogModel.ts @@ -17,7 +17,7 @@ import {viewsGrid} from '@xh/hoist/desktop/cmp/viewmanager/dialog/ManageDialog'; import {Icon} from '@xh/hoist/icon'; import {action, bindable, computed, makeObservable, observable, runInAction} from '@xh/hoist/mobx'; import {pluralize} from '@xh/hoist/utils/js'; -import {capitalize, isEmpty, some, startCase} from 'lodash'; +import {capitalize, compact, isEmpty, some, startCase} from 'lodash'; import {ReactNode} from 'react'; import {ViewPanelModel} from './ViewPanelModel'; @@ -92,15 +92,22 @@ export class ManageDialogModel extends HoistModel { override async doLoadAsync(loadSpec: LoadSpec) { const {tabContainerModel} = this, - {view, ownedViews, globalViews, sharedViews} = this.viewManagerModel; + {enableGlobal, enableSharing, view, ownedViews, globalViews, sharedViews} = + this.viewManagerModel; runInAction(() => { this.ownedGridModel.loadData(ownedViews); - this.globalGridModel.loadData(globalViews); - this.sharedGridModel.loadData(sharedViews); tabContainerModel.setTabTitle('owned', this.ownedTabTitle); - tabContainerModel.setTabTitle('global', this.globalTabTitle); - tabContainerModel.setTabTitle('shared', this.sharedTabTitle); + + if (enableGlobal) { + this.globalGridModel.loadData(globalViews); + tabContainerModel.setTabTitle('global', this.globalTabTitle); + } + + if (enableSharing) { + this.sharedGridModel.loadData(sharedViews); + tabContainerModel.setTabTitle('shared', this.sharedTabTitle); + } }); if (!loadSpec.isRefresh && !view.isDefault) { await this.selectViewAsync(view.info); @@ -129,27 +136,39 @@ export class ManageDialogModel extends HoistModel { // Implementation //------------------------ private init() { + const {enableGlobal, enableSharing} = this.viewManagerModel; + this.ownedGridModel = this.createGridModel('owned'); - this.globalGridModel = this.createGridModel('global'); - this.sharedGridModel = this.createGridModel('shared'); + if (enableGlobal) this.globalGridModel = this.createGridModel('global'); + if (enableSharing) this.sharedGridModel = this.createGridModel('shared'); + const gridModels = compact([ + this.ownedGridModel, + this.globalGridModel, + this.sharedGridModel + ]); + this.tabContainerModel = this.createTabContainerModel(); this.viewPanelModel = new ViewPanelModel(this); - const gridModels = [this.ownedGridModel, this.globalGridModel, this.sharedGridModel]; + this.addReaction({ track: () => this.filter, run: f => gridModels.forEach(m => m.store.setFilter(f)), fireImmediately: true }); - gridModels.forEach(gm => { - this.addReaction({ - track: () => gm.hasSelection, - run: hasSelection => { - gridModels.forEach(it => { - if (it != gm && hasSelection) it.clearSelection(); - }); - } + + // Only allow one selection at a time across all grids + if (gridModels.length > 1) { + gridModels.forEach(gm => { + this.addReaction({ + track: () => gm.hasSelection, + run: hasSelection => { + gridModels.forEach(it => { + if (it != gm && hasSelection) it.clearSelection(); + }); + } + }); }); - }); + } } private async doUpdateAsync(view: ViewInfo, update: ViewUpdateSpec) { @@ -193,8 +212,7 @@ export class ManageDialogModel extends HoistModel { }); if (!confirmed) return; - await viewManagerModel.deleteViewsAsync(views); - await this.refreshAsync(); + return viewManagerModel.deleteViewsAsync(views).finally(() => this.refreshAsync()); } private async doMakeGlobalAsync(view: ViewInfo) { @@ -301,14 +319,12 @@ export class ManageDialogModel extends HoistModel { } private createTabContainerModel(): TabContainerModel { - const {globalDisplayName, typeDisplayName} = this.viewManagerModel, + const {enableGlobal, enableSharing, globalDisplayName, typeDisplayName} = + this.viewManagerModel, view = typeDisplayName, views = pluralize(view), globalViews = `${globalDisplayName} ${views}`, - {enableGlobal, enableSharing} = this.viewManagerModel; - - return new TabContainerModel({ - tabs: [ + tabs = [ { id: 'owned', title: this.ownedTabTitle, @@ -322,33 +338,38 @@ export class ManageDialogModel extends HoistModel { : '' ) }) - }, - { - id: 'global', - title: this.globalTabTitle, - content: viewsGrid({ - model: this.globalGridModel, - helpText: fragment( - Icon.globe(), - `This tab shows ${globalViews} available to everyone. ${capitalize(globalViews)} can be pinned by default so they appear automatically in everyone's menu, but you can choose which ${views} you would like to see by pinning/unpinning them at any time.` - ) - }), - omit: !enableGlobal - }, - { - id: 'shared', - title: this.sharedTabTitle, - content: viewsGrid({ - model: this.sharedGridModel, - helpText: fragment( - Icon.users(), - `This tab shows ${views} shared by other ${XH.appName} users. You can pin these ${views} to add them to your menu and access them directly. Only the owner will be able to save changes to a shared ${view}, but you can save as a copy to make it your own.` - ) - }), - omit: !enableSharing } - ] - }); + ]; + + if (enableGlobal) { + tabs.push({ + id: 'global', + title: this.globalTabTitle, + content: viewsGrid({ + model: this.globalGridModel, + helpText: fragment( + Icon.globe(), + `This tab shows ${globalViews} available to everyone. ${capitalize(globalViews)} can be pinned by default so they appear automatically in everyone's menu, but you can choose which ${views} you would like to see by pinning/unpinning them at any time.` + ) + }) + }); + } + + if (enableSharing) { + tabs.push({ + id: 'shared', + title: this.sharedTabTitle, + content: viewsGrid({ + model: this.sharedGridModel, + helpText: fragment( + Icon.users(), + `This tab shows ${views} shared by other ${XH.appName} users. You can pin these ${views} to add them to your menu and access them directly. Only the owner will be able to save changes to a shared ${view}, but you can save as a copy to make it your own.` + ) + }) + }); + } + + return new TabContainerModel({tabs}); } private get ownedTabTitle(): ReactNode { diff --git a/desktop/cmp/viewmanager/dialog/ViewMultiPanel.ts b/desktop/cmp/viewmanager/dialog/ViewMultiPanel.ts index e718c0ba5..faad4ddc1 100644 --- a/desktop/cmp/viewmanager/dialog/ViewMultiPanel.ts +++ b/desktop/cmp/viewmanager/dialog/ViewMultiPanel.ts @@ -6,7 +6,7 @@ */ import {placeholder, vbox, vframe, vspacer} from '@xh/hoist/cmp/layout'; -import {hoistCmp, uses, UsesSpec} from '@xh/hoist/core'; +import {hoistCmp, uses} from '@xh/hoist/core'; import {button} from '@xh/hoist/desktop/cmp/button'; import {panel} from '@xh/hoist/desktop/cmp/panel'; import {Icon} from '@xh/hoist/icon'; @@ -14,8 +14,8 @@ import {pluralize} from '@xh/hoist/utils/js'; import {every, isEmpty, some} from 'lodash'; import {ManageDialogModel} from './ManageDialogModel'; -export const viewMultiPanel = hoistCmp.factory({ - model: uses(() => ManageDialogModel) as UsesSpec, +export const viewMultiPanel = hoistCmp.factory({ + model: uses(() => ManageDialogModel), render({model}) { const views = model.selectedViews; if (isEmpty(views)) return null; From a7874236d04a1a721bce8e2fbb9fb12b7a64d791 Mon Sep 17 00:00:00 2001 From: Greg Solomon Date: Wed, 18 Dec 2024 14:47:33 -0500 Subject: [PATCH 8/8] Make ViewManagerModel.api private --- cmp/viewmanager/ViewManagerModel.ts | 21 ++++++++++++------- .../viewmanager/dialog/ManageDialogModel.ts | 4 ++-- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/cmp/viewmanager/ViewManagerModel.ts b/cmp/viewmanager/ViewManagerModel.ts index bfe3a5d22..a38a2090d 100644 --- a/cmp/viewmanager/ViewManagerModel.ts +++ b/cmp/viewmanager/ViewManagerModel.ts @@ -29,7 +29,7 @@ import {runInAction} from 'mobx'; import {ReactNode} from 'react'; import {ViewInfo} from './ViewInfo'; import {View} from './View'; -import {ViewToBlobApi, ViewCreateSpec} from './ViewToBlobApi'; +import {ViewToBlobApi, ViewCreateSpec, ViewUpdateSpec} from './ViewToBlobApi'; export interface ViewManagerConfig { /** @@ -201,13 +201,10 @@ export class ViewManagerModel extends HoistModel { */ providers: ViewManagerProvider[] = []; - /** - * Data access for persisting views. - * @internal - */ - api: ViewToBlobApi; + /** Data access for persisting views. */ + private api: ViewToBlobApi; - // Last time changes were pushed to linked persistence providers + /** Last time changes were pushed to linked persistence providers */ private lastPushed: number = null; //--------------- @@ -442,6 +439,16 @@ export class ViewManagerModel extends HoistModel { return null; } + /** Update all aspects of a view's metadata.*/ + async updateViewInfoAsync(view: ViewInfo, updates: ViewUpdateSpec): Promise> { + return this.api.updateViewInfoAsync(view, updates); + } + + /** Promote a view to global visibility/ownership status. */ + async makeViewGlobalAsync(view: ViewInfo): Promise> { + return this.api.makeViewGlobalAsync(view); + } + async deleteViewsAsync(toDelete: ViewInfo[]): Promise { let exception; try { diff --git a/desktop/cmp/viewmanager/dialog/ManageDialogModel.ts b/desktop/cmp/viewmanager/dialog/ManageDialogModel.ts index 1d2885c7a..396a3ce3e 100644 --- a/desktop/cmp/viewmanager/dialog/ManageDialogModel.ts +++ b/desktop/cmp/viewmanager/dialog/ManageDialogModel.ts @@ -173,7 +173,7 @@ export class ManageDialogModel extends HoistModel { private async doUpdateAsync(view: ViewInfo, update: ViewUpdateSpec) { const {viewManagerModel} = this; - await viewManagerModel.api.updateViewInfoAsync(view, update); + await viewManagerModel.updateViewInfoAsync(view, update); await viewManagerModel.refreshAsync(); await this.refreshAsync(); } @@ -235,7 +235,7 @@ export class ManageDialogModel extends HoistModel { if (!confirmed) return; const {viewManagerModel} = this; - const updated = await viewManagerModel.api.makeViewGlobalAsync(view); + const updated = await viewManagerModel.makeViewGlobalAsync(view); await viewManagerModel.refreshAsync(); await this.refreshAsync(); await this.selectViewAsync(updated.info); // reselect -- will have moved tabs!