@xh/hoist 71.0.0-SNAPSHOT.1733791818708 → 71.0.0-SNAPSHOT.1734118787755

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/build/types/cmp/viewmanager/View.d.ts +5 -0
  2. package/build/types/cmp/viewmanager/ViewInfo.d.ts +32 -7
  3. package/build/types/cmp/viewmanager/ViewManagerModel.d.ts +34 -31
  4. package/build/types/cmp/viewmanager/ViewToBlobApi.d.ts +28 -6
  5. package/build/types/cmp/viewmanager/index.d.ts +1 -1
  6. package/build/types/desktop/cmp/viewmanager/ViewManager.d.ts +0 -4
  7. package/build/types/desktop/cmp/viewmanager/ViewManagerLocalModel.d.ts +10 -0
  8. package/build/types/desktop/cmp/viewmanager/ViewMenu.d.ts +2 -2
  9. package/build/types/desktop/cmp/viewmanager/dialog/ManageDialog.d.ts +4 -3
  10. package/build/types/desktop/cmp/viewmanager/dialog/ManageDialogModel.d.ts +19 -10
  11. package/build/types/desktop/cmp/viewmanager/dialog/SaveAsDialog.d.ts +1 -1
  12. package/build/types/{cmp/viewmanager → desktop/cmp/viewmanager/dialog}/SaveAsDialogModel.d.ts +3 -9
  13. package/build/types/desktop/cmp/viewmanager/dialog/Utils.d.ts +3 -0
  14. package/build/types/desktop/cmp/viewmanager/dialog/ViewMultiPanel.d.ts +1 -0
  15. package/build/types/desktop/cmp/viewmanager/dialog/ViewPanel.d.ts +5 -0
  16. package/build/types/desktop/cmp/viewmanager/dialog/{EditFormModel.d.ts → ViewPanelModel.d.ts} +2 -4
  17. package/build/types/svc/JsonBlobService.d.ts +1 -1
  18. package/cmp/viewmanager/View.ts +21 -1
  19. package/cmp/viewmanager/ViewInfo.ts +58 -11
  20. package/cmp/viewmanager/ViewManagerModel.ts +86 -81
  21. package/cmp/viewmanager/ViewToBlobApi.ts +91 -35
  22. package/cmp/viewmanager/index.ts +1 -1
  23. package/desktop/cmp/dash/container/DashContainerModel.ts +17 -5
  24. package/desktop/cmp/viewmanager/ViewManager.scss +25 -28
  25. package/desktop/cmp/viewmanager/ViewManager.ts +28 -26
  26. package/desktop/cmp/viewmanager/ViewManagerLocalModel.ts +28 -0
  27. package/desktop/cmp/viewmanager/ViewMenu.ts +162 -169
  28. package/desktop/cmp/viewmanager/dialog/ManageDialog.ts +67 -40
  29. package/desktop/cmp/viewmanager/dialog/ManageDialogModel.ts +238 -127
  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 +161 -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,
@@ -25,12 +24,11 @@ import {fmtDateTime} from '@xh/hoist/format';
25
24
  import {action, bindable, makeObservable, observable, runInAction, 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';
29
28
  import {ReactNode} from 'react';
30
- import {SaveAsDialogModel} from './SaveAsDialogModel';
31
29
  import {ViewInfo} from './ViewInfo';
32
30
  import {View} from './View';
33
- import {ViewToBlobApi} from './ViewToBlobApi';
31
+ import {ViewToBlobApi, ViewCreateSpec} from './ViewToBlobApi';
34
32
 
35
33
  export interface ViewManagerConfig {
36
34
  /**
@@ -45,8 +43,10 @@ export interface ViewManagerConfig {
45
43
  */
46
44
  enableDefault?: boolean;
47
45
 
48
- /** True (default) to allow user to mark views as favorites. Requires `persistWith`. */
49
- enableFavorites?: boolean;
46
+ /**
47
+ * True (default) to allow users to share their views with other users.
48
+ */
49
+ enableSharing?: boolean;
50
50
 
51
51
  /**
52
52
  * Function to determine the initial view for a user, when no view has already been persisted.
@@ -59,11 +59,10 @@ export interface ViewManagerConfig {
59
59
  initialViewSpec?: (views: ViewInfo[]) => ViewInfo;
60
60
 
61
61
  /**
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.
62
+ * Delay (in ms) to wait after state has been set on associated components before listening for
63
+ * further state changes. The long default wait 1000ms is intended to avoid a false positive
64
+ * dirty indicator when linking to complex components such as dashboards or grids that can
65
+ * report immediate changes to state due to internal processing or rendering.
67
66
  */
68
67
  settleTime?: number;
69
68
 
@@ -97,8 +96,8 @@ export interface ViewManagerConfig {
97
96
  }
98
97
 
99
98
  export interface ViewManagerPersistOptions extends PersistOptions {
100
- /** True to persist favorites or provide specific PersistOptions. (Default true) */
101
- persistFavorites?: boolean | PersistOptions;
99
+ /** True to persist pinning preferences or provide specific PersistOptions. (Default true) */
100
+ persistPinning?: boolean | PersistOptions;
102
101
 
103
102
  /** True to include pending value or provide specific PersistOptions. (Default false) */
104
103
  persistPendingValue?: boolean | PersistOptions;
@@ -112,8 +111,8 @@ export interface ViewManagerPersistOptions extends PersistOptions {
112
111
  * models can be bound to a single ViewManagerModel, allowing a single view to capture the state
113
112
  * of multiple components - e.g. grouping and filtering options along with grid state.
114
113
  * - 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.
114
+ * - Views can be private to their owner, or optionally enabled for sharing to (all) other users.
115
+ * - Views can be marked as pinned for quick access.
117
116
  * - See the desktop {@link ViewManager} component - the initial Hoist UI for this model.
118
117
  */
119
118
  export class ViewManagerModel<T = PlainObject> extends HoistModel {
@@ -146,7 +145,7 @@ export class ViewManagerModel<T = PlainObject> extends HoistModel {
146
145
  readonly globalDisplayName: string;
147
146
  readonly enableAutoSave: boolean;
148
147
  readonly enableDefault: boolean;
149
- readonly enableFavorites: boolean;
148
+ readonly enableSharing: boolean;
150
149
  readonly manageGlobal: boolean;
151
150
  readonly settleTime: number;
152
151
  readonly initialViewSpec: (views: ViewInfo[]) => ViewInfo;
@@ -155,8 +154,14 @@ export class ViewManagerModel<T = PlainObject> extends HoistModel {
155
154
  @observable.ref view: View<T> = null;
156
155
  /** Loaded saved view library - both private and global */
157
156
  @observable.ref views: ViewInfo[] = [];
158
- /** List of tokens for the user's favorite views. */
159
- @observable.ref favorites: string[] = [];
157
+
158
+ /**
159
+ * Map of user's preferred pinned state for views.
160
+ *
161
+ * Note that the actual pinned state for the views is determined by this value, layered
162
+ * over the default state of the views themselves.
163
+ */
164
+ @observable.ref userPinned: Record<string, boolean> = {};
160
165
 
161
166
  /**
162
167
  * True if user has opted-in to automatically saving changes to personal views (if auto-save
@@ -170,14 +175,9 @@ export class ViewManagerModel<T = PlainObject> extends HoistModel {
170
175
  */
171
176
  selectTask: TaskObserver;
172
177
 
173
- /**
174
- * TaskObserver linked to {@link saveAsync}.
175
- */
178
+ /** TaskObserver linked to {@link saveAsync}. */
176
179
  saveTask: TaskObserver;
177
180
 
178
- @observable manageDialogOpen = false;
179
- @managed saveAsDialogModel: SaveAsDialogModel;
180
-
181
181
  //-----------------------
182
182
  // Private, internal state.
183
183
  //-------------------------
@@ -194,7 +194,7 @@ export class ViewManagerModel<T = PlainObject> extends HoistModel {
194
194
  providers: ViewManagerProvider<any>[] = [];
195
195
 
196
196
  /**
197
- * Data access for persisting views
197
+ * Data access for persisting views.
198
198
  * @internal
199
199
  */
200
200
  api: ViewToBlobApi<T>;
@@ -211,32 +211,47 @@ export class ViewManagerModel<T = PlainObject> extends HoistModel {
211
211
 
212
212
  get isViewSavable(): boolean {
213
213
  const {view, manageGlobal} = this;
214
- return !view.isDefault && (manageGlobal || !view.isGlobal);
214
+ return view.isOwned || (view.isGlobal && manageGlobal);
215
215
  }
216
216
 
217
217
  get isViewAutoSavable(): boolean {
218
218
  const {enableAutoSave, autoSave, view} = this;
219
- return enableAutoSave && autoSave && !view.isGlobal && !view.isDefault;
219
+ return (
220
+ enableAutoSave &&
221
+ autoSave &&
222
+ !view.isShared &&
223
+ !view.isDefault &&
224
+ !XH.identityService.isImpersonating
225
+ );
220
226
  }
221
227
 
222
228
  get autoSaveUnavailableReason(): string {
223
229
  const {view, isViewAutoSavable, typeDisplayName, globalDisplayName} = this;
224
230
  if (isViewAutoSavable) return null;
225
231
  if (view.isGlobal) return `Cannot auto-save ${globalDisplayName} ${typeDisplayName}.`;
232
+ if (view.isShared) return `Cannot auto-save shared ${typeDisplayName}.`;
226
233
  if (view.isDefault) return `Cannot auto-save default ${typeDisplayName}.`;
234
+ if (XH.identityService.isImpersonating) return `Auto-save disabled during impersonation.`;
227
235
  return null;
228
236
  }
229
237
 
230
- get favoriteViews(): ViewInfo[] {
231
- return this.views.filter(it => it.isFavorite);
238
+ get pinnedViews(): ViewInfo[] {
239
+ return this.views.filter(it => it.isPinned);
232
240
  }
233
241
 
234
- get globalViews(): ViewInfo[] {
235
- return this.views.filter(it => it.isGlobal);
242
+ /** Views owned by me */
243
+ get ownedViews(): ViewInfo[] {
244
+ return this.views.filter(it => it.isOwned);
236
245
  }
237
246
 
238
- get privateViews(): ViewInfo[] {
239
- return this.views.filter(it => !it.isGlobal);
247
+ /** Views shared *with* me */
248
+ get sharedViews(): ViewInfo[] {
249
+ return this.views.filter(it => it.isShared && !it.isOwned);
250
+ }
251
+
252
+ /** Global views */
253
+ get globalViews(): ViewInfo[] {
254
+ return this.views.filter(it => it.isGlobal);
240
255
  }
241
256
 
242
257
  /** True if any async tasks are pending. */
@@ -257,8 +272,8 @@ export class ViewManagerModel<T = PlainObject> extends HoistModel {
257
272
  manageGlobal = false,
258
273
  enableAutoSave = true,
259
274
  enableDefault = true,
260
- settleTime = 250,
261
- enableFavorites = true,
275
+ enableSharing = true,
276
+ settleTime = 1000,
262
277
  initialViewSpec = null
263
278
  }: ViewManagerConfig) {
264
279
  super();
@@ -275,8 +290,8 @@ export class ViewManagerModel<T = PlainObject> extends HoistModel {
275
290
  this.persistWith = persistWith;
276
291
  this.manageGlobal = executeIfFunction(manageGlobal) ?? false;
277
292
  this.enableDefault = enableDefault;
293
+ this.enableSharing = enableSharing;
278
294
  this.enableAutoSave = enableAutoSave;
279
- this.enableFavorites = enableFavorites;
280
295
  this.settleTime = settleTime;
281
296
  this.initialViewSpec = initialViewSpec;
282
297
 
@@ -287,7 +302,6 @@ export class ViewManagerModel<T = PlainObject> extends HoistModel {
287
302
  message: `Saving ${this.typeDisplayName}...`
288
303
  });
289
304
 
290
- this.saveAsDialogModel = new SaveAsDialogModel(this);
291
305
  this.api = new ViewToBlobApi(this);
292
306
  }
293
307
 
@@ -322,6 +336,14 @@ export class ViewManagerModel<T = PlainObject> extends HoistModel {
322
336
  await this.loadViewAsync(info).catch(e => this.handleException(e));
323
337
  }
324
338
 
339
+ async saveAsAsync(spec: ViewCreateSpec): Promise<void> {
340
+ const view = await this.api.createViewAsync({...spec, value: this.getValue()});
341
+ this.noteSuccess(`Created ${view.typedName}`);
342
+ this.userPin(view.info);
343
+ this.setAsView(view);
344
+ this.refreshAsync();
345
+ }
346
+
325
347
  //------------------------
326
348
  // Saving/resetting
327
349
  //------------------------
@@ -349,15 +371,6 @@ export class ViewManagerModel<T = PlainObject> extends HoistModel {
349
371
  this.refreshAsync();
350
372
  }
351
373
 
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
374
  async resetAsync(): Promise<void> {
362
375
  await this.loadViewAsync(this.view.info).catch(e => this.handleException(e));
363
376
  }
@@ -389,40 +402,29 @@ export class ViewManagerModel<T = PlainObject> extends HoistModel {
389
402
  }
390
403
 
391
404
  //------------------
392
- // Favorites
405
+ // Pinning
393
406
  //------------------
394
- toggleFavorite(token: string) {
395
- this.isFavorite(token) ? this.removeFavorite(token) : this.addFavorite(token);
407
+ togglePinned(view: ViewInfo) {
408
+ view.isPinned ? this.userUnpin(view) : this.userPin(view);
396
409
  }
397
410
 
398
411
  @action
399
- addFavorite(token: string) {
400
- this.favorites = [...this.favorites, token];
412
+ userPin(view: ViewInfo) {
413
+ this.userPinned = {...this.userPinned, [view.token]: true};
401
414
  }
402
415
 
403
416
  @action
404
- removeFavorite(token: string) {
405
- this.favorites = without(this.favorites, token);
417
+ userUnpin(view: ViewInfo) {
418
+ this.userPinned = {...this.userPinned, [view.token]: false};
406
419
  }
407
420
 
408
- isFavorite(token: string) {
409
- return this.favorites.includes(token);
421
+ isUserPinned(view: ViewInfo): boolean | null {
422
+ return this.userPinned[view.token];
410
423
  }
411
424
 
412
425
  //-----------------
413
426
  // Management
414
427
  //-----------------
415
- @action
416
- openManageDialog() {
417
- this.manageDialogOpen = true;
418
- this.refreshAsync();
419
- }
420
-
421
- @action
422
- closeManageDialog() {
423
- this.manageDialogOpen = false;
424
- }
425
-
426
428
  async validateViewNameAsync(name: string, existing: ViewInfo = null): Promise<string> {
427
429
  const maxLength = 50;
428
430
  name = name?.trim();
@@ -430,8 +432,8 @@ export class ViewManagerModel<T = PlainObject> extends HoistModel {
430
432
  if (name.length > maxLength) {
431
433
  return `Name cannot be longer than ${maxLength} characters`;
432
434
  }
433
- if (this.views.some(view => view.name === name && view.token != existing?.token)) {
434
- return `A ${this.typeDisplayName} with name '${name}' already exists`;
435
+ if (this.ownedViews.some(view => view.name === name && view.token != existing?.token)) {
436
+ return `A ${this.typeDisplayName} with name '${name}' already exists.`;
435
437
  }
436
438
  return null;
437
439
  }
@@ -555,15 +557,15 @@ export class ViewManagerModel<T = PlainObject> extends HoistModel {
555
557
  if (isGlobal) {
556
558
  msgs.push(
557
559
  span(
558
- `This is a ${globalDisplayName} ${typeDisplayName}.`,
559
- strong('Changes will be visible to ALL users.')
560
+ `This is a ${globalDisplayName} ${typeDisplayName}. `,
561
+ strong('Changes will be visible to all users.')
560
562
  )
561
563
  );
562
564
  }
563
565
  if (isStale) {
564
566
  msgs.push(
565
567
  span(
566
- `This ${typeDisplayName} was updated by ${latestInfo.lastUpdatedBy} on ${fmtDateTime(latestInfo.lastUpdated)}.`,
568
+ `This ${typeDisplayName} was updated by ${latestInfo.lastUpdatedBy} on ${fmtDateTime(latestInfo.lastUpdated)}. `,
567
569
  strong('Your change may override those changes.')
568
570
  )
569
571
  );
@@ -574,11 +576,11 @@ export class ViewManagerModel<T = PlainObject> extends HoistModel {
574
576
  confirmProps: {
575
577
  text: 'Yes, save changes',
576
578
  intent: 'primary',
577
- outlined: true
579
+ outlined: true,
580
+ autoFocus: false
578
581
  },
579
582
  cancelProps: {
580
- text: 'Cancel',
581
- autoFocus: true
583
+ text: 'Cancel'
582
584
  }
583
585
  });
584
586
  }
@@ -588,21 +590,24 @@ export class ViewManagerModel<T = PlainObject> extends HoistModel {
588
590
  //------------------
589
591
  private initPersist(options: ViewManagerPersistOptions) {
590
592
  const {
591
- persistFavorites = true,
593
+ persistPinning = true,
592
594
  persistPendingValue = false,
593
595
  path = 'viewManager',
594
596
  ...rootPersistWith
595
597
  } = options;
596
598
 
597
- // Favorites, potentially in dedicated location
598
- if (this.enableFavorites && persistFavorites) {
599
- const opts = isObject(persistFavorites) ? persistFavorites : rootPersistWith;
599
+ // Pinning potentially in dedicated location
600
+ if (persistPinning) {
601
+ const opts = isObject(persistPinning) ? persistPinning : rootPersistWith;
600
602
  PersistenceProvider.create({
601
- persistOptions: {path: `${path}.favorites`, ...opts},
603
+ persistOptions: {path: `${path}.pinning`, ...opts},
602
604
  target: {
603
- getPersistableState: () => new PersistableState(this.favorites),
605
+ getPersistableState: () => new PersistableState(this.userPinned),
604
606
  setPersistableState: ({value}) => {
605
- this.favorites = value.filter(tkn => this.views.some(v => v.token === tkn));
607
+ const {views} = this;
608
+ this.userPinned = !isEmpty(views) // Clean state iff views loaded!
609
+ ? pickBy(value, (_, tkn) => views.some(v => v.token === tkn))
610
+ : value;
606
611
  }
607
612
  },
608
613
  owner: this
@@ -6,85 +6,108 @@
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 {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.map(b => new ViewInfo(b, model));
37
54
  } catch (e) {
38
55
  throw XH.exception({
39
- message: `Unable to fetch ${pluralize(owner.typeDisplayName)}`,
56
+ message: `Unable to fetch ${pluralize(model.typeDisplayName)}`,
40
57
  cause: e
41
58
  });
42
59
  }
43
60
  }
44
61
 
62
+ /** Fetch the latest version of a view. */
45
63
  async fetchViewAsync(info: ViewInfo): Promise<View<T>> {
46
- if (!info) return View.createDefault(this.owner);
64
+ const {model} = this;
65
+ if (!info) return View.createDefault(model);
47
66
  try {
48
67
  const blob = await XH.jsonBlobService.getAsync(info.token);
49
- return View.fromBlob(blob, this.owner);
68
+ return View.fromBlob(blob, model);
50
69
  } catch (e) {
51
70
  throw XH.exception({message: `Unable to fetch ${info.typedName}`, cause: e});
52
71
  }
53
72
  }
54
73
 
55
74
  //-----------------
56
- // Crud
75
+ // CRUD
57
76
  //-----------------
58
- async createViewAsync(name: string, description: string, value: PlainObject): Promise<View<T>> {
59
- const {owner} = this;
77
+ /** Create a new view, owned by the current user.*/
78
+ async createViewAsync(spec: ViewCreateSpec): Promise<View<T>> {
79
+ const {model} = this;
60
80
  try {
61
81
  const blob = await XH.jsonBlobService.createAsync({
62
- type: owner.type,
63
- name: name.trim(),
64
- description: description?.trim(),
65
- value
82
+ type: model.type,
83
+ name: spec.name,
84
+ description: spec.description,
85
+ acl: spec.isShared ? '*' : null,
86
+ meta: {group: spec.group, isShared: spec.isShared},
87
+ value: spec.value
66
88
  });
67
- const ret = View.fromBlob(blob, owner);
89
+ const ret = View.fromBlob(blob, model);
68
90
  this.trackChange('Created View', ret);
69
91
  return ret;
70
92
  } catch (e) {
71
- throw XH.exception({message: `Unable to create ${owner.typeDisplayName}`, cause: e});
93
+ throw XH.exception({message: `Unable to create ${model.typeDisplayName}`, cause: e});
72
94
  }
73
95
  }
74
96
 
75
- async updateViewInfoAsync(
76
- view: ViewInfo,
77
- name: string,
78
- description: string,
79
- isGlobal: boolean
80
- ): Promise<View<T>> {
97
+ /** Update all aspects of a view's metadata.*/
98
+ async updateViewInfoAsync(view: ViewInfo, updates: ViewUpdateSpec): Promise<View<T>> {
81
99
  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);
100
+ this.ensureEditable(view);
101
+ const {isGlobal} = view,
102
+ {name, group, description, isShared, isDefaultPinned} = updates,
103
+ meta = {...view.meta, group},
104
+ blob = await XH.jsonBlobService.updateAsync(view.token, {
105
+ name: name.trim(),
106
+ description: description?.trim(),
107
+ acl: isGlobal || isShared ? '*' : null,
108
+ meta: isGlobal ? {...meta, isDefaultPinned} : {...meta, isShared}
109
+ });
110
+ const ret = View.fromBlob(blob, this.model);
88
111
  this.trackChange('Updated View Info', ret);
89
112
  return ret;
90
113
  } catch (e) {
@@ -92,10 +115,30 @@ export class ViewToBlobApi<T> {
92
115
  }
93
116
  }
94
117
 
118
+ /** Promote a view to global visibility/ownership status. */
119
+ async makeViewGlobalAsync(view: ViewInfo): Promise<View<T>> {
120
+ try {
121
+ this.ensureEditable(view);
122
+ const meta = view.meta,
123
+ blob = await XH.jsonBlobService.updateAsync(view.token, {
124
+ owner: null,
125
+ acl: '*',
126
+ meta: omit(meta, ['isShared'])
127
+ });
128
+ const ret = View.fromBlob(blob, this.model);
129
+ this.trackChange('Made View Global', ret);
130
+ return ret;
131
+ } catch (e) {
132
+ throw XH.exception({message: `Unable to update ${view.typedName}`, cause: e});
133
+ }
134
+ }
135
+
136
+ /** Update a view's value. */
95
137
  async updateViewValueAsync(view: View<T>, value: Partial<T>): Promise<View<T>> {
96
138
  try {
139
+ this.ensureEditable(view.info);
97
140
  const blob = await XH.jsonBlobService.updateAsync(view.token, {value});
98
- const ret = View.fromBlob(blob, this.owner);
141
+ const ret = View.fromBlob(blob, this.model);
99
142
  if (ret.isGlobal) {
100
143
  this.trackChange('Updated Global View definition', ret);
101
144
  }
@@ -108,8 +151,10 @@ export class ViewToBlobApi<T> {
108
151
  }
109
152
  }
110
153
 
154
+ /** Delete a view. */
111
155
  async deleteViewAsync(view: ViewInfo) {
112
156
  try {
157
+ this.ensureEditable(view);
113
158
  await XH.jsonBlobService.archiveAsync(view.token);
114
159
  this.trackChange('Deleted View', view);
115
160
  } catch (e) {
@@ -117,11 +162,22 @@ export class ViewToBlobApi<T> {
117
162
  }
118
163
  }
119
164
 
165
+ //------------------
166
+ // Implementation
167
+ //------------------
120
168
  private trackChange(message: string, v: View | ViewInfo) {
121
169
  XH.track({
122
170
  message,
123
171
  category: 'Views',
124
- data: {name: v.name, token: v.token, isGlobal: v.isGlobal, type: v.type}
172
+ data: pick(v, ['name', 'token', 'isGlobal', 'type'])
125
173
  });
126
174
  }
175
+
176
+ private ensureEditable(view: ViewInfo) {
177
+ const {model} = this;
178
+ throwIf(
179
+ !view.isEditable,
180
+ `Cannot save changes to ${model.globalDisplayName} ${model.typeDisplayName} - missing required permission.`
181
+ );
182
+ }
127
183
  }
@@ -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';
@@ -24,7 +24,17 @@ import {wait} from '@xh/hoist/promise';
24
24
  import {isOmitted} from '@xh/hoist/utils/impl';
25
25
  import {debounced, ensureUniqueBy, throwIf} from '@xh/hoist/utils/js';
26
26
  import {createObservableRef} from '@xh/hoist/utils/react';
27
- import {cloneDeep, defaultsDeep, find, isFinite, isNil, last, reject, startCase} from 'lodash';
27
+ import {
28
+ cloneDeep,
29
+ defaultsDeep,
30
+ find,
31
+ isEqual,
32
+ isFinite,
33
+ isNil,
34
+ last,
35
+ reject,
36
+ startCase
37
+ } from 'lodash';
28
38
  import {createRoot} from 'react-dom/client';
29
39
  import {DashConfig, DashModel} from '../';
30
40
  import {DashViewModel, DashViewState} from '../DashViewModel';
@@ -371,13 +381,15 @@ export class DashContainerModel
371
381
  this.publishState();
372
382
  }
373
383
 
374
- @debounced(1000)
384
+ @debounced(100)
375
385
  private publishState() {
376
386
  const {goldenLayout} = this;
377
387
  if (!goldenLayout) return;
378
- runInAction(() => {
379
- this.state = convertGLToState(goldenLayout, this);
380
- });
388
+
389
+ const newState = convertGLToState(goldenLayout, this);
390
+ if (!isEqual(this.state, newState)) {
391
+ runInAction(() => (this.state = newState));
392
+ }
381
393
  }
382
394
 
383
395
  private onItemDestroyed(item) {