@xh/hoist 71.0.0-SNAPSHOT.1733262000771 → 71.0.0-SNAPSHOT.1733266596001
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -2
- package/appcontainer/AppContainerModel.ts +2 -1
- package/build/types/cmp/viewmanager/SaveAsDialogModel.d.ts +23 -0
- package/build/types/cmp/viewmanager/View.d.ts +28 -0
- package/build/types/cmp/viewmanager/ViewInfo.d.ts +30 -0
- package/build/types/cmp/viewmanager/ViewManagerModel.d.ts +185 -0
- package/build/types/cmp/viewmanager/index.d.ts +4 -0
- package/build/types/core/XH.d.ts +2 -1
- package/build/types/core/persist/PersistOptions.d.ts +3 -1
- package/build/types/core/persist/index.d.ts +6 -5
- package/build/types/core/persist/{CustomProvider.d.ts → provider/CustomProvider.d.ts} +1 -1
- package/build/types/core/persist/{DashViewProvider.d.ts → provider/DashViewProvider.d.ts} +2 -2
- package/build/types/core/persist/{LocalStorageProvider.d.ts → provider/LocalStorageProvider.d.ts} +1 -1
- package/build/types/core/persist/{PrefProvider.d.ts → provider/PrefProvider.d.ts} +1 -2
- package/build/types/core/persist/provider/SessionStorageProvider.d.ts +10 -0
- package/build/types/core/persist/{viewmanager → provider}/ViewManagerProvider.d.ts +3 -3
- package/build/types/desktop/cmp/viewmanager/ViewManager.d.ts +9 -10
- package/build/types/desktop/cmp/viewmanager/ViewMenu.d.ts +5 -0
- package/build/types/desktop/cmp/viewmanager/dialog/EditForm.d.ts +5 -0
- package/build/types/desktop/cmp/viewmanager/dialog/EditFormModel.d.ts +18 -0
- package/build/types/desktop/cmp/viewmanager/dialog/ManageDialog.d.ts +5 -0
- package/build/types/desktop/cmp/viewmanager/dialog/ManageDialogModel.d.ts +38 -0
- package/build/types/desktop/cmp/viewmanager/dialog/SaveAsDialog.d.ts +5 -0
- package/build/types/svc/JsonBlobService.d.ts +1 -1
- package/build/types/svc/index.d.ts +2 -1
- package/build/types/svc/storage/BaseStorageService.d.ts +21 -0
- package/build/types/svc/storage/LocalStorageService.d.ts +12 -0
- package/build/types/svc/storage/SessionStorageService.d.ts +12 -0
- package/cmp/viewmanager/SaveAsDialogModel.ts +97 -0
- package/cmp/viewmanager/View.ts +56 -0
- package/cmp/viewmanager/ViewInfo.ts +58 -0
- package/cmp/viewmanager/ViewManagerModel.ts +710 -0
- package/cmp/viewmanager/index.ts +4 -0
- package/core/XH.ts +2 -0
- package/core/persist/PersistOptions.ts +4 -1
- package/core/persist/PersistenceProvider.ts +5 -0
- package/core/persist/index.ts +6 -5
- package/core/persist/{CustomProvider.ts → provider/CustomProvider.ts} +1 -1
- package/core/persist/{DashViewProvider.ts → provider/DashViewProvider.ts} +1 -1
- package/core/persist/{LocalStorageProvider.ts → provider/LocalStorageProvider.ts} +1 -1
- package/core/persist/{PrefProvider.ts → provider/PrefProvider.ts} +2 -2
- package/core/persist/provider/SessionStorageProvider.ts +35 -0
- package/core/persist/{viewmanager → provider}/ViewManagerProvider.ts +5 -9
- package/desktop/cmp/viewmanager/ViewManager.ts +47 -229
- package/desktop/cmp/viewmanager/ViewMenu.ts +191 -0
- package/desktop/cmp/viewmanager/dialog/EditForm.ts +126 -0
- package/desktop/cmp/viewmanager/dialog/EditFormModel.ts +125 -0
- package/desktop/cmp/viewmanager/dialog/ManageDialog.ts +98 -0
- package/desktop/cmp/viewmanager/dialog/ManageDialogModel.ts +279 -0
- package/desktop/cmp/viewmanager/{impl/SaveDialog.ts → dialog/SaveAsDialog.ts} +20 -12
- package/package.json +1 -1
- package/svc/JsonBlobService.ts +1 -1
- package/svc/index.ts +2 -1
- package/svc/{LocalStorageService.ts → storage/BaseStorageService.ts} +13 -23
- package/svc/storage/LocalStorageService.ts +23 -0
- package/svc/storage/SessionStorageService.ts +23 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/build/types/core/persist/viewmanager/Types.d.ts +0 -48
- package/build/types/core/persist/viewmanager/ViewManagerModel.d.ts +0 -145
- package/build/types/core/persist/viewmanager/impl/BuildViewTree.d.ts +0 -8
- package/build/types/core/persist/viewmanager/impl/ManageDialogModel.d.ts +0 -30
- package/build/types/core/persist/viewmanager/impl/SaveDialogModel.d.ts +0 -23
- package/build/types/core/persist/viewmanager/index.d.ts +0 -2
- package/build/types/desktop/cmp/viewmanager/impl/ManageDialog.d.ts +0 -6
- package/build/types/desktop/cmp/viewmanager/impl/SaveDialog.d.ts +0 -2
- package/build/types/svc/LocalStorageService.d.ts +0 -24
- package/core/persist/viewmanager/Types.ts +0 -53
- package/core/persist/viewmanager/ViewManagerModel.ts +0 -481
- package/core/persist/viewmanager/impl/BuildViewTree.ts +0 -68
- package/core/persist/viewmanager/impl/ManageDialogModel.ts +0 -276
- package/core/persist/viewmanager/impl/SaveDialogModel.ts +0 -112
- package/core/persist/viewmanager/index.ts +0 -2
- package/desktop/cmp/viewmanager/impl/ManageDialog.ts +0 -197
|
@@ -0,0 +1,710 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This file belongs to Hoist, an application development toolkit
|
|
3
|
+
* developed by Extremely Heavy Industries (www.xh.io | info@xh.io)
|
|
4
|
+
*
|
|
5
|
+
* Copyright © 2024 Extremely Heavy Industries Inc.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {fragment, strong, p, span} from '@xh/hoist/cmp/layout';
|
|
9
|
+
import {
|
|
10
|
+
ExceptionHandlerOptions,
|
|
11
|
+
HoistModel,
|
|
12
|
+
LoadSpec,
|
|
13
|
+
managed,
|
|
14
|
+
PersistableState,
|
|
15
|
+
PersistenceProvider,
|
|
16
|
+
PersistOptions,
|
|
17
|
+
PlainObject,
|
|
18
|
+
TaskObserver,
|
|
19
|
+
Thunkable,
|
|
20
|
+
XH
|
|
21
|
+
} from '@xh/hoist/core';
|
|
22
|
+
import type {ViewManagerProvider} from '@xh/hoist/core';
|
|
23
|
+
import {genDisplayName} from '@xh/hoist/data';
|
|
24
|
+
import {fmtDateTime} from '@xh/hoist/format';
|
|
25
|
+
import {action, bindable, makeObservable, observable, runInAction, when} from '@xh/hoist/mobx';
|
|
26
|
+
import {olderThan, SECONDS} from '@xh/hoist/utils/datetime';
|
|
27
|
+
import {executeIfFunction, pluralize, throwIf} from '@xh/hoist/utils/js';
|
|
28
|
+
import {find, isEqual, isNil, isObject, lowerCase, without} from 'lodash';
|
|
29
|
+
import {ReactNode} from 'react';
|
|
30
|
+
import {SaveAsDialogModel} from './SaveAsDialogModel';
|
|
31
|
+
import {ViewInfo} from './ViewInfo';
|
|
32
|
+
import {View} from './View';
|
|
33
|
+
|
|
34
|
+
export interface ViewManagerConfig {
|
|
35
|
+
/**
|
|
36
|
+
* True (default) to allow user to opt in to automatically saving changes to their current view.
|
|
37
|
+
*/
|
|
38
|
+
enableAutoSave?: boolean;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* True (default) to allow the user to select a "Default" option that restores all persisted
|
|
42
|
+
* objects to their in-code defaults. If not enabled, at least one saved view should be created
|
|
43
|
+
* in advance, so that there is a clear initial selection for users without any private views.
|
|
44
|
+
*/
|
|
45
|
+
enableDefault?: boolean;
|
|
46
|
+
|
|
47
|
+
/** True (default) to allow user to mark views as favorites. Requires `persistWith`. */
|
|
48
|
+
enableFavorites?: boolean;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Function to determine the initial view for a user, when no view has already been persisted.
|
|
52
|
+
* Will be passed a list of views available to the current user. Implementations where
|
|
53
|
+
* enableDefault is set false should typically return some view, if any views are
|
|
54
|
+
* available. If no view is returned, the control will be forced to fall back on the default.
|
|
55
|
+
*
|
|
56
|
+
* Must be set when enableDefault is false.
|
|
57
|
+
*/
|
|
58
|
+
initialViewSpec?: (views: ViewInfo[]) => ViewInfo;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Delay after state has been set on associated components before they will be observed for
|
|
62
|
+
* any further state changes. Larger values may be useful when providing state to complex
|
|
63
|
+
* components such as dashboards or grids that may create dirty state immediately after load.
|
|
64
|
+
*
|
|
65
|
+
* Specified in milliseconds. Default is 250.
|
|
66
|
+
*/
|
|
67
|
+
settleTime?: number;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* True to allow the user to publish or edit the global views. Apps are expected to
|
|
71
|
+
* commonly set this based on user roles - e.g. `XH.getUser().hasRole('MANAGE_GRID_VIEWS')`.
|
|
72
|
+
*/
|
|
73
|
+
manageGlobal?: Thunkable<boolean>;
|
|
74
|
+
|
|
75
|
+
/** Used to persist the user's state. */
|
|
76
|
+
persistWith?: ViewManagerPersistOptions;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Required discriminator for the particular class of views to be loaded and managed by this
|
|
80
|
+
* model. Set to something descriptive and specific enough to be identifiable and allow for
|
|
81
|
+
* different viewManagers to be added to your app in the future - e.g. `portfolioGridView` or
|
|
82
|
+
* `tradeBlotterDashboard`.
|
|
83
|
+
*/
|
|
84
|
+
viewType: string;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Optional user-facing display name for the view type, displayed in the ViewManager menu
|
|
88
|
+
* and associated management dialogs and prompts. Defaulted from `viewType` if not provided.
|
|
89
|
+
*/
|
|
90
|
+
typeDisplayName?: string;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Optional user-facing display name for describing global views. Defaults to 'global'
|
|
94
|
+
*/
|
|
95
|
+
globalDisplayName?: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface ViewManagerPersistOptions extends PersistOptions {
|
|
99
|
+
/** True to persist favorites or provide specific PersistOptions. (Default true) */
|
|
100
|
+
persistFavorites?: boolean | PersistOptions;
|
|
101
|
+
|
|
102
|
+
/** True to include pending value or provide specific PersistOptions. (Default false) */
|
|
103
|
+
persistPendingValue?: boolean | PersistOptions;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* ViewManagerModel coordinates the loading, saving, and management of user-defined bundles of
|
|
108
|
+
* {@link Persistable} component/model state.
|
|
109
|
+
*
|
|
110
|
+
* - Models to be persisted are bound to this model via their `persistWith` config. One or more
|
|
111
|
+
* models can be bound to a single ViewManagerModel, allowing a single view to capture the state
|
|
112
|
+
* of multiple components - e.g. grouping and filtering options along with grid state.
|
|
113
|
+
* - Views are persisted back to the server as JsonBlob objects.
|
|
114
|
+
* - Views can be private to their owner, or optionally enabled for global use by (all) other users.
|
|
115
|
+
* - Views can be marked as favorites for quick access.
|
|
116
|
+
* - See the desktop {@link ViewManager} component - the initial Hoist UI for this model.
|
|
117
|
+
*/
|
|
118
|
+
export class ViewManagerModel<T = PlainObject> extends HoistModel {
|
|
119
|
+
/**
|
|
120
|
+
* Factory to create new instances of this model and await its initial load before binding to
|
|
121
|
+
* any persistable component models. This ensures that bound models will have the expected
|
|
122
|
+
* initial persisted state applied within their constructor, before their components have
|
|
123
|
+
* rendered, and avoids thrashing of component state during initial load.
|
|
124
|
+
*
|
|
125
|
+
* To minimize the impact this async requirement has on the design and lifecycle of individual
|
|
126
|
+
* components within an app, consider eagerly constructing any viewManagerModels required within
|
|
127
|
+
* your `AppModel.initAsync` method and saving a reference to them there for component models
|
|
128
|
+
* to then use when they are mounted. The VM model instances will then be "ready to go" and
|
|
129
|
+
* usable within model constructors. (Initializing and referencing from one or more app
|
|
130
|
+
* services would be another, similar option.)
|
|
131
|
+
*
|
|
132
|
+
* Note that this method may throw if the ViewManager cannot be initialized successfully,
|
|
133
|
+
* but should generally fail quietly due to the early instantiation.
|
|
134
|
+
*/
|
|
135
|
+
static async createAsync(config: ViewManagerConfig): Promise<ViewManagerModel> {
|
|
136
|
+
const ret = new ViewManagerModel(config);
|
|
137
|
+
await ret.initAsync();
|
|
138
|
+
return ret;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Immutable configuration for this model. */
|
|
142
|
+
readonly viewType: string;
|
|
143
|
+
readonly typeDisplayName: string;
|
|
144
|
+
readonly globalDisplayName: string;
|
|
145
|
+
readonly enableAutoSave: boolean;
|
|
146
|
+
readonly enableDefault: boolean;
|
|
147
|
+
readonly enableFavorites: boolean;
|
|
148
|
+
readonly manageGlobal: boolean;
|
|
149
|
+
readonly settleTime: number;
|
|
150
|
+
readonly initialViewSpec: (views: ViewInfo[]) => ViewInfo;
|
|
151
|
+
|
|
152
|
+
/** Current view. Will not include uncommitted changes */
|
|
153
|
+
@observable.ref view: View<T> = null;
|
|
154
|
+
/** Loaded saved view library - both private and global */
|
|
155
|
+
@observable.ref views: ViewInfo[] = [];
|
|
156
|
+
/** List of tokens for the user's favorite views. */
|
|
157
|
+
@observable.ref favorites: string[] = [];
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* True if user has opted-in to automatically saving changes to personal views (if auto-save
|
|
161
|
+
* generally available as per `enableAutoSave`).
|
|
162
|
+
*/
|
|
163
|
+
@bindable autoSave = true;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* TaskObserver linked to {@link selectViewAsync}. If a change to the active view is likely to
|
|
167
|
+
* require intensive layout/grid work, consider masking affected components with this task.
|
|
168
|
+
*/
|
|
169
|
+
selectTask: TaskObserver;
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* TaskObserver linked to {@link saveAsync}.
|
|
173
|
+
*/
|
|
174
|
+
saveTask: TaskObserver;
|
|
175
|
+
|
|
176
|
+
@observable manageDialogOpen = false;
|
|
177
|
+
@managed readonly saveAsDialogModel: SaveAsDialogModel;
|
|
178
|
+
|
|
179
|
+
// Unsaved changes on the current view.
|
|
180
|
+
@observable.ref private pendingValue: PendingValue<T> = null;
|
|
181
|
+
|
|
182
|
+
// Last time changes were pushed to linked persistence providers
|
|
183
|
+
private lastPushed: number = null;
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Array of {@link ViewManagerProvider} instances bound to this model. Providers will
|
|
187
|
+
* push themselves onto this array when constructed with a reference to this model. Used to
|
|
188
|
+
* proactively push state to the target components when the model's selected `value` changes.
|
|
189
|
+
*
|
|
190
|
+
* @internal
|
|
191
|
+
*/
|
|
192
|
+
providers: ViewManagerProvider<any>[] = [];
|
|
193
|
+
|
|
194
|
+
declare persistWith: ViewManagerPersistOptions;
|
|
195
|
+
|
|
196
|
+
get isValueDirty(): boolean {
|
|
197
|
+
return !!this.pendingValue;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
get isViewSavable(): boolean {
|
|
201
|
+
const {view, manageGlobal} = this;
|
|
202
|
+
return !view.isDefault && (manageGlobal || !view.isGlobal);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
get isViewAutoSavable(): boolean {
|
|
206
|
+
const {enableAutoSave, autoSave, view} = this;
|
|
207
|
+
return enableAutoSave && autoSave && !view.isGlobal && !view.isDefault;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
get autoSaveUnavailableReason(): string {
|
|
211
|
+
const {view, isViewAutoSavable, typeDisplayName, globalDisplayName} = this;
|
|
212
|
+
if (isViewAutoSavable) return null;
|
|
213
|
+
if (view.isGlobal) return `Cannot auto-save ${globalDisplayName} ${typeDisplayName}.`;
|
|
214
|
+
if (view.isDefault) return `Cannot auto-save default ${typeDisplayName}.`;
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
get favoriteViews(): ViewInfo[] {
|
|
219
|
+
return this.views.filter(it => it.isFavorite);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
get globalViews(): ViewInfo[] {
|
|
223
|
+
return this.views.filter(it => it.isGlobal);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
get privateViews(): ViewInfo[] {
|
|
227
|
+
return this.views.filter(it => !it.isGlobal);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** True if any async tasks are pending. */
|
|
231
|
+
get isLoading(): boolean {
|
|
232
|
+
const {loadModel, saveTask, selectTask} = this;
|
|
233
|
+
return loadModel.isPending || saveTask.isPending || selectTask.isPending;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Use the static {@link createAsync} factory to create an instance of this model and await its
|
|
238
|
+
* initial load before binding to persistable components.
|
|
239
|
+
*/
|
|
240
|
+
private constructor({
|
|
241
|
+
viewType,
|
|
242
|
+
persistWith,
|
|
243
|
+
typeDisplayName,
|
|
244
|
+
globalDisplayName = 'global',
|
|
245
|
+
manageGlobal = false,
|
|
246
|
+
enableAutoSave = true,
|
|
247
|
+
enableDefault = true,
|
|
248
|
+
settleTime = 250,
|
|
249
|
+
enableFavorites = true,
|
|
250
|
+
initialViewSpec = null
|
|
251
|
+
}: ViewManagerConfig) {
|
|
252
|
+
super();
|
|
253
|
+
makeObservable(this);
|
|
254
|
+
|
|
255
|
+
throwIf(
|
|
256
|
+
!enableDefault && !initialViewSpec,
|
|
257
|
+
"ViewManagerModel requires 'initialViewSpec' if `enableDefault` is false."
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
this.viewType = viewType;
|
|
261
|
+
this.typeDisplayName = lowerCase(typeDisplayName ?? genDisplayName(viewType));
|
|
262
|
+
this.globalDisplayName = globalDisplayName;
|
|
263
|
+
this.persistWith = persistWith;
|
|
264
|
+
this.manageGlobal = executeIfFunction(manageGlobal) ?? false;
|
|
265
|
+
this.enableDefault = enableDefault;
|
|
266
|
+
this.enableAutoSave = enableAutoSave;
|
|
267
|
+
this.enableFavorites = enableFavorites;
|
|
268
|
+
this.settleTime = settleTime;
|
|
269
|
+
this.saveAsDialogModel = new SaveAsDialogModel(this);
|
|
270
|
+
this.initialViewSpec = initialViewSpec;
|
|
271
|
+
|
|
272
|
+
this.selectTask = TaskObserver.trackLast({
|
|
273
|
+
message: `Updating ${this.typeDisplayName}...`
|
|
274
|
+
});
|
|
275
|
+
this.saveTask = TaskObserver.trackLast({
|
|
276
|
+
message: `Saving ${this.typeDisplayName}...`
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private async initAsync() {
|
|
281
|
+
try {
|
|
282
|
+
const views = await this.fetchViewInfosAsync();
|
|
283
|
+
runInAction(() => (this.views = views));
|
|
284
|
+
|
|
285
|
+
if (this.persistWith) {
|
|
286
|
+
this.initPersist(this.persistWith);
|
|
287
|
+
await when(() => !this.selectTask.isPending);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// If the initial view not initialized from persistence, assign it.
|
|
291
|
+
if (!this.view) {
|
|
292
|
+
await this.loadViewAsync(this.initialViewSpec?.(views), this.pendingValue);
|
|
293
|
+
}
|
|
294
|
+
} catch (e) {
|
|
295
|
+
// Always ensure at least default view is installed.
|
|
296
|
+
if (!this.view) this.loadViewAsync(null, this.pendingValue);
|
|
297
|
+
|
|
298
|
+
this.handleException(e, {showAlert: false, logOnServer: true});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
this.addReaction({
|
|
302
|
+
track: () => [this.pendingValue, this.autoSave],
|
|
303
|
+
run: () => this.maybeAutoSaveAsync(),
|
|
304
|
+
debounce: 5 * SECONDS
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
override async doLoadAsync(loadSpec: LoadSpec) {
|
|
309
|
+
try {
|
|
310
|
+
// 1) Update all view info
|
|
311
|
+
const views = await this.fetchViewInfosAsync();
|
|
312
|
+
if (loadSpec.isStale) return;
|
|
313
|
+
runInAction(() => (this.views = views));
|
|
314
|
+
|
|
315
|
+
// 2) Update active view if needed.
|
|
316
|
+
const {view} = this;
|
|
317
|
+
if (!view.isDefault) {
|
|
318
|
+
// Reload view if can be fast-forwarded. Otherwise, leave as is for save/saveAs.
|
|
319
|
+
const latestInfo = find(views, {token: view.token});
|
|
320
|
+
if (latestInfo && latestInfo.lastUpdated > view.lastUpdated) {
|
|
321
|
+
this.loadViewAsync(latestInfo, this.pendingValue);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
} catch (e) {
|
|
325
|
+
if (loadSpec.isStale) return;
|
|
326
|
+
this.handleException(e, {showAlert: false});
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async selectViewAsync(info: ViewInfo): Promise<void> {
|
|
331
|
+
if (this.isValueDirty) {
|
|
332
|
+
if (this.isViewAutoSavable) await this.maybeAutoSaveAsync();
|
|
333
|
+
if (this.isValueDirty && !(await this.confirmDiscardChangesAsync())) return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
await this.loadViewAsync(info).catch(e => this.handleException(e));
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
//------------------------
|
|
340
|
+
// Saving/resetting
|
|
341
|
+
//------------------------
|
|
342
|
+
async saveAsync(): Promise<void> {
|
|
343
|
+
if (!this.pendingValue || !this.isViewSavable || this.isLoading) {
|
|
344
|
+
this.logError('Unexpected conditions for call to save, skipping');
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
const {pendingValue} = this,
|
|
348
|
+
{info} = this.view;
|
|
349
|
+
try {
|
|
350
|
+
if (!(await this.maybeConfirmSaveAsync(info, pendingValue))) {
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
const update = await XH.jsonBlobService
|
|
354
|
+
.updateAsync(info.token, {value: pendingValue.value})
|
|
355
|
+
.linkTo(this.saveTask);
|
|
356
|
+
|
|
357
|
+
this.setAsView(View.fromBlob(update, this));
|
|
358
|
+
this.noteSuccess(`Saved ${info.typedName}`);
|
|
359
|
+
} catch (e) {
|
|
360
|
+
this.handleException(e, {
|
|
361
|
+
message: `Failed to save ${info.typedName}. If this persists consider \`Save As...\`.`
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
this.refreshAsync();
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async saveAsAsync(): Promise<void> {
|
|
368
|
+
const view = (await this.saveAsDialogModel.openAsync()) as View<T>;
|
|
369
|
+
if (view) {
|
|
370
|
+
this.setAsView(view);
|
|
371
|
+
this.noteSuccess(`Saved ${view.info.typedName}`);
|
|
372
|
+
}
|
|
373
|
+
this.refreshAsync();
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async resetAsync(): Promise<void> {
|
|
377
|
+
await this.loadViewAsync(this.view.info).catch(e => this.handleException(e));
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
//--------------------------------
|
|
381
|
+
// Access for Provider/Components
|
|
382
|
+
//--------------------------------
|
|
383
|
+
getValue(): Partial<T> {
|
|
384
|
+
return this.pendingValue ? this.pendingValue.value : this.view.value;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
@action
|
|
388
|
+
setValue(value: Partial<T>) {
|
|
389
|
+
const {view, pendingValue, lastPushed, settleTime} = this;
|
|
390
|
+
if (!pendingValue && settleTime && !olderThan(lastPushed, settleTime)) {
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
value = this.cleanState(value);
|
|
395
|
+
if (!isEqual(value, view.value)) {
|
|
396
|
+
this.pendingValue = {
|
|
397
|
+
token: pendingValue ? pendingValue.token : view.token,
|
|
398
|
+
baseUpdated: pendingValue ? pendingValue.baseUpdated : view.lastUpdated,
|
|
399
|
+
value
|
|
400
|
+
};
|
|
401
|
+
} else {
|
|
402
|
+
this.pendingValue = null;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
//------------------
|
|
407
|
+
// Favorites
|
|
408
|
+
//------------------
|
|
409
|
+
toggleFavorite(token: string) {
|
|
410
|
+
this.isFavorite(token) ? this.removeFavorite(token) : this.addFavorite(token);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
@action
|
|
414
|
+
addFavorite(token: string) {
|
|
415
|
+
this.favorites = [...this.favorites, token];
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
@action
|
|
419
|
+
removeFavorite(token: string) {
|
|
420
|
+
this.favorites = without(this.favorites, token);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
isFavorite(token: string) {
|
|
424
|
+
return this.favorites.includes(token);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
//-----------------
|
|
428
|
+
// Management
|
|
429
|
+
//-----------------
|
|
430
|
+
@action
|
|
431
|
+
openManageDialog() {
|
|
432
|
+
this.manageDialogOpen = true;
|
|
433
|
+
this.refreshAsync();
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
@action
|
|
437
|
+
closeManageDialog() {
|
|
438
|
+
this.manageDialogOpen = false;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async validateViewNameAsync(name: string, existing: ViewInfo = null): Promise<string> {
|
|
442
|
+
const maxLength = 50;
|
|
443
|
+
name = name?.trim();
|
|
444
|
+
if (!name) return 'Name is required';
|
|
445
|
+
if (name.length > maxLength) {
|
|
446
|
+
return `Name cannot be longer than ${maxLength} characters`;
|
|
447
|
+
}
|
|
448
|
+
if (this.views.some(view => view.name === name && view.token != existing?.token)) {
|
|
449
|
+
return `A ${this.typeDisplayName} with name '${name}' already exists`;
|
|
450
|
+
}
|
|
451
|
+
return null;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async deleteViewAsync(view: ViewInfo) {
|
|
455
|
+
try {
|
|
456
|
+
await XH.jsonBlobService.archiveAsync(view.token);
|
|
457
|
+
this.removeFavorite(view.token);
|
|
458
|
+
} catch (e) {
|
|
459
|
+
throw XH.exception({message: `Unable to delete ${view.typedName}`, cause: e});
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
async updateViewAsync(view: ViewInfo, name: string, description: string, isGlobal: boolean) {
|
|
464
|
+
try {
|
|
465
|
+
await XH.jsonBlobService.updateAsync(view.token, {
|
|
466
|
+
name: name.trim(),
|
|
467
|
+
description: description?.trim(),
|
|
468
|
+
acl: isGlobal ? '*' : null
|
|
469
|
+
});
|
|
470
|
+
} catch (e) {
|
|
471
|
+
throw XH.exception({message: `Unable to update ${view.typedName}`, cause: e});
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async createViewAsync(name: string, description: string, value: PlainObject): Promise<View> {
|
|
476
|
+
try {
|
|
477
|
+
const blob = await XH.jsonBlobService.createAsync({
|
|
478
|
+
type: this.viewType,
|
|
479
|
+
name: name.trim(),
|
|
480
|
+
description: description?.trim(),
|
|
481
|
+
value
|
|
482
|
+
});
|
|
483
|
+
return View.fromBlob(blob, this);
|
|
484
|
+
} catch (e) {
|
|
485
|
+
throw XH.exception({message: `Unable to create ${this.typeDisplayName}`, cause: e});
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
//------------------
|
|
490
|
+
// Implementation
|
|
491
|
+
//------------------
|
|
492
|
+
private loadViewAsync(info: ViewInfo, pendingValue: PendingValue<T> = null): Promise<void> {
|
|
493
|
+
return this.fetchViewAsync(info)
|
|
494
|
+
.thenAction(latest => {
|
|
495
|
+
this.setAsView(latest, pendingValue?.token == info?.token ? pendingValue : null);
|
|
496
|
+
this.providers.forEach(it => it.pushStateToTarget());
|
|
497
|
+
this.lastPushed = Date.now();
|
|
498
|
+
})
|
|
499
|
+
.linkTo(this.selectTask);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
private async fetchViewAsync(info: ViewInfo): Promise<View<T>> {
|
|
503
|
+
if (!info) return View.createDefault();
|
|
504
|
+
try {
|
|
505
|
+
const blob = await XH.jsonBlobService.getAsync(info.token);
|
|
506
|
+
return View.fromBlob(blob, this);
|
|
507
|
+
} catch (e) {
|
|
508
|
+
throw XH.exception({message: `Unable to fetch ${info.typedName}`, cause: e});
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
private async fetchViewInfosAsync(): Promise<ViewInfo[]> {
|
|
513
|
+
try {
|
|
514
|
+
const blobs = await XH.jsonBlobService.listAsync({
|
|
515
|
+
type: this.viewType,
|
|
516
|
+
includeValue: false
|
|
517
|
+
});
|
|
518
|
+
return blobs.map(b => new ViewInfo(b, this));
|
|
519
|
+
} catch (e) {
|
|
520
|
+
throw XH.exception({
|
|
521
|
+
message: `Unable to fetch ${pluralize(this.typeDisplayName)}`,
|
|
522
|
+
cause: e
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
private async maybeAutoSaveAsync() {
|
|
528
|
+
const {pendingValue, isViewAutoSavable, view} = this;
|
|
529
|
+
if (isViewAutoSavable && pendingValue) {
|
|
530
|
+
try {
|
|
531
|
+
const raw = await XH.jsonBlobService
|
|
532
|
+
.updateAsync(view.token, {value: pendingValue.value})
|
|
533
|
+
.linkTo(this.saveTask);
|
|
534
|
+
this.setAsView(View.fromBlob(raw, this));
|
|
535
|
+
} catch (e) {
|
|
536
|
+
// TODO: How to alert but avoid for flaky or spam when user editing a deleted view
|
|
537
|
+
// Keep count and alert server and user once at count n?
|
|
538
|
+
XH.handleException(e, {
|
|
539
|
+
message: `Failing AutoSave for ${this.view.info.typedName}`,
|
|
540
|
+
showAlert: false,
|
|
541
|
+
logOnServer: false
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
@action
|
|
548
|
+
private setAsView(view: View<T>, pendingValue: PendingValue<T> = null) {
|
|
549
|
+
this.view = view;
|
|
550
|
+
this.pendingValue = pendingValue;
|
|
551
|
+
// Ensure we update meta-data as well.
|
|
552
|
+
if (!view.isDefault) {
|
|
553
|
+
this.views = this.views.map(v => (v.token === view.token ? view.info : v));
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
private handleException(e, opts: ExceptionHandlerOptions = {}) {
|
|
558
|
+
XH.handleException(e, opts);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
private noteSuccess(msg: string) {
|
|
562
|
+
XH.successToast(msg);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Stringify and parse to ensure that any value set here is valid, serializable JSON.
|
|
567
|
+
*/
|
|
568
|
+
private cleanState(state: Partial<T>): Partial<T> {
|
|
569
|
+
if (isNil(state)) state = {};
|
|
570
|
+
return JSON.parse(JSON.stringify(state));
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
private async confirmDiscardChangesAsync() {
|
|
574
|
+
return XH.confirm({
|
|
575
|
+
message: `You have unsaved changes. Discard them and continue to switch ${pluralize(this.typeDisplayName)}?`,
|
|
576
|
+
confirmProps: {
|
|
577
|
+
text: 'Discard changes',
|
|
578
|
+
intent: 'danger'
|
|
579
|
+
},
|
|
580
|
+
cancelProps: {
|
|
581
|
+
text: 'Cancel',
|
|
582
|
+
autoFocus: true
|
|
583
|
+
}
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
private async maybeConfirmSaveAsync(info: ViewInfo, pendingValue: PendingValue<T>) {
|
|
588
|
+
// Get latest from server for reference
|
|
589
|
+
const latest = await this.fetchViewAsync(info),
|
|
590
|
+
isGlobal = latest.isGlobal,
|
|
591
|
+
isStale = latest.lastUpdated > pendingValue.baseUpdated;
|
|
592
|
+
if (!isStale && !isGlobal) return true;
|
|
593
|
+
|
|
594
|
+
const latestInfo = latest.info,
|
|
595
|
+
{typeDisplayName, globalDisplayName} = this,
|
|
596
|
+
msgs: ReactNode[] = [`Save ${info.typedName}?`];
|
|
597
|
+
if (isGlobal) {
|
|
598
|
+
msgs.push(
|
|
599
|
+
span(
|
|
600
|
+
`This is a ${globalDisplayName} ${typeDisplayName}.`,
|
|
601
|
+
strong('Changes will be visible to ALL users.')
|
|
602
|
+
)
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
if (isStale) {
|
|
606
|
+
msgs.push(
|
|
607
|
+
span(
|
|
608
|
+
`This ${typeDisplayName} was updated by ${latestInfo.lastUpdatedBy} on ${fmtDateTime(latestInfo.lastUpdated)}.`,
|
|
609
|
+
strong('Your change may override those changes.')
|
|
610
|
+
)
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
return XH.confirm({
|
|
615
|
+
message: fragment(msgs.map(m => p(m))),
|
|
616
|
+
confirmProps: {
|
|
617
|
+
text: 'Yes, save changes',
|
|
618
|
+
intent: 'primary',
|
|
619
|
+
outlined: true
|
|
620
|
+
},
|
|
621
|
+
cancelProps: {
|
|
622
|
+
text: 'Cancel',
|
|
623
|
+
autoFocus: true
|
|
624
|
+
}
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
//------------------
|
|
629
|
+
// Persistence
|
|
630
|
+
//------------------
|
|
631
|
+
private initPersist(options: ViewManagerPersistOptions) {
|
|
632
|
+
const {
|
|
633
|
+
persistFavorites = true,
|
|
634
|
+
persistPendingValue = false,
|
|
635
|
+
path = 'viewManager',
|
|
636
|
+
...rootPersistWith
|
|
637
|
+
} = options;
|
|
638
|
+
|
|
639
|
+
// Favorites, potentially in dedicated location
|
|
640
|
+
if (this.enableFavorites && persistFavorites) {
|
|
641
|
+
const opts = isObject(persistFavorites) ? persistFavorites : rootPersistWith;
|
|
642
|
+
PersistenceProvider.create({
|
|
643
|
+
persistOptions: {path: `${path}.favorites`, ...opts},
|
|
644
|
+
target: {
|
|
645
|
+
getPersistableState: () => new PersistableState(this.favorites),
|
|
646
|
+
setPersistableState: ({value}) => {
|
|
647
|
+
this.favorites = value.filter(tkn => this.views.some(v => v.token === tkn));
|
|
648
|
+
}
|
|
649
|
+
},
|
|
650
|
+
owner: this
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// AutoSave, potentially in core location.
|
|
655
|
+
if (this.enableAutoSave) {
|
|
656
|
+
PersistenceProvider.create({
|
|
657
|
+
persistOptions: {path: `${path}.autoSave`, ...rootPersistWith},
|
|
658
|
+
target: {
|
|
659
|
+
getPersistableState: () => new PersistableState(this.autoSave),
|
|
660
|
+
setPersistableState: ({value}) => (this.autoSave = value)
|
|
661
|
+
},
|
|
662
|
+
owner: this
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Pending Value, potentially in dedicated location
|
|
667
|
+
// On hydration, stash away for one time use when hydrating view itself below
|
|
668
|
+
if (persistPendingValue) {
|
|
669
|
+
const opts = isObject(persistPendingValue) ? persistPendingValue : rootPersistWith;
|
|
670
|
+
PersistenceProvider.create({
|
|
671
|
+
persistOptions: {path: `${path}.pendingValue`, ...opts},
|
|
672
|
+
target: {
|
|
673
|
+
getPersistableState: () => new PersistableState(this.pendingValue),
|
|
674
|
+
setPersistableState: ({value}) => {
|
|
675
|
+
// Only accept this during initialization!
|
|
676
|
+
if (!this.view) this.pendingValue = value;
|
|
677
|
+
}
|
|
678
|
+
},
|
|
679
|
+
owner: this
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// View, in core location
|
|
684
|
+
PersistenceProvider.create({
|
|
685
|
+
persistOptions: {path: `${path}.view`, ...rootPersistWith},
|
|
686
|
+
target: {
|
|
687
|
+
// View could be null, just before initialization.
|
|
688
|
+
getPersistableState: () => new PersistableState(this.view?.token),
|
|
689
|
+
setPersistableState: async ({value: token}) => {
|
|
690
|
+
// Requesting available view -- load it with any init pending val.
|
|
691
|
+
const viewInfo = token ? find(this.views, {token}) : null;
|
|
692
|
+
if (viewInfo || !token) {
|
|
693
|
+
try {
|
|
694
|
+
await this.loadViewAsync(viewInfo, this.pendingValue);
|
|
695
|
+
} catch (e) {
|
|
696
|
+
this.logError('Failure loading persisted view', e);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
},
|
|
701
|
+
owner: this
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
interface PendingValue<T> {
|
|
707
|
+
token: string;
|
|
708
|
+
baseUpdated: number;
|
|
709
|
+
value: Partial<T>;
|
|
710
|
+
}
|