@xh/hoist 73.0.0-SNAPSHOT.1738198923410 → 73.0.0-SNAPSHOT.1738248972394

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,13 +2,23 @@
2
2
 
3
3
  ## v73.0.0-SNAPSHOT - unreleased
4
4
 
5
+ ### 💥 Breaking Changes (upgrade difficulty: 🟢 LOW - Hoist core update)
6
+
7
+ * Requires `hoist-core >= 28.1` with new APIs to support JSON searching in the Admin Console.
8
+
9
+ ### 🎁 New Features
10
+
11
+ * Introduced a new "JSON Search" feature to the Hoist Admin Console, accessible from the Config,
12
+ User Preference, and JSON Blob tabs. Supports searching JSON values stored within these objects
13
+ to filter and match data using JSON Path expressions.
14
+
5
15
  ### 🐞 Bug Fixes
6
16
 
7
17
  * Fixed Role grid losing view state on refresh.
8
18
 
9
19
  ## v72.0.0 - 2025-01-27
10
20
 
11
- ### 💥 Breaking Changes
21
+ ### 💥 Breaking Changes (upgrade difficulty: 🟢 TRIVIAL - minor changes to mobile nav)
12
22
 
13
23
  * Mobile `Navigator` no longer supports `animation` prop, and `NavigatorModel` no longer supports
14
24
  `swipeToGoBack`. Both of these properties are now managed internally by the `Navigator` component.
@@ -21,7 +31,8 @@
21
31
  ### 🐞 Bug Fixes
22
32
 
23
33
  * Fixed `ViewManagerModel` unique name validation.
24
- * Fixed `GridModel.restoreDefaultsAsync()` to restore any default filter, rather than simply clearing it.
34
+ * Fixed `GridModel.restoreDefaultsAsync()` to restore any default filter, rather than simply
35
+ clearing it.
25
36
  * Improved suboptimal column state synchronization between `GridModel` and AG Grid.
26
37
 
27
38
  ### ⚙️ 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
+ }
@@ -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()
@@ -5,10 +5,15 @@
5
5
  * Copyright © 2025 Extremely Heavy Industries Inc.
6
6
  */
7
7
 
8
- import {fragment} from '@xh/hoist/cmp/layout';
8
+ import * as Col from '@xh/hoist/admin/columns/Rest';
9
+ import * as AdminCol from '@xh/hoist/admin/columns';
10
+ import {hframe} from '@xh/hoist/cmp/layout';
9
11
  import {creates, hoistCmp} from '@xh/hoist/core';
10
12
  import {button} from '@xh/hoist/desktop/cmp/button';
13
+ import {jsonSearchButton} from '@xh/hoist/admin/jsonsearch/JsonSearch';
14
+ import {panel} from '@xh/hoist/desktop/cmp/panel';
11
15
  import {restGrid} from '@xh/hoist/desktop/cmp/rest';
16
+ import {toolbarSep} from '@xh/hoist/desktop/cmp/toolbar';
12
17
  import {Icon} from '@xh/hoist/icon';
13
18
  import {differ} from '../../../differ/Differ';
14
19
  import {JsonBlobModel} from './JsonBlobModel';
@@ -17,15 +22,47 @@ export const jsonBlobPanel = hoistCmp.factory({
17
22
  model: creates(JsonBlobModel),
18
23
 
19
24
  render({model}) {
20
- return fragment(
21
- restGrid({
22
- extraToolbarItems: () => {
23
- return button({
24
- icon: Icon.diff(),
25
- text: 'Compare w/ Remote',
26
- onClick: () => model.openDiffer()
27
- });
28
- }
25
+ return hframe(
26
+ panel({
27
+ item: restGrid({
28
+ extraToolbarItems: () => [
29
+ button({
30
+ icon: Icon.diff(),
31
+ text: 'Compare w/ Remote',
32
+ onClick: () => model.openDiffer()
33
+ }),
34
+ toolbarSep(),
35
+ jsonSearchButton({
36
+ subjectName: 'JSON Blob',
37
+ docSearchUrl: 'jsonSearch/searchBlobs',
38
+ gridModelConfig: {
39
+ sortBy: ['type', 'name', 'owner'],
40
+ columns: [
41
+ {
42
+ field: {name: 'token', type: 'string'},
43
+ hidden: true,
44
+ width: 100
45
+ },
46
+ {
47
+ field: {name: 'type', type: 'string'},
48
+ width: 200
49
+ },
50
+ {
51
+ field: {name: 'owner', type: 'string'},
52
+ width: 200
53
+ },
54
+ {...AdminCol.name},
55
+ {
56
+ field: {name: 'json', type: 'string'},
57
+ hidden: true
58
+ },
59
+ {...Col.lastUpdated}
60
+ ]
61
+ },
62
+ groupByOptions: ['owner', 'type', 'name']
63
+ })
64
+ ]
65
+ })
29
66
  }),
30
67
  differ({omit: !model.differModel})
31
68
  );