@xh/hoist 86.1.0 → 86.3.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.
- package/.gitattributes +4 -0
- package/CHANGELOG.md +104 -0
- package/admin/AdminJsonDisplay.ts +31 -0
- package/admin/App.scss +3 -11
- package/admin/AppModel.ts +3 -4
- package/admin/jsonsearch/impl/JsonSearchImplModel.ts +4 -4
- package/admin/tabs/activity/tracking/ActivityTrackingModel.ts +56 -6
- package/admin/tabs/activity/tracking/ActivityTrackingPanel.ts +9 -26
- package/admin/tabs/clients/ClientsModel.ts +4 -1
- package/admin/tabs/clients/activity/ClientDetailModel.ts +5 -1
- package/admin/tabs/cluster/instances/connpool/ConnPoolMonitorPanel.ts +3 -11
- package/admin/tabs/cluster/instances/services/DetailsPanel.ts +2 -13
- package/admin/tabs/cluster/objects/DetailPanel.ts +3 -12
- package/admin/tabs/userData/roles/RolePanel.ts +2 -2
- package/appcontainer/RouterModel.ts +6 -6
- package/build/types/admin/AdminJsonDisplay.d.ts +7 -0
- package/build/types/admin/AppModel.d.ts +3 -4
- package/build/types/admin/jsonsearch/impl/JsonSearchImplModel.d.ts +2 -2
- package/build/types/admin/tabs/activity/tracking/ActivityTrackingModel.d.ts +14 -3
- package/build/types/admin/tabs/clients/ClientsModel.d.ts +3 -0
- package/build/types/appcontainer/RouterModel.d.ts +5 -5
- package/build/types/cmp/grid/Grid.d.ts +8 -4
- package/build/types/cmp/grid/GridModel.d.ts +13 -2
- package/build/types/cmp/grid/Types.d.ts +8 -1
- package/build/types/cmp/grid/filter/GridFilterFieldSpec.d.ts +24 -6
- package/build/types/cmp/input/SegmentedControlOption.d.ts +12 -0
- package/build/types/core/HoistAppModel.d.ts +2 -3
- package/build/types/core/types/Types.d.ts +12 -0
- package/build/types/desktop/cmp/button/grid/ExpandToLevelButton.d.ts +2 -1
- package/build/types/desktop/cmp/filter/FilterChooser.d.ts +5 -0
- package/build/types/desktop/cmp/filter/PopoverFilterChooser.d.ts +3 -1
- package/build/types/desktop/cmp/grid/impl/colchooser/ColChooserModel.d.ts +2 -2
- package/build/types/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomRowModel.d.ts +9 -1
- package/build/types/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomTabModel.d.ts +8 -2
- package/build/types/desktop/cmp/input/CodeInput.d.ts +7 -0
- package/build/types/desktop/cmp/input/NumberInput.d.ts +1 -1
- package/build/types/desktop/cmp/input/Select.d.ts +8 -0
- package/build/types/desktop/cmp/input/impl/CalcWindowedMenuWidth.d.ts +20 -0
- package/build/types/desktop/cmp/rest/impl/RestFormModel.d.ts +1 -1
- package/build/types/format/FormatNumber.d.ts +11 -3
- package/build/types/icon/Icon.d.ts +4 -3
- package/build/types/kit/react-dropzone/index.d.ts +1 -0
- package/build/types/kit/swiper/index.d.ts +4 -3
- package/build/types/mobile/cmp/button/grid/ExpandToLevelButton.d.ts +2 -1
- package/build/types/mobile/cmp/grid/impl/ColChooserModel.d.ts +2 -2
- package/build/types/mobile/cmp/input/NumberInput.d.ts +1 -1
- package/build/types/mobile/cmp/input/Select.d.ts +8 -0
- package/cmp/grid/Grid.ts +27 -2
- package/cmp/grid/GridModel.ts +21 -8
- package/cmp/grid/Types.ts +9 -1
- package/cmp/grid/filter/GridFilterFieldSpec.ts +28 -5
- package/cmp/grid/impl/MenuSupport.ts +3 -5
- package/cmp/input/SegmentedControlOption.ts +14 -0
- package/cmp/store/impl/StoreFilterFieldImplModel.ts +5 -6
- package/core/HoistAppModel.ts +2 -3
- package/core/types/Types.ts +14 -5
- package/desktop/cmp/button/grid/ExpandToLevelButton.ts +4 -5
- package/desktop/cmp/filechooser/FileChooser.ts +6 -3
- package/desktop/cmp/filechooser/FileChooserModel.ts +12 -5
- package/desktop/cmp/filter/FilterChooser.scss +41 -0
- package/desktop/cmp/filter/FilterChooser.ts +147 -7
- package/desktop/cmp/filter/PopoverFilterChooser.ts +7 -84
- package/desktop/cmp/grid/editors/NumberEditor.ts +16 -17
- package/desktop/cmp/grid/editors/SelectEditor.ts +15 -10
- package/desktop/cmp/grid/editors/impl/InlineEditorModel.ts +6 -1
- package/desktop/cmp/grid/find/impl/GridFindFieldImplModel.ts +3 -5
- package/desktop/cmp/grid/impl/colchooser/ColChooserModel.ts +2 -2
- package/desktop/cmp/grid/impl/filter/GridFilterDialog.ts +2 -2
- package/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomRow.ts +12 -12
- package/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomRowModel.ts +18 -0
- package/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomTab.scss +5 -0
- package/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomTab.ts +13 -2
- package/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomTabModel.ts +97 -14
- package/desktop/cmp/grid/impl/filter/headerfilter/values/ValuesTabModel.ts +46 -7
- package/desktop/cmp/input/CheckboxButton.ts +21 -1
- package/desktop/cmp/input/CodeInput.ts +20 -3
- package/desktop/cmp/input/NumberInput.ts +1 -1
- package/desktop/cmp/input/SegmentedControl.ts +7 -3
- package/desktop/cmp/input/Select.ts +37 -18
- package/desktop/cmp/input/impl/CalcWindowedMenuWidth.ts +106 -0
- package/desktop/cmp/rest/impl/RestFormField.ts +1 -1
- package/desktop/cmp/tab/Tabs.scss +72 -25
- package/docs/routing.md +4 -2
- package/docs/upgrade-notes/v86-upgrade-notes.md +73 -8
- package/format/FormatNumber.ts +69 -32
- package/icon/Icon.scss +13 -0
- package/icon/Icon.ts +4 -3
- package/icon/impl/IconHtml.ts +1 -1
- package/kit/react-dropzone/index.ts +1 -0
- package/mcp/README.md +12 -0
- package/mcp/data/ts-registry.spec.ts +156 -0
- package/mcp/data/ts-registry.ts +14 -7
- package/mcp/formatters/typescript.ts +7 -3
- package/mcp/util/paths.ts +21 -0
- package/mobile/cmp/button/grid/ExpandToLevelButton.ts +4 -5
- package/mobile/cmp/grid/impl/ColChooserModel.ts +2 -2
- package/mobile/cmp/input/CheckboxButton.ts +18 -1
- package/mobile/cmp/input/NumberInput.ts +1 -1
- package/mobile/cmp/input/SegmentedControl.ts +7 -3
- package/mobile/cmp/input/Select.ts +21 -15
- package/mobx/README.md +2 -1
- package/package.json +10 -10
- package/styles/vars.scss +15 -0
- package/desktop/cmp/filter/PopoverFilterChooser.scss +0 -42
|
@@ -5,12 +5,18 @@
|
|
|
5
5
|
* Copyright © 2026 Extremely Heavy Industries Inc.
|
|
6
6
|
*/
|
|
7
7
|
import {HoistModel, XH} from '@xh/hoist/core';
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
CompoundFilterOperator,
|
|
10
|
+
FieldFilter,
|
|
11
|
+
FieldFilterOperator,
|
|
12
|
+
FieldFilterSpec,
|
|
13
|
+
FilterLike
|
|
14
|
+
} from '@xh/hoist/data';
|
|
9
15
|
import {action, bindable, computed, makeObservable, observable} from '@xh/hoist/mobx';
|
|
10
|
-
import {compact, isEmpty} from 'lodash';
|
|
16
|
+
import {compact, first, flatMap, forEach, groupBy, isArray, isEmpty, uniq} from 'lodash';
|
|
11
17
|
import {HeaderFilterModel} from '../HeaderFilterModel';
|
|
12
18
|
|
|
13
|
-
import {CustomRowModel} from './CustomRowModel';
|
|
19
|
+
import {CustomRowModel, usesMultiValueInput} from './CustomRowModel';
|
|
14
20
|
|
|
15
21
|
export class CustomTabModel extends HoistModel {
|
|
16
22
|
override xhImpl = true;
|
|
@@ -23,11 +29,16 @@ export class CustomTabModel extends HoistModel {
|
|
|
23
29
|
/** Filter config output by this model. */
|
|
24
30
|
@computed.struct
|
|
25
31
|
get filter(): FilterLike {
|
|
26
|
-
const {op, rowModels} = this
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
if (
|
|
30
|
-
|
|
32
|
+
const {op, rowModels} = this;
|
|
33
|
+
|
|
34
|
+
// Null rowModels flags an unrepresentable filter - emit it unchanged so commit is a no-op.
|
|
35
|
+
if (!rowModels) return this.columnCompoundFilter ?? this.columnFilters;
|
|
36
|
+
|
|
37
|
+
const specs = compact(rowModels.map(it => it.value));
|
|
38
|
+
if (isEmpty(specs)) return null;
|
|
39
|
+
|
|
40
|
+
const filters = this.collapseToArrayFilters(specs, op);
|
|
41
|
+
return filters.length > 1 ? {filters, op} : first(filters);
|
|
31
42
|
}
|
|
32
43
|
|
|
33
44
|
get fieldSpec() {
|
|
@@ -42,6 +53,10 @@ export class CustomTabModel extends HoistModel {
|
|
|
42
53
|
return this.headerFilterModel.columnFilters;
|
|
43
54
|
}
|
|
44
55
|
|
|
56
|
+
get columnCompoundFilter() {
|
|
57
|
+
return this.headerFilterModel.columnCompoundFilter;
|
|
58
|
+
}
|
|
59
|
+
|
|
45
60
|
constructor(headerFilterModel: HeaderFilterModel) {
|
|
46
61
|
super();
|
|
47
62
|
makeObservable(this);
|
|
@@ -74,13 +89,27 @@ export class CustomTabModel extends HoistModel {
|
|
|
74
89
|
//-------------------
|
|
75
90
|
@action
|
|
76
91
|
private doSyncWithFilter() {
|
|
77
|
-
const {
|
|
78
|
-
|
|
92
|
+
const {columnCompoundFilter} = this,
|
|
93
|
+
op = this.deriveOp();
|
|
94
|
+
this.op = op;
|
|
95
|
+
|
|
96
|
+
if (!this.isRepresentable) {
|
|
97
|
+
this.logWarn('Filter cannot be edited in the custom tab; leaving it unchanged');
|
|
98
|
+
this.rowModels = null;
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
79
101
|
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
102
|
+
// Expand a multi-value clause destined for a single-value input into one row per value
|
|
103
|
+
// (joined under the tab op); the multi-select input holds the array directly as one row.
|
|
104
|
+
const rowModels = [],
|
|
105
|
+
children = (columnCompoundFilter?.filters ?? this.columnFilters) as FieldFilter[];
|
|
106
|
+
children.forEach(filter => {
|
|
107
|
+
const {op: fieldOp, value} = filter;
|
|
108
|
+
if (this.needsExpansion(filter)) {
|
|
109
|
+
value.forEach(v => rowModels.push(new CustomRowModel(this, fieldOp, v)));
|
|
110
|
+
} else {
|
|
111
|
+
rowModels.push(new CustomRowModel(this, fieldOp, value));
|
|
112
|
+
}
|
|
84
113
|
});
|
|
85
114
|
|
|
86
115
|
// Add an empty pending row
|
|
@@ -90,4 +119,58 @@ export class CustomTabModel extends HoistModel {
|
|
|
90
119
|
|
|
91
120
|
this.rowModels = rowModels;
|
|
92
121
|
}
|
|
122
|
+
|
|
123
|
+
// Whether a multi-value clause must be expanded to one row per value (vs held by a multi-select).
|
|
124
|
+
private needsExpansion({op, value}: FieldFilter): boolean {
|
|
125
|
+
return isArray(value) && value.length > 1 && !usesMultiValueInput(this.fieldSpec, op);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// The op a multi-value clause joins under: `=`-family => OR, negated => AND.
|
|
129
|
+
private mergeOpFor(op: FieldFilterOperator): CompoundFilterOperator {
|
|
130
|
+
return FieldFilter.INCLUDE_LIKE_OPERATORS.includes(op) ? 'OR' : 'AND';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Op joining the tab's rows
|
|
134
|
+
private deriveOp(): CompoundFilterOperator {
|
|
135
|
+
const {columnCompoundFilter, columnFilters} = this;
|
|
136
|
+
if (columnCompoundFilter) return columnCompoundFilter.op;
|
|
137
|
+
|
|
138
|
+
const arrayFilter = columnFilters.find(f => this.needsExpansion(f));
|
|
139
|
+
return arrayFilter ? this.mergeOpFor(arrayFilter.op) : 'AND';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Filter must be representable as a flat set of rows with a single op
|
|
143
|
+
private get isRepresentable(): boolean {
|
|
144
|
+
const {columnCompoundFilter} = this,
|
|
145
|
+
op = this.deriveOp(),
|
|
146
|
+
children = columnCompoundFilter?.filters ?? this.columnFilters;
|
|
147
|
+
return children.every(
|
|
148
|
+
f =>
|
|
149
|
+
FieldFilter.isFieldFilter(f) &&
|
|
150
|
+
(!this.needsExpansion(f) || op === this.mergeOpFor(f.op))
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Inverse of the expand in `doSyncWithFilter`: collapse same-field/op rows into one multi-value FieldFilter
|
|
155
|
+
private collapseToArrayFilters(
|
|
156
|
+
specs: FieldFilterSpec[],
|
|
157
|
+
op: CompoundFilterOperator
|
|
158
|
+
): FieldFilterSpec[] {
|
|
159
|
+
const ret: FieldFilterSpec[] = [];
|
|
160
|
+
forEach(groupBy(specs, 'op'), (groupSpecs, groupOp: FieldFilterOperator) => {
|
|
161
|
+
const canMerge =
|
|
162
|
+
groupSpecs.length > 1 &&
|
|
163
|
+
FieldFilter.ARRAY_OPERATORS.includes(groupOp) &&
|
|
164
|
+
op === this.mergeOpFor(groupOp);
|
|
165
|
+
|
|
166
|
+
if (canMerge) {
|
|
167
|
+
const {field} = groupSpecs[0],
|
|
168
|
+
value = uniq(flatMap(groupSpecs, it => it.value));
|
|
169
|
+
ret.push({field, op: groupOp, value});
|
|
170
|
+
} else {
|
|
171
|
+
ret.push(...groupSpecs);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
return ret;
|
|
175
|
+
}
|
|
93
176
|
}
|
|
@@ -4,13 +4,28 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Copyright © 2026 Extremely Heavy Industries Inc.
|
|
6
6
|
*/
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
GridFilterModel,
|
|
9
|
+
GridFilterRenderer,
|
|
10
|
+
GridFilterSortValueFn,
|
|
11
|
+
GridModel
|
|
12
|
+
} from '@xh/hoist/cmp/grid';
|
|
8
13
|
import {HoistModel, managed} from '@xh/hoist/core';
|
|
9
14
|
import type {FieldFilterOperator, FieldFilterSpec} from '@xh/hoist/data';
|
|
10
15
|
import {checkbox} from '@xh/hoist/desktop/cmp/input';
|
|
11
16
|
import {Icon} from '@xh/hoist/icon';
|
|
12
17
|
import {action, bindable, computed, makeObservable, observable} from '@xh/hoist/mobx';
|
|
13
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
castArray,
|
|
20
|
+
difference,
|
|
21
|
+
flatten,
|
|
22
|
+
isEmpty,
|
|
23
|
+
isFunction,
|
|
24
|
+
map,
|
|
25
|
+
partition,
|
|
26
|
+
uniq,
|
|
27
|
+
without
|
|
28
|
+
} from 'lodash';
|
|
14
29
|
import {HeaderFilterModel} from '../HeaderFilterModel';
|
|
15
30
|
|
|
16
31
|
export class ValuesTabModel extends HoistModel {
|
|
@@ -281,8 +296,18 @@ export class ValuesTabModel extends HoistModel {
|
|
|
281
296
|
private createGridModel() {
|
|
282
297
|
const {BLANK_PLACEHOLDER} = GridFilterModel,
|
|
283
298
|
{headerFilterModel, fieldSpec} = this,
|
|
284
|
-
{fieldType, column} = headerFilterModel
|
|
285
|
-
|
|
299
|
+
{fieldType, column} = headerFilterModel;
|
|
300
|
+
|
|
301
|
+
// Default to the column's renderer/sortValue, but only where they apply to a bare value -
|
|
302
|
+
// we call them with the value alone (see below), so treat them as pure value transforms.
|
|
303
|
+
const renderer =
|
|
304
|
+
fieldSpec.renderer ??
|
|
305
|
+
(fieldType !== 'tags' ? (column.renderer as GridFilterRenderer) : null),
|
|
306
|
+
sortValue =
|
|
307
|
+
fieldSpec.sortValue ??
|
|
308
|
+
(fieldType !== 'tags' && isFunction(column.sortValue)
|
|
309
|
+
? (column.sortValue as GridFilterSortValueFn)
|
|
310
|
+
: null);
|
|
286
311
|
|
|
287
312
|
return new GridModel({
|
|
288
313
|
store: {
|
|
@@ -340,9 +365,23 @@ export class ValuesTabModel extends HoistModel {
|
|
|
340
365
|
if (v2 === BLANK_PLACEHOLDER) return -1 * mul;
|
|
341
366
|
return defaultComparator(v1, v2);
|
|
342
367
|
},
|
|
343
|
-
renderer
|
|
344
|
-
|
|
345
|
-
|
|
368
|
+
// Apply renderer/sortValue as pure value transforms - pass no context, skip the
|
|
369
|
+
// blank placeholder, and fall back to the raw value if either throws.
|
|
370
|
+
sortValue: v => {
|
|
371
|
+
if (v === BLANK_PLACEHOLDER || !sortValue) return v;
|
|
372
|
+
try {
|
|
373
|
+
return sortValue(v);
|
|
374
|
+
} catch {
|
|
375
|
+
return v;
|
|
376
|
+
}
|
|
377
|
+
},
|
|
378
|
+
renderer: v => {
|
|
379
|
+
if (v === BLANK_PLACEHOLDER || !renderer) return v;
|
|
380
|
+
try {
|
|
381
|
+
return renderer(v);
|
|
382
|
+
} catch {
|
|
383
|
+
return v;
|
|
384
|
+
}
|
|
346
385
|
}
|
|
347
386
|
}
|
|
348
387
|
],
|
|
@@ -47,7 +47,27 @@ class CheckboxButtonInputModel extends HoistInputModel {
|
|
|
47
47
|
// Implementation
|
|
48
48
|
//----------------------------------
|
|
49
49
|
const cmp = hoistCmp.factory<CheckboxButtonInputModel>(
|
|
50
|
-
(
|
|
50
|
+
(
|
|
51
|
+
{
|
|
52
|
+
// HoistInput props - exclude from passthrough to BP
|
|
53
|
+
bind,
|
|
54
|
+
value,
|
|
55
|
+
commitOnChange,
|
|
56
|
+
onChange,
|
|
57
|
+
onCommit,
|
|
58
|
+
// Consumed by this component
|
|
59
|
+
model,
|
|
60
|
+
text,
|
|
61
|
+
icon,
|
|
62
|
+
rightIcon,
|
|
63
|
+
checkedIcon,
|
|
64
|
+
uncheckedIcon,
|
|
65
|
+
iconSide,
|
|
66
|
+
// Remainder passed to hoist button & BP button
|
|
67
|
+
...props
|
|
68
|
+
},
|
|
69
|
+
ref
|
|
70
|
+
) => {
|
|
51
71
|
const checked = !!model.renderValue,
|
|
52
72
|
toggleIcon = checked
|
|
53
73
|
? withDefault(checkedIcon, Icon.checkSquare({prefix: 'fas', intent: 'primary'}))
|
|
@@ -62,6 +62,14 @@ export interface CodeInputProps extends HoistProps, HoistInputProps, LayoutProps
|
|
|
62
62
|
/** True to focus the control on render. */
|
|
63
63
|
autoFocus?: boolean;
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* True to automatically format content for display using the configured `formatter`.
|
|
67
|
+
* Defaults to true for `readonly` inputs - set false to opt out. May also be enabled on
|
|
68
|
+
* editable inputs, in which case content is formatted on blur (never mid-edit, so user
|
|
69
|
+
* edits and cursor position are preserved while typing).
|
|
70
|
+
*/
|
|
71
|
+
autoFormat?: boolean;
|
|
72
|
+
|
|
65
73
|
/** False to not commit on every change/keystroke, default true. */
|
|
66
74
|
commitOnChange?: boolean;
|
|
67
75
|
|
|
@@ -224,7 +232,7 @@ class CodeInputModel extends HoistInputModel {
|
|
|
224
232
|
? button({
|
|
225
233
|
icon: Icon.magic(),
|
|
226
234
|
title: 'Auto-format',
|
|
227
|
-
onClick: () => this.
|
|
235
|
+
onClick: () => this.formatAndSetEditorValue()
|
|
228
236
|
})
|
|
229
237
|
: null,
|
|
230
238
|
showFullscreenButton
|
|
@@ -321,7 +329,16 @@ class CodeInputModel extends HoistInputModel {
|
|
|
321
329
|
this.editor = new EditorView({state, parent: container});
|
|
322
330
|
};
|
|
323
331
|
|
|
324
|
-
|
|
332
|
+
get autoFormat(): boolean {
|
|
333
|
+
const {autoFormat, readonly} = this.componentProps;
|
|
334
|
+
return withDefault(autoFormat, !!readonly);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
override toInternal(val: any) {
|
|
338
|
+
return this.autoFormat ? this.tryPrettyPrint(val) : val;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
private formatAndSetEditorValue() {
|
|
325
342
|
if (!this.editor) return;
|
|
326
343
|
const val = this.tryPrettyPrint(this.editor.state.doc.toString());
|
|
327
344
|
this.editor.dispatch({changes: {from: 0, to: this.editor.state.doc.length, insert: val}});
|
|
@@ -443,7 +460,7 @@ class CodeInputModel extends HoistInputModel {
|
|
|
443
460
|
{
|
|
444
461
|
key: 'Mod-p',
|
|
445
462
|
run: () => {
|
|
446
|
-
this.
|
|
463
|
+
this.formatAndSetEditorValue();
|
|
447
464
|
return true;
|
|
448
465
|
}
|
|
449
466
|
}
|
|
@@ -61,7 +61,7 @@ export interface NumberInputProps extends HoistProps, LayoutProps, StyleProps, H
|
|
|
61
61
|
/** Text to display when control is empty. */
|
|
62
62
|
placeholder?: string;
|
|
63
63
|
|
|
64
|
-
/** Max decimal precision of the value, defaults to 4. */
|
|
64
|
+
/** Max decimal precision of the value, defaults to 4. Set to null for full, unrestricted precision. */
|
|
65
65
|
precision?: NumericPrecision;
|
|
66
66
|
|
|
67
67
|
/** Element to display inline on the right side of the input. */
|
|
@@ -80,6 +80,7 @@ export const [SegmentedControl, segmentedControl] = hoistCmp.withFactory<Segment
|
|
|
80
80
|
interface NormalizedOption extends SegmentedControlOption {
|
|
81
81
|
label: string;
|
|
82
82
|
intent?: Intent;
|
|
83
|
+
testId?: string;
|
|
83
84
|
_key: string;
|
|
84
85
|
}
|
|
85
86
|
|
|
@@ -92,13 +93,14 @@ class SegmentedControlModel extends HoistInputModel {
|
|
|
92
93
|
return options.map((o: any, idx: number) => {
|
|
93
94
|
const key = String(idx);
|
|
94
95
|
if (isObject(o)) {
|
|
95
|
-
const {label, value, icon, disabled, intent} = o as SegmentedControlOption;
|
|
96
|
+
const {label, value, icon, disabled, intent, testId} = o as SegmentedControlOption;
|
|
96
97
|
return {
|
|
97
98
|
value: this.toInternal(value),
|
|
98
99
|
label: label ?? (icon ? '' : String(value)),
|
|
99
100
|
icon,
|
|
100
101
|
disabled,
|
|
101
102
|
intent,
|
|
103
|
+
testId,
|
|
102
104
|
_key: key
|
|
103
105
|
};
|
|
104
106
|
} else {
|
|
@@ -163,13 +165,15 @@ const cmp = hoistCmp.factory<SegmentedControlModel>(({model, className, ...props
|
|
|
163
165
|
// applied via a per-button className that our SCSS keys its solid/hint coloring off of.
|
|
164
166
|
const defaultIntent = intent && intent !== 'none' ? intent : null,
|
|
165
167
|
bpOptions = model.normalizedOptions.map(opt => {
|
|
166
|
-
const optIntent = opt.intent ?? defaultIntent
|
|
168
|
+
const optIntent = opt.intent ?? defaultIntent,
|
|
169
|
+
optTestId = opt.testId ?? (testId ? `${testId}-${String(opt.value)}` : null);
|
|
167
170
|
return {
|
|
168
171
|
value: opt._key,
|
|
169
172
|
label: opt.label,
|
|
170
173
|
icon: opt.icon,
|
|
171
174
|
disabled: opt.disabled,
|
|
172
|
-
className: optIntent ? `xh-segmented-control-option--${optIntent}` : null
|
|
175
|
+
className: optIntent ? `xh-segmented-control-option--${optIntent}` : null,
|
|
176
|
+
...(optTestId ? {[TEST_ID]: optTestId} : null)
|
|
173
177
|
};
|
|
174
178
|
});
|
|
175
179
|
|
|
@@ -31,9 +31,10 @@ import {debouncePromise, wait} from '@xh/hoist/promise';
|
|
|
31
31
|
import {elemWithin, getTestId, mergeDeep, TEST_ID, throwIf, withDefault} from '@xh/hoist/utils/js';
|
|
32
32
|
import {createObservableRef, getLayoutProps} from '@xh/hoist/utils/react';
|
|
33
33
|
import classNames from 'classnames';
|
|
34
|
-
import {castArray, escapeRegExp, isEmpty, isEqual, isNil, isPlainObject,
|
|
34
|
+
import {castArray, escapeRegExp, isEmpty, isEqual, isNil, isPlainObject, unionWith} from 'lodash';
|
|
35
35
|
import {ReactElement, ReactNode} from 'react';
|
|
36
36
|
import {components} from 'react-select';
|
|
37
|
+
import {calcWindowedMenuWidth} from './impl/CalcWindowedMenuWidth';
|
|
37
38
|
import './Select.scss';
|
|
38
39
|
|
|
39
40
|
export const MENU_PORTAL_ID = 'xh-select-input-portal';
|
|
@@ -190,6 +191,15 @@ export interface SelectProps extends HoistProps, HoistInputProps, LayoutProps {
|
|
|
190
191
|
|
|
191
192
|
/** Field on provided options for sourcing each option's value (default `value`). */
|
|
192
193
|
valueField?: string;
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Function to generate a `SelectOption` for a (non-null) selected value not present in the
|
|
197
|
+
* current options list. Return null to fall back to the default value-as-label behavior.
|
|
198
|
+
*
|
|
199
|
+
* Useful with queryFn-based selects, readonly forms, or any case where options may not be
|
|
200
|
+
* loaded when a value is set, ensuring the value renders with its proper label.
|
|
201
|
+
*/
|
|
202
|
+
generateOptionFn?: (value: any) => SelectOption;
|
|
193
203
|
}
|
|
194
204
|
|
|
195
205
|
/**
|
|
@@ -224,6 +234,8 @@ class SelectInputModel extends HoistInputModel {
|
|
|
224
234
|
// Maintained for (but not passed to) async select to resolve value string <> option objects.
|
|
225
235
|
@bindable.ref internalOptions = [];
|
|
226
236
|
|
|
237
|
+
@observable windowedMenuWidth: number = null;
|
|
238
|
+
|
|
227
239
|
// Prop-backed convenience getters
|
|
228
240
|
get asyncMode(): boolean {
|
|
229
241
|
return !!this.componentProps.queryFn;
|
|
@@ -290,6 +302,13 @@ class SelectInputModel extends HoistInputModel {
|
|
|
290
302
|
run: opts => {
|
|
291
303
|
opts = this.normalizeOptions(opts);
|
|
292
304
|
this.internalOptions = opts;
|
|
305
|
+
if (this.windowedMode) {
|
|
306
|
+
this.windowedMenuWidth = calcWindowedMenuWidth(
|
|
307
|
+
opts,
|
|
308
|
+
o => this.formatOptionLabel(o, {context: 'menu'}),
|
|
309
|
+
this.getOrCreatePortalDiv()
|
|
310
|
+
);
|
|
311
|
+
}
|
|
293
312
|
},
|
|
294
313
|
fireImmediately: true
|
|
295
314
|
});
|
|
@@ -427,7 +446,8 @@ class SelectInputModel extends HoistInputModel {
|
|
|
427
446
|
|
|
428
447
|
// Convert external value into option object(s). Options created if missing - this takes the
|
|
429
448
|
// external value from the model, and we will respect that even if we don't know about it.
|
|
430
|
-
// (Exception for a null value, which
|
|
449
|
+
// (Exception for a null value, which is never synthesized - accepted only if provided via
|
|
450
|
+
// options.)
|
|
431
451
|
override toInternal(external) {
|
|
432
452
|
if (this.multiMode) {
|
|
433
453
|
if (external == null || isEqual(external, this.emptyValue)) external = []; // avoid [null]
|
|
@@ -447,7 +467,10 @@ class SelectInputModel extends HoistInputModel {
|
|
|
447
467
|
}
|
|
448
468
|
}
|
|
449
469
|
|
|
450
|
-
|
|
470
|
+
if (!createIfNotFound) return null;
|
|
471
|
+
|
|
472
|
+
// Value not among options - let the app generate an option for it, else synthesize one.
|
|
473
|
+
return this.componentProps.generateOptionFn?.(value) ?? this.valueToOption(value);
|
|
451
474
|
}
|
|
452
475
|
|
|
453
476
|
override toExternal(internal) {
|
|
@@ -471,11 +494,11 @@ class SelectInputModel extends HoistInputModel {
|
|
|
471
494
|
// Normalize / clone a single source value into a normalized option object. Supports Strings
|
|
472
495
|
// and Objects. Objects are validated/defaulted to ensure a label+value or label+options sublist,
|
|
473
496
|
// with other fields brought along to support Selects emitting value objects with ad hoc properties.
|
|
474
|
-
private toOption(src, depth) {
|
|
497
|
+
private toOption(src, depth): SelectOption {
|
|
475
498
|
return isPlainObject(src) ? this.objectToOption(src, depth) : this.valueToOption(src);
|
|
476
499
|
}
|
|
477
500
|
|
|
478
|
-
private objectToOption(src, depth) {
|
|
501
|
+
private objectToOption(src, depth): SelectOption {
|
|
479
502
|
const {componentProps} = this,
|
|
480
503
|
labelField = withDefault(componentProps.labelField, 'label'),
|
|
481
504
|
valueField = withDefault(componentProps.valueField, 'value');
|
|
@@ -498,7 +521,7 @@ class SelectInputModel extends HoistInputModel {
|
|
|
498
521
|
};
|
|
499
522
|
}
|
|
500
523
|
|
|
501
|
-
private valueToOption(src) {
|
|
524
|
+
private valueToOption(src): SelectOption {
|
|
502
525
|
return {label: src != null ? src.toString() : '-null-', value: src};
|
|
503
526
|
}
|
|
504
527
|
|
|
@@ -511,13 +534,9 @@ class SelectInputModel extends HoistInputModel {
|
|
|
511
534
|
|
|
512
535
|
// Carry forward and add to any existing internalOpts to allow our value
|
|
513
536
|
// converters to continue all selected values in multiMode.
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
const matchOpt = matchesByVal[currOpt.value];
|
|
518
|
-
if (!matchOpt) newOpts.push(currOpt); // avoiding dupes
|
|
519
|
-
});
|
|
520
|
-
this.internalOptions = newOpts;
|
|
537
|
+
this.internalOptions = unionWith(matchOpts, this.internalOptions, (a, b) =>
|
|
538
|
+
isEqual(a.value, b.value)
|
|
539
|
+
);
|
|
521
540
|
|
|
522
541
|
// But only return the matching options back to the combo.
|
|
523
542
|
return matchOpts;
|
|
@@ -534,8 +553,7 @@ class SelectInputModel extends HoistInputModel {
|
|
|
534
553
|
// Option Rendering
|
|
535
554
|
//----------------------
|
|
536
555
|
formatOptionLabel = (opt, params) => {
|
|
537
|
-
//
|
|
538
|
-
// If we need to expose customization here, we could consider a dedicated prop.
|
|
556
|
+
// Display the standard label string in the value container (context == 'value').
|
|
539
557
|
if (params.context !== 'menu') {
|
|
540
558
|
return opt.label;
|
|
541
559
|
}
|
|
@@ -551,7 +569,7 @@ class SelectInputModel extends HoistInputModel {
|
|
|
551
569
|
return div(opt.label);
|
|
552
570
|
}
|
|
553
571
|
|
|
554
|
-
return castArray(this.externalValue).
|
|
572
|
+
return castArray(this.externalValue).some(v => isEqual(v, opt.value))
|
|
555
573
|
? hbox({
|
|
556
574
|
items: [
|
|
557
575
|
div({
|
|
@@ -780,9 +798,10 @@ const cmp = hoistCmp.factory<SelectInputModel>(({model, className, ...props}, re
|
|
|
780
798
|
rsProps.formatCreateLabel = model.createMessageFn;
|
|
781
799
|
}
|
|
782
800
|
|
|
783
|
-
|
|
801
|
+
const menuWidth = props.menuWidth ?? (model.windowedMode ? model.windowedMenuWidth : null);
|
|
802
|
+
if (menuWidth != null) {
|
|
784
803
|
rsProps.styles = {
|
|
785
|
-
menu: provided => ({...provided, width:
|
|
804
|
+
menu: provided => ({...provided, width: menuWidth, minWidth: '100%'}),
|
|
786
805
|
...props.rsOptions?.styles
|
|
787
806
|
};
|
|
788
807
|
}
|
|
@@ -0,0 +1,106 @@
|
|
|
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 © 2026 Extremely Heavy Industries Inc.
|
|
6
|
+
*/
|
|
7
|
+
import {logWarn, stripTags} from '@xh/hoist/utils/js';
|
|
8
|
+
import {renderToStaticMarkup} from '@xh/hoist/utils/react';
|
|
9
|
+
import {isEmpty, isNil, isString, sortBy, takeRight} from 'lodash';
|
|
10
|
+
import {isValidElement, ReactNode} from 'react';
|
|
11
|
+
|
|
12
|
+
/** Max number of (widest) options to render and measure when sizing a windowed menu. */
|
|
13
|
+
const SIZE_CALC_SAMPLES = 25;
|
|
14
|
+
/** Allowance for the vertical scrollbar present in windowed (virtualized) menus. */
|
|
15
|
+
const SCROLLBAR_PX = 20;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Compute an explicit pixel width for a windowed (virtualized) `Select` menu.
|
|
19
|
+
*
|
|
20
|
+
* Windowed menus can't auto-size via CSS - react-window absolutely-positions rows at width:100%,
|
|
21
|
+
* so option content never widens the menu and it collapses to the control width. This restores
|
|
22
|
+
* content-based sizing using Canvas + off-screen DOM, the same render-and-measure approach as the
|
|
23
|
+
* grid's `ColumnWidthCalculator`: render each option's actual menu markup (via the configured
|
|
24
|
+
* `optionRenderer`, if any), canvas-estimate its width to pick the widest candidates, then measure
|
|
25
|
+
* only those candidates' displayed width in a hidden probe - so custom `optionRenderer`s (icons,
|
|
26
|
+
* multi-element content, etc.) size correctly. Unlike the grid (which may autosize many columns
|
|
27
|
+
* over thousands of records), this renders one menu's options once per options change.
|
|
28
|
+
*
|
|
29
|
+
* @param options - normalized (possibly grouped) Select options.
|
|
30
|
+
* @param renderOption - renders an option to its menu `ReactNode` (e.g. model.formatOptionLabel).
|
|
31
|
+
* @param portal - shared menu portal div, used to host probes so menu CSS (font, padding) applies.
|
|
32
|
+
* @returns explicit menu width in px, or null to fall back to the control width.
|
|
33
|
+
* @internal
|
|
34
|
+
*/
|
|
35
|
+
export function calcWindowedMenuWidth(
|
|
36
|
+
options: any[],
|
|
37
|
+
renderOption: (opt: any) => ReactNode,
|
|
38
|
+
portal: HTMLElement
|
|
39
|
+
): number {
|
|
40
|
+
const flatOpts = [],
|
|
41
|
+
collect = opts => opts.forEach(o => (o.options ? collect(o.options) : flatOpts.push(o)));
|
|
42
|
+
collect(options);
|
|
43
|
+
if (isEmpty(flatOpts)) return null;
|
|
44
|
+
|
|
45
|
+
// Render a hidden probe (menu > option) into the portal, so menu CSS (font, padding) applies
|
|
46
|
+
// via its location under document.body.xh-app.
|
|
47
|
+
const menu = document.createElement('div'),
|
|
48
|
+
option = document.createElement('div');
|
|
49
|
+
menu.className = 'xh-select__menu';
|
|
50
|
+
menu.style.cssText = 'position:absolute; visibility:hidden; height:0; overflow:hidden';
|
|
51
|
+
option.className = 'xh-select__option';
|
|
52
|
+
option.style.cssText = 'width:max-content; white-space:nowrap';
|
|
53
|
+
menu.appendChild(option);
|
|
54
|
+
portal.appendChild(menu);
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
// 1) Render each option's menu markup and canvas-estimate its width (tags stripped).
|
|
58
|
+
const ctx = getCanvasContext(option),
|
|
59
|
+
estimates = flatOpts.map(o => {
|
|
60
|
+
const node = renderOption(o),
|
|
61
|
+
markup = isValidElement(node)
|
|
62
|
+
? renderToStaticMarkup(node)
|
|
63
|
+
: isNil(node)
|
|
64
|
+
? ''
|
|
65
|
+
: String(node);
|
|
66
|
+
return {markup, width: getStringWidth(ctx, stripTags(markup))};
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// 2) Measure only the widest sample's displayed width in the hidden probe.
|
|
70
|
+
const sample = takeRight(sortBy(estimates, 'width'), SIZE_CALC_SAMPLES);
|
|
71
|
+
let ret = 0;
|
|
72
|
+
sample.forEach(({markup}) => {
|
|
73
|
+
option.innerHTML = markup;
|
|
74
|
+
ret = Math.max(ret, Math.ceil(option.clientWidth));
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Option padding is already measured; pad only for the windowed scrollbar.
|
|
78
|
+
return ret ? ret + SCROLLBAR_PX : null;
|
|
79
|
+
} catch (e) {
|
|
80
|
+
// E.g. an optionRenderer that can't be statically rendered - fall back to control width.
|
|
81
|
+
logWarn(['Error calculating windowed menu width.', e], 'Select');
|
|
82
|
+
return null;
|
|
83
|
+
} finally {
|
|
84
|
+
portal.removeChild(menu);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
//------------------
|
|
89
|
+
// Canvas-based width estimation - mirrors grid's ColumnWidthCalculator.
|
|
90
|
+
//------------------
|
|
91
|
+
let _canvas: HTMLCanvasElement;
|
|
92
|
+
function getCanvasContext(measureEl: HTMLElement): CanvasRenderingContext2D {
|
|
93
|
+
if (!_canvas) _canvas = document.createElement('canvas');
|
|
94
|
+
|
|
95
|
+
const ctx = _canvas.getContext('2d'),
|
|
96
|
+
style = window.getComputedStyle(measureEl),
|
|
97
|
+
fontSize = style.getPropertyValue('font-size'),
|
|
98
|
+
fontFamily = style.getPropertyValue('font-family');
|
|
99
|
+
|
|
100
|
+
ctx.font = `${fontSize} ${fontFamily}`;
|
|
101
|
+
return ctx;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function getStringWidth(ctx: CanvasRenderingContext2D, string: any): number {
|
|
105
|
+
return isString(string) ? ctx.measureText(string).width : 0;
|
|
106
|
+
}
|
|
@@ -68,7 +68,7 @@ function renderDefaultInput(name: string, model: RestFormModel) {
|
|
|
68
68
|
case 'number':
|
|
69
69
|
return numberInput();
|
|
70
70
|
case 'json':
|
|
71
|
-
return jsonInput({enableSearch: true, height: 250});
|
|
71
|
+
return jsonInput({autoFormat: true, enableSearch: true, height: 250});
|
|
72
72
|
case 'date':
|
|
73
73
|
return dateInput();
|
|
74
74
|
case 'localDate':
|