@xh/hoist 71.0.0-SNAPSHOT.1733854822950 → 71.0.0-SNAPSHOT.1734551243081

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 (42) hide show
  1. package/CHANGELOG.md +1 -0
  2. package/build/types/cmp/viewmanager/View.d.ts +5 -0
  3. package/build/types/cmp/viewmanager/ViewInfo.d.ts +32 -7
  4. package/build/types/cmp/viewmanager/ViewManagerModel.d.ts +41 -31
  5. package/build/types/cmp/viewmanager/ViewToBlobApi.d.ts +28 -7
  6. package/build/types/cmp/viewmanager/index.d.ts +1 -1
  7. package/build/types/desktop/cmp/viewmanager/ViewManager.d.ts +0 -4
  8. package/build/types/desktop/cmp/viewmanager/ViewManagerLocalModel.d.ts +11 -0
  9. package/build/types/desktop/cmp/viewmanager/ViewMenu.d.ts +2 -2
  10. package/build/types/desktop/cmp/viewmanager/dialog/ManageDialog.d.ts +3 -1
  11. package/build/types/desktop/cmp/viewmanager/dialog/ManageDialogModel.d.ts +18 -13
  12. package/build/types/desktop/cmp/viewmanager/dialog/SaveAsDialog.d.ts +1 -1
  13. package/build/types/{cmp/viewmanager → desktop/cmp/viewmanager/dialog}/SaveAsDialogModel.d.ts +3 -9
  14. package/build/types/desktop/cmp/viewmanager/dialog/Utils.d.ts +3 -0
  15. package/build/types/desktop/cmp/viewmanager/dialog/ViewMultiPanel.d.ts +2 -0
  16. package/build/types/desktop/cmp/viewmanager/dialog/ViewPanel.d.ts +5 -0
  17. package/build/types/desktop/cmp/viewmanager/dialog/{EditFormModel.d.ts → ViewPanelModel.d.ts} +2 -4
  18. package/build/types/svc/JsonBlobService.d.ts +1 -1
  19. package/cmp/viewmanager/View.ts +21 -1
  20. package/cmp/viewmanager/ViewInfo.ts +58 -11
  21. package/cmp/viewmanager/ViewManagerModel.ts +109 -82
  22. package/cmp/viewmanager/ViewToBlobApi.ts +114 -42
  23. package/cmp/viewmanager/index.ts +1 -1
  24. package/desktop/cmp/viewmanager/ViewManager.scss +25 -28
  25. package/desktop/cmp/viewmanager/ViewManager.ts +32 -27
  26. package/desktop/cmp/viewmanager/ViewManagerLocalModel.ts +33 -0
  27. package/desktop/cmp/viewmanager/ViewMenu.ts +162 -169
  28. package/desktop/cmp/viewmanager/dialog/ManageDialog.ts +69 -42
  29. package/desktop/cmp/viewmanager/dialog/ManageDialogModel.ts +267 -150
  30. package/desktop/cmp/viewmanager/dialog/SaveAsDialog.ts +30 -9
  31. package/{cmp/viewmanager → desktop/cmp/viewmanager/dialog}/SaveAsDialogModel.ts +35 -40
  32. package/desktop/cmp/viewmanager/dialog/Utils.ts +18 -0
  33. package/desktop/cmp/viewmanager/dialog/ViewMultiPanel.ts +70 -0
  34. package/desktop/cmp/viewmanager/dialog/ViewPanel.ts +166 -0
  35. package/desktop/cmp/viewmanager/dialog/ViewPanelModel.ts +116 -0
  36. package/package.json +1 -1
  37. package/svc/JsonBlobService.ts +3 -3
  38. package/svc/storage/BaseStorageService.ts +1 -1
  39. package/tsconfig.tsbuildinfo +1 -1
  40. package/build/types/desktop/cmp/viewmanager/dialog/EditForm.d.ts +0 -5
  41. package/desktop/cmp/viewmanager/dialog/EditForm.ts +0 -126
  42. package/desktop/cmp/viewmanager/dialog/EditFormModel.ts +0 -125
@@ -10,7 +10,6 @@ import {
10
10
  ExceptionHandlerOptions,
11
11
  HoistModel,
12
12
  LoadSpec,
13
- managed,
14
13
  PersistableState,
15
14
  PersistenceProvider,
16
15
  PersistOptions,
@@ -22,15 +21,15 @@ import {
22
21
  import type {ViewManagerProvider} from '@xh/hoist/core';
23
22
  import {genDisplayName} from '@xh/hoist/data';
24
23
  import {fmtDateTime} from '@xh/hoist/format';
25
- import {action, bindable, makeObservable, observable, runInAction, when} from '@xh/hoist/mobx';
24
+ import {action, bindable, makeObservable, observable, when} from '@xh/hoist/mobx';
26
25
  import {olderThan, SECONDS} from '@xh/hoist/utils/datetime';
27
26
  import {executeIfFunction, pluralize, throwIf} from '@xh/hoist/utils/js';
28
- import {find, isEqual, isNil, isObject, lowerCase, without} from 'lodash';
27
+ import {find, isEmpty, isEqual, isNil, isObject, lowerCase, pickBy} from 'lodash';
28
+ import {runInAction} from 'mobx';
29
29
  import {ReactNode} from 'react';
30
- import {SaveAsDialogModel} from './SaveAsDialogModel';
31
30
  import {ViewInfo} from './ViewInfo';
32
31
  import {View} from './View';
33
- import {ViewToBlobApi} from './ViewToBlobApi';
32
+ import {ViewToBlobApi, ViewCreateSpec} from './ViewToBlobApi';
34
33
 
35
34
  export interface ViewManagerConfig {
36
35
  /**
@@ -45,8 +44,16 @@ export interface ViewManagerConfig {
45
44
  */
46
45
  enableDefault?: boolean;
47
46
 
48
- /** True (default) to allow user to mark views as favorites. Requires `persistWith`. */
49
- enableFavorites?: boolean;
47
+ /**
48
+ * True (default) to enable "global" views - i.e. views that are not owned by a user and are
49
+ * available to all.
50
+ */
51
+ enableGlobal?: boolean;
52
+
53
+ /**
54
+ * True (default) to allow users to share their views with other users.
55
+ */
56
+ enableSharing?: boolean;
50
57
 
51
58
  /**
52
59
  * Function to determine the initial view for a user, when no view has already been persisted.
@@ -59,11 +66,10 @@ export interface ViewManagerConfig {
59
66
  initialViewSpec?: (views: ViewInfo[]) => ViewInfo;
60
67
 
61
68
  /**
62
- * Delay after state has been set on associated components before they will be observed for
63
- * any further state changes. Larger values may be useful when providing state to complex
64
- * components such as dashboards or grids that may create dirty state immediately after load.
65
- *
66
- * Specified in milliseconds. Default is 250.
69
+ * Delay (in ms) to wait after state has been set on associated components before listening for
70
+ * further state changes. The long default wait 1000ms is intended to avoid a false positive
71
+ * dirty indicator when linking to complex components such as dashboards or grids that can
72
+ * report immediate changes to state due to internal processing or rendering.
67
73
  */
68
74
  settleTime?: number;
69
75
 
@@ -97,8 +103,8 @@ export interface ViewManagerConfig {
97
103
  }
98
104
 
99
105
  export interface ViewManagerPersistOptions extends PersistOptions {
100
- /** True to persist favorites or provide specific PersistOptions. (Default true) */
101
- persistFavorites?: boolean | PersistOptions;
106
+ /** True to persist pinning preferences or provide specific PersistOptions. (Default true) */
107
+ persistPinning?: boolean | PersistOptions;
102
108
 
103
109
  /** True to include pending value or provide specific PersistOptions. (Default false) */
104
110
  persistPendingValue?: boolean | PersistOptions;
@@ -112,8 +118,8 @@ export interface ViewManagerPersistOptions extends PersistOptions {
112
118
  * models can be bound to a single ViewManagerModel, allowing a single view to capture the state
113
119
  * of multiple components - e.g. grouping and filtering options along with grid state.
114
120
  * - Views are persisted back to the server as JsonBlob objects.
115
- * - Views can be private to their owner, or optionally enabled for global use by (all) other users.
116
- * - Views can be marked as favorites for quick access.
121
+ * - Views can be private to their owner, or optionally enabled for sharing to (all) other users.
122
+ * - Views can be marked as pinned for quick access.
117
123
  * - See the desktop {@link ViewManager} component - the initial Hoist UI for this model.
118
124
  */
119
125
  export class ViewManagerModel<T = PlainObject> extends HoistModel {
@@ -146,7 +152,8 @@ export class ViewManagerModel<T = PlainObject> extends HoistModel {
146
152
  readonly globalDisplayName: string;
147
153
  readonly enableAutoSave: boolean;
148
154
  readonly enableDefault: boolean;
149
- readonly enableFavorites: boolean;
155
+ readonly enableGlobal: boolean;
156
+ readonly enableSharing: boolean;
150
157
  readonly manageGlobal: boolean;
151
158
  readonly settleTime: number;
152
159
  readonly initialViewSpec: (views: ViewInfo[]) => ViewInfo;
@@ -155,8 +162,14 @@ export class ViewManagerModel<T = PlainObject> extends HoistModel {
155
162
  @observable.ref view: View<T> = null;
156
163
  /** Loaded saved view library - both private and global */
157
164
  @observable.ref views: ViewInfo[] = [];
158
- /** List of tokens for the user's favorite views. */
159
- @observable.ref favorites: string[] = [];
165
+
166
+ /**
167
+ * Map of user's preferred pinned state for views.
168
+ *
169
+ * Note that the actual pinned state for the views is determined by this value, layered
170
+ * over the default state of the views themselves.
171
+ */
172
+ @observable.ref userPinned: Record<string, boolean> = {};
160
173
 
161
174
  /**
162
175
  * True if user has opted-in to automatically saving changes to personal views (if auto-save
@@ -170,14 +183,9 @@ export class ViewManagerModel<T = PlainObject> extends HoistModel {
170
183
  */
171
184
  selectTask: TaskObserver;
172
185
 
173
- /**
174
- * TaskObserver linked to {@link saveAsync}.
175
- */
186
+ /** TaskObserver linked to {@link saveAsync}. */
176
187
  saveTask: TaskObserver;
177
188
 
178
- @observable manageDialogOpen = false;
179
- @managed saveAsDialogModel: SaveAsDialogModel;
180
-
181
189
  //-----------------------
182
190
  // Private, internal state.
183
191
  //-------------------------
@@ -194,7 +202,7 @@ export class ViewManagerModel<T = PlainObject> extends HoistModel {
194
202
  providers: ViewManagerProvider<any>[] = [];
195
203
 
196
204
  /**
197
- * Data access for persisting views
205
+ * Data access for persisting views.
198
206
  * @internal
199
207
  */
200
208
  api: ViewToBlobApi<T>;
@@ -211,32 +219,41 @@ export class ViewManagerModel<T = PlainObject> extends HoistModel {
211
219
 
212
220
  get isViewSavable(): boolean {
213
221
  const {view, manageGlobal} = this;
214
- return !view.isDefault && (manageGlobal || !view.isGlobal);
222
+ return view.isOwned || (view.isGlobal && manageGlobal);
215
223
  }
216
224
 
217
225
  get isViewAutoSavable(): boolean {
218
226
  const {enableAutoSave, autoSave, view} = this;
219
- return enableAutoSave && autoSave && !view.isGlobal && !view.isDefault;
227
+ return enableAutoSave && autoSave && view.isOwned && !XH.identityService.isImpersonating;
220
228
  }
221
229
 
222
230
  get autoSaveUnavailableReason(): string {
223
231
  const {view, isViewAutoSavable, typeDisplayName, globalDisplayName} = this;
224
232
  if (isViewAutoSavable) return null;
225
233
  if (view.isGlobal) return `Cannot auto-save ${globalDisplayName} ${typeDisplayName}.`;
234
+ if (view.isShared) return `Cannot auto-save shared ${typeDisplayName}.`;
226
235
  if (view.isDefault) return `Cannot auto-save default ${typeDisplayName}.`;
236
+ if (XH.identityService.isImpersonating) return `Auto-save disabled during impersonation.`;
227
237
  return null;
228
238
  }
229
239
 
230
- get favoriteViews(): ViewInfo[] {
231
- return this.views.filter(it => it.isFavorite);
240
+ get pinnedViews(): ViewInfo[] {
241
+ return this.views.filter(it => it.isPinned);
232
242
  }
233
243
 
234
- get globalViews(): ViewInfo[] {
235
- return this.views.filter(it => it.isGlobal);
244
+ /** Views owned by me */
245
+ get ownedViews(): ViewInfo[] {
246
+ return this.views.filter(it => it.isOwned);
236
247
  }
237
248
 
238
- get privateViews(): ViewInfo[] {
239
- return this.views.filter(it => !it.isGlobal);
249
+ /** Views shared *with* me */
250
+ get sharedViews(): ViewInfo[] {
251
+ return this.views.filter(it => it.isShared && !it.isOwned);
252
+ }
253
+
254
+ /** Global views */
255
+ get globalViews(): ViewInfo[] {
256
+ return this.views.filter(it => it.isGlobal);
240
257
  }
241
258
 
242
259
  /** True if any async tasks are pending. */
@@ -257,8 +274,9 @@ export class ViewManagerModel<T = PlainObject> extends HoistModel {
257
274
  manageGlobal = false,
258
275
  enableAutoSave = true,
259
276
  enableDefault = true,
260
- settleTime = 250,
261
- enableFavorites = true,
277
+ enableGlobal = true,
278
+ enableSharing = true,
279
+ settleTime = 1000,
262
280
  initialViewSpec = null
263
281
  }: ViewManagerConfig) {
264
282
  super();
@@ -275,8 +293,9 @@ export class ViewManagerModel<T = PlainObject> extends HoistModel {
275
293
  this.persistWith = persistWith;
276
294
  this.manageGlobal = executeIfFunction(manageGlobal) ?? false;
277
295
  this.enableDefault = enableDefault;
296
+ this.enableGlobal = enableGlobal;
297
+ this.enableSharing = enableSharing;
278
298
  this.enableAutoSave = enableAutoSave;
279
- this.enableFavorites = enableFavorites;
280
299
  this.settleTime = settleTime;
281
300
  this.initialViewSpec = initialViewSpec;
282
301
 
@@ -287,7 +306,6 @@ export class ViewManagerModel<T = PlainObject> extends HoistModel {
287
306
  message: `Saving ${this.typeDisplayName}...`
288
307
  });
289
308
 
290
- this.saveAsDialogModel = new SaveAsDialogModel(this);
291
309
  this.api = new ViewToBlobApi(this);
292
310
  }
293
311
 
@@ -322,6 +340,14 @@ export class ViewManagerModel<T = PlainObject> extends HoistModel {
322
340
  await this.loadViewAsync(info).catch(e => this.handleException(e));
323
341
  }
324
342
 
343
+ async saveAsAsync(spec: ViewCreateSpec): Promise<void> {
344
+ const view = await this.api.createViewAsync({...spec, value: this.getValue()});
345
+ this.noteSuccess(`Created ${view.typedName}`);
346
+ this.userPin(view.info);
347
+ this.setAsView(view);
348
+ this.refreshAsync();
349
+ }
350
+
325
351
  //------------------------
326
352
  // Saving/resetting
327
353
  //------------------------
@@ -349,15 +375,6 @@ export class ViewManagerModel<T = PlainObject> extends HoistModel {
349
375
  this.refreshAsync();
350
376
  }
351
377
 
352
- async saveAsAsync(): Promise<void> {
353
- const view = (await this.saveAsDialogModel.openAsync()) as View<T>;
354
- if (view) {
355
- this.setAsView(view);
356
- this.noteSuccess(`Saved ${view.typedName}`);
357
- }
358
- this.refreshAsync();
359
- }
360
-
361
378
  async resetAsync(): Promise<void> {
362
379
  await this.loadViewAsync(this.view.info).catch(e => this.handleException(e));
363
380
  }
@@ -389,40 +406,29 @@ export class ViewManagerModel<T = PlainObject> extends HoistModel {
389
406
  }
390
407
 
391
408
  //------------------
392
- // Favorites
409
+ // Pinning
393
410
  //------------------
394
- toggleFavorite(token: string) {
395
- this.isFavorite(token) ? this.removeFavorite(token) : this.addFavorite(token);
411
+ togglePinned(view: ViewInfo) {
412
+ view.isPinned ? this.userUnpin(view) : this.userPin(view);
396
413
  }
397
414
 
398
415
  @action
399
- addFavorite(token: string) {
400
- this.favorites = [...this.favorites, token];
416
+ userPin(view: ViewInfo) {
417
+ this.userPinned = {...this.userPinned, [view.token]: true};
401
418
  }
402
419
 
403
420
  @action
404
- removeFavorite(token: string) {
405
- this.favorites = without(this.favorites, token);
421
+ userUnpin(view: ViewInfo) {
422
+ this.userPinned = {...this.userPinned, [view.token]: false};
406
423
  }
407
424
 
408
- isFavorite(token: string) {
409
- return this.favorites.includes(token);
425
+ isUserPinned(view: ViewInfo): boolean | null {
426
+ return this.userPinned[view.token];
410
427
  }
411
428
 
412
429
  //-----------------
413
430
  // Management
414
431
  //-----------------
415
- @action
416
- openManageDialog() {
417
- this.manageDialogOpen = true;
418
- this.refreshAsync();
419
- }
420
-
421
- @action
422
- closeManageDialog() {
423
- this.manageDialogOpen = false;
424
- }
425
-
426
432
  async validateViewNameAsync(name: string, existing: ViewInfo = null): Promise<string> {
427
433
  const maxLength = 50;
428
434
  name = name?.trim();
@@ -430,12 +436,30 @@ export class ViewManagerModel<T = PlainObject> extends HoistModel {
430
436
  if (name.length > maxLength) {
431
437
  return `Name cannot be longer than ${maxLength} characters`;
432
438
  }
433
- if (this.views.some(view => view.name === name && view.token != existing?.token)) {
434
- return `A ${this.typeDisplayName} with name '${name}' already exists`;
439
+ if (this.ownedViews.some(view => view.name === name && view.token != existing?.token)) {
440
+ return `A ${this.typeDisplayName} with name '${name}' already exists.`;
435
441
  }
436
442
  return null;
437
443
  }
438
444
 
445
+ async deleteViewsAsync(toDelete: ViewInfo[]): Promise<void> {
446
+ let exception;
447
+ try {
448
+ await this.api.deleteViewsAsync(toDelete);
449
+ } catch (e) {
450
+ exception = e;
451
+ }
452
+
453
+ await this.refreshAsync();
454
+ const {views} = this;
455
+
456
+ if (toDelete.some(view => view.isCurrentView) && !views.some(view => view.isCurrentView)) {
457
+ await this.loadViewAsync(this.initialViewSpec?.(views));
458
+ }
459
+
460
+ if (exception) throw exception;
461
+ }
462
+
439
463
  //------------------
440
464
  // Implementation
441
465
  //------------------
@@ -555,15 +579,15 @@ export class ViewManagerModel<T = PlainObject> extends HoistModel {
555
579
  if (isGlobal) {
556
580
  msgs.push(
557
581
  span(
558
- `This is a ${globalDisplayName} ${typeDisplayName}.`,
559
- strong('Changes will be visible to ALL users.')
582
+ `This is a ${globalDisplayName} ${typeDisplayName}. `,
583
+ strong('Changes will be visible to all users.')
560
584
  )
561
585
  );
562
586
  }
563
587
  if (isStale) {
564
588
  msgs.push(
565
589
  span(
566
- `This ${typeDisplayName} was updated by ${latestInfo.lastUpdatedBy} on ${fmtDateTime(latestInfo.lastUpdated)}.`,
590
+ `This ${typeDisplayName} was updated by ${latestInfo.lastUpdatedBy} on ${fmtDateTime(latestInfo.lastUpdated)}. `,
567
591
  strong('Your change may override those changes.')
568
592
  )
569
593
  );
@@ -574,11 +598,11 @@ export class ViewManagerModel<T = PlainObject> extends HoistModel {
574
598
  confirmProps: {
575
599
  text: 'Yes, save changes',
576
600
  intent: 'primary',
577
- outlined: true
601
+ outlined: true,
602
+ autoFocus: false
578
603
  },
579
604
  cancelProps: {
580
- text: 'Cancel',
581
- autoFocus: true
605
+ text: 'Cancel'
582
606
  }
583
607
  });
584
608
  }
@@ -588,21 +612,24 @@ export class ViewManagerModel<T = PlainObject> extends HoistModel {
588
612
  //------------------
589
613
  private initPersist(options: ViewManagerPersistOptions) {
590
614
  const {
591
- persistFavorites = true,
615
+ persistPinning = true,
592
616
  persistPendingValue = false,
593
617
  path = 'viewManager',
594
618
  ...rootPersistWith
595
619
  } = options;
596
620
 
597
- // Favorites, potentially in dedicated location
598
- if (this.enableFavorites && persistFavorites) {
599
- const opts = isObject(persistFavorites) ? persistFavorites : rootPersistWith;
621
+ // Pinning potentially in dedicated location
622
+ if (persistPinning) {
623
+ const opts = isObject(persistPinning) ? persistPinning : rootPersistWith;
600
624
  PersistenceProvider.create({
601
- persistOptions: {path: `${path}.favorites`, ...opts},
625
+ persistOptions: {path: `${path}.pinning`, ...opts},
602
626
  target: {
603
- getPersistableState: () => new PersistableState(this.favorites),
627
+ getPersistableState: () => new PersistableState(this.userPinned),
604
628
  setPersistableState: ({value}) => {
605
- this.favorites = value.filter(tkn => this.views.some(v => v.token === tkn));
629
+ const {views} = this;
630
+ this.userPinned = !isEmpty(views) // Clean state iff views loaded!
631
+ ? pickBy(value, (_, tkn) => views.some(v => v.token === tkn))
632
+ : value;
606
633
  }
607
634
  },
608
635
  owner: this
@@ -6,85 +6,114 @@
6
6
  */
7
7
 
8
8
  import {PlainObject, XH} from '@xh/hoist/core';
9
- import {pluralize} from '@xh/hoist/utils/js';
9
+ import {pluralize, throwIf} from '@xh/hoist/utils/js';
10
+ import {isEmpty, omit, pick} from 'lodash';
10
11
  import {ViewInfo} from './ViewInfo';
11
12
  import {View} from './View';
12
13
  import {ViewManagerModel} from './ViewManagerModel';
13
14
 
15
+ export interface ViewCreateSpec {
16
+ name: string;
17
+ group: string;
18
+ description: string;
19
+ isShared: boolean;
20
+ value?: PlainObject;
21
+ }
22
+
23
+ export interface ViewUpdateSpec {
24
+ name: string;
25
+ group: string;
26
+ description: string;
27
+ isShared?: boolean;
28
+ isDefaultPinned?: boolean;
29
+ }
30
+
14
31
  /**
15
- * Class for accessing and updating views using JSON Blobs Service.
16
- *
32
+ * Class for accessing and updating views using {@link JsonBlobService}.
17
33
  * @internal
18
34
  */
19
35
  export class ViewToBlobApi<T> {
20
- private owner: ViewManagerModel<T>;
36
+ private readonly model: ViewManagerModel<T>;
21
37
 
22
- constructor(owner: ViewManagerModel<T>) {
23
- this.owner = owner;
38
+ constructor(model: ViewManagerModel<T>) {
39
+ this.model = model;
24
40
  }
25
41
 
26
42
  //---------------
27
43
  // Load/search.
28
44
  //---------------
45
+ /** Fetch metadata for all views accessible by current user. */
29
46
  async fetchViewInfosAsync(): Promise<ViewInfo[]> {
30
- const {owner} = this;
47
+ const {model} = this;
31
48
  try {
32
49
  const blobs = await XH.jsonBlobService.listAsync({
33
- type: owner.type,
50
+ type: model.type,
34
51
  includeValue: false
35
52
  });
36
- return blobs.map(b => new ViewInfo(b, owner));
53
+ return blobs
54
+ .map(b => new ViewInfo(b, model))
55
+ .filter(
56
+ view =>
57
+ (model.enableGlobal || !view.isGlobal) &&
58
+ (model.enableSharing || !view.isShared)
59
+ );
37
60
  } catch (e) {
38
61
  throw XH.exception({
39
- message: `Unable to fetch ${pluralize(owner.typeDisplayName)}`,
62
+ message: `Unable to fetch ${pluralize(model.typeDisplayName)}`,
40
63
  cause: e
41
64
  });
42
65
  }
43
66
  }
44
67
 
68
+ /** Fetch the latest version of a view. */
45
69
  async fetchViewAsync(info: ViewInfo): Promise<View<T>> {
46
- if (!info) return View.createDefault(this.owner);
70
+ const {model} = this;
71
+ if (!info) return View.createDefault(model);
47
72
  try {
48
73
  const blob = await XH.jsonBlobService.getAsync(info.token);
49
- return View.fromBlob(blob, this.owner);
74
+ return View.fromBlob(blob, model);
50
75
  } catch (e) {
51
76
  throw XH.exception({message: `Unable to fetch ${info.typedName}`, cause: e});
52
77
  }
53
78
  }
54
79
 
55
80
  //-----------------
56
- // Crud
81
+ // CRUD
57
82
  //-----------------
58
- async createViewAsync(name: string, description: string, value: PlainObject): Promise<View<T>> {
59
- const {owner} = this;
83
+ /** Create a new view, owned by the current user.*/
84
+ async createViewAsync(spec: ViewCreateSpec): Promise<View<T>> {
85
+ const {model} = this;
60
86
  try {
61
87
  const blob = await XH.jsonBlobService.createAsync({
62
- type: owner.type,
63
- name: name.trim(),
64
- description: description?.trim(),
65
- value
88
+ type: model.type,
89
+ name: spec.name,
90
+ description: spec.description,
91
+ acl: spec.isShared ? '*' : null,
92
+ meta: {group: spec.group, isShared: spec.isShared},
93
+ value: spec.value
66
94
  });
67
- const ret = View.fromBlob(blob, owner);
95
+ const ret = View.fromBlob(blob, model);
68
96
  this.trackChange('Created View', ret);
69
97
  return ret;
70
98
  } catch (e) {
71
- throw XH.exception({message: `Unable to create ${owner.typeDisplayName}`, cause: e});
99
+ throw XH.exception({message: `Unable to create ${model.typeDisplayName}`, cause: e});
72
100
  }
73
101
  }
74
102
 
75
- async updateViewInfoAsync(
76
- view: ViewInfo,
77
- name: string,
78
- description: string,
79
- isGlobal: boolean
80
- ): Promise<View<T>> {
103
+ /** Update all aspects of a view's metadata.*/
104
+ async updateViewInfoAsync(view: ViewInfo, updates: ViewUpdateSpec): Promise<View<T>> {
81
105
  try {
82
- const blob = await XH.jsonBlobService.updateAsync(view.token, {
83
- name: name.trim(),
84
- description: description?.trim(),
85
- acl: isGlobal ? '*' : null
86
- });
87
- const ret = View.fromBlob(blob, this.owner);
106
+ this.ensureEditable(view);
107
+ const {isGlobal} = view,
108
+ {name, group, description, isShared, isDefaultPinned} = updates,
109
+ meta = {...view.meta, group},
110
+ blob = await XH.jsonBlobService.updateAsync(view.token, {
111
+ name: name.trim(),
112
+ description: description?.trim(),
113
+ acl: isGlobal || isShared ? '*' : null,
114
+ meta: isGlobal ? {...meta, isDefaultPinned} : {...meta, isShared}
115
+ });
116
+ const ret = View.fromBlob(blob, this.model);
88
117
  this.trackChange('Updated View Info', ret);
89
118
  return ret;
90
119
  } catch (e) {
@@ -92,10 +121,30 @@ export class ViewToBlobApi<T> {
92
121
  }
93
122
  }
94
123
 
124
+ /** Promote a view to global visibility/ownership status. */
125
+ async makeViewGlobalAsync(view: ViewInfo): Promise<View<T>> {
126
+ try {
127
+ this.ensureEditable(view);
128
+ const meta = view.meta,
129
+ blob = await XH.jsonBlobService.updateAsync(view.token, {
130
+ owner: null,
131
+ acl: '*',
132
+ meta: omit(meta, ['isShared'])
133
+ });
134
+ const ret = View.fromBlob(blob, this.model);
135
+ this.trackChange('Made View Global', ret);
136
+ return ret;
137
+ } catch (e) {
138
+ throw XH.exception({message: `Unable to update ${view.typedName}`, cause: e});
139
+ }
140
+ }
141
+
142
+ /** Update a view's value. */
95
143
  async updateViewValueAsync(view: View<T>, value: Partial<T>): Promise<View<T>> {
96
144
  try {
145
+ this.ensureEditable(view.info);
97
146
  const blob = await XH.jsonBlobService.updateAsync(view.token, {value});
98
- const ret = View.fromBlob(blob, this.owner);
147
+ const ret = View.fromBlob(blob, this.model);
99
148
  if (ret.isGlobal) {
100
149
  this.trackChange('Updated Global View definition', ret);
101
150
  }
@@ -108,20 +157,43 @@ export class ViewToBlobApi<T> {
108
157
  }
109
158
  }
110
159
 
111
- async deleteViewAsync(view: ViewInfo) {
112
- try {
113
- await XH.jsonBlobService.archiveAsync(view.token);
114
- this.trackChange('Deleted View', view);
115
- } catch (e) {
116
- throw XH.exception({message: `Unable to delete ${view.typedName}`, cause: e});
160
+ async deleteViewsAsync(views: ViewInfo[]) {
161
+ views.forEach(v => this.ensureEditable(v));
162
+ const results = await Promise.allSettled(
163
+ views.map(v => XH.jsonBlobService.archiveAsync(v.token))
164
+ ),
165
+ outcome = results.map((result, idx) => ({result, view: views[idx]})),
166
+ failed = outcome.filter(({result}) => result.status === 'rejected') as Array<{
167
+ result: PromiseRejectedResult;
168
+ view: ViewInfo;
169
+ }>;
170
+
171
+ this.trackChange(`Deleted ${pluralize('View', views.length - failed.length, true)}`);
172
+
173
+ if (!isEmpty(failed)) {
174
+ throw XH.exception({
175
+ message: `Failed to delete ${pluralize(this.model.typeDisplayName, failed.length, true)}: ${failed.map(({view}) => view.name).join(', ')}`,
176
+ cause: failed.map(({result}) => result.reason)
177
+ });
117
178
  }
118
179
  }
119
180
 
120
- private trackChange(message: string, v: View | ViewInfo) {
181
+ //------------------
182
+ // Implementation
183
+ //------------------
184
+ private trackChange(message: string, v?: View | ViewInfo) {
121
185
  XH.track({
122
186
  message,
123
187
  category: 'Views',
124
- data: {name: v.name, token: v.token, isGlobal: v.isGlobal, type: v.type}
188
+ data: pick(v, ['name', 'token', 'isGlobal', 'type'])
125
189
  });
126
190
  }
191
+
192
+ private ensureEditable(view: ViewInfo) {
193
+ const {model} = this;
194
+ throwIf(
195
+ !view.isEditable,
196
+ `Cannot save changes to ${model.globalDisplayName} ${model.typeDisplayName} - missing required permission.`
197
+ );
198
+ }
127
199
  }
@@ -1,4 +1,4 @@
1
1
  export * from './ViewManagerModel';
2
2
  export * from './ViewInfo';
3
3
  export * from './View';
4
- export * from './SaveAsDialogModel';
4
+ export * from './ViewToBlobApi';