@xh/hoist 71.0.0-SNAPSHOT.1733262000771 → 71.0.0-SNAPSHOT.1733347475493

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.
Files changed (73) hide show
  1. package/CHANGELOG.md +8 -2
  2. package/appcontainer/AppContainerModel.ts +2 -1
  3. package/build/types/cmp/viewmanager/SaveAsDialogModel.d.ts +23 -0
  4. package/build/types/cmp/viewmanager/View.d.ts +28 -0
  5. package/build/types/cmp/viewmanager/ViewInfo.d.ts +30 -0
  6. package/build/types/cmp/viewmanager/ViewManagerModel.d.ts +185 -0
  7. package/build/types/cmp/viewmanager/index.d.ts +4 -0
  8. package/build/types/core/XH.d.ts +2 -1
  9. package/build/types/core/persist/PersistOptions.d.ts +3 -1
  10. package/build/types/core/persist/index.d.ts +6 -5
  11. package/build/types/core/persist/{CustomProvider.d.ts → provider/CustomProvider.d.ts} +1 -1
  12. package/build/types/core/persist/{DashViewProvider.d.ts → provider/DashViewProvider.d.ts} +2 -2
  13. package/build/types/core/persist/{LocalStorageProvider.d.ts → provider/LocalStorageProvider.d.ts} +1 -1
  14. package/build/types/core/persist/{PrefProvider.d.ts → provider/PrefProvider.d.ts} +1 -2
  15. package/build/types/core/persist/provider/SessionStorageProvider.d.ts +10 -0
  16. package/build/types/core/persist/{viewmanager → provider}/ViewManagerProvider.d.ts +3 -3
  17. package/build/types/desktop/cmp/viewmanager/ViewManager.d.ts +9 -10
  18. package/build/types/desktop/cmp/viewmanager/ViewMenu.d.ts +5 -0
  19. package/build/types/desktop/cmp/viewmanager/dialog/EditForm.d.ts +5 -0
  20. package/build/types/desktop/cmp/viewmanager/dialog/EditFormModel.d.ts +18 -0
  21. package/build/types/desktop/cmp/viewmanager/dialog/ManageDialog.d.ts +5 -0
  22. package/build/types/desktop/cmp/viewmanager/dialog/ManageDialogModel.d.ts +38 -0
  23. package/build/types/desktop/cmp/viewmanager/dialog/SaveAsDialog.d.ts +5 -0
  24. package/build/types/svc/JsonBlobService.d.ts +1 -1
  25. package/build/types/svc/index.d.ts +2 -1
  26. package/build/types/svc/storage/BaseStorageService.d.ts +21 -0
  27. package/build/types/svc/storage/LocalStorageService.d.ts +12 -0
  28. package/build/types/svc/storage/SessionStorageService.d.ts +12 -0
  29. package/cmp/viewmanager/SaveAsDialogModel.ts +97 -0
  30. package/cmp/viewmanager/View.ts +56 -0
  31. package/cmp/viewmanager/ViewInfo.ts +58 -0
  32. package/cmp/viewmanager/ViewManagerModel.ts +710 -0
  33. package/cmp/viewmanager/index.ts +4 -0
  34. package/core/XH.ts +2 -0
  35. package/core/persist/PersistOptions.ts +4 -1
  36. package/core/persist/PersistenceProvider.ts +5 -0
  37. package/core/persist/index.ts +6 -5
  38. package/core/persist/{CustomProvider.ts → provider/CustomProvider.ts} +1 -1
  39. package/core/persist/{DashViewProvider.ts → provider/DashViewProvider.ts} +1 -1
  40. package/core/persist/{LocalStorageProvider.ts → provider/LocalStorageProvider.ts} +1 -1
  41. package/core/persist/{PrefProvider.ts → provider/PrefProvider.ts} +2 -2
  42. package/core/persist/provider/SessionStorageProvider.ts +35 -0
  43. package/core/persist/{viewmanager → provider}/ViewManagerProvider.ts +5 -9
  44. package/desktop/cmp/viewmanager/ViewManager.ts +53 -229
  45. package/desktop/cmp/viewmanager/ViewMenu.ts +201 -0
  46. package/desktop/cmp/viewmanager/dialog/EditForm.ts +126 -0
  47. package/desktop/cmp/viewmanager/dialog/EditFormModel.ts +125 -0
  48. package/desktop/cmp/viewmanager/dialog/ManageDialog.ts +98 -0
  49. package/desktop/cmp/viewmanager/dialog/ManageDialogModel.ts +279 -0
  50. package/desktop/cmp/viewmanager/{impl/SaveDialog.ts → dialog/SaveAsDialog.ts} +20 -12
  51. package/package.json +1 -1
  52. package/svc/JsonBlobService.ts +1 -1
  53. package/svc/index.ts +2 -1
  54. package/svc/{LocalStorageService.ts → storage/BaseStorageService.ts} +13 -23
  55. package/svc/storage/LocalStorageService.ts +23 -0
  56. package/svc/storage/SessionStorageService.ts +23 -0
  57. package/tsconfig.tsbuildinfo +1 -1
  58. package/build/types/core/persist/viewmanager/Types.d.ts +0 -48
  59. package/build/types/core/persist/viewmanager/ViewManagerModel.d.ts +0 -145
  60. package/build/types/core/persist/viewmanager/impl/BuildViewTree.d.ts +0 -8
  61. package/build/types/core/persist/viewmanager/impl/ManageDialogModel.d.ts +0 -30
  62. package/build/types/core/persist/viewmanager/impl/SaveDialogModel.d.ts +0 -23
  63. package/build/types/core/persist/viewmanager/index.d.ts +0 -2
  64. package/build/types/desktop/cmp/viewmanager/impl/ManageDialog.d.ts +0 -6
  65. package/build/types/desktop/cmp/viewmanager/impl/SaveDialog.d.ts +0 -2
  66. package/build/types/svc/LocalStorageService.d.ts +0 -24
  67. package/core/persist/viewmanager/Types.ts +0 -53
  68. package/core/persist/viewmanager/ViewManagerModel.ts +0 -481
  69. package/core/persist/viewmanager/impl/BuildViewTree.ts +0 -68
  70. package/core/persist/viewmanager/impl/ManageDialogModel.ts +0 -276
  71. package/core/persist/viewmanager/impl/SaveDialogModel.ts +0 -112
  72. package/core/persist/viewmanager/index.ts +0 -2
  73. 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
+ }