diff --git a/CHANGELOG.md b/CHANGELOG.md index 814f3fc5c..e864b7633 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## v71.0.0-SNAPSHOT - unreleased + +### ⚙️ Technical +* Misc. Improvements to ViewManager + + ## v70.0.0 - 2024-11-15 ### 💥 Breaking Changes (upgrade difficulty: 🟢 LOW - changes to advanced persistence APIs) diff --git a/core/persist/viewmanager/Types.ts b/core/persist/viewmanager/Types.ts index b1a5df575..30d3f6731 100644 --- a/core/persist/viewmanager/Types.ts +++ b/core/persist/viewmanager/Types.ts @@ -20,8 +20,10 @@ export interface View { isShared: boolean; lastUpdated: number; lastUpdatedBy: string; - /** User-supplied descriptive name. */ + /** User-supplied descriptive name, including folder designating prefix. */ name: string; + /** User-supplied descriptive name, without folder designating prefix. */ + shortName: string; /** Original creator of the view, and the only user with access to it if not shared. */ owner: string; token: string; diff --git a/core/persist/viewmanager/ViewManagerModel.ts b/core/persist/viewmanager/ViewManagerModel.ts index 434e200f2..eb3e1094a 100644 --- a/core/persist/viewmanager/ViewManagerModel.ts +++ b/core/persist/viewmanager/ViewManagerModel.ts @@ -14,11 +14,11 @@ import { } from '@xh/hoist/core'; import {genDisplayName} from '@xh/hoist/data'; import {action, bindable, computed, makeObservable, observable} from '@xh/hoist/mobx'; -import {wait} from '@xh/hoist/promise'; import {executeIfFunction, pluralize, throwIf} from '@xh/hoist/utils/js'; -import {isEmpty, isEqual, isNil, lowerCase, sortBy, startCase} from 'lodash'; +import {isEqual, isNil, isUndefined, lowerCase, startCase} from 'lodash'; import {runInAction} from 'mobx'; import {SaveDialogModel} from './impl/SaveDialogModel'; +import {buildViewTree} from './impl/BuildViewTree'; import {View, ViewTree} from './Types'; export interface ViewManagerConfig { @@ -104,16 +104,19 @@ export class ViewManagerModel /** Current state of the active view, can include not-yet-persisted changes. */ @observable.ref pendingValue: T = {} as T; /** Loaded saved view definitions - both private and shared. */ - @observable.ref views: View[] = []; - /** Token identifier for the currently selected view, or null if in default mode. */ - @bindable selectedToken: string = null; + @observable.ref views: View[] = null; + + /** Currently selected view, or null if in default mode. Token only will be set during pre-loading.*/ + @observable selectedToken: string = null; + @observable.ref selectedView: View = null; + /** List of tokens for the user's favorite views. */ @bindable favorites: string[] = []; /** * True if user has opted-in to automatically saving changes to personal views (if auto-save * generally available as per `enableAutoSave`). */ - @bindable autoSaveActive = false; + @bindable autoSave = false; /** * TaskObserver linked to {@link selectViewAsync}. If a change to the active view is likely to @@ -137,51 +140,30 @@ export class ViewManagerModel return executeIfFunction(this._enableSharing); } - get selectedView(): View { - return this.views.find(it => it.token === this.selectedToken); - } - - @computed - get isSharedViewSelected(): boolean { - return !!this.selectedView?.isShared; - } - @computed get canSave(): boolean { - const {selectedView} = this; - return ( - selectedView && - this.isDirty && - (this.enableSharing || !selectedView.isShared) && - !this.loadModel.isPending - ); + const {loadModel, selectedView, enableSharing} = this; + return !loadModel.isPending && selectedView && (enableSharing || !selectedView.isShared); } - /** - * True if displaying the save button is appropriate from the model's point of view, even if - * that button might be disabled due to no changes having been made. Works in concert with the - * desktop ViewManager component's `showSaveButton` prop. - */ @computed - get canShowSaveButton(): boolean { - const {selectedView} = this; + get canAutoSave(): boolean { + const {enableAutoSave, autoSave, loadModel, selectedView} = this; return ( + !loadModel.isPending && + enableAutoSave && + autoSave && selectedView && - (!this.enableAutoSave || !this.autoSaveActive) && - (this.enableSharing || !selectedView.isShared) + !selectedView.isShared ); } @computed - get enableAutoSaveToggle(): boolean { - return this.selectedView && !this.isSharedViewSelected; - } - - @computed - get disabledAutoSaveReason(): string { - const {displayName} = this; - if (!this.selectedView) return `Cannot auto-save default ${displayName}.`; - if (this.isSharedViewSelected) return `Cannot auto-save shared ${displayName}.`; + get autoSaveUnavailableReason(): string { + const {canAutoSave, selectedView, displayName} = this; + if (canAutoSave) return null; + if (!selectedView) return `Cannot auto-save default ${displayName}.`; + if (selectedView.isShared) return `Cannot auto-save shared ${displayName}.`; return null; } @@ -207,11 +189,11 @@ export class ViewManagerModel } get sharedViewTree(): ViewTree[] { - return this.buildViewTree(sortBy(this.sharedViews, 'name')); + return buildViewTree(this.sharedViews, this); } get privateViewTree(): ViewTree[] { - return this.buildViewTree(sortBy(this.privateViews, 'name')); + return buildViewTree(this.privateViews, this); } /** @@ -257,12 +239,9 @@ export class ViewManagerModel this.addReaction( { - track: () => this.pendingValue, - run: () => this.maybeAutoSaveAsync({skipToast: true}) - }, - { - track: () => this.autoSaveActive, - run: () => this.maybeAutoSaveAsync({skipToast: false}) + // Track pendingValue, so we retry on fail if view stays dirty -- could use backup timer + track: () => [this.pendingValue, this.autoSave], + run: () => this.maybeAutoSaveAsync() }, { track: () => this.favorites, @@ -274,53 +253,51 @@ export class ViewManagerModel override async doLoadAsync(loadSpec: LoadSpec) { const rawViews = await XH.jsonBlobService.listAsync({ type: this.viewType, - includeValue: true, + includeValue: false, loadSpec }); if (loadSpec.isStale) return; - runInAction(() => (this.views = this.processRaw(rawViews))); + runInAction(() => { + this.views = rawViews.map(it => this.processRaw(it)); + }); const token = loadSpec.meta.selectToken ?? - this.selectedView?.token ?? + this.selectedToken ?? (this.enableDefault ? null : this.views[0]?.token); await this.selectViewAsync(token); } async selectViewAsync(token: string) { - // Introduce minimal wait and link to viewSelectionObserver to allow apps to mask. - await wait(100) - .then(() => { - this.selectedToken = token; - - // Allow this model to restore its own persisted state in its ctor and note the desired - // selected token before views have been loaded. Once views are loaded, this method will - // be called again with the desired token and will proceed to set the value. - if (isEmpty(this.views)) return; - - this.setValue(this.selectedView?.value ?? ({} as T)); - }) - .linkTo(this.viewSelectionObserver); + // If views have not been loaded yet (e.g. constructing), nothing to be done but pre-set state + if (!this.views) { + this.selectedToken = token; + return; + } + await this.selectViewInternalAsync(token).linkTo(this.viewSelectionObserver); } - async saveAsync(skipToast: boolean = false) { - const {canSave, selectedToken, pendingValue, isSharedViewSelected, DisplayName} = this; - throwIf(!canSave, 'Unable to save view at this time.'); // sanity check - user should not reach + //------------------------ + // Saving/resetting + //------------------------ + async saveAsync() { + const {canSave, selectedToken, pendingValue, selectedView, DisplayName} = this; + throwIf(!canSave, 'Unable to save view.'); - if (isSharedViewSelected) { + if (selectedView?.isShared) { if (!(await this.confirmSaveForSharedViewAsync())) return; } try { await XH.jsonBlobService.updateAsync(selectedToken, {value: pendingValue}); + runInAction(() => { + this.value = this.pendingValue; + }); + XH.successToast(`${DisplayName} successfully saved.`); } catch (e) { XH.handleException(e, {alertType: 'toast'}); - skipToast = true; // don't show the success toast below, but still refresh. } - - await this.refreshAsync({selectToken: selectedToken}); - if (!skipToast) XH.successToast(`${DisplayName} successfully saved.`); } async saveAsAsync() { @@ -364,10 +341,6 @@ export class ViewManagerModel this.manageDialogOpen = false; } - getHierarchyDisplayName(name: string) { - return name?.substring(name.lastIndexOf('\\') + 1); - } - //------------------ // Favorites //------------------ @@ -391,38 +364,69 @@ export class ViewManagerModel // Persistable //------------------ getPersistableState(): PersistableState { - return new PersistableState({selectedToken: this.selectedToken, favorites: this.favorites}); + const state: ViewManagerModelPersistState = { + selectedToken: this.selectedToken, + favorites: this.favorites + }; + if (this.enableAutoSave) { + state.autoSave = this.autoSave; + } + return new PersistableState(state); } setPersistableState(state: PersistableState) { - const {selectedToken, favorites} = state.value; - if (selectedToken) this.selectViewAsync(selectedToken); - if (favorites) this.favorites = favorites; + const {selectedToken, favorites, autoSave} = state.value; + if (!isUndefined(selectedToken)) { + this.selectViewAsync(selectedToken); + } + if (!isUndefined(favorites)) { + this.favorites = favorites; + } + if (!isUndefined(autoSave) && this.enableAutoSave) { + this.autoSave = autoSave; + } } //------------------ // Implementation //------------------ - private processRaw(raw: PlainObject[]): View[] { - const name = pluralize(this.DisplayName); - return raw.map(it => { - const isShared = it.acl === '*'; - return { - ...it, - isShared, - group: isShared ? `Shared ${name}` : `My ${name}`, - isFavorite: this.isFavorite(it.token) - } as View; + private async selectViewInternalAsync(token: string) { + let view: View = null; + if (token != null) { + try { + const raw = await XH.jsonBlobService.getAsync(token); + view = this.processRaw(raw); + } catch (e) { + XH.handleException(e, {showAlert: false}); + view = null; + token = null; + } + } + + runInAction(() => { + this.selectedToken = token; + this.selectedView = view; + this.setValue(this.selectedView?.value ?? ({} as T)); }); } - @action + private processRaw(raw: PlainObject): View { + const name = pluralize(this.DisplayName); + const isShared = raw.acl === '*'; + return { + ...raw, + shortName: raw.name?.substring(raw.name.lastIndexOf('\\') + 1), + isShared, + group: isShared ? `Shared ${name}` : `My ${name}`, + isFavorite: this.isFavorite(raw.token) + } as View; + } + private setValue(value: T) { value = this.cleanValue(value); if (isEqual(value, this.value) && isEqual(value, this.pendingValue)) return; - this.value = value; - this.pendingValue = value; + this.value = this.pendingValue = value; this.providers.forEach(it => it.pushStateToTarget()); } @@ -447,69 +451,18 @@ export class ViewManagerModel }); } - private async maybeAutoSaveAsync({skipToast}: {skipToast: boolean}) { - if ( - this.enableAutoSave && - this.autoSaveActive && - this.canSave && - !this.isSharedViewSelected - ) { - await this.saveAsync(skipToast); - } - } - - private buildViewTree(views: View[], depth: number = 0): ViewTree[] { - const groups = {}, - unbalancedStableGroupsAndViews = []; - - views.forEach(view => { - // Leaf Node - if (this.getNameHierarchySubstring(view.name, depth + 1) == null) { - unbalancedStableGroupsAndViews.push(view); - return; + private async maybeAutoSaveAsync() { + if (this.canAutoSave && this.isDirty) { + const {selectedToken, pendingValue} = this; + try { + await XH.jsonBlobService.updateAsync(selectedToken, {value: pendingValue}); + runInAction(() => { + this.value = this.pendingValue; + }); + } catch (e) { + XH.handleException(e, {showAlert: false}); } - // Belongs to an already defined group - const group = this.getNameHierarchySubstring(view.name, depth); - if (groups[group]) { - groups[group].children.push(view); - return; - } - // Belongs to a not defined group, create it - groups[group] = {name: group, children: [view], isMenuFolder: true}; - unbalancedStableGroupsAndViews.push(groups[group]); - }); - - return unbalancedStableGroupsAndViews.map(it => { - const {name, isMenuFolder, children, description, token} = it; - if (isMenuFolder) { - return { - type: 'folder', - text: name, - items: this.buildViewTree(children, depth + 1), - selected: this.isFolderForEntry(name, this.selectedView?.name, depth) - }; - } - return { - type: 'view', - text: this.getHierarchyDisplayName(name), - selected: this.selectedToken === token, - token, - description - }; - }); - } - - private getNameHierarchySubstring(name: string, depth: number) { - const arr = name?.split('\\') ?? []; - if (arr.length <= depth) { - return null; } - return arr.slice(0, depth + 1).join('\\'); - } - - private isFolderForEntry(folderName: string, entryName: string, depth: number) { - const name = this.getNameHierarchySubstring(entryName, depth); - return name && name === folderName && folderName.length < entryName.length; } // Update flag on each view, replacing entire views collection for observability. @@ -522,6 +475,7 @@ export class ViewManagerModel } interface ViewManagerModelPersistState { - selectedToken: string; - favorites: string[]; + selectedToken?: string; + favorites?: string[]; + autoSave?: boolean; } diff --git a/core/persist/viewmanager/impl/BuildViewTree.ts b/core/persist/viewmanager/impl/BuildViewTree.ts new file mode 100644 index 000000000..fcb05daf0 --- /dev/null +++ b/core/persist/viewmanager/impl/BuildViewTree.ts @@ -0,0 +1,68 @@ +import {View, ViewManagerModel, ViewTree} from '@xh/hoist/core/persist/viewmanager'; +import {sortBy} from 'lodash'; + +/** + * Create a menu-friendly, tree representation of a set of views, using the `\` + * in view names to create folders. + * + * @internal + */ +export function buildViewTree(views: View[], model: ViewManagerModel): ViewTree[] { + views = sortBy(views, 'name'); + return buildTreeInternal(views, model.selectedView, 0); +} + +function buildTreeInternal(views: View[], selected: View, depth: number): ViewTree[] { + // 1) Get groups and leaves at this level. + const groups = {}, + groupsAndLeaves = []; + views.forEach(view => { + // Leaf Node + if (getNameAtDepth(view.name, depth + 1) == null) { + groupsAndLeaves.push(view); + return; + } + // Belongs to an already defined group + const group = getNameAtDepth(view.name, depth); + if (groups[group]) { + groups[group].children.push(view); + return; + } + // Belongs to a not defined group, create it + groups[group] = {name: group, children: [view], isMenuFolder: true}; + groupsAndLeaves.push(groups[group]); + }); + + // 2) Make ViewTree, recursing for groups + return groupsAndLeaves.map(it => { + const {name, isMenuFolder, children, description, token} = it; + return isMenuFolder + ? { + type: 'folder', + text: getFolderDisplayName(name, depth), + items: buildTreeInternal(children, selected, depth + 1), + selected: isFolderForEntry(name, selected?.name, depth) + } + : { + type: 'view', + text: it.shortName, + selected: selected?.token === token, + token, + description + }; + }); +} + +function getNameAtDepth(name: string, depth: number) { + const arr = name?.split('\\') ?? []; + return arr.length <= depth ? null : arr.slice(0, depth + 1).join('\\'); +} + +function isFolderForEntry(folderName: string, entryName: string, depth: number) { + const name = getNameAtDepth(entryName, depth); + return name && name === folderName && folderName.length < entryName.length; +} + +function getFolderDisplayName(name: string, depth: number) { + return name?.split('\\')[depth]; +} diff --git a/core/persist/viewmanager/impl/ManageDialogModel.ts b/core/persist/viewmanager/impl/ManageDialogModel.ts index 69ead3a7b..176edd2e6 100644 --- a/core/persist/viewmanager/impl/ManageDialogModel.ts +++ b/core/persist/viewmanager/impl/ManageDialogModel.ts @@ -214,6 +214,8 @@ export class ManageDialogModel extends HoistModel { hideHeaders: true, showGroupRowCounts: false, selModel: 'multiple', + contextMenu: null, + sizingMode: 'standard', store: { idSpec: 'token', fields: [ diff --git a/core/persist/viewmanager/impl/SaveDialogModel.ts b/core/persist/viewmanager/impl/SaveDialogModel.ts index 141473b19..d4c39e3ed 100644 --- a/core/persist/viewmanager/impl/SaveDialogModel.ts +++ b/core/persist/viewmanager/impl/SaveDialogModel.ts @@ -40,7 +40,7 @@ export class SaveDialogModel extends HoistModel { this.invalidNames = invalidNames; this.formModel.init({ - name: viewStub.name ? `${viewStub.name} (COPY)` : '', + name: viewStub.name ?? '', description: viewStub.description }); diff --git a/desktop/cmp/viewmanager/ViewManager.ts b/desktop/cmp/viewmanager/ViewManager.ts index 3f80ab33f..bcacdf21f 100644 --- a/desktop/cmp/viewmanager/ViewManager.ts +++ b/desktop/cmp/viewmanager/ViewManager.ts @@ -5,19 +5,36 @@ import {ViewTree} from '@xh/hoist/core/persist/viewmanager'; import {ViewManagerModel} from '@xh/hoist/core/persist/viewmanager/ViewManagerModel'; import {button, ButtonProps} from '@xh/hoist/desktop/cmp/button'; import {switchInput} from '@xh/hoist/desktop/cmp/input'; -import {manageDialog} from '@xh/hoist/desktop/cmp/viewmanager/cmp/ManageDialog'; -import {saveDialog} from '@xh/hoist/desktop/cmp/viewmanager/cmp/SaveDialog'; +import {manageDialog} from './impl/ManageDialog'; +import {saveDialog} from './impl/SaveDialog'; import {Icon} from '@xh/hoist/icon'; import {menu, menuDivider, menuItem, popover} from '@xh/hoist/kit/blueprint'; import {consumeEvent, pluralize} from '@xh/hoist/utils/js'; import {isEmpty} from 'lodash'; import {ReactNode} from 'react'; +/** + * Visibility options for save/revert button. + * + * 'never' to hide button. + * 'whenDirty' to only show when persistence state is dirty and button is therefore enabled. + * 'always' will always show button, unless autoSave is active. + * + * Note that we never show the button when 'autoSave' is active because it would never be enabled + * for more than a flash. + */ +export type ViewManagerStateButtonMode = 'whenDirty' | 'always' | 'never'; + export interface ViewManagerProps extends HoistProps { menuButtonProps?: Partial; saveButtonProps?: Partial; - /** 'whenDirty' to only show saveButton when persistence state is dirty. (Default 'whenDirty') */ - showSaveButton?: 'whenDirty' | 'always' | 'never'; + revertButtonProps?: Partial; + + /** Default 'whenDirty' */ + showSaveButton?: ViewManagerStateButtonMode; + /** Default 'never' */ + showRevertButton?: ViewManagerStateButtonMode; + /** True to render private views in sub-menu (Default false)*/ showPrivateViewsInSubMenu?: boolean; /** True to render shared views in sub-menu (Default false)*/ @@ -40,7 +57,9 @@ export const [ViewManager, viewManager] = hoistCmp.withFactory className, menuButtonProps, saveButtonProps, + revertButtonProps, showSaveButton = 'whenDirty', + showRevertButton = 'never', showPrivateViewsInSubMenu = false, showSharedViewsInSubMenu = false }: ViewManagerProps) { @@ -55,8 +74,12 @@ export const [ViewManager, viewManager] = hoistCmp.withFactory popoverClassName: 'xh-view-manager__popover' }), saveButton({ - showSaveButton, + mode: showSaveButton, ...saveButtonProps + }), + revertButton({ + mode: showRevertButton, + ...revertButtonProps }) ] }), @@ -74,7 +97,7 @@ const menuButton = hoistCmp.factory({ const {selectedView, DisplayName} = model; return button({ className: 'xh-view-manager__menu-button', - text: model.getHierarchyDisplayName(selectedView?.name) ?? `Default ${DisplayName}`, + text: selectedView?.shortName ?? `Default ${DisplayName}`, icon: Icon.bookmark(), rightIcon: Icon.chevronDown(), outlined: true, @@ -84,86 +107,109 @@ const menuButton = hoistCmp.factory({ }); const saveButton = hoistCmp.factory({ - render({model, showSaveButton, ...rest}) { - if ( - !model.canShowSaveButton || - showSaveButton === 'never' || - (showSaveButton === 'whenDirty' && !model.isDirty) - ) { - return null; - } - + render({model, mode, ...rest}) { + if (hideStateButton(model, mode)) return null; return button({ className: 'xh-view-manager__save-button', icon: Icon.save(), tooltip: `Save changes to this ${model.displayName}`, intent: 'primary', - disabled: !model.canSave, - onClick: () => model.saveAsync(false).linkTo(model.loadModel), + disabled: !model.isDirty, + onClick: () => { + model.canSave ? model.saveAsync() : model.saveAsAsync(); + }, + ...rest + }); + } +}); + +const revertButton = hoistCmp.factory({ + render({model, mode, ...rest}) { + if (hideStateButton(model, mode)) return null; + return button({ + className: 'xh-view-manager__revert-button', + icon: Icon.reset(), + tooltip: `Revert changes to this ${model.displayName}`, + intent: 'danger', + disabled: !model.isDirty, + onClick: () => model.resetAsync(), ...rest }); } }); +function hideStateButton(model: ViewManagerModel, mode: ViewManagerStateButtonMode): boolean { + return mode === 'never' || (mode === 'whenDirty' && !model.isDirty) || model.canAutoSave; +} + const viewMenu = hoistCmp.factory({ render({model, showPrivateViewsInSubMenu, showSharedViewsInSubMenu}) { - const {DisplayName} = model, - pluralDisp = pluralize(DisplayName), - items = []; + const { + autoSaveUnavailableReason, + enableDefault, + canSave, + selectedToken, + enableAutoSave, + DisplayName, + autoSave, + privateViewTree, + sharedViewTree, + favoriteViews, + views, + isDirty + } = model; - if (!isEmpty(model.favoriteViews)) { + const pluralDisp = pluralize(DisplayName), + items = []; + if (!isEmpty(favoriteViews)) { items.push( menuDivider({title: 'Favorites'}), - ...model.favoriteViews.map(it => { + ...favoriteViews.map(it => { return menuItem({ key: `${it.token}-favorite`, icon: model.selectedToken === it.token ? Icon.check() : Icon.placeholder(), text: menuItemTextAndFaveToggle({ - view: {...it, text: model.getHierarchyDisplayName(it.name)} + view: {...it, text: it.shortName} }), - onClick: () => model.selectViewAsync(it.token).linkTo(model.loadModel), + onClick: () => model.selectViewAsync(it.token), title: it.description }); }) ); } - if (!isEmpty(model.privateViewTree)) { + if (!isEmpty(privateViewTree)) { if (showPrivateViewsInSubMenu) { items.push( menuDivider({omit: isEmpty(items)}), menuItem({ text: `My ${pluralDisp}`, shouldDismissPopover: false, - children: model.privateViewTree.map(it => { - return buildMenuItem(it, model); - }) + items: privateViewTree.map(it => buildMenuItem(it, model)) }) ); } else { items.push( menuDivider({title: `My ${pluralDisp}`}), - ...model.privateViewTree.map(it => buildMenuItem(it, model)) + ...privateViewTree.map(it => buildMenuItem(it, model)) ); } } - if (!isEmpty(model.sharedViewTree)) { + if (!isEmpty(sharedViewTree)) { if (showSharedViewsInSubMenu) { items.push( menuDivider({omit: isEmpty(items)}), menuItem({ text: `Shared ${pluralDisp}`, shouldDismissPopover: false, - children: model.sharedViewTree.map(it => { - return buildMenuItem(it, model); - }) + items: sharedViewTree.map(it => buildMenuItem(it, model)) }) ); } else { items.push( menuDivider({title: `Shared ${pluralDisp}`}), - ...model.sharedViewTree.map(it => buildMenuItem(it, model)) + ...sharedViewTree.map(it => buildMenuItem(it, model)) ); } } @@ -172,19 +218,19 @@ const viewMenu = hoistCmp.factory({ className: 'xh-view-manager__menu', items: [ ...items, - menuDivider({omit: !model.enableDefault || isEmpty(items)}), + menuDivider({omit: !enableDefault || isEmpty(items)}), menuItem({ - icon: model.selectedToken ? Icon.placeholder() : Icon.check(), + icon: selectedToken ? Icon.placeholder() : Icon.check(), text: `Default ${DisplayName}`, - omit: !model.enableDefault, + omit: !enableDefault, onClick: () => model.selectViewAsync(null) }), menuDivider(), menuItem({ icon: Icon.save(), text: 'Save', - disabled: !model.canSave, - onClick: () => model.saveAsync(false) + disabled: !canSave || !isDirty, + onClick: () => model.saveAsync() }), menuItem({ icon: Icon.copy(), @@ -193,26 +239,27 @@ const viewMenu = hoistCmp.factory({ }), menuItem({ icon: Icon.reset(), - text: `Revert ${DisplayName}`, - disabled: !model.isDirty, + text: `Revert`, + disabled: !isDirty, onClick: () => model.resetAsync() }), - menuDivider({omit: !model.enableAutoSave}), + menuDivider({omit: !enableAutoSave}), menuItem({ - omit: !model.enableAutoSave, + omit: !enableAutoSave, text: switchInput({ label: 'Auto Save', - bind: 'autoSaveActive', - inline: true, - disabled: !model.enableAutoSaveToggle + value: !autoSaveUnavailableReason && autoSave, + disabled: !!autoSaveUnavailableReason, + onChange: v => (model.autoSave = v), + inline: true }), - title: model.disabledAutoSaveReason, + title: autoSaveUnavailableReason, shouldDismissPopover: false }), menuDivider(), menuItem({ icon: Icon.gear(), - disabled: isEmpty(model.views), + disabled: isEmpty(views), text: `Manage ${pluralDisp}...`, onClick: () => model.openManageDialog() }) @@ -231,7 +278,7 @@ function buildMenuItem(viewOrFolder: ViewTree, model: ViewManagerModel): ReactNo text, icon, shouldDismissPopover: false, - children: viewOrFolder.items + items: viewOrFolder.items ? viewOrFolder.items.map(child => buildMenuItem(child, model)) : [] }); @@ -242,7 +289,7 @@ function buildMenuItem(viewOrFolder: ViewTree, model: ViewManagerModel): ReactNo icon, text: menuItemTextAndFaveToggle({model, view: viewOrFolder}), title: viewOrFolder.description, - onClick: () => model.selectViewAsync(viewOrFolder.token).linkTo(model.loadModel) + onClick: () => model.selectViewAsync(viewOrFolder.token) }); } } diff --git a/desktop/cmp/viewmanager/cmp/ManageDialog.ts b/desktop/cmp/viewmanager/impl/ManageDialog.ts similarity index 99% rename from desktop/cmp/viewmanager/cmp/ManageDialog.ts rename to desktop/cmp/viewmanager/impl/ManageDialog.ts index dc25f3ea8..934f18d69 100644 --- a/desktop/cmp/viewmanager/cmp/ManageDialog.ts +++ b/desktop/cmp/viewmanager/impl/ManageDialog.ts @@ -59,7 +59,7 @@ const formPanel = hoistCmp.factory({ isOwnView = values.owner === XH.getUsername(); if (model.hasMultiSelection) { - return multiSelectionPanel(); + return multiSelectionPanel({onClose}); } if (!model.selectedId) diff --git a/desktop/cmp/viewmanager/cmp/SaveDialog.ts b/desktop/cmp/viewmanager/impl/SaveDialog.ts similarity index 100% rename from desktop/cmp/viewmanager/cmp/SaveDialog.ts rename to desktop/cmp/viewmanager/impl/SaveDialog.ts diff --git a/desktop/cmp/viewmanager/index.ts b/desktop/cmp/viewmanager/index.ts index 764b8f961..df8c5416b 100644 --- a/desktop/cmp/viewmanager/index.ts +++ b/desktop/cmp/viewmanager/index.ts @@ -1,3 +1 @@ export * from './ViewManager'; -export * from './cmp/ManageDialog'; -export * from './cmp/SaveDialog'; diff --git a/svc/JsonBlobService.ts b/svc/JsonBlobService.ts index 0324f6889..023bdc72c 100644 --- a/svc/JsonBlobService.ts +++ b/svc/JsonBlobService.ts @@ -58,15 +58,12 @@ export class JsonBlobService extends HoistService { } /** Retrieve all blobs of a particular type that are visible to the current user. */ - async listAsync({ - type, - includeValue, - loadSpec - }: { + async listAsync(spec: { type: string; includeValue?: boolean; loadSpec?: LoadSpec; - }) { + }): Promise { + const {type, includeValue, loadSpec} = spec; return XH.fetchJson({ url: 'xh/listJsonBlobs', params: {type, includeValue},