@xh/hoist 86.2.0 → 86.4.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 +75 -0
- package/admin/AppModel.ts +38 -11
- 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/userData/roles/RoleModel.ts +11 -18
- package/admin/tabs/userData/roles/RolePanel.ts +2 -9
- package/appcontainer/RouterModel.ts +6 -6
- package/build/types/admin/AppModel.d.ts +7 -4
- 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/admin/tabs/userData/roles/RoleModel.d.ts +2 -2
- package/build/types/appcontainer/RouterModel.d.ts +5 -5
- package/build/types/cmp/grid/Grid.d.ts +2 -1
- package/build/types/cmp/grid/GridModel.d.ts +2 -2
- package/build/types/cmp/grid/Types.d.ts +8 -1
- 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/data/Store.d.ts +3 -2
- 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/Select.d.ts +8 -0
- 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/grid/impl/ColChooserModel.d.ts +2 -2
- package/build/types/mobile/cmp/input/Select.d.ts +8 -0
- package/build/types/svc/PrefService.d.ts +12 -1
- package/build/types/utils/datetime/LocalDate.d.ts +23 -1
- package/cmp/grid/Grid.ts +10 -2
- package/cmp/grid/GridModel.ts +4 -3
- package/cmp/grid/Types.ts +9 -1
- package/cmp/grid/impl/MenuSupport.ts +1 -1
- 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/data/Store.ts +3 -2
- package/desktop/cmp/filechooser/FileChooser.ts +6 -3
- package/desktop/cmp/filechooser/FileChooserModel.ts +12 -5
- package/desktop/cmp/filter/FilterChooser.scss +42 -0
- package/desktop/cmp/filter/FilterChooser.ts +147 -7
- package/desktop/cmp/filter/PopoverFilterChooser.ts +7 -84
- 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/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/input/SegmentedControl.ts +7 -3
- package/desktop/cmp/input/Select.ts +24 -16
- package/docs/routing.md +4 -2
- package/docs/upgrade-notes/v86-upgrade-notes.md +73 -8
- 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 +13 -6
- package/mcp/formatters/typescript.ts +7 -3
- package/mcp/util/paths.ts +21 -0
- package/mobile/cmp/grid/impl/ColChooserModel.ts +2 -2
- 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 +5 -5
- package/svc/PrefService.ts +80 -20
- package/svc/README.md +6 -0
- package/utils/datetime/LocalDate.ts +24 -1
- package/desktop/cmp/filter/PopoverFilterChooser.scss +0 -42
package/core/types/Types.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {LocalDate} from '@xh/hoist/utils/datetime';
|
|
|
9
9
|
import {MomentInput} from 'moment';
|
|
10
10
|
import {Component, FunctionComponent, ReactElement} from 'react';
|
|
11
11
|
import {DebounceSettings} from 'lodash';
|
|
12
|
+
import {Route} from 'router5';
|
|
12
13
|
|
|
13
14
|
/** Values available for intents. */
|
|
14
15
|
export type Intent = 'primary' | 'success' | 'warning' | 'danger';
|
|
@@ -55,14 +56,22 @@ export type DebounceSpec = number | (DebounceSettings & {interval: number});
|
|
|
55
56
|
* function that returns a ReactElement. In either case, the function will be called with no arguments.
|
|
56
57
|
*/
|
|
57
58
|
export type Content =
|
|
58
|
-
| ReactElement
|
|
59
|
-
| FunctionComponent
|
|
60
|
-
| Component
|
|
61
|
-
| ElementFactory
|
|
62
|
-
| (() => ReactElement);
|
|
59
|
+
ReactElement | FunctionComponent | Component | ElementFactory | (() => ReactElement);
|
|
63
60
|
|
|
64
61
|
export type DateLike = Date | LocalDate | MomentInput;
|
|
65
62
|
|
|
63
|
+
/**
|
|
64
|
+
* A Router5 {@link Route} spec, extended with Hoist's `omit` support for declarative exclusion of
|
|
65
|
+
* routes at registration time (e.g. role-gated sections). Note `omit` is evaluated once when routes
|
|
66
|
+
* are added to the router during app startup - it is not reactive.
|
|
67
|
+
*
|
|
68
|
+
* @see HoistAppModel.getRoutes
|
|
69
|
+
*/
|
|
70
|
+
export interface HoistRoute extends Omit<Route, 'children'> {
|
|
71
|
+
omit?: Thunkable<boolean>;
|
|
72
|
+
children?: HoistRoute[];
|
|
73
|
+
}
|
|
74
|
+
|
|
66
75
|
/** Valid units for the {@link LocalDate} adjustment methods. */
|
|
67
76
|
export type LocalDateUnit =
|
|
68
77
|
| 'year'
|
package/data/Store.ts
CHANGED
|
@@ -559,8 +559,9 @@ export class Store
|
|
|
559
559
|
* Add new Records to this Store in a local, uncommitted state - i.e. with data that has yet to
|
|
560
560
|
* be persisted back to, or sourced from, the server or other data source of record.
|
|
561
561
|
*
|
|
562
|
-
* Note that data objects passed to this method must include a
|
|
563
|
-
*
|
|
562
|
+
* Note that data objects passed to this method must include a literal `id` property - this
|
|
563
|
+
* method does *not* run the Store's `idSpec` function. Callers can generate an id with
|
|
564
|
+
* `XH.genId()` if no natural ID can be produced locally on the client.
|
|
564
565
|
*
|
|
565
566
|
* For StoreRecord additions that originate from the server, call `updateData()` instead.
|
|
566
567
|
*
|
|
@@ -12,7 +12,7 @@ import {dropzone} from '@xh/hoist/kit/react-dropzone';
|
|
|
12
12
|
import {elementFromContent, getLayoutProps} from '@xh/hoist/utils/react';
|
|
13
13
|
import {FileRejection} from 'react-dropzone';
|
|
14
14
|
import {FileChooserModel} from './FileChooserModel';
|
|
15
|
-
import {
|
|
15
|
+
import {isEmpty} from 'lodash';
|
|
16
16
|
import classNames from 'classnames';
|
|
17
17
|
import {defaultEmptyDisplay} from './impl/DefaultEmptyDisplay';
|
|
18
18
|
import {defaultFileDisplay} from './impl/DefaultFileDisplay';
|
|
@@ -88,8 +88,11 @@ export const [FileChooser, fileChooser] = hoistCmp.withFactory<FileChooserProps>
|
|
|
88
88
|
|
|
89
89
|
return dropzone({
|
|
90
90
|
ref: model.dropzoneRef,
|
|
91
|
-
// react-dropzone
|
|
92
|
-
|
|
91
|
+
// react-dropzone expects `accept` to be a {type: [extensions]} map. The FileChooserModel
|
|
92
|
+
// only accepts a list of extensions. Extensions alone are sufficient for validation and
|
|
93
|
+
// a MIME type is not required. Use `_/_` as a dummy MIME type to prevent console warnings
|
|
94
|
+
// from react-dropzone.
|
|
95
|
+
accept: accept ? {'_/_': accept} : null,
|
|
93
96
|
// Disable interaction (drag/click/drop) at the limit; the target shows a clear message.
|
|
94
97
|
disabled: disabled || atLimit,
|
|
95
98
|
maxFiles,
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import {em, li, span, ul, vbox} from '@xh/hoist/cmp/layout';
|
|
8
8
|
import {HoistModel, Some, ToastSpec, XH} from '@xh/hoist/core';
|
|
9
9
|
import '@xh/hoist/desktop/register';
|
|
10
|
-
import {FileRejection} from '@xh/hoist/kit/react-dropzone';
|
|
10
|
+
import {ErrorCode, FileRejection} from '@xh/hoist/kit/react-dropzone';
|
|
11
11
|
import {action, makeObservable, observable} from '@xh/hoist/mobx';
|
|
12
12
|
import {pluralize, withDefault} from '@xh/hoist/utils/js';
|
|
13
13
|
import {createObservableRef} from '@xh/hoist/utils/react';
|
|
@@ -187,10 +187,17 @@ export class FileChooserModel extends HoistModel {
|
|
|
187
187
|
return true;
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
-
private defaultRejectMessage(rejections: FileRejection[]): ReactElement {
|
|
191
|
-
// 1) Map rejected files to error messages
|
|
190
|
+
private defaultRejectMessage = (rejections: FileRejection[]): ReactElement => {
|
|
191
|
+
// 1) Map rejected files to error messages. Use custom message for invalid types to avoid displaying dummy MIME type.
|
|
192
192
|
const errorsByFile = fromPairs(
|
|
193
|
-
map(rejections, ({file, errors}) => [
|
|
193
|
+
map(rejections, ({file, errors}) => [
|
|
194
|
+
file.name,
|
|
195
|
+
map(errors, e =>
|
|
196
|
+
e.code === ErrorCode.FileInvalidType
|
|
197
|
+
? `File type must be one of ${this.accept.join(', ')}`
|
|
198
|
+
: e.message
|
|
199
|
+
)
|
|
200
|
+
])
|
|
194
201
|
);
|
|
195
202
|
|
|
196
203
|
// 2) List files with bulleted error messages
|
|
@@ -207,7 +214,7 @@ export class FileChooserModel extends HoistModel {
|
|
|
207
214
|
});
|
|
208
215
|
|
|
209
216
|
return vbox(rejectItems);
|
|
210
|
-
}
|
|
217
|
+
};
|
|
211
218
|
|
|
212
219
|
private getRejectToastSpec(params: Partial<ToastSpec> | boolean): Partial<ToastSpec> {
|
|
213
220
|
if (params == false) return null;
|
|
@@ -15,6 +15,48 @@
|
|
|
15
15
|
margin: 7px 4px;
|
|
16
16
|
align-self: start;
|
|
17
17
|
}
|
|
18
|
+
|
|
19
|
+
// Popover mode - collapsed trigger expands into an overlaid popover.
|
|
20
|
+
&--popover > .bp6-popover-target {
|
|
21
|
+
display: flex;
|
|
22
|
+
flex: 1;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
&__trigger {
|
|
26
|
+
.xh-select__value-container--is-multi {
|
|
27
|
+
overflow-y: hidden !important;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Extra class names required to override the default styles of the popover.
|
|
32
|
+
&__popover.bp6-popover.bp6-minimal {
|
|
33
|
+
box-shadow: none;
|
|
34
|
+
|
|
35
|
+
// Overlay the expanded input onto the collapsed 30px trigger. Offset follows Blueprint's
|
|
36
|
+
// resolved placement, which flips (bottom-anchored) near the viewport's bottom edge.
|
|
37
|
+
&.bp6-popover-placement-bottom {
|
|
38
|
+
margin-top: -30px !important;
|
|
39
|
+
}
|
|
40
|
+
&.bp6-popover-placement-top {
|
|
41
|
+
margin-bottom: -30px !important;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Content is portaled out of .xh-filter-chooser - restate the flex rule so it fills the width.
|
|
45
|
+
.bp6-popover-target {
|
|
46
|
+
display: flex;
|
|
47
|
+
flex: 1;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Opaque bg to occlude the trigger and page beneath (--xh-input-bg is translucent in dark mode).
|
|
51
|
+
.bp6-popover-content {
|
|
52
|
+
background: var(--xh-bg);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.xh-select__value-container--is-multi {
|
|
56
|
+
height: unset;
|
|
57
|
+
line-height: unset;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
18
60
|
}
|
|
19
61
|
|
|
20
62
|
.xh-filter-chooser-option {
|
|
@@ -6,14 +6,23 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import {FilterChooserFilter, FilterChooserModel} from '@xh/hoist/cmp/filter';
|
|
8
8
|
import {box, div, hbox, hframe, vbox} from '@xh/hoist/cmp/layout';
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
hoistCmp,
|
|
11
|
+
HoistModel,
|
|
12
|
+
HoistProps,
|
|
13
|
+
LayoutProps,
|
|
14
|
+
lookup,
|
|
15
|
+
useLocalModel,
|
|
16
|
+
uses
|
|
17
|
+
} from '@xh/hoist/core';
|
|
18
|
+
import {bindable, makeObservable} from '@xh/hoist/mobx';
|
|
10
19
|
import {button} from '@xh/hoist/desktop/cmp/button';
|
|
11
20
|
import {select} from '@xh/hoist/desktop/cmp/input';
|
|
12
21
|
import '@xh/hoist/desktop/register';
|
|
13
22
|
import {Icon} from '@xh/hoist/icon';
|
|
14
23
|
import {menu, menuDivider, menuItem, popover} from '@xh/hoist/kit/blueprint';
|
|
15
|
-
import {withDefault} from '@xh/hoist/utils/js';
|
|
16
|
-
import {splitLayoutProps} from '@xh/hoist/utils/react';
|
|
24
|
+
import {elemWithin, withDefault} from '@xh/hoist/utils/js';
|
|
25
|
+
import {getLayoutProps, splitLayoutProps} from '@xh/hoist/utils/react';
|
|
17
26
|
import classNames from 'classnames';
|
|
18
27
|
import {isEmpty, sortBy} from 'lodash';
|
|
19
28
|
import {badge} from '@xh/hoist/cmp/badge';
|
|
@@ -41,6 +50,11 @@ export interface FilterChooserProps extends HoistProps<FilterChooserModel>, Layo
|
|
|
41
50
|
placeholder?: string;
|
|
42
51
|
/** Icon clicked to launch favorites menu. (Defaults to Icon.favorite()) */
|
|
43
52
|
favoritesIcon?: ReactElement;
|
|
53
|
+
/**
|
|
54
|
+
* True to render collapsed in-place, expanding into a popover when opened - useful in toolbars
|
|
55
|
+
* and other height-constrained containers. Opens in the direction set by `menuPlacement`.
|
|
56
|
+
*/
|
|
57
|
+
popover?: boolean;
|
|
44
58
|
}
|
|
45
59
|
|
|
46
60
|
/**
|
|
@@ -50,6 +64,30 @@ export interface FilterChooserProps extends HoistProps<FilterChooserModel>, Layo
|
|
|
50
64
|
export const [FilterChooser, filterChooser] = hoistCmp.withFactory<FilterChooserProps>({
|
|
51
65
|
model: uses(FilterChooserModel),
|
|
52
66
|
className: 'xh-filter-chooser',
|
|
67
|
+
render({model, className, ...props}, ref) {
|
|
68
|
+
return props.popover
|
|
69
|
+
? popoverFilterChooser({model, className, ...props, ref})
|
|
70
|
+
: filterChooserControl({model, className, ...props, ref});
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
//------------------
|
|
75
|
+
// Implementation
|
|
76
|
+
//------------------
|
|
77
|
+
interface FilterChooserControlProps extends FilterChooserProps {
|
|
78
|
+
/** Internal - false to render a non-interactive display (the collapsed popover trigger). */
|
|
79
|
+
xhInteractive?: boolean;
|
|
80
|
+
/** Internal - override for the favorites menu open state. */
|
|
81
|
+
xhFavoritesOpen?: boolean;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The Select-based control shared by the inline FilterChooser and both faces (collapsed trigger and
|
|
86
|
+
* expanded content) of the popover variant. Only the interactive instance binds `model.inputRef`,
|
|
87
|
+
* so the popover can mount trigger and content simultaneously without contention.
|
|
88
|
+
*/
|
|
89
|
+
const filterChooserControl = hoistCmp.factory<FilterChooserControlProps>({
|
|
90
|
+
model: uses(FilterChooserModel),
|
|
53
91
|
render({model, className, ...props}, ref) {
|
|
54
92
|
const [layoutProps, chooserProps] = splitLayoutProps(props),
|
|
55
93
|
{
|
|
@@ -68,7 +106,9 @@ export const [FilterChooser, filterChooser] = hoistCmp.withFactory<FilterChooser
|
|
|
68
106
|
maxMenuHeight,
|
|
69
107
|
menuPlacement,
|
|
70
108
|
menuWidth,
|
|
71
|
-
favoritesIcon
|
|
109
|
+
favoritesIcon,
|
|
110
|
+
xhInteractive = true,
|
|
111
|
+
xhFavoritesOpen
|
|
72
112
|
} = chooserProps,
|
|
73
113
|
disabled = unsupportedFilter || chooserProps.disabled,
|
|
74
114
|
placeholder = unsupportedFilter
|
|
@@ -90,7 +130,8 @@ export const [FilterChooser, filterChooser] = hoistCmp.withFactory<FilterChooser
|
|
|
90
130
|
flex: 1,
|
|
91
131
|
height: layoutProps?.height,
|
|
92
132
|
bind: 'selectValue',
|
|
93
|
-
|
|
133
|
+
// Only the interactive instance owns the shared inputRef.
|
|
134
|
+
ref: xhInteractive ? inputRef : undefined,
|
|
94
135
|
|
|
95
136
|
autoFocus,
|
|
96
137
|
disabled,
|
|
@@ -119,12 +160,15 @@ export const [FilterChooser, filterChooser] = hoistCmp.withFactory<FilterChooser
|
|
|
119
160
|
},
|
|
120
161
|
components: {
|
|
121
162
|
DropdownIndicator: () => favoritesIconCmp(model, favoritesIcon)
|
|
122
|
-
}
|
|
163
|
+
},
|
|
164
|
+
// Display-only trigger: suppress menu + typing, but leave clear and
|
|
165
|
+
// favorites affordances live for single-click access.
|
|
166
|
+
...(xhInteractive ? {} : {menuIsOpen: false, isSearchable: false})
|
|
123
167
|
}
|
|
124
168
|
})
|
|
125
169
|
),
|
|
126
170
|
content: favoritesMenu(),
|
|
127
|
-
isOpen: favoritesIsOpen,
|
|
171
|
+
isOpen: xhFavoritesOpen ?? favoritesIsOpen,
|
|
128
172
|
position: 'bottom-right',
|
|
129
173
|
minimal: true,
|
|
130
174
|
onInteraction: willOpen => {
|
|
@@ -136,6 +180,102 @@ export const [FilterChooser, filterChooser] = hoistCmp.withFactory<FilterChooser
|
|
|
136
180
|
}
|
|
137
181
|
});
|
|
138
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Wraps a FilterChooser so it renders collapsed in-place and expands into a popover when opened,
|
|
185
|
+
* allowing it to grow vertically beyond the height of a toolbar. The collapsed trigger always
|
|
186
|
+
* occupies its space (so surrounding layout never shifts) and routes single clicks on its clear
|
|
187
|
+
* and favorites controls directly, rather than first opening the popover.
|
|
188
|
+
*/
|
|
189
|
+
const popoverFilterChooser = hoistCmp.factory<FilterChooserProps>({
|
|
190
|
+
model: uses(FilterChooserModel),
|
|
191
|
+
render({model, className, ...props}, ref) {
|
|
192
|
+
const impl = useLocalModel(FilterChooserLocalModel),
|
|
193
|
+
{popoverIsOpen} = impl,
|
|
194
|
+
{popover: _popover, ...rest} = props,
|
|
195
|
+
layoutProps = getLayoutProps(rest);
|
|
196
|
+
|
|
197
|
+
return box({
|
|
198
|
+
ref,
|
|
199
|
+
className: classNames(className, 'xh-filter-chooser--popover'),
|
|
200
|
+
...layoutProps,
|
|
201
|
+
item: popover({
|
|
202
|
+
isOpen: popoverIsOpen,
|
|
203
|
+
popoverClassName: 'xh-filter-chooser__popover',
|
|
204
|
+
matchTargetWidth: true,
|
|
205
|
+
minimal: true,
|
|
206
|
+
position: 'bottom',
|
|
207
|
+
item: filterChooserControl({
|
|
208
|
+
model,
|
|
209
|
+
flex: 1,
|
|
210
|
+
className: 'xh-filter-chooser__trigger',
|
|
211
|
+
...rest,
|
|
212
|
+
displayCount: true,
|
|
213
|
+
xhInteractive: false,
|
|
214
|
+
// Trigger hosts the favorites menu only while collapsed; once open, the
|
|
215
|
+
// expanded content takes over.
|
|
216
|
+
xhFavoritesOpen: model.favoritesIsOpen && !popoverIsOpen
|
|
217
|
+
}),
|
|
218
|
+
content: filterChooserControl({
|
|
219
|
+
model,
|
|
220
|
+
flex: 1,
|
|
221
|
+
className: 'xh-filter-chooser__content',
|
|
222
|
+
...rest,
|
|
223
|
+
displayCount: true
|
|
224
|
+
}),
|
|
225
|
+
onInteraction: (willOpen, e) => {
|
|
226
|
+
if (willOpen) {
|
|
227
|
+
// Let clicks on the inline clear / favorites controls act directly.
|
|
228
|
+
const target = e?.target as HTMLElement;
|
|
229
|
+
if (
|
|
230
|
+
target &&
|
|
231
|
+
(elemWithin(target, 'xh-select__clear-indicator') ||
|
|
232
|
+
elemWithin(target, 'xh-filter-chooser-favorite-icon'))
|
|
233
|
+
) {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
impl.open();
|
|
237
|
+
} else {
|
|
238
|
+
impl.close();
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
})
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
class FilterChooserLocalModel extends HoistModel {
|
|
247
|
+
override xhImpl = true;
|
|
248
|
+
|
|
249
|
+
@lookup(FilterChooserModel)
|
|
250
|
+
model: FilterChooserModel;
|
|
251
|
+
|
|
252
|
+
@bindable
|
|
253
|
+
popoverIsOpen: boolean = false;
|
|
254
|
+
|
|
255
|
+
constructor() {
|
|
256
|
+
super();
|
|
257
|
+
makeObservable(this);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
open() {
|
|
261
|
+
this.popoverIsOpen = true;
|
|
262
|
+
|
|
263
|
+
// Focus the (now-mounted) interactive input and open its menu once available.
|
|
264
|
+
this.addReaction({
|
|
265
|
+
when: () => !!this.model.inputRef.current,
|
|
266
|
+
run: () => {
|
|
267
|
+
const inputRef = this.model.inputRef.current;
|
|
268
|
+
inputRef.focus();
|
|
269
|
+
(inputRef as any).reactSelectRef.current?.openMenu('first');
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
close() {
|
|
275
|
+
this.popoverIsOpen = false;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
139
279
|
//-----------------
|
|
140
280
|
// Options
|
|
141
281
|
//------------------
|
|
@@ -5,100 +5,23 @@
|
|
|
5
5
|
* Copyright © 2026 Extremely Heavy Industries Inc.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {hoistCmp,
|
|
9
|
-
import {bindable, makeObservable} from '@xh/hoist/mobx';
|
|
10
|
-
import {box, hframe} from '@xh/hoist/cmp/layout';
|
|
8
|
+
import {hoistCmp, uses} from '@xh/hoist/core';
|
|
11
9
|
import '@xh/hoist/desktop/register';
|
|
12
|
-
import {popover} from '@xh/hoist/kit/blueprint';
|
|
13
|
-
import {getLayoutProps} from '@xh/hoist/utils/react';
|
|
14
|
-
import './PopoverFilterChooser.scss';
|
|
15
|
-
import {filterChooser, FilterChooserProps} from './FilterChooser';
|
|
16
10
|
import {FilterChooserModel} from '@xh/hoist/cmp/filter';
|
|
11
|
+
import {filterChooser, FilterChooserProps} from './FilterChooser';
|
|
17
12
|
|
|
18
13
|
/**
|
|
19
14
|
* A wrapper around a FilterChooser that renders in a popover when opened, allowing it to expand
|
|
20
15
|
* vertically beyond the height of a toolbar.
|
|
16
|
+
*
|
|
17
|
+
* @deprecated Use `filterChooser({popover: true})` instead - the popover behavior is now a built-in
|
|
18
|
+
* mode of `FilterChooser`. This alias will be removed in a future major release.
|
|
21
19
|
* @see FilterChooser
|
|
22
20
|
*/
|
|
23
21
|
export const [PopoverFilterChooser, popoverFilterChooser] =
|
|
24
22
|
hoistCmp.withFactory<FilterChooserProps>({
|
|
25
23
|
model: uses(FilterChooserModel),
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const layoutProps = getLayoutProps(props),
|
|
29
|
-
impl = useLocalModel(PopoverFilterChooserLocalModel);
|
|
30
|
-
|
|
31
|
-
return box({
|
|
32
|
-
ref,
|
|
33
|
-
className,
|
|
34
|
-
...layoutProps,
|
|
35
|
-
item: popover({
|
|
36
|
-
isOpen: impl.popoverIsOpen,
|
|
37
|
-
popoverClassName: 'xh-popover-filter-chooser__popover',
|
|
38
|
-
item: hframe(
|
|
39
|
-
filterChooser({
|
|
40
|
-
model,
|
|
41
|
-
// Omit when popover is open to force update the inputRef
|
|
42
|
-
omit: impl.popoverIsOpen,
|
|
43
|
-
className: 'xh-popover-filter-chooser__filter-chooser',
|
|
44
|
-
displayCount: true,
|
|
45
|
-
...props,
|
|
46
|
-
disabled: true
|
|
47
|
-
})
|
|
48
|
-
),
|
|
49
|
-
content: filterChooser({
|
|
50
|
-
model,
|
|
51
|
-
displayCount: true,
|
|
52
|
-
...props
|
|
53
|
-
}),
|
|
54
|
-
matchTargetWidth: true,
|
|
55
|
-
minimal: true,
|
|
56
|
-
position: 'bottom',
|
|
57
|
-
onInteraction: open => {
|
|
58
|
-
if (open) {
|
|
59
|
-
impl.open();
|
|
60
|
-
} else {
|
|
61
|
-
impl.close();
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
})
|
|
65
|
-
});
|
|
24
|
+
render(props) {
|
|
25
|
+
return filterChooser({popover: true, ...props});
|
|
66
26
|
}
|
|
67
27
|
});
|
|
68
|
-
|
|
69
|
-
class PopoverFilterChooserLocalModel extends HoistModel {
|
|
70
|
-
override xhImpl = true;
|
|
71
|
-
|
|
72
|
-
@lookup(FilterChooserModel)
|
|
73
|
-
model: FilterChooserModel;
|
|
74
|
-
|
|
75
|
-
@bindable
|
|
76
|
-
popoverIsOpen: boolean = false;
|
|
77
|
-
|
|
78
|
-
get displaySelectValue() {
|
|
79
|
-
return this.model.selectValue[0];
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
constructor() {
|
|
83
|
-
super();
|
|
84
|
-
makeObservable(this);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
open() {
|
|
88
|
-
this.popoverIsOpen = true;
|
|
89
|
-
|
|
90
|
-
// Focus and open the menu when rendered
|
|
91
|
-
this.addReaction({
|
|
92
|
-
when: () => !!this.model.inputRef.current,
|
|
93
|
-
run: () => {
|
|
94
|
-
const inputRef = this.model.inputRef.current;
|
|
95
|
-
inputRef.focus();
|
|
96
|
-
(inputRef as any).reactSelectRef.current?.openMenu('first');
|
|
97
|
-
}
|
|
98
|
-
});
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
close() {
|
|
102
|
-
this.popoverIsOpen = false;
|
|
103
|
-
}
|
|
104
|
-
}
|
|
@@ -35,7 +35,12 @@ export function useInlineEditorModel(
|
|
|
35
35
|
|
|
36
36
|
useGridCellEditor({
|
|
37
37
|
// This is called in full-row editing when the user tabs into the cell
|
|
38
|
-
focusIn: useCallback(() => impl.focus(), [impl])
|
|
38
|
+
focusIn: useCallback(() => impl.focus(), [impl]),
|
|
39
|
+
// Backstop to flush any pending edit when editing ends, in case no blur fired (#4134).
|
|
40
|
+
isCancelAfterEnd: useCallback(() => {
|
|
41
|
+
impl.ref.current?.doCommit();
|
|
42
|
+
return false;
|
|
43
|
+
}, [impl])
|
|
39
44
|
});
|
|
40
45
|
|
|
41
46
|
return component({
|
|
@@ -268,7 +268,6 @@ export class GridFindFieldImplModel extends HoistModel {
|
|
|
268
268
|
|
|
269
269
|
private getActiveFields(): string[] {
|
|
270
270
|
const {gridModel, includeFields, excludeFields} = this,
|
|
271
|
-
groupBy = gridModel.groupBy,
|
|
272
271
|
visibleCols = gridModel.getVisibleLeafColumns();
|
|
273
272
|
|
|
274
273
|
let ret = ['id', ...gridModel.store.fieldNames];
|
|
@@ -293,12 +292,11 @@ export class GridFindFieldImplModel extends HoistModel {
|
|
|
293
292
|
// Run exclude once more to support explicitly excluding a dot-sep field added above.
|
|
294
293
|
if (excludeFields) ret = without(ret, ...excludeFields);
|
|
295
294
|
|
|
296
|
-
// Final filter for column visibility, or explicit request for inclusion.
|
|
295
|
+
// Final filter for column visibility, or explicit request for inclusion. Deliberately not
|
|
296
|
+
// keyed to groupBy, so query results stay stable across regrouping (see #4070).
|
|
297
297
|
ret = ret.filter(f => {
|
|
298
298
|
return (
|
|
299
|
-
(includeFields && includeFields.includes(f)) ||
|
|
300
|
-
visibleCols.find(c => c.field === f) ||
|
|
301
|
-
groupBy.includes(f)
|
|
299
|
+
(includeFields && includeFields.includes(f)) || visibleCols.find(c => c.field === f)
|
|
302
300
|
);
|
|
303
301
|
});
|
|
304
302
|
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Copyright © 2026 Extremely Heavy Industries Inc.
|
|
6
6
|
*/
|
|
7
|
-
import {ColChooserConfig, GridModel} from '@xh/hoist/cmp/grid';
|
|
7
|
+
import {ColChooserConfig, GridModel, IColChooserModel} from '@xh/hoist/cmp/grid';
|
|
8
8
|
import {HoistModel, managed} from '@xh/hoist/core';
|
|
9
9
|
import type {FilterMatchMode} from '@xh/hoist/data';
|
|
10
10
|
import {LeftRightChooserModel} from '@xh/hoist/desktop/cmp/leftrightchooser';
|
|
@@ -15,7 +15,7 @@ import {sortBy} from 'lodash';
|
|
|
15
15
|
* State management for the ColChooser component.
|
|
16
16
|
* @internal
|
|
17
17
|
*/
|
|
18
|
-
export class ColChooserModel extends HoistModel {
|
|
18
|
+
export class ColChooserModel extends HoistModel implements IColChooserModel {
|
|
19
19
|
override xhImpl = true;
|
|
20
20
|
|
|
21
21
|
gridModel: GridModel;
|
|
@@ -12,7 +12,7 @@ import {dateInput, numberInput, select, textInput} from '@xh/hoist/desktop/cmp/i
|
|
|
12
12
|
import {Icon} from '@xh/hoist/icon';
|
|
13
13
|
import {kebabCase} from 'lodash';
|
|
14
14
|
|
|
15
|
-
import {CustomRowModel} from './CustomRowModel';
|
|
15
|
+
import {CustomRowModel, usesMultiValueInput} from './CustomRowModel';
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* Row with operator and value combination for CustomTab.
|
|
@@ -74,17 +74,7 @@ const inputField = hoistCmp.factory<CustomRowModel>(({model}) => {
|
|
|
74
74
|
...fieldSpec.inputProps
|
|
75
75
|
};
|
|
76
76
|
|
|
77
|
-
if (fieldSpec
|
|
78
|
-
return numberInput({
|
|
79
|
-
...props,
|
|
80
|
-
enableShorthandUnits: true
|
|
81
|
-
});
|
|
82
|
-
} else if (fieldSpec.isDateBasedFieldType) {
|
|
83
|
-
return dateInput({
|
|
84
|
-
...props,
|
|
85
|
-
valueType: fieldSpec.fieldType as 'localDate' | 'date'
|
|
86
|
-
});
|
|
87
|
-
} else if (fieldSpec.supportsSuggestions(op as FieldFilterOperator)) {
|
|
77
|
+
if (usesMultiValueInput(fieldSpec, op as FieldFilterOperator)) {
|
|
88
78
|
return select({
|
|
89
79
|
...props,
|
|
90
80
|
options: fieldSpec.values,
|
|
@@ -94,6 +84,16 @@ const inputField = hoistCmp.factory<CustomRowModel>(({model}) => {
|
|
|
94
84
|
hideSelectedOptionCheck: true,
|
|
95
85
|
enableClear: false
|
|
96
86
|
});
|
|
87
|
+
} else if (fieldSpec.isNumericFieldType) {
|
|
88
|
+
return numberInput({
|
|
89
|
+
...props,
|
|
90
|
+
enableShorthandUnits: true
|
|
91
|
+
});
|
|
92
|
+
} else if (fieldSpec.isDateBasedFieldType) {
|
|
93
|
+
return dateInput({
|
|
94
|
+
...props,
|
|
95
|
+
valueType: fieldSpec.fieldType as 'localDate' | 'date'
|
|
96
|
+
});
|
|
97
97
|
} else {
|
|
98
98
|
return textInput(props);
|
|
99
99
|
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Copyright © 2026 Extremely Heavy Industries Inc.
|
|
6
6
|
*/
|
|
7
|
+
import type {GridFilterFieldSpec} from '@xh/hoist/cmp/grid';
|
|
7
8
|
import {HoistModel} from '@xh/hoist/core';
|
|
8
9
|
import {FieldFilterOperator, FieldFilterSpec} from '@xh/hoist/data';
|
|
9
10
|
import {HeaderFilterModel} from '../HeaderFilterModel';
|
|
@@ -13,6 +14,23 @@ import {CustomTabModel} from './CustomTabModel';
|
|
|
13
14
|
|
|
14
15
|
type OperatorOptionValue = 'blank' | 'not blank' | FieldFilterOperator;
|
|
15
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Whether a row for the given op renders the multi-value `select` input - the only custom-tab input
|
|
19
|
+
* that holds an array of values directly. The single-value inputs (number, date, text) are used
|
|
20
|
+
* otherwise, so array filter values bound for them must be expanded into one row each (see
|
|
21
|
+
* `CustomTabModel.doSyncWithFilter`). Mirrors the input choice made in `CustomRow`.
|
|
22
|
+
*/
|
|
23
|
+
export function usesMultiValueInput(
|
|
24
|
+
fieldSpec: GridFilterFieldSpec,
|
|
25
|
+
op: FieldFilterOperator
|
|
26
|
+
): boolean {
|
|
27
|
+
return (
|
|
28
|
+
!fieldSpec.isNumericFieldType &&
|
|
29
|
+
!fieldSpec.isDateBasedFieldType &&
|
|
30
|
+
fieldSpec.supportsSuggestions(op)
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
16
34
|
/**
|
|
17
35
|
* @internal
|
|
18
36
|
*/
|
|
@@ -23,13 +23,24 @@ import {CustomTabModel} from './CustomTabModel';
|
|
|
23
23
|
export const customTab = hoistCmp.factory({
|
|
24
24
|
model: uses(CustomTabModel),
|
|
25
25
|
render({model}) {
|
|
26
|
+
const {rowModels} = model;
|
|
27
|
+
if (!rowModels) {
|
|
28
|
+
return panel({
|
|
29
|
+
className: 'xh-custom-filter-tab',
|
|
30
|
+
item: div({
|
|
31
|
+
className: 'xh-custom-filter-tab__unsupported',
|
|
32
|
+
item: 'This filter is too complex to edit here.'
|
|
33
|
+
})
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
26
37
|
return panel({
|
|
27
38
|
className: 'xh-custom-filter-tab',
|
|
28
39
|
tbar: tbar(),
|
|
29
40
|
items: div({
|
|
30
41
|
className: 'xh-custom-filter-tab__list',
|
|
31
42
|
items: [
|
|
32
|
-
...
|
|
43
|
+
...rowModels.map(m => customRow({model: m})),
|
|
33
44
|
div({
|
|
34
45
|
className: 'xh-custom-filter-tab__list__add-btn-row',
|
|
35
46
|
items: [
|
|
@@ -52,7 +63,7 @@ export const customTab = hoistCmp.factory({
|
|
|
52
63
|
const tbar = hoistCmp.factory<CustomTabModel>(({model}) => {
|
|
53
64
|
return toolbar({
|
|
54
65
|
className: 'xh-custom-filter-tab__tbar',
|
|
55
|
-
omit: model.rowModels.length < 2,
|
|
66
|
+
omit: !model.rowModels || model.rowModels.length < 2,
|
|
56
67
|
compact: true,
|
|
57
68
|
items: [
|
|
58
69
|
filler(),
|