@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
@@ -5,33 +5,39 @@
5
5
  * Copyright © 2024 Extremely Heavy Industries Inc.
6
6
  */
7
7
 
8
- import {grid, GridAutosizeMode, GridModel} from '@xh/hoist/cmp/grid';
9
- import {fragment, p, strong} from '@xh/hoist/cmp/layout';
8
+ import {badge} from '@xh/hoist/cmp/badge';
9
+ import {dateTimeCol, GridAutosizeMode, GridModel} from '@xh/hoist/cmp/grid';
10
+ import {fragment, hbox, p, strong} from '@xh/hoist/cmp/layout';
10
11
  import {TabContainerModel} from '@xh/hoist/cmp/tab';
11
- import {HoistModel, LoadSpec, lookup, managed, TaskObserver, XH} from '@xh/hoist/core';
12
+ import {ViewInfo, ViewManagerModel, ViewUpdateSpec} from '@xh/hoist/cmp/viewmanager';
13
+ import {HoistModel, LoadSpec, managed, TaskObserver, XH} from '@xh/hoist/core';
12
14
  import {FilterTestFn} from '@xh/hoist/data';
13
- import {computed} from 'mobx';
14
- import {ReactNode} from 'react';
15
- import {EditFormModel} from './EditFormModel';
15
+ import {button} from '@xh/hoist/desktop/cmp/button';
16
+ import {viewsGrid} from '@xh/hoist/desktop/cmp/viewmanager/dialog/ManageDialog';
16
17
  import {Icon} from '@xh/hoist/icon';
17
- import {bindable, makeObservable} from '@xh/hoist/mobx';
18
+ import {action, bindable, computed, makeObservable, observable, runInAction} from '@xh/hoist/mobx';
18
19
  import {pluralize} from '@xh/hoist/utils/js';
19
- import {ViewInfo, ViewManagerModel} from '@xh/hoist/cmp/viewmanager';
20
- import {find, some, startCase} from 'lodash';
20
+ import {capitalize, compact, isEmpty, some, startCase} from 'lodash';
21
+ import {ReactNode} from 'react';
22
+ import {ViewPanelModel} from './ViewPanelModel';
21
23
 
22
24
  /**
23
25
  * Backing model for ManageDialog
24
26
  */
25
27
  export class ManageDialogModel extends HoistModel {
26
- @lookup(() => ViewManagerModel)
27
28
  viewManagerModel: ViewManagerModel;
28
29
 
29
- @managed privateGridModel: GridModel;
30
+ @observable isOpen: boolean = false;
31
+
32
+ @managed ownedGridModel: GridModel;
30
33
  @managed globalGridModel: GridModel;
31
- @managed editFormModel: EditFormModel;
34
+ @managed sharedGridModel: GridModel;
35
+
36
+ @managed viewPanelModel: ViewPanelModel;
37
+
32
38
  @managed tabContainerModel: TabContainerModel;
33
39
 
34
- @bindable filter: FilterTestFn;
40
+ @bindable.ref filter: FilterTestFn;
35
41
 
36
42
  readonly updateTask = TaskObserver.trackLast();
37
43
 
@@ -40,98 +46,69 @@ export class ManageDialogModel extends HoistModel {
40
46
  }
41
47
 
42
48
  get gridModel(): GridModel {
43
- return this.tabContainerModel.activeTabId == 'global'
44
- ? this.globalGridModel
45
- : this.privateGridModel;
49
+ switch (this.tabContainerModel.activeTabId) {
50
+ case 'global':
51
+ return this.globalGridModel;
52
+ case 'shared':
53
+ return this.sharedGridModel;
54
+ case 'owned':
55
+ default:
56
+ return this.ownedGridModel;
57
+ }
46
58
  }
47
59
 
48
60
  @computed
49
61
  get selectedView(): ViewInfo {
50
- return this.gridModel.selectedRecord?.data.info;
62
+ return this.gridModel.selectedRecord?.data.view;
51
63
  }
52
64
 
53
65
  @computed
54
66
  get selectedViews(): ViewInfo[] {
55
- return this.gridModel.selectedRecords.map(it => it.data.info) as ViewInfo[];
56
- }
57
-
58
- get canDelete(): boolean {
59
- const {viewManagerModel, manageGlobal, selectedViews} = this,
60
- {views, enableDefault} = viewManagerModel;
61
-
62
- // Can't delete global views without role.
63
- if (!manageGlobal && selectedViews.some(v => v.isGlobal)) return false;
64
-
65
- // Can't delete all the views, unless default mode is enabled.
66
- return enableDefault || views.length - selectedViews.length > 0;
67
- }
68
-
69
- get manageGlobal(): boolean {
70
- return this.viewManagerModel.manageGlobal;
67
+ return this.gridModel.selectedRecords.map(it => it.data.view) as ViewInfo[];
71
68
  }
72
69
 
73
- get typeDisplayName(): string {
74
- return this.viewManagerModel.typeDisplayName;
75
- }
76
-
77
- get globalDisplayName(): string {
78
- return this.viewManagerModel.globalDisplayName;
70
+ constructor(viewManagerModel: ViewManagerModel) {
71
+ super();
72
+ makeObservable(this);
73
+ this.viewManagerModel = viewManagerModel;
79
74
  }
80
75
 
81
- get enableFavorites(): boolean {
82
- return this.viewManagerModel.enableFavorites;
76
+ @action
77
+ open() {
78
+ if (!this.tabContainerModel) this.init();
79
+ this.loadAsync();
80
+ this.isOpen = true;
83
81
  }
84
82
 
85
- constructor() {
86
- super();
87
- makeObservable(this);
83
+ @action
84
+ close() {
85
+ this.isOpen = false;
88
86
  }
89
87
 
90
- close() {
91
- this.viewManagerModel.closeManageDialog();
88
+ activateSelectedViewAndClose() {
89
+ this.viewManagerModel.selectViewAsync(this.selectedView);
90
+ this.close();
92
91
  }
93
92
 
94
- override onLinked() {
95
- super.onLinked();
93
+ override async doLoadAsync(loadSpec: LoadSpec) {
94
+ const {tabContainerModel} = this,
95
+ {enableGlobal, enableSharing, view, ownedViews, globalViews, sharedViews} =
96
+ this.viewManagerModel;
96
97
 
97
- this.privateGridModel = this.createGridModel('personal');
98
- this.globalGridModel = this.createGridModel(this.globalDisplayName);
99
- this.tabContainerModel = this.createTabContainerModel();
100
- this.editFormModel = new EditFormModel(this);
98
+ runInAction(() => {
99
+ this.ownedGridModel.loadData(ownedViews);
100
+ tabContainerModel.setTabTitle('owned', this.ownedTabTitle);
101
101
 
102
- const {privateGridModel, globalGridModel, editFormModel} = this;
103
- this.addReaction(
104
- {
105
- track: () => this.selectedView,
106
- run: r => editFormModel.setView(r)
107
- },
108
- {
109
- track: () => this.filter,
110
- run: f => {
111
- privateGridModel.store.setFilter(f);
112
- globalGridModel.store.setFilter(f);
113
- },
114
- fireImmediately: true
115
- },
116
- {
117
- track: () => privateGridModel.selectedRecords,
118
- run: recs => {
119
- if (recs.length) globalGridModel.clearSelection();
120
- }
121
- },
122
- {
123
- track: () => globalGridModel.selectedRecords,
124
- run: recs => {
125
- if (recs.length) privateGridModel.clearSelection();
126
- }
102
+ if (enableGlobal) {
103
+ this.globalGridModel.loadData(globalViews);
104
+ tabContainerModel.setTabTitle('global', this.globalTabTitle);
127
105
  }
128
- );
129
- }
130
106
 
131
- override async doLoadAsync(loadSpec: LoadSpec) {
132
- const {view, globalViews, privateViews} = this.viewManagerModel;
133
- this.globalGridModel.loadData(globalViews);
134
- this.privateGridModel.loadData(privateViews);
107
+ if (enableSharing) {
108
+ this.sharedGridModel.loadData(sharedViews);
109
+ tabContainerModel.setTabTitle('shared', this.sharedTabTitle);
110
+ }
111
+ });
135
112
  if (!loadSpec.isRefresh && !view.isDefault) {
136
113
  await this.selectViewAsync(view.info);
137
114
  }
@@ -141,51 +118,87 @@ export class ManageDialogModel extends HoistModel {
141
118
  return this.doDeleteAsync(views).linkTo(this.updateTask).catchDefault();
142
119
  }
143
120
 
144
- async updateAsync(view: ViewInfo, name: string, description: string, isGlobal: boolean) {
145
- return this.doUpdateAsync(view, name, description, isGlobal)
146
- .linkTo(this.updateTask)
147
- .catchDefault();
121
+ async updateAsync(view: ViewInfo, update: ViewUpdateSpec) {
122
+ return this.doUpdateAsync(view, update).linkTo(this.updateTask).catchDefault();
123
+ }
124
+
125
+ async makeGlobalAsync(view: ViewInfo) {
126
+ return this.doMakeGlobalAsync(view).linkTo(this.updateTask).catchDefault();
127
+ }
128
+
129
+ @action
130
+ togglePinned(views: ViewInfo[]) {
131
+ views.forEach(v => this.viewManagerModel.togglePinned(v));
132
+ this.refreshAsync();
148
133
  }
149
134
 
150
135
  //------------------------
151
136
  // Implementation
152
137
  //------------------------
153
- private async doUpdateAsync(
154
- view: ViewInfo,
155
- name: string,
156
- description: string,
157
- isGlobal: boolean
158
- ) {
159
- const {viewManagerModel} = this;
138
+ private init() {
139
+ const {enableGlobal, enableSharing} = this.viewManagerModel;
140
+
141
+ this.ownedGridModel = this.createGridModel('owned');
142
+ if (enableGlobal) this.globalGridModel = this.createGridModel('global');
143
+ if (enableSharing) this.sharedGridModel = this.createGridModel('shared');
144
+ const gridModels = compact([
145
+ this.ownedGridModel,
146
+ this.globalGridModel,
147
+ this.sharedGridModel
148
+ ]);
149
+
150
+ this.tabContainerModel = this.createTabContainerModel();
151
+ this.viewPanelModel = new ViewPanelModel(this);
152
+
153
+ this.addReaction({
154
+ track: () => this.filter,
155
+ run: f => gridModels.forEach(m => m.store.setFilter(f)),
156
+ fireImmediately: true
157
+ });
158
+
159
+ // Only allow one selection at a time across all grids
160
+ if (gridModels.length > 1) {
161
+ gridModels.forEach(gm => {
162
+ this.addReaction({
163
+ track: () => gm.hasSelection,
164
+ run: hasSelection => {
165
+ gridModels.forEach(it => {
166
+ if (it != gm && hasSelection) it.clearSelection();
167
+ });
168
+ }
169
+ });
170
+ });
171
+ }
172
+ }
160
173
 
161
- await viewManagerModel.api.updateViewInfoAsync(view, name, description, isGlobal);
174
+ private async doUpdateAsync(view: ViewInfo, update: ViewUpdateSpec) {
175
+ const {viewManagerModel} = this;
176
+ await viewManagerModel.api.updateViewInfoAsync(view, update);
162
177
  await viewManagerModel.refreshAsync();
163
178
  await this.refreshAsync();
164
-
165
- // reselect the updated copy of this view -- it may have moved.
166
- await this.selectViewAsync(find(viewManagerModel.views, {token: view.token}));
167
179
  }
168
180
 
169
181
  private async doDeleteAsync(views: ViewInfo[]) {
170
- const {viewManagerModel, typeDisplayName} = this,
171
- {enableDefault} = viewManagerModel,
182
+ const {viewManagerModel} = this,
183
+ {typeDisplayName} = viewManagerModel,
172
184
  count = views.length;
173
185
 
174
186
  if (!count) return;
175
187
 
176
- if (viewManagerModel.views.length === count && !enableDefault) {
177
- throw XH.exception({
178
- message: `You cannot delete all ${pluralize(typeDisplayName)}.`,
179
- isRoutine: true
180
- });
181
- }
182
-
183
188
  const confirmStr = count > 1 ? pluralize(typeDisplayName, count, true) : views[0].typedName;
184
189
  const msgs: ReactNode[] = [`Are you sure you want to delete ${confirmStr}?`];
185
- if (some(views, 'isGlobal')) {
190
+ if (some(views, v => v.isGlobal || v.isShared)) {
186
191
  count > 1
187
- ? msgs.push(strong('These global views will no longer be available to ALL users.'))
188
- : msgs.push(strong('This global view will no longer be available to ALL users.'));
192
+ ? msgs.push(
193
+ strong(
194
+ `This includes at least one public ${typeDisplayName}, to be deleted for all users.`
195
+ )
196
+ )
197
+ : msgs.push(
198
+ strong(
199
+ `This is a public ${typeDisplayName} and will be deleted for all users.`
200
+ )
201
+ );
189
202
  }
190
203
 
191
204
  const confirmed = await XH.confirm({
@@ -199,81 +212,185 @@ export class ManageDialogModel extends HoistModel {
199
212
  });
200
213
  if (!confirmed) return;
201
214
 
202
- for (const view of views) {
203
- await viewManagerModel.api.deleteViewAsync(view);
204
- }
215
+ return viewManagerModel.deleteViewsAsync(views).finally(() => this.refreshAsync());
216
+ }
205
217
 
218
+ private async doMakeGlobalAsync(view: ViewInfo) {
219
+ const {globalDisplayName, typeDisplayName} = this.viewManagerModel,
220
+ {typedName} = view,
221
+ msgs = [
222
+ `The ${typedName} will become a ${globalDisplayName} ${typeDisplayName} visible to all other ${XH.appName} users.`,
223
+ strong('Are you sure you want to proceed?')
224
+ ];
225
+
226
+ const confirmed = await XH.confirm({
227
+ message: fragment(msgs.map(m => p(m))),
228
+ confirmProps: {
229
+ text: `Yes, change visibility`,
230
+ outlined: true,
231
+ autoFocus: false,
232
+ intent: 'primary'
233
+ }
234
+ });
235
+ if (!confirmed) return;
236
+
237
+ const {viewManagerModel} = this;
238
+ const updated = await viewManagerModel.api.makeViewGlobalAsync(view);
206
239
  await viewManagerModel.refreshAsync();
207
240
  await this.refreshAsync();
241
+ await this.selectViewAsync(updated.info); // reselect -- will have moved tabs!
208
242
  }
209
243
 
210
- async selectViewAsync(view: ViewInfo) {
211
- this.tabContainerModel.activateTab(view.isGlobal ? 'global' : 'private');
244
+ private async selectViewAsync(view: ViewInfo) {
245
+ this.tabContainerModel.activateTab(
246
+ view.isOwned ? 'owned' : view.isGlobal ? 'global' : 'shared'
247
+ );
212
248
  await this.gridModel.selectAsync(view.token);
213
249
  }
214
250
 
215
- private createGridModel(name: string): GridModel {
251
+ private createGridModel(type: 'owned' | 'global' | 'shared'): GridModel {
252
+ const {typeDisplayName, globalDisplayName} = this.viewManagerModel;
253
+
254
+ const modifier =
255
+ type == 'owned' ? `personal` : type == 'global' ? globalDisplayName : 'shared';
256
+
216
257
  return new GridModel({
217
- emptyText: `No ${name} ${pluralize(this.typeDisplayName)} found...`,
258
+ emptyText: `No ${modifier} ${pluralize(typeDisplayName)} found...`,
218
259
  sortBy: 'name',
219
- hideHeaders: true,
220
260
  showGroupRowCounts: false,
261
+ groupBy: ['group'],
262
+ groupSortFn: (a, b) => {
263
+ // Place ungrouped items at bottom.
264
+ if (a == '') return 1;
265
+ if (b == '') return -1;
266
+ return a.localeCompare(b);
267
+ },
221
268
  selModel: 'multiple',
222
269
  contextMenu: null,
223
270
  sizingMode: 'standard',
271
+ hideHeaders: true,
224
272
  store: {
225
273
  idSpec: 'token',
226
- processRawData: v => ({name: v.name, isFavorite: v.isFavorite, info: v}),
274
+ processRawData: v => ({
275
+ name: v.name,
276
+ group: v.isGlobal || v.isOwned ? v.group : v.owner,
277
+ owner: v.owner,
278
+ lastUpdated: v.lastUpdated,
279
+ isPinned: v.isPinned,
280
+ view: v
281
+ }),
227
282
  fields: [
228
283
  {name: 'name', type: 'string'},
229
- {name: 'isFavorite', type: 'bool'},
230
- {name: 'info', type: 'auto'}
284
+ {name: 'group', type: 'string'},
285
+ {name: 'owner', type: 'string'},
286
+ {name: 'lastUpdated', type: 'date'},
287
+ {name: 'isPinned', type: 'bool'},
288
+ {name: 'view', type: 'auto'}
231
289
  ]
232
290
  },
233
291
  autosizeOptions: {mode: GridAutosizeMode.DISABLED},
234
292
  columns: [
235
293
  {field: 'name', flex: true},
294
+ {field: 'group', hidden: true},
295
+ {field: 'owner', hidden: true},
296
+ {field: 'lastUpdated', ...dateTimeCol},
236
297
  {
237
- colId: 'isFavorite',
238
- field: 'info',
239
- omit: !this.enableFavorites,
298
+ field: 'isPinned',
240
299
  width: 40,
241
300
  align: 'center',
242
- headerName: Icon.favorite(),
243
- highlightOnChange: true,
244
- renderer: v => {
245
- const {isFavorite} = v;
246
- return Icon.favorite({
247
- prefix: isFavorite ? 'fas' : 'fal',
248
- className: isFavorite ? 'xh-yellow' : 'xh-text-color-muted'
301
+ headerName: Icon.pin(),
302
+ headerTooltip: 'Pin to menu',
303
+ renderer: (isPinned, {record}) => {
304
+ return button({
305
+ icon: Icon.pin({
306
+ prefix: isPinned ? 'fas' : 'fal',
307
+ className: isPinned ? 'xh-yellow' : 'xh-text-color-muted'
308
+ }),
309
+ tooltip: isPinned ? 'Unpin from menu' : 'Pin to menu',
310
+ onClick: () => {
311
+ this.togglePinned([record.data.view]);
312
+ }
249
313
  });
250
314
  }
251
315
  }
252
316
  ],
253
- onCellClicked: ({colDef, data: record, api}) => {
254
- if (colDef.colId === 'isFavorite') {
255
- this.viewManagerModel.toggleFavorite(record.id);
256
- api.redrawRows();
257
- }
258
- }
317
+ groupRowRenderer: ({value}) => (isEmpty(value) ? 'Ungrouped' : value)
259
318
  });
260
319
  }
261
320
 
262
321
  private createTabContainerModel(): TabContainerModel {
263
- const pluralType = startCase(pluralize(this.typeDisplayName));
264
- return new TabContainerModel({
265
- tabs: [
266
- {
267
- id: 'private',
268
- title: `My ${pluralType}`,
269
- content: grid({model: this.privateGridModel})
270
- },
322
+ const {enableGlobal, enableSharing, globalDisplayName, typeDisplayName} =
323
+ this.viewManagerModel,
324
+ view = typeDisplayName,
325
+ views = pluralize(view),
326
+ globalViews = `${globalDisplayName} ${views}`,
327
+ tabs = [
271
328
  {
272
- id: 'global',
273
- title: `${startCase(this.globalDisplayName)} ${pluralType}`,
274
- content: grid({model: this.globalGridModel})
329
+ id: 'owned',
330
+ title: this.ownedTabTitle,
331
+ content: viewsGrid({
332
+ model: this.ownedGridModel,
333
+ helpText: fragment(
334
+ Icon.user(),
335
+ `This tab shows ${views} you have created. Pinned ${views} are shown in your menu for quick access. Set a group on ${views} to show them together in a sub-menu. `,
336
+ enableSharing
337
+ ? `Opt-in to sharing any of your ${views} to make them discoverable by other users.`
338
+ : ''
339
+ )
340
+ })
275
341
  }
276
- ]
277
- });
342
+ ];
343
+
344
+ if (enableGlobal) {
345
+ tabs.push({
346
+ id: 'global',
347
+ title: this.globalTabTitle,
348
+ content: viewsGrid({
349
+ model: this.globalGridModel,
350
+ helpText: fragment(
351
+ Icon.globe(),
352
+ `This tab shows ${globalViews} available to everyone. ${capitalize(globalViews)} can be pinned by default so they appear automatically in everyone's menu, but you can choose which ${views} you would like to see by pinning/unpinning them at any time.`
353
+ )
354
+ })
355
+ });
356
+ }
357
+
358
+ if (enableSharing) {
359
+ tabs.push({
360
+ id: 'shared',
361
+ title: this.sharedTabTitle,
362
+ content: viewsGrid({
363
+ model: this.sharedGridModel,
364
+ helpText: fragment(
365
+ Icon.users(),
366
+ `This tab shows ${views} shared by other ${XH.appName} users. You can pin these ${views} to add them to your menu and access them directly. Only the owner will be able to save changes to a shared ${view}, but you can save as a copy to make it your own.`
367
+ )
368
+ })
369
+ });
370
+ }
371
+
372
+ return new TabContainerModel({tabs});
373
+ }
374
+
375
+ private get ownedTabTitle(): ReactNode {
376
+ return hbox(
377
+ `My ${startCase(pluralize(this.viewManagerModel.typeDisplayName))}`,
378
+ badge(this.ownedGridModel.store.allCount)
379
+ );
380
+ }
381
+
382
+ private get globalTabTitle(): ReactNode {
383
+ const {globalDisplayName, typeDisplayName} = this.viewManagerModel;
384
+ return hbox(
385
+ `${startCase(globalDisplayName)} ${startCase(pluralize(typeDisplayName))}`,
386
+ badge(this.globalGridModel.store.allCount)
387
+ );
388
+ }
389
+
390
+ private get sharedTabTitle(): ReactNode {
391
+ return hbox(
392
+ `Shared ${startCase(pluralize(this.viewManagerModel.typeDisplayName))}`,
393
+ badge(this.sharedGridModel.store.allCount)
394
+ );
278
395
  }
279
396
  }
@@ -6,16 +6,17 @@
6
6
  */
7
7
 
8
8
  import {form} from '@xh/hoist/cmp/form';
9
- import {filler, vframe} from '@xh/hoist/cmp/layout';
9
+ import {filler, hbox, vframe} from '@xh/hoist/cmp/layout';
10
10
  import {hoistCmp, uses} from '@xh/hoist/core';
11
- import {SaveAsDialogModel} from '@xh/hoist/cmp/viewmanager/';
12
11
  import {button} from '@xh/hoist/desktop/cmp/button';
13
12
  import {formField} from '@xh/hoist/desktop/cmp/form';
14
- import {textArea, textInput} from '@xh/hoist/desktop/cmp/input';
13
+ import {select, switchInput, textArea, textInput} from '@xh/hoist/desktop/cmp/input';
15
14
  import {panel} from '@xh/hoist/desktop/cmp/panel';
16
15
  import {toolbar} from '@xh/hoist/desktop/cmp/toolbar';
17
16
  import {dialog} from '@xh/hoist/kit/blueprint';
18
17
  import {startCase} from 'lodash';
18
+ import {SaveAsDialogModel} from './SaveAsDialogModel';
19
+ import {getGroupOptions} from './Utils';
19
20
 
20
21
  /**
21
22
  * Default Save As dialog used by ViewManager.
@@ -34,7 +35,7 @@ export const saveAsDialog = hoistCmp.factory<SaveAsDialogModel>({
34
35
  isOpen: true,
35
36
  style: {width: 500},
36
37
  canOutsideClickClose: false,
37
- onClose: () => model.cancel(),
38
+ onClose: () => model.close(),
38
39
  item: formPanel()
39
40
  });
40
41
  }
@@ -45,7 +46,9 @@ const formPanel = hoistCmp.factory<SaveAsDialogModel>({
45
46
  return panel({
46
47
  item: form({
47
48
  fieldDefaults: {
48
- commitOnChange: true
49
+ commitOnChange: true,
50
+ inline: true,
51
+ minimal: true
49
52
  },
50
53
  item: vframe({
51
54
  className: 'xh-view-manager__save-dialog__form',
@@ -60,13 +63,31 @@ const formPanel = hoistCmp.factory<SaveAsDialogModel>({
60
63
  }
61
64
  })
62
65
  }),
66
+ formField({
67
+ field: 'group',
68
+ item: select({
69
+ enableCreate: true,
70
+ enableClear: true,
71
+ placeholder: 'Select optional group....',
72
+ options: getGroupOptions(model.parent, 'owned')
73
+ })
74
+ }),
63
75
  formField({
64
76
  field: 'description',
65
77
  item: textArea({
66
78
  selectOnFocus: true,
67
- height: 90
79
+ height: 70
80
+ })
81
+ }),
82
+ hbox(
83
+ formField({
84
+ field: 'isShared',
85
+ label: 'Share?',
86
+ labelTextAlign: 'left',
87
+ omit: !model.parent.enableSharing,
88
+ item: switchInput()
68
89
  })
69
- })
90
+ )
70
91
  ]
71
92
  })
72
93
  }),
@@ -78,12 +99,12 @@ const formPanel = hoistCmp.factory<SaveAsDialogModel>({
78
99
 
79
100
  const bbar = hoistCmp.factory<SaveAsDialogModel>({
80
101
  render({model}) {
81
- const {typeDisplayName} = model;
102
+ const {typeDisplayName} = model.parent;
82
103
  return toolbar(
83
104
  filler(),
84
105
  button({
85
106
  text: 'Cancel',
86
- onClick: () => model.cancel()
107
+ onClick: () => model.close()
87
108
  }),
88
109
  button({
89
110
  text: `Save as new ${startCase(typeDisplayName)}`,