@xh/hoist 86.0.1 → 86.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.
- package/CHANGELOG.md +104 -0
- package/admin/AdminJsonDisplay.ts +31 -0
- package/admin/App.scss +3 -11
- package/admin/jsonsearch/impl/JsonSearchImplModel.ts +4 -4
- 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/RoleModel.ts +1 -19
- package/appcontainer/RouterModel.ts +2 -0
- package/appcontainer/ThemeModel.ts +41 -0
- package/appcontainer/login/LoginPanelModel.ts +6 -2
- package/build/types/admin/AdminJsonDisplay.d.ts +7 -0
- package/build/types/admin/jsonsearch/impl/JsonSearchImplModel.d.ts +2 -2
- package/build/types/appcontainer/RouterModel.d.ts +1 -0
- package/build/types/appcontainer/ThemeModel.d.ts +9 -0
- package/build/types/cmp/grid/Grid.d.ts +6 -3
- package/build/types/cmp/grid/GridModel.d.ts +11 -0
- package/build/types/cmp/grid/filter/GridFilterFieldSpec.d.ts +24 -6
- package/build/types/cmp/input/SegmentedControlOption.d.ts +44 -0
- package/build/types/cmp/input/index.d.ts +1 -0
- package/build/types/core/AppSpec.d.ts +8 -2
- package/build/types/core/HoistAuthModel.d.ts +6 -3
- package/build/types/data/filter/BaseFilterFieldSpec.d.ts +7 -1
- package/build/types/data/filter/FieldFilter.d.ts +2 -0
- package/build/types/desktop/cmp/button/grid/ExpandToLevelButton.d.ts +2 -1
- package/build/types/desktop/cmp/filechooser/FileChooserModel.d.ts +1 -1
- 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/SegmentedControl.d.ts +1 -41
- 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 +5 -3
- package/build/types/mobile/cmp/appOption/SizingModeAppOption.d.ts +3 -3
- package/build/types/mobile/cmp/appOption/ThemeAppOption.d.ts +3 -3
- package/build/types/mobile/cmp/button/grid/ExpandToLevelButton.d.ts +2 -1
- package/build/types/mobile/cmp/input/NumberInput.d.ts +1 -1
- package/build/types/mobile/cmp/input/SegmentedControl.d.ts +47 -0
- package/build/types/mobile/cmp/input/SwitchInput.d.ts +1 -1
- package/build/types/mobile/cmp/input/TextInput.d.ts +3 -0
- package/build/types/mobile/cmp/input/index.d.ts +1 -0
- package/build/types/svc/JsonBlobService.d.ts +7 -7
- package/cmp/error/ErrorMessage.scss +2 -0
- package/cmp/grid/Grid.ts +17 -0
- package/cmp/grid/GridModel.ts +17 -5
- package/cmp/grid/filter/GridFilterFieldSpec.ts +33 -7
- package/cmp/grid/impl/GridHScrollbar.ts +10 -3
- package/cmp/grid/impl/MenuSupport.ts +2 -4
- package/cmp/input/SegmentedControlOption.ts +61 -0
- package/cmp/input/index.ts +1 -0
- package/core/AppSpec.ts +9 -1
- package/core/HoistAuthModel.ts +13 -6
- package/data/Field.ts +3 -1
- package/data/filter/BaseFilterFieldSpec.ts +16 -2
- package/data/filter/FieldFilter.ts +90 -21
- package/desktop/appcontainer/LoginPanel.ts +5 -3
- package/desktop/cmp/button/grid/ExpandToLevelButton.ts +4 -5
- package/desktop/cmp/filechooser/FileChooserModel.ts +1 -1
- package/desktop/cmp/grid/editors/NumberEditor.ts +16 -17
- package/desktop/cmp/grid/editors/SelectEditor.ts +15 -10
- package/desktop/cmp/grid/impl/filter/GridFilterDialog.ts +2 -2
- package/desktop/cmp/grid/impl/filter/headerfilter/HeaderFilterModel.ts +9 -4
- package/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomRowModel.ts +2 -2
- 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 +8 -51
- package/desktop/cmp/input/Select.ts +13 -2
- package/desktop/cmp/input/impl/CalcWindowedMenuWidth.ts +106 -0
- package/desktop/cmp/rest/impl/RestFormField.ts +1 -1
- package/desktop/cmp/tab/Tabs.scss +78 -27
- package/desktop/cmp/tab/dynamic/DynamicTabSwitcher.ts +7 -3
- package/docs/error-handling.md +9 -0
- package/format/FormatNumber.ts +69 -32
- package/icon/Icon.scss +13 -0
- package/icon/Icon.ts +7 -3
- package/icon/impl/IconHtml.ts +1 -1
- package/icon/index.ts +8 -0
- package/mcp/data/ts-registry.ts +1 -1
- package/mobile/appcontainer/LoginPanel.scss +53 -11
- package/mobile/appcontainer/LoginPanel.ts +69 -44
- package/mobile/cmp/appOption/SizingModeAppOption.ts +5 -16
- package/mobile/cmp/appOption/ThemeAppOption.ts +8 -25
- package/mobile/cmp/button/grid/ExpandToLevelButton.ts +4 -5
- package/mobile/cmp/error/impl/ErrorMessage.ts +4 -8
- package/mobile/cmp/input/CheckboxButton.ts +18 -1
- package/mobile/cmp/input/NumberInput.ts +1 -1
- package/mobile/cmp/input/SegmentedControl.scss +126 -0
- package/mobile/cmp/input/SegmentedControl.ts +210 -0
- package/mobile/cmp/input/SwitchInput.ts +1 -1
- package/mobile/cmp/input/TextInput.scss +7 -0
- package/mobile/cmp/input/TextInput.ts +11 -1
- package/mobile/cmp/input/index.ts +1 -0
- package/mobile/cmp/tab/impl/Tabs.scss +8 -0
- package/mobile/cmp/toolbar/Toolbar.scss +8 -0
- package/package.json +7 -7
- package/styles/vars.scss +21 -1
- package/svc/JsonBlobService.ts +21 -20
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Copyright © 2026 Extremely Heavy Industries Inc.
|
|
6
6
|
*/
|
|
7
|
-
import {ColumnRenderer} from '@xh/hoist/cmp/grid';
|
|
8
7
|
import {HoistInputProps} from '@xh/hoist/cmp/input';
|
|
9
8
|
import {PlainObject} from '@xh/hoist/core';
|
|
10
9
|
import {
|
|
@@ -19,17 +18,38 @@ import {
|
|
|
19
18
|
BaseFilterFieldSpecConfig
|
|
20
19
|
} from '@xh/hoist/data/filter/BaseFilterFieldSpec';
|
|
21
20
|
import {castArray, compact, flatMap, isDate, isEmpty, uniqBy} from 'lodash';
|
|
21
|
+
import {ReactNode} from 'react';
|
|
22
22
|
import {GridFilterModel} from './GridFilterModel';
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Produces the display content for an entry in the Values tab of a column filter. Must be a pure
|
|
26
|
+
* transform of the value - the values list carries no source record, and there is no column or
|
|
27
|
+
* grid context to reach for (unlike a Column's `renderer`, which runs against the source grid).
|
|
28
|
+
*/
|
|
29
|
+
export type GridFilterRenderer = (value: any) => ReactNode;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Produces the value to sort by for an entry in the Values tab of a column filter. Must be a pure
|
|
33
|
+
* transform of the value - the values list carries no source record, and there is no column or
|
|
34
|
+
* grid context to reach for (unlike a Column's `sortValue`, which runs against the source grid).
|
|
35
|
+
*/
|
|
36
|
+
export type GridFilterSortValueFn = (value: any) => any;
|
|
37
|
+
|
|
24
38
|
export interface GridFilterFieldSpecConfig extends BaseFilterFieldSpecConfig {
|
|
25
39
|
/** GridFilterModel instance owning this fieldSpec. */
|
|
26
40
|
filterModel?: GridFilterModel;
|
|
27
41
|
|
|
28
42
|
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
43
|
+
* Pure function producing the display content for each entry in the values filter display. If
|
|
44
|
+
* not provided, the Column's renderer is used where it can be applied to a bare value.
|
|
45
|
+
*/
|
|
46
|
+
renderer?: GridFilterRenderer;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Pure function producing the value to sort each entry of the values filter display by. If not
|
|
50
|
+
* provided, the Column's sortValue is used where it can be applied to a bare value.
|
|
31
51
|
*/
|
|
32
|
-
|
|
52
|
+
sortValue?: GridFilterSortValueFn;
|
|
33
53
|
|
|
34
54
|
/**
|
|
35
55
|
* Props to pass through to the HoistInput components used on the custom filter tab.
|
|
@@ -47,7 +67,8 @@ export interface GridFilterFieldSpecConfig extends BaseFilterFieldSpecConfig {
|
|
|
47
67
|
*/
|
|
48
68
|
export class GridFilterFieldSpec extends BaseFilterFieldSpec {
|
|
49
69
|
filterModel: GridFilterModel;
|
|
50
|
-
renderer:
|
|
70
|
+
renderer: GridFilterRenderer;
|
|
71
|
+
sortValue: GridFilterSortValueFn;
|
|
51
72
|
inputProps: PlainObject;
|
|
52
73
|
defaultOp: FieldFilterOperator;
|
|
53
74
|
|
|
@@ -57,6 +78,7 @@ export class GridFilterFieldSpec extends BaseFilterFieldSpec {
|
|
|
57
78
|
constructor({
|
|
58
79
|
filterModel,
|
|
59
80
|
renderer,
|
|
81
|
+
sortValue,
|
|
60
82
|
inputProps,
|
|
61
83
|
defaultOp,
|
|
62
84
|
...rest
|
|
@@ -65,6 +87,7 @@ export class GridFilterFieldSpec extends BaseFilterFieldSpec {
|
|
|
65
87
|
|
|
66
88
|
this.filterModel = filterModel;
|
|
67
89
|
this.renderer = renderer;
|
|
90
|
+
this.sortValue = sortValue;
|
|
68
91
|
this.inputProps = inputProps;
|
|
69
92
|
this.defaultOp = this.ops.includes(defaultOp) ? defaultOp : this.ops[0];
|
|
70
93
|
}
|
|
@@ -87,10 +110,13 @@ export class GridFilterFieldSpec extends BaseFilterFieldSpec {
|
|
|
87
110
|
// Values from current column filter. `flatMap` here unwraps the array `parseVal` returns
|
|
88
111
|
// for `tags`-typed fields, lining those values up with the scalar values produced by
|
|
89
112
|
// `Store.getValuesForFieldFilter` above. Without this, a filter value like `'foo'` would
|
|
90
|
-
// survive as `['foo']` and dedupe incorrectly.
|
|
113
|
+
// survive as `['foo']` and dedupe incorrectly. The `filter` drops the non-selectable null
|
|
114
|
+
// value carried by a blank `tags` filter.
|
|
91
115
|
const colFilterVals = flatMap(columnFilters, filter => {
|
|
92
116
|
return castArray(filter.value).flatMap(val => sourceField.parseVal(val));
|
|
93
|
-
})
|
|
117
|
+
})
|
|
118
|
+
.filter(it => sourceField.type !== 'tags' || it != null)
|
|
119
|
+
.map(it => this.toDisplayValue(it));
|
|
94
120
|
|
|
95
121
|
// Combine + unique - these are all values that *could* be shown in the filter UI.
|
|
96
122
|
const allValues = uniqBy([...allSrcVals, ...colFilterVals], this.getUniqueValue);
|
|
@@ -79,6 +79,10 @@ class GridHScrollbarModel extends HoistModel {
|
|
|
79
79
|
return this.viewRef.current.querySelector('.ag-center-cols-viewport');
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
private get agHeaderViewport(): HTMLDivElement {
|
|
83
|
+
return this.viewRef.current.querySelector('.ag-header-viewport');
|
|
84
|
+
}
|
|
85
|
+
|
|
82
86
|
private get agVerticalScrollContainer(): HTMLDivElement {
|
|
83
87
|
return this.viewRef.current.querySelector('.ag-body-vertical-scroll-container');
|
|
84
88
|
}
|
|
@@ -102,6 +106,7 @@ class GridHScrollbarModel extends HoistModel {
|
|
|
102
106
|
|
|
103
107
|
scrollViewport(left: number) {
|
|
104
108
|
this.agViewport.scrollLeft = left;
|
|
109
|
+
this.agHeaderViewport.scrollLeft = left;
|
|
105
110
|
}
|
|
106
111
|
|
|
107
112
|
override afterLinked() {
|
|
@@ -110,9 +115,11 @@ class GridHScrollbarModel extends HoistModel {
|
|
|
110
115
|
run: () => {
|
|
111
116
|
const {agViewport, viewRef} = this;
|
|
112
117
|
this.viewWidth = viewRef.current.clientWidth;
|
|
113
|
-
agViewport.addEventListener('scroll', e =>
|
|
114
|
-
|
|
115
|
-
|
|
118
|
+
agViewport.addEventListener('scroll', e => {
|
|
119
|
+
const left = (e.target as HTMLDivElement).scrollLeft;
|
|
120
|
+
this.scrollScroller(left);
|
|
121
|
+
this.agHeaderViewport.scrollLeft = left;
|
|
122
|
+
});
|
|
116
123
|
this.agViewportResizeObserver = observeResize(
|
|
117
124
|
() => this.onAgViewportResized(),
|
|
118
125
|
agViewport,
|
|
@@ -280,15 +280,13 @@ function levelExpandAction(gridModel: GridModel): RecordAction {
|
|
|
280
280
|
return new RecordAction({
|
|
281
281
|
text: 'Expand to...',
|
|
282
282
|
displayFn: () => {
|
|
283
|
-
const {maxDepth,
|
|
283
|
+
const {maxDepth, resolvedLevelLabels} = gridModel;
|
|
284
284
|
|
|
285
285
|
// Don't show for flat grid models or if we don't have labels
|
|
286
286
|
if (!maxDepth || !resolvedLevelLabels) return {hidden: true};
|
|
287
287
|
|
|
288
288
|
const items = resolvedLevelLabels.map((label, idx) => {
|
|
289
|
-
const isCurrLevel =
|
|
290
|
-
expandLevel === idx ||
|
|
291
|
-
(expandLevel > maxDepth && idx === resolvedLevelLabels.length - 1);
|
|
289
|
+
const isCurrLevel = gridModel.isCurrentExpandLevel(idx);
|
|
292
290
|
|
|
293
291
|
return {
|
|
294
292
|
icon: isCurrLevel ? Icon.check() : null,
|
|
@@ -0,0 +1,61 @@
|
|
|
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 {Intent} from '@xh/hoist/core';
|
|
8
|
+
import {LocalDate} from '@xh/hoist/utils/datetime';
|
|
9
|
+
import {ReactElement} from 'react';
|
|
10
|
+
|
|
11
|
+
/** Primitive value types supported as a SegmentedControl option value/label. */
|
|
12
|
+
export type OptionPrimitive = string | number | boolean | LocalDate;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Option for a SegmentedControl, shared by the desktop and mobile implementations.
|
|
16
|
+
*/
|
|
17
|
+
export interface SegmentedControlOption {
|
|
18
|
+
/** Value for this option. */
|
|
19
|
+
value: OptionPrimitive;
|
|
20
|
+
|
|
21
|
+
/** Display label. Defaults to `value.toString()` if omitted. */
|
|
22
|
+
label?: string;
|
|
23
|
+
|
|
24
|
+
/** Icon element, displayed before the label. */
|
|
25
|
+
icon?: ReactElement;
|
|
26
|
+
|
|
27
|
+
/** True to disable this individual option. */
|
|
28
|
+
disabled?: boolean;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Visual intent for this option - rendered as a solid fill when selected and as a subtle
|
|
32
|
+
* text-color hint when not (e.g. to flag a destructive choice). Overrides any control-level
|
|
33
|
+
* `intent` default. Defaults to the control's `intent`.
|
|
34
|
+
*/
|
|
35
|
+
intent?: Intent;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Variant of SegmentedControlOption for representing a null/"no value" selection.
|
|
40
|
+
* Label is required to force use case to override default js 'null' toString rendering.
|
|
41
|
+
*/
|
|
42
|
+
export interface SegmentedControlNullOption {
|
|
43
|
+
/** Null value for this option. */
|
|
44
|
+
value: null;
|
|
45
|
+
|
|
46
|
+
/** Display label - required for null options. */
|
|
47
|
+
label: string;
|
|
48
|
+
|
|
49
|
+
/** Icon element, displayed before the label. */
|
|
50
|
+
icon?: ReactElement;
|
|
51
|
+
|
|
52
|
+
/** True to disable this individual option. */
|
|
53
|
+
disabled?: boolean;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Visual intent for this option - rendered as a solid fill when selected and as a subtle
|
|
57
|
+
* text-color hint when not (e.g. to flag a destructive choice). Overrides any control-level
|
|
58
|
+
* `intent` default. Defaults to the control's `intent`.
|
|
59
|
+
*/
|
|
60
|
+
intent?: Intent;
|
|
61
|
+
}
|
package/cmp/input/index.ts
CHANGED
package/core/AppSpec.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import {ElementFactory, HoistAppModel, HoistAuthModel, HoistProps, XH} from '@xh/hoist/core';
|
|
8
8
|
import {throwIf} from '@xh/hoist/utils/js';
|
|
9
9
|
import {isFunction, isNil, isString} from 'lodash';
|
|
10
|
-
import {Component, ComponentClass, FunctionComponent} from 'react';
|
|
10
|
+
import {Component, ComponentClass, FunctionComponent, ReactElement} from 'react';
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Spec for a client-side Hoist application. A config matching this class's shape is provided
|
|
@@ -118,6 +118,12 @@ export class AppSpec<T extends HoistAppModel = HoistAppModel> {
|
|
|
118
118
|
/** Optional message to show users when denied access to app. */
|
|
119
119
|
lockoutMessage?: string;
|
|
120
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Icon to display on the form-based login page, if active via `enableLoginForm: true`.
|
|
123
|
+
* Defaults to `Icon.shieldHalved()`.
|
|
124
|
+
*/
|
|
125
|
+
loginPanelIcon?: ReactElement;
|
|
126
|
+
|
|
121
127
|
/** Optional message to show on login form, if `showLoginForm: true`. */
|
|
122
128
|
loginMessage?: string;
|
|
123
129
|
|
|
@@ -156,6 +162,7 @@ export class AppSpec<T extends HoistAppModel = HoistAppModel> {
|
|
|
156
162
|
lockoutMessage = null,
|
|
157
163
|
lockoutPanel = null,
|
|
158
164
|
loginMessage = null,
|
|
165
|
+
loginPanelIcon = null,
|
|
159
166
|
modelClass,
|
|
160
167
|
showBrowserContextMenu = false,
|
|
161
168
|
trackAppLoad = true
|
|
@@ -191,6 +198,7 @@ export class AppSpec<T extends HoistAppModel = HoistAppModel> {
|
|
|
191
198
|
this.lockoutMessage = lockoutMessage;
|
|
192
199
|
this.lockoutPanel = lockoutPanel;
|
|
193
200
|
this.loginMessage = loginMessage;
|
|
201
|
+
this.loginPanelIcon = loginPanelIcon;
|
|
194
202
|
this.modelClass = modelClass;
|
|
195
203
|
this.showBrowserContextMenu = showBrowserContextMenu;
|
|
196
204
|
this.trackAppLoad = trackAppLoad;
|
package/core/HoistAuthModel.ts
CHANGED
|
@@ -66,8 +66,12 @@ export class HoistAuthModel extends HoistModel {
|
|
|
66
66
|
* Process a manual login, submitted by user via form.
|
|
67
67
|
* @returns identity of the user authenticated with the server; null if not yet authenticated.
|
|
68
68
|
*/
|
|
69
|
-
async loginWithCredentialsAsync(
|
|
70
|
-
|
|
69
|
+
async loginWithCredentialsAsync(
|
|
70
|
+
username: string,
|
|
71
|
+
password: string,
|
|
72
|
+
ctx?: CallContextLike
|
|
73
|
+
): Promise<IdentityInfo> {
|
|
74
|
+
return this.runner(ctx)
|
|
71
75
|
.span('login')
|
|
72
76
|
.run(async ctx => {
|
|
73
77
|
const {success, identity} = await XH.fetchJson(
|
|
@@ -87,8 +91,8 @@ export class HoistAuthModel extends HoistModel {
|
|
|
87
91
|
* The default implementation will call the 'logout' endpoint on the Grails server, clearing
|
|
88
92
|
* any server-side session state there. Override to manage any client-side or third-party state.
|
|
89
93
|
*/
|
|
90
|
-
async logoutAsync(): Promise<void> {
|
|
91
|
-
await this.runner().span('logout').fetchJson({url: 'xh/logout'});
|
|
94
|
+
async logoutAsync(ctx?: CallContextLike): Promise<void> {
|
|
95
|
+
await this.runner(ctx).span('logout').fetchJson({url: 'xh/logout'});
|
|
92
96
|
}
|
|
93
97
|
|
|
94
98
|
/**
|
|
@@ -96,9 +100,12 @@ export class HoistAuthModel extends HoistModel {
|
|
|
96
100
|
* whitelisted by Hoist to allow access prior to user authentication. For use in bootstrapping
|
|
97
101
|
* client-side auth solutions that require configs such as OAuth endpoint URLs and client IDs.
|
|
98
102
|
* See `BaseAuthenticationService.getClientConfig()` in hoist-core.
|
|
103
|
+
*
|
|
104
|
+
* @param ctx - tracing/load context. When called from within a `completeAuthAsync` override,
|
|
105
|
+
* forward the supplied context so this fetch nests under the bootstrap trace.
|
|
99
106
|
*/
|
|
100
|
-
async loadConfigAsync(): Promise<PlainObject> {
|
|
101
|
-
return this.runner().span('config').fetchJson({url: 'xh/authConfig'});
|
|
107
|
+
async loadConfigAsync(ctx?: CallContextLike): Promise<PlainObject> {
|
|
108
|
+
return this.runner(ctx).span('config').fetchJson({url: 'xh/authConfig'});
|
|
102
109
|
}
|
|
103
110
|
|
|
104
111
|
/**
|
package/data/Field.ts
CHANGED
|
@@ -166,7 +166,9 @@ export function parseFieldValue(
|
|
|
166
166
|
case 'date':
|
|
167
167
|
return isLocalDate(val) ? val.date : isDate(val) ? val : new Date(val);
|
|
168
168
|
case 'localDate':
|
|
169
|
-
|
|
169
|
+
if (isLocalDate(val)) return val;
|
|
170
|
+
// `get` parses strict 'YYYYMMDD'/'YYYY-MM-DD' strings; `from` coerces Date/number/moment.
|
|
171
|
+
return isString(val) ? LocalDate.get(val) : LocalDate.from(val);
|
|
170
172
|
}
|
|
171
173
|
|
|
172
174
|
throw XH.exception(`Unknown field type '${type}'`);
|
|
@@ -21,7 +21,12 @@ import {FieldFilterOperator} from './Types';
|
|
|
21
21
|
export interface BaseFilterFieldSpecConfig {
|
|
22
22
|
/** Identifying field name to filter on. */
|
|
23
23
|
field: string;
|
|
24
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* Type of field, will default from related field on source if provided, or 'auto'. A `date`
|
|
26
|
+
* (timestamp) source defaults to 'localDate' so filtering compares by calendar day (range and
|
|
27
|
+
* equality operators use full-day bounds). Set explicitly to 'date' to filter by exact
|
|
28
|
+
* timestamp instead.
|
|
29
|
+
*/
|
|
25
30
|
fieldType?: FieldType;
|
|
26
31
|
/** DisplayName, will default from related field on source if provided */
|
|
27
32
|
displayName?: string;
|
|
@@ -77,7 +82,11 @@ export abstract class BaseFilterFieldSpec extends HoistBase {
|
|
|
77
82
|
this.source = source;
|
|
78
83
|
|
|
79
84
|
const sourceField = this.sourceField;
|
|
80
|
-
|
|
85
|
+
// Default a `date` (timestamp) source to `localDate` so filtering compares by calendar day
|
|
86
|
+
// rather than against midnight (#3338). Apps wanting exact-timestamp filtering can set
|
|
87
|
+
// `fieldType: 'date'` explicitly.
|
|
88
|
+
this.fieldType =
|
|
89
|
+
fieldType ?? (sourceField?.type === 'date' ? 'localDate' : sourceField?.type) ?? 'auto';
|
|
81
90
|
this.displayName = displayName ?? sourceField?.displayName ?? genDisplayName(field);
|
|
82
91
|
this.ops = this.parseOperators(ops);
|
|
83
92
|
this.forceSelection = forceSelection ?? false;
|
|
@@ -183,7 +192,12 @@ export abstract class BaseFilterFieldSpec extends HoistBase {
|
|
|
183
192
|
: ['>', '>=', '<', '<=', '=', '!='];
|
|
184
193
|
}
|
|
185
194
|
|
|
195
|
+
private get isLocalDateFilteringTimestamp(): boolean {
|
|
196
|
+
return this.fieldType === 'localDate' && this.sourceField?.type === 'date';
|
|
197
|
+
}
|
|
198
|
+
|
|
186
199
|
private get isEnumerableByDefault(): boolean {
|
|
200
|
+
if (this.isLocalDateFilteringTimestamp) return false;
|
|
187
201
|
switch (this.fieldType) {
|
|
188
202
|
case 'int':
|
|
189
203
|
case 'number':
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
escapeRegExp,
|
|
15
15
|
first,
|
|
16
16
|
isArray,
|
|
17
|
+
isEmpty,
|
|
17
18
|
isEqual,
|
|
18
19
|
isNil,
|
|
19
20
|
isString,
|
|
@@ -112,9 +113,9 @@ export class FieldFilter extends Filter {
|
|
|
112
113
|
// Overrides
|
|
113
114
|
//-----------------
|
|
114
115
|
override getTestFn(store?: Store): FilterTestFn {
|
|
115
|
-
|
|
116
|
-
regExps;
|
|
116
|
+
const {field, op, value} = this;
|
|
117
117
|
|
|
118
|
+
let storeFieldType: FieldType;
|
|
118
119
|
if (store) {
|
|
119
120
|
const storeField = store.getField(field);
|
|
120
121
|
if (!storeField) {
|
|
@@ -127,29 +128,72 @@ export class FieldFilter extends Filter {
|
|
|
127
128
|
}
|
|
128
129
|
return () => true;
|
|
129
130
|
}
|
|
131
|
+
storeFieldType = storeField.type;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const opFn = this.getOpFn(op, value, storeFieldType);
|
|
135
|
+
|
|
136
|
+
if (!store) return r => opFn(r[field]);
|
|
137
|
+
|
|
138
|
+
return (r: StoreRecord) => {
|
|
139
|
+
const val = r.get(field);
|
|
140
|
+
if (opFn(val)) return true;
|
|
141
|
+
|
|
142
|
+
// Maximize chances of matching. Always pass adds ...
|
|
143
|
+
if (r.isAdd) return true;
|
|
130
144
|
|
|
131
|
-
|
|
145
|
+
// ... and check any differing original value as well
|
|
146
|
+
const committedVal = r.committedData[field];
|
|
147
|
+
return committedVal !== val && opFn(committedVal);
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private getOpFn(
|
|
152
|
+
op: FieldFilterOperator,
|
|
153
|
+
value: any,
|
|
154
|
+
storeFieldType?: FieldType
|
|
155
|
+
): (v: any) => boolean {
|
|
156
|
+
const RANGE_OPS = FieldFilter.RANGE_LIKE_OPERATORS,
|
|
157
|
+
ARR_OPS = FieldFilter.ARRAY_OPERATORS;
|
|
158
|
+
|
|
159
|
+
// Day-aware filtering for LocalDate value(s) over a timestamp field - compares against
|
|
160
|
+
// full calendar-day bounds for range and equality operators (#3338).
|
|
161
|
+
const firstVal = isArray(value) ? value[0] : value;
|
|
162
|
+
if (
|
|
163
|
+
storeFieldType === 'date' &&
|
|
164
|
+
LocalDate.isLocalDate(firstVal) &&
|
|
165
|
+
(RANGE_OPS.includes(op) || op === '=' || op === '!=')
|
|
166
|
+
) {
|
|
167
|
+
return this.getDayBoundedOpFn(op, value);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Coerce candidate value(s) to the store field's type when filtering against a store.
|
|
171
|
+
if (storeFieldType) {
|
|
172
|
+
const fieldType = storeFieldType === 'tags' ? 'string' : storeFieldType;
|
|
132
173
|
value = isArray(value)
|
|
133
174
|
? value.map(v => parseFieldValue(v, fieldType))
|
|
134
175
|
: parseFieldValue(value, fieldType);
|
|
135
176
|
}
|
|
136
|
-
|
|
137
|
-
if (FieldFilter.ARRAY_OPERATORS.includes(op)) {
|
|
177
|
+
if (ARR_OPS.includes(op)) {
|
|
138
178
|
value = castArray(value);
|
|
139
179
|
}
|
|
140
180
|
|
|
141
|
-
|
|
181
|
+
// Treat null, empty string, and empty array (blank `tags`) alike as "blank".
|
|
182
|
+
const isBlank = (v: any) => isNil(v) || v === '' || (isArray(v) && isEmpty(v));
|
|
183
|
+
|
|
184
|
+
let regExps, opFn: (v: any) => boolean;
|
|
142
185
|
switch (op) {
|
|
143
186
|
case '=':
|
|
144
187
|
opFn = v => {
|
|
145
|
-
if (
|
|
146
|
-
|
|
188
|
+
if (isBlank(v)) v = null;
|
|
189
|
+
// A blank filter (empty `value`) matches only blank record values.
|
|
190
|
+
return (v == null && isEmpty(value)) || value.some(it => isEqual(v, it));
|
|
147
191
|
};
|
|
148
192
|
break;
|
|
149
193
|
case '!=':
|
|
150
194
|
opFn = v => {
|
|
151
|
-
if (
|
|
152
|
-
return !value.some(it => isEqual(v, it));
|
|
195
|
+
if (isBlank(v)) v = null;
|
|
196
|
+
return (v != null || !isEmpty(value)) && !value.some(it => isEqual(v, it));
|
|
153
197
|
};
|
|
154
198
|
break;
|
|
155
199
|
case '>':
|
|
@@ -197,20 +241,45 @@ export class FieldFilter extends Filter {
|
|
|
197
241
|
default:
|
|
198
242
|
throw XH.exception(`Unknown operator: ${op}`);
|
|
199
243
|
}
|
|
244
|
+
return opFn;
|
|
245
|
+
}
|
|
200
246
|
|
|
201
|
-
|
|
247
|
+
// Compare a timestamp against full calendar-day bounds `[dayStart, nextDayStart)` so a
|
|
248
|
+
// LocalDate value filters by date part. Supports range and equality operators (#3338).
|
|
249
|
+
private getDayBoundedOpFn(
|
|
250
|
+
op: FieldFilterOperator,
|
|
251
|
+
value: LocalDate | LocalDate[]
|
|
252
|
+
): (v: any) => boolean {
|
|
253
|
+
const toMillis = (v: any) => (v instanceof Date ? v.getTime() : v);
|
|
202
254
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
255
|
+
// Equality ops test the date part against any of the candidate day(s).
|
|
256
|
+
if (op === '=' || op === '!=') {
|
|
257
|
+
const ranges = castArray(value).map(d => [
|
|
258
|
+
d.date.getTime(),
|
|
259
|
+
d.nextDay().date.getTime()
|
|
260
|
+
]),
|
|
261
|
+
inAnyDay = (t: number) => ranges.some(([start, next]) => t >= start && t < next);
|
|
262
|
+
return op === '='
|
|
263
|
+
? v => !isNil(v) && inAnyDay(toMillis(v))
|
|
264
|
+
: v => isNil(v) || !inAnyDay(toMillis(v));
|
|
265
|
+
}
|
|
209
266
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
267
|
+
// Range ops compare against the bounds of a single day.
|
|
268
|
+
const day = value as LocalDate,
|
|
269
|
+
dayStart = day.date.getTime(),
|
|
270
|
+
nextDayStart = day.nextDay().date.getTime();
|
|
271
|
+
switch (op) {
|
|
272
|
+
case '>':
|
|
273
|
+
return v => !isNil(v) && toMillis(v) >= nextDayStart;
|
|
274
|
+
case '>=':
|
|
275
|
+
return v => !isNil(v) && toMillis(v) >= dayStart;
|
|
276
|
+
case '<':
|
|
277
|
+
return v => !isNil(v) && toMillis(v) < dayStart;
|
|
278
|
+
case '<=':
|
|
279
|
+
return v => !isNil(v) && toMillis(v) < nextDayStart;
|
|
280
|
+
default:
|
|
281
|
+
throw XH.exception(`Unsupported calendar-day operator: ${op}`);
|
|
282
|
+
}
|
|
214
283
|
}
|
|
215
284
|
|
|
216
285
|
override equals(other: Filter): boolean {
|
|
@@ -24,7 +24,7 @@ export const loginPanel = hoistCmp.factory({
|
|
|
24
24
|
model: creates(LoginPanelModel),
|
|
25
25
|
|
|
26
26
|
render({model}) {
|
|
27
|
-
const {loginMessage} = XH.appSpec,
|
|
27
|
+
const {loginMessage, loginPanelIcon} = XH.appSpec,
|
|
28
28
|
{loadObserver, warning, isValid, loginInProgress} = model;
|
|
29
29
|
|
|
30
30
|
const onKeyDown = ev => {
|
|
@@ -36,8 +36,8 @@ export const loginPanel = hoistCmp.factory({
|
|
|
36
36
|
justifyContent: 'center',
|
|
37
37
|
flexDirection: 'column',
|
|
38
38
|
item: panel({
|
|
39
|
-
title: XH.clientAppName
|
|
40
|
-
icon: Icon.
|
|
39
|
+
title: `Login to ${XH.clientAppName}`,
|
|
40
|
+
icon: loginPanelIcon ?? Icon.shieldHalved({prefix: 'fas'}),
|
|
41
41
|
className: 'xh-login',
|
|
42
42
|
testId: 'xh-login',
|
|
43
43
|
width: 300,
|
|
@@ -47,6 +47,7 @@ export const loginPanel = hoistCmp.factory({
|
|
|
47
47
|
form(
|
|
48
48
|
textInput({
|
|
49
49
|
bind: 'username',
|
|
50
|
+
leftIcon: Icon.user(),
|
|
50
51
|
placeholder: 'Username',
|
|
51
52
|
autoComplete: 'username',
|
|
52
53
|
autoFocus: true,
|
|
@@ -57,6 +58,7 @@ export const loginPanel = hoistCmp.factory({
|
|
|
57
58
|
}),
|
|
58
59
|
textInput({
|
|
59
60
|
bind: 'password',
|
|
61
|
+
leftIcon: Icon.lock(),
|
|
60
62
|
placeholder: 'Password...',
|
|
61
63
|
autoComplete: 'current-password',
|
|
62
64
|
type: 'password',
|
|
@@ -28,7 +28,8 @@ export interface ExpandToLevelButtonProps extends Omit<ButtonProps, 'title'> {
|
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
30
|
* A menu button to expand a multi-level grouped or tree grid out to a desired level.
|
|
31
|
-
* Requires {@link GridConfig.levelLabels} to be configured on the bound GridModel
|
|
31
|
+
* Requires {@link GridConfig.levelLabels} to be configured on the bound GridModel - the menu offers
|
|
32
|
+
* one entry per labelled level, so a partial array will limit the available expand-to targets.
|
|
32
33
|
*/
|
|
33
34
|
export const [ExpandToLevelButton, expandToLevelButton] =
|
|
34
35
|
hoistCmp.withFactory<ExpandToLevelButtonProps>({
|
|
@@ -53,13 +54,11 @@ export const [ExpandToLevelButton, expandToLevelButton] =
|
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
// Render a disabled button if requested, if we have a flat grid, or no level labels
|
|
56
|
-
const {maxDepth,
|
|
57
|
+
const {maxDepth, resolvedLevelLabels} = gridModel;
|
|
57
58
|
if (disabled || !maxDepth || !resolvedLevelLabels) return disabledButton();
|
|
58
59
|
|
|
59
60
|
const menuItems: MenuItemLike[] = resolvedLevelLabels.map((label, idx) => {
|
|
60
|
-
const isCurrLevel =
|
|
61
|
-
expandLevel === idx ||
|
|
62
|
-
(expandLevel > maxDepth && idx === resolvedLevelLabels.length - 1);
|
|
61
|
+
const isCurrLevel = gridModel.isCurrentExpandLevel(idx);
|
|
63
62
|
|
|
64
63
|
return {
|
|
65
64
|
icon: isCurrLevel ? Icon.check() : Icon.placeholder(),
|
|
@@ -96,7 +96,7 @@ export class FileChooserModel extends HoistModel {
|
|
|
96
96
|
private readonly rejectToastMessage: (rejectedFiles: FileRejection[]) => ReactNode;
|
|
97
97
|
private readonly rejectToastSpec: Partial<ToastSpec>;
|
|
98
98
|
|
|
99
|
-
constructor(config: FileChooserConfig) {
|
|
99
|
+
constructor(config: FileChooserConfig = {}) {
|
|
100
100
|
super();
|
|
101
101
|
makeObservable(this);
|
|
102
102
|
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import {withDefault} from '@xh/hoist/utils/js';
|
|
8
8
|
import {isNil} from 'lodash';
|
|
9
|
-
import {
|
|
10
|
-
import {CustomCellEditorProps
|
|
9
|
+
import {useEffect} from 'react';
|
|
10
|
+
import {CustomCellEditorProps} from '@xh/hoist/kit/ag-grid';
|
|
11
11
|
import {hoistCmp} from '@xh/hoist/core';
|
|
12
12
|
import {numberInput, NumberInputProps} from '@xh/hoist/desktop/cmp/input';
|
|
13
13
|
import '@xh/hoist/desktop/register';
|
|
@@ -39,20 +39,19 @@ export const [NumberEditor, numberEditor] = hoistCmp.withFactory<NumberEditorPro
|
|
|
39
39
|
}
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
useEffect(() => {
|
|
45
|
-
if (eventKey?.length === 1) onValueChange(eventKey);
|
|
46
|
-
}, [eventKey, onValueChange]);
|
|
47
|
-
|
|
48
|
-
// Gets called before editor component is rendered, to give agGrid a chance to
|
|
49
|
-
// cancel the editing before it even starts if char is not a number.
|
|
50
|
-
const isCancelBeforeStart = useCallback(
|
|
51
|
-
() => eventKey?.length === 1 && '1234567890'.indexOf(eventKey) < 0,
|
|
52
|
-
[eventKey]
|
|
53
|
-
);
|
|
42
|
+
// Characters that can validly begin a numeric entry - digits, sign, and decimal point.
|
|
43
|
+
const NUMBER_START_RE = /[0-9.+-]/;
|
|
54
44
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
45
|
+
const useNumberGuard = ({onValueChange, eventKey, stopEditing}: CustomCellEditorProps) => {
|
|
46
|
+
// When editing is started by typing a printable character, seed the editor with it if it can
|
|
47
|
+
// legally begin a number (digit, `-`, `+`, or `.`), otherwise stop editing to reject the
|
|
48
|
+
// keystroke - reverting to the original value, as the editor was never seeded.
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (eventKey?.length !== 1) return;
|
|
51
|
+
if (NUMBER_START_RE.test(eventKey)) {
|
|
52
|
+
onValueChange(eventKey);
|
|
53
|
+
} else {
|
|
54
|
+
stopEditing(true);
|
|
55
|
+
}
|
|
56
|
+
}, [eventKey, onValueChange, stopEditing]);
|
|
58
57
|
};
|
|
@@ -30,16 +30,21 @@ export const [SelectEditor, selectEditor] = hoistCmp.withFactory<SelectEditorPro
|
|
|
30
30
|
onCommit: flushOnCommit
|
|
31
31
|
? () => wait().then(() => props.agParams.stopEditing())
|
|
32
32
|
: null,
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
33
|
+
// Auto-size the menu to content, not the narrow cell - but skip when width is already
|
|
34
|
+
// set, via windowed measurement (#4325) or an explicit `menuWidth` (#4057).
|
|
35
|
+
rsOptions:
|
|
36
|
+
props.inputProps?.enableWindowed || props.inputProps?.menuWidth != null
|
|
37
|
+
? {}
|
|
38
|
+
: {
|
|
39
|
+
styles: {
|
|
40
|
+
menu: styles => ({
|
|
41
|
+
...styles,
|
|
42
|
+
whiteSpace: 'nowrap',
|
|
43
|
+
width: 'auto',
|
|
44
|
+
minWidth: '100%'
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
},
|
|
43
48
|
...props.inputProps
|
|
44
49
|
}
|
|
45
50
|
};
|