@xh/hoist 72.0.0 β†’ 72.2.0

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 (55) hide show
  1. package/CHANGELOG.md +41 -2
  2. package/admin/jsonsearch/JsonSearch.ts +294 -0
  3. package/admin/jsonsearch/impl/JsonSearchImplModel.ts +175 -0
  4. package/admin/tabs/activity/clienterrors/ClientErrorsModel.ts +23 -4
  5. package/admin/tabs/general/config/ConfigPanel.ts +26 -4
  6. package/admin/tabs/userData/jsonblob/JsonBlobPanel.ts +47 -10
  7. package/admin/tabs/userData/prefs/UserPreferencePanel.ts +45 -15
  8. package/admin/tabs/userData/roles/RoleModel.ts +3 -3
  9. package/admin/tabs/userData/roles/details/RoleDetailsModel.ts +2 -1
  10. package/admin/tabs/userData/roles/editor/form/RoleFormModel.ts +3 -3
  11. package/admin/tabs/userData/roles/recategorize/RecategorizeDialogModel.ts +1 -1
  12. package/build/types/admin/jsonsearch/JsonSearch.d.ts +17 -0
  13. package/build/types/admin/jsonsearch/impl/JsonSearchImplModel.d.ts +32 -0
  14. package/build/types/cmp/tab/TabContainerModel.d.ts +5 -5
  15. package/build/types/core/HoistProps.d.ts +1 -0
  16. package/build/types/core/XH.d.ts +5 -5
  17. package/build/types/core/persist/PersistenceProvider.d.ts +4 -0
  18. package/build/types/core/types/Interfaces.d.ts +9 -0
  19. package/build/types/data/Store.d.ts +4 -0
  20. package/build/types/data/StoreRecord.d.ts +2 -0
  21. package/build/types/desktop/cmp/appOption/AutoRefreshAppOption.d.ts +1 -0
  22. package/build/types/desktop/cmp/appOption/ThemeAppOption.d.ts +1 -0
  23. package/build/types/kit/blueprint/Wrappers.d.ts +1 -1
  24. package/build/types/kit/swiper/index.d.ts +4 -4
  25. package/build/types/security/BaseOAuthClient.d.ts +19 -21
  26. package/build/types/security/Token.d.ts +0 -1
  27. package/build/types/security/Types.d.ts +12 -0
  28. package/build/types/security/authzero/AuthZeroClient.d.ts +3 -4
  29. package/build/types/security/msal/MsalClient.d.ts +3 -4
  30. package/cmp/filter/FilterChooserModel.ts +6 -2
  31. package/cmp/grid/impl/InitPersist.ts +9 -3
  32. package/cmp/grouping/GroupingChooserModel.ts +6 -2
  33. package/cmp/tab/TabContainerModel.ts +5 -5
  34. package/cmp/zoneGrid/impl/InitPersist.ts +9 -3
  35. package/core/HoistBase.ts +1 -2
  36. package/core/HoistBaseDecorators.ts +4 -1
  37. package/core/HoistProps.ts +1 -0
  38. package/core/XH.ts +13 -5
  39. package/core/exception/Exception.ts +19 -12
  40. package/core/persist/PersistenceProvider.ts +31 -0
  41. package/core/types/Interfaces.ts +11 -0
  42. package/data/Store.ts +13 -3
  43. package/data/StoreRecord.ts +6 -1
  44. package/data/cube/row/BaseRow.ts +3 -2
  45. package/desktop/appcontainer/ExceptionDialog.ts +1 -1
  46. package/desktop/cmp/dash/canvas/DashCanvas.ts +2 -1
  47. package/mobile/cmp/navigator/NavigatorModel.ts +7 -0
  48. package/package.json +1 -1
  49. package/security/BaseOAuthClient.ts +41 -36
  50. package/security/Token.ts +0 -2
  51. package/security/Types.ts +22 -0
  52. package/security/authzero/AuthZeroClient.ts +6 -8
  53. package/security/msal/MsalClient.ts +6 -8
  54. package/tsconfig.tsbuildinfo +1 -1
  55. package/utils/react/LayoutPropUtils.ts +2 -1
package/CHANGELOG.md CHANGED
@@ -1,8 +1,46 @@
1
1
  # Changelog
2
2
 
3
+ ## v72.2.0 - 2025-03-13
4
+
5
+ ### 🎁 New Features
6
+ * Modified `TabContainerModel` to make more methods `protected`, improving extensibility for
7
+ advanced use-cases.
8
+ * Enhanced `XH.reloadApp` with new argument to clear query parameters before loading.
9
+ * Enhanced exception handling in `FetchService` to capture messages returned as raw strings, or
10
+ without explicit names.
11
+ * Added dedicated columns to the Admin Console "Client Errors" tab for error names and messages.
12
+ * `BaseOAuthClient` has been enhanced to allow `lazy` loading of Access Tokens, and also made more
13
+ robust such that Access Tokens that fail to load will never prevent the client from
14
+ initialization.
15
+
16
+ ### 🐞 Bug Fixes
17
+
18
+ * Prevent native browser context menu on Dash Canvas surfaces. It can hide the Dash Canvas custom
19
+ context menu when an app's `showBrowserContextMenu` flag is `true`.
20
+
21
+ ## v72.1.0 - 2025-02-13
22
+
23
+ ### 🎁 New Features
24
+
25
+ * Introduced a new "JSON Search" feature to the Hoist Admin Console, accessible from the Config,
26
+ User Preference, and JSON Blob tabs. Supports searching JSON values stored within these objects
27
+ to filter and match data using JSON Path expressions.
28
+ * ⚠️Requires `hoist-core >= 28.1` with new APIs for this (optional) feature to function.
29
+ * Added new getters `StoreRecord.isDirty`, `Store.dirtyRecords`, and `Store.isDirty` to provide a
30
+ more consistent API in the data package. The pre-existing `isModified` getters are retained as
31
+ aliases, with the same semantics.
32
+
33
+ ### 🐞 Bug Fixes
34
+
35
+ * Tuned mobile swipe handling to prevent horizontal swipes on a scrolling grid view from triggering
36
+ the Navigator's back gesture.
37
+ * Prevented the Admin Console Roles grid from losing its expand/collapse/scroll state on refresh.
38
+ * Fixed bug when merging `PersistOptions` with conflicting implicit provider types.
39
+ * Fixed bug where explicit `persistGrouping` options were not being respected by `GridModel`.
40
+
3
41
  ## v72.0.0 - 2025-01-27
4
42
 
5
- ### πŸ’₯ Breaking Changes
43
+ ### πŸ’₯ Breaking Changes (upgrade difficulty: 🟒 TRIVIAL - minor changes to mobile nav)
6
44
 
7
45
  * Mobile `Navigator` no longer supports `animation` prop, and `NavigatorModel` no longer supports
8
46
  `swipeToGoBack`. Both of these properties are now managed internally by the `Navigator` component.
@@ -15,7 +53,8 @@
15
53
  ### 🐞 Bug Fixes
16
54
 
17
55
  * Fixed `ViewManagerModel` unique name validation.
18
- * Fixed `GridModel.restoreDefaultsAsync()` to restore any default filter, rather than simply clearing it.
56
+ * Fixed `GridModel.restoreDefaultsAsync()` to restore any default filter, rather than simply
57
+ clearing it.
19
58
  * Improved suboptimal column state synchronization between `GridModel` and AG Grid.
20
59
 
21
60
  ### βš™οΈ Technical
@@ -0,0 +1,294 @@
1
+ /*
2
+ * This file belongs to Hoist, an application development toolkit
3
+ * developed by Extremely Heavy Industries (www.xh.io | info@xh.io)
4
+ *
5
+ * Copyright Β© 2025 Extremely Heavy Industries Inc.
6
+ */
7
+
8
+ import {errorMessage} from '@xh/hoist/cmp/error';
9
+ import {grid, GridConfig, gridCountLabel} from '@xh/hoist/cmp/grid';
10
+ import {a, box, filler, fragment, hframe, label, li, p, span, ul, vbox} from '@xh/hoist/cmp/layout';
11
+ import {hoistCmp, HoistProps, SelectOption, useLocalModel} from '@xh/hoist/core';
12
+ import {button} from '@xh/hoist/desktop/cmp/button';
13
+ import {clipboardButton} from '@xh/hoist/desktop/cmp/clipboard';
14
+ import {buttonGroupInput, jsonInput, select, textInput} from '@xh/hoist/desktop/cmp/input';
15
+ import {panel} from '@xh/hoist/desktop/cmp/panel';
16
+ import {toolbar, toolbarSep} from '@xh/hoist/desktop/cmp/toolbar';
17
+ import {Icon} from '@xh/hoist/icon';
18
+ import {dialog, popover} from '@xh/hoist/kit/blueprint';
19
+ import {pluralize} from '@xh/hoist/utils/js';
20
+ import {startCase} from 'lodash';
21
+ import {JsonSearchImplModel} from './impl/JsonSearchImplModel';
22
+
23
+ export interface JsonSearchButtonProps extends HoistProps {
24
+ /** Descriptive label for the type of records being searched - will be auto-pluralized. */
25
+ subjectName: string;
26
+
27
+ /** Endpoint to search and return matches - Hoist `JsonSearchController` action expected. */
28
+ docSearchUrl: string;
29
+
30
+ /** Config for GridModel used to display search results. */
31
+ gridModelConfig: GridConfig;
32
+
33
+ /** Field names on returned results to enable for grouping in the search results grid. */
34
+ groupByOptions: Array<SelectOption | any>;
35
+ }
36
+
37
+ /**
38
+ * Main entry point component for the JSON search feature. Supported out-of-the-box for a limited
39
+ * set of Hoist artifacts that hold JSON values: JSONBlob, Configs, and User Preferences.
40
+ */
41
+ export const jsonSearchButton = hoistCmp.factory<JsonSearchButtonProps>({
42
+ displayName: 'JsonSearchButton',
43
+
44
+ render() {
45
+ const impl = useLocalModel(JsonSearchImplModel);
46
+
47
+ return fragment(
48
+ button({
49
+ icon: Icon.json(),
50
+ text: 'JSON Search',
51
+ onClick: () => impl.toggleSearchIsOpen()
52
+ }),
53
+ jsonSearchDialog({
54
+ omit: !impl.isOpen,
55
+ model: impl
56
+ })
57
+ );
58
+ }
59
+ });
60
+
61
+ const jsonSearchDialog = hoistCmp.factory<JsonSearchImplModel>({
62
+ displayName: 'JsonSearchDialog',
63
+
64
+ render({model}) {
65
+ const {error, subjectName} = model;
66
+
67
+ return dialog({
68
+ title: `JSON Search: ${subjectName}`,
69
+ style: {
70
+ width: '90vw',
71
+ height: '90vh'
72
+ },
73
+ icon: Icon.json(),
74
+ isOpen: true,
75
+ className: 'xh-admin-diff-detail',
76
+ onClose: () => model.toggleSearchIsOpen(),
77
+ item: panel({
78
+ tbar: searchTbar(),
79
+ item: panel({
80
+ mask: model.docLoadTask,
81
+ items: [
82
+ errorMessage({
83
+ error,
84
+ title: error?.name ? startCase(error.name) : undefined
85
+ }),
86
+ hframe({
87
+ omit: error,
88
+ items: [
89
+ panel({
90
+ item: grid({model: model.gridModel}),
91
+ modelConfig: {
92
+ side: 'left',
93
+ defaultSize: '30%',
94
+ collapsible: true,
95
+ defaultCollapsed: false,
96
+ resizable: true
97
+ }
98
+ }),
99
+ panel({
100
+ mask: model.nodeLoadTask,
101
+ tbar: readerTbar(),
102
+ item: jsonInput({
103
+ model,
104
+ bind: 'readerContent',
105
+ flex: 1,
106
+ width: '100%',
107
+ readonly: true,
108
+ showCopyButton: true
109
+ })
110
+ })
111
+ ]
112
+ })
113
+ ]
114
+ })
115
+ })
116
+ });
117
+ }
118
+ });
119
+
120
+ const searchTbar = hoistCmp.factory<JsonSearchImplModel>({
121
+ render({model}) {
122
+ return toolbar(
123
+ pathField({model}),
124
+ button({
125
+ text: `Search ${model.subjectName}`,
126
+ intent: 'success',
127
+ outlined: true,
128
+ disabled: !model.path,
129
+ onClick: () => model.loadMatchingDocsAsync()
130
+ }),
131
+ '-',
132
+ helpButton({model}),
133
+ '-',
134
+ span('Group by:'),
135
+ select({
136
+ bind: 'groupBy',
137
+ options: model.groupByOptions,
138
+ width: 160,
139
+ enableFilter: false
140
+ }),
141
+ '-',
142
+ gridCountLabel({
143
+ gridModel: model.gridModel,
144
+ unit: 'match'
145
+ })
146
+ );
147
+ }
148
+ });
149
+
150
+ const pathField = hoistCmp.factory<JsonSearchImplModel>({
151
+ render({model}) {
152
+ return textInput({
153
+ bind: 'path',
154
+ autoFocus: true,
155
+ commitOnChange: true,
156
+ leftIcon: Icon.search(),
157
+ enableClear: true,
158
+ placeholder: 'Provide a JSON Path expression to evaluate',
159
+ width: null,
160
+ flex: 1,
161
+ onKeyDown: e => {
162
+ if (e.key === 'Enter') model.loadMatchingDocsAsync();
163
+ }
164
+ });
165
+ }
166
+ });
167
+
168
+ const helpButton = hoistCmp.factory<JsonSearchImplModel>({
169
+ render({model}) {
170
+ return popover({
171
+ item: button({
172
+ icon: Icon.questionCircle(),
173
+ outlined: true
174
+ }),
175
+ content: vbox({
176
+ className: 'xh-pad',
177
+ items: [
178
+ p(
179
+ `JSON Path expressions allow you to recursively query JSON documents, matching nodes based on their path, properties, and values.`
180
+ ),
181
+ p(
182
+ `Enter a path and press [Enter] to search for matches within the JSON content of ${model.subjectName}.`
183
+ ),
184
+ ul({
185
+ items: queryExamples.map(({query, explanation}) =>
186
+ li({
187
+ key: query,
188
+ items: [
189
+ span({
190
+ className:
191
+ 'xh-border xh-pad-half xh-bg-alt xh-font-family-mono',
192
+ item: query
193
+ }),
194
+ ' ',
195
+ clipboardButton({
196
+ text: null,
197
+ icon: Icon.copy(),
198
+ getCopyText: () => query,
199
+ successMessage: 'Query copied to clipboard.'
200
+ }),
201
+ ' ',
202
+ explanation
203
+ ]
204
+ })
205
+ ),
206
+ style: {marginTop: 0}
207
+ }),
208
+ a({
209
+ href: 'https://github.com/json-path/JsonPath?tab=readme-ov-file#operators',
210
+ target: '_blank',
211
+ item: 'Path Syntax Docs & More Examples'
212
+ })
213
+ ]
214
+ })
215
+ });
216
+ }
217
+ });
218
+
219
+ const readerTbar = hoistCmp.factory<JsonSearchImplModel>(({model}) => {
220
+ return toolbar({
221
+ items: [
222
+ buttonGroupInput({
223
+ model,
224
+ bind: 'readerContentType',
225
+ minimal: true,
226
+ outlined: true,
227
+ disabled: !model.selectedRecord,
228
+ items: [
229
+ button({
230
+ text: 'Document',
231
+ value: 'document'
232
+ }),
233
+ button({
234
+ text: 'Matches',
235
+ value: 'matches'
236
+ })
237
+ ]
238
+ }),
239
+ fragment({
240
+ omit: model.readerContentType !== 'matches' || !model.selectedRecord,
241
+ items: [
242
+ toolbarSep(),
243
+ label('View path as'),
244
+ buttonGroupInput({
245
+ model,
246
+ bind: 'pathFormat',
247
+ minimal: true,
248
+ outlined: true,
249
+ items: [
250
+ button({
251
+ text: 'XPath',
252
+ value: 'XPath'
253
+ }),
254
+ button({
255
+ text: 'JSONPath',
256
+ value: 'JSONPath'
257
+ })
258
+ ]
259
+ })
260
+ ]
261
+ }),
262
+ filler(),
263
+ box({
264
+ omit: !model.matchingNodeCount,
265
+ item: `${pluralize('match', model.matchingNodeCount, true)} within this document`
266
+ })
267
+ ]
268
+ });
269
+ });
270
+
271
+ const queryExamples = [
272
+ {
273
+ query: '$.displayMode',
274
+ explanation: 'Return documents with a top-level property "displayMode"'
275
+ },
276
+ {
277
+ query: "$..[?(@.colId == 'trader')]",
278
+ explanation:
279
+ 'Find all nodes (anywhere in the document) with a property "colId" equal to "trader"'
280
+ },
281
+ {
282
+ query: '$..[?(@.colId && @.width)]',
283
+ explanation: 'Find all nodes with a property "colId" and a property "width"'
284
+ },
285
+ {
286
+ query: '$..[?(@.colId && @.hidden != true)]',
287
+ explanation:
288
+ 'Find all nodes with a property "colId" and a property "hidden" not equal to true'
289
+ },
290
+ {
291
+ query: '$..grid[?(@.version == 1)]',
292
+ explanation: 'Find all nodes with a key of "grid" and a property "version" equal to 1'
293
+ }
294
+ ];
@@ -0,0 +1,175 @@
1
+ /*
2
+ * This file belongs to Hoist, an application development toolkit
3
+ * developed by Extremely Heavy Industries (www.xh.io | info@xh.io)
4
+ *
5
+ * Copyright Β© 2025 Extremely Heavy Industries Inc.
6
+ */
7
+
8
+ import {GridConfig, GridModel} from '@xh/hoist/cmp/grid';
9
+ import {HoistModel, managed, TaskObserver, XH} from '@xh/hoist/core';
10
+ import {action, bindable, makeObservable, observable} from '@xh/hoist/mobx';
11
+ import {pluralize} from '@xh/hoist/utils/js';
12
+ import {camelCase, isEmpty, zipWith} from 'lodash';
13
+
14
+ /**
15
+ * @internal
16
+ */
17
+ export class JsonSearchImplModel extends HoistModel {
18
+ override xhImpl = true;
19
+
20
+ private matchingNodesUrl = 'jsonSearch/getMatchingNodes';
21
+
22
+ @managed gridModel: GridModel;
23
+ @managed docLoadTask: TaskObserver = TaskObserver.trackLast();
24
+ @managed nodeLoadTask: TaskObserver = TaskObserver.trackLast();
25
+
26
+ @observable groupBy: string = null;
27
+ @observable isOpen: boolean = false;
28
+
29
+ @bindable.ref error = null;
30
+ @bindable path: string = '';
31
+ @bindable readerContentType: 'document' | 'matches' = 'matches';
32
+ @bindable pathFormat: 'XPath' | 'JSONPath' = 'XPath';
33
+ @bindable readerContent: string = '';
34
+ @bindable matchingNodeCount: number = 0;
35
+
36
+ get subjectName(): string {
37
+ return pluralize(this.componentProps.subjectName);
38
+ }
39
+
40
+ get docSearchUrl(): string {
41
+ return this.componentProps.docSearchUrl;
42
+ }
43
+
44
+ get gridModelConfig(): GridConfig {
45
+ return this.componentProps.gridModelConfig;
46
+ }
47
+
48
+ get selectedRecord() {
49
+ return this.gridModel.selectedRecord;
50
+ }
51
+
52
+ get groupByOptions() {
53
+ const cols = this.gridModel.getLeafColumns();
54
+ return [
55
+ ...this.componentProps.groupByOptions.map(it => ({
56
+ value: it,
57
+ label: cols.find(col => col.colId === it)?.displayName ?? it
58
+ })),
59
+ {value: null, label: 'None'}
60
+ ];
61
+ }
62
+
63
+ @action
64
+ toggleSearchIsOpen() {
65
+ this.isOpen = !this.isOpen;
66
+ }
67
+
68
+ constructor() {
69
+ super();
70
+ makeObservable(this);
71
+ }
72
+
73
+ override onLinked() {
74
+ this.gridModel = new GridModel({
75
+ ...this.gridModelConfig,
76
+ emptyText: 'No matches found...',
77
+ selModel: 'single'
78
+ });
79
+
80
+ this.markPersist('path', {localStorageKey: `xhJsonSearch${camelCase(this.subjectName)}`});
81
+
82
+ this.addReaction(
83
+ {
84
+ track: () => this.path,
85
+ run: path => {
86
+ if (isEmpty(path)) {
87
+ this.error = null;
88
+ this.gridModel.clear();
89
+ }
90
+ }
91
+ },
92
+ {
93
+ track: () => [this.selectedRecord, this.readerContentType, this.pathFormat],
94
+ run: () => this.loadReaderContentAsync(),
95
+ debounce: 300
96
+ }
97
+ );
98
+
99
+ // We might have a persisted path - go ahead and load if so.
100
+ this.loadMatchingDocsAsync();
101
+ }
102
+
103
+ async loadMatchingDocsAsync() {
104
+ const {path, gridModel, docLoadTask} = this;
105
+
106
+ if (isEmpty(path)) {
107
+ this.error = null;
108
+ gridModel.clear();
109
+ return;
110
+ }
111
+
112
+ try {
113
+ const data = await XH.fetchJson({
114
+ url: this.docSearchUrl,
115
+ params: {path}
116
+ }).linkTo(docLoadTask);
117
+
118
+ this.error = null;
119
+ gridModel.loadData(data);
120
+ gridModel.preSelectFirstAsync();
121
+ } catch (e) {
122
+ gridModel.clear();
123
+ this.error = e;
124
+ }
125
+ }
126
+
127
+ private async loadReaderContentAsync() {
128
+ if (!this.selectedRecord) {
129
+ this.matchingNodeCount = 0;
130
+ this.readerContent = '';
131
+ return;
132
+ }
133
+
134
+ const {json} = this.selectedRecord.data;
135
+
136
+ if (this.readerContentType === 'document') {
137
+ this.readerContent = JSON.stringify(JSON.parse(json), null, 2);
138
+ return;
139
+ }
140
+
141
+ let nodes = await XH.fetchJson({
142
+ url: this.matchingNodesUrl,
143
+ params: {
144
+ path: this.path,
145
+ json
146
+ }
147
+ }).linkTo(this.nodeLoadTask);
148
+
149
+ this.matchingNodeCount = nodes.paths.length;
150
+ nodes = zipWith(nodes.paths, nodes.values, (path: string, value) => {
151
+ return {
152
+ path: this.pathFormat === 'XPath' ? this.convertToXPath(path) : path,
153
+ value
154
+ };
155
+ });
156
+ this.readerContent = JSON.stringify(nodes, null, 2);
157
+ }
158
+
159
+ private convertToXPath(JSONPath: string): string {
160
+ return JSONPath.replaceAll(/^\$\['?/g, '/')
161
+ .replaceAll(/^\$/g, '')
162
+ .replaceAll(/'?]\['?/g, '/')
163
+ .replaceAll(/'?]$/g, '');
164
+ }
165
+
166
+ @action
167
+ private setGroupBy(groupBy: string) {
168
+ this.groupBy = groupBy;
169
+
170
+ // Always select first when regrouping.
171
+ const groupByArr = groupBy ? groupBy.split(',') : [];
172
+ this.gridModel.setGroupBy(groupByArr);
173
+ this.gridModel.preSelectFirstAsync();
174
+ }
175
+ }
@@ -9,7 +9,7 @@ import * as Col from '@xh/hoist/admin/columns';
9
9
  import {FilterChooserModel} from '@xh/hoist/cmp/filter';
10
10
  import {FormModel} from '@xh/hoist/cmp/form';
11
11
  import {GridModel} from '@xh/hoist/cmp/grid';
12
- import {HoistModel, LoadSpec, managed, XH} from '@xh/hoist/core';
12
+ import {HoistModel, LoadSpec, managed, PlainObject, XH} from '@xh/hoist/core';
13
13
  import {StoreRecord} from '@xh/hoist/data';
14
14
  import {fmtJson} from '@xh/hoist/format';
15
15
  import {action, bindable, comparer, computed, makeObservable, observable} from '@xh/hoist/mobx';
@@ -62,6 +62,14 @@ export class ClientErrorsModel extends HoistModel {
62
62
  {...Col.appVersion},
63
63
  {...Col.appEnvironment},
64
64
  {...Col.msg, displayName: 'User Message', hidden},
65
+ {
66
+ field: {name: 'errorName', type: 'string'},
67
+ autosizeMaxWidth: 400
68
+ },
69
+ {
70
+ field: {name: 'errorMessage', type: 'string'},
71
+ autosizeMaxWidth: 400
72
+ },
65
73
  {...Col.error, hidden},
66
74
  {...Col.url},
67
75
  {...Col.correlationId},
@@ -119,18 +127,29 @@ export class ClientErrorsModel extends HoistModel {
119
127
  }
120
128
 
121
129
  override async doLoadAsync(loadSpec: LoadSpec) {
122
- const {gridModel} = this;
130
+ const {query, gridModel} = this;
123
131
 
124
132
  try {
125
- const data = await XH.fetchService.postJson({
133
+ const data: PlainObject[] = await XH.fetchService.postJson({
126
134
  url: 'clientErrorAdmin',
127
- body: this.query,
135
+ body: query,
128
136
  loadSpec
129
137
  });
130
138
 
139
+ // Parse name + message from JSON-serialized error object out to top-level properties.
140
+ data.forEach(it => {
141
+ try {
142
+ const error = JSON.parse(it.error);
143
+ it.errorName = error?.name;
144
+ it.errorMessage = error?.message;
145
+ } catch (ignored) {}
146
+ });
147
+
131
148
  gridModel.loadData(data);
132
149
  await gridModel.preSelectFirstAsync();
133
150
  } catch (e) {
151
+ if (loadSpec.isStale || loadSpec.isAutoRefresh) return;
152
+
134
153
  gridModel.clear();
135
154
  XH.handleException(e);
136
155
  }
@@ -4,10 +4,14 @@
4
4
  *
5
5
  * Copyright Β© 2025 Extremely Heavy Industries Inc.
6
6
  */
7
+ import * as AdminCol from '@xh/hoist/admin/columns';
8
+ import * as Col from '@xh/hoist/admin/columns/Rest';
9
+ import {jsonSearchButton} from '@xh/hoist/admin/jsonsearch/JsonSearch';
7
10
  import {fragment} from '@xh/hoist/cmp/layout';
8
11
  import {creates, hoistCmp} from '@xh/hoist/core';
9
12
  import {button} from '@xh/hoist/desktop/cmp/button';
10
13
  import {restGrid} from '@xh/hoist/desktop/cmp/rest';
14
+ import {toolbarSep} from '@xh/hoist/desktop/cmp/toolbar';
11
15
  import {Icon} from '@xh/hoist/icon';
12
16
  import {differ} from '../../../differ/Differ';
13
17
  import {regroupDialog} from '../../../regroup/RegroupDialog';
@@ -20,13 +24,31 @@ export const configPanel = hoistCmp.factory({
20
24
  return fragment(
21
25
  restGrid({
22
26
  testId: 'config',
23
- extraToolbarItems: () => {
24
- return button({
27
+ extraToolbarItems: () => [
28
+ button({
25
29
  icon: Icon.diff(),
26
30
  text: 'Compare w/ Remote',
27
31
  onClick: () => model.openDiffer()
28
- });
29
- }
32
+ }),
33
+ toolbarSep(),
34
+ jsonSearchButton({
35
+ subjectName: 'Config',
36
+ docSearchUrl: 'jsonSearch/searchConfigs',
37
+ gridModelConfig: {
38
+ sortBy: ['groupName', 'name'],
39
+ columns: [
40
+ {...AdminCol.groupName},
41
+ {...AdminCol.name},
42
+ {
43
+ field: {name: 'json', type: 'string'},
44
+ hidden: true
45
+ },
46
+ {...Col.lastUpdated}
47
+ ]
48
+ },
49
+ groupByOptions: ['groupName']
50
+ })
51
+ ]
30
52
  }),
31
53
  differ({omit: !model.differModel}),
32
54
  regroupDialog()