@xh/hoist 70.0.0-SNAPSHOT.1731083521069 → 70.0.0-SNAPSHOT.1731623470295

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 (95) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/build/types/cmp/filter/FilterChooserModel.d.ts +17 -12
  3. package/build/types/cmp/grid/GridModel.d.ts +5 -9
  4. package/build/types/cmp/grid/Types.d.ts +7 -19
  5. package/build/types/cmp/grid/columns/Column.d.ts +0 -1
  6. package/build/types/cmp/grid/impl/InitPersist.d.ts +7 -0
  7. package/build/types/cmp/grouping/GroupingChooserModel.d.ts +6 -8
  8. package/build/types/cmp/tab/TabContainerModel.d.ts +10 -4
  9. package/build/types/cmp/zoneGrid/Types.d.ts +6 -6
  10. package/build/types/cmp/zoneGrid/ZoneGridModel.d.ts +0 -2
  11. package/build/types/cmp/zoneGrid/impl/InitPersist.d.ts +7 -0
  12. package/build/types/core/HoistBase.d.ts +1 -1
  13. package/build/types/core/persist/CustomProvider.d.ts +5 -6
  14. package/build/types/core/persist/DashViewProvider.d.ts +6 -6
  15. package/build/types/core/persist/LocalStorageProvider.d.ts +4 -5
  16. package/build/types/core/persist/PersistOptions.d.ts +5 -4
  17. package/build/types/core/persist/Persistable.d.ts +14 -0
  18. package/build/types/core/persist/PersistenceProvider.d.ts +47 -34
  19. package/build/types/core/persist/PrefProvider.d.ts +5 -5
  20. package/build/types/core/persist/index.d.ts +2 -0
  21. package/build/types/core/persist/viewmanager/Types.d.ts +46 -0
  22. package/build/types/core/persist/viewmanager/ViewManagerModel.d.ts +149 -0
  23. package/build/types/core/persist/viewmanager/ViewManagerProvider.d.ts +10 -0
  24. package/build/types/core/persist/viewmanager/impl/ManageDialogModel.d.ts +30 -0
  25. package/build/types/core/persist/viewmanager/impl/SaveDialogModel.d.ts +23 -0
  26. package/build/types/core/persist/viewmanager/index.d.ts +2 -0
  27. package/build/types/desktop/cmp/button/ColAutosizeButton.d.ts +1 -1
  28. package/build/types/desktop/cmp/dash/DashConfig.d.ts +3 -1
  29. package/build/types/desktop/cmp/dash/DashModel.d.ts +1 -2
  30. package/build/types/desktop/cmp/dash/DashViewSpec.d.ts +1 -1
  31. package/build/types/desktop/cmp/dash/canvas/DashCanvasModel.d.ts +10 -2
  32. package/build/types/desktop/cmp/dash/container/DashContainerModel.d.ts +26 -10
  33. package/build/types/desktop/cmp/dash/container/impl/DashContainerUtils.d.ts +4 -2
  34. package/build/types/desktop/cmp/panel/PanelModel.d.ts +8 -4
  35. package/build/types/desktop/cmp/viewmanager/ViewManager.d.ts +22 -0
  36. package/build/types/desktop/cmp/viewmanager/cmp/ManageDialog.d.ts +6 -0
  37. package/build/types/desktop/cmp/viewmanager/cmp/SaveDialog.d.ts +2 -0
  38. package/build/types/desktop/cmp/viewmanager/index.d.ts +3 -0
  39. package/build/types/kit/blueprint/Wrappers.d.ts +1 -1
  40. package/build/types/mobile/cmp/button/ColAutosizeButton.d.ts +1 -1
  41. package/build/types/promise/Promise.d.ts +6 -5
  42. package/build/types/svc/GridAutosizeService.d.ts +2 -5
  43. package/build/types/svc/JsonBlobService.d.ts +45 -24
  44. package/cmp/filter/FilterChooserModel.ts +142 -125
  45. package/cmp/grid/Grid.ts +2 -10
  46. package/cmp/grid/GridModel.ts +18 -31
  47. package/cmp/grid/Types.ts +7 -21
  48. package/cmp/grid/columns/Column.ts +0 -1
  49. package/cmp/grid/impl/InitPersist.ts +71 -0
  50. package/cmp/grouping/GroupingChooserModel.ts +48 -57
  51. package/cmp/tab/TabContainerModel.ts +22 -36
  52. package/cmp/zoneGrid/Types.ts +6 -6
  53. package/cmp/zoneGrid/ZoneGridModel.ts +2 -7
  54. package/cmp/zoneGrid/impl/InitPersist.ts +70 -0
  55. package/core/HoistBase.ts +14 -22
  56. package/core/HoistBaseDecorators.ts +26 -28
  57. package/core/persist/CustomProvider.ts +7 -10
  58. package/core/persist/DashViewProvider.ts +8 -10
  59. package/core/persist/LocalStorageProvider.ts +9 -12
  60. package/core/persist/PersistOptions.ts +6 -4
  61. package/core/persist/Persistable.ts +23 -0
  62. package/core/persist/PersistenceProvider.ts +159 -79
  63. package/core/persist/PrefProvider.ts +9 -12
  64. package/core/persist/index.ts +2 -0
  65. package/core/persist/viewmanager/Types.ts +51 -0
  66. package/core/persist/viewmanager/ViewManagerModel.ts +515 -0
  67. package/core/persist/viewmanager/ViewManagerProvider.ts +51 -0
  68. package/core/persist/viewmanager/impl/ManageDialogModel.ts +274 -0
  69. package/core/persist/viewmanager/impl/SaveDialogModel.ts +112 -0
  70. package/core/persist/viewmanager/index.ts +2 -0
  71. package/desktop/cmp/button/ColAutosizeButton.ts +1 -1
  72. package/desktop/cmp/dash/DashConfig.ts +3 -1
  73. package/desktop/cmp/dash/DashModel.ts +1 -2
  74. package/desktop/cmp/dash/DashViewSpec.ts +1 -1
  75. package/desktop/cmp/dash/canvas/DashCanvasModel.ts +31 -30
  76. package/desktop/cmp/dash/container/DashContainerModel.ts +68 -43
  77. package/desktop/cmp/dash/container/impl/DashContainerUtils.ts +13 -4
  78. package/desktop/cmp/leftrightchooser/LeftRightChooserFilter.ts +1 -1
  79. package/desktop/cmp/panel/PanelModel.ts +33 -53
  80. package/desktop/cmp/store/impl/StoreFilterField.ts +1 -1
  81. package/desktop/cmp/viewmanager/ViewManager.scss +58 -0
  82. package/desktop/cmp/viewmanager/ViewManager.ts +274 -0
  83. package/desktop/cmp/viewmanager/cmp/ManageDialog.ts +197 -0
  84. package/desktop/cmp/viewmanager/cmp/SaveDialog.ts +89 -0
  85. package/desktop/cmp/viewmanager/index.ts +3 -0
  86. package/mobile/cmp/button/ColAutosizeButton.ts +1 -1
  87. package/package.json +1 -1
  88. package/promise/Promise.ts +6 -7
  89. package/svc/GridAutosizeService.ts +73 -36
  90. package/svc/JsonBlobService.ts +64 -31
  91. package/tsconfig.tsbuildinfo +1 -1
  92. package/build/types/cmp/grid/impl/GridPersistenceModel.d.ts +0 -41
  93. package/build/types/cmp/zoneGrid/impl/ZoneGridPersistenceModel.d.ts +0 -39
  94. package/cmp/grid/impl/GridPersistenceModel.ts +0 -174
  95. package/cmp/zoneGrid/impl/ZoneGridPersistenceModel.ts +0 -149
@@ -0,0 +1,515 @@
1
+ import {
2
+ HoistModel,
3
+ LoadSpec,
4
+ managed,
5
+ Persistable,
6
+ PersistableState,
7
+ PersistenceProvider,
8
+ PersistOptions,
9
+ PlainObject,
10
+ Thunkable,
11
+ ViewManagerProvider,
12
+ XH
13
+ } from '@xh/hoist/core';
14
+ import {genDisplayName} from '@xh/hoist/data';
15
+ import {action, bindable, computed, makeObservable, observable} from '@xh/hoist/mobx';
16
+ import {wait} from '@xh/hoist/promise';
17
+ import {executeIfFunction, pluralize, throwIf} from '@xh/hoist/utils/js';
18
+ import {isEmpty, isEqual, isNil, lowerCase, sortBy, startCase} from 'lodash';
19
+ import {runInAction} from 'mobx';
20
+ import {SaveDialogModel} from './impl/SaveDialogModel';
21
+ import {View, ViewTree} from './Types';
22
+
23
+ export interface ViewManagerConfig {
24
+ /**
25
+ * True (default) to allow user to opt-in to automatically saving changes to their private
26
+ * views - requires `persistWith`.
27
+ */
28
+ enableAutoSave?: boolean;
29
+ /**
30
+ * True (default) to allow the user to select a "Default" option that restores all persisted
31
+ * objects to their in-code defaults. If not enabled, at least one saved view should be created
32
+ * in advance, so that there is a clear initial selection for users without any private views.
33
+ */
34
+ enableDefault?: boolean;
35
+ /** True (default) to allow user to mark views as favorites. Requires `persistWith`. */
36
+ enableFavorites?: boolean;
37
+ /**
38
+ * True to allow the user to publish or edit globally shared views. Apps are expected to
39
+ * commonly set this based on user roles - e.g. `XH.getUser().hasRole('MANAGE_GRID_VIEWS')`.
40
+ */
41
+ enableSharing?: Thunkable<boolean>;
42
+ /** Used to persist the user's last selected + favorite views and autoSave preference. */
43
+ persistWith?: PersistOptions;
44
+ /**
45
+ * Required discriminator for the particular class of views to be loaded and managed by this
46
+ * model. Maps onto the `type` field of the persisted `JsonBlob`. Set to something descriptive
47
+ * and specific enough to be identifiable and allow for different viewManagers to be added
48
+ * to your app in the future - e.g. `portfolioGridView` or `tradeBlotterDashboard`.
49
+ */
50
+ viewType: string;
51
+ /**
52
+ * Optional user-facing display name for the view type, displayed in the ViewManager menu
53
+ * and associated management dialogs and prompts. Defaulted from `viewType` if not provided.
54
+ */
55
+ viewTypeDisplayName?: string;
56
+ }
57
+
58
+ /**
59
+ * ViewManagerModel coordinates the loading, saving, and management of user-defined bundles of
60
+ * {@link Persistable} component/model state.
61
+ *
62
+ * - Models to be persisted are bound to this model via their `persistWith` config. One or more
63
+ * models can be bound to a single ViewManagerModel, allowing a single view to capture the state
64
+ * of multiple components - e.g. grouping and filtering options along with grid state.
65
+ * - Views are persisted back to the server as JsonBlob objects.
66
+ * - Views can be private to their owner, or optionally enabled for sharing to (all) other users.
67
+ * - Views can be marked as favorites for quick access.
68
+ * - See the desktop {@link ViewManager} component - the initial Hoist UI for this model.
69
+ */
70
+ export class ViewManagerModel<T extends PlainObject = PlainObject>
71
+ extends HoistModel
72
+ implements Persistable<ViewManagerModelPersistState>
73
+ {
74
+ /**
75
+ * Factory to create new instances of this model and await its initial load before binding to
76
+ * any persistable component models. This ensures that bound models will have the expected
77
+ * initial persisted state applied within their constructor, before their components have
78
+ * rendered, and avoids thrashing of component state during initial load.
79
+ *
80
+ * To minimize the impact this async requirement has on the design and lifecycle of individual
81
+ * components within an app, consider eagerly constructing any viewManagerModels required within
82
+ * your `AppModel.initAsync` method and saving a reference to them there for component models
83
+ * to then use when they are mounted. The VM model instances will then be "ready to go" and
84
+ * usable within model constructors. (Initializing and referencing from one or more app
85
+ * services would be another, similar option.)
86
+ */
87
+ static async createAsync(config: ViewManagerConfig): Promise<ViewManagerModel> {
88
+ const ret = new ViewManagerModel(config);
89
+ await ret.loadAsync();
90
+ return ret;
91
+ }
92
+
93
+ /** Immutable configuration for this model. */
94
+ readonly viewType: string;
95
+ readonly displayName: string; // from viewTypeDisplayName, or generated off of viewType
96
+ readonly DisplayName: string; // capitalized viewTypeDisplayName
97
+ readonly enableDefault: boolean;
98
+ readonly enableAutoSave: boolean;
99
+ readonly enableFavorites: boolean;
100
+
101
+ /** Last selected, fully-persisted state of the active view. */
102
+ @observable.ref value: T = {} as T;
103
+ /** Current state of the active view, can include not-yet-persisted changes. */
104
+ @observable.ref pendingValue: T = {} as T;
105
+ /** Loaded saved view definitions - both private and shared. */
106
+ @observable.ref views: View<T>[] = [];
107
+ /** Token identifier for the currently selected view, or null if in default mode. */
108
+ @bindable selectedToken: string = null;
109
+ /** List of tokens for the user's favorite views. */
110
+ @bindable favorites: string[] = [];
111
+ /**
112
+ * True if user has opted-in to automatically saving changes to personal views (if auto-save
113
+ * generally available as per `enableAutoSave`).
114
+ */
115
+ @bindable autoSaveActive = false;
116
+
117
+ @observable manageDialogOpen = false;
118
+ @managed readonly saveDialogModel: SaveDialogModel;
119
+
120
+ private readonly _enableSharing: Thunkable<boolean>;
121
+
122
+ /**
123
+ * @internal array of {@link ViewManagerProvider} instances bound to this model. Providers will
124
+ * push themselves onto this array when constructed with a reference to this model. Used to
125
+ * proactively push state to the target components when the model's selected `value` changes.
126
+ */
127
+ providers: ViewManagerProvider<any>[] = [];
128
+
129
+ get enableSharing(): boolean {
130
+ return executeIfFunction(this._enableSharing);
131
+ }
132
+
133
+ get selectedView(): View<T> {
134
+ return this.views.find(it => it.token === this.selectedToken);
135
+ }
136
+
137
+ @computed
138
+ get isSharedViewSelected(): boolean {
139
+ return !!this.selectedView?.isShared;
140
+ }
141
+
142
+ @computed
143
+ get canSave(): boolean {
144
+ const {selectedView} = this;
145
+ return (
146
+ selectedView &&
147
+ this.isDirty &&
148
+ (this.enableSharing || !selectedView.isShared) &&
149
+ !this.loadModel.isPending
150
+ );
151
+ }
152
+
153
+ /**
154
+ * True if displaying the save button is appropriate from the model's point of view, even if
155
+ * that button might be disabled due to no changes having been made. Works in concert with the
156
+ * desktop ViewManager component's `showSaveButton` prop.
157
+ */
158
+ @computed
159
+ get canShowSaveButton(): boolean {
160
+ const {selectedView} = this;
161
+ return (
162
+ selectedView &&
163
+ (!this.enableAutoSave || !this.autoSaveActive) &&
164
+ (this.enableSharing || !selectedView.isShared)
165
+ );
166
+ }
167
+
168
+ @computed
169
+ get enableAutoSaveToggle(): boolean {
170
+ return this.selectedView && !this.isSharedViewSelected;
171
+ }
172
+
173
+ @computed
174
+ get disabledAutoSaveReason(): string {
175
+ const {displayName} = this;
176
+ if (!this.selectedView) return `Cannot auto-save default ${displayName}.`;
177
+ if (this.isSharedViewSelected) return `Cannot auto-save shared ${displayName}.`;
178
+ return null;
179
+ }
180
+
181
+ @computed
182
+ get isDirty(): boolean {
183
+ return !isEqual(this.pendingValue, this.value);
184
+ }
185
+
186
+ get isShared(): boolean {
187
+ return !!this.selectedView?.isShared;
188
+ }
189
+
190
+ get favoriteViews(): View<T>[] {
191
+ return this.views.filter(it => it.isFavorite);
192
+ }
193
+
194
+ get sharedViews(): View<T>[] {
195
+ return this.views.filter(it => it.isShared);
196
+ }
197
+
198
+ get privateViews(): View<T>[] {
199
+ return this.views.filter(it => !it.isShared);
200
+ }
201
+
202
+ get sharedViewTree(): ViewTree[] {
203
+ return this.buildViewTree(sortBy(this.sharedViews, 'name'));
204
+ }
205
+
206
+ get privateViewTree(): ViewTree[] {
207
+ return this.buildViewTree(sortBy(this.privateViews, 'name'));
208
+ }
209
+
210
+ /**
211
+ * Use the static {@link createAsync} factory to create an instance of this model and await its
212
+ * initial load before binding to persistable components.
213
+ */
214
+ private constructor({
215
+ viewType,
216
+ viewTypeDisplayName,
217
+ persistWith,
218
+ enableSharing = false,
219
+ enableDefault = true,
220
+ enableAutoSave = true,
221
+ enableFavorites = true
222
+ }: ViewManagerConfig) {
223
+ super();
224
+ makeObservable(this);
225
+
226
+ throwIf(!viewType, 'Missing required viewType in ViewManagerModel config.');
227
+ this.viewType = viewType;
228
+ this.displayName = lowerCase(viewTypeDisplayName ?? genDisplayName(viewType));
229
+ this.DisplayName = startCase(this.displayName);
230
+
231
+ this._enableSharing = enableSharing;
232
+ this.enableDefault = enableDefault;
233
+ this.enableAutoSave = enableAutoSave && !!persistWith;
234
+ this.enableFavorites = enableFavorites && !!persistWith;
235
+ this.saveDialogModel = new SaveDialogModel(this);
236
+
237
+ if (persistWith) {
238
+ PersistenceProvider.create({
239
+ persistOptions: {
240
+ path: 'viewManager',
241
+ ...persistWith
242
+ },
243
+ target: this
244
+ });
245
+ }
246
+
247
+ this.addReaction(
248
+ {
249
+ track: () => this.pendingValue,
250
+ run: () => this.maybeAutoSaveAsync({skipToast: true})
251
+ },
252
+ {
253
+ track: () => this.autoSaveActive,
254
+ run: () => this.maybeAutoSaveAsync({skipToast: false})
255
+ },
256
+ {
257
+ track: () => this.favorites,
258
+ run: () => this.onFavoritesChange()
259
+ }
260
+ );
261
+ }
262
+
263
+ override async doLoadAsync(loadSpec: LoadSpec) {
264
+ const rawViews = await XH.jsonBlobService.listAsync({
265
+ type: this.viewType,
266
+ includeValue: true,
267
+ loadSpec
268
+ });
269
+ if (loadSpec.isStale) return;
270
+
271
+ runInAction(() => (this.views = this.processRaw(rawViews)));
272
+
273
+ const token =
274
+ loadSpec.meta.selectToken ??
275
+ this.selectedView?.token ??
276
+ (this.enableDefault ? null : this.views[0]?.token);
277
+ await this.selectViewAsync(token);
278
+ }
279
+
280
+ async selectViewAsync(token: string) {
281
+ // TODO - review if we benefit from async + masking - eg during intensive
282
+ // component rebuild within setValue?
283
+ await wait();
284
+
285
+ this.selectedToken = token;
286
+
287
+ // Allow this model to restore its own persisted state in its ctor and note the desired
288
+ // selected token before views have been loaded. Once views are loaded, this method will
289
+ // be called again with the desired token and will proceed to set the value.
290
+ if (isEmpty(this.views)) return;
291
+
292
+ this.setValue(this.selectedView?.value ?? ({} as T));
293
+ }
294
+
295
+ async saveAsync(skipToast: boolean = false) {
296
+ const {canSave, selectedToken, pendingValue, isSharedViewSelected, DisplayName} = this;
297
+ throwIf(!canSave, 'Unable to save view at this time.'); // sanity check - user should not reach
298
+
299
+ if (isSharedViewSelected) {
300
+ if (!(await this.confirmSaveForSharedViewAsync())) return;
301
+ }
302
+
303
+ try {
304
+ await XH.jsonBlobService.updateAsync(selectedToken, {value: pendingValue});
305
+ } catch (e) {
306
+ XH.handleException(e, {alertType: 'toast'});
307
+ skipToast = true; // don't show the success toast below, but still refresh.
308
+ }
309
+
310
+ await this.refreshAsync({selectToken: selectedToken});
311
+ if (!skipToast) XH.successToast(`${DisplayName} successfully saved.`);
312
+ }
313
+
314
+ async saveAsAsync() {
315
+ const {selectedView, views, DisplayName} = this,
316
+ {name, description} = selectedView ?? {};
317
+
318
+ const newView = await this.saveDialogModel.openAsync(
319
+ {
320
+ name,
321
+ description,
322
+ value: this.pendingValue
323
+ },
324
+ views.map(it => it.name)
325
+ );
326
+
327
+ if (newView) {
328
+ await this.refreshAsync({selectToken: newView.token});
329
+ XH.successToast(`${DisplayName} successfully saved.`);
330
+ }
331
+ }
332
+
333
+ async resetAsync() {
334
+ return this.selectViewAsync(this.selectedView?.token);
335
+ }
336
+
337
+ @action
338
+ setPendingValue(pendingValue: T) {
339
+ pendingValue = this.cleanValue(pendingValue);
340
+ if (!isEqual(pendingValue, this.pendingValue)) {
341
+ this.pendingValue = pendingValue;
342
+ }
343
+ }
344
+
345
+ @action
346
+ openManageDialog() {
347
+ this.manageDialogOpen = true;
348
+ }
349
+
350
+ @action
351
+ closeManageDialog() {
352
+ this.manageDialogOpen = false;
353
+ }
354
+
355
+ getHierarchyDisplayName(name: string) {
356
+ return name?.substring(name.lastIndexOf('\\') + 1);
357
+ }
358
+
359
+ //------------------
360
+ // Favorites
361
+ //------------------
362
+ toggleFavorite(token: string) {
363
+ this.isFavorite(token) ? this.removeFavorite(token) : this.addFavorite(token);
364
+ }
365
+
366
+ addFavorite(token: string) {
367
+ this.favorites = [...this.favorites, token];
368
+ }
369
+
370
+ removeFavorite(token: string) {
371
+ this.favorites = this.favorites.filter(it => it !== token);
372
+ }
373
+
374
+ isFavorite(token: string) {
375
+ return this.favorites.includes(token);
376
+ }
377
+
378
+ //------------------
379
+ // Persistable
380
+ //------------------
381
+ getPersistableState(): PersistableState<ViewManagerModelPersistState> {
382
+ return new PersistableState({selectedToken: this.selectedToken, favorites: this.favorites});
383
+ }
384
+
385
+ setPersistableState(state: PersistableState<ViewManagerModelPersistState>) {
386
+ const {selectedToken, favorites} = state.value;
387
+ if (selectedToken) this.selectViewAsync(selectedToken);
388
+ if (favorites) this.favorites = favorites;
389
+ }
390
+
391
+ //------------------
392
+ // Implementation
393
+ //------------------
394
+ private processRaw(raw: PlainObject[]): View<T>[] {
395
+ const name = pluralize(this.DisplayName);
396
+ return raw.map(it => {
397
+ const isShared = it.acl === '*';
398
+ return {
399
+ ...it,
400
+ isShared,
401
+ group: isShared ? `Shared ${name}` : `My ${name}`,
402
+ isFavorite: this.isFavorite(it.token)
403
+ } as View<T>;
404
+ });
405
+ }
406
+
407
+ @action
408
+ private setValue(value: T) {
409
+ value = this.cleanValue(value);
410
+ if (isEqual(value, this.value) && isEqual(value, this.pendingValue)) return;
411
+
412
+ this.value = value;
413
+ this.pendingValue = value;
414
+ this.providers.forEach(it => it.pushStateToTarget());
415
+ }
416
+
417
+ // Stringify and parse to ensure that any value set here is valid, serializable JSON.
418
+ private cleanValue(value: T): T {
419
+ if (isNil(value)) value = {} as T;
420
+ return JSON.parse(JSON.stringify(value));
421
+ }
422
+
423
+ private async confirmSaveForSharedViewAsync() {
424
+ return XH.confirm({
425
+ message: `You are saving a shared public ${this.displayName}. Do you wish to continue?`,
426
+ confirmProps: {
427
+ text: 'Yes, save changes',
428
+ intent: 'primary',
429
+ outlined: true
430
+ },
431
+ cancelProps: {
432
+ text: 'Cancel',
433
+ autoFocus: true
434
+ }
435
+ });
436
+ }
437
+
438
+ private async maybeAutoSaveAsync({skipToast}: {skipToast: boolean}) {
439
+ if (
440
+ this.enableAutoSave &&
441
+ this.autoSaveActive &&
442
+ this.canSave &&
443
+ !this.isSharedViewSelected
444
+ ) {
445
+ await this.saveAsync(skipToast);
446
+ }
447
+ }
448
+
449
+ private buildViewTree(views: View<T>[], depth: number = 0): ViewTree[] {
450
+ const groups = {},
451
+ unbalancedStableGroupsAndViews = [];
452
+
453
+ views.forEach(view => {
454
+ // Leaf Node
455
+ if (this.getNameHierarchySubstring(view.name, depth + 1) == null) {
456
+ unbalancedStableGroupsAndViews.push(view);
457
+ return;
458
+ }
459
+ // Belongs to an already defined group
460
+ const group = this.getNameHierarchySubstring(view.name, depth);
461
+ if (groups[group]) {
462
+ groups[group].children.push(view);
463
+ return;
464
+ }
465
+ // Belongs to a not defined group, create it
466
+ groups[group] = {name: group, children: [view], isMenuFolder: true};
467
+ unbalancedStableGroupsAndViews.push(groups[group]);
468
+ });
469
+
470
+ return unbalancedStableGroupsAndViews.map(it => {
471
+ const {name, isMenuFolder, children, description, token} = it;
472
+ if (isMenuFolder) {
473
+ return {
474
+ type: 'folder',
475
+ text: name,
476
+ items: this.buildViewTree(children, depth + 1),
477
+ selected: this.isFolderForEntry(name, this.selectedView?.name, depth)
478
+ };
479
+ }
480
+ return {
481
+ type: 'view',
482
+ text: this.getHierarchyDisplayName(name),
483
+ selected: this.selectedToken === token,
484
+ token,
485
+ description
486
+ };
487
+ });
488
+ }
489
+
490
+ private getNameHierarchySubstring(name: string, depth: number) {
491
+ const arr = name?.split('\\') ?? [];
492
+ if (arr.length <= depth) {
493
+ return null;
494
+ }
495
+ return arr.slice(0, depth + 1).join('\\');
496
+ }
497
+
498
+ private isFolderForEntry(folderName: string, entryName: string, depth: number) {
499
+ const name = this.getNameHierarchySubstring(entryName, depth);
500
+ return name && name === folderName && folderName.length < entryName.length;
501
+ }
502
+
503
+ // Update flag on each view, replacing entire views collection for observability.
504
+ private onFavoritesChange() {
505
+ this.views = this.views.map(view => ({
506
+ ...view,
507
+ isFavorite: this.isFavorite(view.token)
508
+ }));
509
+ }
510
+ }
511
+
512
+ interface ViewManagerModelPersistState {
513
+ selectedToken: string;
514
+ favorites: string[];
515
+ }
@@ -0,0 +1,51 @@
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 {throwIf} from '@xh/hoist/utils/js';
9
+ import {pull} from 'lodash';
10
+ import {PersistenceProvider, PersistenceProviderConfig} from '../index';
11
+ import type {ViewManagerModel} from './index';
12
+
13
+ export class ViewManagerProvider<S> extends PersistenceProvider<S> {
14
+ readonly viewManagerModel: ViewManagerModel;
15
+
16
+ constructor(cfg: PersistenceProviderConfig<S>) {
17
+ super(cfg);
18
+ const {viewManagerModel} = cfg.persistOptions;
19
+ throwIf(!viewManagerModel, `ViewManagerProvider requires a 'viewManagerModel'.`);
20
+ this.viewManagerModel = viewManagerModel;
21
+ viewManagerModel.providers.push(this);
22
+ }
23
+
24
+ pushStateToTarget() {
25
+ const state = this.read();
26
+ if (state) {
27
+ this.target.setPersistableState(state);
28
+ } else {
29
+ this.target.setPersistableState(this.defaultState);
30
+ }
31
+ }
32
+
33
+ //----------------
34
+ // Implementation
35
+ //----------------
36
+ override readRaw() {
37
+ return this.viewManagerModel.pendingValue;
38
+ }
39
+
40
+ override writeRaw(data: Record<typeof this.path, S>) {
41
+ this.viewManagerModel.setPendingValue(data);
42
+ }
43
+
44
+ override destroy() {
45
+ if (this.viewManagerModel) {
46
+ pull(this.viewManagerModel.providers, this);
47
+ }
48
+
49
+ super.destroy();
50
+ }
51
+ }