From 9b02eade184594d35f3a52e93b443498332636da Mon Sep 17 00:00:00 2001 From: Ryanseanlee Date: Tue, 3 Sep 2024 22:35:00 -0700 Subject: [PATCH 01/19] Persistence Manager --- .../PersistenceManager.scss | 30 ++ .../persistenceManager/PersistenceManager.ts | 214 +++++++++++ .../PersistenceManagerModel.ts | 345 ++++++++++++++++++ .../persistenceManager/impl/ManageDialog.ts | 160 ++++++++ .../impl/ManageDialogModel.ts | 227 ++++++++++++ .../cmp/persistenceManager/impl/SaveDialog.ts | 91 +++++ .../impl/SaveDialogModel.ts | 86 +++++ desktop/cmp/persistenceManager/index.ts | 2 + svc/JsonBlobService.ts | 17 +- 9 files changed, 1168 insertions(+), 4 deletions(-) create mode 100644 desktop/cmp/persistenceManager/PersistenceManager.scss create mode 100644 desktop/cmp/persistenceManager/PersistenceManager.ts create mode 100644 desktop/cmp/persistenceManager/PersistenceManagerModel.ts create mode 100644 desktop/cmp/persistenceManager/impl/ManageDialog.ts create mode 100644 desktop/cmp/persistenceManager/impl/ManageDialogModel.ts create mode 100644 desktop/cmp/persistenceManager/impl/SaveDialog.ts create mode 100644 desktop/cmp/persistenceManager/impl/SaveDialogModel.ts create mode 100644 desktop/cmp/persistenceManager/index.ts diff --git a/desktop/cmp/persistenceManager/PersistenceManager.scss b/desktop/cmp/persistenceManager/PersistenceManager.scss new file mode 100644 index 0000000000..9e5135f46a --- /dev/null +++ b/desktop/cmp/persistenceManager/PersistenceManager.scss @@ -0,0 +1,30 @@ +.persistence-manager { + align-items: center; + + // Save Button + & > .xh-button { + margin-right: var(--xh-pad-half-px); + } + + // Dialogs + &__manage-dialog, + &__save-as-dialog { + &__form { + padding: var(--xh-pad-px); + + .xh-form-field.xh-form-field-readonly .xh-form-field-readonly-display { + padding: 0; + } + + .xh-form-field .xh-form-field-info { + line-height: 1.5em; + margin-top: var(--xh-pad-half-px); + white-space: unset; + + .xh-icon { + margin-right: 2px; + } + } + } + } +} diff --git a/desktop/cmp/persistenceManager/PersistenceManager.ts b/desktop/cmp/persistenceManager/PersistenceManager.ts new file mode 100644 index 0000000000..282d03911c --- /dev/null +++ b/desktop/cmp/persistenceManager/PersistenceManager.ts @@ -0,0 +1,214 @@ +import {div, fragment, hbox} from '@xh/hoist/cmp/layout'; +import {hoistCmp, HoistProps, PlainObject, uses} from '@xh/hoist/core'; +import {button} from '@xh/hoist/desktop/cmp/button'; +import {Icon} from '@xh/hoist/icon/Icon'; +import {menu, menuDivider, menuItem, popover} from '@xh/hoist/kit/blueprint'; +import {groupBy, keys, sortBy} from 'lodash'; +import {ReactNode} from 'react'; +import {manageDialog} from './impl/ManageDialog'; +import {saveDialog} from './impl/SaveDialog'; +import './PersistenceManager.scss'; +import {PersistenceManagerModel} from './PersistenceManagerModel'; + +interface PersistenceManagerModelProps extends HoistProps { + model?: PersistenceManagerModel; + /** True to disable options for saving/managing items. */ + minimal?: boolean; +} + +export const [PersistenceManager, persistenceManager] = + hoistCmp.withFactory({ + displayName: 'PersistenceManager', + model: uses(PersistenceManagerModel), + + render({model, minimal = false}) { + const { + selectedObject, + isShared, + capitalPluralNoun, + manageDialogModel, + saveDialogModel + } = model; + + return fragment( + hbox({ + className: 'persistence-manager', + items: [ + popover({ + item: button({ + text: selectedObject?.name + ? getHierarchyDisplayName(selectedObject.name) + : capitalPluralNoun, + icon: isShared ? Icon.users() : Icon.bookmark(), + rightIcon: Icon.chevronDown(), + outlined: true + }), + content: div({ + items: [ + div({className: 'xh-popup__title', item: capitalPluralNoun}), + objMenu({minimal}) + ] + }), + placement: 'bottom-start' + }), + saveButton() + ] + }), + manageDialogModel ? manageDialog({key: manageDialogModel.xhId}) : null, + saveDialogModel ? saveDialog({key: saveDialogModel.xhId}) : null + ); + } + }); + +//------------------------ +// Implementation +//------------------------ + +const saveButton = hoistCmp.factory({ + render({model}) { + return button({ + icon: Icon.save(), + tooltip: `Save changes to this ${model.noun}`, + intent: 'primary', + omit: !model.enableTopLevelSaveButton || !model.canSave, + onClick: () => model.saveAsync(null).linkTo(model.loadModel) + }); + } +}); + +const objMenu = hoistCmp.factory({ + render({model, minimal}) { + const {pluralNoun, objects, loadModel} = model, + grouped = groupBy(objects, it => it.group), + sortedGroupKeys = keys(grouped).sort(), + items = []; + + sortedGroupKeys.forEach(group => { + items.push(menuDivider({title: group})); + items.push(...hierarchicalMenus(sortBy(grouped[group], 'name'))); + }); + + return menu({ + items: [ + ...items, + fragment({ + omit: minimal, + items: [ + menuDivider(), + menuItem({ + icon: Icon.plus(), + text: 'New...', + onClick: () => model.createNewAsync().linkTo(loadModel) + }), + menuItem({ + icon: Icon.save(), + text: 'Save', + disabled: !model.canSave, + onClick: () => model.saveAsync(null).linkTo(loadModel) + }), + menuItem({ + icon: Icon.copy(), + text: 'Save as...', + onClick: () => model.saveAsAsync().linkTo(loadModel) + }), + menuItem({ + icon: Icon.reset(), + text: 'Reset', + disabled: !model.isDirty, + onClick: () => model.resetAsync().linkTo(loadModel) + }), + menuDivider(), + menuItem({ + icon: Icon.gear(), + text: `Manage ${pluralNoun}...`, + onClick: () => model.openManageDialog() + }) + ] + }) + ] + }); + } +}); + +/** + * @param records + * @param depth used during recursion, depth in the path string/hierarchy + * @returns an array of menuItem()s + */ +function hierarchicalMenus(records: PlainObject[], depth: number = 0): ReactNode[] { + const groups = {}, + unbalancedStableGroupsAndRecords = []; + + records.forEach(record => { + // Leaf Node + if (getNameHierarchySubstring(record.name, depth + 1) == null) { + unbalancedStableGroupsAndRecords.push(record); + return; + } + // Belongs to an already defined group + const group = getNameHierarchySubstring(record.name, depth); + if (groups[group]) { + groups[group].children.push(record); + return; + } + // Belongs to a not defined group, create it + groups[group] = {name: group, children: [record], isMenuFolder: true}; + unbalancedStableGroupsAndRecords.push(groups[group]); + }); + + return unbalancedStableGroupsAndRecords.map(it => { + if (it.isMenuFolder) { + return objMenuFolder({ + name: it.name, + items: hierarchicalMenus(it.children, depth + 1), + depth + }); + } + return objMenuItem({record: it}); + }); +} + +const objMenuFolder = hoistCmp.factory({ + render({model, name, depth, children}) { + const selected = isFolderForEntry(name, model.selectedObject?.name, depth), + icon = selected ? Icon.check() : Icon.placeholder(); + return menuItem({ + text: getHierarchyDisplayName(name), + icon, + shouldDismissPopover: false, + children + }); + } +}); + +const objMenuItem = hoistCmp.factory({ + render({model, record}) { + const {id, name} = record, + selected = model.selectedId === id, + icon = selected ? Icon.check() : Icon.placeholder(); + + return menuItem({ + key: id, + icon: icon, + text: getHierarchyDisplayName(name), + onClick: () => model.selectAsync(id).linkTo(model.loadModel) + }); + } +}); + +function isFolderForEntry(folderName, entryName, depth) { + const name = getNameHierarchySubstring(entryName, depth); + return name && name === folderName && folderName.length < entryName.length; +} + +function getNameHierarchySubstring(name, depth) { + const arr = name?.split('\\') ?? []; + if (arr.length <= depth) { + return null; + } + return arr.slice(0, depth + 1).join('\\'); +} + +function getHierarchyDisplayName(name) { + return name?.substring(name.lastIndexOf('\\') + 1); +} diff --git a/desktop/cmp/persistenceManager/PersistenceManagerModel.ts b/desktop/cmp/persistenceManager/PersistenceManagerModel.ts new file mode 100644 index 0000000000..b4008b6224 --- /dev/null +++ b/desktop/cmp/persistenceManager/PersistenceManagerModel.ts @@ -0,0 +1,345 @@ +import { + HoistModel, + LoadSpec, + managed, + PersistenceProvider, + PersistOptions, + PlainObject, + Thunkable, + XH +} from '@xh/hoist/core'; +import {StoreRecordId} from '@xh/hoist/data/StoreRecord'; +import {action, bindable, computed, makeObservable, observable} from '@xh/hoist/mobx'; +import {executeIfFunction, pluralize, throwIf} from '@xh/hoist/utils/js'; +import {capitalize, cloneDeep, isEmpty, isEqualWith, isFunction, isNil} from 'lodash'; +import {ManageDialogModel} from './impl/ManageDialogModel'; +import {ObjStub, SaveDialogModel} from './impl/SaveDialogModel'; +import {runInAction} from 'mobx'; + +/** + * PersistenceManager provides re-usable loading, selection, and user management of named configs, which are modelled + * and persisted on the server as databased domain objects extending the `PersistedObject` trait. + * + * These generic configs are intended for specific use cases such as saved Grid views, Dashboards, and data import + * mapping configs. This model loads all available views from a configured endpoint and exposes a `provider` property + * that can be passed to any HoistModel that takes a `persistWith` config. + * + * Objects managed by this system can be private or shared based on a user's company and/or app roles. The `acl` field + * on each persisted object determines if and how it is shared. Users with the configured `adminRole` can share saved + * objects to all app users and save changes to shared objects directly. Users with the configured `editorRole` can + * share objects to their own company only and modify shared objects shared only within their company. All users can + * save/update/delete their own private objects. + */ +export interface PersistenceManagerProps { + // /** Endpoint for rest controller. */ + // url: string; + + /** Key used in JsonBlob */ + type: string; + /** User-facing name/label for an object managed by this model. */ + noun: string; + /** Whether user can publish or edit globally shared objects. */ + canManageGlobal: Thunkable; + /** Async callback triggered when view changes. Should be used to recreate the affected models. */ + onChangeAsync: (value: PlainObject) => void; + /** Used to persist this model's selected ID. */ + persistWith: PersistOptions; + /** True (default) to render a save button alongside the primary menu button when dirty. */ + enableTopLevelSaveButton?: boolean; + /** Fn to produce a new, empty object - can be async. */ + newObjectFnAsync?: () => PlainObject; +} + +export class PersistenceManagerModel extends HoistModel { + //------------------------ + // Persistence Provider + // Pass this to models that implement `persistWith` to include their state in the view. + //------------------------ + provider: PersistOptions = { + getData: () => cloneDeep(this.pendingValue ?? this.value ?? {}), + setData: value => this.mergePendingValue(value) + }; + + @observable.ref @managed manageDialogModel: ManageDialogModel; + + @observable.ref @managed saveDialogModel: SaveDialogModel; + + enableTopLevelSaveButton: boolean = true; + + newObjectFn: () => PlainObject; + + /** Current state of the active object, can include not-yet-persisted changes. */ + @observable.ref pendingValue: PlainObject = null; + + @observable.ref views: PlainObject[] = []; + + @bindable selectedId: StoreRecordId; + + /** Reference Key used to query JsonBlobs */ + type: string; + + /** Configured word to label an object persisted by this model. */ + noun: string; + + get pluralNoun(): string { + return pluralize(this.noun); + } + + get capitalNoun(): string { + return capitalize(this.noun); + } + + get capitalPluralNoun(): string { + return capitalize(this.pluralNoun); + } + + private readonly canManageGlobalFn: Thunkable; + + get canManageGlobal(): boolean { + return executeIfFunction(this.canManageGlobalFn); + } + + onChangeAsync?: (value: PlainObject) => void; + + get value(): PlainObject { + return this.selectedObject?.value; + } + + get objects(): PlainObject[] { + return this.views; + } + + get selectedObject(): PlainObject { + return this.views.find(it => it.id === this.selectedId); + } + + @computed + get canSave(): boolean { + const {value} = this; + if (!value) return false; + return ( + this.isDirty && (this.canManageGlobal || value.isShared) && !this.loadModel.isPending + ); + } + + @computed + get isDirty(): boolean { + return !this.isEqualSkipAutosize(this.pendingValue, this.value); + } + + get isShared(): boolean { + return !!this.selectedObject?.isShared; + } + + // Internal persistence provider, used to save *this* model's state, i.e. selectedId + _provider; + + constructor({ + type, + noun, + onChangeAsync, + persistWith, + canManageGlobal, + enableTopLevelSaveButton = true, + newObjectFnAsync + }: PersistenceManagerProps) { + super(); + makeObservable(this); + + throwIf( + !isFunction(onChangeAsync), + 'PersistenceManagerModel requires an `onChangeAsync` callback function' + ); + + this.type = type; + this.canManageGlobalFn = canManageGlobal; + this.enableTopLevelSaveButton = enableTopLevelSaveButton; + this.noun = noun || 'item'; + this.newObjectFn = newObjectFnAsync ?? (() => ({})); + this.onChangeAsync = onChangeAsync; + + // Set up internal PersistenceProvider -- fail gently + if (persistWith) { + try { + this._provider = PersistenceProvider.create({ + path: 'persistenceManager', + ...persistWith + }); + + const state = this._provider.read(); + if (state?.selectedId) this.selectedId = state.selectedId; + this.addReaction({ + track: () => this.selectedId, + run: selectedId => this._provider.write({selectedId}) + }); + } catch (e) { + this.logError('Error applying persistWith', persistWith, e); + XH.safeDestroy(this._provider); + this._provider = null; + } + } + + this.loadAsync(); + } + + // TODO - Carefully review if this method needs isStale checks, and how to properly implement them. + override async doLoadAsync(loadSpec: LoadSpec) { + const rawViews = await XH.jsonBlobService.listAsync({type: this.type, includeValue: true}); + + runInAction(() => (this.views = this.processRaw(rawViews))); + + const {objects} = this; + // Auto-create an empty view if required + if (!objects.length) { + const newValue = await this.newObjectFn(), + newObject = await XH.jsonBlobService.createAsync({ + type: this.type, + name: `My ${this.capitalNoun}`, + value: newValue + }); + runInAction(() => (this.views = this.processRaw([newObject]))); + } + + // Always call selectAsync to ensure pendingValue updated and onChangeAsync callback fired if needed + const id = this.selectedObject?.id ?? this.objects[0].id; + await this.selectAsync(id); + } + + async selectAsync(id: StoreRecordId) { + this.selectedId = id; + if (!this.isDirty) return; + + const {value} = this; + + this.setPendingValue(value); + await this.onChangeAsync(value); + + // If current value is empty, we know it is the auto-created default view - + // save it to capture the default state. + if (isEmpty(value)) { + await this.saveAsync(true); + } + } + + async saveAsync(skipToast: boolean = false) { + const {token, id} = this.selectedObject; + if (this.isShared) { + if (!(await this.confirmShareObjSaveAsync())) return; + } + await XH.jsonBlobService.updateAsync(token, { + ...this.selectedObject, + value: this.pendingValue + }); + await this.refreshAsync(); + await this.selectAsync(id); + + if (!skipToast) XH.successToast(`${capitalize(this.noun)} successfully saved.`); + } + + async saveAsAsync() { + const {name, description} = this.selectedObject; + + this.openSaveDialog({ + name, + description, + value: this.pendingValue, + isAdd: false + }); + } + + async createNewAsync() { + const {name, description} = this.selectedObject, + newValue = await this.newObjectFn(); + + this.openSaveDialog({ + name, + description, + value: newValue, + isAdd: true + }); + } + + @action + openSaveDialog(objStub: ObjStub) { + this.saveDialogModel = new SaveDialogModel(this, objStub); + } + + @action + closeSaveDialog() { + const {saveDialogModel} = this; + this.saveDialogModel = null; + XH.safeDestroy(saveDialogModel); + } + + async resetAsync() { + return this.selectAsync(this.selectedId); + } + + @action + openManageDialog() { + this.manageDialogModel = new ManageDialogModel(this); + } + + @action + closeManageDialog() { + const {manageDialogModel} = this; + this.manageDialogModel = null; + XH.safeDestroy(manageDialogModel); + this.refreshAsync(); + } + + //------------------ + // Implementation + //------------------ + mergePendingValue(value: PlainObject) { + value = {...this.pendingValue, ...this.cleanValue(value)}; + this.setPendingValue(value); + } + + processRaw(raw: PlainObject): PlainObject[] { + const {capitalPluralNoun: noun} = this; + return raw.map(it => { + it.isShared = it.acl === '*'; + const group = it.isShared ? `Shared ${noun}` : `My ${noun}`; + return {...it, group}; + }); + } + + @action + setPendingValue(value: PlainObject) { + value = this.cleanValue(value); + if (!this.isEqualSkipAutosize(this.pendingValue, value)) { + this.pendingValue = value; + } + } + + cleanValue(value: PlainObject): PlainObject { + // Stringify and parse to ensure that the value is valid JSON + // (i.e. no object instances, no keys with undefined values, etc.) + return JSON.parse(JSON.stringify(value)); + } + + isEqualSkipAutosize(a, b) { + // Skip spurious column autosize differences between states + const comparer = (aVal, bVal, key, aObj) => { + if (key === 'width' && !isNil(aObj.colId) && !aObj.manuallySized) return true; + return undefined; + }; + return isEqualWith(a, b, comparer); + } + + async confirmShareObjSaveAsync() { + return XH.confirm({ + message: `You are saving a shared public ${this.noun}. Do you wish to continue?`, + confirmProps: { + text: 'Yes, save changes', + intent: 'primary', + outlined: true + }, + cancelProps: { + text: 'Cancel', + autoFocus: true + } + }); + } +} diff --git a/desktop/cmp/persistenceManager/impl/ManageDialog.ts b/desktop/cmp/persistenceManager/impl/ManageDialog.ts new file mode 100644 index 0000000000..0c1a459a0f --- /dev/null +++ b/desktop/cmp/persistenceManager/impl/ManageDialog.ts @@ -0,0 +1,160 @@ +import {form} from '@xh/hoist/cmp/form'; +import {grid} from '@xh/hoist/cmp/grid'; +import {br, div, filler, fragment, hframe, placeholder, spacer, vframe} from '@xh/hoist/cmp/layout'; +import {hoistCmp, uses, XH} from '@xh/hoist/core'; +import {button} from '@xh/hoist/desktop/cmp/button'; +import {formField} from '@xh/hoist/desktop/cmp/form'; +import {switchInput, textArea, textInput} from '@xh/hoist/desktop/cmp/input'; +import {mask} from '@xh/hoist/desktop/cmp/mask'; +import {panel} from '@xh/hoist/desktop/cmp/panel'; +import {toolbar} from '@xh/hoist/desktop/cmp/toolbar'; +import {fmtCompactDate} from '@xh/hoist/format'; +import {Icon} from '@xh/hoist/icon'; +import {dialog} from '@xh/hoist/kit/blueprint'; +import {ManageDialogModel} from './ManageDialogModel'; + +export const manageDialog = hoistCmp.factory({ + displayName: 'ManageDialog', + model: uses(ManageDialogModel), + + render({model}) { + return dialog({ + isOpen: true, + icon: Icon.gear(), + title: `Manage ${model.parentModel.capitalPluralNoun}`, + className: 'persistence-manager__manage-dialog', + style: {width: 800, height: 475}, + canOutsideClickClose: false, + onClose: () => model.close(), + item: hframe(gridPanel(), formPanel(), mask({bind: model.loadModel, spinner: true})) + }); + } +}); + +const gridPanel = hoistCmp.factory({ + render() { + return panel({ + className: 'persistence-manager__manage-dialog__grid-panel', + modelConfig: {defaultSize: 350, side: 'left', collapsible: false}, + item: grid() + }); + } +}); + +const formPanel = hoistCmp.factory({ + render({model}) { + const {selectedId, noun, pluralNoun, formModel, canEdit} = model, + {values} = formModel; + + if (!selectedId) + return panel({ + item: placeholder(`Select a ${noun}`), + bbar: [ + filler(), + button({ + text: 'Close', + onClick: () => model.close() + }) + ] + }); + + return panel({ + className: 'persistence-manager__manage-dialog__form-panel', + item: vframe({ + className: 'persistence-manager__manage-dialog__form', + items: [ + form({ + fieldDefaults: { + inline: true, + minimal: true, + commitOnChange: true + }, + items: [ + formField({ + field: 'name', + item: textInput(), + info: canEdit + ? fragment( + Icon.info(), + `Organize your ${pluralNoun} into folders by including the "\\" character in their names - e.g. "My folder\\My ${noun}".` + ) + : null + }), + formField({ + field: 'description', + item: textArea({ + selectOnFocus: true, + height: 70 + }), + readonlyRenderer: v => (v ? v : '[None]') + }), + formField({ + field: 'isShared', + item: switchInput({ + labelSide: 'left' + }), + omit: !model.canManageGlobal + }) + ] + }), + spacer({ + height: 10, + omit: !model.showSaveButton + }), + button({ + icon: Icon.check(), + text: 'Save Changes', + intent: 'success', + minimal: false, + style: {margin: '0 20px'}, + omit: !model.showSaveButton, + onClick: () => model.saveAsync() + }), + filler(), + div({ + items: [ + `Created ${fmtCompactDate(values.dateCreated)} by ${ + values.owner === XH.getUsername() ? 'you' : values.owner + }.`, + br(), + `Updated ${fmtCompactDate(values.lastUpdated)} by ${ + values.lastUpdatedBy === XH.getUsername() + ? 'you' + : values.lastUpdatedBy + }.` + ], + className: 'xh-text-color-muted' + }) + ] + }), + bbar: bbar() + }); + } +}); + +const bbar = hoistCmp.factory({ + render({model}) { + const {formModel} = model; + return toolbar( + button({ + icon: Icon.delete(), + text: 'Delete', + intent: 'danger', + disabled: !model.canDelete, + onClick: () => model.deleteAsync() + }), + filler(), + button({ + icon: Icon.reset(), + tooltip: 'Revert changes', + omit: !model.showSaveButton, + onClick: () => formModel.reset() + }), + '-', + button({ + text: 'Close', + onClick: () => model.close() + }) + ); + } +}); diff --git a/desktop/cmp/persistenceManager/impl/ManageDialogModel.ts b/desktop/cmp/persistenceManager/impl/ManageDialogModel.ts new file mode 100644 index 0000000000..29e6024598 --- /dev/null +++ b/desktop/cmp/persistenceManager/impl/ManageDialogModel.ts @@ -0,0 +1,227 @@ +import {FormModel} from '@xh/hoist/cmp/form'; +import {GridAutosizeMode, GridModel} from '@xh/hoist/cmp/grid'; +import {HoistModel, LoadSpec, managed, XH} from '@xh/hoist/core'; +import {lengthIs, required} from '@xh/hoist/data'; +import {Icon} from '@xh/hoist/icon'; +import {makeObservable, observable} from '@xh/hoist/mobx'; +import {wait} from '@xh/hoist/promise'; +import {PersistenceManagerModel} from '../PersistenceManagerModel'; + +export class ManageDialogModel extends HoistModel { + parentModel: PersistenceManagerModel; + + @observable isOpen: boolean = false; + + @managed gridModel: GridModel; + + @managed formModel: FormModel; + + get noun(): string { + return this.parentModel.noun; + } + + get pluralNoun(): string { + return this.parentModel.pluralNoun; + } + + get selectedId(): string { + return this.gridModel.selectedId as string; + } + + get selIsShared(): boolean { + return this.gridModel.selectedRecord?.data.isShared ?? false; + } + + get userCreated(): boolean { + return this.gridModel.selectedRecord?.data.createdBy === XH.getUser().username; + } + + get canDelete(): boolean { + return this.parentModel.objects.length > 1 && (this.canManageGlobal || this.selIsShared); + } + + get canEdit(): boolean { + return this.canManageGlobal || this.selIsShared; + } + + get showSaveButton(): boolean { + const {formModel} = this; + return formModel.isDirty && !formModel.readonly && !this.loadModel.isPending; + } + + /** True if the selected object would end up shared to all users if saved. */ + get willBeGlobal(): boolean { + return this.formModel.values.isGlobal; + } + + get canManageGlobal(): boolean { + return this.parentModel.canManageGlobal; + } + + constructor(parentModel: PersistenceManagerModel) { + super(); + makeObservable(this); + + this.parentModel = parentModel; + this.gridModel = this.createGridModel(); + this.formModel = this.createFormModel(); + + const {gridModel, formModel} = this; + this.addReaction({ + track: () => gridModel.selectedRecord, + run: record => { + if (record) { + formModel.readonly = !this.canEdit; + formModel.init({ + ...record.data + }); + } + } + }); + + wait() + .then(() => this.loadAsync()) + .then(() => this.ensureGridHasSelection()); + } + + close() { + this.parentModel.closeManageDialog(); + } + + async saveAsync() { + return this.doSaveAsync().linkTo(this.loadModel).catchDefault(); + } + + async deleteAsync() { + return this.doDeleteAsync().linkTo(this.loadModel).catchDefault(); + } + + //------------------------ + // Implementation + //------------------------ + + override async doLoadAsync(loadSpec: LoadSpec) { + await this.parentModel.loadAsync(loadSpec); + const {objects, selectedObject, views} = this.parentModel; + this.gridModel.loadData(views); + const id = selectedObject?.id ?? objects[0].id; + await this.gridModel.selectAsync(id); + this.formModel.init({ + ...this.gridModel.selectedRecord.data + }); + } + + async doSaveAsync() { + const {formModel, noun, canManageGlobal, selectedId} = this, + {fields, isDirty} = formModel, + {name, description, isShared} = formModel.getData(), + isValid = await formModel.validateAsync(); + + if (!isValid || !selectedId || !isDirty) return; + + // Additional sanity-check before POSTing an update - non-admins should never be modifying global views. + if (isShared && !canManageGlobal) + throw XH.exception( + `Cannot save changes to globally-shared ${noun} - missing required permission.` + ); + + if (fields.isShared.isDirty) { + const confirmed = await XH.confirm({ + message: isShared + ? `This will share the selected ${noun} with ALL other users.` + : `The selected ${noun} will no longer be available to all other users.` + }); + + if (!confirmed) return; + } + + await XH.jsonBlobService.updateAsync(this.gridModel.selectedRecord.data.token, { + name, + description, + acl: isShared ? '*' : null + }); + + await this.refreshAsync(); + } + + async doDeleteAsync() { + const {selectedRecord} = this.gridModel; + if (!selectedRecord) return; + + const {name, token} = selectedRecord.data; + const confirmed = await XH.confirm({ + title: 'Delete', + icon: Icon.delete(), + message: `Are you sure you want to delete "${name}"?` + }); + if (!confirmed) return; + + await XH.jsonBlobService.archiveAsync(token); + await this.refreshAsync(); + } + + ensureGridHasSelection() { + const {gridModel} = this; + if (gridModel.hasSelection) return; + + const {selectedObject} = this.parentModel; + if (selectedObject) { + gridModel.selModel.select(selectedObject.id); + } else { + gridModel.preSelectFirstAsync(); + } + } + + createGridModel(): GridModel { + return new GridModel({ + sortBy: 'name', + groupBy: 'group', + stripeRows: false, + rowBorders: true, + hideHeaders: true, + showGroupRowCounts: false, + store: { + fields: [ + 'token', + 'name', + 'description', + 'isShared', + {name: 'acl', type: 'json'}, + {name: 'meta', type: 'json'}, + {name: 'dateCreated', type: 'date'}, + {name: 'createdBy', type: 'string'}, + {name: 'owner', type: 'string'}, + {name: 'lastUpdatedBy', type: 'string'}, + {name: 'lastUpdated', type: 'date'} + ] + }, + autosizeOptions: {mode: GridAutosizeMode.DISABLED}, + columns: [ + { + field: 'isShared', + width: 40, + align: 'center', + headerName: Icon.globe(), + renderer: v => (v ? Icon.globe() : null), + tooltip: v => (v ? 'Shared with all users.' : '') + }, + {field: 'name', flex: true}, + {field: 'group', hidden: true} + ] + }); + } + + createFormModel(): FormModel { + return new FormModel({ + fields: [ + {name: 'name', rules: [required, lengthIs({max: 255})]}, + {name: 'description'}, + {name: 'isShared', displayName: 'Shared'}, + {name: 'owner', readonly: true}, + {name: 'dateCreated', displayName: 'Created', readonly: true}, + {name: 'lastUpdated', displayName: 'Updated', readonly: true}, + {name: 'lastUpdatedBy', displayName: 'Updated By', readonly: true} + ] + }); + } +} diff --git a/desktop/cmp/persistenceManager/impl/SaveDialog.ts b/desktop/cmp/persistenceManager/impl/SaveDialog.ts new file mode 100644 index 0000000000..873ea9977d --- /dev/null +++ b/desktop/cmp/persistenceManager/impl/SaveDialog.ts @@ -0,0 +1,91 @@ +import {form} from '@xh/hoist/cmp/form'; +import {filler, fragment, vframe} from '@xh/hoist/cmp/layout'; +import {hoistCmp, uses} from '@xh/hoist/core'; +import {button} from '@xh/hoist/desktop/cmp/button'; +import {formField} from '@xh/hoist/desktop/cmp/form'; +import {textArea, textInput} from '@xh/hoist/desktop/cmp/input'; +import {mask} from '@xh/hoist/desktop/cmp/mask'; +import {panel} from '@xh/hoist/desktop/cmp/panel'; +import {toolbar} from '@xh/hoist/desktop/cmp/toolbar'; +import {Icon} from '@xh/hoist/icon'; +import {dialog} from '@xh/hoist/kit/blueprint'; +import {SaveDialogModel} from './SaveDialogModel'; + +export const saveDialog = hoistCmp.factory({ + displayName: 'SaveDialog', + model: uses(SaveDialogModel), + + render({model}) { + const {objStub, isAdd, parentModel} = model; + return dialog({ + isOpen: true, + icon: isAdd ? Icon.plus() : Icon.copy(), + title: isAdd ? `Create new ${parentModel.capitalNoun}` : `Save "${objStub.name}" as...`, + className: 'persistence-manager__save-as-dialog', + style: {width: 500, height: 255}, + canOutsideClickClose: false, + onClose: () => model.close(), + item: fragment(formPanel(), mask({bind: model.saveTask, spinner: true})) + }); + } +}); + +const formPanel = hoistCmp.factory({ + render({model}) { + return panel({ + item: vframe({ + className: 'persistence-manager__save-as-dialog__form', + items: [ + form({ + fieldDefaults: { + inline: true, + minimal: true, + commitOnChange: true + }, + items: [ + formField({ + field: 'name', + item: textInput({ + autoFocus: true, + selectOnFocus: true, + onKeyDown: e => { + if (e.key === 'Enter') model.saveAsAsync(); + } + }) + }), + formField({ + field: 'description', + item: textArea({ + selectOnFocus: true, + height: 90 + }) + }) + ] + }) + ] + }), + bbar: bbar() + }); + } +}); + +const bbar = hoistCmp.factory({ + render({model}) { + const {formModel, isAdd} = model; + return toolbar( + filler(), + button({ + text: 'Cancel', + onClick: () => model.close() + }), + button({ + icon: isAdd ? Icon.plus() : Icon.copy(), + text: isAdd ? `Create` : 'Save as new copy', + outlined: true, + intent: 'success', + disabled: !formModel.isValid, + onClick: () => model.saveAsAsync() + }) + ); + } +}); diff --git a/desktop/cmp/persistenceManager/impl/SaveDialogModel.ts b/desktop/cmp/persistenceManager/impl/SaveDialogModel.ts new file mode 100644 index 0000000000..5fb8ea589e --- /dev/null +++ b/desktop/cmp/persistenceManager/impl/SaveDialogModel.ts @@ -0,0 +1,86 @@ +import {FormModel} from '@xh/hoist/cmp/form'; +import {HoistModel, managed, PlainObject, TaskObserver, XH} from '@xh/hoist/core'; +import {lengthIs, required} from '@xh/hoist/data'; +import {makeObservable} from '@xh/hoist/mobx'; +import {PersistenceManagerModel} from '../PersistenceManagerModel'; + +export class SaveDialogModel extends HoistModel { + parentModel: PersistenceManagerModel; + + @managed saveTask = TaskObserver.trackLast(); + + @managed formModel = new FormModel({ + fields: [ + { + name: 'name', + rules: [ + required, + lengthIs({max: 255}), + ({value}) => { + if (this.parentModel?.objects.find(it => it.name === value)) { + return `An entry with name "${value}" already exists`; + } + } + ] + }, + {name: 'description'} + ] + }); + + objStub: ObjStub; + + get isAdd(): boolean { + return !!this.objStub?.isAdd; + } + + constructor(parentModel: PersistenceManagerModel, objStub: ObjStub) { + super(); + makeObservable(this); + + this.parentModel = parentModel; + this.objStub = objStub; + this.formModel.init({ + name: this.isAdd ? `` : `${objStub.name} (COPY)`, + description: this.isAdd ? `` : objStub.description + }); + } + + close() { + this.parentModel.closeSaveDialog(); + } + + async saveAsAsync() { + return this.doSaveAsAsync().linkTo(this.saveTask).catchDefault(); + } + + //------------------------ + // Implementation + //------------------------ + async doSaveAsAsync() { + const {formModel, parentModel, objStub} = this, + {name, description} = formModel.getData(), + isValid = await formModel.validateAsync(); + + if (!isValid) return; + + const newObj = await XH.jsonBlobService + .createAsync({ + type: this.parentModel.type, + name, + description, + value: objStub.value + }) + .catchDefault(); + + await parentModel.refreshAsync(); + await parentModel.selectAsync(newObj.id); + this.close(); + } +} + +export interface ObjStub { + name: string; + description: string; + value: PlainObject; + isAdd: boolean; +} diff --git a/desktop/cmp/persistenceManager/index.ts b/desktop/cmp/persistenceManager/index.ts new file mode 100644 index 0000000000..b1ce41ef89 --- /dev/null +++ b/desktop/cmp/persistenceManager/index.ts @@ -0,0 +1,2 @@ +export * from './PersistenceManagerModel'; +export * from './PersistenceManager'; diff --git a/svc/JsonBlobService.ts b/svc/JsonBlobService.ts index a8d5f42ccd..5c8c0e40e1 100644 --- a/svc/JsonBlobService.ts +++ b/svc/JsonBlobService.ts @@ -4,7 +4,7 @@ * * Copyright © 2024 Extremely Heavy Industries Inc. */ -import {XH, HoistService} from '@xh/hoist/core'; +import {XH, HoistService, PlainObject} from '@xh/hoist/core'; /** * Service to read and set chunks of user-specific JSON persisted via Hoist Core's JSONBlob class. @@ -36,12 +36,14 @@ export class JsonBlobService extends HoistService { async createAsync({ type, name, + acl, value, meta, description }: { type: string; name: string; + acl?: string | PlainObject; description?: string; value: any; meta?: any; @@ -49,7 +51,7 @@ export class JsonBlobService extends HoistService { return XH.fetchJson({ url: 'xh/createJsonBlob', params: { - data: JSON.stringify({type, name, value, meta, description}) + data: JSON.stringify({type, name, acl, value, meta, description}) } }); } @@ -59,16 +61,23 @@ export class JsonBlobService extends HoistService { token: string, { name, + acl, value, meta, description - }: {name?: string; value?: any; meta?: any; description?: string} + }: { + name?: string; + acl?: string | PlainObject; + value?: any; + meta?: any; + description?: string; + } ) { return XH.fetchJson({ url: 'xh/updateJsonBlob', params: { token, - update: JSON.stringify({name, value, meta, description}) + update: JSON.stringify({name, acl, value, meta, description}) } }); } From b4b177c099a399a8e17f1010c0cce099ec3a2e1a Mon Sep 17 00:00:00 2001 From: Ryanseanlee Date: Thu, 5 Sep 2024 11:34:21 -0700 Subject: [PATCH 02/19] Greg CR --- .../PersistenceManager.scss | 2 +- .../persistenceManager/PersistenceManager.ts | 81 +++++++++---------- .../PersistenceManagerModel.ts | 62 +++++++------- .../persistenceManager/impl/ManageDialog.ts | 10 +-- .../impl/ManageDialogModel.ts | 16 ++-- .../cmp/persistenceManager/impl/SaveDialog.ts | 4 +- .../impl/SaveDialogModel.ts | 74 +++++++++-------- 7 files changed, 123 insertions(+), 126 deletions(-) diff --git a/desktop/cmp/persistenceManager/PersistenceManager.scss b/desktop/cmp/persistenceManager/PersistenceManager.scss index 9e5135f46a..107044c3bc 100644 --- a/desktop/cmp/persistenceManager/PersistenceManager.scss +++ b/desktop/cmp/persistenceManager/PersistenceManager.scss @@ -1,4 +1,4 @@ -.persistence-manager { +.xh-persistence-manager { align-items: center; // Save Button diff --git a/desktop/cmp/persistenceManager/PersistenceManager.ts b/desktop/cmp/persistenceManager/PersistenceManager.ts index 282d03911c..ee5c5cbddb 100644 --- a/desktop/cmp/persistenceManager/PersistenceManager.ts +++ b/desktop/cmp/persistenceManager/PersistenceManager.ts @@ -10,8 +10,7 @@ import {saveDialog} from './impl/SaveDialog'; import './PersistenceManager.scss'; import {PersistenceManagerModel} from './PersistenceManagerModel'; -interface PersistenceManagerModelProps extends HoistProps { - model?: PersistenceManagerModel; +interface PersistenceManagerModelProps extends HoistProps { /** True to disable options for saving/managing items. */ minimal?: boolean; } @@ -32,13 +31,13 @@ export const [PersistenceManager, persistenceManager] = return fragment( hbox({ - className: 'persistence-manager', + className: 'xh-persistence-manager', items: [ popover({ item: button({ - text: selectedObject?.name - ? getHierarchyDisplayName(selectedObject.name) - : capitalPluralNoun, + text: + getHierarchyDisplayName(selectedObject?.name) ?? + capitalPluralNoun, icon: isShared ? Icon.users() : Icon.bookmark(), rightIcon: Icon.chevronDown(), outlined: true @@ -54,8 +53,8 @@ export const [PersistenceManager, persistenceManager] = saveButton() ] }), - manageDialogModel ? manageDialog({key: manageDialogModel.xhId}) : null, - saveDialogModel ? saveDialog({key: saveDialogModel.xhId}) : null + manageDialog({omit: !manageDialogModel}), + saveDialog({omit: !saveDialogModel}) ); } }); @@ -71,7 +70,7 @@ const saveButton = hoistCmp.factory({ tooltip: `Save changes to this ${model.noun}`, intent: 'primary', omit: !model.enableTopLevelSaveButton || !model.canSave, - onClick: () => model.saveAsync(null).linkTo(model.loadModel) + onClick: () => model.saveAsync(false).linkTo(model.loadModel) }); } }); @@ -79,7 +78,7 @@ const saveButton = hoistCmp.factory({ const objMenu = hoistCmp.factory({ render({model, minimal}) { const {pluralNoun, objects, loadModel} = model, - grouped = groupBy(objects, it => it.group), + grouped = groupBy(objects, 'group'), sortedGroupKeys = keys(grouped).sort(), items = []; @@ -88,42 +87,38 @@ const objMenu = hoistCmp.factory({ items.push(...hierarchicalMenus(sortBy(grouped[group], 'name'))); }); + if (minimal) return menu({items}); return menu({ items: [ ...items, - fragment({ - omit: minimal, - items: [ - menuDivider(), - menuItem({ - icon: Icon.plus(), - text: 'New...', - onClick: () => model.createNewAsync().linkTo(loadModel) - }), - menuItem({ - icon: Icon.save(), - text: 'Save', - disabled: !model.canSave, - onClick: () => model.saveAsync(null).linkTo(loadModel) - }), - menuItem({ - icon: Icon.copy(), - text: 'Save as...', - onClick: () => model.saveAsAsync().linkTo(loadModel) - }), - menuItem({ - icon: Icon.reset(), - text: 'Reset', - disabled: !model.isDirty, - onClick: () => model.resetAsync().linkTo(loadModel) - }), - menuDivider(), - menuItem({ - icon: Icon.gear(), - text: `Manage ${pluralNoun}...`, - onClick: () => model.openManageDialog() - }) - ] + menuDivider(), + menuItem({ + icon: Icon.plus(), + text: 'New...', + onClick: () => model.createNewAsync().linkTo(loadModel) + }), + menuItem({ + icon: Icon.save(), + text: 'Save', + disabled: !model.canSave, + onClick: () => model.saveAsync(false).linkTo(loadModel) + }), + menuItem({ + icon: Icon.copy(), + text: 'Save as...', + onClick: () => model.saveAsAsync().linkTo(loadModel) + }), + menuItem({ + icon: Icon.reset(), + text: 'Reset', + disabled: !model.isDirty, + onClick: () => model.resetAsync().linkTo(loadModel) + }), + menuDivider(), + menuItem({ + icon: Icon.gear(), + text: `Manage ${pluralNoun}...`, + onClick: () => model.openManageDialog() }) ] }); diff --git a/desktop/cmp/persistenceManager/PersistenceManagerModel.ts b/desktop/cmp/persistenceManager/PersistenceManagerModel.ts index b4008b6224..e8a8e04e53 100644 --- a/desktop/cmp/persistenceManager/PersistenceManagerModel.ts +++ b/desktop/cmp/persistenceManager/PersistenceManagerModel.ts @@ -10,8 +10,8 @@ import { } from '@xh/hoist/core'; import {StoreRecordId} from '@xh/hoist/data/StoreRecord'; import {action, bindable, computed, makeObservable, observable} from '@xh/hoist/mobx'; -import {executeIfFunction, pluralize, throwIf} from '@xh/hoist/utils/js'; -import {capitalize, cloneDeep, isEmpty, isEqualWith, isFunction, isNil} from 'lodash'; +import {executeIfFunction, pluralize} from '@xh/hoist/utils/js'; +import {capitalize, cloneDeep, isEmpty, isEqualWith, isNil} from 'lodash'; import {ManageDialogModel} from './impl/ManageDialogModel'; import {ObjStub, SaveDialogModel} from './impl/SaveDialogModel'; import {runInAction} from 'mobx'; @@ -30,10 +30,7 @@ import {runInAction} from 'mobx'; * share objects to their own company only and modify shared objects shared only within their company. All users can * save/update/delete their own private objects. */ -export interface PersistenceManagerProps { - // /** Endpoint for rest controller. */ - // url: string; - +export interface PersistenceManagerConfig { /** Key used in JsonBlob */ type: string; /** User-facing name/label for an object managed by this model. */ @@ -55,19 +52,28 @@ export class PersistenceManagerModel extends HoistModel { // Persistence Provider // Pass this to models that implement `persistWith` to include their state in the view. //------------------------ - provider: PersistOptions = { + readonly provider: PersistOptions = { getData: () => cloneDeep(this.pendingValue ?? this.value ?? {}), setData: value => this.mergePendingValue(value) }; - @observable.ref @managed manageDialogModel: ManageDialogModel; + readonly enableTopLevelSaveButton: boolean = true; - @observable.ref @managed saveDialogModel: SaveDialogModel; + private readonly canManageGlobalFn: Thunkable; + + readonly newObjectFn: () => PlainObject; + + readonly onChangeAsync?: (value: PlainObject) => void; - enableTopLevelSaveButton: boolean = true; + /** Reference Key used to query JsonBlobs */ + readonly type: string; + + /** Configured word to label an object persisted by this model. */ + readonly noun: string; - newObjectFn: () => PlainObject; + @observable.ref @managed manageDialogModel: ManageDialogModel; + @observable.ref @managed saveDialogModel: SaveDialogModel; /** Current state of the active object, can include not-yet-persisted changes. */ @observable.ref pendingValue: PlainObject = null; @@ -75,12 +81,6 @@ export class PersistenceManagerModel extends HoistModel { @bindable selectedId: StoreRecordId; - /** Reference Key used to query JsonBlobs */ - type: string; - - /** Configured word to label an object persisted by this model. */ - noun: string; - get pluralNoun(): string { return pluralize(this.noun); } @@ -93,14 +93,10 @@ export class PersistenceManagerModel extends HoistModel { return capitalize(this.pluralNoun); } - private readonly canManageGlobalFn: Thunkable; - get canManageGlobal(): boolean { return executeIfFunction(this.canManageGlobalFn); } - onChangeAsync?: (value: PlainObject) => void; - get value(): PlainObject { return this.selectedObject?.value; } @@ -118,7 +114,7 @@ export class PersistenceManagerModel extends HoistModel { const {value} = this; if (!value) return false; return ( - this.isDirty && (this.canManageGlobal || value.isShared) && !this.loadModel.isPending + this.isDirty && (this.canManageGlobal || !value.isShared) && !this.loadModel.isPending ); } @@ -132,7 +128,7 @@ export class PersistenceManagerModel extends HoistModel { } // Internal persistence provider, used to save *this* model's state, i.e. selectedId - _provider; + private readonly _provider; constructor({ type, @@ -142,15 +138,10 @@ export class PersistenceManagerModel extends HoistModel { canManageGlobal, enableTopLevelSaveButton = true, newObjectFnAsync - }: PersistenceManagerProps) { + }: PersistenceManagerConfig) { super(); makeObservable(this); - throwIf( - !isFunction(onChangeAsync), - 'PersistenceManagerModel requires an `onChangeAsync` callback function' - ); - this.type = type; this.canManageGlobalFn = canManageGlobal; this.enableTopLevelSaveButton = enableTopLevelSaveButton; @@ -226,10 +217,14 @@ export class PersistenceManagerModel extends HoistModel { if (this.isShared) { if (!(await this.confirmShareObjSaveAsync())) return; } - await XH.jsonBlobService.updateAsync(token, { - ...this.selectedObject, - value: this.pendingValue - }); + try { + await XH.jsonBlobService.updateAsync(token, { + ...this.selectedObject, + value: this.pendingValue + }); + } catch (e) { + return XH.handleException(e, {alertType: 'toast'}); + } await this.refreshAsync(); await this.selectAsync(id); @@ -285,7 +280,6 @@ export class PersistenceManagerModel extends HoistModel { const {manageDialogModel} = this; this.manageDialogModel = null; XH.safeDestroy(manageDialogModel); - this.refreshAsync(); } //------------------ diff --git a/desktop/cmp/persistenceManager/impl/ManageDialog.ts b/desktop/cmp/persistenceManager/impl/ManageDialog.ts index 0c1a459a0f..be03772a2c 100644 --- a/desktop/cmp/persistenceManager/impl/ManageDialog.ts +++ b/desktop/cmp/persistenceManager/impl/ManageDialog.ts @@ -22,8 +22,8 @@ export const manageDialog = hoistCmp.factory({ isOpen: true, icon: Icon.gear(), title: `Manage ${model.parentModel.capitalPluralNoun}`, - className: 'persistence-manager__manage-dialog', - style: {width: 800, height: 475}, + className: 'xh-persistence-manager__manage-dialog', + style: {width: 800, height: 475, maxWidth: '90vm'}, canOutsideClickClose: false, onClose: () => model.close(), item: hframe(gridPanel(), formPanel(), mask({bind: model.loadModel, spinner: true})) @@ -34,7 +34,7 @@ export const manageDialog = hoistCmp.factory({ const gridPanel = hoistCmp.factory({ render() { return panel({ - className: 'persistence-manager__manage-dialog__grid-panel', + className: 'xh-persistence-manager__manage-dialog__grid-panel', modelConfig: {defaultSize: 350, side: 'left', collapsible: false}, item: grid() }); @@ -59,9 +59,9 @@ const formPanel = hoistCmp.factory({ }); return panel({ - className: 'persistence-manager__manage-dialog__form-panel', + className: 'xh-persistence-manager__manage-dialog__form-panel', item: vframe({ - className: 'persistence-manager__manage-dialog__form', + className: 'xh-persistence-manager__manage-dialog__form', items: [ form({ fieldDefaults: { diff --git a/desktop/cmp/persistenceManager/impl/ManageDialogModel.ts b/desktop/cmp/persistenceManager/impl/ManageDialogModel.ts index 29e6024598..7f7a0100ba 100644 --- a/desktop/cmp/persistenceManager/impl/ManageDialogModel.ts +++ b/desktop/cmp/persistenceManager/impl/ManageDialogModel.ts @@ -12,9 +12,9 @@ export class ManageDialogModel extends HoistModel { @observable isOpen: boolean = false; - @managed gridModel: GridModel; + @managed readonly gridModel: GridModel; - @managed formModel: FormModel; + @managed readonly formModel: FormModel; get noun(): string { return this.parentModel.noun; @@ -37,11 +37,11 @@ export class ManageDialogModel extends HoistModel { } get canDelete(): boolean { - return this.parentModel.objects.length > 1 && (this.canManageGlobal || this.selIsShared); + return this.parentModel.objects.length > 1 && (this.canManageGlobal || !this.selIsShared); } get canEdit(): boolean { - return this.canManageGlobal || this.selIsShared; + return this.canManageGlobal || !this.selIsShared; } get showSaveButton(): boolean { @@ -182,10 +182,10 @@ export class ManageDialogModel extends HoistModel { showGroupRowCounts: false, store: { fields: [ - 'token', - 'name', - 'description', - 'isShared', + {name: 'token', type: 'string'}, + {name: 'name', type: 'string'}, + {name: 'description', type: 'string'}, + {name: 'isShared', type: 'bool'}, {name: 'acl', type: 'json'}, {name: 'meta', type: 'json'}, {name: 'dateCreated', type: 'date'}, diff --git a/desktop/cmp/persistenceManager/impl/SaveDialog.ts b/desktop/cmp/persistenceManager/impl/SaveDialog.ts index 873ea9977d..f07b07174e 100644 --- a/desktop/cmp/persistenceManager/impl/SaveDialog.ts +++ b/desktop/cmp/persistenceManager/impl/SaveDialog.ts @@ -21,7 +21,7 @@ export const saveDialog = hoistCmp.factory({ isOpen: true, icon: isAdd ? Icon.plus() : Icon.copy(), title: isAdd ? `Create new ${parentModel.capitalNoun}` : `Save "${objStub.name}" as...`, - className: 'persistence-manager__save-as-dialog', + className: 'xh-persistence-manager__save-as-dialog', style: {width: 500, height: 255}, canOutsideClickClose: false, onClose: () => model.close(), @@ -34,7 +34,7 @@ const formPanel = hoistCmp.factory({ render({model}) { return panel({ item: vframe({ - className: 'persistence-manager__save-as-dialog__form', + className: 'xh-persistence-manager__save-as-dialog__form', items: [ form({ fieldDefaults: { diff --git a/desktop/cmp/persistenceManager/impl/SaveDialogModel.ts b/desktop/cmp/persistenceManager/impl/SaveDialogModel.ts index 5fb8ea589e..27c909cd16 100644 --- a/desktop/cmp/persistenceManager/impl/SaveDialogModel.ts +++ b/desktop/cmp/persistenceManager/impl/SaveDialogModel.ts @@ -7,27 +7,10 @@ import {PersistenceManagerModel} from '../PersistenceManagerModel'; export class SaveDialogModel extends HoistModel { parentModel: PersistenceManagerModel; - @managed saveTask = TaskObserver.trackLast(); - - @managed formModel = new FormModel({ - fields: [ - { - name: 'name', - rules: [ - required, - lengthIs({max: 255}), - ({value}) => { - if (this.parentModel?.objects.find(it => it.name === value)) { - return `An entry with name "${value}" already exists`; - } - } - ] - }, - {name: 'description'} - ] - }); - - objStub: ObjStub; + @managed + readonly formModel = this.createFormModel(); + readonly saveTask = TaskObserver.trackLast(); + readonly objStub: ObjStub; get isAdd(): boolean { return !!this.objStub?.isAdd; @@ -50,12 +33,33 @@ export class SaveDialogModel extends HoistModel { } async saveAsAsync() { - return this.doSaveAsAsync().linkTo(this.saveTask).catchDefault(); + return this.doSaveAsAsync().linkTo(this.saveTask); } //------------------------ // Implementation //------------------------ + + createFormModel(): FormModel { + return new FormModel({ + fields: [ + { + name: 'name', + rules: [ + required, + lengthIs({max: 255}), + ({value}) => { + if (this.parentModel?.objects.find(it => it.name === value)) { + return `An entry with name "${value}" already exists`; + } + } + ] + }, + {name: 'description'} + ] + }); + } + async doSaveAsAsync() { const {formModel, parentModel, objStub} = this, {name, description} = formModel.getData(), @@ -63,18 +67,22 @@ export class SaveDialogModel extends HoistModel { if (!isValid) return; - const newObj = await XH.jsonBlobService - .createAsync({ - type: this.parentModel.type, - name, - description, - value: objStub.value - }) - .catchDefault(); + try { + const newObj = await XH.jsonBlobService + .createAsync({ + type: this.parentModel.type, + name, + description, + value: objStub.value + }) + .catchDefault(); - await parentModel.refreshAsync(); - await parentModel.selectAsync(newObj.id); - this.close(); + await parentModel.refreshAsync(); + await parentModel.selectAsync(newObj.id); + this.close(); + } catch (e) { + XH.handleException(e); + } } } From b91d76649d32af41b2ffee30cf6da8bf17d4d13e Mon Sep 17 00:00:00 2001 From: Ryanseanlee Date: Fri, 6 Sep 2024 12:24:50 -0700 Subject: [PATCH 03/19] - Save and manage dialog model created once and open with isOpen state - Remove pluralize and capitalize getters - Created Entity that can be string or an "entitySpec" like field/fieldSpec -Generic class and PersistenceView type --- .../persistenceManager/PersistenceManager.ts | 29 ++- .../PersistenceManagerModel.ts | 176 ++++++++---------- desktop/cmp/persistenceManager/Types.ts | 19 ++ .../persistenceManager/impl/ManageDialog.ts | 12 +- .../impl/ManageDialogModel.ts | 85 ++++----- .../cmp/persistenceManager/impl/SaveDialog.ts | 17 +- .../impl/SaveDialogModel.ts | 65 +++---- 7 files changed, 197 insertions(+), 206 deletions(-) create mode 100644 desktop/cmp/persistenceManager/Types.ts diff --git a/desktop/cmp/persistenceManager/PersistenceManager.ts b/desktop/cmp/persistenceManager/PersistenceManager.ts index ee5c5cbddb..b91099cee5 100644 --- a/desktop/cmp/persistenceManager/PersistenceManager.ts +++ b/desktop/cmp/persistenceManager/PersistenceManager.ts @@ -3,12 +3,13 @@ import {hoistCmp, HoistProps, PlainObject, uses} from '@xh/hoist/core'; import {button} from '@xh/hoist/desktop/cmp/button'; import {Icon} from '@xh/hoist/icon/Icon'; import {menu, menuDivider, menuItem, popover} from '@xh/hoist/kit/blueprint'; -import {groupBy, keys, sortBy} from 'lodash'; +import {capitalize, groupBy, keys, sortBy} from 'lodash'; import {ReactNode} from 'react'; import {manageDialog} from './impl/ManageDialog'; import {saveDialog} from './impl/SaveDialog'; import './PersistenceManager.scss'; import {PersistenceManagerModel} from './PersistenceManagerModel'; +import {pluralize} from '@xh/hoist/utils/js'; interface PersistenceManagerModelProps extends HoistProps { /** True to disable options for saving/managing items. */ @@ -21,13 +22,8 @@ export const [PersistenceManager, persistenceManager] = model: uses(PersistenceManagerModel), render({model, minimal = false}) { - const { - selectedObject, - isShared, - capitalPluralNoun, - manageDialogModel, - saveDialogModel - } = model; + const {selectedView, isShared, entity, manageDialogModel, saveDialogModel} = model, + displayName = capitalize(pluralize(entity.displayName)); return fragment( hbox({ @@ -35,16 +31,14 @@ export const [PersistenceManager, persistenceManager] = items: [ popover({ item: button({ - text: - getHierarchyDisplayName(selectedObject?.name) ?? - capitalPluralNoun, + text: getHierarchyDisplayName(selectedView?.name) ?? displayName, icon: isShared ? Icon.users() : Icon.bookmark(), rightIcon: Icon.chevronDown(), outlined: true }), content: div({ items: [ - div({className: 'xh-popup__title', item: capitalPluralNoun}), + div({className: 'xh-popup__title', item: displayName}), objMenu({minimal}) ] }), @@ -67,7 +61,7 @@ const saveButton = hoistCmp.factory({ render({model}) { return button({ icon: Icon.save(), - tooltip: `Save changes to this ${model.noun}`, + tooltip: `Save changes to this ${model.entity.displayName}`, intent: 'primary', omit: !model.enableTopLevelSaveButton || !model.canSave, onClick: () => model.saveAsync(false).linkTo(model.loadModel) @@ -77,8 +71,8 @@ const saveButton = hoistCmp.factory({ const objMenu = hoistCmp.factory({ render({model, minimal}) { - const {pluralNoun, objects, loadModel} = model, - grouped = groupBy(objects, 'group'), + const {views, loadModel, entity} = model, + grouped = groupBy(views, 'group'), sortedGroupKeys = keys(grouped).sort(), items = []; @@ -95,6 +89,7 @@ const objMenu = hoistCmp.factory({ menuItem({ icon: Icon.plus(), text: 'New...', + omit: !model.newObjectFn, onClick: () => model.createNewAsync().linkTo(loadModel) }), menuItem({ @@ -117,7 +112,7 @@ const objMenu = hoistCmp.factory({ menuDivider(), menuItem({ icon: Icon.gear(), - text: `Manage ${pluralNoun}...`, + text: `Manage ${pluralize(entity.displayName)}...`, onClick: () => model.openManageDialog() }) ] @@ -165,7 +160,7 @@ function hierarchicalMenus(records: PlainObject[], depth: number = 0): ReactNode const objMenuFolder = hoistCmp.factory({ render({model, name, depth, children}) { - const selected = isFolderForEntry(name, model.selectedObject?.name, depth), + const selected = isFolderForEntry(name, model.selectedView?.name, depth), icon = selected ? Icon.check() : Icon.placeholder(); return menuItem({ text: getHierarchyDisplayName(name), diff --git a/desktop/cmp/persistenceManager/PersistenceManagerModel.ts b/desktop/cmp/persistenceManager/PersistenceManagerModel.ts index e8a8e04e53..3bf89d6ce4 100644 --- a/desktop/cmp/persistenceManager/PersistenceManagerModel.ts +++ b/desktop/cmp/persistenceManager/PersistenceManagerModel.ts @@ -11,10 +11,11 @@ import { import {StoreRecordId} from '@xh/hoist/data/StoreRecord'; import {action, bindable, computed, makeObservable, observable} from '@xh/hoist/mobx'; import {executeIfFunction, pluralize} from '@xh/hoist/utils/js'; -import {capitalize, cloneDeep, isEmpty, isEqualWith, isNil} from 'lodash'; +import {capitalize, cloneDeep, isEmpty, isEqualWith, isNil, isString, startCase} from 'lodash'; import {ManageDialogModel} from './impl/ManageDialogModel'; -import {ObjStub, SaveDialogModel} from './impl/SaveDialogModel'; +import {SaveDialogModel} from './impl/SaveDialogModel'; import {runInAction} from 'mobx'; +import {PersistenceView} from '@xh/hoist/desktop/cmp/persistenceManager/Types'; /** * PersistenceManager provides re-usable loading, selection, and user management of named configs, which are modelled @@ -30,91 +31,77 @@ import {runInAction} from 'mobx'; * share objects to their own company only and modify shared objects shared only within their company. All users can * save/update/delete their own private objects. */ -export interface PersistenceManagerConfig { +interface Entity { /** Key used in JsonBlob */ - type: string; + name: string; /** User-facing name/label for an object managed by this model. */ - noun: string; + displayName?: string; +} + +export interface PersistenceManagerConfig { + entity: string | Entity; /** Whether user can publish or edit globally shared objects. */ canManageGlobal: Thunkable; /** Async callback triggered when view changes. Should be used to recreate the affected models. */ - onChangeAsync: (value: PlainObject) => void; + onChangeAsync: (value: T) => void; /** Used to persist this model's selected ID. */ persistWith: PersistOptions; /** True (default) to render a save button alongside the primary menu button when dirty. */ enableTopLevelSaveButton?: boolean; /** Fn to produce a new, empty object - can be async. */ - newObjectFnAsync?: () => PlainObject; + newObjectFnAsync?: () => T; } -export class PersistenceManagerModel extends HoistModel { +export class PersistenceManagerModel extends HoistModel { //------------------------ // Persistence Provider // Pass this to models that implement `persistWith` to include their state in the view. //------------------------ readonly provider: PersistOptions = { getData: () => cloneDeep(this.pendingValue ?? this.value ?? {}), - setData: value => this.mergePendingValue(value) + setData: (value: T) => this.mergePendingValue(value) }; readonly enableTopLevelSaveButton: boolean = true; - private readonly canManageGlobalFn: Thunkable; + private readonly _canManageGlobal: Thunkable; - readonly newObjectFn: () => PlainObject; + readonly newObjectFn: () => T; - readonly onChangeAsync?: (value: PlainObject) => void; + readonly onChangeAsync?: (value: T) => void; - /** Reference Key used to query JsonBlobs */ - readonly type: string; - - /** Configured word to label an object persisted by this model. */ - readonly noun: string; + readonly entity: Entity; @observable.ref @managed manageDialogModel: ManageDialogModel; @observable.ref @managed saveDialogModel: SaveDialogModel; /** Current state of the active object, can include not-yet-persisted changes. */ - @observable.ref pendingValue: PlainObject = null; + @observable.ref pendingValue: T = null; - @observable.ref views: PlainObject[] = []; + @observable.ref views: PersistenceView[] = []; @bindable selectedId: StoreRecordId; - get pluralNoun(): string { - return pluralize(this.noun); - } - - get capitalNoun(): string { - return capitalize(this.noun); - } - - get capitalPluralNoun(): string { - return capitalize(this.pluralNoun); - } - get canManageGlobal(): boolean { - return executeIfFunction(this.canManageGlobalFn); - } - - get value(): PlainObject { - return this.selectedObject?.value; + return executeIfFunction(this._canManageGlobal); } - get objects(): PlainObject[] { - return this.views; + get value(): T { + return this.selectedView?.value; } - get selectedObject(): PlainObject { + get selectedView(): PersistenceView { return this.views.find(it => it.id === this.selectedId); } @computed get canSave(): boolean { - const {value} = this; - if (!value) return false; + const {selectedView} = this; + if (!selectedView) return false; return ( - this.isDirty && (this.canManageGlobal || !value.isShared) && !this.loadModel.isPending + this.isDirty && + (this.canManageGlobal || !selectedView.isShared) && + !this.loadModel.isPending ); } @@ -124,31 +111,30 @@ export class PersistenceManagerModel extends HoistModel { } get isShared(): boolean { - return !!this.selectedObject?.isShared; + return !!this.selectedView?.isShared; } // Internal persistence provider, used to save *this* model's state, i.e. selectedId private readonly _provider; constructor({ - type, - noun, + entity, onChangeAsync, persistWith, canManageGlobal, enableTopLevelSaveButton = true, newObjectFnAsync - }: PersistenceManagerConfig) { + }: PersistenceManagerConfig) { super(); makeObservable(this); - this.type = type; - this.canManageGlobalFn = canManageGlobal; + this.entity = this.parseEntity(entity); + this._canManageGlobal = canManageGlobal; this.enableTopLevelSaveButton = enableTopLevelSaveButton; - this.noun = noun || 'item'; - this.newObjectFn = newObjectFnAsync ?? (() => ({})); + this.newObjectFn = newObjectFnAsync ?? null; this.onChangeAsync = onChangeAsync; - + this.saveDialogModel = new SaveDialogModel(this, this.entity.name); + this.manageDialogModel = new ManageDialogModel(this); // Set up internal PersistenceProvider -- fail gently if (persistWith) { try { @@ -175,24 +161,25 @@ export class PersistenceManagerModel extends HoistModel { // TODO - Carefully review if this method needs isStale checks, and how to properly implement them. override async doLoadAsync(loadSpec: LoadSpec) { - const rawViews = await XH.jsonBlobService.listAsync({type: this.type, includeValue: true}); + const {name, displayName} = this.entity, + rawViews = await XH.jsonBlobService.listAsync({type: name, includeValue: true}); runInAction(() => (this.views = this.processRaw(rawViews))); - const {objects} = this; + const {views} = this; // Auto-create an empty view if required - if (!objects.length) { + if (!views.length) { const newValue = await this.newObjectFn(), newObject = await XH.jsonBlobService.createAsync({ - type: this.type, - name: `My ${this.capitalNoun}`, + type: name, + name: `My ${capitalize(displayName)}`, value: newValue }); runInAction(() => (this.views = this.processRaw([newObject]))); } // Always call selectAsync to ensure pendingValue updated and onChangeAsync callback fired if needed - const id = this.selectedObject?.id ?? this.objects[0].id; + const id = this.selectedView?.id ?? this.views[0].id; await this.selectAsync(id); } @@ -213,14 +200,15 @@ export class PersistenceManagerModel extends HoistModel { } async saveAsync(skipToast: boolean = false) { - const {token, id} = this.selectedObject; - if (this.isShared) { + const {selectedView, entity, pendingValue, isShared} = this, + {token, id} = selectedView; + if (isShared) { if (!(await this.confirmShareObjSaveAsync())) return; } try { await XH.jsonBlobService.updateAsync(token, { - ...this.selectedObject, - value: this.pendingValue + ...selectedView, + value: pendingValue }); } catch (e) { return XH.handleException(e, {alertType: 'toast'}); @@ -228,42 +216,30 @@ export class PersistenceManagerModel extends HoistModel { await this.refreshAsync(); await this.selectAsync(id); - if (!skipToast) XH.successToast(`${capitalize(this.noun)} successfully saved.`); + if (!skipToast) XH.successToast(`${capitalize(entity.displayName)} successfully saved.`); } async saveAsAsync() { - const {name, description} = this.selectedObject; - - this.openSaveDialog({ + const {name, description} = this.selectedView; + this.saveDialogModel.open({ name, description, - value: this.pendingValue, - isAdd: false + value: this.pendingValue }); } async createNewAsync() { - const {name, description} = this.selectedObject, + const {name, description} = this.selectedView, newValue = await this.newObjectFn(); - this.openSaveDialog({ - name, - description, - value: newValue, - isAdd: true - }); - } - - @action - openSaveDialog(objStub: ObjStub) { - this.saveDialogModel = new SaveDialogModel(this, objStub); - } - - @action - closeSaveDialog() { - const {saveDialogModel} = this; - this.saveDialogModel = null; - XH.safeDestroy(saveDialogModel); + this.saveDialogModel.open( + { + name, + description, + value: newValue + }, + true + ); } async resetAsync() { @@ -272,42 +248,48 @@ export class PersistenceManagerModel extends HoistModel { @action openManageDialog() { - this.manageDialogModel = new ManageDialogModel(this); + this.manageDialogModel.openAsync(); } @action closeManageDialog() { - const {manageDialogModel} = this; - this.manageDialogModel = null; - XH.safeDestroy(manageDialogModel); + this.manageDialogModel.close(); } //------------------ // Implementation //------------------ - mergePendingValue(value: PlainObject) { + + private parseEntity(entity: string | Entity): Entity { + const ret = isString(entity) ? {name: entity} : {...entity}; + ret.displayName = ret.displayName ?? startCase(ret.name); + return ret; + } + + mergePendingValue(value: T) { value = {...this.pendingValue, ...this.cleanValue(value)}; this.setPendingValue(value); } - processRaw(raw: PlainObject): PlainObject[] { - const {capitalPluralNoun: noun} = this; + private processRaw(raw: PlainObject): PersistenceView[] { + const {entity} = this, + name = capitalize(pluralize(entity.displayName)); return raw.map(it => { it.isShared = it.acl === '*'; - const group = it.isShared ? `Shared ${noun}` : `My ${noun}`; + const group = it.isShared ? `Shared ${name}` : `My ${name}`; return {...it, group}; }); } @action - setPendingValue(value: PlainObject) { + setPendingValue(value: T) { value = this.cleanValue(value); if (!this.isEqualSkipAutosize(this.pendingValue, value)) { this.pendingValue = value; } } - cleanValue(value: PlainObject): PlainObject { + cleanValue(value: T): T { // Stringify and parse to ensure that the value is valid JSON // (i.e. no object instances, no keys with undefined values, etc.) return JSON.parse(JSON.stringify(value)); @@ -324,7 +306,7 @@ export class PersistenceManagerModel extends HoistModel { async confirmShareObjSaveAsync() { return XH.confirm({ - message: `You are saving a shared public ${this.noun}. Do you wish to continue?`, + message: `You are saving a shared public ${this.entity.displayName}. Do you wish to continue?`, confirmProps: { text: 'Yes, save changes', intent: 'primary', diff --git a/desktop/cmp/persistenceManager/Types.ts b/desktop/cmp/persistenceManager/Types.ts new file mode 100644 index 0000000000..b90822431a --- /dev/null +++ b/desktop/cmp/persistenceManager/Types.ts @@ -0,0 +1,19 @@ +import {PlainObject} from '@xh/hoist/core'; +import {LocalDate} from '@xh/hoist/utils/datetime'; + +export interface PersistenceView { + acl: string; + archived: boolean; + dateCreated: LocalDate; + description: string; + id: number; + lastUpdated: LocalDate; + lastUpdatedBy: string; + name: string; + owner: string; + token: string; + type: string; + group: string; + isShared: boolean; + value: T; +} diff --git a/desktop/cmp/persistenceManager/impl/ManageDialog.ts b/desktop/cmp/persistenceManager/impl/ManageDialog.ts index be03772a2c..6f38c83f33 100644 --- a/desktop/cmp/persistenceManager/impl/ManageDialog.ts +++ b/desktop/cmp/persistenceManager/impl/ManageDialog.ts @@ -12,6 +12,8 @@ import {fmtCompactDate} from '@xh/hoist/format'; import {Icon} from '@xh/hoist/icon'; import {dialog} from '@xh/hoist/kit/blueprint'; import {ManageDialogModel} from './ManageDialogModel'; +import {pluralize} from '@xh/hoist/utils/js'; +import {capitalize} from 'lodash'; export const manageDialog = hoistCmp.factory({ displayName: 'ManageDialog', @@ -19,9 +21,9 @@ export const manageDialog = hoistCmp.factory({ render({model}) { return dialog({ - isOpen: true, + isOpen: model.isOpen, icon: Icon.gear(), - title: `Manage ${model.parentModel.capitalPluralNoun}`, + title: `Manage ${capitalize(pluralize(model.parentModel.entity.displayName))}`, className: 'xh-persistence-manager__manage-dialog', style: {width: 800, height: 475, maxWidth: '90vm'}, canOutsideClickClose: false, @@ -43,12 +45,12 @@ const gridPanel = hoistCmp.factory({ const formPanel = hoistCmp.factory({ render({model}) { - const {selectedId, noun, pluralNoun, formModel, canEdit} = model, + const {selectedId, parentModel, formModel, canEdit} = model, {values} = formModel; if (!selectedId) return panel({ - item: placeholder(`Select a ${noun}`), + item: placeholder(`Select a ${parentModel.entity.displayName}`), bbar: [ filler(), button({ @@ -76,7 +78,7 @@ const formPanel = hoistCmp.factory({ info: canEdit ? fragment( Icon.info(), - `Organize your ${pluralNoun} into folders by including the "\\" character in their names - e.g. "My folder\\My ${noun}".` + `Organize your ${pluralize(parentModel.entity.displayName)} into folders by including the "\\" character in their names - e.g. "My folder\\My ${parentModel.entity.displayName}".` ) : null }), diff --git a/desktop/cmp/persistenceManager/impl/ManageDialogModel.ts b/desktop/cmp/persistenceManager/impl/ManageDialogModel.ts index 7f7a0100ba..0963741f1b 100644 --- a/desktop/cmp/persistenceManager/impl/ManageDialogModel.ts +++ b/desktop/cmp/persistenceManager/impl/ManageDialogModel.ts @@ -1,29 +1,20 @@ import {FormModel} from '@xh/hoist/cmp/form'; import {GridAutosizeMode, GridModel} from '@xh/hoist/cmp/grid'; -import {HoistModel, LoadSpec, managed, XH} from '@xh/hoist/core'; +import {HoistModel, managed, XH} from '@xh/hoist/core'; import {lengthIs, required} from '@xh/hoist/data'; import {Icon} from '@xh/hoist/icon'; -import {makeObservable, observable} from '@xh/hoist/mobx'; -import {wait} from '@xh/hoist/promise'; +import {bindable, makeObservable} from '@xh/hoist/mobx'; import {PersistenceManagerModel} from '../PersistenceManagerModel'; export class ManageDialogModel extends HoistModel { parentModel: PersistenceManagerModel; - @observable isOpen: boolean = false; + @bindable isOpen: boolean = false; @managed readonly gridModel: GridModel; @managed readonly formModel: FormModel; - get noun(): string { - return this.parentModel.noun; - } - - get pluralNoun(): string { - return this.parentModel.pluralNoun; - } - get selectedId(): string { return this.gridModel.selectedId as string; } @@ -37,7 +28,7 @@ export class ManageDialogModel extends HoistModel { } get canDelete(): boolean { - return this.parentModel.objects.length > 1 && (this.canManageGlobal || !this.selIsShared); + return this.parentModel.views.length > 1 && (this.canManageGlobal || !this.selIsShared); } get canEdit(): boolean { @@ -45,8 +36,8 @@ export class ManageDialogModel extends HoistModel { } get showSaveButton(): boolean { - const {formModel} = this; - return formModel.isDirty && !formModel.readonly && !this.loadModel.isPending; + const {formModel, parentModel} = this; + return formModel.isDirty && !formModel.readonly && !parentModel.loadModel.isPending; } /** True if the selected object would end up shared to all users if saved. */ @@ -66,26 +57,26 @@ export class ManageDialogModel extends HoistModel { this.gridModel = this.createGridModel(); this.formModel = this.createFormModel(); - const {gridModel, formModel} = this; this.addReaction({ - track: () => gridModel.selectedRecord, + track: () => this.gridModel.selectedRecord, run: record => { if (record) { - formModel.readonly = !this.canEdit; - formModel.init({ + this.formModel.readonly = !this.canEdit; + this.formModel.init({ ...record.data }); } } }); + } - wait() - .then(() => this.loadAsync()) - .then(() => this.ensureGridHasSelection()); + async openAsync() { + this.isOpen = true; + await this.refreshModelsAsync(); } close() { - this.parentModel.closeManageDialog(); + this.isOpen = false; } async saveAsync() { @@ -100,36 +91,26 @@ export class ManageDialogModel extends HoistModel { // Implementation //------------------------ - override async doLoadAsync(loadSpec: LoadSpec) { - await this.parentModel.loadAsync(loadSpec); - const {objects, selectedObject, views} = this.parentModel; - this.gridModel.loadData(views); - const id = selectedObject?.id ?? objects[0].id; - await this.gridModel.selectAsync(id); - this.formModel.init({ - ...this.gridModel.selectedRecord.data - }); - } - async doSaveAsync() { - const {formModel, noun, canManageGlobal, selectedId} = this, + const {formModel, parentModel, canManageGlobal, selectedId} = this, {fields, isDirty} = formModel, {name, description, isShared} = formModel.getData(), - isValid = await formModel.validateAsync(); + isValid = await formModel.validateAsync(), + displayName = parentModel.entity.displayName; if (!isValid || !selectedId || !isDirty) return; // Additional sanity-check before POSTing an update - non-admins should never be modifying global views. if (isShared && !canManageGlobal) throw XH.exception( - `Cannot save changes to globally-shared ${noun} - missing required permission.` + `Cannot save changes to globally-shared ${parentModel.entity.displayName} - missing required permission.` ); if (fields.isShared.isDirty) { const confirmed = await XH.confirm({ message: isShared - ? `This will share the selected ${noun} with ALL other users.` - : `The selected ${noun} will no longer be available to all other users.` + ? `This will share the selected ${displayName} with ALL other users.` + : `The selected ${displayName} will no longer be available to all other users.` }); if (!confirmed) return; @@ -141,7 +122,8 @@ export class ManageDialogModel extends HoistModel { acl: isShared ? '*' : null }); - await this.refreshAsync(); + await this.parentModel.refreshAsync(); + await this.refreshModelsAsync(); } async doDeleteAsync() { @@ -157,18 +139,25 @@ export class ManageDialogModel extends HoistModel { if (!confirmed) return; await XH.jsonBlobService.archiveAsync(token); - await this.refreshAsync(); + await this.parentModel.refreshAsync(); } - ensureGridHasSelection() { - const {gridModel} = this; - if (gridModel.hasSelection) return; + async refreshModelsAsync() { + const {views} = this.parentModel; + this.gridModel.loadData(views); + await this.ensureGridHasSelection(); + this.formModel.init({ + ...this.gridModel.selectedRecord.data + }); + } - const {selectedObject} = this.parentModel; - if (selectedObject) { - gridModel.selModel.select(selectedObject.id); + async ensureGridHasSelection() { + const {gridModel} = this; + const {selectedView} = this.parentModel; + if (selectedView) { + gridModel.selModel.select(selectedView.id); } else { - gridModel.preSelectFirstAsync(); + await gridModel.preSelectFirstAsync(); } } diff --git a/desktop/cmp/persistenceManager/impl/SaveDialog.ts b/desktop/cmp/persistenceManager/impl/SaveDialog.ts index f07b07174e..d3e797850e 100644 --- a/desktop/cmp/persistenceManager/impl/SaveDialog.ts +++ b/desktop/cmp/persistenceManager/impl/SaveDialog.ts @@ -9,6 +9,7 @@ import {panel} from '@xh/hoist/desktop/cmp/panel'; import {toolbar} from '@xh/hoist/desktop/cmp/toolbar'; import {Icon} from '@xh/hoist/icon'; import {dialog} from '@xh/hoist/kit/blueprint'; +import {capitalize} from 'lodash'; import {SaveDialogModel} from './SaveDialogModel'; export const saveDialog = hoistCmp.factory({ @@ -16,11 +17,13 @@ export const saveDialog = hoistCmp.factory({ model: uses(SaveDialogModel), render({model}) { - const {objStub, isAdd, parentModel} = model; + const {viewStub, isNewAdd, isOpen, parentModel} = model; return dialog({ - isOpen: true, - icon: isAdd ? Icon.plus() : Icon.copy(), - title: isAdd ? `Create new ${parentModel.capitalNoun}` : `Save "${objStub.name}" as...`, + isOpen: isOpen, + icon: isNewAdd ? Icon.plus() : Icon.copy(), + title: isNewAdd + ? `Create new ${capitalize(parentModel.entity.displayName)}` + : `Save "${viewStub?.name}" as...`, className: 'xh-persistence-manager__save-as-dialog', style: {width: 500, height: 255}, canOutsideClickClose: false, @@ -71,7 +74,7 @@ const formPanel = hoistCmp.factory({ const bbar = hoistCmp.factory({ render({model}) { - const {formModel, isAdd} = model; + const {formModel, isNewAdd} = model; return toolbar( filler(), button({ @@ -79,8 +82,8 @@ const bbar = hoistCmp.factory({ onClick: () => model.close() }), button({ - icon: isAdd ? Icon.plus() : Icon.copy(), - text: isAdd ? `Create` : 'Save as new copy', + icon: isNewAdd ? Icon.plus() : Icon.copy(), + text: isNewAdd ? `Create` : 'Save as new copy', outlined: true, intent: 'success', disabled: !formModel.isValid, diff --git a/desktop/cmp/persistenceManager/impl/SaveDialogModel.ts b/desktop/cmp/persistenceManager/impl/SaveDialogModel.ts index 27c909cd16..9a7ba5dccc 100644 --- a/desktop/cmp/persistenceManager/impl/SaveDialogModel.ts +++ b/desktop/cmp/persistenceManager/impl/SaveDialogModel.ts @@ -1,35 +1,45 @@ import {FormModel} from '@xh/hoist/cmp/form'; -import {HoistModel, managed, PlainObject, TaskObserver, XH} from '@xh/hoist/core'; +import {HoistModel, managed, TaskObserver, XH} from '@xh/hoist/core'; import {lengthIs, required} from '@xh/hoist/data'; -import {makeObservable} from '@xh/hoist/mobx'; +import {bindable, makeObservable} from '@xh/hoist/mobx'; import {PersistenceManagerModel} from '../PersistenceManagerModel'; +import {PersistenceView} from '@xh/hoist/desktop/cmp/persistenceManager/Types'; export class SaveDialogModel extends HoistModel { + readonly saveTask = TaskObserver.trackLast(); + private readonly type: string; + parentModel: PersistenceManagerModel; - @managed - readonly formModel = this.createFormModel(); - readonly saveTask = TaskObserver.trackLast(); - readonly objStub: ObjStub; + @bindable viewStub: Partial; + @bindable isOpen: boolean = false; + @bindable isNewAdd: boolean; - get isAdd(): boolean { - return !!this.objStub?.isAdd; - } + @managed readonly formModel = this.createFormModel(); - constructor(parentModel: PersistenceManagerModel, objStub: ObjStub) { + constructor(parentModel: PersistenceManagerModel, type: string) { super(); makeObservable(this); this.parentModel = parentModel; - this.objStub = objStub; + this.type = type; + } + + open(viewStub: Partial, isNewAdd: boolean = false) { + this.isNewAdd = isNewAdd; + this.viewStub = viewStub; + this.formModel.init({ - name: this.isAdd ? `` : `${objStub.name} (COPY)`, - description: this.isAdd ? `` : objStub.description + name: isNewAdd ? `` : `${viewStub.name} (COPY)`, + description: isNewAdd ? `` : viewStub.description }); + + this.isOpen = true; } close() { - this.parentModel.closeSaveDialog(); + this.isOpen = false; + this.formModel.init(); } async saveAsAsync() { @@ -49,7 +59,7 @@ export class SaveDialogModel extends HoistModel { required, lengthIs({max: 255}), ({value}) => { - if (this.parentModel?.objects.find(it => it.name === value)) { + if (this.parentModel?.views.find(it => it.name === value)) { return `An entry with name "${value}" already exists`; } } @@ -61,34 +71,25 @@ export class SaveDialogModel extends HoistModel { } async doSaveAsAsync() { - const {formModel, parentModel, objStub} = this, + const {formModel, parentModel, viewStub, type} = this, {name, description} = formModel.getData(), isValid = await formModel.validateAsync(); if (!isValid) return; try { - const newObj = await XH.jsonBlobService - .createAsync({ - type: this.parentModel.type, - name, - description, - value: objStub.value - }) - .catchDefault(); + const newObj = await XH.jsonBlobService.createAsync({ + type, + name, + description, + value: viewStub.value + }); + this.close(); await parentModel.refreshAsync(); await parentModel.selectAsync(newObj.id); - this.close(); } catch (e) { XH.handleException(e); } } } - -export interface ObjStub { - name: string; - description: string; - value: PlainObject; - isAdd: boolean; -} From e99cd7c9e57c71b40973ba0263ccf91be37e81a2 Mon Sep 17 00:00:00 2001 From: Ryanseanlee Date: Mon, 9 Sep 2024 13:33:26 -0700 Subject: [PATCH 04/19] Remove new and add code default state --- cmp/grid/GridModel.ts | 6 ++- .../persistenceManager/PersistenceManager.ts | 21 +++++---- .../PersistenceManagerModel.ts | 44 ++++--------------- desktop/cmp/persistenceManager/Types.ts | 2 +- .../impl/ManageDialogModel.ts | 1 + .../cmp/persistenceManager/impl/SaveDialog.ts | 15 +++---- .../impl/SaveDialogModel.ts | 8 ++-- 7 files changed, 36 insertions(+), 61 deletions(-) diff --git a/cmp/grid/GridModel.ts b/cmp/grid/GridModel.ts index c3fa19a5c5..d0ef332f42 100644 --- a/cmp/grid/GridModel.ts +++ b/cmp/grid/GridModel.ts @@ -629,8 +629,10 @@ export class GridModel extends HoistModel { * * @returns true if defaults were restored */ - async restoreDefaultsAsync(): Promise { - if (this.restoreDefaultsWarning) { + async restoreDefaultsAsync( + {skipWarning}: {skipWarning: boolean} = {skipWarning: !!this.restoreDefaultsWarning} + ): Promise { + if (this.restoreDefaultsWarning && !skipWarning) { const confirmed = await XH.confirm({ message: this.restoreDefaultsWarning, confirmProps: { diff --git a/desktop/cmp/persistenceManager/PersistenceManager.ts b/desktop/cmp/persistenceManager/PersistenceManager.ts index b91099cee5..88a59984f6 100644 --- a/desktop/cmp/persistenceManager/PersistenceManager.ts +++ b/desktop/cmp/persistenceManager/PersistenceManager.ts @@ -23,7 +23,7 @@ export const [PersistenceManager, persistenceManager] = render({model, minimal = false}) { const {selectedView, isShared, entity, manageDialogModel, saveDialogModel} = model, - displayName = capitalize(pluralize(entity.displayName)); + displayName = entity.displayName; return fragment( hbox({ @@ -31,14 +31,19 @@ export const [PersistenceManager, persistenceManager] = items: [ popover({ item: button({ - text: getHierarchyDisplayName(selectedView?.name) ?? displayName, + text: + getHierarchyDisplayName(selectedView?.name) ?? + `Default ${capitalize(displayName)}`, icon: isShared ? Icon.users() : Icon.bookmark(), rightIcon: Icon.chevronDown(), outlined: true }), content: div({ items: [ - div({className: 'xh-popup__title', item: displayName}), + div({ + className: 'xh-popup__title', + item: capitalize(pluralize(displayName)) + }), objMenu({minimal}) ] }), @@ -85,13 +90,13 @@ const objMenu = hoistCmp.factory({ return menu({ items: [ ...items, - menuDivider(), + menuDivider({title: `Default ${entity.displayName}`}), menuItem({ - icon: Icon.plus(), - text: 'New...', - omit: !model.newObjectFn, - onClick: () => model.createNewAsync().linkTo(loadModel) + icon: model.selectedId === null ? Icon.check() : Icon.placeholder(), + text: entity.displayName, + onClick: () => model.selectAsync(null).linkTo(loadModel) }), + menuDivider(), menuItem({ icon: Icon.save(), text: 'Save', diff --git a/desktop/cmp/persistenceManager/PersistenceManagerModel.ts b/desktop/cmp/persistenceManager/PersistenceManagerModel.ts index 3bf89d6ce4..f704d6f17a 100644 --- a/desktop/cmp/persistenceManager/PersistenceManagerModel.ts +++ b/desktop/cmp/persistenceManager/PersistenceManagerModel.ts @@ -11,7 +11,7 @@ import { import {StoreRecordId} from '@xh/hoist/data/StoreRecord'; import {action, bindable, computed, makeObservable, observable} from '@xh/hoist/mobx'; import {executeIfFunction, pluralize} from '@xh/hoist/utils/js'; -import {capitalize, cloneDeep, isEmpty, isEqualWith, isNil, isString, startCase} from 'lodash'; +import {capitalize, cloneDeep, isEqualWith, isNil, isString, startCase} from 'lodash'; import {ManageDialogModel} from './impl/ManageDialogModel'; import {SaveDialogModel} from './impl/SaveDialogModel'; import {runInAction} from 'mobx'; @@ -97,8 +97,8 @@ export class PersistenceManagerModel extend @computed get canSave(): boolean { const {selectedView} = this; - if (!selectedView) return false; return ( + selectedView && this.isDirty && (this.canManageGlobal || !selectedView.isShared) && !this.loadModel.isPending @@ -161,23 +161,11 @@ export class PersistenceManagerModel extend // TODO - Carefully review if this method needs isStale checks, and how to properly implement them. override async doLoadAsync(loadSpec: LoadSpec) { - const {name, displayName} = this.entity, + const {name} = this.entity, rawViews = await XH.jsonBlobService.listAsync({type: name, includeValue: true}); runInAction(() => (this.views = this.processRaw(rawViews))); - const {views} = this; - // Auto-create an empty view if required - if (!views.length) { - const newValue = await this.newObjectFn(), - newObject = await XH.jsonBlobService.createAsync({ - type: name, - name: `My ${capitalize(displayName)}`, - value: newValue - }); - runInAction(() => (this.views = this.processRaw([newObject]))); - } - // Always call selectAsync to ensure pendingValue updated and onChangeAsync callback fired if needed const id = this.selectedView?.id ?? this.views[0].id; await this.selectAsync(id); @@ -191,12 +179,6 @@ export class PersistenceManagerModel extend this.setPendingValue(value); await this.onChangeAsync(value); - - // If current value is empty, we know it is the auto-created default view - - // save it to capture the default state. - if (isEmpty(value)) { - await this.saveAsync(true); - } } async saveAsync(skipToast: boolean = false) { @@ -220,7 +202,7 @@ export class PersistenceManagerModel extend } async saveAsAsync() { - const {name, description} = this.selectedView; + const {name, description} = this.selectedView ?? {}; this.saveDialogModel.open({ name, description, @@ -228,20 +210,6 @@ export class PersistenceManagerModel extend }); } - async createNewAsync() { - const {name, description} = this.selectedView, - newValue = await this.newObjectFn(); - - this.saveDialogModel.open( - { - name, - description, - value: newValue - }, - true - ); - } - async resetAsync() { return this.selectAsync(this.selectedId); } @@ -283,6 +251,10 @@ export class PersistenceManagerModel extend @action setPendingValue(value: T) { + if (isNil(value)) { + this.pendingValue = null; + return; + } value = this.cleanValue(value); if (!this.isEqualSkipAutosize(this.pendingValue, value)) { this.pendingValue = value; diff --git a/desktop/cmp/persistenceManager/Types.ts b/desktop/cmp/persistenceManager/Types.ts index b90822431a..9aa982eff4 100644 --- a/desktop/cmp/persistenceManager/Types.ts +++ b/desktop/cmp/persistenceManager/Types.ts @@ -2,11 +2,11 @@ import {PlainObject} from '@xh/hoist/core'; import {LocalDate} from '@xh/hoist/utils/datetime'; export interface PersistenceView { + id: number; acl: string; archived: boolean; dateCreated: LocalDate; description: string; - id: number; lastUpdated: LocalDate; lastUpdatedBy: string; name: string; diff --git a/desktop/cmp/persistenceManager/impl/ManageDialogModel.ts b/desktop/cmp/persistenceManager/impl/ManageDialogModel.ts index 0963741f1b..62177c9f12 100644 --- a/desktop/cmp/persistenceManager/impl/ManageDialogModel.ts +++ b/desktop/cmp/persistenceManager/impl/ManageDialogModel.ts @@ -140,6 +140,7 @@ export class ManageDialogModel extends HoistModel { await XH.jsonBlobService.archiveAsync(token); await this.parentModel.refreshAsync(); + await this.refreshModelsAsync(); } async refreshModelsAsync() { diff --git a/desktop/cmp/persistenceManager/impl/SaveDialog.ts b/desktop/cmp/persistenceManager/impl/SaveDialog.ts index d3e797850e..8e864e56de 100644 --- a/desktop/cmp/persistenceManager/impl/SaveDialog.ts +++ b/desktop/cmp/persistenceManager/impl/SaveDialog.ts @@ -9,7 +9,6 @@ import {panel} from '@xh/hoist/desktop/cmp/panel'; import {toolbar} from '@xh/hoist/desktop/cmp/toolbar'; import {Icon} from '@xh/hoist/icon'; import {dialog} from '@xh/hoist/kit/blueprint'; -import {capitalize} from 'lodash'; import {SaveDialogModel} from './SaveDialogModel'; export const saveDialog = hoistCmp.factory({ @@ -17,13 +16,11 @@ export const saveDialog = hoistCmp.factory({ model: uses(SaveDialogModel), render({model}) { - const {viewStub, isNewAdd, isOpen, parentModel} = model; + const {isOpen} = model; return dialog({ isOpen: isOpen, - icon: isNewAdd ? Icon.plus() : Icon.copy(), - title: isNewAdd - ? `Create new ${capitalize(parentModel.entity.displayName)}` - : `Save "${viewStub?.name}" as...`, + icon: Icon.copy(), + title: `Save as...`, className: 'xh-persistence-manager__save-as-dialog', style: {width: 500, height: 255}, canOutsideClickClose: false, @@ -74,7 +71,7 @@ const formPanel = hoistCmp.factory({ const bbar = hoistCmp.factory({ render({model}) { - const {formModel, isNewAdd} = model; + const {formModel} = model; return toolbar( filler(), button({ @@ -82,8 +79,8 @@ const bbar = hoistCmp.factory({ onClick: () => model.close() }), button({ - icon: isNewAdd ? Icon.plus() : Icon.copy(), - text: isNewAdd ? `Create` : 'Save as new copy', + icon: Icon.copy(), + text: 'Save as new copy', outlined: true, intent: 'success', disabled: !formModel.isValid, diff --git a/desktop/cmp/persistenceManager/impl/SaveDialogModel.ts b/desktop/cmp/persistenceManager/impl/SaveDialogModel.ts index 9a7ba5dccc..ddb1b88657 100644 --- a/desktop/cmp/persistenceManager/impl/SaveDialogModel.ts +++ b/desktop/cmp/persistenceManager/impl/SaveDialogModel.ts @@ -13,7 +13,6 @@ export class SaveDialogModel extends HoistModel { @bindable viewStub: Partial; @bindable isOpen: boolean = false; - @bindable isNewAdd: boolean; @managed readonly formModel = this.createFormModel(); @@ -25,13 +24,12 @@ export class SaveDialogModel extends HoistModel { this.type = type; } - open(viewStub: Partial, isNewAdd: boolean = false) { - this.isNewAdd = isNewAdd; + open(viewStub: Partial) { this.viewStub = viewStub; this.formModel.init({ - name: isNewAdd ? `` : `${viewStub.name} (COPY)`, - description: isNewAdd ? `` : viewStub.description + name: viewStub.name ? `${viewStub.name} (COPY)` : '', + description: viewStub.description }); this.isOpen = true; From e58e1be0a3e933d20756a4eb0141d6660c1a75fd Mon Sep 17 00:00:00 2001 From: Ryanseanlee Date: Tue, 10 Sep 2024 15:16:50 -0700 Subject: [PATCH 05/19] Add view tree getter for app specific custom component. Added omitDefaultMenuComponent to config to enable omitting default menu component --- .../persistenceManager/PersistenceManager.ts | 145 ++++++------------ .../PersistenceManagerModel.ts | 104 +++++++++++-- desktop/cmp/persistenceManager/Types.ts | 8 + 3 files changed, 144 insertions(+), 113 deletions(-) diff --git a/desktop/cmp/persistenceManager/PersistenceManager.ts b/desktop/cmp/persistenceManager/PersistenceManager.ts index 88a59984f6..7be97a07f4 100644 --- a/desktop/cmp/persistenceManager/PersistenceManager.ts +++ b/desktop/cmp/persistenceManager/PersistenceManager.ts @@ -1,9 +1,10 @@ import {div, fragment, hbox} from '@xh/hoist/cmp/layout'; -import {hoistCmp, HoistProps, PlainObject, uses} from '@xh/hoist/core'; +import {hoistCmp, HoistProps, uses} from '@xh/hoist/core'; import {button} from '@xh/hoist/desktop/cmp/button'; +import {TreeView} from '@xh/hoist/desktop/cmp/persistenceManager/Types'; import {Icon} from '@xh/hoist/icon/Icon'; import {menu, menuDivider, menuItem, popover} from '@xh/hoist/kit/blueprint'; -import {capitalize, groupBy, keys, sortBy} from 'lodash'; +import {capitalize} from 'lodash'; import {ReactNode} from 'react'; import {manageDialog} from './impl/ManageDialog'; import {saveDialog} from './impl/SaveDialog'; @@ -11,18 +12,25 @@ import './PersistenceManager.scss'; import {PersistenceManagerModel} from './PersistenceManagerModel'; import {pluralize} from '@xh/hoist/utils/js'; -interface PersistenceManagerModelProps extends HoistProps { +interface PersistenceManagerProps extends HoistProps { /** True to disable options for saving/managing items. */ minimal?: boolean; } export const [PersistenceManager, persistenceManager] = - hoistCmp.withFactory({ + hoistCmp.withFactory({ displayName: 'PersistenceManager', model: uses(PersistenceManagerModel), render({model, minimal = false}) { - const {selectedView, isShared, entity, manageDialogModel, saveDialogModel} = model, + const { + selectedView, + isShared, + entity, + manageDialogModel, + saveDialogModel, + omitDefaultMenuComponent + } = model, displayName = entity.displayName; return fragment( @@ -30,10 +38,9 @@ export const [PersistenceManager, persistenceManager] = className: 'xh-persistence-manager', items: [ popover({ + omit: omitDefaultMenuComponent, item: button({ - text: - getHierarchyDisplayName(selectedView?.name) ?? - `Default ${capitalize(displayName)}`, + text: model.getHierarchyDisplayName(selectedView?.name) ?? `-`, icon: isShared ? Icon.users() : Icon.bookmark(), rightIcon: Icon.chevronDown(), outlined: true @@ -74,28 +81,19 @@ const saveButton = hoistCmp.factory({ } }); -const objMenu = hoistCmp.factory({ +const objMenu = hoistCmp.factory({ render({model, minimal}) { - const {views, loadModel, entity} = model, - grouped = groupBy(views, 'group'), - sortedGroupKeys = keys(grouped).sort(), + const {loadModel, entity} = model, items = []; - sortedGroupKeys.forEach(group => { - items.push(menuDivider({title: group})); - items.push(...hierarchicalMenus(sortBy(grouped[group], 'name'))); + model.viewTree.forEach(it => { + items.push(buildView(it, model)); }); if (minimal) return menu({items}); return menu({ items: [ ...items, - menuDivider({title: `Default ${entity.displayName}`}), - menuItem({ - icon: model.selectedId === null ? Icon.check() : Icon.placeholder(), - text: entity.displayName, - onClick: () => model.selectAsync(null).linkTo(loadModel) - }), menuDivider(), menuItem({ icon: Icon.save(), @@ -110,10 +108,15 @@ const objMenu = hoistCmp.factory({ }), menuItem({ icon: Icon.reset(), - text: 'Reset', + text: 'Revert View', disabled: !model.isDirty, onClick: () => model.resetAsync().linkTo(loadModel) }), + menuItem({ + icon: Icon.refresh(), + text: 'Reset Defaults', + onClick: () => model.selectAsync(null).linkTo(loadModel) + }), menuDivider(), menuItem({ icon: Icon.gear(), @@ -125,85 +128,25 @@ const objMenu = hoistCmp.factory({ } }); -/** - * @param records - * @param depth used during recursion, depth in the path string/hierarchy - * @returns an array of menuItem()s - */ -function hierarchicalMenus(records: PlainObject[], depth: number = 0): ReactNode[] { - const groups = {}, - unbalancedStableGroupsAndRecords = []; - - records.forEach(record => { - // Leaf Node - if (getNameHierarchySubstring(record.name, depth + 1) == null) { - unbalancedStableGroupsAndRecords.push(record); - return; - } - // Belongs to an already defined group - const group = getNameHierarchySubstring(record.name, depth); - if (groups[group]) { - groups[group].children.push(record); - return; - } - // Belongs to a not defined group, create it - groups[group] = {name: group, children: [record], isMenuFolder: true}; - unbalancedStableGroupsAndRecords.push(groups[group]); - }); - - return unbalancedStableGroupsAndRecords.map(it => { - if (it.isMenuFolder) { - return objMenuFolder({ - name: it.name, - items: hierarchicalMenus(it.children, depth + 1), - depth +function buildView(view: TreeView, model: PersistenceManagerModel): ReactNode { + const {itemType, text, selected, items, key} = view, + icon = selected ? Icon.check() : Icon.placeholder(); + switch (itemType) { + case 'divider': + return menuDivider({title: text}); + case 'menuFolder': + return menuItem({ + text, + icon, + shouldDismissPopover: false, + children: items ? items.map(child => buildView(child, model)) : [] + }); + case 'view': + return menuItem({ + key, + icon, + text, + onClick: () => model.selectAsync(key).linkTo(model.loadModel) }); - } - return objMenuItem({record: it}); - }); -} - -const objMenuFolder = hoistCmp.factory({ - render({model, name, depth, children}) { - const selected = isFolderForEntry(name, model.selectedView?.name, depth), - icon = selected ? Icon.check() : Icon.placeholder(); - return menuItem({ - text: getHierarchyDisplayName(name), - icon, - shouldDismissPopover: false, - children - }); - } -}); - -const objMenuItem = hoistCmp.factory({ - render({model, record}) { - const {id, name} = record, - selected = model.selectedId === id, - icon = selected ? Icon.check() : Icon.placeholder(); - - return menuItem({ - key: id, - icon: icon, - text: getHierarchyDisplayName(name), - onClick: () => model.selectAsync(id).linkTo(model.loadModel) - }); - } -}); - -function isFolderForEntry(folderName, entryName, depth) { - const name = getNameHierarchySubstring(entryName, depth); - return name && name === folderName && folderName.length < entryName.length; -} - -function getNameHierarchySubstring(name, depth) { - const arr = name?.split('\\') ?? []; - if (arr.length <= depth) { - return null; } - return arr.slice(0, depth + 1).join('\\'); -} - -function getHierarchyDisplayName(name) { - return name?.substring(name.lastIndexOf('\\') + 1); } diff --git a/desktop/cmp/persistenceManager/PersistenceManagerModel.ts b/desktop/cmp/persistenceManager/PersistenceManagerModel.ts index f704d6f17a..550b00d503 100644 --- a/desktop/cmp/persistenceManager/PersistenceManagerModel.ts +++ b/desktop/cmp/persistenceManager/PersistenceManagerModel.ts @@ -11,11 +11,21 @@ import { import {StoreRecordId} from '@xh/hoist/data/StoreRecord'; import {action, bindable, computed, makeObservable, observable} from '@xh/hoist/mobx'; import {executeIfFunction, pluralize} from '@xh/hoist/utils/js'; -import {capitalize, cloneDeep, isEqualWith, isNil, isString, startCase} from 'lodash'; +import { + capitalize, + cloneDeep, + groupBy, + isEqualWith, + isNil, + isString, + keys, + sortBy, + startCase +} from 'lodash'; import {ManageDialogModel} from './impl/ManageDialogModel'; import {SaveDialogModel} from './impl/SaveDialogModel'; import {runInAction} from 'mobx'; -import {PersistenceView} from '@xh/hoist/desktop/cmp/persistenceManager/Types'; +import {PersistenceView, TreeView} from '@xh/hoist/desktop/cmp/persistenceManager/Types'; /** * PersistenceManager provides re-usable loading, selection, and user management of named configs, which are modelled @@ -40,6 +50,8 @@ interface Entity { export interface PersistenceManagerConfig { entity: string | Entity; + /** True to omit the default menu component. Should be used when creating custom app-specific component */ + omitDefaultMenuComponent?: boolean; /** Whether user can publish or edit globally shared objects. */ canManageGlobal: Thunkable; /** Async callback triggered when view changes. Should be used to recreate the affected models. */ @@ -66,12 +78,12 @@ export class PersistenceManagerModel extend private readonly _canManageGlobal: Thunkable; - readonly newObjectFn: () => T; - readonly onChangeAsync?: (value: T) => void; readonly entity: Entity; + readonly omitDefaultMenuComponent: boolean = false; + @observable.ref @managed manageDialogModel: ManageDialogModel; @observable.ref @managed saveDialogModel: SaveDialogModel; @@ -114,16 +126,27 @@ export class PersistenceManagerModel extend return !!this.selectedView?.isShared; } + get viewTree(): TreeView[] { + const groupedViews = groupBy(this.views, 'group'), + sortedGroupKeys = keys(groupedViews).sort(), + ret = []; + sortedGroupKeys.forEach(group => { + ret.push({itemType: 'divider', text: group}); + ret.push(...this.hierarchicalItemSpecs(sortBy(groupedViews[group], 'name'))); + }); + return ret; + } + // Internal persistence provider, used to save *this* model's state, i.e. selectedId private readonly _provider; constructor({ entity, + omitDefaultMenuComponent, onChangeAsync, persistWith, canManageGlobal, - enableTopLevelSaveButton = true, - newObjectFnAsync + enableTopLevelSaveButton = true }: PersistenceManagerConfig) { super(); makeObservable(this); @@ -131,7 +154,7 @@ export class PersistenceManagerModel extend this.entity = this.parseEntity(entity); this._canManageGlobal = canManageGlobal; this.enableTopLevelSaveButton = enableTopLevelSaveButton; - this.newObjectFn = newObjectFnAsync ?? null; + this.omitDefaultMenuComponent = omitDefaultMenuComponent; this.onChangeAsync = onChangeAsync; this.saveDialogModel = new SaveDialogModel(this, this.entity.name); this.manageDialogModel = new ManageDialogModel(this); @@ -224,6 +247,10 @@ export class PersistenceManagerModel extend this.manageDialogModel.close(); } + getHierarchyDisplayName(name) { + return name?.substring(name.lastIndexOf('\\') + 1); + } + //------------------ // Implementation //------------------ @@ -234,7 +261,7 @@ export class PersistenceManagerModel extend return ret; } - mergePendingValue(value: T) { + private mergePendingValue(value: T) { value = {...this.pendingValue, ...this.cleanValue(value)}; this.setPendingValue(value); } @@ -250,7 +277,7 @@ export class PersistenceManagerModel extend } @action - setPendingValue(value: T) { + private setPendingValue(value: T) { if (isNil(value)) { this.pendingValue = null; return; @@ -261,13 +288,13 @@ export class PersistenceManagerModel extend } } - cleanValue(value: T): T { + private cleanValue(value: T): T { // Stringify and parse to ensure that the value is valid JSON // (i.e. no object instances, no keys with undefined values, etc.) return JSON.parse(JSON.stringify(value)); } - isEqualSkipAutosize(a, b) { + private isEqualSkipAutosize(a, b) { // Skip spurious column autosize differences between states const comparer = (aVal, bVal, key, aObj) => { if (key === 'width' && !isNil(aObj.colId) && !aObj.manuallySized) return true; @@ -276,7 +303,7 @@ export class PersistenceManagerModel extend return isEqualWith(a, b, comparer); } - async confirmShareObjSaveAsync() { + private async confirmShareObjSaveAsync() { return XH.confirm({ message: `You are saving a shared public ${this.entity.displayName}. Do you wish to continue?`, confirmProps: { @@ -290,4 +317,57 @@ export class PersistenceManagerModel extend } }); } + + private hierarchicalItemSpecs(views, depth: number = 0): TreeView[] { + const groups = {}, + unbalancedStableGroupsAndViews = []; + + views.forEach(record => { + // Leaf Node + if (this.getNameHierarchySubstring(record.name, depth + 1) == null) { + unbalancedStableGroupsAndViews.push(record); + return; + } + // Belongs to an already defined group + const group = this.getNameHierarchySubstring(record.name, depth); + if (groups[group]) { + groups[group].children.push(record); + return; + } + // Belongs to a not defined group, create it + groups[group] = {name: group, children: [record], isMenuFolder: true}; + unbalancedStableGroupsAndViews.push(groups[group]); + }); + + return unbalancedStableGroupsAndViews.map(it => { + const {name, id, isMenuFolder, children} = it; + if (isMenuFolder) { + return { + itemType: 'menuFolder', + text: name, + items: this.hierarchicalItemSpecs(children, depth + 1), + selected: this.isFolderForEntry(name, this.selectedView?.name, depth) + }; + } + return { + itemType: 'view', + text: this.getHierarchyDisplayName(name), + selected: this.selectedId === id, + key: id + }; + }); + } + + private getNameHierarchySubstring(name, depth) { + const arr = name?.split('\\') ?? []; + if (arr.length <= depth) { + return null; + } + return arr.slice(0, depth + 1).join('\\'); + } + + private isFolderForEntry(folderName, entryName, depth) { + const name = this.getNameHierarchySubstring(entryName, depth); + return name && name === folderName && folderName.length < entryName.length; + } } diff --git a/desktop/cmp/persistenceManager/Types.ts b/desktop/cmp/persistenceManager/Types.ts index 9aa982eff4..ac211ea522 100644 --- a/desktop/cmp/persistenceManager/Types.ts +++ b/desktop/cmp/persistenceManager/Types.ts @@ -17,3 +17,11 @@ export interface PersistenceView { isShared: boolean; value: T; } + +export interface TreeView { + itemType: string; + text: string; + items?: TreeView[]; + selected?: boolean; + key?: number; +} From 0bf7539de866cc233c26c3880f2f247220964e6a Mon Sep 17 00:00:00 2001 From: Ryanseanlee Date: Wed, 11 Sep 2024 12:20:56 -0700 Subject: [PATCH 06/19] Favorites feature --- .../cmp/persistenceManager/PersistenceManagerModel.ts | 11 +++++++++++ desktop/cmp/persistenceManager/Types.ts | 1 + desktop/cmp/persistenceManager/impl/ManageDialog.ts | 6 ++++++ .../cmp/persistenceManager/impl/ManageDialogModel.ts | 7 +++++-- 4 files changed, 23 insertions(+), 2 deletions(-) diff --git a/desktop/cmp/persistenceManager/PersistenceManagerModel.ts b/desktop/cmp/persistenceManager/PersistenceManagerModel.ts index 550b00d503..85f3462182 100644 --- a/desktop/cmp/persistenceManager/PersistenceManagerModel.ts +++ b/desktop/cmp/persistenceManager/PersistenceManagerModel.ts @@ -126,10 +126,20 @@ export class PersistenceManagerModel extend return !!this.selectedView?.isShared; } + get favoritedViews(): PersistenceView[] { + return this.views.filter(it => it.isFavorite); + } + get viewTree(): TreeView[] { const groupedViews = groupBy(this.views, 'group'), sortedGroupKeys = keys(groupedViews).sort(), ret = []; + + if (this.favoritedViews.length > 0) { + ret.push({itemType: 'divider', text: 'Favorites'}); + ret.push(...this.hierarchicalItemSpecs(this.favoritedViews)); + } + sortedGroupKeys.forEach(group => { ret.push({itemType: 'divider', text: group}); ret.push(...this.hierarchicalItemSpecs(sortBy(groupedViews[group], 'name'))); @@ -271,6 +281,7 @@ export class PersistenceManagerModel extend name = capitalize(pluralize(entity.displayName)); return raw.map(it => { it.isShared = it.acl === '*'; + it.isFavorite = it.meta?.isFavorite; const group = it.isShared ? `Shared ${name}` : `My ${name}`; return {...it, group}; }); diff --git a/desktop/cmp/persistenceManager/Types.ts b/desktop/cmp/persistenceManager/Types.ts index ac211ea522..f26f35375f 100644 --- a/desktop/cmp/persistenceManager/Types.ts +++ b/desktop/cmp/persistenceManager/Types.ts @@ -15,6 +15,7 @@ export interface PersistenceView { type: string; group: string; isShared: boolean; + isFavorite: boolean; value: T; } diff --git a/desktop/cmp/persistenceManager/impl/ManageDialog.ts b/desktop/cmp/persistenceManager/impl/ManageDialog.ts index 6f38c83f33..d63945c6d2 100644 --- a/desktop/cmp/persistenceManager/impl/ManageDialog.ts +++ b/desktop/cmp/persistenceManager/impl/ManageDialog.ts @@ -96,6 +96,12 @@ const formPanel = hoistCmp.factory({ labelSide: 'left' }), omit: !model.canManageGlobal + }), + formField({ + field: 'isFavorite', + item: switchInput({ + labelSide: 'left' + }) }) ] }), diff --git a/desktop/cmp/persistenceManager/impl/ManageDialogModel.ts b/desktop/cmp/persistenceManager/impl/ManageDialogModel.ts index 62177c9f12..704073ce8c 100644 --- a/desktop/cmp/persistenceManager/impl/ManageDialogModel.ts +++ b/desktop/cmp/persistenceManager/impl/ManageDialogModel.ts @@ -94,7 +94,7 @@ export class ManageDialogModel extends HoistModel { async doSaveAsync() { const {formModel, parentModel, canManageGlobal, selectedId} = this, {fields, isDirty} = formModel, - {name, description, isShared} = formModel.getData(), + {name, description, isShared, isFavorite} = formModel.getData(), isValid = await formModel.validateAsync(), displayName = parentModel.entity.displayName; @@ -119,7 +119,8 @@ export class ManageDialogModel extends HoistModel { await XH.jsonBlobService.updateAsync(this.gridModel.selectedRecord.data.token, { name, description, - acl: isShared ? '*' : null + acl: isShared ? '*' : null, + meta: {isFavorite} }); await this.parentModel.refreshAsync(); @@ -176,6 +177,7 @@ export class ManageDialogModel extends HoistModel { {name: 'name', type: 'string'}, {name: 'description', type: 'string'}, {name: 'isShared', type: 'bool'}, + {name: 'isFavorite', type: 'bool'}, {name: 'acl', type: 'json'}, {name: 'meta', type: 'json'}, {name: 'dateCreated', type: 'date'}, @@ -207,6 +209,7 @@ export class ManageDialogModel extends HoistModel { {name: 'name', rules: [required, lengthIs({max: 255})]}, {name: 'description'}, {name: 'isShared', displayName: 'Shared'}, + {name: 'isFavorite', displayName: 'Favorite'}, {name: 'owner', readonly: true}, {name: 'dateCreated', displayName: 'Created', readonly: true}, {name: 'lastUpdated', displayName: 'Updated', readonly: true}, From b9b94475319221369a462c9a7fbbd031b49343aa Mon Sep 17 00:00:00 2001 From: Ryanseanlee Date: Thu, 12 Sep 2024 16:19:30 -0700 Subject: [PATCH 07/19] Fix Favorites feature --- .../persistenceManager/PersistenceManager.ts | 6 +-- .../PersistenceManagerModel.ts | 28 ++++++++--- desktop/cmp/persistenceManager/Types.ts | 4 +- .../impl/ManageDialogModel.ts | 47 ++++++++++++++----- 4 files changed, 62 insertions(+), 23 deletions(-) diff --git a/desktop/cmp/persistenceManager/PersistenceManager.ts b/desktop/cmp/persistenceManager/PersistenceManager.ts index 7be97a07f4..bf17f9bcb2 100644 --- a/desktop/cmp/persistenceManager/PersistenceManager.ts +++ b/desktop/cmp/persistenceManager/PersistenceManager.ts @@ -129,7 +129,7 @@ const objMenu = hoistCmp.factory({ }); function buildView(view: TreeView, model: PersistenceManagerModel): ReactNode { - const {itemType, text, selected, items, key} = view, + const {itemType, text, selected, items, id, isFavorite} = view, icon = selected ? Icon.check() : Icon.placeholder(); switch (itemType) { case 'divider': @@ -143,10 +143,10 @@ function buildView(view: TreeView, model: PersistenceManagerModel): ReactNode { }); case 'view': return menuItem({ - key, + key: isFavorite ? `${id}-isFavorite` : id, icon, text, - onClick: () => model.selectAsync(key).linkTo(model.loadModel) + onClick: () => model.selectAsync(id).linkTo(model.loadModel) }); } } diff --git a/desktop/cmp/persistenceManager/PersistenceManagerModel.ts b/desktop/cmp/persistenceManager/PersistenceManagerModel.ts index 85f3462182..b8b03f5645 100644 --- a/desktop/cmp/persistenceManager/PersistenceManagerModel.ts +++ b/desktop/cmp/persistenceManager/PersistenceManagerModel.ts @@ -93,6 +93,7 @@ export class PersistenceManagerModel extend @observable.ref views: PersistenceView[] = []; @bindable selectedId: StoreRecordId; + @bindable favorites: string[] = []; get canManageGlobal(): boolean { return executeIfFunction(this._canManageGlobal); @@ -127,7 +128,7 @@ export class PersistenceManagerModel extend } get favoritedViews(): PersistenceView[] { - return this.views.filter(it => it.isFavorite); + return this.views.filter(it => this.favorites.includes(it.token)); } get viewTree(): TreeView[] { @@ -137,7 +138,15 @@ export class PersistenceManagerModel extend if (this.favoritedViews.length > 0) { ret.push({itemType: 'divider', text: 'Favorites'}); - ret.push(...this.hierarchicalItemSpecs(this.favoritedViews)); + ret.push( + ...this.favoritedViews.map(it => ({ + itemType: 'view', + text: this.getHierarchyDisplayName(it.name), + selected: this.selectedId === it.id, + id: it.id, + isFavorite: true + })) + ); } sortedGroupKeys.forEach(group => { @@ -147,6 +156,13 @@ export class PersistenceManagerModel extend return ret; } + get persistState() { + const ret: PlainObject = {}; + if (this.selectedId) ret.selectedId = this.selectedId; + if (this.favorites) ret.favorites = this.favorites; + return ret; + } + // Internal persistence provider, used to save *this* model's state, i.e. selectedId private readonly _provider; @@ -178,9 +194,10 @@ export class PersistenceManagerModel extend const state = this._provider.read(); if (state?.selectedId) this.selectedId = state.selectedId; + if (state?.favorites) this.favorites = state.favorites; this.addReaction({ - track: () => this.selectedId, - run: selectedId => this._provider.write({selectedId}) + track: () => this.persistState, + run: state => this._provider.write(state) }); } catch (e) { this.logError('Error applying persistWith', persistWith, e); @@ -281,7 +298,6 @@ export class PersistenceManagerModel extend name = capitalize(pluralize(entity.displayName)); return raw.map(it => { it.isShared = it.acl === '*'; - it.isFavorite = it.meta?.isFavorite; const group = it.isShared ? `Shared ${name}` : `My ${name}`; return {...it, group}; }); @@ -364,7 +380,7 @@ export class PersistenceManagerModel extend itemType: 'view', text: this.getHierarchyDisplayName(name), selected: this.selectedId === id, - key: id + id: id }; }); } diff --git a/desktop/cmp/persistenceManager/Types.ts b/desktop/cmp/persistenceManager/Types.ts index f26f35375f..c9f76104b9 100644 --- a/desktop/cmp/persistenceManager/Types.ts +++ b/desktop/cmp/persistenceManager/Types.ts @@ -15,7 +15,6 @@ export interface PersistenceView { type: string; group: string; isShared: boolean; - isFavorite: boolean; value: T; } @@ -24,5 +23,6 @@ export interface TreeView { text: string; items?: TreeView[]; selected?: boolean; - key?: number; + id?: number; + isFavorite?: boolean; } diff --git a/desktop/cmp/persistenceManager/impl/ManageDialogModel.ts b/desktop/cmp/persistenceManager/impl/ManageDialogModel.ts index 704073ce8c..5f5cb28ab6 100644 --- a/desktop/cmp/persistenceManager/impl/ManageDialogModel.ts +++ b/desktop/cmp/persistenceManager/impl/ManageDialogModel.ts @@ -4,6 +4,7 @@ import {HoistModel, managed, XH} from '@xh/hoist/core'; import {lengthIs, required} from '@xh/hoist/data'; import {Icon} from '@xh/hoist/icon'; import {bindable, makeObservable} from '@xh/hoist/mobx'; +import {includes} from 'lodash'; import {PersistenceManagerModel} from '../PersistenceManagerModel'; export class ManageDialogModel extends HoistModel { @@ -63,7 +64,8 @@ export class ManageDialogModel extends HoistModel { if (record) { this.formModel.readonly = !this.canEdit; this.formModel.init({ - ...record.data + ...record.data, + isFavorite: includes(this.parentModel.favorites, record.data.token) }); } } @@ -92,11 +94,12 @@ export class ManageDialogModel extends HoistModel { //------------------------ async doSaveAsync() { - const {formModel, parentModel, canManageGlobal, selectedId} = this, + const {formModel, parentModel, canManageGlobal, selectedId, gridModel} = this, {fields, isDirty} = formModel, {name, description, isShared, isFavorite} = formModel.getData(), isValid = await formModel.validateAsync(), - displayName = parentModel.entity.displayName; + displayName = parentModel.entity.displayName, + token = gridModel.selectedRecord.data.token; if (!isValid || !selectedId || !isDirty) return; @@ -116,11 +119,18 @@ export class ManageDialogModel extends HoistModel { if (!confirmed) return; } - await XH.jsonBlobService.updateAsync(this.gridModel.selectedRecord.data.token, { + if (fields.isFavorite.isDirty) { + if (isFavorite) { + parentModel.favorites = [...parentModel.favorites, token]; + } else { + parentModel.favorites = parentModel.favorites.filter(it => it !== token); + } + } + + await XH.jsonBlobService.updateAsync(token, { name, description, - acl: isShared ? '*' : null, - meta: {isFavorite} + acl: isShared ? '*' : null }); await this.parentModel.refreshAsync(); @@ -128,7 +138,10 @@ export class ManageDialogModel extends HoistModel { } async doDeleteAsync() { - const {selectedRecord} = this.gridModel; + const {parentModel, gridModel, formModel} = this, + {selectedRecord} = gridModel, + {isFavorite} = formModel.getData(), + {favorites} = parentModel; if (!selectedRecord) return; const {name, token} = selectedRecord.data; @@ -139,17 +152,27 @@ export class ManageDialogModel extends HoistModel { }); if (!confirmed) return; + if (formModel.fields.isFavorite.isDirty) { + if (isFavorite) { + parentModel.favorites = [...favorites, token]; + } else { + parentModel.favorites = favorites.filter(it => it !== token); + } + } + await XH.jsonBlobService.archiveAsync(token); - await this.parentModel.refreshAsync(); + await parentModel.refreshAsync(); await this.refreshModelsAsync(); } async refreshModelsAsync() { - const {views} = this.parentModel; - this.gridModel.loadData(views); + const {views, favorites} = this.parentModel, + {gridModel, formModel} = this; + gridModel.loadData(views); await this.ensureGridHasSelection(); - this.formModel.init({ - ...this.gridModel.selectedRecord.data + formModel.init({ + ...gridModel.selectedRecord.data, + isFavorite: includes(favorites, gridModel.selectedRecord.data.token) }); } From 103dafa62f8838cf474114c69ee569a020a260f4 Mon Sep 17 00:00:00 2001 From: Ryanseanlee Date: Tue, 17 Sep 2024 15:21:32 -0700 Subject: [PATCH 08/19] Greg CR --- .../persistenceManager/PersistenceManager.ts | 118 ++++++++++-------- .../PersistenceManagerModel.ts | 65 +++++----- desktop/cmp/persistenceManager/Types.ts | 23 ++-- .../impl/ManageDialogModel.ts | 4 +- 4 files changed, 113 insertions(+), 97 deletions(-) diff --git a/desktop/cmp/persistenceManager/PersistenceManager.ts b/desktop/cmp/persistenceManager/PersistenceManager.ts index bf17f9bcb2..9ed5835aa6 100644 --- a/desktop/cmp/persistenceManager/PersistenceManager.ts +++ b/desktop/cmp/persistenceManager/PersistenceManager.ts @@ -1,7 +1,7 @@ import {div, fragment, hbox} from '@xh/hoist/cmp/layout'; import {hoistCmp, HoistProps, uses} from '@xh/hoist/core'; import {button} from '@xh/hoist/desktop/cmp/button'; -import {TreeView} from '@xh/hoist/desktop/cmp/persistenceManager/Types'; +import {PersistenceViewTree} from '@xh/hoist/desktop/cmp/persistenceManager/Types'; import {Icon} from '@xh/hoist/icon/Icon'; import {menu, menuDivider, menuItem, popover} from '@xh/hoist/kit/blueprint'; import {capitalize} from 'lodash'; @@ -15,6 +15,10 @@ import {pluralize} from '@xh/hoist/utils/js'; interface PersistenceManagerProps extends HoistProps { /** True to disable options for saving/managing items. */ minimal?: boolean; + /** True (default) to render a save button alongside the primary menu button when dirty. */ + enableTopLevelSaveButton?: boolean; + /** True to omit the default menu component. Should be used when creating custom app-specific component */ + omitDefaultMenuComponent?: boolean; } export const [PersistenceManager, persistenceManager] = @@ -22,46 +26,8 @@ export const [PersistenceManager, persistenceManager] = displayName: 'PersistenceManager', model: uses(PersistenceManagerModel), - render({model, minimal = false}) { - const { - selectedView, - isShared, - entity, - manageDialogModel, - saveDialogModel, - omitDefaultMenuComponent - } = model, - displayName = entity.displayName; - - return fragment( - hbox({ - className: 'xh-persistence-manager', - items: [ - popover({ - omit: omitDefaultMenuComponent, - item: button({ - text: model.getHierarchyDisplayName(selectedView?.name) ?? `-`, - icon: isShared ? Icon.users() : Icon.bookmark(), - rightIcon: Icon.chevronDown(), - outlined: true - }), - content: div({ - items: [ - div({ - className: 'xh-popup__title', - item: capitalize(pluralize(displayName)) - }), - objMenu({minimal}) - ] - }), - placement: 'bottom-start' - }), - saveButton() - ] - }), - manageDialog({omit: !manageDialogModel}), - saveDialog({omit: !saveDialogModel}) - ); + render({model, ...props}) { + return fragment(defaultMenu({...props}), manageDialog(), saveDialog()); } }); @@ -69,13 +35,47 @@ export const [PersistenceManager, persistenceManager] = // Implementation //------------------------ +const defaultMenu = hoistCmp.factory({ + render({ + model, + omitDefaultMenuComponent = false, + minimal = false, + enableTopLevelSaveButton = true + }) { + const {selectedView, isShared, entity} = model, + displayName = entity.displayName; + return hbox({ + className: 'xh-persistence-manager', + items: [ + popover({ + omit: omitDefaultMenuComponent, + item: button({ + text: model.getHierarchyDisplayName(selectedView?.name) ?? `-`, + icon: isShared ? Icon.users() : Icon.bookmark(), + rightIcon: Icon.chevronDown(), + outlined: true + }), + content: div( + div({ + className: 'xh-popup__title', + item: capitalize(pluralize(displayName)) + }), + objMenu({minimal}) + ), + placement: 'bottom-start' + }), + saveButton({omit: !enableTopLevelSaveButton || !model.canSave}) + ] + }); + } +}); + const saveButton = hoistCmp.factory({ render({model}) { return button({ icon: Icon.save(), tooltip: `Save changes to this ${model.entity.displayName}`, intent: 'primary', - omit: !model.enableTopLevelSaveButton || !model.canSave, onClick: () => model.saveAsync(false).linkTo(model.loadModel) }); } @@ -86,7 +86,22 @@ const objMenu = hoistCmp.factory({ const {loadModel, entity} = model, items = []; + if (model.favoritedViews.length > 0) { + items.push(menuDivider({title: 'Favorites'})); + items.push( + ...model.favoritedViews.map(it => { + return menuItem({ + key: `${it.id}-isFavorite`, + icon: model.selectedId === it.id ? Icon.check() : Icon.placeholder(), + text: model.getHierarchyDisplayName(it.name), + onClick: () => model.selectAsync(it.id).linkTo(model.loadModel) + }); + }) + ); + } + model.viewTree.forEach(it => { + if (it.type === 'divider') items.push(menuDivider({title: it.text})); items.push(buildView(it, model)); }); @@ -114,7 +129,8 @@ const objMenu = hoistCmp.factory({ }), menuItem({ icon: Icon.refresh(), - text: 'Reset Defaults', + text: 'Reset Default View', + omit: !model.isAllowEmpty, onClick: () => model.selectAsync(null).linkTo(loadModel) }), menuDivider(), @@ -128,25 +144,23 @@ const objMenu = hoistCmp.factory({ } }); -function buildView(view: TreeView, model: PersistenceManagerModel): ReactNode { - const {itemType, text, selected, items, id, isFavorite} = view, +function buildView(view: PersistenceViewTree, model: PersistenceManagerModel): ReactNode { + const {type, text, selected} = view, icon = selected ? Icon.check() : Icon.placeholder(); - switch (itemType) { - case 'divider': - return menuDivider({title: text}); - case 'menuFolder': + switch (type) { + case 'directory': return menuItem({ text, icon, shouldDismissPopover: false, - children: items ? items.map(child => buildView(child, model)) : [] + children: view.items ? view.items.map(child => buildView(child, model)) : [] }); case 'view': return menuItem({ - key: isFavorite ? `${id}-isFavorite` : id, + key: view.isFavorite ? `${view.id}-isFavorite` : view.id, icon, text, - onClick: () => model.selectAsync(id).linkTo(model.loadModel) + onClick: () => model.selectAsync(view.id).linkTo(model.loadModel) }); } } diff --git a/desktop/cmp/persistenceManager/PersistenceManagerModel.ts b/desktop/cmp/persistenceManager/PersistenceManagerModel.ts index b8b03f5645..217da72453 100644 --- a/desktop/cmp/persistenceManager/PersistenceManagerModel.ts +++ b/desktop/cmp/persistenceManager/PersistenceManagerModel.ts @@ -25,7 +25,7 @@ import { import {ManageDialogModel} from './impl/ManageDialogModel'; import {SaveDialogModel} from './impl/SaveDialogModel'; import {runInAction} from 'mobx'; -import {PersistenceView, TreeView} from '@xh/hoist/desktop/cmp/persistenceManager/Types'; +import {PersistenceView, PersistenceViewTree} from '@xh/hoist/desktop/cmp/persistenceManager/Types'; /** * PersistenceManager provides re-usable loading, selection, and user management of named configs, which are modelled @@ -49,19 +49,16 @@ interface Entity { } export interface PersistenceManagerConfig { + /** Entity name or object for this model. */ entity: string | Entity; - /** True to omit the default menu component. Should be used when creating custom app-specific component */ - omitDefaultMenuComponent?: boolean; /** Whether user can publish or edit globally shared objects. */ canManageGlobal: Thunkable; /** Async callback triggered when view changes. Should be used to recreate the affected models. */ onChangeAsync: (value: T) => void; /** Used to persist this model's selected ID. */ persistWith: PersistOptions; - /** True (default) to render a save button alongside the primary menu button when dirty. */ - enableTopLevelSaveButton?: boolean; - /** Fn to produce a new, empty object - can be async. */ - newObjectFnAsync?: () => T; + /** Optional flag to force selection of a view. Defaults false*/ + allowEmpty?: boolean; } export class PersistenceManagerModel extends HoistModel { @@ -74,24 +71,23 @@ export class PersistenceManagerModel extend setData: (value: T) => this.mergePendingValue(value) }; - readonly enableTopLevelSaveButton: boolean = true; - private readonly _canManageGlobal: Thunkable; + private readonly _allowEmpty: boolean; + readonly onChangeAsync?: (value: T) => void; readonly entity: Entity; - readonly omitDefaultMenuComponent: boolean = false; - - @observable.ref @managed manageDialogModel: ManageDialogModel; + @managed readonly manageDialogModel: ManageDialogModel; - @observable.ref @managed saveDialogModel: SaveDialogModel; + @managed readonly saveDialogModel: SaveDialogModel; /** Current state of the active object, can include not-yet-persisted changes. */ @observable.ref pendingValue: T = null; @observable.ref views: PersistenceView[] = []; + @bindable private _loadedInitially: boolean = false; @bindable selectedId: StoreRecordId; @bindable favorites: string[] = []; @@ -127,30 +123,25 @@ export class PersistenceManagerModel extend return !!this.selectedView?.isShared; } + get isAllowEmpty(): boolean { + return this._allowEmpty; + } + + get isLoadedInitially(): boolean { + return this._loadedInitially; + } + get favoritedViews(): PersistenceView[] { return this.views.filter(it => this.favorites.includes(it.token)); } - get viewTree(): TreeView[] { + get viewTree(): PersistenceViewTree[] { const groupedViews = groupBy(this.views, 'group'), sortedGroupKeys = keys(groupedViews).sort(), ret = []; - if (this.favoritedViews.length > 0) { - ret.push({itemType: 'divider', text: 'Favorites'}); - ret.push( - ...this.favoritedViews.map(it => ({ - itemType: 'view', - text: this.getHierarchyDisplayName(it.name), - selected: this.selectedId === it.id, - id: it.id, - isFavorite: true - })) - ); - } - sortedGroupKeys.forEach(group => { - ret.push({itemType: 'divider', text: group}); + ret.push({type: 'divider', text: group}); ret.push(...this.hierarchicalItemSpecs(sortBy(groupedViews[group], 'name'))); }); return ret; @@ -168,19 +159,17 @@ export class PersistenceManagerModel extend constructor({ entity, - omitDefaultMenuComponent, onChangeAsync, persistWith, canManageGlobal, - enableTopLevelSaveButton = true + allowEmpty = false }: PersistenceManagerConfig) { super(); makeObservable(this); this.entity = this.parseEntity(entity); this._canManageGlobal = canManageGlobal; - this.enableTopLevelSaveButton = enableTopLevelSaveButton; - this.omitDefaultMenuComponent = omitDefaultMenuComponent; + this._allowEmpty = allowEmpty; this.onChangeAsync = onChangeAsync; this.saveDialogModel = new SaveDialogModel(this, this.entity.name); this.manageDialogModel = new ManageDialogModel(this); @@ -217,7 +206,7 @@ export class PersistenceManagerModel extend runInAction(() => (this.views = this.processRaw(rawViews))); // Always call selectAsync to ensure pendingValue updated and onChangeAsync callback fired if needed - const id = this.selectedView?.id ?? this.views[0].id; + const id = (this.selectedView?.id ?? this.isAllowEmpty) ? this.views[0]?.id : null; await this.selectAsync(id); } @@ -229,6 +218,7 @@ export class PersistenceManagerModel extend this.setPendingValue(value); await this.onChangeAsync(value); + this._loadedInitially = true; } async saveAsync(skipToast: boolean = false) { @@ -345,7 +335,7 @@ export class PersistenceManagerModel extend }); } - private hierarchicalItemSpecs(views, depth: number = 0): TreeView[] { + private hierarchicalItemSpecs(views, depth: number = 0): PersistenceViewTree[] { const groups = {}, unbalancedStableGroupsAndViews = []; @@ -370,17 +360,18 @@ export class PersistenceManagerModel extend const {name, id, isMenuFolder, children} = it; if (isMenuFolder) { return { - itemType: 'menuFolder', + type: 'directory', text: name, items: this.hierarchicalItemSpecs(children, depth + 1), selected: this.isFolderForEntry(name, this.selectedView?.name, depth) }; } return { - itemType: 'view', + type: 'view', text: this.getHierarchyDisplayName(name), selected: this.selectedId === id, - id: id + id: id, + isFavorite: false }; }); } diff --git a/desktop/cmp/persistenceManager/Types.ts b/desktop/cmp/persistenceManager/Types.ts index c9f76104b9..0bc1463880 100644 --- a/desktop/cmp/persistenceManager/Types.ts +++ b/desktop/cmp/persistenceManager/Types.ts @@ -18,11 +18,20 @@ export interface PersistenceView { value: T; } -export interface TreeView { - itemType: string; +export type PersistenceViewTree = { text: string; - items?: TreeView[]; - selected?: boolean; - id?: number; - isFavorite?: boolean; -} + selected: boolean; +} & ( + | { + type: 'directory'; + items: PersistenceViewTree[]; + } + | { + type: 'view'; + id: number; + isFavorite: boolean; + } + | { + type: 'divider'; + } +); diff --git a/desktop/cmp/persistenceManager/impl/ManageDialogModel.ts b/desktop/cmp/persistenceManager/impl/ManageDialogModel.ts index 5f5cb28ab6..b587b572b5 100644 --- a/desktop/cmp/persistenceManager/impl/ManageDialogModel.ts +++ b/desktop/cmp/persistenceManager/impl/ManageDialogModel.ts @@ -29,7 +29,9 @@ export class ManageDialogModel extends HoistModel { } get canDelete(): boolean { - return this.parentModel.views.length > 1 && (this.canManageGlobal || !this.selIsShared); + const {parentModel, selIsShared, canManageGlobal} = this, + {views, isAllowEmpty} = parentModel; + return (isAllowEmpty ? views.length > 1 : true) && (canManageGlobal || !selIsShared); } get canEdit(): boolean { From 9ca0fab8c553bd5a22e2b04bb77b613588e6b924 Mon Sep 17 00:00:00 2001 From: Ryanseanlee Date: Fri, 20 Sep 2024 13:55:50 -0700 Subject: [PATCH 09/19] Favorites --- .../PersistenceManager.scss | 20 +- .../persistenceManager/PersistenceManager.ts | 171 ++-------------- .../PersistenceManagerModel.ts | 103 +++++----- desktop/cmp/persistenceManager/Types.ts | 4 - .../{impl => cmp}/ManageDialog.ts | 1 + .../{impl => cmp}/ManageDialogModel.ts | 22 +- .../persistenceManager/cmp/PersistenceMenu.ts | 190 ++++++++++++++++++ .../{impl => cmp}/SaveDialog.ts | 5 +- .../{impl => cmp}/SaveDialogModel.ts | 0 svc/JsonBlobService.ts | 10 +- 10 files changed, 309 insertions(+), 217 deletions(-) rename desktop/cmp/persistenceManager/{impl => cmp}/ManageDialog.ts (99%) rename desktop/cmp/persistenceManager/{impl => cmp}/ManageDialogModel.ts (94%) create mode 100644 desktop/cmp/persistenceManager/cmp/PersistenceMenu.ts rename desktop/cmp/persistenceManager/{impl => cmp}/SaveDialog.ts (94%) rename desktop/cmp/persistenceManager/{impl => cmp}/SaveDialogModel.ts (100%) diff --git a/desktop/cmp/persistenceManager/PersistenceManager.scss b/desktop/cmp/persistenceManager/PersistenceManager.scss index 107044c3bc..74d64b63cf 100644 --- a/desktop/cmp/persistenceManager/PersistenceManager.scss +++ b/desktop/cmp/persistenceManager/PersistenceManager.scss @@ -8,7 +8,7 @@ // Dialogs &__manage-dialog, - &__save-as-dialog { + &__save-dialog { &__form { padding: var(--xh-pad-px); @@ -27,4 +27,22 @@ } } } + + &__menu-item-fav { + opacity: 0.1; + + &:hover { + opacity: 1; + color: var(--xh-yellow); + } + + &--active { + opacity: 1; + color: var(--xh-yellow); + + &:hover { + color: var(--xh-orange-muted); + } + } + } } diff --git a/desktop/cmp/persistenceManager/PersistenceManager.ts b/desktop/cmp/persistenceManager/PersistenceManager.ts index 9ed5835aa6..5159106808 100644 --- a/desktop/cmp/persistenceManager/PersistenceManager.ts +++ b/desktop/cmp/persistenceManager/PersistenceManager.ts @@ -1,166 +1,33 @@ -import {div, fragment, hbox} from '@xh/hoist/cmp/layout'; +import {fragment} from '@xh/hoist/cmp/layout'; import {hoistCmp, HoistProps, uses} from '@xh/hoist/core'; -import {button} from '@xh/hoist/desktop/cmp/button'; -import {PersistenceViewTree} from '@xh/hoist/desktop/cmp/persistenceManager/Types'; -import {Icon} from '@xh/hoist/icon/Icon'; -import {menu, menuDivider, menuItem, popover} from '@xh/hoist/kit/blueprint'; -import {capitalize} from 'lodash'; -import {ReactNode} from 'react'; -import {manageDialog} from './impl/ManageDialog'; -import {saveDialog} from './impl/SaveDialog'; import './PersistenceManager.scss'; import {PersistenceManagerModel} from './PersistenceManagerModel'; -import {pluralize} from '@xh/hoist/utils/js'; +import {manageDialog} from './cmp/ManageDialog'; +import {saveDialog} from './cmp/SaveDialog'; +import {persistenceMenu, PersistenceMenuProps} from './cmp/PersistenceMenu'; -interface PersistenceManagerProps extends HoistProps { - /** True to disable options for saving/managing items. */ - minimal?: boolean; - /** True (default) to render a save button alongside the primary menu button when dirty. */ - enableTopLevelSaveButton?: boolean; - /** True to omit the default menu component. Should be used when creating custom app-specific component */ - omitDefaultMenuComponent?: boolean; +export interface PersistenceManagerProps extends HoistProps { + persistenceMenu: boolean | PersistenceMenuProps; } export const [PersistenceManager, persistenceManager] = hoistCmp.withFactory({ displayName: 'PersistenceManager', + className: 'xh-persistence-manager', model: uses(PersistenceManagerModel), render({model, ...props}) { - return fragment(defaultMenu({...props}), manageDialog(), saveDialog()); - } - }); - -//------------------------ -// Implementation -//------------------------ - -const defaultMenu = hoistCmp.factory({ - render({ - model, - omitDefaultMenuComponent = false, - minimal = false, - enableTopLevelSaveButton = true - }) { - const {selectedView, isShared, entity} = model, - displayName = entity.displayName; - return hbox({ - className: 'xh-persistence-manager', - items: [ - popover({ - omit: omitDefaultMenuComponent, - item: button({ - text: model.getHierarchyDisplayName(selectedView?.name) ?? `-`, - icon: isShared ? Icon.users() : Icon.bookmark(), - rightIcon: Icon.chevronDown(), - outlined: true - }), - content: div( - div({ - className: 'xh-popup__title', - item: capitalize(pluralize(displayName)) - }), - objMenu({minimal}) - ), - placement: 'bottom-start' - }), - saveButton({omit: !enableTopLevelSaveButton || !model.canSave}) - ] - }); - } -}); - -const saveButton = hoistCmp.factory({ - render({model}) { - return button({ - icon: Icon.save(), - tooltip: `Save changes to this ${model.entity.displayName}`, - intent: 'primary', - onClick: () => model.saveAsync(false).linkTo(model.loadModel) - }); - } -}); - -const objMenu = hoistCmp.factory({ - render({model, minimal}) { - const {loadModel, entity} = model, - items = []; - - if (model.favoritedViews.length > 0) { - items.push(menuDivider({title: 'Favorites'})); - items.push( - ...model.favoritedViews.map(it => { - return menuItem({ - key: `${it.id}-isFavorite`, - icon: model.selectedId === it.id ? Icon.check() : Icon.placeholder(), - text: model.getHierarchyDisplayName(it.name), - onClick: () => model.selectAsync(it.id).linkTo(model.loadModel) - }); - }) + const persistenceMenuProps: PersistenceMenuProps = {}; + if (props.persistenceMenu === false) { + persistenceMenuProps.omitDefaultMenuComponent = true; + persistenceMenuProps.omitTopLevelSaveButton = false; + } else { + Object.assign(persistenceMenuProps, props.persistenceMenu); + } + return fragment( + persistenceMenu({...persistenceMenuProps}), + manageDialog(), + saveDialog() ); } - - model.viewTree.forEach(it => { - if (it.type === 'divider') items.push(menuDivider({title: it.text})); - items.push(buildView(it, model)); - }); - - if (minimal) return menu({items}); - return menu({ - items: [ - ...items, - menuDivider(), - menuItem({ - icon: Icon.save(), - text: 'Save', - disabled: !model.canSave, - onClick: () => model.saveAsync(false).linkTo(loadModel) - }), - menuItem({ - icon: Icon.copy(), - text: 'Save as...', - onClick: () => model.saveAsAsync().linkTo(loadModel) - }), - menuItem({ - icon: Icon.reset(), - text: 'Revert View', - disabled: !model.isDirty, - onClick: () => model.resetAsync().linkTo(loadModel) - }), - menuItem({ - icon: Icon.refresh(), - text: 'Reset Default View', - omit: !model.isAllowEmpty, - onClick: () => model.selectAsync(null).linkTo(loadModel) - }), - menuDivider(), - menuItem({ - icon: Icon.gear(), - text: `Manage ${pluralize(entity.displayName)}...`, - onClick: () => model.openManageDialog() - }) - ] - }); - } -}); - -function buildView(view: PersistenceViewTree, model: PersistenceManagerModel): ReactNode { - const {type, text, selected} = view, - icon = selected ? Icon.check() : Icon.placeholder(); - switch (type) { - case 'directory': - return menuItem({ - text, - icon, - shouldDismissPopover: false, - children: view.items ? view.items.map(child => buildView(child, model)) : [] - }); - case 'view': - return menuItem({ - key: view.isFavorite ? `${view.id}-isFavorite` : view.id, - icon, - text, - onClick: () => model.selectAsync(view.id).linkTo(model.loadModel) - }); - } -} + }); diff --git a/desktop/cmp/persistenceManager/PersistenceManagerModel.ts b/desktop/cmp/persistenceManager/PersistenceManagerModel.ts index 217da72453..f51c37923a 100644 --- a/desktop/cmp/persistenceManager/PersistenceManagerModel.ts +++ b/desktop/cmp/persistenceManager/PersistenceManagerModel.ts @@ -11,19 +11,9 @@ import { import {StoreRecordId} from '@xh/hoist/data/StoreRecord'; import {action, bindable, computed, makeObservable, observable} from '@xh/hoist/mobx'; import {executeIfFunction, pluralize} from '@xh/hoist/utils/js'; -import { - capitalize, - cloneDeep, - groupBy, - isEqualWith, - isNil, - isString, - keys, - sortBy, - startCase -} from 'lodash'; -import {ManageDialogModel} from './impl/ManageDialogModel'; -import {SaveDialogModel} from './impl/SaveDialogModel'; +import {capitalize, cloneDeep, isEqualWith, isNil, isString, sortBy, startCase} from 'lodash'; +import {ManageDialogModel} from './cmp/ManageDialogModel'; +import {SaveDialogModel} from './cmp/SaveDialogModel'; import {runInAction} from 'mobx'; import {PersistenceView, PersistenceViewTree} from '@xh/hoist/desktop/cmp/persistenceManager/Types'; @@ -57,8 +47,8 @@ export interface PersistenceManagerConfig { onChangeAsync: (value: T) => void; /** Used to persist this model's selected ID. */ persistWith: PersistOptions; - /** Optional flag to force selection of a view. Defaults false*/ - allowEmpty?: boolean; + /** Optional flag to allow empty view selection. Defaults false*/ + enableDefault?: boolean; } export class PersistenceManagerModel extends HoistModel { @@ -73,7 +63,10 @@ export class PersistenceManagerModel extend private readonly _canManageGlobal: Thunkable; - private readonly _allowEmpty: boolean; + // Internal persistence provider, used to save *this* model's state, i.e. selectedId + private readonly _provider; + + readonly enableDefault: boolean; readonly onChangeAsync?: (value: T) => void; @@ -87,8 +80,7 @@ export class PersistenceManagerModel extend @observable.ref views: PersistenceView[] = []; - @bindable private _loadedInitially: boolean = false; - @bindable selectedId: StoreRecordId; + @bindable selectedId: StoreRecordId = null; @bindable favorites: string[] = []; get canManageGlobal(): boolean { @@ -123,53 +115,47 @@ export class PersistenceManagerModel extend return !!this.selectedView?.isShared; } - get isAllowEmpty(): boolean { - return this._allowEmpty; - } - get isLoadedInitially(): boolean { - return this._loadedInitially; + return !!this.loadSupport.lastSucceeded; } get favoritedViews(): PersistenceView[] { return this.views.filter(it => this.favorites.includes(it.token)); } - get viewTree(): PersistenceViewTree[] { - const groupedViews = groupBy(this.views, 'group'), - sortedGroupKeys = keys(groupedViews).sort(), - ret = []; + get sharedViews(): PersistenceView[] { + return this.views.filter(it => it.isShared); + } - sortedGroupKeys.forEach(group => { - ret.push({type: 'divider', text: group}); - ret.push(...this.hierarchicalItemSpecs(sortBy(groupedViews[group], 'name'))); - }); - return ret; + get privateViews(): PersistenceView[] { + return this.views.filter(it => !it.isShared); } - get persistState() { - const ret: PlainObject = {}; - if (this.selectedId) ret.selectedId = this.selectedId; - if (this.favorites) ret.favorites = this.favorites; - return ret; + get sharedViewTree(): PersistenceViewTree[] { + return this.hierarchicalItemSpecs(sortBy(this.sharedViews, 'name')); } - // Internal persistence provider, used to save *this* model's state, i.e. selectedId - private readonly _provider; + get privateViewTree(): PersistenceViewTree[] { + return this.hierarchicalItemSpecs(sortBy(this.privateViews, 'name')); + } + + get persistState() { + return {selectedId: this.selectedId, favorites: this.favorites}; + } constructor({ entity, onChangeAsync, persistWith, canManageGlobal, - allowEmpty = false + enableDefault = false }: PersistenceManagerConfig) { super(); makeObservable(this); this.entity = this.parseEntity(entity); this._canManageGlobal = canManageGlobal; - this._allowEmpty = allowEmpty; + this.enableDefault = enableDefault; this.onChangeAsync = onChangeAsync; this.saveDialogModel = new SaveDialogModel(this, this.entity.name); this.manageDialogModel = new ManageDialogModel(this); @@ -201,12 +187,15 @@ export class PersistenceManagerModel extend // TODO - Carefully review if this method needs isStale checks, and how to properly implement them. override async doLoadAsync(loadSpec: LoadSpec) { const {name} = this.entity, - rawViews = await XH.jsonBlobService.listAsync({type: name, includeValue: true}); + rawViews = await XH.jsonBlobService.listAsync( + {type: name, includeValue: true}, + loadSpec + ); runInAction(() => (this.views = this.processRaw(rawViews))); // Always call selectAsync to ensure pendingValue updated and onChangeAsync callback fired if needed - const id = (this.selectedView?.id ?? this.isAllowEmpty) ? this.views[0]?.id : null; + const id = this.selectedView?.id ?? (!this.enableDefault ? this.views[0]?.id : null); await this.selectAsync(id); } @@ -218,7 +207,6 @@ export class PersistenceManagerModel extend this.setPendingValue(value); await this.onChangeAsync(value); - this._loadedInitially = true; } async saveAsync(skipToast: boolean = false) { @@ -254,6 +242,30 @@ export class PersistenceManagerModel extend return this.selectAsync(this.selectedId); } + toggleFavorite(id: number) { + const token = this.views.find(it => it.id === id)?.token; + if (!token) return; + + if (this.favorites.includes(token)) { + this.removeFavorite(token); + } else { + this.addFavorite(token); + } + } + + addFavorite(token: string) { + this.favorites = [...this.favorites, token]; + } + + removeFavorite(token: string) { + this.favorites = this.favorites.filter(it => it !== token); + } + + isFavorite(id: number) { + const token = this.views.find(it => it.id === id)?.token; + return this.favorites.includes(token); + } + @action openManageDialog() { this.manageDialogModel.openAsync(); @@ -370,8 +382,7 @@ export class PersistenceManagerModel extend type: 'view', text: this.getHierarchyDisplayName(name), selected: this.selectedId === id, - id: id, - isFavorite: false + id }; }); } diff --git a/desktop/cmp/persistenceManager/Types.ts b/desktop/cmp/persistenceManager/Types.ts index 0bc1463880..534e9ae267 100644 --- a/desktop/cmp/persistenceManager/Types.ts +++ b/desktop/cmp/persistenceManager/Types.ts @@ -29,9 +29,5 @@ export type PersistenceViewTree = { | { type: 'view'; id: number; - isFavorite: boolean; - } - | { - type: 'divider'; } ); diff --git a/desktop/cmp/persistenceManager/impl/ManageDialog.ts b/desktop/cmp/persistenceManager/cmp/ManageDialog.ts similarity index 99% rename from desktop/cmp/persistenceManager/impl/ManageDialog.ts rename to desktop/cmp/persistenceManager/cmp/ManageDialog.ts index d63945c6d2..17a578b052 100644 --- a/desktop/cmp/persistenceManager/impl/ManageDialog.ts +++ b/desktop/cmp/persistenceManager/cmp/ManageDialog.ts @@ -17,6 +17,7 @@ import {capitalize} from 'lodash'; export const manageDialog = hoistCmp.factory({ displayName: 'ManageDialog', + className: 'xh-persistence-manager__manage-dialog', model: uses(ManageDialogModel), render({model}) { diff --git a/desktop/cmp/persistenceManager/impl/ManageDialogModel.ts b/desktop/cmp/persistenceManager/cmp/ManageDialogModel.ts similarity index 94% rename from desktop/cmp/persistenceManager/impl/ManageDialogModel.ts rename to desktop/cmp/persistenceManager/cmp/ManageDialogModel.ts index b587b572b5..33018ba851 100644 --- a/desktop/cmp/persistenceManager/impl/ManageDialogModel.ts +++ b/desktop/cmp/persistenceManager/cmp/ManageDialogModel.ts @@ -4,7 +4,7 @@ import {HoistModel, managed, XH} from '@xh/hoist/core'; import {lengthIs, required} from '@xh/hoist/data'; import {Icon} from '@xh/hoist/icon'; import {bindable, makeObservable} from '@xh/hoist/mobx'; -import {includes} from 'lodash'; +import {includes, isEmpty} from 'lodash'; import {PersistenceManagerModel} from '../PersistenceManagerModel'; export class ManageDialogModel extends HoistModel { @@ -24,14 +24,10 @@ export class ManageDialogModel extends HoistModel { return this.gridModel.selectedRecord?.data.isShared ?? false; } - get userCreated(): boolean { - return this.gridModel.selectedRecord?.data.createdBy === XH.getUser().username; - } - get canDelete(): boolean { const {parentModel, selIsShared, canManageGlobal} = this, - {views, isAllowEmpty} = parentModel; - return (isAllowEmpty ? views.length > 1 : true) && (canManageGlobal || !selIsShared); + {views, enableDefault} = parentModel; + return (enableDefault ? true : views.length > 1) && (canManageGlobal || !selIsShared); } get canEdit(): boolean { @@ -80,6 +76,8 @@ export class ManageDialogModel extends HoistModel { } close() { + this.gridModel.clear(); + this.formModel.init(); this.isOpen = false; } @@ -123,9 +121,9 @@ export class ManageDialogModel extends HoistModel { if (fields.isFavorite.isDirty) { if (isFavorite) { - parentModel.favorites = [...parentModel.favorites, token]; + parentModel.addFavorite(token); } else { - parentModel.favorites = parentModel.favorites.filter(it => it !== token); + parentModel.removeFavorite(token); } } @@ -170,6 +168,12 @@ export class ManageDialogModel extends HoistModel { async refreshModelsAsync() { const {views, favorites} = this.parentModel, {gridModel, formModel} = this; + + if (isEmpty(views)) { + this.close(); + return; + } + gridModel.loadData(views); await this.ensureGridHasSelection(); formModel.init({ diff --git a/desktop/cmp/persistenceManager/cmp/PersistenceMenu.ts b/desktop/cmp/persistenceManager/cmp/PersistenceMenu.ts new file mode 100644 index 0000000000..aaa010c29d --- /dev/null +++ b/desktop/cmp/persistenceManager/cmp/PersistenceMenu.ts @@ -0,0 +1,190 @@ +import {div, filler, hbox, span} from '@xh/hoist/cmp/layout'; +import {hoistCmp, HoistProps, uses} from '@xh/hoist/core'; +import {button} from '@xh/hoist/desktop/cmp/button'; +import {PersistenceManagerModel} from '@xh/hoist/desktop/cmp/persistenceManager'; +import {PersistenceViewTree} from '@xh/hoist/desktop/cmp/persistenceManager/Types'; +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 {capitalize, isEmpty} from 'lodash'; +import {ReactNode} from 'react'; + +export interface PersistenceMenuProps extends HoistProps { + /** True to disable options for saving/managing items. */ + minimal?: boolean; + /** True (default) to render a save button alongside the primary menu button when dirty. */ + omitTopLevelSaveButton?: boolean; + /** True to omit the default menu component. Should be used when creating custom app-specific component */ + omitDefaultMenuComponent?: boolean; +} + +export const [PersistenceMenu, persistenceMenu] = hoistCmp.withFactory({ + displayName: 'PersistenceMenu', + className: 'xh-persistence-manager__menu', + model: uses(PersistenceManagerModel), + + render({ + model, + omitDefaultMenuComponent = false, + minimal = false, + omitTopLevelSaveButton = true + }) { + if (omitDefaultMenuComponent) return null; + const {selectedView, isShared, entity} = model, + displayName = entity.displayName; + return hbox({ + className: 'xh-persistence-manager__menu', + items: [ + popover({ + item: button({ + text: + model.getHierarchyDisplayName(selectedView?.name) ?? + `Default ${capitalize(displayName)}`, + icon: isShared ? Icon.users() : Icon.bookmark(), + rightIcon: Icon.chevronDown(), + outlined: true + }), + content: div( + div({ + className: 'xh-popup__title', + item: capitalize(pluralize(displayName)) + }), + objMenu({minimal}) + ), + placement: 'bottom-start' + }), + saveButton({omit: !omitTopLevelSaveButton || !model.canSave}) + ] + }); + } +}); + +const menuFavorite = hoistCmp.factory({ + render({model, view}) { + const isFavorite = model.isFavorite(view.id); + return hbox({ + alignItems: 'center', + items: [ + span({style: {paddingRight: 5}, item: view.text}), + filler(), + div({ + className: `xh-persistence-manager__menu-item-fav ${isFavorite ? 'xh-persistence-manager__menu-item-fav--active' : ''}`, + item: Icon.favorite({ + prefix: isFavorite ? 'fas' : 'far' + }), + onClick: e => { + consumeEvent(e); + model.toggleFavorite(view.id); + } + }) + ] + }); + } +}); + +const saveButton = hoistCmp.factory({ + render({model}) { + return button({ + icon: Icon.save(), + tooltip: `Save changes to this ${model.entity.displayName}`, + intent: 'primary', + onClick: () => model.saveAsync(false).linkTo(model.loadModel) + }); + } +}); + +const objMenu = hoistCmp.factory({ + render({model, minimal}) { + const {entity} = model, + items = []; + + if (!isEmpty(model.favoritedViews)) { + items.push(menuDivider({title: 'Favorites'})); + items.push( + ...model.favoritedViews.map(it => { + return menuItem({ + key: `${it.id}-isFavorite`, + icon: model.selectedId === it.id ? Icon.check() : Icon.placeholder(), + text: menuFavorite({ + view: {...it, text: model.getHierarchyDisplayName(it.name)} + }), + onClick: () => model.selectAsync(it.id).linkTo(model.loadModel) + }); + }) + ); + } + if (!isEmpty(model.privateViewTree)) { + items.push(menuDivider({title: `My ${pluralize(entity.displayName)}`})); + model.privateViewTree.forEach(it => { + items.push(buildView(it, model)); + }); + } + if (!isEmpty(model.sharedViewTree)) { + items.push(menuDivider({title: `Shared ${pluralize(entity.displayName)}`})); + model.sharedViewTree.forEach(it => { + items.push(buildView(it, model)); + }); + } + + if (minimal) return menu({items}); + return menu({ + items: [ + ...items, + menuDivider({omit: !model.enableDefault || isEmpty(items)}), + menuItem({ + icon: model.selectedId ? Icon.placeholder() : Icon.check(), + text: `Default ${capitalize(entity.displayName)}`, + omit: !model.enableDefault, + onClick: () => model.selectAsync(null) + }), + menuDivider({omit: !model.enableDefault}), + menuItem({ + icon: Icon.save(), + text: 'Save', + disabled: !model.canSave, + onClick: () => model.saveAsync(false) + }), + menuItem({ + icon: Icon.copy(), + text: 'Save as...', + onClick: () => model.saveAsAsync() + }), + menuItem({ + icon: Icon.reset(), + text: `Revert ${capitalize(entity.displayName)}`, + disabled: !model.isDirty, + onClick: () => model.resetAsync() + }), + menuDivider(), + menuItem({ + icon: Icon.gear(), + disabled: isEmpty(model.views), + text: `Manage ${pluralize(entity.displayName)}...`, + onClick: () => model.openManageDialog() + }) + ] + }); + } +}); + +function buildView(view: PersistenceViewTree, model: PersistenceManagerModel): ReactNode { + const {type, text, selected} = view, + icon = selected ? Icon.check() : Icon.placeholder(); + switch (type) { + case 'directory': + return menuItem({ + text, + icon, + shouldDismissPopover: false, + children: view.items ? view.items.map(child => buildView(child, model)) : [] + }); + case 'view': + return menuItem({ + className: 'xh-persistence-manager__menu-item', + key: view.id, + icon, + text: menuFavorite({model, view}), + onClick: () => model.selectAsync(view.id).linkTo(model.loadModel) + }); + } +} diff --git a/desktop/cmp/persistenceManager/impl/SaveDialog.ts b/desktop/cmp/persistenceManager/cmp/SaveDialog.ts similarity index 94% rename from desktop/cmp/persistenceManager/impl/SaveDialog.ts rename to desktop/cmp/persistenceManager/cmp/SaveDialog.ts index 8e864e56de..21e8d79c49 100644 --- a/desktop/cmp/persistenceManager/impl/SaveDialog.ts +++ b/desktop/cmp/persistenceManager/cmp/SaveDialog.ts @@ -13,6 +13,7 @@ import {SaveDialogModel} from './SaveDialogModel'; export const saveDialog = hoistCmp.factory({ displayName: 'SaveDialog', + className: 'xh-persistence-manager__save-dialog', model: uses(SaveDialogModel), render({model}) { @@ -21,7 +22,7 @@ export const saveDialog = hoistCmp.factory({ isOpen: isOpen, icon: Icon.copy(), title: `Save as...`, - className: 'xh-persistence-manager__save-as-dialog', + className: 'xh-persistence-manager__save-dialog', style: {width: 500, height: 255}, canOutsideClickClose: false, onClose: () => model.close(), @@ -34,7 +35,7 @@ const formPanel = hoistCmp.factory({ render({model}) { return panel({ item: vframe({ - className: 'xh-persistence-manager__save-as-dialog__form', + className: 'xh-persistence-manager__save-dialog__form', items: [ form({ fieldDefaults: { diff --git a/desktop/cmp/persistenceManager/impl/SaveDialogModel.ts b/desktop/cmp/persistenceManager/cmp/SaveDialogModel.ts similarity index 100% rename from desktop/cmp/persistenceManager/impl/SaveDialogModel.ts rename to desktop/cmp/persistenceManager/cmp/SaveDialogModel.ts diff --git a/svc/JsonBlobService.ts b/svc/JsonBlobService.ts index 5c8c0e40e1..2ce4254d17 100644 --- a/svc/JsonBlobService.ts +++ b/svc/JsonBlobService.ts @@ -4,7 +4,7 @@ * * Copyright © 2024 Extremely Heavy Industries Inc. */ -import {XH, HoistService, PlainObject} from '@xh/hoist/core'; +import {XH, HoistService, PlainObject, LoadSpec} from '@xh/hoist/core'; /** * Service to read and set chunks of user-specific JSON persisted via Hoist Core's JSONBlob class. @@ -25,10 +25,14 @@ export class JsonBlobService extends HoistService { * @param type - reference key for which type of data to list. * @param includeValue - true to include the full value string for each blob. */ - async listAsync({type, includeValue}: {type: string; includeValue?: boolean}) { + async listAsync( + {type, includeValue}: {type: string; includeValue?: boolean}, + loadSpec: LoadSpec + ) { return XH.fetchJson({ url: 'xh/listJsonBlobs', - params: {type, includeValue} + params: {type, includeValue}, + loadSpec }); } From c71ebfeafc768c0f6563078d31e3f0c29ace6fb7 Mon Sep 17 00:00:00 2001 From: Ryanseanlee Date: Tue, 24 Sep 2024 14:19:22 -0700 Subject: [PATCH 10/19] Grid popover --- .../PersistenceManager.scss | 17 ++- .../persistenceManager/PersistenceManager.ts | 34 +++++- .../PersistenceManagerModel.ts | 107 ++++++++++++++++-- .../cmp/PersistenceGridPopover.ts | 99 ++++++++++++++++ .../persistenceManager/cmp/PersistenceMenu.ts | 19 +--- 5 files changed, 240 insertions(+), 36 deletions(-) create mode 100644 desktop/cmp/persistenceManager/cmp/PersistenceGridPopover.ts diff --git a/desktop/cmp/persistenceManager/PersistenceManager.scss b/desktop/cmp/persistenceManager/PersistenceManager.scss index 74d64b63cf..0a47a2ce30 100644 --- a/desktop/cmp/persistenceManager/PersistenceManager.scss +++ b/desktop/cmp/persistenceManager/PersistenceManager.scss @@ -29,20 +29,19 @@ } &__menu-item-fav { - opacity: 0.1; + opacity: 0.5; + color: var(--xh-yellow) !important; + } + &--active { + color: var(--xh-orange-muted) !important; + } - &:hover { + .ag-row-hover .xh-persistence-manager { + &__menu-item-fav { opacity: 1; - color: var(--xh-yellow); } - &--active { opacity: 1; - color: var(--xh-yellow); - - &:hover { - color: var(--xh-orange-muted); - } } } } diff --git a/desktop/cmp/persistenceManager/PersistenceManager.ts b/desktop/cmp/persistenceManager/PersistenceManager.ts index 5159106808..ebb7ae199e 100644 --- a/desktop/cmp/persistenceManager/PersistenceManager.ts +++ b/desktop/cmp/persistenceManager/PersistenceManager.ts @@ -1,13 +1,20 @@ import {fragment} from '@xh/hoist/cmp/layout'; import {hoistCmp, HoistProps, uses} from '@xh/hoist/core'; import './PersistenceManager.scss'; +import {button} from '@xh/hoist/desktop/cmp/button'; +import { + persistenceGridPopover, + PersistenceGridPopoverProps +} from '@xh/hoist/desktop/cmp/persistenceManager/cmp/PersistenceGridPopover'; +import {Icon} from '@xh/hoist/icon'; import {PersistenceManagerModel} from './PersistenceManagerModel'; import {manageDialog} from './cmp/ManageDialog'; import {saveDialog} from './cmp/SaveDialog'; import {persistenceMenu, PersistenceMenuProps} from './cmp/PersistenceMenu'; export interface PersistenceManagerProps extends HoistProps { - persistenceMenu: boolean | PersistenceMenuProps; + persistenceMenu?: boolean | PersistenceMenuProps; + persistenceGridPopover?: boolean | PersistenceGridPopoverProps; } export const [PersistenceManager, persistenceManager] = @@ -17,17 +24,38 @@ export const [PersistenceManager, persistenceManager] = model: uses(PersistenceManagerModel), render({model, ...props}) { - const persistenceMenuProps: PersistenceMenuProps = {}; + const persistenceMenuProps: PersistenceMenuProps = {}, + persistenceGridPopoverProps: PersistenceGridPopoverProps = {}; if (props.persistenceMenu === false) { persistenceMenuProps.omitDefaultMenuComponent = true; - persistenceMenuProps.omitTopLevelSaveButton = false; + persistenceMenuProps.omitTopLevelSaveButton = true; } else { Object.assign(persistenceMenuProps, props.persistenceMenu); } + + if (props.persistenceGridPopover === false) { + persistenceGridPopoverProps.omitDefaultGridComponent = true; + persistenceGridPopoverProps.omitTopLevelSaveButton = true; + } else { + Object.assign(persistenceGridPopoverProps, props.persistenceGridPopover); + } + return fragment( persistenceMenu({...persistenceMenuProps}), + persistenceGridPopover({...persistenceGridPopoverProps}), manageDialog(), saveDialog() ); } }); + +export const saveButton = hoistCmp.factory({ + render({model}) { + return button({ + icon: Icon.save(), + tooltip: `Save changes to this ${model.entity.displayName}`, + intent: 'primary', + onClick: () => model.saveAsync(false).linkTo(model.loadModel) + }); + } +}); diff --git a/desktop/cmp/persistenceManager/PersistenceManagerModel.ts b/desktop/cmp/persistenceManager/PersistenceManagerModel.ts index f51c37923a..cefba5bc1a 100644 --- a/desktop/cmp/persistenceManager/PersistenceManagerModel.ts +++ b/desktop/cmp/persistenceManager/PersistenceManagerModel.ts @@ -8,7 +8,8 @@ import { Thunkable, XH } from '@xh/hoist/core'; -import {StoreRecordId} from '@xh/hoist/data/StoreRecord'; +import {RecordActionSpec} from '@xh/hoist/data'; +import {actionCol, calcActionColWidth} from '@xh/hoist/desktop/cmp/grid'; import {action, bindable, computed, makeObservable, observable} from '@xh/hoist/mobx'; import {executeIfFunction, pluralize} from '@xh/hoist/utils/js'; import {capitalize, cloneDeep, isEqualWith, isNil, isString, sortBy, startCase} from 'lodash'; @@ -16,6 +17,8 @@ import {ManageDialogModel} from './cmp/ManageDialogModel'; import {SaveDialogModel} from './cmp/SaveDialogModel'; import {runInAction} from 'mobx'; import {PersistenceView, PersistenceViewTree} from '@xh/hoist/desktop/cmp/persistenceManager/Types'; +import {GridModel} from '@xh/hoist/cmp/grid'; +import {Icon} from '@xh/hoist/icon'; /** * PersistenceManager provides re-usable loading, selection, and user management of named configs, which are modelled @@ -75,12 +78,15 @@ export class PersistenceManagerModel extend @managed readonly manageDialogModel: ManageDialogModel; @managed readonly saveDialogModel: SaveDialogModel; + + @managed readonly gridModel: GridModel; + /** Current state of the active object, can include not-yet-persisted changes. */ @observable.ref pendingValue: T = null; @observable.ref views: PersistenceView[] = []; - @bindable selectedId: StoreRecordId = null; + @bindable selectedId: number = null; @bindable favorites: string[] = []; get canManageGlobal(): boolean { @@ -159,6 +165,8 @@ export class PersistenceManagerModel extend this.onChangeAsync = onChangeAsync; this.saveDialogModel = new SaveDialogModel(this, this.entity.name); this.manageDialogModel = new ManageDialogModel(this); + this.gridModel = this.createGridModel(); + // Set up internal PersistenceProvider -- fail gently if (persistWith) { try { @@ -170,10 +178,25 @@ export class PersistenceManagerModel extend const state = this._provider.read(); if (state?.selectedId) this.selectedId = state.selectedId; if (state?.favorites) this.favorites = state.favorites; - this.addReaction({ - track: () => this.persistState, - run: state => this._provider.write(state) - }); + this.addReaction( + { + track: () => this.persistState, + run: state => this._provider.write(state) + }, + { + track: () => this.gridModel.selectedRecord, + run: record => { + if (record) { + let id = record.id; + if (typeof id === 'string') { + id = +id.replace('-favorite', ''); + } + this.selectAsync(id); + this.gridModel.selectAsync(record.id); + } + } + } + ); } catch (e) { this.logError('Error applying persistWith', persistWith, e); XH.safeDestroy(this._provider); @@ -197,16 +220,16 @@ export class PersistenceManagerModel extend // Always call selectAsync to ensure pendingValue updated and onChangeAsync callback fired if needed const id = this.selectedView?.id ?? (!this.enableDefault ? this.views[0]?.id : null); await this.selectAsync(id); + this.loadGrid(); } - async selectAsync(id: StoreRecordId) { + async selectAsync(id: number) { this.selectedId = id; - if (!this.isDirty) return; - const {value} = this; this.setPendingValue(value); await this.onChangeAsync(value); + this.gridModel.agApi?.redrawRows(); } async saveAsync(skipToast: boolean = false) { @@ -251,6 +274,7 @@ export class PersistenceManagerModel extend } else { this.addFavorite(token); } + this.loadGrid(); } addFavorite(token: string) { @@ -284,6 +308,71 @@ export class PersistenceManagerModel extend // Implementation //------------------ + private createGridModel(): GridModel { + return new GridModel({ + groupBy: 'group', + autosizeOptions: {mode: 'managed'}, + selModel: 'single', + store: {fields: ['token']}, + showHover: true, + groupSortFn: (aVal, bVal, groupField) => { + return groupField === 'Favorites' ? -1 : aVal.localeCompare(bVal); + }, + cellBorders: true, + columns: [ + { + field: 'id', + renderer: v => { + let id = v; + if (typeof id === 'string') { + id = +id.replace('-favorite', ''); + } + return this.selectedId === id ? Icon.check() : null; + } + }, + {field: 'name'}, + {field: 'group', hidden: true}, + {field: 'description', flex: 1}, + { + ...actionCol, + width: calcActionColWidth(1), + actions: [this.favoriteAction()], + colId: 'favAction' + } + ], + hideHeaders: true, + showGroupRowCounts: false + }); + } + + private loadGrid() { + const favoriteViews = this.favoritedViews.map(it => ({ + ...it, + id: `${it.id}-favorite`, + group: 'Favorites' + })); + this.gridModel.loadData([...favoriteViews, ...this.views]); + this.gridModel.agApi?.redrawRows(); + } + + private favoriteAction(): RecordActionSpec { + return { + icon: Icon.star(), + tooltip: 'Click to add to favorites', + displayFn: ({record}) => ({ + className: `xh-persistence-manager__menu-item-fav ${this.favorites.includes(record.data.token) ? 'xh-persistence-manager__menu-item-fav--active' : ''}`, + icon: this.favorites.includes(record.data.token) + ? Icon.star({prefix: 'fas'}) + : Icon.star({prefix: 'far'}) + }), + actionFn: ({record}) => { + const id = + typeof record.id === 'string' ? +record.id.replace('-favorite', '') : record.id; + this.toggleFavorite(id); + } + }; + } + private parseEntity(entity: string | Entity): Entity { const ret = isString(entity) ? {name: entity} : {...entity}; ret.displayName = ret.displayName ?? startCase(ret.name); diff --git a/desktop/cmp/persistenceManager/cmp/PersistenceGridPopover.ts b/desktop/cmp/persistenceManager/cmp/PersistenceGridPopover.ts new file mode 100644 index 0000000000..dad58bdac5 --- /dev/null +++ b/desktop/cmp/persistenceManager/cmp/PersistenceGridPopover.ts @@ -0,0 +1,99 @@ +import {grid} from '@xh/hoist/cmp/grid'; +import {filler, hbox} from '@xh/hoist/cmp/layout'; +import {storeFilterField} from '@xh/hoist/cmp/store'; +import {hoistCmp, HoistProps, uses} from '@xh/hoist/core'; +import {panel} from '@xh/hoist/desktop/cmp/panel'; +import {toolbar} from '@xh/hoist/desktop/cmp/toolbar'; +import {Icon} from '@xh/hoist/icon'; +import {capitalize, isEmpty, isNull} from 'lodash'; +import {button} from '../../button'; +import {popover} from '@xh/hoist/kit/blueprint'; +import {PersistenceManagerModel, saveButton} from '@xh/hoist/desktop/cmp/persistenceManager'; + +export interface PersistenceGridPopoverProps extends HoistProps { + /** True (default) to render a save button alongside the primary menu button when dirty. */ + omitTopLevelSaveButton?: boolean; + /** True to omit the default menu component. Should be used when creating custom app-specific component */ + omitDefaultGridComponent?: boolean; +} + +export const [PersistenceGridPopover, persistenceGridPopover] = + hoistCmp.withFactory({ + displayName: 'PersistenceGridPopover', + className: 'xh-persistence-manager__menu', + model: uses(PersistenceManagerModel), + + render({ + model, + omitDefaultGridComponent = false, + omitTopLevelSaveButton = false + }: PersistenceGridPopoverProps) { + if (omitDefaultGridComponent) return null; + const {selectedView, isShared, entity} = model, + displayName = entity.displayName; + return hbox({ + items: [ + popover({ + placement: 'bottom-end', + item: button({ + text: + model.getHierarchyDisplayName(selectedView?.name) ?? + `Default ${capitalize(displayName)}`, + icon: isShared ? Icon.users() : Icon.bookmark(), + rightIcon: Icon.chevronDown(), + outlined: true + }), + content: panel({ + className: 'xh-persistence-manager', + compactHeader: true, + style: {minHeight: 100, width: 500}, + item: grid({agOptions: {domLayout: 'autoHeight'}}), + bbar: bbar() + }) + }), + saveButton({omit: omitTopLevelSaveButton || !model.canSave}) + ] + }); + } + }); + +const bbar = hoistCmp.factory({ + render({model}) { + return toolbar( + storeFilterField({store: model.gridModel.store}), + filler(), + button({ + icon: Icon.home(), + intent: 'primary', + omit: !model.enableDefault, + disabled: isNull(model.selectedId), + onClick: () => model.selectAsync(null) + }), + button({ + icon: Icon.gear(), + disabled: isEmpty(model.views), + onClick: () => model.openManageDialog() + }), + '-', + button({ + tooltip: `Revert ${capitalize(model.entity.displayName)}`, + icon: Icon.reset(), + disabled: !model.isDirty || !model.selectedView, + onClick: () => model.resetAsync() + }), + button({ + text: 'Save as...', + icon: Icon.copy(), + disabled: !model.selectedView, + onClick: () => model.saveAsAsync() + }), + button({ + text: 'Save', + icon: Icon.save(), + intent: 'primary', + disabled: !model.canSave, + onClick: () => model.saveAsync() + }) + ); + } +}); diff --git a/desktop/cmp/persistenceManager/cmp/PersistenceMenu.ts b/desktop/cmp/persistenceManager/cmp/PersistenceMenu.ts index aaa010c29d..4e72f2e8cb 100644 --- a/desktop/cmp/persistenceManager/cmp/PersistenceMenu.ts +++ b/desktop/cmp/persistenceManager/cmp/PersistenceMenu.ts @@ -1,7 +1,7 @@ import {div, filler, hbox, span} from '@xh/hoist/cmp/layout'; import {hoistCmp, HoistProps, uses} from '@xh/hoist/core'; import {button} from '@xh/hoist/desktop/cmp/button'; -import {PersistenceManagerModel} from '@xh/hoist/desktop/cmp/persistenceManager'; +import {PersistenceManagerModel, saveButton} from '@xh/hoist/desktop/cmp/persistenceManager'; import {PersistenceViewTree} from '@xh/hoist/desktop/cmp/persistenceManager/Types'; import {Icon} from '@xh/hoist/icon'; import {menu, menuDivider, menuItem, popover} from '@xh/hoist/kit/blueprint'; @@ -27,8 +27,8 @@ export const [PersistenceMenu, persistenceMenu] = hoistCmp.withFactory({ } }); -const saveButton = hoistCmp.factory({ - render({model}) { - return button({ - icon: Icon.save(), - tooltip: `Save changes to this ${model.entity.displayName}`, - intent: 'primary', - onClick: () => model.saveAsync(false).linkTo(model.loadModel) - }); - } -}); - const objMenu = hoistCmp.factory({ render({model, minimal}) { const {entity} = model, From 2982458b3a01e727af3e9264cfc6abdbb212c65b Mon Sep 17 00:00:00 2001 From: Ryanseanlee Date: Fri, 27 Sep 2024 09:20:30 -0700 Subject: [PATCH 11/19] Remove persistence grid --- core/persist/PersistOptions.ts | 8 +- core/persist/PersistenceManagerProvider.ts | 39 ++++ core/persist/PersistenceProvider.ts | 4 + core/persist/index.ts | 1 + .../PersistenceManagerModel.ts | 190 +++++------------- .../persist}/persistenceManager/Types.ts | 1 + .../impl}/ManageDialogModel.ts | 2 +- .../impl}/SaveDialogModel.ts | 3 +- core/persist/persistenceManager/index.ts | 2 + .../PersistenceManager.scss | 35 ++-- .../persistenceManager/PersistenceManager.ts | 53 +---- .../persistenceManager/cmp/ManageDialog.ts | 2 +- .../cmp/PersistenceGridPopover.ts | 136 ++++++++++++- .../persistenceManager/cmp/PersistenceMenu.ts | 115 ++++++++--- .../cmp/persistenceManager/cmp/SaveDialog.ts | 2 +- desktop/cmp/persistenceManager/index.ts | 4 +- 16 files changed, 353 insertions(+), 244 deletions(-) create mode 100644 core/persist/PersistenceManagerProvider.ts rename {desktop/cmp => core/persist}/persistenceManager/PersistenceManagerModel.ts (67%) rename {desktop/cmp => core/persist}/persistenceManager/Types.ts (96%) rename {desktop/cmp/persistenceManager/cmp => core/persist/persistenceManager/impl}/ManageDialogModel.ts (99%) rename {desktop/cmp/persistenceManager/cmp => core/persist/persistenceManager/impl}/SaveDialogModel.ts (94%) create mode 100644 core/persist/persistenceManager/index.ts diff --git a/core/persist/PersistOptions.ts b/core/persist/PersistOptions.ts index ba1b1bf46d..af0e27167e 100644 --- a/core/persist/PersistOptions.ts +++ b/core/persist/PersistOptions.ts @@ -6,6 +6,7 @@ */ import {DebounceSpec} from '../'; +import {PersistenceManagerModel} from './persistenceManager'; /** * Options governing persistence. @@ -18,8 +19,8 @@ export interface PersistOptions { debounce?: DebounceSpec; /** - * Type of PersistenceProvider to create. If not provided, defaulted based - * on the presence of `prefKey`, `localStorageKey`, `dashViewModel`, `getData` and `setData`. + * Type of PersistenceProvider to create. If not provided, defaulted based on the presence of + * `prefKey`, `localStorageKey`, `dashViewModel`, `persistenceManagerModel`, `getData` and `setData`. */ type?: string; @@ -32,6 +33,9 @@ export interface PersistOptions { /** DashViewModel used to read / write view state. */ dashViewModel?: object; + /** PersistenceManagerModel used to read / write view state. */ + persistenceManagerModel?: PersistenceManagerModel; + /** * Function returning blob of data to be used for reading state. * Ignored if `prefKey`, `localStorageKey` or `dashViewModel` are provided. diff --git a/core/persist/PersistenceManagerProvider.ts b/core/persist/PersistenceManagerProvider.ts new file mode 100644 index 0000000000..16286f18a2 --- /dev/null +++ b/core/persist/PersistenceManagerProvider.ts @@ -0,0 +1,39 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2024 Extremely Heavy Industries Inc. + */ + +import {cloneDeep} from 'lodash'; +import {PersistenceProvider, PersistOptions} from './'; +import {throwIf} from '@xh/hoist/utils/js'; +import {PersistenceManagerModel} from './persistenceManager'; + +/** + * PersistenceProvider that stores state for PersistenceManager. + */ +export class PersistenceManagerProvider extends PersistenceProvider { + persistenceManagerModel: PersistenceManagerModel; + + constructor({persistenceManagerModel, ...rest}: PersistOptions) { + throwIf( + !persistenceManagerModel, + `PersistenceManagerProvider requires a 'persistenceManagerModel'.` + ); + super(rest); + this.persistenceManagerModel = persistenceManagerModel; + } + + //---------------- + // Implementation + //---------------- + override readRaw() { + const {pendingValue, value} = this.persistenceManagerModel; + return cloneDeep(pendingValue ?? value ?? {}); + } + + override writeRaw(data) { + this.persistenceManagerModel.mergePendingValue(data); + } +} diff --git a/core/persist/PersistenceProvider.ts b/core/persist/PersistenceProvider.ts index 644378bb85..9524928ff5 100644 --- a/core/persist/PersistenceProvider.ts +++ b/core/persist/PersistenceProvider.ts @@ -10,6 +10,7 @@ import { LocalStorageProvider, PrefProvider, DashViewProvider, + PersistenceManagerProvider, CustomProvider, PersistOptions } from './'; @@ -53,6 +54,7 @@ export class PersistenceProvider { if (rest.prefKey) type = 'pref'; if (rest.localStorageKey) type = 'localStorage'; if (rest.dashViewModel) type = 'dashView'; + if (rest.persistenceManagerModel) type = 'persistenceManagerModel'; if (rest.getData || rest.setData) type = 'custom'; } @@ -63,6 +65,8 @@ export class PersistenceProvider { return new LocalStorageProvider(rest); case `dashView`: return new DashViewProvider(rest); + case 'persistenceManagerModel': + return new PersistenceManagerProvider(rest); case 'custom': return new CustomProvider(rest); default: diff --git a/core/persist/index.ts b/core/persist/index.ts index bc417d4c04..9d380dd6fb 100644 --- a/core/persist/index.ts +++ b/core/persist/index.ts @@ -4,3 +4,4 @@ export * from './LocalStorageProvider'; export * from './DashViewProvider'; export * from './PrefProvider'; export * from './CustomProvider'; +export * from './PersistenceManagerProvider'; diff --git a/desktop/cmp/persistenceManager/PersistenceManagerModel.ts b/core/persist/persistenceManager/PersistenceManagerModel.ts similarity index 67% rename from desktop/cmp/persistenceManager/PersistenceManagerModel.ts rename to core/persist/persistenceManager/PersistenceManagerModel.ts index cefba5bc1a..7ad83de72a 100644 --- a/desktop/cmp/persistenceManager/PersistenceManagerModel.ts +++ b/core/persist/persistenceManager/PersistenceManagerModel.ts @@ -8,17 +8,13 @@ import { Thunkable, XH } from '@xh/hoist/core'; -import {RecordActionSpec} from '@xh/hoist/data'; -import {actionCol, calcActionColWidth} from '@xh/hoist/desktop/cmp/grid'; import {action, bindable, computed, makeObservable, observable} from '@xh/hoist/mobx'; import {executeIfFunction, pluralize} from '@xh/hoist/utils/js'; -import {capitalize, cloneDeep, isEqualWith, isNil, isString, sortBy, startCase} from 'lodash'; -import {ManageDialogModel} from './cmp/ManageDialogModel'; -import {SaveDialogModel} from './cmp/SaveDialogModel'; +import {capitalize, find, isEqualWith, isNil, isString, sortBy, startCase} from 'lodash'; import {runInAction} from 'mobx'; -import {PersistenceView, PersistenceViewTree} from '@xh/hoist/desktop/cmp/persistenceManager/Types'; -import {GridModel} from '@xh/hoist/cmp/grid'; -import {Icon} from '@xh/hoist/icon'; +import {ManageDialogModel} from './impl/ManageDialogModel'; +import {SaveDialogModel} from './impl/SaveDialogModel'; +import {PersistenceView, PersistenceViewTree} from './Types'; /** * PersistenceManager provides re-usable loading, selection, and user management of named configs, which are modelled @@ -41,28 +37,25 @@ interface Entity { displayName?: string; } -export interface PersistenceManagerConfig { +export interface PersistenceManagerConfig { /** Entity name or object for this model. */ entity: string | Entity; /** Whether user can publish or edit globally shared objects. */ canManageGlobal: Thunkable; - /** Async callback triggered when view changes. Should be used to recreate the affected models. */ - onChangeAsync: (value: T) => void; /** Used to persist this model's selected ID. */ persistWith: PersistOptions; /** Optional flag to allow empty view selection. Defaults false*/ enableDefault?: boolean; + /** Optional flag to allow auto save state. Defaults false*/ + enableAutoSave?: boolean; } export class PersistenceManagerModel extends HoistModel { - //------------------------ - // Persistence Provider - // Pass this to models that implement `persistWith` to include their state in the view. - //------------------------ - readonly provider: PersistOptions = { - getData: () => cloneDeep(this.pendingValue ?? this.value ?? {}), - setData: (value: T) => this.mergePendingValue(value) - }; + static async createAsync(config: PersistenceManagerConfig): Promise { + const ret = new PersistenceManagerModel(config); + await ret.loadAsync(); + return ret; + } private readonly _canManageGlobal: Thunkable; @@ -70,8 +63,7 @@ export class PersistenceManagerModel extend private readonly _provider; readonly enableDefault: boolean; - - readonly onChangeAsync?: (value: T) => void; + readonly enableAutoSave: boolean; readonly entity: Entity; @@ -79,14 +71,12 @@ export class PersistenceManagerModel extend @managed readonly saveDialogModel: SaveDialogModel; - @managed readonly gridModel: GridModel; - /** Current state of the active object, can include not-yet-persisted changes. */ @observable.ref pendingValue: T = null; @observable.ref views: PersistenceView[] = []; - @bindable selectedId: number = null; + @bindable selectedToken: string = null; @bindable favorites: string[] = []; get canManageGlobal(): boolean { @@ -98,7 +88,15 @@ export class PersistenceManagerModel extend } get selectedView(): PersistenceView { - return this.views.find(it => it.id === this.selectedId); + return this.views.find(it => it.token === this.selectedToken); + } + + get isSharedViewSelected(): boolean { + return !isNil( + find(this.sharedViews, it => { + return it.token === this.selectedToken; + }) + ); } @computed @@ -121,10 +119,6 @@ export class PersistenceManagerModel extend return !!this.selectedView?.isShared; } - get isLoadedInitially(): boolean { - return !!this.loadSupport.lastSucceeded; - } - get favoritedViews(): PersistenceView[] { return this.views.filter(it => this.favorites.includes(it.token)); } @@ -146,26 +140,25 @@ export class PersistenceManagerModel extend } get persistState() { - return {selectedId: this.selectedId, favorites: this.favorites}; + return {selectedToken: this.selectedToken, favorites: this.favorites}; } - constructor({ + private constructor({ entity, - onChangeAsync, persistWith, canManageGlobal, - enableDefault = false - }: PersistenceManagerConfig) { + enableDefault = false, + enableAutoSave = false + }: PersistenceManagerConfig) { super(); makeObservable(this); this.entity = this.parseEntity(entity); this._canManageGlobal = canManageGlobal; this.enableDefault = enableDefault; - this.onChangeAsync = onChangeAsync; + this.enableAutoSave = enableAutoSave; this.saveDialogModel = new SaveDialogModel(this, this.entity.name); this.manageDialogModel = new ManageDialogModel(this); - this.gridModel = this.createGridModel(); // Set up internal PersistenceProvider -- fail gently if (persistWith) { @@ -176,7 +169,7 @@ export class PersistenceManagerModel extend }); const state = this._provider.read(); - if (state?.selectedId) this.selectedId = state.selectedId; + if (state?.selectedToken) this.selectedToken = state.selectedToken; if (state?.favorites) this.favorites = state.favorites; this.addReaction( { @@ -184,15 +177,10 @@ export class PersistenceManagerModel extend run: state => this._provider.write(state) }, { - track: () => this.gridModel.selectedRecord, - run: record => { - if (record) { - let id = record.id; - if (typeof id === 'string') { - id = +id.replace('-favorite', ''); - } - this.selectAsync(id); - this.gridModel.selectAsync(record.id); + track: () => this.pendingValue, + run: () => { + if (this.enableAutoSave && !this.isSharedViewSelected) { + this.saveAsync(true); } } } @@ -203,8 +191,6 @@ export class PersistenceManagerModel extend this._provider = null; } } - - this.loadAsync(); } // TODO - Carefully review if this method needs isStale checks, and how to properly implement them. @@ -218,23 +204,20 @@ export class PersistenceManagerModel extend runInAction(() => (this.views = this.processRaw(rawViews))); // Always call selectAsync to ensure pendingValue updated and onChangeAsync callback fired if needed - const id = this.selectedView?.id ?? (!this.enableDefault ? this.views[0]?.id : null); - await this.selectAsync(id); - this.loadGrid(); + const token = + this.selectedView?.token ?? (!this.enableDefault ? this.views[0]?.token : null); + await this.selectAsync(token); } - async selectAsync(id: number) { - this.selectedId = id; + async selectAsync(token: string) { + this.selectedToken = token; const {value} = this; - this.setPendingValue(value); - await this.onChangeAsync(value); - this.gridModel.agApi?.redrawRows(); } async saveAsync(skipToast: boolean = false) { const {selectedView, entity, pendingValue, isShared} = this, - {token, id} = selectedView; + {token} = selectedView; if (isShared) { if (!(await this.confirmShareObjSaveAsync())) return; } @@ -247,7 +230,7 @@ export class PersistenceManagerModel extend return XH.handleException(e, {alertType: 'toast'}); } await this.refreshAsync(); - await this.selectAsync(id); + await this.selectAsync(token); if (!skipToast) XH.successToast(`${capitalize(entity.displayName)} successfully saved.`); } @@ -262,11 +245,10 @@ export class PersistenceManagerModel extend } async resetAsync() { - return this.selectAsync(this.selectedId); + return this.selectAsync(this.selectedToken); } - toggleFavorite(id: number) { - const token = this.views.find(it => it.id === id)?.token; + toggleFavorite(token: string) { if (!token) return; if (this.favorites.includes(token)) { @@ -274,7 +256,6 @@ export class PersistenceManagerModel extend } else { this.addFavorite(token); } - this.loadGrid(); } addFavorite(token: string) { @@ -285,8 +266,7 @@ export class PersistenceManagerModel extend this.favorites = this.favorites.filter(it => it !== token); } - isFavorite(id: number) { - const token = this.views.find(it => it.id === id)?.token; + isFavorite(token: string) { return this.favorites.includes(token); } @@ -304,86 +284,21 @@ export class PersistenceManagerModel extend return name?.substring(name.lastIndexOf('\\') + 1); } + mergePendingValue(value: T) { + value = {...this.pendingValue, ...this.cleanValue(value)}; + this.setPendingValue(value); + } + //------------------ // Implementation //------------------ - private createGridModel(): GridModel { - return new GridModel({ - groupBy: 'group', - autosizeOptions: {mode: 'managed'}, - selModel: 'single', - store: {fields: ['token']}, - showHover: true, - groupSortFn: (aVal, bVal, groupField) => { - return groupField === 'Favorites' ? -1 : aVal.localeCompare(bVal); - }, - cellBorders: true, - columns: [ - { - field: 'id', - renderer: v => { - let id = v; - if (typeof id === 'string') { - id = +id.replace('-favorite', ''); - } - return this.selectedId === id ? Icon.check() : null; - } - }, - {field: 'name'}, - {field: 'group', hidden: true}, - {field: 'description', flex: 1}, - { - ...actionCol, - width: calcActionColWidth(1), - actions: [this.favoriteAction()], - colId: 'favAction' - } - ], - hideHeaders: true, - showGroupRowCounts: false - }); - } - - private loadGrid() { - const favoriteViews = this.favoritedViews.map(it => ({ - ...it, - id: `${it.id}-favorite`, - group: 'Favorites' - })); - this.gridModel.loadData([...favoriteViews, ...this.views]); - this.gridModel.agApi?.redrawRows(); - } - - private favoriteAction(): RecordActionSpec { - return { - icon: Icon.star(), - tooltip: 'Click to add to favorites', - displayFn: ({record}) => ({ - className: `xh-persistence-manager__menu-item-fav ${this.favorites.includes(record.data.token) ? 'xh-persistence-manager__menu-item-fav--active' : ''}`, - icon: this.favorites.includes(record.data.token) - ? Icon.star({prefix: 'fas'}) - : Icon.star({prefix: 'far'}) - }), - actionFn: ({record}) => { - const id = - typeof record.id === 'string' ? +record.id.replace('-favorite', '') : record.id; - this.toggleFavorite(id); - } - }; - } - private parseEntity(entity: string | Entity): Entity { const ret = isString(entity) ? {name: entity} : {...entity}; ret.displayName = ret.displayName ?? startCase(ret.name); return ret; } - private mergePendingValue(value: T) { - value = {...this.pendingValue, ...this.cleanValue(value)}; - this.setPendingValue(value); - } - private processRaw(raw: PlainObject): PersistenceView[] { const {entity} = this, name = capitalize(pluralize(entity.displayName)); @@ -458,7 +373,7 @@ export class PersistenceManagerModel extend }); return unbalancedStableGroupsAndViews.map(it => { - const {name, id, isMenuFolder, children} = it; + const {name, id, isMenuFolder, children, token} = it; if (isMenuFolder) { return { type: 'directory', @@ -470,8 +385,9 @@ export class PersistenceManagerModel extend return { type: 'view', text: this.getHierarchyDisplayName(name), - selected: this.selectedId === id, - id + selected: this.selectedView?.id === id, + id, + token }; }); } diff --git a/desktop/cmp/persistenceManager/Types.ts b/core/persist/persistenceManager/Types.ts similarity index 96% rename from desktop/cmp/persistenceManager/Types.ts rename to core/persist/persistenceManager/Types.ts index 534e9ae267..6a34fc2935 100644 --- a/desktop/cmp/persistenceManager/Types.ts +++ b/core/persist/persistenceManager/Types.ts @@ -29,5 +29,6 @@ export type PersistenceViewTree = { | { type: 'view'; id: number; + token: string; } ); diff --git a/desktop/cmp/persistenceManager/cmp/ManageDialogModel.ts b/core/persist/persistenceManager/impl/ManageDialogModel.ts similarity index 99% rename from desktop/cmp/persistenceManager/cmp/ManageDialogModel.ts rename to core/persist/persistenceManager/impl/ManageDialogModel.ts index 33018ba851..61c7d52df8 100644 --- a/desktop/cmp/persistenceManager/cmp/ManageDialogModel.ts +++ b/core/persist/persistenceManager/impl/ManageDialogModel.ts @@ -1,11 +1,11 @@ import {FormModel} from '@xh/hoist/cmp/form'; import {GridAutosizeMode, GridModel} from '@xh/hoist/cmp/grid'; import {HoistModel, managed, XH} from '@xh/hoist/core'; +import {PersistenceManagerModel} from '@xh/hoist/core/persist/persistenceManager'; import {lengthIs, required} from '@xh/hoist/data'; import {Icon} from '@xh/hoist/icon'; import {bindable, makeObservable} from '@xh/hoist/mobx'; import {includes, isEmpty} from 'lodash'; -import {PersistenceManagerModel} from '../PersistenceManagerModel'; export class ManageDialogModel extends HoistModel { parentModel: PersistenceManagerModel; diff --git a/desktop/cmp/persistenceManager/cmp/SaveDialogModel.ts b/core/persist/persistenceManager/impl/SaveDialogModel.ts similarity index 94% rename from desktop/cmp/persistenceManager/cmp/SaveDialogModel.ts rename to core/persist/persistenceManager/impl/SaveDialogModel.ts index ddb1b88657..e52cbb4820 100644 --- a/desktop/cmp/persistenceManager/cmp/SaveDialogModel.ts +++ b/core/persist/persistenceManager/impl/SaveDialogModel.ts @@ -1,9 +1,8 @@ import {FormModel} from '@xh/hoist/cmp/form'; import {HoistModel, managed, TaskObserver, XH} from '@xh/hoist/core'; +import {PersistenceManagerModel, PersistenceView} from '@xh/hoist/core/persist/persistenceManager'; import {lengthIs, required} from '@xh/hoist/data'; import {bindable, makeObservable} from '@xh/hoist/mobx'; -import {PersistenceManagerModel} from '../PersistenceManagerModel'; -import {PersistenceView} from '@xh/hoist/desktop/cmp/persistenceManager/Types'; export class SaveDialogModel extends HoistModel { readonly saveTask = TaskObserver.trackLast(); diff --git a/core/persist/persistenceManager/index.ts b/core/persist/persistenceManager/index.ts new file mode 100644 index 0000000000..ec3e2e5f9b --- /dev/null +++ b/core/persist/persistenceManager/index.ts @@ -0,0 +1,2 @@ +export * from './PersistenceManagerModel'; +export * from './Types'; diff --git a/desktop/cmp/persistenceManager/PersistenceManager.scss b/desktop/cmp/persistenceManager/PersistenceManager.scss index 0a47a2ce30..0298d6809e 100644 --- a/desktop/cmp/persistenceManager/PersistenceManager.scss +++ b/desktop/cmp/persistenceManager/PersistenceManager.scss @@ -28,20 +28,29 @@ } } - &__menu-item-fav { - opacity: 0.5; - color: var(--xh-yellow) !important; - } - &--active { - color: var(--xh-orange-muted) !important; - } - - .ag-row-hover .xh-persistence-manager { - &__menu-item-fav { - opacity: 1; + &__menu-item { + &:hover { + .xh-persistence-manager__menu-item--fav { + opacity: 1; + color: var(--xh-yellow); + } } - &--active { - opacity: 1; + + &--fav { + opacity: 0; + + &:hover { + color: var(--xh-yellow-light) !important; + } + + &--active { + opacity: 1; + color: var(--xh-yellow); + } + + &--active:hover { + color: var(--xh-yellow-light) !important; + } } } } diff --git a/desktop/cmp/persistenceManager/PersistenceManager.ts b/desktop/cmp/persistenceManager/PersistenceManager.ts index ebb7ae199e..50d150c2a5 100644 --- a/desktop/cmp/persistenceManager/PersistenceManager.ts +++ b/desktop/cmp/persistenceManager/PersistenceManager.ts @@ -1,21 +1,10 @@ import {fragment} from '@xh/hoist/cmp/layout'; import {hoistCmp, HoistProps, uses} from '@xh/hoist/core'; import './PersistenceManager.scss'; -import {button} from '@xh/hoist/desktop/cmp/button'; -import { - persistenceGridPopover, - PersistenceGridPopoverProps -} from '@xh/hoist/desktop/cmp/persistenceManager/cmp/PersistenceGridPopover'; -import {Icon} from '@xh/hoist/icon'; -import {PersistenceManagerModel} from './PersistenceManagerModel'; -import {manageDialog} from './cmp/ManageDialog'; -import {saveDialog} from './cmp/SaveDialog'; -import {persistenceMenu, PersistenceMenuProps} from './cmp/PersistenceMenu'; +import {manageDialog, saveDialog, persistenceMenu} from '@xh/hoist/desktop/cmp/persistenceManager'; +import {PersistenceManagerModel} from '@xh/hoist/core/persist/persistenceManager'; -export interface PersistenceManagerProps extends HoistProps { - persistenceMenu?: boolean | PersistenceMenuProps; - persistenceGridPopover?: boolean | PersistenceGridPopoverProps; -} +export interface PersistenceManagerProps extends HoistProps {} export const [PersistenceManager, persistenceManager] = hoistCmp.withFactory({ @@ -23,39 +12,15 @@ export const [PersistenceManager, persistenceManager] = className: 'xh-persistence-manager', model: uses(PersistenceManagerModel), - render({model, ...props}) { - const persistenceMenuProps: PersistenceMenuProps = {}, - persistenceGridPopoverProps: PersistenceGridPopoverProps = {}; - if (props.persistenceMenu === false) { - persistenceMenuProps.omitDefaultMenuComponent = true; - persistenceMenuProps.omitTopLevelSaveButton = true; - } else { - Object.assign(persistenceMenuProps, props.persistenceMenu); - } - - if (props.persistenceGridPopover === false) { - persistenceGridPopoverProps.omitDefaultGridComponent = true; - persistenceGridPopoverProps.omitTopLevelSaveButton = true; - } else { - Object.assign(persistenceGridPopoverProps, props.persistenceGridPopover); - } - + render() { return fragment( - persistenceMenu({...persistenceMenuProps}), - persistenceGridPopover({...persistenceGridPopoverProps}), + persistenceMenu({ + showPrivateViewsInSubMenu: false, + showSharedViewsInSubMenu: true, + showSaveButton: 'always' + }), manageDialog(), saveDialog() ); } }); - -export const saveButton = hoistCmp.factory({ - render({model}) { - return button({ - icon: Icon.save(), - tooltip: `Save changes to this ${model.entity.displayName}`, - intent: 'primary', - onClick: () => model.saveAsync(false).linkTo(model.loadModel) - }); - } -}); diff --git a/desktop/cmp/persistenceManager/cmp/ManageDialog.ts b/desktop/cmp/persistenceManager/cmp/ManageDialog.ts index 17a578b052..a327fac213 100644 --- a/desktop/cmp/persistenceManager/cmp/ManageDialog.ts +++ b/desktop/cmp/persistenceManager/cmp/ManageDialog.ts @@ -2,6 +2,7 @@ import {form} from '@xh/hoist/cmp/form'; import {grid} from '@xh/hoist/cmp/grid'; import {br, div, filler, fragment, hframe, placeholder, spacer, vframe} from '@xh/hoist/cmp/layout'; import {hoistCmp, uses, XH} from '@xh/hoist/core'; +import {ManageDialogModel} from '@xh/hoist/core/persist/persistenceManager/impl/ManageDialogModel'; import {button} from '@xh/hoist/desktop/cmp/button'; import {formField} from '@xh/hoist/desktop/cmp/form'; import {switchInput, textArea, textInput} from '@xh/hoist/desktop/cmp/input'; @@ -11,7 +12,6 @@ import {toolbar} from '@xh/hoist/desktop/cmp/toolbar'; import {fmtCompactDate} from '@xh/hoist/format'; import {Icon} from '@xh/hoist/icon'; import {dialog} from '@xh/hoist/kit/blueprint'; -import {ManageDialogModel} from './ManageDialogModel'; import {pluralize} from '@xh/hoist/utils/js'; import {capitalize} from 'lodash'; diff --git a/desktop/cmp/persistenceManager/cmp/PersistenceGridPopover.ts b/desktop/cmp/persistenceManager/cmp/PersistenceGridPopover.ts index dad58bdac5..d097636ab1 100644 --- a/desktop/cmp/persistenceManager/cmp/PersistenceGridPopover.ts +++ b/desktop/cmp/persistenceManager/cmp/PersistenceGridPopover.ts @@ -1,14 +1,25 @@ -import {grid} from '@xh/hoist/cmp/grid'; +import {grid, GridModel} from '@xh/hoist/cmp/grid'; import {filler, hbox} from '@xh/hoist/cmp/layout'; import {storeFilterField} from '@xh/hoist/cmp/store'; -import {hoistCmp, HoistProps, uses} from '@xh/hoist/core'; +import { + hoistCmp, + HoistModel, + HoistProps, + lookup, + managed, + useLocalModel, + uses +} from '@xh/hoist/core'; +import {RecordActionSpec} from '@xh/hoist/data'; +import {actionCol, calcActionColWidth} from '@xh/hoist/desktop/cmp/grid'; import {panel} from '@xh/hoist/desktop/cmp/panel'; +import {persistenceSaveButton} from '@xh/hoist/desktop/cmp/persistenceManager'; import {toolbar} from '@xh/hoist/desktop/cmp/toolbar'; import {Icon} from '@xh/hoist/icon'; -import {capitalize, isEmpty, isNull} from 'lodash'; -import {button} from '../../button'; import {popover} from '@xh/hoist/kit/blueprint'; -import {PersistenceManagerModel, saveButton} from '@xh/hoist/desktop/cmp/persistenceManager'; +import {capitalize, isEmpty, isNull} from 'lodash'; +import {button} from '@xh/hoist/desktop/cmp/button'; +import {PersistenceManagerModel} from '@xh/hoist/core/persist/persistenceManager'; export interface PersistenceGridPopoverProps extends HoistProps { /** True (default) to render a save button alongside the primary menu button when dirty. */ @@ -28,6 +39,8 @@ export const [PersistenceGridPopover, persistenceGridPopover] = omitDefaultGridComponent = false, omitTopLevelSaveButton = false }: PersistenceGridPopoverProps) { + const impl = useLocalModel(PersistenceGridPopoverModel), + {store} = impl.gridModel; if (omitDefaultGridComponent) return null; const {selectedView, isShared, entity} = model, displayName = entity.displayName; @@ -47,26 +60,29 @@ export const [PersistenceGridPopover, persistenceGridPopover] = className: 'xh-persistence-manager', compactHeader: true, style: {minHeight: 100, width: 500}, - item: grid({agOptions: {domLayout: 'autoHeight'}}), - bbar: bbar() + item: grid({ + model: impl.gridModel, + agOptions: {domLayout: 'autoHeight'} + }), + bbar: bbar({store}) }) }), - saveButton({omit: omitTopLevelSaveButton || !model.canSave}) + persistenceSaveButton({omit: omitTopLevelSaveButton || !model.canSave}) ] }); } }); const bbar = hoistCmp.factory({ - render({model}) { + render({model, store}) { return toolbar( - storeFilterField({store: model.gridModel.store}), + storeFilterField({store}), filler(), button({ icon: Icon.home(), intent: 'primary', omit: !model.enableDefault, - disabled: isNull(model.selectedId), + disabled: isNull(model.selectedToken), onClick: () => model.selectAsync(null) }), button({ @@ -97,3 +113,101 @@ const bbar = hoistCmp.factory({ ); } }); + +class PersistenceGridPopoverModel extends HoistModel { + @lookup(PersistenceManagerModel) + persistenceManagerModel: PersistenceManagerModel; + + @managed gridModel = this.createGridModel(); + + override onLinked() { + const {persistenceManagerModel} = this; + this.addReaction( + { + track: () => this.gridModel.selectedRecord, + run: record => { + if (record) { + persistenceManagerModel.selectAsync(record.data.token); + } + } + }, + { + track: () => [ + persistenceManagerModel.favorites, + persistenceManagerModel.selectedToken, + persistenceManagerModel.views + ], + run: () => this.loadGrid(), + fireImmediately: true + } + ); + } + + private createGridModel(): GridModel { + return new GridModel({ + groupBy: 'group', + autosizeOptions: {mode: 'managed'}, + selModel: 'single', + store: {fields: ['token']}, + showHover: true, + groupSortFn: (aVal, bVal, groupField) => { + return groupField === 'Favorites' ? -1 : aVal.localeCompare(bVal); + }, + cellBorders: true, + columns: [ + { + field: 'id', + renderer: v => { + let id = v; + if (typeof id === 'string') { + id = +id.replace('-favorite', ''); + } + return this.persistenceManagerModel.selectedView?.id === id + ? Icon.check() + : null; + } + }, + {field: 'name'}, + {field: 'group', hidden: true}, + {field: 'description', flex: 1}, + { + ...actionCol, + width: calcActionColWidth(1), + actions: [this.favoriteAction()], + colId: 'favAction' + } + ], + hideHeaders: true, + showGroupRowCounts: false + }); + } + + private favoriteAction(): RecordActionSpec { + return { + icon: Icon.star(), + tooltip: 'Click to add to favorites', + displayFn: ({record}) => { + const {favorites} = this.persistenceManagerModel; + return { + className: `xh-persistence-manager__menu-item-fav ${favorites.includes(record.data.token) ? 'xh-persistence-manager__menu-item-fav--active' : ''}`, + icon: favorites.includes(record.data.token) + ? Icon.star({prefix: 'fas'}) + : Icon.star({prefix: 'far'}) + }; + }, + actionFn: ({record}) => { + this.persistenceManagerModel.toggleFavorite(record.data.token); + } + }; + } + + private loadGrid() { + const favoriteViews = this.persistenceManagerModel.favoritedViews.map(it => ({ + ...it, + id: `${it.id}-favorite`, + group: 'Favorites' + })); + this.gridModel.loadData([...favoriteViews, ...this.persistenceManagerModel.views]); + this.gridModel.agApi?.redrawRows(); + } +} diff --git a/desktop/cmp/persistenceManager/cmp/PersistenceMenu.ts b/desktop/cmp/persistenceManager/cmp/PersistenceMenu.ts index 4e72f2e8cb..eeb8b12d2d 100644 --- a/desktop/cmp/persistenceManager/cmp/PersistenceMenu.ts +++ b/desktop/cmp/persistenceManager/cmp/PersistenceMenu.ts @@ -1,8 +1,10 @@ import {div, filler, hbox, span} from '@xh/hoist/cmp/layout'; import {hoistCmp, HoistProps, uses} from '@xh/hoist/core'; +import { + PersistenceManagerModel, + PersistenceViewTree +} from '@xh/hoist/core/persist/persistenceManager'; import {button} from '@xh/hoist/desktop/cmp/button'; -import {PersistenceManagerModel, saveButton} from '@xh/hoist/desktop/cmp/persistenceManager'; -import {PersistenceViewTree} from '@xh/hoist/desktop/cmp/persistenceManager/Types'; import {Icon} from '@xh/hoist/icon'; import {menu, menuDivider, menuItem, popover} from '@xh/hoist/kit/blueprint'; import {consumeEvent, pluralize} from '@xh/hoist/utils/js'; @@ -10,12 +12,12 @@ import {capitalize, isEmpty} from 'lodash'; import {ReactNode} from 'react'; export interface PersistenceMenuProps extends HoistProps { - /** True to disable options for saving/managing items. */ - minimal?: boolean; - /** True (default) to render a save button alongside the primary menu button when dirty. */ - omitTopLevelSaveButton?: boolean; - /** True to omit the default menu component. Should be used when creating custom app-specific component */ - omitDefaultMenuComponent?: boolean; + /** */ + showSaveButton?: 'whenDirty' | 'always' | 'never'; + /** */ + showPrivateViewsInSubMenu?: boolean; + /** */ + showSharedViewsInSubMenu?: boolean; } export const [PersistenceMenu, persistenceMenu] = hoistCmp.withFactory({ @@ -25,11 +27,10 @@ export const [PersistenceMenu, persistenceMenu] = hoistCmp.withFactory({ + render({model, disabled}) { + return button({ + icon: Icon.save(), + tooltip: `Save changes to this ${model.entity.displayName}`, + intent: 'primary', + disabled, + onClick: () => model.saveAsync(false).linkTo(model.loadModel) + }); + } +}); + const menuFavorite = hoistCmp.factory({ render({model, view}) { - const isFavorite = model.isFavorite(view.id); + const isFavorite = model.isFavorite(view.token); return hbox({ + className: 'xh-persistence-manager__menu-item', alignItems: 'center', items: [ span({style: {paddingRight: 5}, item: view.text}), filler(), div({ - className: `xh-persistence-manager__menu-item-fav ${isFavorite ? 'xh-persistence-manager__menu-item-fav--active' : ''}`, + className: `xh-persistence-manager__menu-item--fav ${isFavorite ? 'xh-persistence-manager__menu-item--fav--active' : ''}`, item: Icon.favorite({ prefix: isFavorite ? 'fas' : 'far' }), onClick: e => { consumeEvent(e); - model.toggleFavorite(view.id); + model.toggleFavorite(view.token); } }) ] @@ -83,7 +103,7 @@ const menuFavorite = hoistCmp.factory({ }); const objMenu = hoistCmp.factory({ - render({model, minimal}) { + render({model, showPrivateViewsInSubMenu, showSharedViewsInSubMenu}) { const {entity} = model, items = []; @@ -93,35 +113,68 @@ const objMenu = hoistCmp.factory({ ...model.favoritedViews.map(it => { return menuItem({ key: `${it.id}-isFavorite`, - icon: model.selectedId === it.id ? Icon.check() : Icon.placeholder(), + icon: model.selectedToken === it.token ? Icon.check() : Icon.placeholder(), text: menuFavorite({ view: {...it, text: model.getHierarchyDisplayName(it.name)} }), - onClick: () => model.selectAsync(it.id).linkTo(model.loadModel) + onClick: () => model.selectAsync(it.token).linkTo(model.loadModel) }); }) ); } if (!isEmpty(model.privateViewTree)) { - items.push(menuDivider({title: `My ${pluralize(entity.displayName)}`})); - model.privateViewTree.forEach(it => { - items.push(buildView(it, model)); - }); + items.push( + menuDivider({ + title: showPrivateViewsInSubMenu ? null : `My ${pluralize(entity.displayName)}` + }) + ); + if (showPrivateViewsInSubMenu) { + items.push( + menuItem({ + text: `My ${pluralize(entity.displayName)}`, + shouldDismissPopover: false, + children: model.privateViewTree.map(it => { + return buildView(it, model); + }) + }) + ); + } else { + model.privateViewTree.forEach(it => { + items.push(buildView(it, model)); + }); + } } if (!isEmpty(model.sharedViewTree)) { - items.push(menuDivider({title: `Shared ${pluralize(entity.displayName)}`})); - model.sharedViewTree.forEach(it => { - items.push(buildView(it, model)); - }); + items.push( + menuDivider({ + title: showSharedViewsInSubMenu + ? null + : `Shared ${pluralize(entity.displayName)}` + }) + ); + if (showSharedViewsInSubMenu) { + items.push( + menuItem({ + text: `Shared ${pluralize(entity.displayName)}`, + shouldDismissPopover: false, + children: model.sharedViewTree.map(it => { + return buildView(it, model); + }) + }) + ); + } else { + model.sharedViewTree.forEach(it => { + items.push(buildView(it, model)); + }); + } } - if (minimal) return menu({items}); return menu({ items: [ ...items, menuDivider({omit: !model.enableDefault || isEmpty(items)}), menuItem({ - icon: model.selectedId ? Icon.placeholder() : Icon.check(), + icon: model.selectedToken ? Icon.placeholder() : Icon.check(), text: `Default ${capitalize(entity.displayName)}`, omit: !model.enableDefault, onClick: () => model.selectAsync(null) @@ -173,7 +226,7 @@ function buildView(view: PersistenceViewTree, model: PersistenceManagerModel): R key: view.id, icon, text: menuFavorite({model, view}), - onClick: () => model.selectAsync(view.id).linkTo(model.loadModel) + onClick: () => model.selectAsync(view.token).linkTo(model.loadModel) }); } } diff --git a/desktop/cmp/persistenceManager/cmp/SaveDialog.ts b/desktop/cmp/persistenceManager/cmp/SaveDialog.ts index 21e8d79c49..6b8f1a9eeb 100644 --- a/desktop/cmp/persistenceManager/cmp/SaveDialog.ts +++ b/desktop/cmp/persistenceManager/cmp/SaveDialog.ts @@ -1,6 +1,7 @@ import {form} from '@xh/hoist/cmp/form'; import {filler, fragment, vframe} from '@xh/hoist/cmp/layout'; import {hoistCmp, uses} from '@xh/hoist/core'; +import {SaveDialogModel} from '@xh/hoist/core/persist/persistenceManager/impl/SaveDialogModel'; import {button} from '@xh/hoist/desktop/cmp/button'; import {formField} from '@xh/hoist/desktop/cmp/form'; import {textArea, textInput} from '@xh/hoist/desktop/cmp/input'; @@ -9,7 +10,6 @@ import {panel} from '@xh/hoist/desktop/cmp/panel'; import {toolbar} from '@xh/hoist/desktop/cmp/toolbar'; import {Icon} from '@xh/hoist/icon'; import {dialog} from '@xh/hoist/kit/blueprint'; -import {SaveDialogModel} from './SaveDialogModel'; export const saveDialog = hoistCmp.factory({ displayName: 'SaveDialog', diff --git a/desktop/cmp/persistenceManager/index.ts b/desktop/cmp/persistenceManager/index.ts index b1ce41ef89..49fbbdd394 100644 --- a/desktop/cmp/persistenceManager/index.ts +++ b/desktop/cmp/persistenceManager/index.ts @@ -1,2 +1,4 @@ -export * from './PersistenceManagerModel'; export * from './PersistenceManager'; +export * from './cmp/ManageDialog'; +export * from './cmp/SaveDialog'; +export * from './cmp/PersistenceMenu'; From afd6f7e42292e7b38cdd1ad876b821f9a17decb2 Mon Sep 17 00:00:00 2001 From: Ryanseanlee Date: Fri, 27 Sep 2024 09:32:01 -0700 Subject: [PATCH 12/19] Update type --- svc/JsonBlobService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/svc/JsonBlobService.ts b/svc/JsonBlobService.ts index 2ce4254d17..e8822603b7 100644 --- a/svc/JsonBlobService.ts +++ b/svc/JsonBlobService.ts @@ -47,7 +47,7 @@ export class JsonBlobService extends HoistService { }: { type: string; name: string; - acl?: string | PlainObject; + acl?: string; description?: string; value: any; meta?: any; From 3ee73679a9678a3f92fc8e54b95044ac5dabdd35 Mon Sep 17 00:00:00 2001 From: Ryanseanlee Date: Fri, 27 Sep 2024 12:00:59 -0700 Subject: [PATCH 13/19] Remove ID and remove PersistenceGridPopover --- .../PersistenceManagerModel.ts | 5 +- core/persist/persistenceManager/Types.ts | 2 - .../impl/ManageDialogModel.ts | 7 +- .../cmp/PersistenceGridPopover.ts | 213 ------------------ .../persistenceManager/cmp/PersistenceMenu.ts | 4 +- 5 files changed, 8 insertions(+), 223 deletions(-) delete mode 100644 desktop/cmp/persistenceManager/cmp/PersistenceGridPopover.ts diff --git a/core/persist/persistenceManager/PersistenceManagerModel.ts b/core/persist/persistenceManager/PersistenceManagerModel.ts index 7ad83de72a..8a30072da3 100644 --- a/core/persist/persistenceManager/PersistenceManagerModel.ts +++ b/core/persist/persistenceManager/PersistenceManagerModel.ts @@ -373,7 +373,7 @@ export class PersistenceManagerModel extend }); return unbalancedStableGroupsAndViews.map(it => { - const {name, id, isMenuFolder, children, token} = it; + const {name, isMenuFolder, children, token} = it; if (isMenuFolder) { return { type: 'directory', @@ -385,8 +385,7 @@ export class PersistenceManagerModel extend return { type: 'view', text: this.getHierarchyDisplayName(name), - selected: this.selectedView?.id === id, - id, + selected: this.selectedToken === token, token }; }); diff --git a/core/persist/persistenceManager/Types.ts b/core/persist/persistenceManager/Types.ts index 6a34fc2935..ffc46eeb71 100644 --- a/core/persist/persistenceManager/Types.ts +++ b/core/persist/persistenceManager/Types.ts @@ -2,7 +2,6 @@ import {PlainObject} from '@xh/hoist/core'; import {LocalDate} from '@xh/hoist/utils/datetime'; export interface PersistenceView { - id: number; acl: string; archived: boolean; dateCreated: LocalDate; @@ -28,7 +27,6 @@ export type PersistenceViewTree = { } | { type: 'view'; - id: number; token: string; } ); diff --git a/core/persist/persistenceManager/impl/ManageDialogModel.ts b/core/persist/persistenceManager/impl/ManageDialogModel.ts index 61c7d52df8..b36dd2c1b3 100644 --- a/core/persist/persistenceManager/impl/ManageDialogModel.ts +++ b/core/persist/persistenceManager/impl/ManageDialogModel.ts @@ -184,9 +184,9 @@ export class ManageDialogModel extends HoistModel { async ensureGridHasSelection() { const {gridModel} = this; - const {selectedView} = this.parentModel; - if (selectedView) { - gridModel.selModel.select(selectedView.id); + const {selectedToken} = this.parentModel; + if (selectedToken) { + gridModel.selModel.select(selectedToken); } else { await gridModel.preSelectFirstAsync(); } @@ -201,6 +201,7 @@ export class ManageDialogModel extends HoistModel { hideHeaders: true, showGroupRowCounts: false, store: { + idSpec: 'token', fields: [ {name: 'token', type: 'string'}, {name: 'name', type: 'string'}, diff --git a/desktop/cmp/persistenceManager/cmp/PersistenceGridPopover.ts b/desktop/cmp/persistenceManager/cmp/PersistenceGridPopover.ts deleted file mode 100644 index d097636ab1..0000000000 --- a/desktop/cmp/persistenceManager/cmp/PersistenceGridPopover.ts +++ /dev/null @@ -1,213 +0,0 @@ -import {grid, GridModel} from '@xh/hoist/cmp/grid'; -import {filler, hbox} from '@xh/hoist/cmp/layout'; -import {storeFilterField} from '@xh/hoist/cmp/store'; -import { - hoistCmp, - HoistModel, - HoistProps, - lookup, - managed, - useLocalModel, - uses -} from '@xh/hoist/core'; -import {RecordActionSpec} from '@xh/hoist/data'; -import {actionCol, calcActionColWidth} from '@xh/hoist/desktop/cmp/grid'; -import {panel} from '@xh/hoist/desktop/cmp/panel'; -import {persistenceSaveButton} from '@xh/hoist/desktop/cmp/persistenceManager'; -import {toolbar} from '@xh/hoist/desktop/cmp/toolbar'; -import {Icon} from '@xh/hoist/icon'; -import {popover} from '@xh/hoist/kit/blueprint'; -import {capitalize, isEmpty, isNull} from 'lodash'; -import {button} from '@xh/hoist/desktop/cmp/button'; -import {PersistenceManagerModel} from '@xh/hoist/core/persist/persistenceManager'; - -export interface PersistenceGridPopoverProps extends HoistProps { - /** True (default) to render a save button alongside the primary menu button when dirty. */ - omitTopLevelSaveButton?: boolean; - /** True to omit the default menu component. Should be used when creating custom app-specific component */ - omitDefaultGridComponent?: boolean; -} - -export const [PersistenceGridPopover, persistenceGridPopover] = - hoistCmp.withFactory({ - displayName: 'PersistenceGridPopover', - className: 'xh-persistence-manager__menu', - model: uses(PersistenceManagerModel), - - render({ - model, - omitDefaultGridComponent = false, - omitTopLevelSaveButton = false - }: PersistenceGridPopoverProps) { - const impl = useLocalModel(PersistenceGridPopoverModel), - {store} = impl.gridModel; - if (omitDefaultGridComponent) return null; - const {selectedView, isShared, entity} = model, - displayName = entity.displayName; - return hbox({ - items: [ - popover({ - placement: 'bottom-end', - item: button({ - text: - model.getHierarchyDisplayName(selectedView?.name) ?? - `Default ${capitalize(displayName)}`, - icon: isShared ? Icon.users() : Icon.bookmark(), - rightIcon: Icon.chevronDown(), - outlined: true - }), - content: panel({ - className: 'xh-persistence-manager', - compactHeader: true, - style: {minHeight: 100, width: 500}, - item: grid({ - model: impl.gridModel, - agOptions: {domLayout: 'autoHeight'} - }), - bbar: bbar({store}) - }) - }), - persistenceSaveButton({omit: omitTopLevelSaveButton || !model.canSave}) - ] - }); - } - }); - -const bbar = hoistCmp.factory({ - render({model, store}) { - return toolbar( - storeFilterField({store}), - filler(), - button({ - icon: Icon.home(), - intent: 'primary', - omit: !model.enableDefault, - disabled: isNull(model.selectedToken), - onClick: () => model.selectAsync(null) - }), - button({ - icon: Icon.gear(), - disabled: isEmpty(model.views), - onClick: () => model.openManageDialog() - }), - '-', - button({ - tooltip: `Revert ${capitalize(model.entity.displayName)}`, - icon: Icon.reset(), - disabled: !model.isDirty || !model.selectedView, - onClick: () => model.resetAsync() - }), - button({ - text: 'Save as...', - icon: Icon.copy(), - disabled: !model.selectedView, - onClick: () => model.saveAsAsync() - }), - button({ - text: 'Save', - icon: Icon.save(), - intent: 'primary', - disabled: !model.canSave, - onClick: () => model.saveAsync() - }) - ); - } -}); - -class PersistenceGridPopoverModel extends HoistModel { - @lookup(PersistenceManagerModel) - persistenceManagerModel: PersistenceManagerModel; - - @managed gridModel = this.createGridModel(); - - override onLinked() { - const {persistenceManagerModel} = this; - this.addReaction( - { - track: () => this.gridModel.selectedRecord, - run: record => { - if (record) { - persistenceManagerModel.selectAsync(record.data.token); - } - } - }, - { - track: () => [ - persistenceManagerModel.favorites, - persistenceManagerModel.selectedToken, - persistenceManagerModel.views - ], - run: () => this.loadGrid(), - fireImmediately: true - } - ); - } - - private createGridModel(): GridModel { - return new GridModel({ - groupBy: 'group', - autosizeOptions: {mode: 'managed'}, - selModel: 'single', - store: {fields: ['token']}, - showHover: true, - groupSortFn: (aVal, bVal, groupField) => { - return groupField === 'Favorites' ? -1 : aVal.localeCompare(bVal); - }, - cellBorders: true, - columns: [ - { - field: 'id', - renderer: v => { - let id = v; - if (typeof id === 'string') { - id = +id.replace('-favorite', ''); - } - return this.persistenceManagerModel.selectedView?.id === id - ? Icon.check() - : null; - } - }, - {field: 'name'}, - {field: 'group', hidden: true}, - {field: 'description', flex: 1}, - { - ...actionCol, - width: calcActionColWidth(1), - actions: [this.favoriteAction()], - colId: 'favAction' - } - ], - hideHeaders: true, - showGroupRowCounts: false - }); - } - - private favoriteAction(): RecordActionSpec { - return { - icon: Icon.star(), - tooltip: 'Click to add to favorites', - displayFn: ({record}) => { - const {favorites} = this.persistenceManagerModel; - return { - className: `xh-persistence-manager__menu-item-fav ${favorites.includes(record.data.token) ? 'xh-persistence-manager__menu-item-fav--active' : ''}`, - icon: favorites.includes(record.data.token) - ? Icon.star({prefix: 'fas'}) - : Icon.star({prefix: 'far'}) - }; - }, - actionFn: ({record}) => { - this.persistenceManagerModel.toggleFavorite(record.data.token); - } - }; - } - - private loadGrid() { - const favoriteViews = this.persistenceManagerModel.favoritedViews.map(it => ({ - ...it, - id: `${it.id}-favorite`, - group: 'Favorites' - })); - this.gridModel.loadData([...favoriteViews, ...this.persistenceManagerModel.views]); - this.gridModel.agApi?.redrawRows(); - } -} diff --git a/desktop/cmp/persistenceManager/cmp/PersistenceMenu.ts b/desktop/cmp/persistenceManager/cmp/PersistenceMenu.ts index eeb8b12d2d..b453fc48b3 100644 --- a/desktop/cmp/persistenceManager/cmp/PersistenceMenu.ts +++ b/desktop/cmp/persistenceManager/cmp/PersistenceMenu.ts @@ -112,7 +112,7 @@ const objMenu = hoistCmp.factory({ items.push( ...model.favoritedViews.map(it => { return menuItem({ - key: `${it.id}-isFavorite`, + key: `${it.token}-isFavorite`, icon: model.selectedToken === it.token ? Icon.check() : Icon.placeholder(), text: menuFavorite({ view: {...it, text: model.getHierarchyDisplayName(it.name)} @@ -223,7 +223,7 @@ function buildView(view: PersistenceViewTree, model: PersistenceManagerModel): R case 'view': return menuItem({ className: 'xh-persistence-manager__menu-item', - key: view.id, + key: view.token, icon, text: menuFavorite({model, view}), onClick: () => model.selectAsync(view.token).linkTo(model.loadModel) From ee1beff635beab7cccf26a7bc0d944b8154ee327 Mon Sep 17 00:00:00 2001 From: Ryanseanlee Date: Mon, 30 Sep 2024 13:57:16 -0700 Subject: [PATCH 14/19] Add comments --- desktop/cmp/persistenceManager/cmp/PersistenceMenu.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/desktop/cmp/persistenceManager/cmp/PersistenceMenu.ts b/desktop/cmp/persistenceManager/cmp/PersistenceMenu.ts index b453fc48b3..49f108dee7 100644 --- a/desktop/cmp/persistenceManager/cmp/PersistenceMenu.ts +++ b/desktop/cmp/persistenceManager/cmp/PersistenceMenu.ts @@ -12,11 +12,11 @@ import {capitalize, isEmpty} from 'lodash'; import {ReactNode} from 'react'; export interface PersistenceMenuProps extends HoistProps { - /** */ + /** 'whenDirty' to only show saveButton when persistence state is dirty. (Default 'whenDirty') */ showSaveButton?: 'whenDirty' | 'always' | 'never'; - /** */ + /** True to render private views in sub-menu (Default false)*/ showPrivateViewsInSubMenu?: boolean; - /** */ + /** True to render shared views in sub-menu (Default false)*/ showSharedViewsInSubMenu?: boolean; } From a01060e3f013cceeff399e3b468f2ffe92867621 Mon Sep 17 00:00:00 2001 From: Ryanseanlee Date: Wed, 2 Oct 2024 17:24:29 -0700 Subject: [PATCH 15/19] Replace Persistence -> View --- core/persist/PersistOptions.ts | 8 ++-- core/persist/PersistenceManagerProvider.ts | 39 ------------------- core/persist/PersistenceProvider.ts | 8 ++-- core/persist/ViewManagerProvider.ts | 36 +++++++++++++++++ core/persist/index.ts | 2 +- core/persist/persistenceManager/index.ts | 2 - .../Types.ts | 6 +-- .../ViewManagerModel.ts} | 34 ++++++++-------- .../impl/ManageDialogModel.ts | 6 +-- .../impl/SaveDialogModel.ts | 11 +++--- core/persist/viewManager/index.ts | 2 + .../persistenceManager/PersistenceManager.ts | 26 ------------- desktop/cmp/persistenceManager/index.ts | 4 -- .../ViewManager.scss} | 4 +- desktop/cmp/viewManager/ViewManager.ts | 21 ++++++++++ .../cmp/ManageDialog.ts | 2 +- .../cmp/SaveDialog.ts | 2 +- .../cmp/ViewMenu.ts} | 23 +++++------ desktop/cmp/viewManager/index.ts | 4 ++ 19 files changed, 115 insertions(+), 125 deletions(-) delete mode 100644 core/persist/PersistenceManagerProvider.ts create mode 100644 core/persist/ViewManagerProvider.ts delete mode 100644 core/persist/persistenceManager/index.ts rename core/persist/{persistenceManager => viewManager}/Types.ts (78%) rename core/persist/{persistenceManager/PersistenceManagerModel.ts => viewManager/ViewManagerModel.ts} (92%) rename core/persist/{persistenceManager => viewManager}/impl/ManageDialogModel.ts (97%) rename core/persist/{persistenceManager => viewManager}/impl/SaveDialogModel.ts (88%) create mode 100644 core/persist/viewManager/index.ts delete mode 100644 desktop/cmp/persistenceManager/PersistenceManager.ts delete mode 100644 desktop/cmp/persistenceManager/index.ts rename desktop/cmp/{persistenceManager/PersistenceManager.scss => viewManager/ViewManager.scss} (92%) create mode 100644 desktop/cmp/viewManager/ViewManager.ts rename desktop/cmp/{persistenceManager => viewManager}/cmp/ManageDialog.ts (98%) rename desktop/cmp/{persistenceManager => viewManager}/cmp/SaveDialog.ts (97%) rename desktop/cmp/{persistenceManager/cmp/PersistenceMenu.ts => viewManager/cmp/ViewMenu.ts} (92%) create mode 100644 desktop/cmp/viewManager/index.ts diff --git a/core/persist/PersistOptions.ts b/core/persist/PersistOptions.ts index af0e27167e..2dcf7e12e2 100644 --- a/core/persist/PersistOptions.ts +++ b/core/persist/PersistOptions.ts @@ -6,7 +6,7 @@ */ import {DebounceSpec} from '../'; -import {PersistenceManagerModel} from './persistenceManager'; +import {ViewManagerModel} from './viewManager'; /** * Options governing persistence. @@ -20,7 +20,7 @@ export interface PersistOptions { /** * Type of PersistenceProvider to create. If not provided, defaulted based on the presence of - * `prefKey`, `localStorageKey`, `dashViewModel`, `persistenceManagerModel`, `getData` and `setData`. + * `prefKey`, `localStorageKey`, `dashViewModel`, `viewManagerModel`, `getData` and `setData`. */ type?: string; @@ -33,8 +33,8 @@ export interface PersistOptions { /** DashViewModel used to read / write view state. */ dashViewModel?: object; - /** PersistenceManagerModel used to read / write view state. */ - persistenceManagerModel?: PersistenceManagerModel; + /** ViewManagerModel used to read / write view state. */ + viewManagerModel?: ViewManagerModel; /** * Function returning blob of data to be used for reading state. diff --git a/core/persist/PersistenceManagerProvider.ts b/core/persist/PersistenceManagerProvider.ts deleted file mode 100644 index 16286f18a2..0000000000 --- a/core/persist/PersistenceManagerProvider.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* - * This file belongs to Hoist, an application development toolkit - * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) - * - * Copyright © 2024 Extremely Heavy Industries Inc. - */ - -import {cloneDeep} from 'lodash'; -import {PersistenceProvider, PersistOptions} from './'; -import {throwIf} from '@xh/hoist/utils/js'; -import {PersistenceManagerModel} from './persistenceManager'; - -/** - * PersistenceProvider that stores state for PersistenceManager. - */ -export class PersistenceManagerProvider extends PersistenceProvider { - persistenceManagerModel: PersistenceManagerModel; - - constructor({persistenceManagerModel, ...rest}: PersistOptions) { - throwIf( - !persistenceManagerModel, - `PersistenceManagerProvider requires a 'persistenceManagerModel'.` - ); - super(rest); - this.persistenceManagerModel = persistenceManagerModel; - } - - //---------------- - // Implementation - //---------------- - override readRaw() { - const {pendingValue, value} = this.persistenceManagerModel; - return cloneDeep(pendingValue ?? value ?? {}); - } - - override writeRaw(data) { - this.persistenceManagerModel.mergePendingValue(data); - } -} diff --git a/core/persist/PersistenceProvider.ts b/core/persist/PersistenceProvider.ts index 9524928ff5..d385165bf4 100644 --- a/core/persist/PersistenceProvider.ts +++ b/core/persist/PersistenceProvider.ts @@ -10,7 +10,7 @@ import { LocalStorageProvider, PrefProvider, DashViewProvider, - PersistenceManagerProvider, + ViewManagerProvider, CustomProvider, PersistOptions } from './'; @@ -54,7 +54,7 @@ export class PersistenceProvider { if (rest.prefKey) type = 'pref'; if (rest.localStorageKey) type = 'localStorage'; if (rest.dashViewModel) type = 'dashView'; - if (rest.persistenceManagerModel) type = 'persistenceManagerModel'; + if (rest.viewManagerModel) type = 'viewManagerModel'; if (rest.getData || rest.setData) type = 'custom'; } @@ -65,8 +65,8 @@ export class PersistenceProvider { return new LocalStorageProvider(rest); case `dashView`: return new DashViewProvider(rest); - case 'persistenceManagerModel': - return new PersistenceManagerProvider(rest); + case 'viewManagerModel': + return new ViewManagerProvider(rest); case 'custom': return new CustomProvider(rest); default: diff --git a/core/persist/ViewManagerProvider.ts b/core/persist/ViewManagerProvider.ts new file mode 100644 index 0000000000..dc47845bc2 --- /dev/null +++ b/core/persist/ViewManagerProvider.ts @@ -0,0 +1,36 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2024 Extremely Heavy Industries Inc. + */ + +import {cloneDeep} from 'lodash'; +import {PersistenceProvider, PersistOptions} from './'; +import {throwIf} from '@xh/hoist/utils/js'; +import {ViewManagerModel} from './viewManager'; + +/** + * PersistenceProvider that stores state for ViewManager. + */ +export class ViewManagerProvider extends PersistenceProvider { + viewManagerModel: ViewManagerModel; + + constructor({viewManagerModel, ...rest}: PersistOptions) { + throwIf(!viewManagerModel, `ViewManagerProvider requires a 'viewManagerModel'.`); + super(rest); + this.viewManagerModel = viewManagerModel; + } + + //---------------- + // Implementation + //---------------- + override readRaw() { + const {pendingValue, value} = this.viewManagerModel; + return cloneDeep(pendingValue ?? value ?? {}); + } + + override writeRaw(data) { + this.viewManagerModel.mergePendingValue(data); + } +} diff --git a/core/persist/index.ts b/core/persist/index.ts index 9d380dd6fb..01503ff88d 100644 --- a/core/persist/index.ts +++ b/core/persist/index.ts @@ -4,4 +4,4 @@ export * from './LocalStorageProvider'; export * from './DashViewProvider'; export * from './PrefProvider'; export * from './CustomProvider'; -export * from './PersistenceManagerProvider'; +export * from './ViewManagerProvider'; diff --git a/core/persist/persistenceManager/index.ts b/core/persist/persistenceManager/index.ts deleted file mode 100644 index ec3e2e5f9b..0000000000 --- a/core/persist/persistenceManager/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './PersistenceManagerModel'; -export * from './Types'; diff --git a/core/persist/persistenceManager/Types.ts b/core/persist/viewManager/Types.ts similarity index 78% rename from core/persist/persistenceManager/Types.ts rename to core/persist/viewManager/Types.ts index ffc46eeb71..0fb0687706 100644 --- a/core/persist/persistenceManager/Types.ts +++ b/core/persist/viewManager/Types.ts @@ -1,7 +1,7 @@ import {PlainObject} from '@xh/hoist/core'; import {LocalDate} from '@xh/hoist/utils/datetime'; -export interface PersistenceView { +export interface View { acl: string; archived: boolean; dateCreated: LocalDate; @@ -17,13 +17,13 @@ export interface PersistenceView { value: T; } -export type PersistenceViewTree = { +export type ViewTree = { text: string; selected: boolean; } & ( | { type: 'directory'; - items: PersistenceViewTree[]; + items: ViewTree[]; } | { type: 'view'; diff --git a/core/persist/persistenceManager/PersistenceManagerModel.ts b/core/persist/viewManager/ViewManagerModel.ts similarity index 92% rename from core/persist/persistenceManager/PersistenceManagerModel.ts rename to core/persist/viewManager/ViewManagerModel.ts index 8a30072da3..16e42de7b5 100644 --- a/core/persist/persistenceManager/PersistenceManagerModel.ts +++ b/core/persist/viewManager/ViewManagerModel.ts @@ -14,10 +14,10 @@ import {capitalize, find, isEqualWith, isNil, isString, sortBy, startCase} from import {runInAction} from 'mobx'; import {ManageDialogModel} from './impl/ManageDialogModel'; import {SaveDialogModel} from './impl/SaveDialogModel'; -import {PersistenceView, PersistenceViewTree} from './Types'; +import {View, ViewTree} from './Types'; /** - * PersistenceManager provides re-usable loading, selection, and user management of named configs, which are modelled + * ViewManager provides re-usable loading, selection, and user management of named configs, which are modelled * and persisted on the server as databased domain objects extending the `PersistedObject` trait. * * These generic configs are intended for specific use cases such as saved Grid views, Dashboards, and data import @@ -37,7 +37,7 @@ interface Entity { displayName?: string; } -export interface PersistenceManagerConfig { +export interface ViewManagerConfig { /** Entity name or object for this model. */ entity: string | Entity; /** Whether user can publish or edit globally shared objects. */ @@ -50,9 +50,9 @@ export interface PersistenceManagerConfig { enableAutoSave?: boolean; } -export class PersistenceManagerModel extends HoistModel { - static async createAsync(config: PersistenceManagerConfig): Promise { - const ret = new PersistenceManagerModel(config); +export class ViewManagerModel extends HoistModel { + static async createAsync(config: ViewManagerConfig): Promise { + const ret = new ViewManagerModel(config); await ret.loadAsync(); return ret; } @@ -74,7 +74,7 @@ export class PersistenceManagerModel extend /** Current state of the active object, can include not-yet-persisted changes. */ @observable.ref pendingValue: T = null; - @observable.ref views: PersistenceView[] = []; + @observable.ref views: View[] = []; @bindable selectedToken: string = null; @bindable favorites: string[] = []; @@ -87,7 +87,7 @@ export class PersistenceManagerModel extend return this.selectedView?.value; } - get selectedView(): PersistenceView { + get selectedView(): View { return this.views.find(it => it.token === this.selectedToken); } @@ -119,23 +119,23 @@ export class PersistenceManagerModel extend return !!this.selectedView?.isShared; } - get favoritedViews(): PersistenceView[] { + get favoritedViews(): View[] { return this.views.filter(it => this.favorites.includes(it.token)); } - get sharedViews(): PersistenceView[] { + get sharedViews(): View[] { return this.views.filter(it => it.isShared); } - get privateViews(): PersistenceView[] { + get privateViews(): View[] { return this.views.filter(it => !it.isShared); } - get sharedViewTree(): PersistenceViewTree[] { + get sharedViewTree(): ViewTree[] { return this.hierarchicalItemSpecs(sortBy(this.sharedViews, 'name')); } - get privateViewTree(): PersistenceViewTree[] { + get privateViewTree(): ViewTree[] { return this.hierarchicalItemSpecs(sortBy(this.privateViews, 'name')); } @@ -149,7 +149,7 @@ export class PersistenceManagerModel extend canManageGlobal, enableDefault = false, enableAutoSave = false - }: PersistenceManagerConfig) { + }: ViewManagerConfig) { super(); makeObservable(this); @@ -164,7 +164,7 @@ export class PersistenceManagerModel extend if (persistWith) { try { this._provider = PersistenceProvider.create({ - path: 'persistenceManager', + path: 'viewManager', ...persistWith }); @@ -299,7 +299,7 @@ export class PersistenceManagerModel extend return ret; } - private processRaw(raw: PlainObject): PersistenceView[] { + private processRaw(raw: PlainObject): View[] { const {entity} = this, name = capitalize(pluralize(entity.displayName)); return raw.map(it => { @@ -351,7 +351,7 @@ export class PersistenceManagerModel extend }); } - private hierarchicalItemSpecs(views, depth: number = 0): PersistenceViewTree[] { + private hierarchicalItemSpecs(views, depth: number = 0): ViewTree[] { const groups = {}, unbalancedStableGroupsAndViews = []; diff --git a/core/persist/persistenceManager/impl/ManageDialogModel.ts b/core/persist/viewManager/impl/ManageDialogModel.ts similarity index 97% rename from core/persist/persistenceManager/impl/ManageDialogModel.ts rename to core/persist/viewManager/impl/ManageDialogModel.ts index b36dd2c1b3..6b8ac3a9ad 100644 --- a/core/persist/persistenceManager/impl/ManageDialogModel.ts +++ b/core/persist/viewManager/impl/ManageDialogModel.ts @@ -1,14 +1,14 @@ import {FormModel} from '@xh/hoist/cmp/form'; import {GridAutosizeMode, GridModel} from '@xh/hoist/cmp/grid'; import {HoistModel, managed, XH} from '@xh/hoist/core'; -import {PersistenceManagerModel} from '@xh/hoist/core/persist/persistenceManager'; import {lengthIs, required} from '@xh/hoist/data'; import {Icon} from '@xh/hoist/icon'; import {bindable, makeObservable} from '@xh/hoist/mobx'; import {includes, isEmpty} from 'lodash'; +import {ViewManagerModel} from '../ViewManagerModel'; export class ManageDialogModel extends HoistModel { - parentModel: PersistenceManagerModel; + parentModel: ViewManagerModel; @bindable isOpen: boolean = false; @@ -48,7 +48,7 @@ export class ManageDialogModel extends HoistModel { return this.parentModel.canManageGlobal; } - constructor(parentModel: PersistenceManagerModel) { + constructor(parentModel: ViewManagerModel) { super(); makeObservable(this); diff --git a/core/persist/persistenceManager/impl/SaveDialogModel.ts b/core/persist/viewManager/impl/SaveDialogModel.ts similarity index 88% rename from core/persist/persistenceManager/impl/SaveDialogModel.ts rename to core/persist/viewManager/impl/SaveDialogModel.ts index e52cbb4820..74d5b0c46c 100644 --- a/core/persist/persistenceManager/impl/SaveDialogModel.ts +++ b/core/persist/viewManager/impl/SaveDialogModel.ts @@ -1,21 +1,22 @@ import {FormModel} from '@xh/hoist/cmp/form'; import {HoistModel, managed, TaskObserver, XH} from '@xh/hoist/core'; -import {PersistenceManagerModel, PersistenceView} from '@xh/hoist/core/persist/persistenceManager'; import {lengthIs, required} from '@xh/hoist/data'; import {bindable, makeObservable} from '@xh/hoist/mobx'; +import {ViewManagerModel} from '../ViewManagerModel'; +import {View} from '../Types'; export class SaveDialogModel extends HoistModel { readonly saveTask = TaskObserver.trackLast(); private readonly type: string; - parentModel: PersistenceManagerModel; + parentModel: ViewManagerModel; - @bindable viewStub: Partial; + @bindable viewStub: Partial; @bindable isOpen: boolean = false; @managed readonly formModel = this.createFormModel(); - constructor(parentModel: PersistenceManagerModel, type: string) { + constructor(parentModel: ViewManagerModel, type: string) { super(); makeObservable(this); @@ -23,7 +24,7 @@ export class SaveDialogModel extends HoistModel { this.type = type; } - open(viewStub: Partial) { + open(viewStub: Partial) { this.viewStub = viewStub; this.formModel.init({ diff --git a/core/persist/viewManager/index.ts b/core/persist/viewManager/index.ts new file mode 100644 index 0000000000..c0940c695a --- /dev/null +++ b/core/persist/viewManager/index.ts @@ -0,0 +1,2 @@ +export * from './ViewManagerModel'; +export * from './Types'; diff --git a/desktop/cmp/persistenceManager/PersistenceManager.ts b/desktop/cmp/persistenceManager/PersistenceManager.ts deleted file mode 100644 index 50d150c2a5..0000000000 --- a/desktop/cmp/persistenceManager/PersistenceManager.ts +++ /dev/null @@ -1,26 +0,0 @@ -import {fragment} from '@xh/hoist/cmp/layout'; -import {hoistCmp, HoistProps, uses} from '@xh/hoist/core'; -import './PersistenceManager.scss'; -import {manageDialog, saveDialog, persistenceMenu} from '@xh/hoist/desktop/cmp/persistenceManager'; -import {PersistenceManagerModel} from '@xh/hoist/core/persist/persistenceManager'; - -export interface PersistenceManagerProps extends HoistProps {} - -export const [PersistenceManager, persistenceManager] = - hoistCmp.withFactory({ - displayName: 'PersistenceManager', - className: 'xh-persistence-manager', - model: uses(PersistenceManagerModel), - - render() { - return fragment( - persistenceMenu({ - showPrivateViewsInSubMenu: false, - showSharedViewsInSubMenu: true, - showSaveButton: 'always' - }), - manageDialog(), - saveDialog() - ); - } - }); diff --git a/desktop/cmp/persistenceManager/index.ts b/desktop/cmp/persistenceManager/index.ts deleted file mode 100644 index 49fbbdd394..0000000000 --- a/desktop/cmp/persistenceManager/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './PersistenceManager'; -export * from './cmp/ManageDialog'; -export * from './cmp/SaveDialog'; -export * from './cmp/PersistenceMenu'; diff --git a/desktop/cmp/persistenceManager/PersistenceManager.scss b/desktop/cmp/viewManager/ViewManager.scss similarity index 92% rename from desktop/cmp/persistenceManager/PersistenceManager.scss rename to desktop/cmp/viewManager/ViewManager.scss index 0298d6809e..0c41f6dd67 100644 --- a/desktop/cmp/persistenceManager/PersistenceManager.scss +++ b/desktop/cmp/viewManager/ViewManager.scss @@ -1,4 +1,4 @@ -.xh-persistence-manager { +.xh-view-manager { align-items: center; // Save Button @@ -30,7 +30,7 @@ &__menu-item { &:hover { - .xh-persistence-manager__menu-item--fav { + .xh-view-manager__menu-item--fav { opacity: 1; color: var(--xh-yellow); } diff --git a/desktop/cmp/viewManager/ViewManager.ts b/desktop/cmp/viewManager/ViewManager.ts new file mode 100644 index 0000000000..96eded807e --- /dev/null +++ b/desktop/cmp/viewManager/ViewManager.ts @@ -0,0 +1,21 @@ +import {fragment} from '@xh/hoist/cmp/layout'; +import {hoistCmp, HoistProps, uses} from '@xh/hoist/core'; +import './ViewManager.scss'; +import {ViewManagerModel} from '@xh/hoist/core/persist/viewManager/ViewManagerModel'; +import {manageDialog} from '@xh/hoist/desktop/cmp/viewManager/cmp/ManageDialog'; +import {saveDialog} from '@xh/hoist/desktop/cmp/viewManager/cmp/SaveDialog'; +import {viewMenu, ViewMenuProps} from './cmp/ViewMenu'; + +export interface ViewManagerProps extends HoistProps { + viewMenuProps: ViewMenuProps; +} + +export const [ViewManager, viewManager] = hoistCmp.withFactory({ + displayName: 'ViewManager', + className: 'xh-view-manager', + model: uses(ViewManagerModel), + + render({viewMenuProps}) { + return fragment(viewMenu(viewMenuProps), manageDialog(), saveDialog()); + } +}); diff --git a/desktop/cmp/persistenceManager/cmp/ManageDialog.ts b/desktop/cmp/viewManager/cmp/ManageDialog.ts similarity index 98% rename from desktop/cmp/persistenceManager/cmp/ManageDialog.ts rename to desktop/cmp/viewManager/cmp/ManageDialog.ts index a327fac213..b8157c34f6 100644 --- a/desktop/cmp/persistenceManager/cmp/ManageDialog.ts +++ b/desktop/cmp/viewManager/cmp/ManageDialog.ts @@ -2,7 +2,7 @@ import {form} from '@xh/hoist/cmp/form'; import {grid} from '@xh/hoist/cmp/grid'; import {br, div, filler, fragment, hframe, placeholder, spacer, vframe} from '@xh/hoist/cmp/layout'; import {hoistCmp, uses, XH} from '@xh/hoist/core'; -import {ManageDialogModel} from '@xh/hoist/core/persist/persistenceManager/impl/ManageDialogModel'; +import {ManageDialogModel} from '@xh/hoist/core/persist/viewManager/impl/ManageDialogModel'; import {button} from '@xh/hoist/desktop/cmp/button'; import {formField} from '@xh/hoist/desktop/cmp/form'; import {switchInput, textArea, textInput} from '@xh/hoist/desktop/cmp/input'; diff --git a/desktop/cmp/persistenceManager/cmp/SaveDialog.ts b/desktop/cmp/viewManager/cmp/SaveDialog.ts similarity index 97% rename from desktop/cmp/persistenceManager/cmp/SaveDialog.ts rename to desktop/cmp/viewManager/cmp/SaveDialog.ts index 6b8f1a9eeb..1ff4afc5b5 100644 --- a/desktop/cmp/persistenceManager/cmp/SaveDialog.ts +++ b/desktop/cmp/viewManager/cmp/SaveDialog.ts @@ -1,7 +1,7 @@ import {form} from '@xh/hoist/cmp/form'; import {filler, fragment, vframe} from '@xh/hoist/cmp/layout'; import {hoistCmp, uses} from '@xh/hoist/core'; -import {SaveDialogModel} from '@xh/hoist/core/persist/persistenceManager/impl/SaveDialogModel'; +import {SaveDialogModel} from '@xh/hoist/core/persist/viewManager/impl/SaveDialogModel'; import {button} from '@xh/hoist/desktop/cmp/button'; import {formField} from '@xh/hoist/desktop/cmp/form'; import {textArea, textInput} from '@xh/hoist/desktop/cmp/input'; diff --git a/desktop/cmp/persistenceManager/cmp/PersistenceMenu.ts b/desktop/cmp/viewManager/cmp/ViewMenu.ts similarity index 92% rename from desktop/cmp/persistenceManager/cmp/PersistenceMenu.ts rename to desktop/cmp/viewManager/cmp/ViewMenu.ts index 49f108dee7..f59077a53c 100644 --- a/desktop/cmp/persistenceManager/cmp/PersistenceMenu.ts +++ b/desktop/cmp/viewManager/cmp/ViewMenu.ts @@ -1,9 +1,6 @@ import {div, filler, hbox, span} from '@xh/hoist/cmp/layout'; import {hoistCmp, HoistProps, uses} from '@xh/hoist/core'; -import { - PersistenceManagerModel, - PersistenceViewTree -} from '@xh/hoist/core/persist/persistenceManager'; +import {ViewManagerModel, ViewTree} from '@xh/hoist/core/persist/viewManager'; import {button} from '@xh/hoist/desktop/cmp/button'; import {Icon} from '@xh/hoist/icon'; import {menu, menuDivider, menuItem, popover} from '@xh/hoist/kit/blueprint'; @@ -11,7 +8,7 @@ import {consumeEvent, pluralize} from '@xh/hoist/utils/js'; import {capitalize, isEmpty} from 'lodash'; import {ReactNode} from 'react'; -export interface PersistenceMenuProps extends HoistProps { +export interface ViewMenuProps extends HoistProps { /** 'whenDirty' to only show saveButton when persistence state is dirty. (Default 'whenDirty') */ showSaveButton?: 'whenDirty' | 'always' | 'never'; /** True to render private views in sub-menu (Default false)*/ @@ -20,17 +17,17 @@ export interface PersistenceMenuProps extends HoistProps({ - displayName: 'PersistenceMenu', +export const [ViewMenu, viewMenu] = hoistCmp.withFactory({ + displayName: 'ViewMenu', className: 'xh-persistence-manager__menu', - model: uses(PersistenceManagerModel), + model: uses(ViewManagerModel), render({ model, showSaveButton = 'whenDirty', showPrivateViewsInSubMenu = false, showSharedViewsInSubMenu = false - }: PersistenceMenuProps) { + }: ViewMenuProps) { const {selectedView, isShared, entity} = model, displayName = entity.displayName; return hbox({ @@ -66,7 +63,7 @@ export const [PersistenceMenu, persistenceMenu] = hoistCmp.withFactory({ +export const persistenceSaveButton = hoistCmp.factory({ render({model, disabled}) { return button({ icon: Icon.save(), @@ -78,7 +75,7 @@ export const persistenceSaveButton = hoistCmp.factory({ } }); -const menuFavorite = hoistCmp.factory({ +const menuFavorite = hoistCmp.factory({ render({model, view}) { const isFavorite = model.isFavorite(view.token); return hbox({ @@ -102,7 +99,7 @@ const menuFavorite = hoistCmp.factory({ } }); -const objMenu = hoistCmp.factory({ +const objMenu = hoistCmp.factory({ render({model, showPrivateViewsInSubMenu, showSharedViewsInSubMenu}) { const {entity} = model, items = []; @@ -209,7 +206,7 @@ const objMenu = hoistCmp.factory({ } }); -function buildView(view: PersistenceViewTree, model: PersistenceManagerModel): ReactNode { +function buildView(view: ViewTree, model: ViewManagerModel): ReactNode { const {type, text, selected} = view, icon = selected ? Icon.check() : Icon.placeholder(); switch (type) { diff --git a/desktop/cmp/viewManager/index.ts b/desktop/cmp/viewManager/index.ts new file mode 100644 index 0000000000..b74435af21 --- /dev/null +++ b/desktop/cmp/viewManager/index.ts @@ -0,0 +1,4 @@ +export * from './ViewManager'; +export * from './cmp/ManageDialog'; +export * from './cmp/SaveDialog'; +export * from './cmp/ViewMenu'; From bb518f568254847a1b46e0acee5d410e8d94af76 Mon Sep 17 00:00:00 2001 From: Ryanseanlee Date: Thu, 3 Oct 2024 14:28:52 -0700 Subject: [PATCH 16/19] Replace Persistence -> View Don't auto save when default view is selected --- core/persist/viewManager/ViewManagerModel.ts | 8 +++++++- desktop/cmp/viewManager/cmp/ViewMenu.ts | 14 ++++++++------ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/core/persist/viewManager/ViewManagerModel.ts b/core/persist/viewManager/ViewManagerModel.ts index 16e42de7b5..2fb3a43234 100644 --- a/core/persist/viewManager/ViewManagerModel.ts +++ b/core/persist/viewManager/ViewManagerModel.ts @@ -179,7 +179,11 @@ export class ViewManagerModel extends Hoist { track: () => this.pendingValue, run: () => { - if (this.enableAutoSave && !this.isSharedViewSelected) { + if ( + this.enableAutoSave && + !this.isSharedViewSelected && + this.selectedToken !== null + ) { this.saveAsync(true); } } @@ -244,6 +248,8 @@ export class ViewManagerModel extends Hoist }); } + // TODO Reset for default view currently not working because value doesnt change so reaction on + // portfolioPanelModel doesnt trigger. async resetAsync() { return this.selectAsync(this.selectedToken); } diff --git a/desktop/cmp/viewManager/cmp/ViewMenu.ts b/desktop/cmp/viewManager/cmp/ViewMenu.ts index f59077a53c..3f3f381679 100644 --- a/desktop/cmp/viewManager/cmp/ViewMenu.ts +++ b/desktop/cmp/viewManager/cmp/ViewMenu.ts @@ -19,7 +19,7 @@ export interface ViewMenuProps extends HoistProps { export const [ViewMenu, viewMenu] = hoistCmp.withFactory({ displayName: 'ViewMenu', - className: 'xh-persistence-manager__menu', + className: 'xh-view-manager__menu', model: uses(ViewManagerModel), render({ @@ -30,8 +30,9 @@ export const [ViewMenu, viewMenu] = hoistCmp.withFactory({ }: ViewMenuProps) { const {selectedView, isShared, entity} = model, displayName = entity.displayName; + return hbox({ - className: 'xh-persistence-manager__menu', + className: 'xh-view-manager__menu', items: [ popover({ item: button({ @@ -54,7 +55,8 @@ export const [ViewMenu, viewMenu] = hoistCmp.withFactory({ persistenceSaveButton({ omit: showSaveButton === 'never' || - (showSaveButton === 'whenDirty' && !model.isDirty && !model.canSave) || + (showSaveButton === 'whenDirty' && !model.isDirty) || + !model.canSave || (model.enableAutoSave && !model.canSave && model.isSharedViewSelected), disabled: !model.canSave }) @@ -79,13 +81,13 @@ const menuFavorite = hoistCmp.factory({ render({model, view}) { const isFavorite = model.isFavorite(view.token); return hbox({ - className: 'xh-persistence-manager__menu-item', + className: 'xh-view-manager__menu-item', alignItems: 'center', items: [ span({style: {paddingRight: 5}, item: view.text}), filler(), div({ - className: `xh-persistence-manager__menu-item--fav ${isFavorite ? 'xh-persistence-manager__menu-item--fav--active' : ''}`, + className: `xh-view-manager__menu-item--fav ${isFavorite ? 'xh-view-manager__menu-item--fav--active' : ''}`, item: Icon.favorite({ prefix: isFavorite ? 'fas' : 'far' }), @@ -219,7 +221,7 @@ function buildView(view: ViewTree, model: ViewManagerModel): ReactNode { }); case 'view': return menuItem({ - className: 'xh-persistence-manager__menu-item', + className: 'xh-view-manager__menu-item', key: view.token, icon, text: menuFavorite({model, view}), From 7ae18ece5e569fae54a1a7b88f848619b1e8c6ac Mon Sep 17 00:00:00 2001 From: Ryanseanlee Date: Mon, 7 Oct 2024 13:16:11 -0700 Subject: [PATCH 17/19] add isWriteStateImmediately getter on persistenceProvider --- cmp/filter/FilterChooserModel.ts | 5 +++- cmp/grid/GridModel.ts | 10 +++++-- cmp/grid/impl/GridPersistenceModel.ts | 3 +- cmp/grouping/GroupingChooserModel.ts | 3 +- cmp/tab/TabContainerModel.ts | 3 +- cmp/zoneGrid/impl/ZoneGridPersistenceModel.ts | 9 ++++-- core/HoistBase.ts | 3 +- core/persist/PersistenceProvider.ts | 4 +++ core/persist/ViewManagerProvider.ts | 4 +++ core/persist/viewManager/ViewManagerModel.ts | 30 ++++++++++++------- .../viewManager/impl/SaveDialogModel.ts | 2 +- desktop/cmp/dash/canvas/DashCanvasModel.ts | 3 +- .../cmp/dash/container/DashContainerModel.ts | 3 +- desktop/cmp/panel/PanelModel.ts | 3 +- desktop/cmp/viewManager/cmp/ViewMenu.ts | 15 +++++++++- 15 files changed, 74 insertions(+), 26 deletions(-) diff --git a/cmp/filter/FilterChooserModel.ts b/cmp/filter/FilterChooserModel.ts index 4c1f811116..f7582112f5 100644 --- a/cmp/filter/FilterChooserModel.ts +++ b/cmp/filter/FilterChooserModel.ts @@ -196,7 +196,10 @@ export class FilterChooserModel extends HoistModel { this.addReaction({ track: () => this.persistState, - run: state => this.provider.write(state) + run: state => { + this.provider.write(state); + }, + fireImmediately: this.provider.isWriteStateImmediately }); } catch (e) { this.logError(e); diff --git a/cmp/grid/GridModel.ts b/cmp/grid/GridModel.ts index 3a5b12b353..75d71e772f 100644 --- a/cmp/grid/GridModel.ts +++ b/cmp/grid/GridModel.ts @@ -631,7 +631,13 @@ export class GridModel extends HoistModel { * @returns true if defaults were restored */ async restoreDefaultsAsync( - {skipWarning}: {skipWarning: boolean} = {skipWarning: !!this.restoreDefaultsWarning} + { + skipWarning, + skipClearPersistenceModel + }: {skipWarning?: boolean; skipClearPersistenceModel?: boolean} = { + skipWarning: !!this.restoreDefaultsWarning, + skipClearPersistenceModel: false + } ): Promise { if (this.restoreDefaultsWarning && !skipWarning) { const confirmed = await XH.confirm({ @@ -651,7 +657,7 @@ export class GridModel extends HoistModel { this.setGroupBy(groupBy); this.filterModel?.clear(); - this.persistenceModel?.clear(); + if (!skipClearPersistenceModel) this.persistenceModel?.clear(); if (this.autosizeOptions.mode === 'managed') { await this.autosizeAsync(); diff --git a/cmp/grid/impl/GridPersistenceModel.ts b/cmp/grid/impl/GridPersistenceModel.ts index e5f3a327a9..ebfd2a5f69 100644 --- a/cmp/grid/impl/GridPersistenceModel.ts +++ b/cmp/grid/impl/GridPersistenceModel.ts @@ -55,7 +55,8 @@ export class GridPersistenceModel extends HoistModel { this.state = this.loadState() ?? this.legacyState() ?? {version: this.VERSION}; this.addReaction({ track: () => this.state, - run: state => this.provider.write(state) + run: state => this.provider.write(state), + fireImmediately: this.provider.isWriteStateImmediately }); } catch (e) { this.logError(e); diff --git a/cmp/grouping/GroupingChooserModel.ts b/cmp/grouping/GroupingChooserModel.ts index d97ade2031..90be8ca905 100644 --- a/cmp/grouping/GroupingChooserModel.ts +++ b/cmp/grouping/GroupingChooserModel.ts @@ -160,7 +160,8 @@ export class GroupingChooserModel extends HoistModel { this.addReaction({ track: () => this.persistState, - run: state => this.provider.write(state) + run: state => this.provider.write(state), + fireImmediately: this.provider.isWriteStateImmediately }); } catch (e) { this.logError(e); diff --git a/cmp/tab/TabContainerModel.ts b/cmp/tab/TabContainerModel.ts index ff56053739..96324a3f20 100644 --- a/cmp/tab/TabContainerModel.ts +++ b/cmp/tab/TabContainerModel.ts @@ -402,7 +402,8 @@ export class TabContainerModel extends HoistModel { if (this.provider) { this.addReaction({ track: () => this.activeTabId, - run: activeTabId => this.provider.write({activeTabId}) + run: activeTabId => this.provider.write({activeTabId}), + fireImmediately: this.provider.isWriteStateImmediately }); } } diff --git a/cmp/zoneGrid/impl/ZoneGridPersistenceModel.ts b/cmp/zoneGrid/impl/ZoneGridPersistenceModel.ts index 95ddab38e7..bbecdb8500 100644 --- a/cmp/zoneGrid/impl/ZoneGridPersistenceModel.ts +++ b/cmp/zoneGrid/impl/ZoneGridPersistenceModel.ts @@ -91,7 +91,8 @@ export class ZoneGridPersistenceModel extends HoistModel { track: () => this.zoneGridModel.mappings, run: mappings => { this.patchState({mappings}); - } + }, + fireImmediately: this.provider.isWriteStateImmediately }; } @@ -108,7 +109,8 @@ export class ZoneGridPersistenceModel extends HoistModel { track: () => this.zoneGridModel.sortBy, run: sortBy => { this.patchState({sortBy: sortBy?.toString()}); - } + }, + fireImmediately: this.provider.isWriteStateImmediately }; } @@ -125,7 +127,8 @@ export class ZoneGridPersistenceModel extends HoistModel { track: () => this.zoneGridModel.groupBy, run: groupBy => { this.patchState({groupBy}); - } + }, + fireImmediately: this.provider.isWriteStateImmediately }; } diff --git a/core/HoistBase.ts b/core/HoistBase.ts index 01b3191923..2a25cea212 100644 --- a/core/HoistBase.ts +++ b/core/HoistBase.ts @@ -264,7 +264,8 @@ export abstract class HoistBase { } this.addReaction({ track: () => this[property], - run: data => provider.write(data) + run: data => provider.write(data), + fireImmediately: provider.isWriteStateImmediately }); } catch (e) { this.logError( diff --git a/core/persist/PersistenceProvider.ts b/core/persist/PersistenceProvider.ts index d385165bf4..f9838bf34e 100644 --- a/core/persist/PersistenceProvider.ts +++ b/core/persist/PersistenceProvider.ts @@ -46,6 +46,10 @@ export class PersistenceProvider { path: string; debounce: DebounceSpec; + get isWriteStateImmediately(): boolean { + return false; + } + /** * Construct an instance of this class. */ diff --git a/core/persist/ViewManagerProvider.ts b/core/persist/ViewManagerProvider.ts index dc47845bc2..473fc7eba4 100644 --- a/core/persist/ViewManagerProvider.ts +++ b/core/persist/ViewManagerProvider.ts @@ -16,6 +16,10 @@ import {ViewManagerModel} from './viewManager'; export class ViewManagerProvider extends PersistenceProvider { viewManagerModel: ViewManagerModel; + override get isWriteStateImmediately(): boolean { + return true; + } + constructor({viewManagerModel, ...rest}: PersistOptions) { throwIf(!viewManagerModel, `ViewManagerProvider requires a 'viewManagerModel'.`); super(rest); diff --git a/core/persist/viewManager/ViewManagerModel.ts b/core/persist/viewManager/ViewManagerModel.ts index 2fb3a43234..766f842a12 100644 --- a/core/persist/viewManager/ViewManagerModel.ts +++ b/core/persist/viewManager/ViewManagerModel.ts @@ -10,7 +10,7 @@ import { } from '@xh/hoist/core'; import {action, bindable, computed, makeObservable, observable} from '@xh/hoist/mobx'; import {executeIfFunction, pluralize} from '@xh/hoist/utils/js'; -import {capitalize, find, isEqualWith, isNil, isString, sortBy, startCase} from 'lodash'; +import {capitalize, clone, find, isEqualWith, isNil, isString, sortBy, startCase} from 'lodash'; import {runInAction} from 'mobx'; import {ManageDialogModel} from './impl/ManageDialogModel'; import {SaveDialogModel} from './impl/SaveDialogModel'; @@ -76,17 +76,16 @@ export class ViewManagerModel extends Hoist @observable.ref views: View[] = []; + @observable.ref value: T = null; + @bindable selectedToken: string = null; @bindable favorites: string[] = []; + @bindable autoSaveToggle: boolean = false; get canManageGlobal(): boolean { return executeIfFunction(this._canManageGlobal); } - get value(): T { - return this.selectedView?.value; - } - get selectedView(): View { return this.views.find(it => it.token === this.selectedToken); } @@ -181,12 +180,19 @@ export class ViewManagerModel extends Hoist run: () => { if ( this.enableAutoSave && + this.autoSaveToggle && !this.isSharedViewSelected && this.selectedToken !== null ) { this.saveAsync(true); } } + }, + { + track: () => this.autoSaveToggle, + run: autoSaveToggle => { + if (autoSaveToggle) this.saveAsync(false); + } } ); } catch (e) { @@ -215,8 +221,10 @@ export class ViewManagerModel extends Hoist async selectAsync(token: string) { this.selectedToken = token; - const {value} = this; - this.setPendingValue(value); + runInAction(() => { + this.value = clone(this.selectedView?.value ?? ({} as T)); + }); + this.setPendingValue(this.value); } async saveAsync(skipToast: boolean = false) { @@ -248,10 +256,10 @@ export class ViewManagerModel extends Hoist }); } - // TODO Reset for default view currently not working because value doesnt change so reaction on - // portfolioPanelModel doesnt trigger. async resetAsync() { - return this.selectAsync(this.selectedToken); + runInAction(() => { + this.value = clone(this.selectedView?.value ?? ({} as T)); + }); } toggleFavorite(token: string) { @@ -263,7 +271,6 @@ export class ViewManagerModel extends Hoist this.addFavorite(token); } } - addFavorite(token: string) { this.favorites = [...this.favorites, token]; } @@ -321,6 +328,7 @@ export class ViewManagerModel extends Hoist this.pendingValue = null; return; } + value = this.cleanValue(value); if (!this.isEqualSkipAutosize(this.pendingValue, value)) { this.pendingValue = value; diff --git a/core/persist/viewManager/impl/SaveDialogModel.ts b/core/persist/viewManager/impl/SaveDialogModel.ts index 74d5b0c46c..0680fb7f02 100644 --- a/core/persist/viewManager/impl/SaveDialogModel.ts +++ b/core/persist/viewManager/impl/SaveDialogModel.ts @@ -85,7 +85,7 @@ export class SaveDialogModel extends HoistModel { this.close(); await parentModel.refreshAsync(); - await parentModel.selectAsync(newObj.id); + await parentModel.selectAsync(newObj.token); } catch (e) { XH.handleException(e); } diff --git a/desktop/cmp/dash/canvas/DashCanvasModel.ts b/desktop/cmp/dash/canvas/DashCanvasModel.ts index 6d52795517..a6bb76c84b 100644 --- a/desktop/cmp/dash/canvas/DashCanvasModel.ts +++ b/desktop/cmp/dash/canvas/DashCanvasModel.ts @@ -201,7 +201,8 @@ export class DashCanvasModel extends DashModel< this.addReaction( { track: () => this.viewState, - run: () => this.publishState() + run: () => this.publishState(), + fireImmediately: this.provider.isWriteStateImmediately }, { when: () => !!this.ref.current, diff --git a/desktop/cmp/dash/container/DashContainerModel.ts b/desktop/cmp/dash/container/DashContainerModel.ts index 0617a4f4e6..4a064abcb6 100644 --- a/desktop/cmp/dash/container/DashContainerModel.ts +++ b/desktop/cmp/dash/container/DashContainerModel.ts @@ -211,7 +211,8 @@ export class DashContainerModel extends DashModel< this.addReaction({ track: () => this.viewState, - run: () => this.updateState() + run: () => this.updateState(), + fireImmediately: this.provider.isWriteStateImmediately }); } diff --git a/desktop/cmp/panel/PanelModel.ts b/desktop/cmp/panel/PanelModel.ts index 542f5f7845..6c0d753237 100644 --- a/desktop/cmp/panel/PanelModel.ts +++ b/desktop/cmp/panel/PanelModel.ts @@ -299,7 +299,8 @@ export class PanelModel extends HoistModel { if (resizable) state.size = this.size; return state; }, - run: state => this.provider.write(state) + run: state => this.provider.write(state), + fireImmediately: this.provider.isWriteStateImmediately }); } } diff --git a/desktop/cmp/viewManager/cmp/ViewMenu.ts b/desktop/cmp/viewManager/cmp/ViewMenu.ts index 3f3f381679..167ab487be 100644 --- a/desktop/cmp/viewManager/cmp/ViewMenu.ts +++ b/desktop/cmp/viewManager/cmp/ViewMenu.ts @@ -2,6 +2,7 @@ import {div, filler, hbox, span} from '@xh/hoist/cmp/layout'; import {hoistCmp, HoistProps, uses} from '@xh/hoist/core'; import {ViewManagerModel, ViewTree} from '@xh/hoist/core/persist/viewManager'; import {button} from '@xh/hoist/desktop/cmp/button'; +import {switchInput} from '@xh/hoist/desktop/cmp/input'; import {Icon} from '@xh/hoist/icon'; import {menu, menuDivider, menuItem, popover} from '@xh/hoist/kit/blueprint'; import {consumeEvent, pluralize} from '@xh/hoist/utils/js'; @@ -57,7 +58,10 @@ export const [ViewMenu, viewMenu] = hoistCmp.withFactory({ showSaveButton === 'never' || (showSaveButton === 'whenDirty' && !model.isDirty) || !model.canSave || - (model.enableAutoSave && !model.canSave && model.isSharedViewSelected), + (model.enableAutoSave && + model.autoSaveToggle && + !model.canSave && + model.isSharedViewSelected), disabled: !model.canSave }) ] @@ -197,6 +201,15 @@ const objMenu = hoistCmp.factory({ onClick: () => model.resetAsync() }), menuDivider(), + menuItem({ + text: switchInput({ + label: 'Auto Save', + bind: 'autoSaveToggle', + inline: true + }), + shouldDismissPopover: false + }), + menuDivider(), menuItem({ icon: Icon.gear(), disabled: isEmpty(model.views), From ac6e3974f59b0fb80d68613de2dfe402aaf76a10 Mon Sep 17 00:00:00 2001 From: Ryanseanlee Date: Wed, 16 Oct 2024 11:43:46 -0700 Subject: [PATCH 18/19] greg CR --- cmp/filter/FilterChooserModel.ts | 2 +- cmp/grid/GridModel.ts | 12 +- cmp/grid/impl/GridPersistenceModel.ts | 12 +- cmp/grouping/GroupingChooserModel.ts | 2 +- cmp/tab/TabContainerModel.ts | 2 +- cmp/zoneGrid/impl/ZoneGridPersistenceModel.ts | 6 +- core/HoistBase.ts | 2 +- core/persist/PersistenceProvider.ts | 3 +- core/persist/ViewManagerProvider.ts | 2 +- core/persist/viewManager/ViewManagerModel.ts | 69 +++++----- .../viewManager/impl/ManageDialogModel.ts | 129 +++++++----------- .../viewManager/impl/SaveDialogModel.ts | 55 +++++--- desktop/cmp/dash/canvas/DashCanvasModel.ts | 2 +- .../cmp/dash/container/DashContainerModel.ts | 2 +- desktop/cmp/panel/PanelModel.ts | 2 +- desktop/cmp/viewManager/ViewManager.ts | 11 +- desktop/cmp/viewManager/cmp/ManageDialog.ts | 45 +++--- desktop/cmp/viewManager/cmp/SaveDialog.ts | 4 +- svc/JsonBlobService.ts | 39 ++---- 19 files changed, 192 insertions(+), 209 deletions(-) diff --git a/cmp/filter/FilterChooserModel.ts b/cmp/filter/FilterChooserModel.ts index f7582112f5..3c38fa4b8a 100644 --- a/cmp/filter/FilterChooserModel.ts +++ b/cmp/filter/FilterChooserModel.ts @@ -199,7 +199,7 @@ export class FilterChooserModel extends HoistModel { run: state => { this.provider.write(state); }, - fireImmediately: this.provider.isWriteStateImmediately + fireImmediately: this.provider.shouldWriteInitialState }); } catch (e) { this.logError(e); diff --git a/cmp/grid/GridModel.ts b/cmp/grid/GridModel.ts index 75d71e772f..be1e83f54a 100644 --- a/cmp/grid/GridModel.ts +++ b/cmp/grid/GridModel.ts @@ -631,12 +631,8 @@ export class GridModel extends HoistModel { * @returns true if defaults were restored */ async restoreDefaultsAsync( - { - skipWarning, - skipClearPersistenceModel - }: {skipWarning?: boolean; skipClearPersistenceModel?: boolean} = { - skipWarning: !!this.restoreDefaultsWarning, - skipClearPersistenceModel: false + {skipWarning}: {skipWarning?: boolean} = { + skipWarning: !!this.restoreDefaultsWarning } ): Promise { if (this.restoreDefaultsWarning && !skipWarning) { @@ -657,7 +653,7 @@ export class GridModel extends HoistModel { this.setGroupBy(groupBy); this.filterModel?.clear(); - if (!skipClearPersistenceModel) this.persistenceModel?.clear(); + this.persistenceModel?.clear(); if (this.autosizeOptions.mode === 'managed') { await this.autosizeAsync(); @@ -1624,7 +1620,7 @@ export class GridModel extends HoistModel { // Remove the width from any non-resizable column - we don't want to track those widths as // they are set programmatically (e.g. fixed / action columns), and saved state should not // conflict with any code-level updates to their widths. - if (!col.resizable) state = omit(state, 'width'); + if (!col.resizable || !col.manuallySized) state = omit(state, 'width'); // TODO // Remove all metadata other than the id and the hidden state from hidden columns, to save // on space when storing user configs with large amounts of hidden fields. diff --git a/cmp/grid/impl/GridPersistenceModel.ts b/cmp/grid/impl/GridPersistenceModel.ts index ebfd2a5f69..08e490a689 100644 --- a/cmp/grid/impl/GridPersistenceModel.ts +++ b/cmp/grid/impl/GridPersistenceModel.ts @@ -56,7 +56,7 @@ export class GridPersistenceModel extends HoistModel { this.addReaction({ track: () => this.state, run: state => this.provider.write(state), - fireImmediately: this.provider.isWriteStateImmediately + fireImmediately: this.provider.shouldWriteInitialState }); } catch (e) { this.logError(e); @@ -82,6 +82,7 @@ export class GridPersistenceModel extends HoistModel { @action clear() { + if (this.provider.shouldWriteInitialState) return; this.state = {version: this.VERSION}; } @@ -97,7 +98,8 @@ export class GridPersistenceModel extends HoistModel { columns: gridModel.cleanColumnState(columnState), autosize: autosizeState }); - } + }, + fireImmediately: this.provider.shouldWriteInitialState }; } @@ -115,7 +117,8 @@ export class GridPersistenceModel extends HoistModel { track: () => this.gridModel.sortBy, run: sortBy => { this.patchState({sortBy: sortBy.map(it => it.toString())}); - } + }, + fireImmediately: this.provider.shouldWriteInitialState }; } @@ -132,7 +135,8 @@ export class GridPersistenceModel extends HoistModel { track: () => this.gridModel.groupBy, run: groupBy => { this.patchState({groupBy}); - } + }, + fireImmediately: this.provider.shouldWriteInitialState }; } diff --git a/cmp/grouping/GroupingChooserModel.ts b/cmp/grouping/GroupingChooserModel.ts index 90be8ca905..3c8cce8d0d 100644 --- a/cmp/grouping/GroupingChooserModel.ts +++ b/cmp/grouping/GroupingChooserModel.ts @@ -161,7 +161,7 @@ export class GroupingChooserModel extends HoistModel { this.addReaction({ track: () => this.persistState, run: state => this.provider.write(state), - fireImmediately: this.provider.isWriteStateImmediately + fireImmediately: this.provider.shouldWriteInitialState }); } catch (e) { this.logError(e); diff --git a/cmp/tab/TabContainerModel.ts b/cmp/tab/TabContainerModel.ts index 96324a3f20..94b98fbc27 100644 --- a/cmp/tab/TabContainerModel.ts +++ b/cmp/tab/TabContainerModel.ts @@ -403,7 +403,7 @@ export class TabContainerModel extends HoistModel { this.addReaction({ track: () => this.activeTabId, run: activeTabId => this.provider.write({activeTabId}), - fireImmediately: this.provider.isWriteStateImmediately + fireImmediately: this.provider.shouldWriteInitialState }); } } diff --git a/cmp/zoneGrid/impl/ZoneGridPersistenceModel.ts b/cmp/zoneGrid/impl/ZoneGridPersistenceModel.ts index bbecdb8500..e662dac9c2 100644 --- a/cmp/zoneGrid/impl/ZoneGridPersistenceModel.ts +++ b/cmp/zoneGrid/impl/ZoneGridPersistenceModel.ts @@ -92,7 +92,7 @@ export class ZoneGridPersistenceModel extends HoistModel { run: mappings => { this.patchState({mappings}); }, - fireImmediately: this.provider.isWriteStateImmediately + fireImmediately: this.provider.shouldWriteInitialState }; } @@ -110,7 +110,7 @@ export class ZoneGridPersistenceModel extends HoistModel { run: sortBy => { this.patchState({sortBy: sortBy?.toString()}); }, - fireImmediately: this.provider.isWriteStateImmediately + fireImmediately: this.provider.shouldWriteInitialState }; } @@ -128,7 +128,7 @@ export class ZoneGridPersistenceModel extends HoistModel { run: groupBy => { this.patchState({groupBy}); }, - fireImmediately: this.provider.isWriteStateImmediately + fireImmediately: this.provider.shouldWriteInitialState }; } diff --git a/core/HoistBase.ts b/core/HoistBase.ts index 4f36e3ea40..3d72f72faf 100644 --- a/core/HoistBase.ts +++ b/core/HoistBase.ts @@ -270,7 +270,7 @@ export abstract class HoistBase { this.addReaction({ track: () => this[property], run: data => provider.write(data), - fireImmediately: provider.isWriteStateImmediately + fireImmediately: provider.shouldWriteInitialState }); } catch (e) { this.logError( diff --git a/core/persist/PersistenceProvider.ts b/core/persist/PersistenceProvider.ts index f9838bf34e..62b1dce8ae 100644 --- a/core/persist/PersistenceProvider.ts +++ b/core/persist/PersistenceProvider.ts @@ -46,7 +46,8 @@ export class PersistenceProvider { path: string; debounce: DebounceSpec; - get isWriteStateImmediately(): boolean { + // TODO - to discuss + get shouldWriteInitialState(): boolean { return false; } diff --git a/core/persist/ViewManagerProvider.ts b/core/persist/ViewManagerProvider.ts index 473fc7eba4..b9975ce043 100644 --- a/core/persist/ViewManagerProvider.ts +++ b/core/persist/ViewManagerProvider.ts @@ -16,7 +16,7 @@ import {ViewManagerModel} from './viewManager'; export class ViewManagerProvider extends PersistenceProvider { viewManagerModel: ViewManagerModel; - override get isWriteStateImmediately(): boolean { + override get shouldWriteInitialState(): boolean { return true; } diff --git a/core/persist/viewManager/ViewManagerModel.ts b/core/persist/viewManager/ViewManagerModel.ts index 766f842a12..20e3c9506c 100644 --- a/core/persist/viewManager/ViewManagerModel.ts +++ b/core/persist/viewManager/ViewManagerModel.ts @@ -10,9 +10,8 @@ import { } from '@xh/hoist/core'; import {action, bindable, computed, makeObservable, observable} from '@xh/hoist/mobx'; import {executeIfFunction, pluralize} from '@xh/hoist/utils/js'; -import {capitalize, clone, find, isEqualWith, isNil, isString, sortBy, startCase} from 'lodash'; +import {capitalize, clone, find, isEqual, isNil, isString, sortBy, startCase} from 'lodash'; import {runInAction} from 'mobx'; -import {ManageDialogModel} from './impl/ManageDialogModel'; import {SaveDialogModel} from './impl/SaveDialogModel'; import {View, ViewTree} from './Types'; @@ -57,30 +56,25 @@ export class ViewManagerModel extends Hoist return ret; } - private readonly _canManageGlobal: Thunkable; - - // Internal persistence provider, used to save *this* model's state, i.e. selectedId - private readonly _provider; + @managed readonly saveDialogModel: SaveDialogModel; readonly enableDefault: boolean; readonly enableAutoSave: boolean; - readonly entity: Entity; - @managed readonly manageDialogModel: ManageDialogModel; - - @managed readonly saveDialogModel: SaveDialogModel; - /** Current state of the active object, can include not-yet-persisted changes. */ @observable.ref pendingValue: T = null; - @observable.ref views: View[] = []; - @observable.ref value: T = null; + @observable isManageDialogVisible = false; @bindable selectedToken: string = null; @bindable favorites: string[] = []; - @bindable autoSaveToggle: boolean = false; + @bindable autoSaveToggle = false; + + // Internal persistence provider, used to save *this* model's state, i.e. selectedId + private readonly _provider; + private readonly _canManageGlobal: Thunkable; get canManageGlobal(): boolean { return executeIfFunction(this._canManageGlobal); @@ -111,7 +105,7 @@ export class ViewManagerModel extends Hoist @computed get isDirty(): boolean { - return !this.isEqualSkipAutosize(this.pendingValue, this.value); + return !isEqual(this.pendingValue, this.value); } get isShared(): boolean { @@ -156,8 +150,7 @@ export class ViewManagerModel extends Hoist this._canManageGlobal = canManageGlobal; this.enableDefault = enableDefault; this.enableAutoSave = enableAutoSave; - this.saveDialogModel = new SaveDialogModel(this, this.entity.name); - this.manageDialogModel = new ManageDialogModel(this); + this.saveDialogModel = new SaveDialogModel(this.entity.name); // Set up internal PersistenceProvider -- fail gently if (persistWith) { @@ -249,11 +242,20 @@ export class ViewManagerModel extends Hoist async saveAsAsync() { const {name, description} = this.selectedView ?? {}; - this.saveDialogModel.open({ - name, - description, - value: this.pendingValue - }); + const newView = await this.saveDialogModel.openAsync( + { + name, + description, + value: this.pendingValue + }, + this.views.map(it => it.name) + ); + + if (newView) { + await this.refreshAsync(); + await this.selectAsync(newView.token); + XH.successToast(`${capitalize(this.entity.displayName)} successfully saved.`); + } } async resetAsync() { @@ -285,12 +287,12 @@ export class ViewManagerModel extends Hoist @action openManageDialog() { - this.manageDialogModel.openAsync(); + this.isManageDialogVisible = true; } @action closeManageDialog() { - this.manageDialogModel.close(); + this.isManageDialogVisible = false; } getHierarchyDisplayName(name) { @@ -323,15 +325,15 @@ export class ViewManagerModel extends Hoist } @action - private setPendingValue(value: T) { - if (isNil(value)) { + private setPendingValue(pendingValue: T) { + if (isNil(pendingValue)) { this.pendingValue = null; return; } - value = this.cleanValue(value); - if (!this.isEqualSkipAutosize(this.pendingValue, value)) { - this.pendingValue = value; + pendingValue = this.cleanValue(pendingValue); + if (!isEqual(this.pendingValue, pendingValue)) { + this.pendingValue = pendingValue; } } @@ -341,15 +343,6 @@ export class ViewManagerModel extends Hoist return JSON.parse(JSON.stringify(value)); } - private isEqualSkipAutosize(a, b) { - // Skip spurious column autosize differences between states - const comparer = (aVal, bVal, key, aObj) => { - if (key === 'width' && !isNil(aObj.colId) && !aObj.manuallySized) return true; - return undefined; - }; - return isEqualWith(a, b, comparer); - } - private async confirmShareObjSaveAsync() { return XH.confirm({ message: `You are saving a shared public ${this.entity.displayName}. Do you wish to continue?`, diff --git a/core/persist/viewManager/impl/ManageDialogModel.ts b/core/persist/viewManager/impl/ManageDialogModel.ts index 6b8ac3a9ad..6976519cec 100644 --- a/core/persist/viewManager/impl/ManageDialogModel.ts +++ b/core/persist/viewManager/impl/ManageDialogModel.ts @@ -1,21 +1,22 @@ import {FormModel} from '@xh/hoist/cmp/form'; import {GridAutosizeMode, GridModel} from '@xh/hoist/cmp/grid'; -import {HoistModel, managed, XH} from '@xh/hoist/core'; +import {HoistModel, lookup, managed, TaskObserver, XH} from '@xh/hoist/core'; import {lengthIs, required} from '@xh/hoist/data'; import {Icon} from '@xh/hoist/icon'; -import {bindable, makeObservable} from '@xh/hoist/mobx'; -import {includes, isEmpty} from 'lodash'; +import {makeObservable} from '@xh/hoist/mobx'; +import {includes} from 'lodash'; import {ViewManagerModel} from '../ViewManagerModel'; export class ManageDialogModel extends HoistModel { - parentModel: ViewManagerModel; - - @bindable isOpen: boolean = false; - @managed readonly gridModel: GridModel; - @managed readonly formModel: FormModel; + readonly saveTask = TaskObserver.trackLast(); + readonly deleteTask = TaskObserver.trackLast(); + + @lookup(() => ViewManagerModel) + private readonly viewManagerModel: ViewManagerModel; + get selectedId(): string { return this.gridModel.selectedId as string; } @@ -24,9 +25,13 @@ export class ManageDialogModel extends HoistModel { return this.gridModel.selectedRecord?.data.isShared ?? false; } + get displayName(): string { + return this.viewManagerModel.entity.displayName; + } + get canDelete(): boolean { - const {parentModel, selIsShared, canManageGlobal} = this, - {views, enableDefault} = parentModel; + const {viewManagerModel, selIsShared, canManageGlobal} = this, + {views, enableDefault} = viewManagerModel; return (enableDefault ? true : views.length > 1) && (canManageGlobal || !selIsShared); } @@ -35,24 +40,19 @@ export class ManageDialogModel extends HoistModel { } get showSaveButton(): boolean { - const {formModel, parentModel} = this; - return formModel.isDirty && !formModel.readonly && !parentModel.loadModel.isPending; - } - - /** True if the selected object would end up shared to all users if saved. */ - get willBeGlobal(): boolean { - return this.formModel.values.isGlobal; + const {formModel, viewManagerModel} = this; + return formModel.isDirty && !formModel.readonly && !viewManagerModel.loadModel.isPending; } get canManageGlobal(): boolean { - return this.parentModel.canManageGlobal; + return this.viewManagerModel.canManageGlobal; } constructor(parentModel: ViewManagerModel) { super(); makeObservable(this); - this.parentModel = parentModel; + this.viewManagerModel = parentModel; this.gridModel = this.createGridModel(); this.formModel = this.createFormModel(); @@ -63,30 +63,24 @@ export class ManageDialogModel extends HoistModel { this.formModel.readonly = !this.canEdit; this.formModel.init({ ...record.data, - isFavorite: includes(this.parentModel.favorites, record.data.token) + isFavorite: includes(this.viewManagerModel.favorites, record.data.token) }); } } }); } - async openAsync() { - this.isOpen = true; - await this.refreshModelsAsync(); - } - - close() { - this.gridModel.clear(); - this.formModel.init(); - this.isOpen = false; + override async doLoadAsync() { + this.gridModel.loadData(this.viewManagerModel.views); + await this.ensureGridHasSelection(); } async saveAsync() { - return this.doSaveAsync().linkTo(this.loadModel).catchDefault(); + return this.doSaveAsync().linkTo(this.saveTask).catchDefault(); } async deleteAsync() { - return this.doDeleteAsync().linkTo(this.loadModel).catchDefault(); + return this.doDeleteAsync().linkTo(this.deleteTask).catchDefault(); } //------------------------ @@ -94,11 +88,11 @@ export class ManageDialogModel extends HoistModel { //------------------------ async doSaveAsync() { - const {formModel, parentModel, canManageGlobal, selectedId, gridModel} = this, + const {formModel, viewManagerModel, canManageGlobal, selectedId, gridModel} = this, {fields, isDirty} = formModel, {name, description, isShared, isFavorite} = formModel.getData(), isValid = await formModel.validateAsync(), - displayName = parentModel.entity.displayName, + displayName = viewManagerModel.entity.displayName, token = gridModel.selectedRecord.data.token; if (!isValid || !selectedId || !isDirty) return; @@ -106,7 +100,7 @@ export class ManageDialogModel extends HoistModel { // Additional sanity-check before POSTing an update - non-admins should never be modifying global views. if (isShared && !canManageGlobal) throw XH.exception( - `Cannot save changes to globally-shared ${parentModel.entity.displayName} - missing required permission.` + `Cannot save changes to globally-shared ${viewManagerModel.entity.displayName} - missing required permission.` ); if (fields.isShared.isDirty) { @@ -121,9 +115,9 @@ export class ManageDialogModel extends HoistModel { if (fields.isFavorite.isDirty) { if (isFavorite) { - parentModel.addFavorite(token); + viewManagerModel.addFavorite(token); } else { - parentModel.removeFavorite(token); + viewManagerModel.removeFavorite(token); } } @@ -133,58 +127,37 @@ export class ManageDialogModel extends HoistModel { acl: isShared ? '*' : null }); - await this.parentModel.refreshAsync(); - await this.refreshModelsAsync(); + await this.viewManagerModel.refreshAsync(); + await this.refreshAsync(); } async doDeleteAsync() { - const {parentModel, gridModel, formModel} = this, - {selectedRecord} = gridModel, - {isFavorite} = formModel.getData(), - {favorites} = parentModel; + const {viewManagerModel, gridModel} = this, + {selectedRecord} = gridModel; if (!selectedRecord) return; - const {name, token} = selectedRecord.data; - const confirmed = await XH.confirm({ - title: 'Delete', - icon: Icon.delete(), - message: `Are you sure you want to delete "${name}"?` - }); + const {name, token} = selectedRecord.data, + confirmed = await XH.confirm({ + title: 'Delete', + icon: Icon.delete(), + message: `Are you sure you want to delete "${name}"?` + }); if (!confirmed) return; - if (formModel.fields.isFavorite.isDirty) { - if (isFavorite) { - parentModel.favorites = [...favorites, token]; - } else { - parentModel.favorites = favorites.filter(it => it !== token); - } - } + viewManagerModel.removeFavorite(token); await XH.jsonBlobService.archiveAsync(token); - await parentModel.refreshAsync(); - await this.refreshModelsAsync(); + await viewManagerModel.refreshAsync(); + await this.refreshAsync(); } - async refreshModelsAsync() { - const {views, favorites} = this.parentModel, - {gridModel, formModel} = this; - - if (isEmpty(views)) { - this.close(); - return; - } - - gridModel.loadData(views); - await this.ensureGridHasSelection(); - formModel.init({ - ...gridModel.selectedRecord.data, - isFavorite: includes(favorites, gridModel.selectedRecord.data.token) - }); - } + //------------------------- + // Implementation + //------------------------- - async ensureGridHasSelection() { - const {gridModel} = this; - const {selectedToken} = this.parentModel; + private async ensureGridHasSelection() { + const {gridModel, viewManagerModel} = this, + {selectedToken} = viewManagerModel; if (selectedToken) { gridModel.selModel.select(selectedToken); } else { @@ -192,7 +165,7 @@ export class ManageDialogModel extends HoistModel { } } - createGridModel(): GridModel { + private createGridModel(): GridModel { return new GridModel({ sortBy: 'name', groupBy: 'group', @@ -233,7 +206,7 @@ export class ManageDialogModel extends HoistModel { }); } - createFormModel(): FormModel { + private createFormModel(): FormModel { return new FormModel({ fields: [ {name: 'name', rules: [required, lengthIs({max: 255})]}, diff --git a/core/persist/viewManager/impl/SaveDialogModel.ts b/core/persist/viewManager/impl/SaveDialogModel.ts index 0680fb7f02..b4ea4ff00b 100644 --- a/core/persist/viewManager/impl/SaveDialogModel.ts +++ b/core/persist/viewManager/impl/SaveDialogModel.ts @@ -1,31 +1,34 @@ import {FormModel} from '@xh/hoist/cmp/form'; import {HoistModel, managed, TaskObserver, XH} from '@xh/hoist/core'; import {lengthIs, required} from '@xh/hoist/data'; -import {bindable, makeObservable} from '@xh/hoist/mobx'; -import {ViewManagerModel} from '../ViewManagerModel'; +import {makeObservable} from '@xh/hoist/mobx'; +import {JsonBlob} from '@xh/hoist/svc'; +import {action, observable} from 'mobx'; import {View} from '../Types'; export class SaveDialogModel extends HoistModel { - readonly saveTask = TaskObserver.trackLast(); - private readonly type: string; + @managed readonly formModel: FormModel; - parentModel: ViewManagerModel; + readonly saveTask = TaskObserver.trackLast(); - @bindable viewStub: Partial; - @bindable isOpen: boolean = false; + @observable viewStub: Partial; + @observable isOpen: boolean = false; - @managed readonly formModel = this.createFormModel(); + private resolveOpen: (value: JsonBlob) => void; + private readonly type: string; + private invalidNames: string[] = []; - constructor(parentModel: ViewManagerModel, type: string) { + constructor(type: string) { super(); makeObservable(this); - - this.parentModel = parentModel; this.type = type; + this.formModel = this.createFormModel(); } - open(viewStub: Partial) { + @action + openAsync(viewStub: Partial, invalidNames: string[]): Promise { this.viewStub = viewStub; + this.invalidNames = invalidNames; this.formModel.init({ name: viewStub.name ? `${viewStub.name} (COPY)` : '', @@ -33,11 +36,15 @@ export class SaveDialogModel extends HoistModel { }); this.isOpen = true; + + return new Promise(resolve => { + this.resolveOpen = resolve; + }); } - close() { - this.isOpen = false; - this.formModel.init(); + cancel() { + this.close(); + this.resolveOpen(null); } async saveAsAsync() { @@ -48,7 +55,7 @@ export class SaveDialogModel extends HoistModel { // Implementation //------------------------ - createFormModel(): FormModel { + private createFormModel(): FormModel { return new FormModel({ fields: [ { @@ -57,7 +64,7 @@ export class SaveDialogModel extends HoistModel { required, lengthIs({max: 255}), ({value}) => { - if (this.parentModel?.views.find(it => it.name === value)) { + if (this.invalidNames.includes(value)) { return `An entry with name "${value}" already exists`; } } @@ -68,8 +75,8 @@ export class SaveDialogModel extends HoistModel { }); } - async doSaveAsAsync() { - const {formModel, parentModel, viewStub, type} = this, + private async doSaveAsAsync() { + const {formModel, viewStub, type} = this, {name, description} = formModel.getData(), isValid = await formModel.validateAsync(); @@ -83,11 +90,15 @@ export class SaveDialogModel extends HoistModel { value: viewStub.value }); this.close(); - - await parentModel.refreshAsync(); - await parentModel.selectAsync(newObj.token); + this.resolveOpen(newObj); } catch (e) { XH.handleException(e); } } + + @action + private close() { + this.isOpen = false; + this.formModel.init(); + } } diff --git a/desktop/cmp/dash/canvas/DashCanvasModel.ts b/desktop/cmp/dash/canvas/DashCanvasModel.ts index a6bb76c84b..f6e010c55b 100644 --- a/desktop/cmp/dash/canvas/DashCanvasModel.ts +++ b/desktop/cmp/dash/canvas/DashCanvasModel.ts @@ -202,7 +202,7 @@ export class DashCanvasModel extends DashModel< { track: () => this.viewState, run: () => this.publishState(), - fireImmediately: this.provider.isWriteStateImmediately + fireImmediately: this.provider.shouldWriteInitialState }, { when: () => !!this.ref.current, diff --git a/desktop/cmp/dash/container/DashContainerModel.ts b/desktop/cmp/dash/container/DashContainerModel.ts index 4a064abcb6..ea778a6f50 100644 --- a/desktop/cmp/dash/container/DashContainerModel.ts +++ b/desktop/cmp/dash/container/DashContainerModel.ts @@ -212,7 +212,7 @@ export class DashContainerModel extends DashModel< this.addReaction({ track: () => this.viewState, run: () => this.updateState(), - fireImmediately: this.provider.isWriteStateImmediately + fireImmediately: this.provider.shouldWriteInitialState }); } diff --git a/desktop/cmp/panel/PanelModel.ts b/desktop/cmp/panel/PanelModel.ts index 6c0d753237..3ab0bcd746 100644 --- a/desktop/cmp/panel/PanelModel.ts +++ b/desktop/cmp/panel/PanelModel.ts @@ -300,7 +300,7 @@ export class PanelModel extends HoistModel { return state; }, run: state => this.provider.write(state), - fireImmediately: this.provider.isWriteStateImmediately + fireImmediately: this.provider.shouldWriteInitialState }); } } diff --git a/desktop/cmp/viewManager/ViewManager.ts b/desktop/cmp/viewManager/ViewManager.ts index 96eded807e..6acebdb67f 100644 --- a/desktop/cmp/viewManager/ViewManager.ts +++ b/desktop/cmp/viewManager/ViewManager.ts @@ -15,7 +15,14 @@ export const [ViewManager, viewManager] = hoistCmp.withFactory className: 'xh-view-manager', model: uses(ViewManagerModel), - render({viewMenuProps}) { - return fragment(viewMenu(viewMenuProps), manageDialog(), saveDialog()); + render({model, viewMenuProps}) { + return fragment( + viewMenu(viewMenuProps), + manageDialog({ + omit: !model.isManageDialogVisible, + onClose: () => model.closeManageDialog() + }), + saveDialog() + ); } }); diff --git a/desktop/cmp/viewManager/cmp/ManageDialog.ts b/desktop/cmp/viewManager/cmp/ManageDialog.ts index b8157c34f6..413d0d728b 100644 --- a/desktop/cmp/viewManager/cmp/ManageDialog.ts +++ b/desktop/cmp/viewManager/cmp/ManageDialog.ts @@ -1,12 +1,11 @@ import {form} from '@xh/hoist/cmp/form'; import {grid} from '@xh/hoist/cmp/grid'; import {br, div, filler, fragment, hframe, placeholder, spacer, vframe} from '@xh/hoist/cmp/layout'; -import {hoistCmp, uses, XH} from '@xh/hoist/core'; +import {creates, hoistCmp, HoistProps, XH} from '@xh/hoist/core'; import {ManageDialogModel} from '@xh/hoist/core/persist/viewManager/impl/ManageDialogModel'; import {button} from '@xh/hoist/desktop/cmp/button'; import {formField} from '@xh/hoist/desktop/cmp/form'; import {switchInput, textArea, textInput} from '@xh/hoist/desktop/cmp/input'; -import {mask} from '@xh/hoist/desktop/cmp/mask'; import {panel} from '@xh/hoist/desktop/cmp/panel'; import {toolbar} from '@xh/hoist/desktop/cmp/toolbar'; import {fmtCompactDate} from '@xh/hoist/format'; @@ -15,21 +14,29 @@ import {dialog} from '@xh/hoist/kit/blueprint'; import {pluralize} from '@xh/hoist/utils/js'; import {capitalize} from 'lodash'; -export const manageDialog = hoistCmp.factory({ +export interface ManageDialogProps extends HoistProps { + onClose: () => void; +} + +export const manageDialog = hoistCmp.factory({ displayName: 'ManageDialog', className: 'xh-persistence-manager__manage-dialog', - model: uses(ManageDialogModel), + model: creates(ManageDialogModel), - render({model}) { + render({model, onClose}) { + const {displayName, saveTask, deleteTask} = model; return dialog({ - isOpen: model.isOpen, + isOpen: true, icon: Icon.gear(), - title: `Manage ${capitalize(pluralize(model.parentModel.entity.displayName))}`, + title: `Manage ${capitalize(pluralize(displayName))}`, className: 'xh-persistence-manager__manage-dialog', style: {width: 800, height: 475, maxWidth: '90vm'}, canOutsideClickClose: false, - onClose: () => model.close(), - item: hframe(gridPanel(), formPanel(), mask({bind: model.loadModel, spinner: true})) + onClose, + item: panel({ + mask: [saveTask, deleteTask], + item: hframe(gridPanel(), formPanel({onClose})) + }) }); } }); @@ -44,19 +51,19 @@ const gridPanel = hoistCmp.factory({ } }); -const formPanel = hoistCmp.factory({ - render({model}) { - const {selectedId, parentModel, formModel, canEdit} = model, +const formPanel = hoistCmp.factory({ + render({model, onClose}) { + const {selectedId, displayName, formModel, canEdit} = model, {values} = formModel; if (!selectedId) return panel({ - item: placeholder(`Select a ${parentModel.entity.displayName}`), + item: placeholder(`Select a ${displayName}`), bbar: [ filler(), button({ text: 'Close', - onClick: () => model.close() + onClick: onClose }) ] }); @@ -79,7 +86,7 @@ const formPanel = hoistCmp.factory({ info: canEdit ? fragment( Icon.info(), - `Organize your ${pluralize(parentModel.entity.displayName)} into folders by including the "\\" character in their names - e.g. "My folder\\My ${parentModel.entity.displayName}".` + `Organize your ${pluralize(displayName)} into folders by including the "\\" character in their names - e.g. "My folder\\My ${displayName}".` ) : null }), @@ -136,13 +143,13 @@ const formPanel = hoistCmp.factory({ }) ] }), - bbar: bbar() + bbar: bbar({onClose}) }); } }); -const bbar = hoistCmp.factory({ - render({model}) { +const bbar = hoistCmp.factory({ + render({model, onClose}) { const {formModel} = model; return toolbar( button({ @@ -162,7 +169,7 @@ const bbar = hoistCmp.factory({ '-', button({ text: 'Close', - onClick: () => model.close() + onClick: onClose }) ); } diff --git a/desktop/cmp/viewManager/cmp/SaveDialog.ts b/desktop/cmp/viewManager/cmp/SaveDialog.ts index 1ff4afc5b5..66a84ddee9 100644 --- a/desktop/cmp/viewManager/cmp/SaveDialog.ts +++ b/desktop/cmp/viewManager/cmp/SaveDialog.ts @@ -25,7 +25,7 @@ export const saveDialog = hoistCmp.factory({ className: 'xh-persistence-manager__save-dialog', style: {width: 500, height: 255}, canOutsideClickClose: false, - onClose: () => model.close(), + onClose: () => model.cancel(), item: fragment(formPanel(), mask({bind: model.saveTask, spinner: true})) }); } @@ -77,7 +77,7 @@ const bbar = hoistCmp.factory({ filler(), button({ text: 'Cancel', - onClick: () => model.close() + onClick: () => model.cancel() }), button({ icon: Icon.copy(), diff --git a/svc/JsonBlobService.ts b/svc/JsonBlobService.ts index e8822603b7..e0e5a18b44 100644 --- a/svc/JsonBlobService.ts +++ b/svc/JsonBlobService.ts @@ -4,7 +4,17 @@ * * Copyright © 2024 Extremely Heavy Industries Inc. */ -import {XH, HoistService, PlainObject, LoadSpec} from '@xh/hoist/core'; +import {XH, HoistService, LoadSpec} from '@xh/hoist/core'; + +export interface JsonBlob { + type: string; + name: string; + token: string; + acl?: string; + description?: string; + value?: any; + meta?: any; +} /** * Service to read and set chunks of user-specific JSON persisted via Hoist Core's JSONBlob class. @@ -12,7 +22,7 @@ import {XH, HoistService, PlainObject, LoadSpec} from '@xh/hoist/core'; export class JsonBlobService extends HoistService { static instance: JsonBlobService; - async getAsync(token) { + async getAsync(token: string): Promise { return XH.fetchJson({ url: 'xh/getJsonBlob', params: {token} @@ -44,14 +54,7 @@ export class JsonBlobService extends HoistService { value, meta, description - }: { - type: string; - name: string; - acl?: string; - description?: string; - value: any; - meta?: any; - }) { + }: Omit): Promise { return XH.fetchJson({ url: 'xh/createJsonBlob', params: { @@ -63,20 +66,8 @@ export class JsonBlobService extends HoistService { /** Modify an existing JSONBlob, as identified by its unique token. */ async updateAsync( token: string, - { - name, - acl, - value, - meta, - description - }: { - name?: string; - acl?: string | PlainObject; - value?: any; - meta?: any; - description?: string; - } - ) { + {name, acl, value, meta, description}: Omit + ): Promise { return XH.fetchJson({ url: 'xh/updateJsonBlob', params: { From c5536e26cf08b9438ee8c03739fbf8b5c8601351 Mon Sep 17 00:00:00 2001 From: Ryanseanlee Date: Wed, 16 Oct 2024 12:02:39 -0700 Subject: [PATCH 19/19] greg CR --- data/filter/Filter.ts | 3 +++ data/filter/FunctionFilter.ts | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/data/filter/Filter.ts b/data/filter/Filter.ts index a4f55f21bb..cd122c28c1 100644 --- a/data/filter/Filter.ts +++ b/data/filter/Filter.ts @@ -5,6 +5,7 @@ * Copyright © 2024 Extremely Heavy Industries Inc. */ +import {PlainObject} from '@xh/hoist/core'; import {Store} from '../Store'; import {FilterTestFn} from './Types'; @@ -33,4 +34,6 @@ export abstract class Filter { /** @returns true if the provided other Filter is equivalent to this instance.*/ abstract equals(other: Filter): boolean; + + abstract toJSON(): PlainObject; } diff --git a/data/filter/FunctionFilter.ts b/data/filter/FunctionFilter.ts index 0b8f21baaa..e829bd7ae7 100644 --- a/data/filter/FunctionFilter.ts +++ b/data/filter/FunctionFilter.ts @@ -5,6 +5,7 @@ * Copyright © 2024 Extremely Heavy Industries Inc. */ +import {PlainObject} from '@xh/hoist/core'; import {throwIf} from '@xh/hoist/utils/js'; import {isFunction} from 'lodash'; import {Store} from '../Store'; @@ -51,4 +52,9 @@ export class FunctionFilter extends Filter { if (other === this) return true; return other instanceof FunctionFilter && this.testFn === other.testFn; } + + /** @returns Undefined because to JSON on a FunctionFilter is not supported */ + override toJSON(): PlainObject { + return undefined; + } }