From 376d1e7a94973e6b40030b18a1c145be0a3a3b96 Mon Sep 17 00:00:00 2001 From: lbwexler Date: Thu, 19 Dec 2024 11:30:12 -0500 Subject: [PATCH 1/4] checkpoint --- cmp/viewmanager/ViewManagerModel.ts | 153 ++++++++-------------------- cmp/viewmanager/ViewToBlobApi.ts | 45 +++++++- svc/JsonBlobService.ts | 26 ++++- 3 files changed, 106 insertions(+), 118 deletions(-) diff --git a/cmp/viewmanager/ViewManagerModel.ts b/cmp/viewmanager/ViewManagerModel.ts index a38a2090d..b3727435a 100644 --- a/cmp/viewmanager/ViewManagerModel.ts +++ b/cmp/viewmanager/ViewManagerModel.ts @@ -10,9 +10,6 @@ import { ExceptionHandlerOptions, HoistModel, LoadSpec, - PersistableState, - PersistenceProvider, - PersistOptions, PlainObject, TaskObserver, Thunkable, @@ -21,10 +18,10 @@ import { import type {ViewManagerProvider} from '@xh/hoist/core'; import {genDisplayName} from '@xh/hoist/data'; import {fmtDateTime} from '@xh/hoist/format'; -import {action, bindable, makeObservable, observable, when} from '@xh/hoist/mobx'; +import {action, bindable, makeObservable, observable} from '@xh/hoist/mobx'; import {olderThan, SECONDS} from '@xh/hoist/utils/datetime'; import {executeIfFunction, pluralize, throwIf} from '@xh/hoist/utils/js'; -import {find, isEmpty, isEqual, isNil, isObject, lowerCase, pickBy} from 'lodash'; +import {find, isEqual, isNil, lowerCase} from 'lodash'; import {runInAction} from 'mobx'; import {ReactNode} from 'react'; import {ViewInfo} from './ViewInfo'; @@ -79,9 +76,6 @@ export interface ViewManagerConfig { */ manageGlobal?: Thunkable; - /** Used to persist the user's state. */ - persistWith?: ViewManagerPersistOptions; - /** * Required discriminator for the particular class of views to be loaded and managed by this * model. Set to something descriptive and specific enough to be identifiable and allow for @@ -90,6 +84,13 @@ export interface ViewManagerConfig { */ type: string; + /** + * Optional sub-discriminator for the particular location in your app this instance of the view + * manager appears. A particular currentView and pendingValue will be maintained by instance, + * but all other options, and library state will be shared by type. + */ + instance?: string; + /** * Optional user-facing display name for the view type, displayed in the ViewManager menu * and associated management dialogs and prompts. Defaulted from `type` if not provided. @@ -102,14 +103,6 @@ export interface ViewManagerConfig { globalDisplayName?: string; } -export interface ViewManagerPersistOptions extends PersistOptions { - /** True to persist pinning preferences or provide specific PersistOptions. (Default true) */ - persistPinning?: boolean | PersistOptions; - - /** True to include pending value or provide specific PersistOptions. (Default false) */ - persistPendingValue?: boolean | PersistOptions; -} - /** * ViewManagerModel coordinates the loading, saving, and management of user-defined bundles of * {@link Persistable} component/model state. @@ -146,8 +139,8 @@ export class ViewManagerModel extends HoistModel { } /** Immutable configuration for this model. */ - declare persistWith: ViewManagerPersistOptions; readonly type: string; + readonly instance: string; readonly typeDisplayName: string; readonly globalDisplayName: string; readonly enableAutoSave: boolean; @@ -175,7 +168,7 @@ export class ViewManagerModel extends HoistModel { * True if user has opted-in to automatically saving changes to personal views (if auto-save * generally available as per `enableAutoSave`). */ - @bindable autoSave = true; + @bindable autoSave = false; /** * TaskObserver linked to {@link selectViewAsync}. If a change to the active view is likely to @@ -265,7 +258,7 @@ export class ViewManagerModel extends HoistModel { */ private constructor({ type, - persistWith, + instance = 'default', typeDisplayName, globalDisplayName = 'global', manageGlobal = false, @@ -285,9 +278,9 @@ export class ViewManagerModel extends HoistModel { ); this.type = type; + this.instance = instance; this.typeDisplayName = lowerCase(typeDisplayName ?? genDisplayName(type)); this.globalDisplayName = globalDisplayName; - this.persistWith = persistWith; this.manageGlobal = executeIfFunction(manageGlobal) ?? false; this.enableDefault = enableDefault; this.enableGlobal = enableGlobal; @@ -471,31 +464,45 @@ export class ViewManagerModel extends HoistModel { // Implementation //------------------ private async initAsync() { + let {api, pendingValue} = this, + initialToken; + try { - const views = await this.api.fetchViewInfosAsync(); - runInAction(() => (this.views = views)); + // Initialize views and related state + const views = await api.fetchViewInfosAsync(), + state = await api.getStateAsync(); + runInAction(() => { + this.views = views; + this.userPinned = state.userPinned; + this.autoSave = state.autoSave; + }); - if (this.persistWith) { - this.initPersist(this.persistWith); - await when(() => !this.selectTask.isPending); - } + // Initialize/choose current view. Null is ok, and will yield default. + initialToken = state.currentView; + const initialView = + (initialToken ? find(this.views, {token: initialToken}) : null) ?? + this.initialViewSpec?.(views); - // If the initial view not initialized from persistence, assign it. - if (!this.view) { - await this.loadViewAsync(this.initialViewSpec?.(views), this.pendingValue); - } + await this.loadViewAsync(initialView, pendingValue); } catch (e) { - // Always ensure at least default view is installed. - if (!this.view) this.loadViewAsync(null, this.pendingValue); - + // Always ensure at least default view is installed (other state defaults are fine) + this.loadViewAsync(null, pendingValue); this.handleException(e, {showAlert: false, logOnServer: true}); } + // Add reactions this.addReaction({ track: () => [this.pendingValue, this.autoSave], run: () => this.maybeAutoSaveAsync(), debounce: 5 * SECONDS }); + + this.addReaction({ + track: () => [this.view, this.userPinned, this.autoSave], + run: () => api.updateStateAsync(), + debounce: 5 * SECONDS, + fireImmediately: this.view.token != initialToken + }); } private async loadViewAsync( @@ -613,86 +620,6 @@ export class ViewManagerModel extends HoistModel { } }); } - - //------------------ - // Persistence - //------------------ - private initPersist(options: ViewManagerPersistOptions) { - const { - persistPinning = true, - persistPendingValue = false, - path = 'viewManager', - ...rootPersistWith - } = options; - - // Pinning potentially in dedicated location - if (persistPinning) { - const opts = isObject(persistPinning) ? persistPinning : rootPersistWith; - PersistenceProvider.create({ - persistOptions: {path: `${path}.pinning`, ...opts}, - target: { - getPersistableState: () => new PersistableState(this.userPinned), - setPersistableState: ({value}) => { - const {views} = this; - this.userPinned = !isEmpty(views) // Clean state iff views loaded! - ? pickBy(value, (_, tkn) => views.some(v => v.token === tkn)) - : value; - } - }, - owner: this - }); - } - - // AutoSave, potentially in core location. - if (this.enableAutoSave) { - PersistenceProvider.create({ - persistOptions: {path: `${path}.autoSave`, ...rootPersistWith}, - target: { - getPersistableState: () => new PersistableState(this.autoSave), - setPersistableState: ({value}) => (this.autoSave = value) - }, - owner: this - }); - } - - // Pending Value, potentially in dedicated location - // On hydration, stash away for one time use when hydrating view itself below - if (persistPendingValue) { - const opts = isObject(persistPendingValue) ? persistPendingValue : rootPersistWith; - PersistenceProvider.create({ - persistOptions: {path: `${path}.pendingValue`, ...opts}, - target: { - getPersistableState: () => new PersistableState(this.pendingValue), - setPersistableState: ({value}) => { - // Only accept this during initialization! - if (!this.view) this.pendingValue = value; - } - }, - owner: this - }); - } - - // View, in core location - PersistenceProvider.create({ - persistOptions: {path: `${path}.view`, ...rootPersistWith}, - target: { - // View could be null, just before initialization. - getPersistableState: () => new PersistableState(this.view?.token), - setPersistableState: async ({value: token}) => { - // Requesting available view -- load it with any init pending val. - const viewInfo = token ? find(this.views, {token}) : null; - if (viewInfo || !token) { - try { - await this.loadViewAsync(viewInfo, this.pendingValue); - } catch (e) { - this.logError('Failure loading persisted view', e); - } - } - } - }, - owner: this - }); - } } interface PendingValue { diff --git a/cmp/viewmanager/ViewToBlobApi.ts b/cmp/viewmanager/ViewToBlobApi.ts index 463d73740..e7428ee0e 100644 --- a/cmp/viewmanager/ViewToBlobApi.ts +++ b/cmp/viewmanager/ViewToBlobApi.ts @@ -7,7 +7,7 @@ import {PlainObject, XH} from '@xh/hoist/core'; import {pluralize, throwIf} from '@xh/hoist/utils/js'; -import {isEmpty, omit, pick} from 'lodash'; +import {isEmpty, omit, pick, pickBy, isEqual} from 'lodash'; import {ViewInfo} from './ViewInfo'; import {View} from './View'; import {ViewManagerModel} from './ViewManagerModel'; @@ -28,6 +28,14 @@ export interface ViewUpdateSpec { isDefaultPinned?: boolean; } +export interface ViewUserState { + currentView?: string; + userPinned?: Record; + autoSave?: boolean; +} + +const STATE_BLOB_NAME = 'xhUserState'; + /** * Class for accessing and updating views using {@link JsonBlobService}. * @internal @@ -51,6 +59,7 @@ export class ViewToBlobApi { includeValue: false }); return blobs + .filter(b => b.name != STATE_BLOB_NAME) .map(b => new ViewInfo(b, model)) .filter( view => @@ -178,9 +187,43 @@ export class ViewToBlobApi { } } + //-------------------------- + // State related changes + //-------------------------- + async getStateAsync(): Promise { + const raw = await this.getRawState(); + return { + currentView: raw.currentViews?.[this.model.instance], + userPinned: raw.userPinned, + autoSave: raw.autoSave + }; + } + + async updateStateAsync() { + let {views, autoSave, view, userPinned, instance, type} = this.model; + userPinned = !isEmpty(views) // Clean state iff views loaded! + ? pickBy(userPinned, (_, tkn) => views.some(v => v.token === tkn)) + : userPinned; + const raw = await this.getRawState(), + newRaw = { + currentViews: {...raw.currentViews, [instance]: view.token}, + userPinned, + autoSave + }; + + if (!isEqual(raw, newRaw)) { + await XH.jsonBlobService.createOrUpdateAsync(type, STATE_BLOB_NAME, {value: newRaw}); + } + } + //------------------ // Implementation //------------------ + private async getRawState(): Promise { + const ret = await XH.jsonBlobService.findAsync(this.model.type, STATE_BLOB_NAME); + return ret ? ret.value : {autoSave: false, userPinned: {}, currentViews: {}}; + } + private trackChange(message: string, v?: View | ViewInfo) { XH.track({ message, diff --git a/svc/JsonBlobService.ts b/svc/JsonBlobService.ts index e7a3335e0..2965446c0 100644 --- a/svc/JsonBlobService.ts +++ b/svc/JsonBlobService.ts @@ -96,10 +96,28 @@ export class JsonBlobService extends HoistService { const update = omitBy({acl, description, meta, name, owner, value}, isUndefined); return XH.fetchJson({ url: 'xh/updateJsonBlob', - params: { - token, - update: JSON.stringify(update) - } + params: {token, update: JSON.stringify(update)} + }); + } + + /** Create or update a blob for a user with the existing type and name. */ + async createOrUpdateAsync( + type: string, + name: string, + {acl, description, meta, value}: Partial + ): Promise { + const update = omitBy({acl, description, meta, name, value}, isUndefined); + return XH.fetchJson({ + url: 'xh/createOrUpdateJsonBlob', + params: {type, name, update: JSON.stringify(update)} + }); + } + + /** Find a blob owned by this user with a specific type and name. If none exists, return null. */ + async findAsync(type: string, name: string): Promise { + return XH.fetchJson({ + url: 'xh/findJsonBlob', + params: {type, name} }); } From fc3a30d2ef61bf29ab1baab595eff5c029ae0b17 Mon Sep 17 00:00:00 2001 From: lbwexler Date: Thu, 19 Dec 2024 13:16:14 -0500 Subject: [PATCH 2/4] Checkpoint --- cmp/viewmanager/ViewManagerModel.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/cmp/viewmanager/ViewManagerModel.ts b/cmp/viewmanager/ViewManagerModel.ts index b3727435a..c7bafbeba 100644 --- a/cmp/viewmanager/ViewManagerModel.ts +++ b/cmp/viewmanager/ViewManagerModel.ts @@ -309,7 +309,7 @@ export class ViewManagerModel extends HoistModel { // 2) Update active view if needed. const {view} = this; if (!view.isDefault) { - // Reload view if can be fast-forwarded. Otherwise, leave as is for save/saveAs. + // Reload view if it can be fast-forwarded. Otherwise, leave as is for save/saveAs. const latestInfo = find(views, {token: view.token}); if (latestInfo && latestInfo.lastUpdated > view.lastUpdated) { this.loadViewAsync(latestInfo, this.pendingValue); @@ -322,9 +322,14 @@ export class ViewManagerModel extends HoistModel { } async selectViewAsync(info: ViewInfo): Promise { - if (this.isValueDirty) { - if (this.isViewAutoSavable) await this.maybeAutoSaveAsync(); - if (this.isValueDirty && !(await this.confirmDiscardChangesAsync())) return; + // ensure any pending auto-save gets completed + if (this.isValueDirty && this.isViewAutoSavable) { + await this.maybeAutoSaveAsync(); + } + + // if still dirty, require confirm + if (this.isValueDirty && this.view.isOwned && !(await this.confirmDiscardChangesAsync())) { + return; } await this.loadViewAsync(info).catch(e => this.handleException(e)); From 864bdaf8bddfe64cea019fbff740b4f483fb8e19 Mon Sep 17 00:00:00 2001 From: lbwexler Date: Tue, 24 Dec 2024 12:08:07 -0500 Subject: [PATCH 3/4] Checkpoint --- cmp/viewmanager/DataAccess.ts | 179 ++++++++++++++++++++ cmp/viewmanager/ViewManagerModel.ts | 133 +++++++++------ cmp/viewmanager/ViewToBlobApi.ts | 242 ---------------------------- cmp/viewmanager/index.ts | 2 +- 4 files changed, 266 insertions(+), 290 deletions(-) create mode 100644 cmp/viewmanager/DataAccess.ts delete mode 100644 cmp/viewmanager/ViewToBlobApi.ts diff --git a/cmp/viewmanager/DataAccess.ts b/cmp/viewmanager/DataAccess.ts new file mode 100644 index 000000000..9050e03b4 --- /dev/null +++ b/cmp/viewmanager/DataAccess.ts @@ -0,0 +1,179 @@ +/* + * 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 {PlainObject, XH} from '@xh/hoist/core'; +import {pluralize, throwIf} from '@xh/hoist/utils/js'; +import {map} from 'lodash'; +import {ViewInfo} from './ViewInfo'; +import {View} from './View'; +import {ViewManagerModel} from './ViewManagerModel'; + +export interface ViewCreateSpec { + name: string; + group: string; + description: string; + isShared: boolean; + value?: PlainObject; +} + +export interface ViewUpdateSpec { + name: string; + group: string; + description: string; + isShared?: boolean; + isDefaultPinned?: boolean; +} + +export interface ViewUserState { + currentView?: string; + userPinned: Record; + autoSave: boolean; +} + +/** + * Supporting class for accessing and updating ViewManager and View data. + * + * @internal + */ +export class DataAccess { + private readonly model: ViewManagerModel; + + constructor(model: ViewManagerModel) { + this.model = model; + } + + //--------------- + // Load/search. + //--------------- + /** Fetch metadata for all views accessible by current user. */ + async fetchDataAsync(): Promise<{views: ViewInfo[]; state: ViewUserState}> { + const {typeDisplayName, type, instance} = this.model; + try { + const ret = await XH.fetchJson({ + url: 'xhView/allData', + params: {type, viewInstance: instance} + }); + return { + views: ret.views.map(v => new ViewInfo(v, this.model)), + state: ret.state + }; + } catch (e) { + throw XH.exception({ + message: `Unable to fetch ${pluralize(typeDisplayName)}`, + cause: e + }); + } + } + + /** Fetch the latest version of a view. */ + async fetchViewAsync(info: ViewInfo): Promise> { + const {model} = this; + if (!info) return View.createDefault(model); + try { + const raw = await XH.fetchJson({url: 'xhView/get', params: {token: info.token}}); + return View.fromBlob(raw, model); + } catch (e) { + throw XH.exception({message: `Unable to fetch ${info.typedName}`, cause: e}); + } + } + + /** Create a new view, owned by the current user.*/ + async createViewAsync(spec: ViewCreateSpec): Promise> { + const {model} = this; + try { + const raw = await XH.postJson({ + url: 'xhView/create', + body: {type: model.type, ...spec} + }); + return View.fromBlob(raw, model); + } catch (e) { + throw XH.exception({message: `Unable to create ${model.typeDisplayName}`, cause: e}); + } + } + + /** Update all aspects of a view's metadata.*/ + async updateViewInfoAsync(view: ViewInfo, updates: ViewUpdateSpec): Promise> { + try { + this.ensureEditable(view); + const raw = await XH.postJson({ + url: 'xhView/updateViewInfo', + params: {token: view.token}, + body: updates + }); + return View.fromBlob(raw, this.model); + } catch (e) { + throw XH.exception({message: `Unable to update ${view.typedName}`, cause: e}); + } + } + + /** Promote a view to global visibility/ownership status. */ + async makeViewGlobalAsync(view: ViewInfo): Promise> { + try { + this.ensureEditable(view); + const raw = await XH.fetchJson({url: 'xhView/makeGlobal', params: {token: view.token}}); + return View.fromBlob(raw, this.model); + } catch (e) { + throw XH.exception({message: `Unable to update ${view.typedName}`, cause: e}); + } + } + + /** Update a view's value. */ + async updateViewValueAsync(view: View, value: Partial): Promise> { + try { + this.ensureEditable(view.info); + const raw = await XH.postJson({ + url: 'xhView/updateValue', + params: {token: view.token}, + body: value + }); + return View.fromBlob(raw, this.model); + } catch (e) { + throw XH.exception({ + message: `Unable to update value for ${view.typedName}`, + cause: e + }); + } + } + + async deleteViewsAsync(views: ViewInfo[]) { + views.forEach(v => this.ensureEditable(v)); + try { + await XH.postJson({ + url: 'xhView/delete', + params: {tokens: map(views, 'token').join(',')} + }); + } catch (e) { + throw XH.exception({ + message: `Failed to delete ${pluralize(this.model.typeDisplayName)}`, + cause: e + }); + } + } + + //-------------------------- + // State related changes + //-------------------------- + async updateStateAsync(update: Partial) { + const {type, instance} = this.model; + await XH.postJson({ + url: 'xhView/updateState', + params: {type, viewInstance: instance}, + body: update + }); + } + + //------------------ + // Implementation + //------------------ + private ensureEditable(view: ViewInfo) { + const {model} = this; + throwIf( + !view.isEditable, + `Cannot save changes to ${model.typeDisplayName} - missing required permission.` + ); + } +} diff --git a/cmp/viewmanager/ViewManagerModel.ts b/cmp/viewmanager/ViewManagerModel.ts index c7bafbeba..ecb506d05 100644 --- a/cmp/viewmanager/ViewManagerModel.ts +++ b/cmp/viewmanager/ViewManagerModel.ts @@ -15,18 +15,17 @@ import { Thunkable, XH } from '@xh/hoist/core'; -import type {ViewManagerProvider} from '@xh/hoist/core'; +import type {ViewManagerProvider, ReactionSpec} from '@xh/hoist/core'; import {genDisplayName} from '@xh/hoist/data'; import {fmtDateTime} from '@xh/hoist/format'; -import {action, bindable, makeObservable, observable} from '@xh/hoist/mobx'; +import {action, bindable, makeObservable, observable, comparer, runInAction} from '@xh/hoist/mobx'; import {olderThan, SECONDS} from '@xh/hoist/utils/datetime'; import {executeIfFunction, pluralize, throwIf} from '@xh/hoist/utils/js'; -import {find, isEqual, isNil, lowerCase} from 'lodash'; -import {runInAction} from 'mobx'; +import {find, isEqual, isNil, isNull, isUndefined, lowerCase} from 'lodash'; import {ReactNode} from 'react'; import {ViewInfo} from './ViewInfo'; import {View} from './View'; -import {ViewToBlobApi, ViewCreateSpec, ViewUpdateSpec} from './ViewToBlobApi'; +import {DataAccess, ViewCreateSpec, ViewUpdateSpec, ViewUserState} from './DataAccess'; export interface ViewManagerConfig { /** @@ -195,7 +194,7 @@ export class ViewManagerModel extends HoistModel { providers: ViewManagerProvider[] = []; /** Data access for persisting views. */ - private api: ViewToBlobApi; + private dataAccess: DataAccess; /** Last time changes were pushed to linked persistence providers */ private lastPushed: number = null; @@ -296,20 +295,23 @@ export class ViewManagerModel extends HoistModel { message: `Saving ${this.typeDisplayName}...` }); - this.api = new ViewToBlobApi(this); + this.dataAccess = new DataAccess(this); } override async doLoadAsync(loadSpec: LoadSpec) { + const {dataAccess, view} = this; try { - // 1) Update all view info - const views = await this.api.fetchViewInfosAsync(); + // 1) Update views and related state + const {views, state} = await dataAccess.fetchDataAsync(); if (loadSpec.isStale) return; - runInAction(() => (this.views = views)); + runInAction(() => { + this.views = views; + this.userPinned = state.userPinned; + this.autoSave = state.autoSave; + }); - // 2) Update active view if needed. - const {view} = this; + // potentially fast-forward current view. if (!view.isDefault) { - // Reload view if it can be fast-forwarded. Otherwise, leave as is for save/saveAs. const latestInfo = find(views, {token: view.token}); if (latestInfo && latestInfo.lastUpdated > view.lastUpdated) { this.loadViewAsync(latestInfo, this.pendingValue); @@ -336,11 +338,10 @@ export class ViewManagerModel extends HoistModel { } async saveAsAsync(spec: ViewCreateSpec): Promise { - const view = await this.api.createViewAsync({...spec, value: this.getValue()}); + const view = await this.dataAccess.createViewAsync({...spec, value: this.getValue()}); this.noteSuccess(`Created ${view.typedName}`); this.userPin(view.info); this.setAsView(view); - this.refreshAsync(); } //------------------------ @@ -351,12 +352,12 @@ export class ViewManagerModel extends HoistModel { this.logError('Unexpected conditions for call to save, skipping'); return; } - const {pendingValue, view, api} = this; + const {pendingValue, view, dataAccess} = this; try { if (!(await this.maybeConfirmSaveAsync(view, pendingValue))) { return; } - const updated = await api + const updated = await dataAccess .updateViewValueAsync(view, pendingValue.value) .linkTo(this.saveTask); @@ -439,18 +440,18 @@ export class ViewManagerModel extends HoistModel { /** Update all aspects of a view's metadata.*/ async updateViewInfoAsync(view: ViewInfo, updates: ViewUpdateSpec): Promise> { - return this.api.updateViewInfoAsync(view, updates); + return this.dataAccess.updateViewInfoAsync(view, updates); } /** Promote a view to global visibility/ownership status. */ async makeViewGlobalAsync(view: ViewInfo): Promise> { - return this.api.makeViewGlobalAsync(view); + return this.dataAccess.makeViewGlobalAsync(view); } async deleteViewsAsync(toDelete: ViewInfo[]): Promise { let exception; try { - await this.api.deleteViewsAsync(toDelete); + await this.dataAccess.deleteViewsAsync(toDelete); } catch (e) { exception = e; } @@ -469,52 +470,86 @@ export class ViewManagerModel extends HoistModel { // Implementation //------------------ private async initAsync() { - let {api, pendingValue} = this, - initialToken; + let {dataAccess, pendingValueStorageKey} = this, + initialState; try { - // Initialize views and related state - const views = await api.fetchViewInfosAsync(), - state = await api.getStateAsync(); + // 1) Initialize views and related state + const {views, state} = await dataAccess.fetchDataAsync(); + initialState = state; runInAction(() => { this.views = views; this.userPinned = state.userPinned; this.autoSave = state.autoSave; + this.pendingValue = XH.sessionStorageService.get(pendingValueStorageKey); }); - // Initialize/choose current view. Null is ok, and will yield default. - initialToken = state.currentView; - const initialView = - (initialToken ? find(this.views, {token: initialToken}) : null) ?? - this.initialViewSpec?.(views); + // 2 Initialize/choose initial view. Null is ok, and will yield default. + let initialView, + initialTkn = initialState.currentView; + if (isUndefined(initialTkn)) { + initialView = this.initialViewSpec?.(views); + } else if (!isNull(initialTkn)) { + initialView = find(views, {token: initialTkn}) ?? this.initialViewSpec?.(views); + } else { + initialView = null; + } - await this.loadViewAsync(initialView, pendingValue); + await this.loadViewAsync(initialView, this.pendingValue); } catch (e) { // Always ensure at least default view is installed (other state defaults are fine) - this.loadViewAsync(null, pendingValue); + this.loadViewAsync(null, this.pendingValue); this.handleException(e, {showAlert: false, logOnServer: true}); } - // Add reactions - this.addReaction({ + this.addReaction( + this.pendingValueReaction(), + this.autoSaveReaction(), + ...this.stateReactions(initialState) + ); + } + + private pendingValueReaction(): ReactionSpec { + return { + track: () => this.pendingValue, + run: v => XH.sessionStorageService.set(this.pendingValueStorageKey, v) + }; + } + + private autoSaveReaction(): ReactionSpec { + return { track: () => [this.pendingValue, this.autoSave], run: () => this.maybeAutoSaveAsync(), - debounce: 5 * SECONDS - }); - - this.addReaction({ - track: () => [this.view, this.userPinned, this.autoSave], - run: () => api.updateStateAsync(), - debounce: 5 * SECONDS, - fireImmediately: this.view.token != initialToken - }); + debounce: 2 * SECONDS + }; + } + + private stateReactions(initialState: ViewUserState): ReactionSpec[] { + const {dataAccess} = this; + return [ + { + track: () => this.userPinned, + run: userPinned => dataAccess.updateStateAsync({userPinned}), + equals: comparer.structural, + debounce: 2 * SECONDS + }, + { + track: () => this.autoSave, + run: autoSave => dataAccess.updateStateAsync({autoSave}) + }, + { + track: () => this.view?.token, + run: tkn => dataAccess.updateStateAsync({currentView: tkn}), + fireImmediately: this.view?.token !== initialState.currentView + } + ]; } private async loadViewAsync( info: ViewInfo, pendingValue: PendingValue = null ): Promise { - return this.api + return this.dataAccess .fetchViewAsync(info) .thenAction(latest => { this.setAsView(latest, pendingValue?.token == info?.token ? pendingValue : null); @@ -525,10 +560,10 @@ export class ViewManagerModel extends HoistModel { } private async maybeAutoSaveAsync() { - const {pendingValue, isViewAutoSavable, view, api} = this; + const {pendingValue, isViewAutoSavable, view, dataAccess} = this; if (isViewAutoSavable && pendingValue) { try { - const updated = await api + const updated = await dataAccess .updateViewValueAsync(view, pendingValue.value) .linkTo(this.saveTask); @@ -563,6 +598,10 @@ export class ViewManagerModel extends HoistModel { XH.successToast(msg); } + private get pendingValueStorageKey(): string { + return `${this.type}_${this.instance}`; + } + /** * Stringify and parse to ensure that any value set here is valid, serializable JSON. */ @@ -587,7 +626,7 @@ export class ViewManagerModel extends HoistModel { private async maybeConfirmSaveAsync(view: View, pendingValue: PendingValue) { // Get latest from server for reference - const latest = await this.api.fetchViewAsync(view.info), + const latest = await this.dataAccess.fetchViewAsync(view.info), isGlobal = latest.isGlobal, isStale = latest.lastUpdated > pendingValue.baseUpdated; if (!isStale && !isGlobal) return true; diff --git a/cmp/viewmanager/ViewToBlobApi.ts b/cmp/viewmanager/ViewToBlobApi.ts deleted file mode 100644 index e7428ee0e..000000000 --- a/cmp/viewmanager/ViewToBlobApi.ts +++ /dev/null @@ -1,242 +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 {PlainObject, XH} from '@xh/hoist/core'; -import {pluralize, throwIf} from '@xh/hoist/utils/js'; -import {isEmpty, omit, pick, pickBy, isEqual} from 'lodash'; -import {ViewInfo} from './ViewInfo'; -import {View} from './View'; -import {ViewManagerModel} from './ViewManagerModel'; - -export interface ViewCreateSpec { - name: string; - group: string; - description: string; - isShared: boolean; - value?: PlainObject; -} - -export interface ViewUpdateSpec { - name: string; - group: string; - description: string; - isShared?: boolean; - isDefaultPinned?: boolean; -} - -export interface ViewUserState { - currentView?: string; - userPinned?: Record; - autoSave?: boolean; -} - -const STATE_BLOB_NAME = 'xhUserState'; - -/** - * Class for accessing and updating views using {@link JsonBlobService}. - * @internal - */ -export class ViewToBlobApi { - private readonly model: ViewManagerModel; - - constructor(model: ViewManagerModel) { - this.model = model; - } - - //--------------- - // Load/search. - //--------------- - /** Fetch metadata for all views accessible by current user. */ - async fetchViewInfosAsync(): Promise { - const {model} = this; - try { - const blobs = await XH.jsonBlobService.listAsync({ - type: model.type, - includeValue: false - }); - return blobs - .filter(b => b.name != STATE_BLOB_NAME) - .map(b => new ViewInfo(b, model)) - .filter( - view => - (model.enableGlobal || !view.isGlobal) && - (model.enableSharing || !view.isShared) - ); - } catch (e) { - throw XH.exception({ - message: `Unable to fetch ${pluralize(model.typeDisplayName)}`, - cause: e - }); - } - } - - /** Fetch the latest version of a view. */ - async fetchViewAsync(info: ViewInfo): Promise> { - const {model} = this; - if (!info) return View.createDefault(model); - try { - const blob = await XH.jsonBlobService.getAsync(info.token); - return View.fromBlob(blob, model); - } catch (e) { - throw XH.exception({message: `Unable to fetch ${info.typedName}`, cause: e}); - } - } - - //----------------- - // CRUD - //----------------- - /** Create a new view, owned by the current user.*/ - async createViewAsync(spec: ViewCreateSpec): Promise> { - const {model} = this; - try { - const blob = await XH.jsonBlobService.createAsync({ - type: model.type, - name: spec.name, - description: spec.description, - acl: spec.isShared ? '*' : null, - meta: {group: spec.group, isShared: spec.isShared}, - value: spec.value - }); - const ret = View.fromBlob(blob, model); - this.trackChange('Created View', ret); - return ret; - } catch (e) { - throw XH.exception({message: `Unable to create ${model.typeDisplayName}`, cause: e}); - } - } - - /** Update all aspects of a view's metadata.*/ - async updateViewInfoAsync(view: ViewInfo, updates: ViewUpdateSpec): Promise> { - try { - this.ensureEditable(view); - const {isGlobal} = view, - {name, group, description, isShared, isDefaultPinned} = updates, - meta = {...view.meta, group}, - blob = await XH.jsonBlobService.updateAsync(view.token, { - name: name.trim(), - description: description?.trim(), - acl: isGlobal || isShared ? '*' : null, - meta: isGlobal ? {...meta, isDefaultPinned} : {...meta, isShared} - }); - const ret = View.fromBlob(blob, this.model); - this.trackChange('Updated View Info', ret); - return ret; - } catch (e) { - throw XH.exception({message: `Unable to update ${view.typedName}`, cause: e}); - } - } - - /** Promote a view to global visibility/ownership status. */ - async makeViewGlobalAsync(view: ViewInfo): Promise> { - try { - this.ensureEditable(view); - const meta = view.meta, - blob = await XH.jsonBlobService.updateAsync(view.token, { - owner: null, - acl: '*', - meta: omit(meta, ['isShared']) - }); - const ret = View.fromBlob(blob, this.model); - this.trackChange('Made View Global', ret); - return ret; - } catch (e) { - throw XH.exception({message: `Unable to update ${view.typedName}`, cause: e}); - } - } - - /** Update a view's value. */ - async updateViewValueAsync(view: View, value: Partial): Promise> { - try { - this.ensureEditable(view.info); - const blob = await XH.jsonBlobService.updateAsync(view.token, {value}); - const ret = View.fromBlob(blob, this.model); - if (ret.isGlobal) { - this.trackChange('Updated Global View definition', ret); - } - return ret; - } catch (e) { - throw XH.exception({ - message: `Unable to update value for ${view.typedName}`, - cause: e - }); - } - } - - async deleteViewsAsync(views: ViewInfo[]) { - views.forEach(v => this.ensureEditable(v)); - const results = await Promise.allSettled( - views.map(v => XH.jsonBlobService.archiveAsync(v.token)) - ), - outcome = results.map((result, idx) => ({result, view: views[idx]})), - failed = outcome.filter(({result}) => result.status === 'rejected') as Array<{ - result: PromiseRejectedResult; - view: ViewInfo; - }>; - - this.trackChange(`Deleted ${pluralize('View', views.length - failed.length, true)}`); - - if (!isEmpty(failed)) { - throw XH.exception({ - message: `Failed to delete ${pluralize(this.model.typeDisplayName, failed.length, true)}: ${failed.map(({view}) => view.name).join(', ')}`, - cause: failed.map(({result}) => result.reason) - }); - } - } - - //-------------------------- - // State related changes - //-------------------------- - async getStateAsync(): Promise { - const raw = await this.getRawState(); - return { - currentView: raw.currentViews?.[this.model.instance], - userPinned: raw.userPinned, - autoSave: raw.autoSave - }; - } - - async updateStateAsync() { - let {views, autoSave, view, userPinned, instance, type} = this.model; - userPinned = !isEmpty(views) // Clean state iff views loaded! - ? pickBy(userPinned, (_, tkn) => views.some(v => v.token === tkn)) - : userPinned; - const raw = await this.getRawState(), - newRaw = { - currentViews: {...raw.currentViews, [instance]: view.token}, - userPinned, - autoSave - }; - - if (!isEqual(raw, newRaw)) { - await XH.jsonBlobService.createOrUpdateAsync(type, STATE_BLOB_NAME, {value: newRaw}); - } - } - - //------------------ - // Implementation - //------------------ - private async getRawState(): Promise { - const ret = await XH.jsonBlobService.findAsync(this.model.type, STATE_BLOB_NAME); - return ret ? ret.value : {autoSave: false, userPinned: {}, currentViews: {}}; - } - - private trackChange(message: string, v?: View | ViewInfo) { - XH.track({ - message, - category: 'Views', - data: pick(v, ['name', 'token', 'isGlobal', 'type']) - }); - } - - private ensureEditable(view: ViewInfo) { - const {model} = this; - throwIf( - !view.isEditable, - `Cannot save changes to ${model.globalDisplayName} ${model.typeDisplayName} - missing required permission.` - ); - } -} diff --git a/cmp/viewmanager/index.ts b/cmp/viewmanager/index.ts index b23745396..b8485c5b4 100644 --- a/cmp/viewmanager/index.ts +++ b/cmp/viewmanager/index.ts @@ -1,4 +1,4 @@ export * from './ViewManagerModel'; export * from './ViewInfo'; export * from './View'; -export * from './ViewToBlobApi'; +export * from './DataAccess'; From 52820443c93a361a07104bb2a78d0e1cf3df6269 Mon Sep 17 00:00:00 2001 From: lbwexler Date: Fri, 27 Dec 2024 13:07:32 -0500 Subject: [PATCH 4/4] changes from CR --- cmp/viewmanager/ViewManagerModel.ts | 8 ++++---- svc/JsonBlobService.ts | 13 +++++-------- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/cmp/viewmanager/ViewManagerModel.ts b/cmp/viewmanager/ViewManagerModel.ts index ecb506d05..a4a04980a 100644 --- a/cmp/viewmanager/ViewManagerModel.ts +++ b/cmp/viewmanager/ViewManagerModel.ts @@ -84,9 +84,9 @@ export interface ViewManagerConfig { type: string; /** - * Optional sub-discriminator for the particular location in your app this instance of the view - * manager appears. A particular currentView and pendingValue will be maintained by instance, - * but all other options, and library state will be shared by type. + * Optional sub-discriminator for the particular location in your app this instance of the + * view manager appears in. A particular currentView and pendingValue will be maintained by + * instance, but all other options, and the available library of views will be shared by type. */ instance?: string; @@ -484,7 +484,7 @@ export class ViewManagerModel extends HoistModel { this.pendingValue = XH.sessionStorageService.get(pendingValueStorageKey); }); - // 2 Initialize/choose initial view. Null is ok, and will yield default. + // 2) Initialize/choose initial view. Null is ok, and will yield default. let initialView, initialTkn = initialState.currentView; if (isUndefined(initialTkn)) { diff --git a/svc/JsonBlobService.ts b/svc/JsonBlobService.ts index 2965446c0..a57e5e411 100644 --- a/svc/JsonBlobService.ts +++ b/svc/JsonBlobService.ts @@ -5,7 +5,7 @@ * Copyright © 2024 Extremely Heavy Industries Inc. */ import {HoistService, LoadSpec, PlainObject, XH} from '@xh/hoist/core'; -import {isUndefined, omitBy} from 'lodash'; +import {pick} from 'lodash'; export interface JsonBlob { /** Either null for private blobs or special token "*" for globally shared blobs. */ @@ -89,11 +89,8 @@ export class JsonBlobService extends HoistService { } /** Modify mutable properties of an existing JSONBlob, as identified by its unique token. */ - async updateAsync( - token: string, - {acl, description, meta, name, owner, value}: Partial - ): Promise { - const update = omitBy({acl, description, meta, name, owner, value}, isUndefined); + async updateAsync(token: string, update: Partial): Promise { + update = pick(update, ['acl', 'description', 'meta', 'name', 'owner', 'value']); return XH.fetchJson({ url: 'xh/updateJsonBlob', params: {token, update: JSON.stringify(update)} @@ -104,9 +101,9 @@ export class JsonBlobService extends HoistService { async createOrUpdateAsync( type: string, name: string, - {acl, description, meta, value}: Partial + data: Partial ): Promise { - const update = omitBy({acl, description, meta, name, value}, isUndefined); + const update = pick(data, ['acl', 'description', 'meta', 'value']); return XH.fetchJson({ url: 'xh/createOrUpdateJsonBlob', params: {type, name, update: JSON.stringify(update)}