@xh/hoist 76.0.0-SNAPSHOT.1756924112722 → 76.0.0-SNAPSHOT.1757108800208

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.
package/CHANGELOG.md CHANGED
@@ -2,7 +2,9 @@
2
2
 
3
3
  ## 76.0.0-SNAPSHOT - unreleased
4
4
 
5
- ### 💥 Breaking Changes (upgrade difficulty: 🟢 LOW - upgrade to Hoist Core)
5
+ ### 💥 Breaking Changes (upgrade difficulty: 🟢 LOW - upgrade to Hoist Core, change to Tab constructor)
6
+ * The constructor for `TabModel` has changed to take its owning container as a second argument.
7
+ (Most applications do not create `TabModels` directly, but it is possible.)
6
8
 
7
9
  ### 🎁 New Features
8
10
 
@@ -11,6 +13,8 @@
11
13
  or disruptive action.
12
14
  * Updated grid column filters to apply on `Enter` / dismiss on `Esc` and tweaked the filter popup
13
15
  toolbar for clarity.
16
+ * Added new ability to specify nested tab containers in a single declarative config. Apps may now
17
+ provide a spec for a nested tab container directly to the `TabConfig.content` property.
14
18
 
15
19
  ### 🐞 Bug Fixes
16
20
 
package/admin/AppModel.ts CHANGED
@@ -4,7 +4,6 @@
4
4
  *
5
5
  * Copyright © 2025 Extremely Heavy Industries Inc.
6
6
  */
7
- import {clusterTab} from '@xh/hoist/admin/tabs/cluster/ClusterTab';
8
7
  import {GridModel} from '@xh/hoist/cmp/grid';
9
8
  import {TabConfig, TabContainerModel} from '@xh/hoist/cmp/tab';
10
9
  import {ViewManagerModel} from '@xh/hoist/cmp/viewmanager';
@@ -14,9 +13,15 @@ import {without} from 'lodash';
14
13
  import {Route} from 'router5';
15
14
  import {activityTrackingPanel} from './tabs/activity/tracking/ActivityTrackingPanel';
16
15
  import {clientsPanel} from './tabs/clients/ClientsPanel';
17
- import {generalTab} from './tabs/general/GeneralTab';
18
16
  import {monitorTab} from './tabs/monitor/MonitorTab';
19
- import {userDataTab} from './tabs/userData/UserDataTab';
17
+ import {instancesTab, clusterObjectsPanel} from '@xh/hoist/admin/tabs/cluster';
18
+ import {aboutPanel, alertBannerPanel, configPanel} from '@xh/hoist/admin/tabs/general';
19
+ import {
20
+ jsonBlobPanel,
21
+ userPreferencePanel,
22
+ rolePanel,
23
+ userPanel
24
+ } from '@xh/hoist/admin/tabs/userData';
20
25
 
21
26
  export class AppModel extends HoistAppModel {
22
27
  tabModel: TabContainerModel;
@@ -118,16 +123,31 @@ export class AppModel extends HoistAppModel {
118
123
  }
119
124
 
120
125
  createTabs(): TabConfig[] {
126
+ const conf = XH.getConf('xhAdminAppConfig', {});
127
+
121
128
  return [
122
129
  {
123
130
  id: 'general',
124
131
  icon: Icon.info(),
125
- content: generalTab
132
+ content: {
133
+ switcher: {orientation: 'left', testId: 'general-tab-switcher'},
134
+ tabs: [
135
+ {id: 'about', icon: Icon.info(), content: aboutPanel},
136
+ {id: 'config', icon: Icon.settings(), content: configPanel},
137
+ {id: 'alertBanner', icon: Icon.bullhorn(), content: alertBannerPanel}
138
+ ]
139
+ }
126
140
  },
127
141
  {
128
142
  id: 'servers',
129
143
  icon: Icon.server(),
130
- content: clusterTab
144
+ content: {
145
+ switcher: {orientation: 'left', testId: 'cluster-tab-switcher'},
146
+ tabs: [
147
+ {id: 'instances', icon: Icon.server(), content: instancesTab},
148
+ {id: 'objects', icon: Icon.boxFull(), content: clusterObjectsPanel}
149
+ ]
150
+ }
131
151
  },
132
152
  {
133
153
  id: 'clients',
@@ -142,7 +162,35 @@ export class AppModel extends HoistAppModel {
142
162
  {
143
163
  id: 'userData',
144
164
  icon: Icon.users(),
145
- content: userDataTab
165
+ content: {
166
+ switcher: {orientation: 'left', testId: 'user-data-tab-switcher'},
167
+ refreshMode: 'onShowAlways',
168
+ tabs: [
169
+ {
170
+ id: 'users',
171
+ icon: Icon.users(),
172
+ content: userPanel,
173
+ omit: conf['hideUsersTab']
174
+ },
175
+ {
176
+ id: 'roles',
177
+ icon: Icon.idBadge(),
178
+ content: rolePanel
179
+ },
180
+ {
181
+ id: 'prefs',
182
+ title: 'Preferences',
183
+ icon: Icon.bookmark(),
184
+ content: userPreferencePanel
185
+ },
186
+ {
187
+ id: 'jsonBlobs',
188
+ title: 'JSON Blobs',
189
+ icon: Icon.json(),
190
+ content: jsonBlobPanel
191
+ }
192
+ ]
193
+ }
146
194
  },
147
195
  {
148
196
  id: 'activity',
@@ -0,0 +1,2 @@
1
+ export * from './instances/InstancesTab';
2
+ export * from './objects/ClusterObjectsPanel';
@@ -0,0 +1,3 @@
1
+ export * from './about/AboutPanel';
2
+ export * from './alertBanner/AlertBannerPanel';
3
+ export * from './config/ConfigPanel';
@@ -0,0 +1,4 @@
1
+ export * from './jsonblob/JsonBlobPanel';
2
+ export * from './prefs/UserPreferencePanel';
3
+ export * from './roles/RolePanel';
4
+ export * from './users/UserPanel';
@@ -0,0 +1,2 @@
1
+ export * from './instances/InstancesTab';
2
+ export * from './objects/ClusterObjectsPanel';
@@ -0,0 +1,3 @@
1
+ export * from './about/AboutPanel';
2
+ export * from './alertBanner/AlertBannerPanel';
3
+ export * from './config/ConfigPanel';
@@ -0,0 +1,4 @@
1
+ export * from './jsonblob/JsonBlobPanel';
2
+ export * from './prefs/UserPreferencePanel';
3
+ export * from './roles/RolePanel';
4
+ export * from './users/UserPanel';
@@ -4,7 +4,7 @@ import { TabConfig, TabModel } from './TabModel';
4
4
  import { TabSwitcherProps } from './TabSwitcherProps';
5
5
  export interface TabContainerConfig {
6
6
  /** Tabs to be displayed. */
7
- tabs?: TabConfig[];
7
+ tabs: TabConfig[];
8
8
  /**
9
9
  * ID of Tab to be shown initially if routing does not specify otherwise. If not set,
10
10
  * will default to first tab in the provided collection.
@@ -74,10 +74,18 @@ export declare class TabContainerModel extends HoistModel implements Persistable
74
74
  setTabs(tabs: Array<TabModel | TabConfig>): void;
75
75
  /** Add a single tab to the container. */
76
76
  addTab(tab: TabModel | TabConfig, opts?: AddTabOptions): TabModel;
77
- /** Remove a single tab from the container. */
77
+ /**
78
+ * Remove a single tab from the container.
79
+ * Supported for tabs that are immediate children of this container.
80
+ **/
78
81
  removeTab(tab: TabModel | string): void;
79
- /** Update the title of an existing tab. Logs failures quietly on debug if not found. */
82
+ /**
83
+ * Update the title of an existing tab.
84
+ * Supported for tabs that are immediate children of this container.
85
+ * Logs failures quietly on debug if not found.
86
+ * */
80
87
  setTabTitle(tabId: string, title: ReactNode): void;
88
+ /** Find a tab that is an immediate child of this container. */
81
89
  findTab(id: string): TabModel;
82
90
  get activeTab(): TabModel;
83
91
  /** The tab immediately before the active tab in the model's tab list. */
@@ -88,9 +96,11 @@ export declare class TabContainerModel extends HoistModel implements Persistable
88
96
  * Set the currently active Tab.
89
97
  *
90
98
  * If using routing, this method will navigate to the new tab via the router and the active Tab
91
- * will only be updated once the router state changes. Otherwise the active Tab will be updated
99
+ * will only be updated once the router state changes. Otherwise, the active Tab will be updated
92
100
  * immediately.
93
101
  *
102
+ * Supported for tabs that are immediate children of this container.
103
+ *
94
104
  * @param tab - TabModel or id of TabModel to be activated.
95
105
  */
96
106
  activateTab(tab: TabModel | string): void;
@@ -1,14 +1,9 @@
1
1
  import { HoistModel, RefreshMode, RenderMode, Content, RefreshContextModel, Thunkable } from '@xh/hoist/core';
2
- import { TabContainerModel } from '@xh/hoist/cmp/tab/TabContainerModel';
2
+ import { TabContainerConfig, TabContainerModel } from '@xh/hoist/cmp/tab';
3
3
  import { ReactElement, ReactNode } from 'react';
4
4
  export interface TabConfig {
5
5
  /** Unique ID, used by container for locating tabs and generating routes. */
6
6
  id: string;
7
- /**
8
- * Parent TabContainerModel. Provided by the container when constructing these models -
9
- * no need for application to specify directly.
10
- */
11
- containerModel?: TabContainerModel;
12
7
  /** Display title for the Tab in the container's TabSwitcher. */
13
8
  title?: ReactNode;
14
9
  /** Display icon for the Tab in the container's TabSwitcher. */
@@ -24,8 +19,10 @@ export interface TabConfig {
24
19
  excludeFromSwitcher?: boolean;
25
20
  /** Display an affordance to allow the user to remove this tab from its container.*/
26
21
  showRemoveAction?: boolean;
27
- /** Item to be rendered by this tab.*/
28
- content?: Content;
22
+ /**
23
+ * Item to be rendered by this tab, or specification for a child tab container for this tab.
24
+ */
25
+ content?: Content | TabConfig[] | TabContainerConfig;
29
26
  /**
30
27
  * Strategy for rendering this tab. If null, will default to its container's mode. See enum
31
28
  * for description of supported modes.
@@ -57,15 +54,18 @@ export declare class TabModel extends HoistModel {
57
54
  excludeFromSwitcher: boolean;
58
55
  showRemoveAction: boolean;
59
56
  content: Content;
60
- private _renderMode;
61
- private _refreshMode;
62
57
  containerModel: TabContainerModel;
63
58
  refreshContextModel: RefreshContextModel;
59
+ /** Child TabContainerModel. For nested TabContainers only. */
60
+ childContainerModel: TabContainerModel;
61
+ private _renderMode;
62
+ private _refreshMode;
64
63
  get isTabModel(): boolean;
65
- constructor({ id, containerModel, title, icon, tooltip, disabled, excludeFromSwitcher, showRemoveAction, content, refreshMode, renderMode, xhImpl }: TabConfig);
64
+ constructor({ id, title, icon, tooltip, disabled, excludeFromSwitcher, showRemoveAction, content, refreshMode, renderMode, xhImpl }: TabConfig, containerModel: TabContainerModel);
66
65
  activate(): void;
67
66
  get renderMode(): RenderMode;
68
67
  get refreshMode(): RefreshMode;
69
68
  get isActive(): boolean;
70
69
  setDisabled(disabled: boolean): void;
70
+ private parseContent;
71
71
  }
@@ -27,7 +27,7 @@ import {TabSwitcherProps} from './TabSwitcherProps';
27
27
 
28
28
  export interface TabContainerConfig {
29
29
  /** Tabs to be displayed. */
30
- tabs?: TabConfig[];
30
+ tabs: TabConfig[];
31
31
 
32
32
  /**
33
33
  * ID of Tab to be shown initially if routing does not specify otherwise. If not set,
@@ -157,13 +157,14 @@ export class TabContainerModel extends HoistModel implements Persistable<{active
157
157
 
158
158
  this.forwardRouterToTab(this.activeTabId);
159
159
  } else if (persistWith) {
160
- PersistenceProvider.create({
161
- persistOptions: {
162
- path: 'tabContainer',
163
- ...persistWith
164
- },
165
- target: this
166
- });
160
+ ((this.persistWith = {
161
+ path: 'tabContainer',
162
+ ...persistWith
163
+ }),
164
+ PersistenceProvider.create({
165
+ persistOptions: this.persistWith,
166
+ target: this
167
+ }));
167
168
  }
168
169
 
169
170
  if (track) {
@@ -202,13 +203,7 @@ export class TabContainerModel extends HoistModel implements Persistable<{active
202
203
  this.activeTabId = this.calculateActiveTabId(tabs);
203
204
  }
204
205
  this.tabs = tabs.map(t =>
205
- t instanceof TabModel
206
- ? t
207
- : new TabModel({
208
- ...t,
209
- containerModel: this,
210
- xhImpl: this.xhImpl
211
- })
206
+ t instanceof TabModel ? t : new TabModel({...t, xhImpl: this.xhImpl}, this)
212
207
  );
213
208
 
214
209
  if (oldTabs) {
@@ -228,7 +223,10 @@ export class TabContainerModel extends HoistModel implements Persistable<{active
228
223
  return this.findTab(tab.id);
229
224
  }
230
225
 
231
- /** Remove a single tab from the container. */
226
+ /**
227
+ * Remove a single tab from the container.
228
+ * Supported for tabs that are immediate children of this container.
229
+ **/
232
230
  @action
233
231
  removeTab(tab: TabModel | string) {
234
232
  const {tabs, activeTab} = this,
@@ -250,16 +248,19 @@ export class TabContainerModel extends HoistModel implements Persistable<{active
250
248
  this.setTabs(without(tabs, toRemove));
251
249
  }
252
250
 
253
- /** Update the title of an existing tab. Logs failures quietly on debug if not found. */
251
+ /**
252
+ * Update the title of an existing tab.
253
+ * Supported for tabs that are immediate children of this container.
254
+ * Logs failures quietly on debug if not found.
255
+ * */
254
256
  setTabTitle(tabId: string, title: ReactNode) {
255
257
  const tab = this.findTab(tabId);
256
258
  if (tab) {
257
259
  tab.title = title;
258
- } else {
259
- this.logDebug(`Failed to setTabTitle`, `Tab ${tabId} not found`);
260
260
  }
261
261
  }
262
262
 
263
+ /** Find a tab that is an immediate child of this container. */
263
264
  findTab(id: string): TabModel {
264
265
  return find(this.tabs, {id});
265
266
  }
@@ -284,9 +285,11 @@ export class TabContainerModel extends HoistModel implements Persistable<{active
284
285
  * Set the currently active Tab.
285
286
  *
286
287
  * If using routing, this method will navigate to the new tab via the router and the active Tab
287
- * will only be updated once the router state changes. Otherwise the active Tab will be updated
288
+ * will only be updated once the router state changes. Otherwise, the active Tab will be updated
288
289
  * immediately.
289
290
  *
291
+ * Supported for tabs that are immediate children of this container.
292
+ *
290
293
  * @param tab - TabModel or id of TabModel to be activated.
291
294
  */
292
295
  activateTab(tab: TabModel | string) {
@@ -17,20 +17,14 @@ import {
17
17
  } from '@xh/hoist/core';
18
18
  import {action, computed, observable, makeObservable, bindable} from '@xh/hoist/mobx';
19
19
  import {throwIf} from '@xh/hoist/utils/js';
20
- import {startCase} from 'lodash';
21
- import {TabContainerModel} from '@xh/hoist/cmp/tab/TabContainerModel';
20
+ import {isArray, isUndefined, startCase} from 'lodash';
21
+ import {TabContainerConfig, TabContainerModel, tabContainer} from '@xh/hoist/cmp/tab';
22
22
  import {ReactElement, ReactNode} from 'react';
23
23
 
24
24
  export interface TabConfig {
25
25
  /** Unique ID, used by container for locating tabs and generating routes. */
26
26
  id: string;
27
27
 
28
- /**
29
- * Parent TabContainerModel. Provided by the container when constructing these models -
30
- * no need for application to specify directly.
31
- */
32
- containerModel?: TabContainerModel;
33
-
34
28
  /** Display title for the Tab in the container's TabSwitcher. */
35
29
  title?: ReactNode;
36
30
 
@@ -52,8 +46,10 @@ export interface TabConfig {
52
46
  /** Display an affordance to allow the user to remove this tab from its container.*/
53
47
  showRemoveAction?: boolean;
54
48
 
55
- /** Item to be rendered by this tab.*/
56
- content?: Content;
49
+ /**
50
+ * Item to be rendered by this tab, or specification for a child tab container for this tab.
51
+ */
52
+ content?: Content | TabConfig[] | TabContainerConfig;
57
53
 
58
54
  /**
59
55
  * Strategy for rendering this tab. If null, will default to its container's mode. See enum
@@ -91,30 +87,35 @@ export class TabModel extends HoistModel {
91
87
  showRemoveAction: boolean;
92
88
  content: Content;
93
89
 
94
- private _renderMode: RenderMode;
95
- private _refreshMode: RefreshMode;
96
-
97
90
  containerModel: TabContainerModel;
98
91
  @managed refreshContextModel: RefreshContextModel;
99
92
 
93
+ /** Child TabContainerModel. For nested TabContainers only. */
94
+ @managed childContainerModel: TabContainerModel;
95
+
96
+ private _renderMode: RenderMode;
97
+ private _refreshMode: RefreshMode;
98
+
100
99
  get isTabModel() {
101
100
  return true;
102
101
  }
103
102
 
104
- constructor({
105
- id,
106
- containerModel,
107
- title = startCase(id),
108
- icon = null,
109
- tooltip = null,
110
- disabled = false,
111
- excludeFromSwitcher = false,
112
- showRemoveAction = false,
113
- content,
114
- refreshMode,
115
- renderMode,
116
- xhImpl = false
117
- }: TabConfig) {
103
+ constructor(
104
+ {
105
+ id,
106
+ title = startCase(id),
107
+ icon = null,
108
+ tooltip = null,
109
+ disabled = false,
110
+ excludeFromSwitcher = false,
111
+ showRemoveAction = false,
112
+ content,
113
+ refreshMode,
114
+ renderMode,
115
+ xhImpl = false
116
+ }: TabConfig,
117
+ containerModel: TabContainerModel
118
+ ) {
118
119
  super();
119
120
  makeObservable(this);
120
121
  this.xhImpl = xhImpl;
@@ -125,20 +126,18 @@ export class TabModel extends HoistModel {
125
126
  );
126
127
 
127
128
  this.id = id.toString();
128
- this.containerModel = containerModel;
129
129
  this.title = title;
130
130
  this.icon = icon;
131
131
  this.tooltip = tooltip;
132
132
  this.disabled = !!disabled;
133
133
  this.excludeFromSwitcher = excludeFromSwitcher;
134
134
  this.showRemoveAction = showRemoveAction;
135
- this.content = content;
136
-
135
+ this.containerModel = containerModel;
137
136
  this._renderMode = renderMode;
138
137
  this._refreshMode = refreshMode;
139
-
140
138
  this.refreshContextModel = new ManagedRefreshContextModel(this);
141
139
  this.refreshContextModel.xhImpl = true;
140
+ this.content = this.parseContent(content);
142
141
  }
143
142
 
144
143
  activate() {
@@ -170,4 +169,47 @@ export class TabModel extends HoistModel {
170
169
 
171
170
  this.disabled = disabled;
172
171
  }
172
+
173
+ //------------------
174
+ // Implementation
175
+ //------------------
176
+ private parseContent(content: Content | TabContainerConfig | TabConfig[]): Content {
177
+ // Recognize if content is a child container spec.
178
+ let childConfig: TabContainerConfig = null;
179
+ if (isArray(content)) {
180
+ childConfig = {tabs: content};
181
+ } else if ('tabs' in content) {
182
+ childConfig = content;
183
+ } else {
184
+ // ...otherwise just pass through
185
+ return content;
186
+ }
187
+
188
+ // It's a child container, create model and return
189
+ throwIf(XH.isMobileApp, 'Child Tabs not supported for Mobile TabContainer');
190
+ const {id} = this,
191
+ parent = this.containerModel;
192
+
193
+ childConfig = {
194
+ renderMode: parent.renderMode,
195
+ refreshMode: parent.refreshMode,
196
+ emptyText: parent.emptyText,
197
+ switcher: parent.switcher,
198
+ track: parent.track,
199
+ ...childConfig
200
+ };
201
+
202
+ // Trampoline nested routing OR persistence (TCM supports one or the other)
203
+ if (parent.route && !childConfig.route) {
204
+ childConfig.route = `${parent.route}.${id}`;
205
+ } else if (parent.persistWith && isUndefined(childConfig.persistWith)) {
206
+ childConfig.persistWith = {
207
+ ...parent.persistWith,
208
+ path: `${parent.persistWith.path}.${id}`
209
+ };
210
+ }
211
+
212
+ this.childContainerModel = new TabContainerModel(childConfig);
213
+ return tabContainer({model: this.childContainerModel});
214
+ }
173
215
  }
@@ -7,7 +7,7 @@
7
7
  import {PlainObject, XH} from '@xh/hoist/core';
8
8
  import {FetchOptions} from '@xh/hoist/svc';
9
9
  import {pluralize} from '@xh/hoist/utils/js';
10
- import {isPlainObject, truncate} from 'lodash';
10
+ import {isPlainObject, isString, truncate} from 'lodash';
11
11
  import {FetchException, HoistException, TimeoutException, TimeoutExceptionConfig} from './Types';
12
12
 
13
13
  /**
@@ -91,14 +91,13 @@ export class Exception {
91
91
  try {
92
92
  const cType = headers.get('Content-Type');
93
93
  if (cType?.includes('application/json')) {
94
- const obj = safeParseJson(responseText),
95
- message = obj ? obj.message : truncate(responseText?.trim(), {length: 255});
94
+ const parsedResp = safeParseJson(responseText);
96
95
  return this.createFetchException({
97
96
  ...defaults,
98
- name: obj?.name ?? defaults.name,
99
- message: message ?? statusText,
100
- isRoutine: obj?.isRoutine ?? false,
101
- serverDetails: obj ?? responseText
97
+ name: parsedResp?.name ?? defaults.name,
98
+ message: extractMessage(parsedResp, responseText, statusText),
99
+ isRoutine: parsedResp?.isRoutine ?? false,
100
+ serverDetails: parsedResp ?? responseText
102
101
  });
103
102
  }
104
103
  } catch (ignored) {}
@@ -234,6 +233,24 @@ function safeParseJson(txt: string): PlainObject {
234
233
  }
235
234
  }
236
235
 
236
+ function extractMessage(parsedResp: PlainObject, responseText: string, statusText: string): string {
237
+ let ret: string;
238
+ if (parsedResp) {
239
+ // From parsed response, including cause if provided (e.g. ExternalHttpException)
240
+ ret = parsedResp.message;
241
+ if (isString(parsedResp.cause)) {
242
+ const cause = truncate(parsedResp.cause, {length: 255});
243
+ ret = ret ? `${ret} (Caused by: ${cause})` : cause;
244
+ }
245
+ } else {
246
+ // Use raw text if not JSON parseable
247
+ ret = truncate(responseText?.trim(), {length: 255});
248
+ }
249
+
250
+ // Fallback to statusText if we have nothing else.
251
+ return ret || statusText;
252
+ }
253
+
237
254
  export function isHoistException(src: unknown): src is HoistException {
238
255
  return src?.['isHoistException'];
239
256
  }
@@ -27,7 +27,7 @@ export const tab = hoistCmp.factory({
27
27
  model: uses(TabModel, {publishMode: 'limited'}),
28
28
 
29
29
  render({model, className, testId}) {
30
- let {content, isActive, renderMode, refreshContextModel} = model,
30
+ const {content, isActive, renderMode, refreshContextModel} = model,
31
31
  wasActivated = useRef(false);
32
32
 
33
33
  if (!wasActivated.current && isActive) wasActivated.current = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xh/hoist",
3
- "version": "76.0.0-SNAPSHOT.1756924112722",
3
+ "version": "76.0.0-SNAPSHOT.1757108800208",
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",