@xh/hoist 71.0.0-SNAPSHOT.1735311286067 → 71.0.0-SNAPSHOT.1735324944017

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.
@@ -10,26 +10,22 @@ import {
10
10
  ExceptionHandlerOptions,
11
11
  HoistModel,
12
12
  LoadSpec,
13
- PersistableState,
14
- PersistenceProvider,
15
- PersistOptions,
16
13
  PlainObject,
17
14
  TaskObserver,
18
15
  Thunkable,
19
16
  XH
20
17
  } from '@xh/hoist/core';
21
- import type {ViewManagerProvider} from '@xh/hoist/core';
18
+ import type {ViewManagerProvider, ReactionSpec} from '@xh/hoist/core';
22
19
  import {genDisplayName} from '@xh/hoist/data';
23
20
  import {fmtDateTime} from '@xh/hoist/format';
24
- import {action, bindable, makeObservable, observable, when} from '@xh/hoist/mobx';
21
+ import {action, bindable, makeObservable, observable, comparer, runInAction} from '@xh/hoist/mobx';
25
22
  import {olderThan, SECONDS} from '@xh/hoist/utils/datetime';
26
23
  import {executeIfFunction, pluralize, throwIf} from '@xh/hoist/utils/js';
27
- import {find, isEmpty, isEqual, isNil, isObject, lowerCase, pickBy} from 'lodash';
28
- import {runInAction} from 'mobx';
24
+ import {find, isEqual, isNil, isNull, isUndefined, lowerCase} from 'lodash';
29
25
  import {ReactNode} from 'react';
30
26
  import {ViewInfo} from './ViewInfo';
31
27
  import {View} from './View';
32
- import {ViewToBlobApi, ViewCreateSpec, ViewUpdateSpec} from './ViewToBlobApi';
28
+ import {DataAccess, ViewCreateSpec, ViewUpdateSpec, ViewUserState} from './DataAccess';
33
29
 
34
30
  export interface ViewManagerConfig {
35
31
  /**
@@ -79,9 +75,6 @@ export interface ViewManagerConfig {
79
75
  */
80
76
  manageGlobal?: Thunkable<boolean>;
81
77
 
82
- /** Used to persist the user's state. */
83
- persistWith?: ViewManagerPersistOptions;
84
-
85
78
  /**
86
79
  * Required discriminator for the particular class of views to be loaded and managed by this
87
80
  * model. Set to something descriptive and specific enough to be identifiable and allow for
@@ -90,6 +83,13 @@ export interface ViewManagerConfig {
90
83
  */
91
84
  type: string;
92
85
 
86
+ /**
87
+ * Optional sub-discriminator for the particular location in your app this instance of the
88
+ * view manager appears in. A particular currentView and pendingValue will be maintained by
89
+ * instance, but all other options, and the available library of views will be shared by type.
90
+ */
91
+ instance?: string;
92
+
93
93
  /**
94
94
  * Optional user-facing display name for the view type, displayed in the ViewManager menu
95
95
  * and associated management dialogs and prompts. Defaulted from `type` if not provided.
@@ -102,14 +102,6 @@ export interface ViewManagerConfig {
102
102
  globalDisplayName?: string;
103
103
  }
104
104
 
105
- export interface ViewManagerPersistOptions extends PersistOptions {
106
- /** True to persist pinning preferences or provide specific PersistOptions. (Default true) */
107
- persistPinning?: boolean | PersistOptions;
108
-
109
- /** True to include pending value or provide specific PersistOptions. (Default false) */
110
- persistPendingValue?: boolean | PersistOptions;
111
- }
112
-
113
105
  /**
114
106
  * ViewManagerModel coordinates the loading, saving, and management of user-defined bundles of
115
107
  * {@link Persistable} component/model state.
@@ -146,8 +138,8 @@ export class ViewManagerModel<T = PlainObject> extends HoistModel {
146
138
  }
147
139
 
148
140
  /** Immutable configuration for this model. */
149
- declare persistWith: ViewManagerPersistOptions;
150
141
  readonly type: string;
142
+ readonly instance: string;
151
143
  readonly typeDisplayName: string;
152
144
  readonly globalDisplayName: string;
153
145
  readonly enableAutoSave: boolean;
@@ -175,7 +167,7 @@ export class ViewManagerModel<T = PlainObject> extends HoistModel {
175
167
  * True if user has opted-in to automatically saving changes to personal views (if auto-save
176
168
  * generally available as per `enableAutoSave`).
177
169
  */
178
- @bindable autoSave = true;
170
+ @bindable autoSave = false;
179
171
 
180
172
  /**
181
173
  * TaskObserver linked to {@link selectViewAsync}. If a change to the active view is likely to
@@ -202,7 +194,7 @@ export class ViewManagerModel<T = PlainObject> extends HoistModel {
202
194
  providers: ViewManagerProvider<any>[] = [];
203
195
 
204
196
  /** Data access for persisting views. */
205
- private api: ViewToBlobApi<T>;
197
+ private dataAccess: DataAccess<T>;
206
198
 
207
199
  /** Last time changes were pushed to linked persistence providers */
208
200
  private lastPushed: number = null;
@@ -265,7 +257,7 @@ export class ViewManagerModel<T = PlainObject> extends HoistModel {
265
257
  */
266
258
  private constructor({
267
259
  type,
268
- persistWith,
260
+ instance = 'default',
269
261
  typeDisplayName,
270
262
  globalDisplayName = 'global',
271
263
  manageGlobal = false,
@@ -285,9 +277,9 @@ export class ViewManagerModel<T = PlainObject> extends HoistModel {
285
277
  );
286
278
 
287
279
  this.type = type;
280
+ this.instance = instance;
288
281
  this.typeDisplayName = lowerCase(typeDisplayName ?? genDisplayName(type));
289
282
  this.globalDisplayName = globalDisplayName;
290
- this.persistWith = persistWith;
291
283
  this.manageGlobal = executeIfFunction(manageGlobal) ?? false;
292
284
  this.enableDefault = enableDefault;
293
285
  this.enableGlobal = enableGlobal;
@@ -303,20 +295,23 @@ export class ViewManagerModel<T = PlainObject> extends HoistModel {
303
295
  message: `Saving ${this.typeDisplayName}...`
304
296
  });
305
297
 
306
- this.api = new ViewToBlobApi(this);
298
+ this.dataAccess = new DataAccess(this);
307
299
  }
308
300
 
309
301
  override async doLoadAsync(loadSpec: LoadSpec) {
302
+ const {dataAccess, view} = this;
310
303
  try {
311
- // 1) Update all view info
312
- const views = await this.api.fetchViewInfosAsync();
304
+ // 1) Update views and related state
305
+ const {views, state} = await dataAccess.fetchDataAsync();
313
306
  if (loadSpec.isStale) return;
314
- runInAction(() => (this.views = views));
307
+ runInAction(() => {
308
+ this.views = views;
309
+ this.userPinned = state.userPinned;
310
+ this.autoSave = state.autoSave;
311
+ });
315
312
 
316
- // 2) Update active view if needed.
317
- const {view} = this;
313
+ // potentially fast-forward current view.
318
314
  if (!view.isDefault) {
319
- // Reload view if can be fast-forwarded. Otherwise, leave as is for save/saveAs.
320
315
  const latestInfo = find(views, {token: view.token});
321
316
  if (latestInfo && latestInfo.lastUpdated > view.lastUpdated) {
322
317
  this.loadViewAsync(latestInfo, this.pendingValue);
@@ -329,20 +324,24 @@ export class ViewManagerModel<T = PlainObject> extends HoistModel {
329
324
  }
330
325
 
331
326
  async selectViewAsync(info: ViewInfo): Promise<void> {
332
- if (this.isValueDirty) {
333
- if (this.isViewAutoSavable) await this.maybeAutoSaveAsync();
334
- if (this.isValueDirty && !(await this.confirmDiscardChangesAsync())) return;
327
+ // ensure any pending auto-save gets completed
328
+ if (this.isValueDirty && this.isViewAutoSavable) {
329
+ await this.maybeAutoSaveAsync();
330
+ }
331
+
332
+ // if still dirty, require confirm
333
+ if (this.isValueDirty && this.view.isOwned && !(await this.confirmDiscardChangesAsync())) {
334
+ return;
335
335
  }
336
336
 
337
337
  await this.loadViewAsync(info).catch(e => this.handleException(e));
338
338
  }
339
339
 
340
340
  async saveAsAsync(spec: ViewCreateSpec): Promise<void> {
341
- const view = await this.api.createViewAsync({...spec, value: this.getValue()});
341
+ const view = await this.dataAccess.createViewAsync({...spec, value: this.getValue()});
342
342
  this.noteSuccess(`Created ${view.typedName}`);
343
343
  this.userPin(view.info);
344
344
  this.setAsView(view);
345
- this.refreshAsync();
346
345
  }
347
346
 
348
347
  //------------------------
@@ -353,12 +352,12 @@ export class ViewManagerModel<T = PlainObject> extends HoistModel {
353
352
  this.logError('Unexpected conditions for call to save, skipping');
354
353
  return;
355
354
  }
356
- const {pendingValue, view, api} = this;
355
+ const {pendingValue, view, dataAccess} = this;
357
356
  try {
358
357
  if (!(await this.maybeConfirmSaveAsync(view, pendingValue))) {
359
358
  return;
360
359
  }
361
- const updated = await api
360
+ const updated = await dataAccess
362
361
  .updateViewValueAsync(view, pendingValue.value)
363
362
  .linkTo(this.saveTask);
364
363
 
@@ -441,18 +440,18 @@ export class ViewManagerModel<T = PlainObject> extends HoistModel {
441
440
 
442
441
  /** Update all aspects of a view's metadata.*/
443
442
  async updateViewInfoAsync(view: ViewInfo, updates: ViewUpdateSpec): Promise<View<T>> {
444
- return this.api.updateViewInfoAsync(view, updates);
443
+ return this.dataAccess.updateViewInfoAsync(view, updates);
445
444
  }
446
445
 
447
446
  /** Promote a view to global visibility/ownership status. */
448
447
  async makeViewGlobalAsync(view: ViewInfo): Promise<View<T>> {
449
- return this.api.makeViewGlobalAsync(view);
448
+ return this.dataAccess.makeViewGlobalAsync(view);
450
449
  }
451
450
 
452
451
  async deleteViewsAsync(toDelete: ViewInfo[]): Promise<void> {
453
452
  let exception;
454
453
  try {
455
- await this.api.deleteViewsAsync(toDelete);
454
+ await this.dataAccess.deleteViewsAsync(toDelete);
456
455
  } catch (e) {
457
456
  exception = e;
458
457
  }
@@ -471,38 +470,86 @@ export class ViewManagerModel<T = PlainObject> extends HoistModel {
471
470
  // Implementation
472
471
  //------------------
473
472
  private async initAsync() {
473
+ let {dataAccess, pendingValueStorageKey} = this,
474
+ initialState;
475
+
474
476
  try {
475
- const views = await this.api.fetchViewInfosAsync();
476
- runInAction(() => (this.views = views));
477
+ // 1) Initialize views and related state
478
+ const {views, state} = await dataAccess.fetchDataAsync();
479
+ initialState = state;
480
+ runInAction(() => {
481
+ this.views = views;
482
+ this.userPinned = state.userPinned;
483
+ this.autoSave = state.autoSave;
484
+ this.pendingValue = XH.sessionStorageService.get(pendingValueStorageKey);
485
+ });
477
486
 
478
- if (this.persistWith) {
479
- this.initPersist(this.persistWith);
480
- await when(() => !this.selectTask.isPending);
487
+ // 2) Initialize/choose initial view. Null is ok, and will yield default.
488
+ let initialView,
489
+ initialTkn = initialState.currentView;
490
+ if (isUndefined(initialTkn)) {
491
+ initialView = this.initialViewSpec?.(views);
492
+ } else if (!isNull(initialTkn)) {
493
+ initialView = find(views, {token: initialTkn}) ?? this.initialViewSpec?.(views);
494
+ } else {
495
+ initialView = null;
481
496
  }
482
497
 
483
- // If the initial view not initialized from persistence, assign it.
484
- if (!this.view) {
485
- await this.loadViewAsync(this.initialViewSpec?.(views), this.pendingValue);
486
- }
498
+ await this.loadViewAsync(initialView, this.pendingValue);
487
499
  } catch (e) {
488
- // Always ensure at least default view is installed.
489
- if (!this.view) this.loadViewAsync(null, this.pendingValue);
490
-
500
+ // Always ensure at least default view is installed (other state defaults are fine)
501
+ this.loadViewAsync(null, this.pendingValue);
491
502
  this.handleException(e, {showAlert: false, logOnServer: true});
492
503
  }
493
504
 
494
- this.addReaction({
505
+ this.addReaction(
506
+ this.pendingValueReaction(),
507
+ this.autoSaveReaction(),
508
+ ...this.stateReactions(initialState)
509
+ );
510
+ }
511
+
512
+ private pendingValueReaction(): ReactionSpec {
513
+ return {
514
+ track: () => this.pendingValue,
515
+ run: v => XH.sessionStorageService.set(this.pendingValueStorageKey, v)
516
+ };
517
+ }
518
+
519
+ private autoSaveReaction(): ReactionSpec {
520
+ return {
495
521
  track: () => [this.pendingValue, this.autoSave],
496
522
  run: () => this.maybeAutoSaveAsync(),
497
- debounce: 5 * SECONDS
498
- });
523
+ debounce: 2 * SECONDS
524
+ };
525
+ }
526
+
527
+ private stateReactions(initialState: ViewUserState): ReactionSpec[] {
528
+ const {dataAccess} = this;
529
+ return [
530
+ {
531
+ track: () => this.userPinned,
532
+ run: userPinned => dataAccess.updateStateAsync({userPinned}),
533
+ equals: comparer.structural,
534
+ debounce: 2 * SECONDS
535
+ },
536
+ {
537
+ track: () => this.autoSave,
538
+ run: autoSave => dataAccess.updateStateAsync({autoSave})
539
+ },
540
+ {
541
+ track: () => this.view?.token,
542
+ run: tkn => dataAccess.updateStateAsync({currentView: tkn}),
543
+ fireImmediately: this.view?.token !== initialState.currentView
544
+ }
545
+ ];
499
546
  }
500
547
 
501
548
  private async loadViewAsync(
502
549
  info: ViewInfo,
503
550
  pendingValue: PendingValue<T> = null
504
551
  ): Promise<void> {
505
- return this.api
552
+ return this.dataAccess
506
553
  .fetchViewAsync(info)
507
554
  .thenAction(latest => {
508
555
  this.setAsView(latest, pendingValue?.token == info?.token ? pendingValue : null);
@@ -513,10 +560,10 @@ export class ViewManagerModel<T = PlainObject> extends HoistModel {
513
560
  }
514
561
 
515
562
  private async maybeAutoSaveAsync() {
516
- const {pendingValue, isViewAutoSavable, view, api} = this;
563
+ const {pendingValue, isViewAutoSavable, view, dataAccess} = this;
517
564
  if (isViewAutoSavable && pendingValue) {
518
565
  try {
519
- const updated = await api
566
+ const updated = await dataAccess
520
567
  .updateViewValueAsync(view, pendingValue.value)
521
568
  .linkTo(this.saveTask);
522
569
 
@@ -551,6 +598,10 @@ export class ViewManagerModel<T = PlainObject> extends HoistModel {
551
598
  XH.successToast(msg);
552
599
  }
553
600
 
601
+ private get pendingValueStorageKey(): string {
602
+ return `${this.type}_${this.instance}`;
603
+ }
604
+
554
605
  /**
555
606
  * Stringify and parse to ensure that any value set here is valid, serializable JSON.
556
607
  */
@@ -575,7 +626,7 @@ export class ViewManagerModel<T = PlainObject> extends HoistModel {
575
626
 
576
627
  private async maybeConfirmSaveAsync(view: View, pendingValue: PendingValue<T>) {
577
628
  // Get latest from server for reference
578
- const latest = await this.api.fetchViewAsync(view.info),
629
+ const latest = await this.dataAccess.fetchViewAsync(view.info),
579
630
  isGlobal = latest.isGlobal,
580
631
  isStale = latest.lastUpdated > pendingValue.baseUpdated;
581
632
  if (!isStale && !isGlobal) return true;
@@ -613,86 +664,6 @@ export class ViewManagerModel<T = PlainObject> extends HoistModel {
613
664
  }
614
665
  });
615
666
  }
616
-
617
- //------------------
618
- // Persistence
619
- //------------------
620
- private initPersist(options: ViewManagerPersistOptions) {
621
- const {
622
- persistPinning = true,
623
- persistPendingValue = false,
624
- path = 'viewManager',
625
- ...rootPersistWith
626
- } = options;
627
-
628
- // Pinning potentially in dedicated location
629
- if (persistPinning) {
630
- const opts = isObject(persistPinning) ? persistPinning : rootPersistWith;
631
- PersistenceProvider.create({
632
- persistOptions: {path: `${path}.pinning`, ...opts},
633
- target: {
634
- getPersistableState: () => new PersistableState(this.userPinned),
635
- setPersistableState: ({value}) => {
636
- const {views} = this;
637
- this.userPinned = !isEmpty(views) // Clean state iff views loaded!
638
- ? pickBy(value, (_, tkn) => views.some(v => v.token === tkn))
639
- : value;
640
- }
641
- },
642
- owner: this
643
- });
644
- }
645
-
646
- // AutoSave, potentially in core location.
647
- if (this.enableAutoSave) {
648
- PersistenceProvider.create({
649
- persistOptions: {path: `${path}.autoSave`, ...rootPersistWith},
650
- target: {
651
- getPersistableState: () => new PersistableState(this.autoSave),
652
- setPersistableState: ({value}) => (this.autoSave = value)
653
- },
654
- owner: this
655
- });
656
- }
657
-
658
- // Pending Value, potentially in dedicated location
659
- // On hydration, stash away for one time use when hydrating view itself below
660
- if (persistPendingValue) {
661
- const opts = isObject(persistPendingValue) ? persistPendingValue : rootPersistWith;
662
- PersistenceProvider.create({
663
- persistOptions: {path: `${path}.pendingValue`, ...opts},
664
- target: {
665
- getPersistableState: () => new PersistableState(this.pendingValue),
666
- setPersistableState: ({value}) => {
667
- // Only accept this during initialization!
668
- if (!this.view) this.pendingValue = value;
669
- }
670
- },
671
- owner: this
672
- });
673
- }
674
-
675
- // View, in core location
676
- PersistenceProvider.create({
677
- persistOptions: {path: `${path}.view`, ...rootPersistWith},
678
- target: {
679
- // View could be null, just before initialization.
680
- getPersistableState: () => new PersistableState(this.view?.token),
681
- setPersistableState: async ({value: token}) => {
682
- // Requesting available view -- load it with any init pending val.
683
- const viewInfo = token ? find(this.views, {token}) : null;
684
- if (viewInfo || !token) {
685
- try {
686
- await this.loadViewAsync(viewInfo, this.pendingValue);
687
- } catch (e) {
688
- this.logError('Failure loading persisted view', e);
689
- }
690
- }
691
- }
692
- },
693
- owner: this
694
- });
695
- }
696
667
  }
697
668
 
698
669
  interface PendingValue<T> {
@@ -1,4 +1,4 @@
1
1
  export * from './ViewManagerModel';
2
2
  export * from './ViewInfo';
3
3
  export * from './View';
4
- export * from './ViewToBlobApi';
4
+ export * from './DataAccess';
@@ -56,33 +56,12 @@
56
56
  padding: var(--xh-pad-half-px) var(--xh-pad-px);
57
57
  }
58
58
 
59
- // Standard and outlined have borders - remove standard radius in between adjacent buttons so
60
- // they seamlessly combine their inner borders.
61
- border-radius: 0;
62
- &.xh-button--standard,
63
- &.xh-button--outlined {
64
- &:first-child {
65
- border-bottom-left-radius: var(--xh-button-border-radius-px);
66
- border-top-left-radius: var(--xh-button-border-radius-px);
67
- }
68
-
69
- &:last-child {
70
- border-bottom-right-radius: var(--xh-button-border-radius-px);
71
- border-top-right-radius: var(--xh-button-border-radius-px);
72
- }
73
- }
74
-
75
59
  // Minimal has no borders - radius is made visible by background shading when hovered or
76
60
  // pressed, and looks more natural without any radius.
77
61
  &.xh-button--minimal {
78
62
  border-radius: 0;
79
63
  }
80
64
 
81
- // Outlined buttons should avoid doubled-up inner borders.
82
- &.xh-button--outlined:not(:last-child) {
83
- border-right: none;
84
- }
85
-
86
65
  &.xh-button--enabled {
87
66
  // Hoist calls FocusStyleManager.onlyShowFocusOnTabs(), which by default hides focus indicators
88
67
  // unless a component has been tabbed into via keyboard. Override here to always show a focus
@@ -93,6 +72,54 @@
93
72
  }
94
73
  }
95
74
  }
75
+
76
+ // Horizontal mode only styles
77
+ &:not(.bp5-vertical) {
78
+ .xh-button.bp5-button {
79
+ // Standard and outlined have borders - remove standard radius in between adjacent buttons so
80
+ // they seamlessly combine their inner borders.
81
+ border-radius: 0;
82
+ &.xh-button--standard,
83
+ &.xh-button--outlined {
84
+ &:first-child {
85
+ border-radius: var(--xh-button-border-radius-px) 0 0 var(--xh-button-border-radius-px);
86
+ }
87
+
88
+ &:last-child {
89
+ border-radius: 0 var(--xh-button-border-radius-px) var(--xh-button-border-radius-px) 0;
90
+ }
91
+ }
92
+
93
+ // Outlined buttons should avoid doubled-up inner borders.
94
+ &.xh-button--outlined:not(:last-child) {
95
+ border-right: none;
96
+ }
97
+ }
98
+ }
99
+
100
+ // Vertical mode only styles
101
+ &.bp5-vertical {
102
+ .xh-button.bp5-button {
103
+ // Standard and outlined have borders - remove standard radius in between adjacent buttons so
104
+ // they seamlessly combine their inner borders.
105
+ border-radius: 0;
106
+ &.xh-button--standard,
107
+ &.xh-button--outlined {
108
+ &:first-child {
109
+ border-radius: var(--xh-button-border-radius-px) var(--xh-button-border-radius-px) 0 0;
110
+ }
111
+
112
+ &:last-child {
113
+ border-radius: 0 0 var(--xh-button-border-radius-px) var(--xh-button-border-radius-px);
114
+ }
115
+ }
116
+
117
+ // Outlined buttons should avoid doubled-up inner borders.
118
+ &.xh-button--outlined:not(:last-child) {
119
+ border-bottom: none;
120
+ }
121
+ }
122
+ }
96
123
  }
97
124
  }
98
125
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xh/hoist",
3
- "version": "71.0.0-SNAPSHOT.1735311286067",
3
+ "version": "71.0.0-SNAPSHOT.1735324944017",
4
4
  "description": "Hoist add-on for building and deploying React Applications.",
5
5
  "repository": "github:xh/hoist-react",
6
6
  "homepage": "https://xh.io",
@@ -5,7 +5,7 @@
5
5
  * Copyright © 2024 Extremely Heavy Industries Inc.
6
6
  */
7
7
  import {HoistService, LoadSpec, PlainObject, XH} from '@xh/hoist/core';
8
- import {isUndefined, omitBy} from 'lodash';
8
+ import {pick} from 'lodash';
9
9
 
10
10
  export interface JsonBlob {
11
11
  /** Either null for private blobs or special token "*" for globally shared blobs. */
@@ -89,17 +89,32 @@ export class JsonBlobService extends HoistService {
89
89
  }
90
90
 
91
91
  /** Modify mutable properties of an existing JSONBlob, as identified by its unique token. */
92
- async updateAsync(
93
- token: string,
94
- {acl, description, meta, name, owner, value}: Partial<JsonBlob>
95
- ): Promise<JsonBlob> {
96
- const update = omitBy({acl, description, meta, name, owner, value}, isUndefined);
92
+ async updateAsync(token: string, update: Partial<JsonBlob>): Promise<JsonBlob> {
93
+ update = pick(update, ['acl', 'description', 'meta', 'name', 'owner', 'value']);
97
94
  return XH.fetchJson({
98
95
  url: 'xh/updateJsonBlob',
99
- params: {
100
- token,
101
- update: JSON.stringify(update)
102
- }
96
+ params: {token, update: JSON.stringify(update)}
97
+ });
98
+ }
99
+
100
+ /** Create or update a blob for a user with the existing type and name. */
101
+ async createOrUpdateAsync(
102
+ type: string,
103
+ name: string,
104
+ data: Partial<JsonBlob>
105
+ ): Promise<JsonBlob> {
106
+ const update = pick(data, ['acl', 'description', 'meta', 'value']);
107
+ return XH.fetchJson({
108
+ url: 'xh/createOrUpdateJsonBlob',
109
+ params: {type, name, update: JSON.stringify(update)}
110
+ });
111
+ }
112
+
113
+ /** Find a blob owned by this user with a specific type and name. If none exists, return null. */
114
+ async findAsync(type: string, name: string): Promise<JsonBlob> {
115
+ return XH.fetchJson({
116
+ url: 'xh/findJsonBlob',
117
+ params: {type, name}
103
118
  });
104
119
  }
105
120