@xh/hoist 87.0.0 → 87.1.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 +38 -0
- package/appcontainer/MessageModel.ts +83 -7
- package/appcontainer/MessageSourceModel.ts +8 -0
- package/build/types/appcontainer/MessageModel.d.ts +14 -2
- package/build/types/core/XH.d.ts +4 -0
- package/build/types/core/persist/PersistenceProvider.d.ts +22 -1
- package/build/types/core/types/Interfaces.d.ts +38 -0
- package/build/types/desktop/cmp/filechooser/FileChooserModel.d.ts +3 -3
- package/build/types/desktop/cmp/input/Select.d.ts +7 -0
- package/build/types/desktop/cmp/viewmanager/dialog/ManageDialogModel.d.ts +1 -11
- package/build/types/desktop/cmp/viewmanager/dialog/TopLevelDropStrip.d.ts +1 -1
- package/build/types/mobile/cmp/input/Select.d.ts +7 -0
- package/cmp/relativetimestamp/RelativeTimestamp.ts +1 -1
- package/cmp/tab/TabModel.ts +1 -1
- package/core/XH.ts +4 -0
- package/core/persist/PersistOptions.ts +1 -6
- package/core/persist/PersistenceProvider.ts +34 -29
- package/core/persist/index.ts +23 -0
- package/core/types/Interfaces.ts +44 -0
- package/desktop/appcontainer/Message.ts +11 -1
- package/desktop/cmp/filechooser/FileChooserModel.ts +31 -17
- package/desktop/cmp/input/Select.scss +4 -0
- package/desktop/cmp/input/Select.ts +36 -0
- package/desktop/cmp/viewmanager/ViewManager.scss +6 -0
- package/desktop/cmp/viewmanager/dialog/ManageDialog.ts +9 -0
- package/desktop/cmp/viewmanager/dialog/ManageDialogModel.ts +8 -37
- package/desktop/cmp/viewmanager/dialog/TopLevelDropStrip.ts +2 -13
- package/mobile/appcontainer/Message.ts +11 -1
- package/mobile/cmp/input/Select.scss +4 -0
- package/mobile/cmp/input/Select.ts +28 -0
- package/package.json +9 -3
- package/utils/js/ClipboardUtils.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,44 @@
|
|
|
12
12
|
3. Plain ASCII punctuation only. Use " - " for in-sentence breaks, never an em dash.
|
|
13
13
|
-->
|
|
14
14
|
|
|
15
|
+
## 87.1.0 - 2026-08-28
|
|
16
|
+
|
|
17
|
+
### 🎁 New Features
|
|
18
|
+
|
|
19
|
+
* Added new `MessageSpec.suppress` config for `XH.message()` and its `alert`, `confirm`, and
|
|
20
|
+
`prompt` variants. Set to `true` (or a config object) to offer users a "Don't show this message
|
|
21
|
+
again" checkbox. Confirmed responses are saved to browser local or session storage - optionally
|
|
22
|
+
with an expiry - and returned immediately by future calls with the same `messageKey`.
|
|
23
|
+
* `FileChooser` now takes as many dropped files as its `maxFiles` limit allows, warning about only
|
|
24
|
+
the surplus rather than discarding the entire drop.
|
|
25
|
+
* Added an opt-in `enforceValueInOptions` prop to the desktop and mobile `Select`, constraining the
|
|
26
|
+
value to the current `options` and dropping any selection no longer found there. Enforced once
|
|
27
|
+
`options` is non-null, so pass null while options load.
|
|
28
|
+
|
|
29
|
+
### ⚙️ Technical
|
|
30
|
+
|
|
31
|
+
* Upgraded `react-dropzone` to v20, which drops its UMD build and ships as an ESM + CJS package with
|
|
32
|
+
an `exports` map. Requires Node >= 22 to install.
|
|
33
|
+
* Added an opt-in `enforceValueInOptions` prop to the desktop and mobile `Select`, constraining the
|
|
34
|
+
value to the current `options` and dropping any selection no longer found there. Enforced once
|
|
35
|
+
`options` is non-null, so pass null while options load.
|
|
36
|
+
* Extended the package `sideEffects` declaration to cover the vendored golden-layout implementation
|
|
37
|
+
and the barrels with registration or configuration side effects on import - icon, mobx, blueprint
|
|
38
|
+
kit, golden-layout kit, and persist. Required by the tree-shaking in hoist-dev-utils v15.
|
|
39
|
+
* Restructured `PersistenceProvider` provider registration to remove a base/subclass import cycle
|
|
40
|
+
that breaks under tree-shaking bundlers' re-export optimization. No API change.
|
|
41
|
+
|
|
42
|
+
### ⚙️ Typescript API Adjustments
|
|
43
|
+
|
|
44
|
+
* `SelectOption` now admits custom fields alongside the standard `value`/`label`, so extra data
|
|
45
|
+
carried on an option (and already passed through at runtime) can be read within `optionRenderer`,
|
|
46
|
+
`filterFn`, and friends without a cast. Annotate the callback's argument with the app's own option
|
|
47
|
+
type for fully typed access - e.g. `optionRenderer: (opt: MyOption) => ...`.
|
|
48
|
+
|
|
49
|
+
### 📚 Libraries
|
|
50
|
+
|
|
51
|
+
* react-dropzone `15.0 → 20.1`
|
|
52
|
+
|
|
15
53
|
## 87.0.0 - 2026-08-25
|
|
16
54
|
|
|
17
55
|
### 💥 Breaking Changes (upgrade difficulty: 🟠 MEDIUM - React 19, data layer, column chooser)
|
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
* Copyright © 2026 Extremely Heavy Industries Inc.
|
|
6
6
|
*/
|
|
7
7
|
import {FormModel} from '@xh/hoist/cmp/form';
|
|
8
|
-
import {HoistModel, XH, MessageSpec, managed} from '@xh/hoist/core';
|
|
8
|
+
import {HoistModel, XH, MessageSpec, MessageSuppressSpec, managed} from '@xh/hoist/core';
|
|
9
9
|
import {action, observable, makeObservable} from '@xh/hoist/mobx';
|
|
10
|
-
import {
|
|
10
|
+
import {DAYS, HOURS, MINUTES} from '@xh/hoist/utils/datetime';
|
|
11
|
+
import {pluralize, throwIf, warnIf} from '@xh/hoist/utils/js';
|
|
11
12
|
import {isEmpty} from 'lodash';
|
|
12
13
|
import {ReactNode} from 'react';
|
|
13
14
|
|
|
@@ -27,6 +28,7 @@ export class MessageModel extends HoistModel {
|
|
|
27
28
|
messageKey;
|
|
28
29
|
className;
|
|
29
30
|
input;
|
|
31
|
+
suppress: MessageSuppressSpec;
|
|
30
32
|
extraConfirmLabel: ReactNode;
|
|
31
33
|
confirmProps;
|
|
32
34
|
cancelProps;
|
|
@@ -45,6 +47,18 @@ export class MessageModel extends HoistModel {
|
|
|
45
47
|
|
|
46
48
|
@observable isOpen = true;
|
|
47
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Previously saved response for a message the user has opted to suppress, or null if no
|
|
52
|
+
* response saved, saved response expired, or suppression not enabled for this message.
|
|
53
|
+
*/
|
|
54
|
+
static getSuppressedResult(spec: MessageSpec): {value: unknown} | null {
|
|
55
|
+
const {messageKey} = spec,
|
|
56
|
+
suppress = parseSuppress(spec.suppress);
|
|
57
|
+
if (!suppress || !messageKey) return null;
|
|
58
|
+
const saved = getSuppressStore(suppress).get(getSuppressKey(messageKey), null);
|
|
59
|
+
return saved && (!saved.expiry || Date.now() <= saved.expiry) ? saved : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
48
62
|
constructor({
|
|
49
63
|
title,
|
|
50
64
|
icon,
|
|
@@ -52,6 +66,7 @@ export class MessageModel extends HoistModel {
|
|
|
52
66
|
messageKey,
|
|
53
67
|
className,
|
|
54
68
|
input,
|
|
69
|
+
suppress,
|
|
55
70
|
extraConfirmText,
|
|
56
71
|
extraConfirmLabel,
|
|
57
72
|
confirmProps = {},
|
|
@@ -65,6 +80,11 @@ export class MessageModel extends HoistModel {
|
|
|
65
80
|
super();
|
|
66
81
|
makeObservable(this);
|
|
67
82
|
|
|
83
|
+
throwIf(
|
|
84
|
+
suppress && !messageKey,
|
|
85
|
+
'Must specify a "messageKey" when "suppress" is enabled for a message.'
|
|
86
|
+
);
|
|
87
|
+
|
|
68
88
|
this.title = title;
|
|
69
89
|
this.icon = icon;
|
|
70
90
|
this.message = message;
|
|
@@ -72,6 +92,7 @@ export class MessageModel extends HoistModel {
|
|
|
72
92
|
this.className = className;
|
|
73
93
|
this.dismissable = dismissable;
|
|
74
94
|
this.cancelOnDismiss = cancelOnDismiss;
|
|
95
|
+
this.suppress = parseSuppress(suppress);
|
|
75
96
|
|
|
76
97
|
const fields = [];
|
|
77
98
|
|
|
@@ -89,6 +110,10 @@ export class MessageModel extends HoistModel {
|
|
|
89
110
|
});
|
|
90
111
|
}
|
|
91
112
|
|
|
113
|
+
if (this.suppress) {
|
|
114
|
+
fields.push({name: 'suppress', initialValue: !!this.suppress.initialValue});
|
|
115
|
+
}
|
|
116
|
+
|
|
92
117
|
if (!isEmpty(fields)) {
|
|
93
118
|
this.formModel = new FormModel({fields});
|
|
94
119
|
}
|
|
@@ -109,15 +134,32 @@ export class MessageModel extends HoistModel {
|
|
|
109
134
|
});
|
|
110
135
|
}
|
|
111
136
|
|
|
137
|
+
/** Label for the suppress checkbox, as configured or an auto-generated default. */
|
|
138
|
+
get suppressLabel(): ReactNode {
|
|
139
|
+
const {suppress} = this;
|
|
140
|
+
if (!suppress) return null;
|
|
141
|
+
if (suppress.label) return suppress.label;
|
|
142
|
+
const expiryLabel = this.suppressExpiryLabel;
|
|
143
|
+
if (expiryLabel) return `Don't show this message again for ${expiryLabel}`;
|
|
144
|
+
return suppress.storage === 'session'
|
|
145
|
+
? `Don't show this message again this session`
|
|
146
|
+
: `Don't show this message again`;
|
|
147
|
+
}
|
|
148
|
+
|
|
112
149
|
@action
|
|
113
150
|
async doConfirmAsync() {
|
|
114
151
|
let resolvedVal = true;
|
|
115
152
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
if (
|
|
120
|
-
|
|
153
|
+
const {formModel} = this;
|
|
154
|
+
if (formModel) {
|
|
155
|
+
await formModel.validateAsync();
|
|
156
|
+
if (!formModel.isValid) return;
|
|
157
|
+
const data = formModel.getData();
|
|
158
|
+
if (formModel.getField('value')) {
|
|
159
|
+
resolvedVal = data.value;
|
|
160
|
+
}
|
|
161
|
+
if (data.suppress) {
|
|
162
|
+
this.saveSuppressedResult(resolvedVal);
|
|
121
163
|
}
|
|
122
164
|
}
|
|
123
165
|
|
|
@@ -168,4 +210,38 @@ export class MessageModel extends HoistModel {
|
|
|
168
210
|
const ret = {...props, onClick: handler};
|
|
169
211
|
return ret.text || ret.icon ? ret : null;
|
|
170
212
|
}
|
|
213
|
+
|
|
214
|
+
private saveSuppressedResult(value: unknown) {
|
|
215
|
+
const {suppress} = this,
|
|
216
|
+
{expiry} = suppress;
|
|
217
|
+
getSuppressStore(suppress).set(getSuppressKey(this.messageKey), {
|
|
218
|
+
value,
|
|
219
|
+
expiry: expiry ? Date.now() + expiry : null
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Humanized suppress expiry duration, expressed in the largest unit that divides it evenly.
|
|
224
|
+
private get suppressExpiryLabel(): string {
|
|
225
|
+
const {expiry} = this.suppress;
|
|
226
|
+
if (!expiry) return null;
|
|
227
|
+
const units: Array<[string, number]> = [
|
|
228
|
+
['day', DAYS],
|
|
229
|
+
['hour', HOURS],
|
|
230
|
+
['minute', MINUTES]
|
|
231
|
+
];
|
|
232
|
+
for (const [unit, unitMs] of units) {
|
|
233
|
+
if (expiry >= unitMs && expiry % unitMs === 0) {
|
|
234
|
+
return pluralize(unit, expiry / unitMs, true);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return pluralize('minute', Math.ceil(expiry / MINUTES), true);
|
|
238
|
+
}
|
|
171
239
|
}
|
|
240
|
+
|
|
241
|
+
const parseSuppress = (suppress: boolean | MessageSuppressSpec): MessageSuppressSpec =>
|
|
242
|
+
suppress ? (suppress === true ? {} : suppress) : null;
|
|
243
|
+
|
|
244
|
+
const getSuppressStore = (suppress: MessageSuppressSpec) =>
|
|
245
|
+
suppress.storage === 'session' ? XH.sessionStorageService : XH.localStorageService;
|
|
246
|
+
|
|
247
|
+
const getSuppressKey = (messageKey: string) => `xhSuppressedMessage.${messageKey}`;
|
|
@@ -28,6 +28,14 @@ export class MessageSourceModel extends HoistModel {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
message(config: MessageSpec) {
|
|
31
|
+
// Resolve immediately to any previously saved response the user has opted to suppress
|
|
32
|
+
// this message with - see MessageSpec.suppress.
|
|
33
|
+
const suppressed = MessageModel.getSuppressedResult(config);
|
|
34
|
+
if (suppressed) {
|
|
35
|
+
this.logDebug(`Suppressed message '${config.messageKey}'`, suppressed.value);
|
|
36
|
+
return Promise.resolve(suppressed.value);
|
|
37
|
+
}
|
|
38
|
+
|
|
31
39
|
// Default autoFocus on any confirm button, if no input control and developer has made no explicit request
|
|
32
40
|
const {confirmProps, cancelProps, input} = config;
|
|
33
41
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { FormModel } from '@xh/hoist/cmp/form';
|
|
2
|
-
import { HoistModel, MessageSpec } from '@xh/hoist/core';
|
|
2
|
+
import { HoistModel, MessageSpec, MessageSuppressSpec } from '@xh/hoist/core';
|
|
3
3
|
import { ReactNode } from 'react';
|
|
4
4
|
/**
|
|
5
5
|
* Model for a single instance of a modal dialog.
|
|
@@ -15,6 +15,7 @@ export declare class MessageModel extends HoistModel {
|
|
|
15
15
|
messageKey: any;
|
|
16
16
|
className: any;
|
|
17
17
|
input: any;
|
|
18
|
+
suppress: MessageSuppressSpec;
|
|
18
19
|
extraConfirmLabel: ReactNode;
|
|
19
20
|
confirmProps: any;
|
|
20
21
|
cancelProps: any;
|
|
@@ -27,11 +28,22 @@ export declare class MessageModel extends HoistModel {
|
|
|
27
28
|
_resolver: any;
|
|
28
29
|
formModel: FormModel;
|
|
29
30
|
isOpen: boolean;
|
|
30
|
-
|
|
31
|
+
/**
|
|
32
|
+
* Previously saved response for a message the user has opted to suppress, or null if no
|
|
33
|
+
* response saved, saved response expired, or suppression not enabled for this message.
|
|
34
|
+
*/
|
|
35
|
+
static getSuppressedResult(spec: MessageSpec): {
|
|
36
|
+
value: unknown;
|
|
37
|
+
} | null;
|
|
38
|
+
constructor({ title, icon, message, messageKey, className, input, suppress, extraConfirmText, extraConfirmLabel, confirmProps, cancelProps, cancelAlign, onConfirm, onCancel, dismissable, cancelOnDismiss }: MessageSpec);
|
|
39
|
+
/** Label for the suppress checkbox, as configured or an auto-generated default. */
|
|
40
|
+
get suppressLabel(): ReactNode;
|
|
31
41
|
doConfirmAsync(): Promise<void>;
|
|
32
42
|
doCancel(): void;
|
|
33
43
|
doEscape(): void;
|
|
34
44
|
close(): void;
|
|
35
45
|
destroy(): void;
|
|
36
46
|
private parseButtonProps;
|
|
47
|
+
private saveSuppressedResult;
|
|
48
|
+
private get suppressExpiryLabel();
|
|
37
49
|
}
|
package/build/types/core/XH.d.ts
CHANGED
|
@@ -331,6 +331,10 @@ export declare class XHApi {
|
|
|
331
331
|
* button instead (e.g. for confirming risky operations), applications should specify a
|
|
332
332
|
* `cancelProps` argument of the following form `cancelProps: {..., autoFocus: true}`.
|
|
333
333
|
*
|
|
334
|
+
* If `suppress` is specified and the user has previously opted to suppress this message,
|
|
335
|
+
* this method will resolve immediately to their previously saved response, without showing
|
|
336
|
+
* a dialog. This also applies to the `alert`, `confirm`, and `prompt` variants below.
|
|
337
|
+
*
|
|
334
338
|
* @returns true if user confirms, false if user cancels. If an input is provided, the
|
|
335
339
|
* returned Promise will resolve to the input value if user confirms, false if user cancels.
|
|
336
340
|
*/
|
|
@@ -1,11 +1,22 @@
|
|
|
1
1
|
import { Class } from 'type-fest';
|
|
2
2
|
import { DebounceSpec, HoistBase, Persistable, PersistableState } from '../';
|
|
3
|
-
import { PersistOptions } from './';
|
|
3
|
+
import { PersistenceProviderType, PersistOptions } from './PersistOptions';
|
|
4
4
|
export type PersistenceProviderConfig<S = any> = {
|
|
5
5
|
persistOptions: PersistOptions;
|
|
6
6
|
target: Persistable<S>;
|
|
7
7
|
owner?: HoistBase;
|
|
8
8
|
};
|
|
9
|
+
/**
|
|
10
|
+
* Provider registration metadata - see {@link PersistenceProvider.registerProviders}.
|
|
11
|
+
* @internal
|
|
12
|
+
*/
|
|
13
|
+
interface ProviderRegistration {
|
|
14
|
+
/** `PersistOptions.type` value that selects this provider. */
|
|
15
|
+
type: PersistenceProviderType;
|
|
16
|
+
/** `PersistOptions` keys whose presence selects this provider in shortcut (typeless) form. */
|
|
17
|
+
shortcutKeys: string[];
|
|
18
|
+
cls: Class<PersistenceProvider, [PersistenceProviderConfig]>;
|
|
19
|
+
}
|
|
9
20
|
/**
|
|
10
21
|
* Abstract superclass for adaptor objects used by models and components to (re)store state to and
|
|
11
22
|
* from a persistent location, typically a Hoist preference or key within browser local storage.
|
|
@@ -33,6 +44,15 @@ export declare abstract class PersistenceProvider<S = any> {
|
|
|
33
44
|
private disposer;
|
|
34
45
|
private lastReadState;
|
|
35
46
|
private lastReadTime;
|
|
47
|
+
private static registrations;
|
|
48
|
+
/**
|
|
49
|
+
* Register concrete provider implementations for lookup by `create`. Called by this
|
|
50
|
+
* package's index.ts for the built-in providers, which must not be statically imported by
|
|
51
|
+
* this base module: they extend this class, so it must be fully initialized before any of
|
|
52
|
+
* them can be defined - importing them here would create an initialization-order cycle.
|
|
53
|
+
* @internal
|
|
54
|
+
*/
|
|
55
|
+
static registerProviders(regs: ProviderRegistration[]): void;
|
|
36
56
|
/**
|
|
37
57
|
* Construct an instance of this class.
|
|
38
58
|
*
|
|
@@ -77,3 +97,4 @@ export declare abstract class PersistenceProvider<S = any> {
|
|
|
77
97
|
static parseProviderClass<S>(opts: PersistOptions): Class<PersistenceProvider<S>, [PersistenceProviderConfig<S>]>;
|
|
78
98
|
private ensureValid;
|
|
79
99
|
}
|
|
100
|
+
export {};
|
|
@@ -140,10 +140,22 @@ export interface MessageSpec {
|
|
|
140
140
|
* Unique key identifying the message. If subsequent messages are triggered with this key, they
|
|
141
141
|
* will replace this message. Useful for usages that may be producing messages recursively, or
|
|
142
142
|
* via timers, and wish to avoid generating a large stack of duplicates.
|
|
143
|
+
*
|
|
144
|
+
* Also identifies the message for suppression purposes - required if `suppress` is set.
|
|
143
145
|
*/
|
|
144
146
|
messageKey?: string;
|
|
145
147
|
/** Config for input to be displayed (as a prompt). */
|
|
146
148
|
input?: MessageSpecInput;
|
|
149
|
+
/**
|
|
150
|
+
* True or config to display a "Don't show this message again" checkbox, allowing users to
|
|
151
|
+
* opt out of future copies of this message. If the user confirms the message with the
|
|
152
|
+
* checkbox checked, their response will be saved to browser storage and returned
|
|
153
|
+
* immediately by future calls with the same `messageKey` (which must also be set).
|
|
154
|
+
*
|
|
155
|
+
* Specify as a config object to customize the checkbox label, limit how long the saved
|
|
156
|
+
* response should remain in effect, or save it to session (vs. local) storage.
|
|
157
|
+
*/
|
|
158
|
+
suppress?: boolean | MessageSuppressSpec;
|
|
147
159
|
/** If specified, user will be required to type this text when confirming. */
|
|
148
160
|
extraConfirmText?: string;
|
|
149
161
|
/**
|
|
@@ -185,6 +197,25 @@ export interface MessageSpecInput {
|
|
|
185
197
|
/** Initial value for the input. */
|
|
186
198
|
initialValue?: any;
|
|
187
199
|
}
|
|
200
|
+
/**
|
|
201
|
+
* Config for user opt-in message suppression - see {@link MessageSpec.suppress}.
|
|
202
|
+
*/
|
|
203
|
+
export interface MessageSuppressSpec {
|
|
204
|
+
/**
|
|
205
|
+
* Time (in ms) for which the user's saved response should remain in effect. Specify with
|
|
206
|
+
* datetime constants, e.g. `30 * DAYS`. Default null suppresses indefinitely.
|
|
207
|
+
*/
|
|
208
|
+
expiry?: number;
|
|
209
|
+
/**
|
|
210
|
+
* Browser storage in which the user's response should be saved (default 'local'). Specify
|
|
211
|
+
* 'session' to suppress only for the lifetime of the current browser tab.
|
|
212
|
+
*/
|
|
213
|
+
storage?: 'local' | 'session';
|
|
214
|
+
/** Label for the suppress checkbox. Defaults to a generated label appropriate to `expiry`. */
|
|
215
|
+
label?: ReactNode;
|
|
216
|
+
/** Initial value of the suppress checkbox (default false). */
|
|
217
|
+
initialValue?: boolean;
|
|
218
|
+
}
|
|
188
219
|
/**
|
|
189
220
|
* The base `MenuToken` type. '-' is interpreted as the standard textless divider. Components will
|
|
190
221
|
* likely extend this type to support other strings like 'copyToClipboard', 'print', etc. which the
|
|
@@ -246,9 +277,16 @@ export type ContextMenuSpec<T = MenuToken, C = MenuContext> = MenuItemLike<T, C>
|
|
|
246
277
|
export declare function isMenuItem<T, C>(item: MenuItemLike<T, C>): item is MenuItem<T, C>;
|
|
247
278
|
/**
|
|
248
279
|
* An option to be passed to Select controls.
|
|
280
|
+
*
|
|
281
|
+
* Additional custom fields are supported alongside the standard entries below and are passed
|
|
282
|
+
* through to callbacks such as `optionRenderer` and `filterFn`. For typed access to such fields,
|
|
283
|
+
* annotate the callback's argument with the app's own option type - e.g.
|
|
284
|
+
* `optionRenderer: (opt: MyOption) => ...`.
|
|
249
285
|
*/
|
|
250
286
|
export interface SelectOption {
|
|
251
287
|
value?: any;
|
|
252
288
|
label?: string;
|
|
253
289
|
options?: (SelectOption | any)[];
|
|
290
|
+
/** Custom fields, passed through to Select callbacks. */
|
|
291
|
+
[key: string]: any;
|
|
254
292
|
}
|
|
@@ -69,10 +69,10 @@ export declare class FileChooserModel extends HoistModel {
|
|
|
69
69
|
/** Open the file browser programmatically. Typically used in a button's onClick callback.*/
|
|
70
70
|
openFileBrowser(): void;
|
|
71
71
|
/**
|
|
72
|
-
* Add files to the selection.
|
|
72
|
+
* Add files to the selection, taking as many as the `maxFiles` limit allows.
|
|
73
73
|
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
74
|
+
* Does NOT enforce the `accept` / file-size constraints (those are applied only on
|
|
75
|
+
* drop/browse) - use with care.
|
|
76
76
|
*/
|
|
77
77
|
addFiles(files: Some<File>): void;
|
|
78
78
|
/** Remove a single file from the current selection. */
|
|
@@ -46,6 +46,13 @@ export interface SelectProps extends HoistProps, HoistInputProps, LayoutProps {
|
|
|
46
46
|
* Applications should use this option with care.
|
|
47
47
|
*/
|
|
48
48
|
enableWindowed?: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* True to constrain the value to the current `options` - any selected value not found there is
|
|
51
|
+
* removed whenever the value or the list changes. Enforced only once `options` is non-null, so
|
|
52
|
+
* pass null (not `[]`) while options load - `[]` means "no valid choices" and clears the value.
|
|
53
|
+
* Throws if combined with `enableCreate` or `queryFn`.
|
|
54
|
+
*/
|
|
55
|
+
enforceValueInOptions?: boolean;
|
|
49
56
|
/**
|
|
50
57
|
* Function called to filter available options for a given query string input.
|
|
51
58
|
* Used for filtering of options provided by `options` prop when `enableFilter` is true.
|
|
@@ -71,7 +71,7 @@ export declare class ManageDialogModel extends HoistModel {
|
|
|
71
71
|
isDragDisabled(gridModel: GridModel): boolean;
|
|
72
72
|
/**
|
|
73
73
|
* Display state for `gridModel`'s top-level drop strip - rest/armed/hot/blocked, matching
|
|
74
|
-
* the in-flight drag
|
|
74
|
+
* the in-flight drag, if it originated in this grid.
|
|
75
75
|
*/
|
|
76
76
|
stripState(gridModel: GridModel): {
|
|
77
77
|
mode: StripMode;
|
|
@@ -83,11 +83,6 @@ export declare class ManageDialogModel extends HoistModel {
|
|
|
83
83
|
* level, so unlike the in-grid handlers above there is no target to track as the pointer moves.
|
|
84
84
|
*/
|
|
85
85
|
getTopLevelDropZoneEvents(gridModel: GridModel): RowDropZoneEvents;
|
|
86
|
-
/**
|
|
87
|
-
* Move the grid's current selection to the top level, as clicked on its top-level strip. No-op
|
|
88
|
-
* without a draggable selection, or if the selection is already at the top level.
|
|
89
|
-
*/
|
|
90
|
-
moveSelectionToTopLevelAsync(gridModel: GridModel): Promise<void>;
|
|
91
86
|
private init;
|
|
92
87
|
private doUpdateAsync;
|
|
93
88
|
private doUpdateViewsAsync;
|
|
@@ -124,11 +119,6 @@ export declare class ManageDialogModel extends HoistModel {
|
|
|
124
119
|
* selected rows ignored), else the deduped views across all dragged leaf rows.
|
|
125
120
|
*/
|
|
126
121
|
private getDragPayload;
|
|
127
|
-
/**
|
|
128
|
-
* As {@link getDragPayload}, but driven by the grid's selection - for the top-level strip's
|
|
129
|
-
* click-to-move. Null when the selection is empty or undraggable.
|
|
130
|
-
*/
|
|
131
|
-
private getSelectionPayload;
|
|
132
122
|
/** Quoted/pluralized display name for a drag/selection payload, for the strip's hint text. */
|
|
133
123
|
private dragPayloadName;
|
|
134
124
|
/**
|
|
@@ -2,7 +2,7 @@ import { GridModel } from '@xh/hoist/cmp/grid';
|
|
|
2
2
|
/**
|
|
3
3
|
* Strip rendered above the tree grid within one tab of the ViewManager's Manage dialog, registered
|
|
4
4
|
* with ag-Grid as an external row-drop-zone accepting drops that move a view/group out of all
|
|
5
|
-
* groups.
|
|
5
|
+
* groups.
|
|
6
6
|
*
|
|
7
7
|
* Deliberately not a grid row - it sits outside the grid's scrolling viewport, so it needs no
|
|
8
8
|
* special-casing in the tree data and cannot be occluded by a sticky group-row header.
|
|
@@ -28,6 +28,13 @@ export interface SelectProps extends HoistProps, HoistInputProps, LayoutProps {
|
|
|
28
28
|
* will be rendered in the top half of the viewport, above the mobile keyboard.
|
|
29
29
|
*/
|
|
30
30
|
enableFullscreen?: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* True to constrain the value to the current `options` - any selected value not found there is
|
|
33
|
+
* removed whenever the value or the list changes. Enforced only once `options` is non-null, so
|
|
34
|
+
* pass null (not `[]`) while options load - `[]` means "no valid choices" and clears the value.
|
|
35
|
+
* Throws if combined with `enableCreate` or `queryFn`.
|
|
36
|
+
*/
|
|
37
|
+
enforceValueInOptions?: boolean;
|
|
31
38
|
/**
|
|
32
39
|
* Optional override for fullscreen z-index. Useful for enabling fullscreen from
|
|
33
40
|
* within components that have a higher z-index.
|
|
@@ -242,7 +242,7 @@ function doFormat(timestamp: Date | number, opts: RelativeTimestampOptions): str
|
|
|
242
242
|
}
|
|
243
243
|
|
|
244
244
|
// 3) Basic timestamp, with suffix /prefix
|
|
245
|
-
let ret
|
|
245
|
+
let ret: string;
|
|
246
246
|
if (elapsed < 60 * SECONDS) {
|
|
247
247
|
// By default, moment will show 'a few seconds' for durations of 0-45 seconds. At the higher
|
|
248
248
|
// end of that range that output is a bit too inaccurate, so we replace as per below.
|
package/cmp/tab/TabModel.ts
CHANGED
|
@@ -184,7 +184,7 @@ export class TabModel extends HoistModel {
|
|
|
184
184
|
if (!content) return null;
|
|
185
185
|
|
|
186
186
|
// Recognize if content is a child container spec.
|
|
187
|
-
let childConfig: TabContainerConfig
|
|
187
|
+
let childConfig: TabContainerConfig;
|
|
188
188
|
if (isArray(content)) {
|
|
189
189
|
childConfig = {tabs: content};
|
|
190
190
|
} else if ('tabs' in content) {
|
package/core/XH.ts
CHANGED
|
@@ -663,6 +663,10 @@ export class XHApi {
|
|
|
663
663
|
* button instead (e.g. for confirming risky operations), applications should specify a
|
|
664
664
|
* `cancelProps` argument of the following form `cancelProps: {..., autoFocus: true}`.
|
|
665
665
|
*
|
|
666
|
+
* If `suppress` is specified and the user has previously opted to suppress this message,
|
|
667
|
+
* this method will resolve immediately to their previously saved response, without showing
|
|
668
|
+
* a dialog. This also applies to the `alert`, `confirm`, and `prompt` variants below.
|
|
669
|
+
*
|
|
666
670
|
* @returns true if user confirms, false if user cancels. If an input is provided, the
|
|
667
671
|
* returned Promise will resolve to the input value if user confirms, false if user cancels.
|
|
668
672
|
*/
|
|
@@ -15,12 +15,7 @@ import type {ViewManagerModel} from '@xh/hoist/cmp/viewmanager'; // Import type
|
|
|
15
15
|
* Built-in Hoist PersistenceProviders.
|
|
16
16
|
*/
|
|
17
17
|
export type PersistenceProviderType =
|
|
18
|
-
| '
|
|
19
|
-
| 'localStorage'
|
|
20
|
-
| 'sessionStorage'
|
|
21
|
-
| 'dashView'
|
|
22
|
-
| 'viewManager'
|
|
23
|
-
| 'custom';
|
|
18
|
+
'pref' | 'localStorage' | 'sessionStorage' | 'dashView' | 'viewManager' | 'custom';
|
|
24
19
|
|
|
25
20
|
export interface PersistOptions {
|
|
26
21
|
/** Dot delimited path to store state. */
|
|
@@ -23,16 +23,7 @@ import {
|
|
|
23
23
|
import {IReactionDisposer, reaction} from 'mobx';
|
|
24
24
|
import {Class} from 'type-fest';
|
|
25
25
|
import {DebounceSpec, HoistBase, Persistable, PersistableState} from '../';
|
|
26
|
-
import {
|
|
27
|
-
CustomProvider,
|
|
28
|
-
DashViewProvider,
|
|
29
|
-
LocalStorageProvider,
|
|
30
|
-
PersistOptions,
|
|
31
|
-
persistOptions,
|
|
32
|
-
PrefProvider,
|
|
33
|
-
SessionStorageProvider,
|
|
34
|
-
ViewManagerProvider
|
|
35
|
-
} from './';
|
|
26
|
+
import {PersistenceProviderType, PersistOptions, persistOptions} from './PersistOptions';
|
|
36
27
|
|
|
37
28
|
export type PersistenceProviderConfig<S = any> = {
|
|
38
29
|
persistOptions: PersistOptions;
|
|
@@ -40,6 +31,18 @@ export type PersistenceProviderConfig<S = any> = {
|
|
|
40
31
|
owner?: HoistBase;
|
|
41
32
|
};
|
|
42
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Provider registration metadata - see {@link PersistenceProvider.registerProviders}.
|
|
36
|
+
* @internal
|
|
37
|
+
*/
|
|
38
|
+
interface ProviderRegistration {
|
|
39
|
+
/** `PersistOptions.type` value that selects this provider. */
|
|
40
|
+
type: PersistenceProviderType;
|
|
41
|
+
/** `PersistOptions` keys whose presence selects this provider in shortcut (typeless) form. */
|
|
42
|
+
shortcutKeys: string[];
|
|
43
|
+
cls: Class<PersistenceProvider, [PersistenceProviderConfig]>;
|
|
44
|
+
}
|
|
45
|
+
|
|
43
46
|
/**
|
|
44
47
|
* Abstract superclass for adaptor objects used by models and components to (re)store state to and
|
|
45
48
|
* from a persistent location, typically a Hoist preference or key within browser local storage.
|
|
@@ -70,6 +73,19 @@ export abstract class PersistenceProvider<S = any> {
|
|
|
70
73
|
private lastReadState: PersistableState<S>;
|
|
71
74
|
private lastReadTime: number;
|
|
72
75
|
|
|
76
|
+
private static registrations: ProviderRegistration[] = [];
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Register concrete provider implementations for lookup by `create`. Called by this
|
|
80
|
+
* package's index.ts for the built-in providers, which must not be statically imported by
|
|
81
|
+
* this base module: they extend this class, so it must be fully initialized before any of
|
|
82
|
+
* them can be defined - importing them here would create an initialization-order cycle.
|
|
83
|
+
* @internal
|
|
84
|
+
*/
|
|
85
|
+
static registerProviders(regs: ProviderRegistration[]) {
|
|
86
|
+
PersistenceProvider.registrations.push(...regs);
|
|
87
|
+
}
|
|
88
|
+
|
|
73
89
|
/**
|
|
74
90
|
* Construct an instance of this class.
|
|
75
91
|
*
|
|
@@ -227,28 +243,17 @@ export abstract class PersistenceProvider<S = any> {
|
|
|
227
243
|
static parseProviderClass<S>(
|
|
228
244
|
opts: PersistOptions
|
|
229
245
|
): Class<PersistenceProvider<S>, [PersistenceProviderConfig<S>]> {
|
|
230
|
-
|
|
231
|
-
|
|
246
|
+
const {registrations} = PersistenceProvider,
|
|
247
|
+
{type, ...rest} = opts;
|
|
248
|
+
|
|
249
|
+
// 1) Recognize shortcut form - the presence of a provider-specific option key.
|
|
232
250
|
if (!type) {
|
|
233
|
-
|
|
234
|
-
if (
|
|
235
|
-
if (rest.sessionStorageKey) return SessionStorageProvider;
|
|
236
|
-
if (rest.dashViewModel) return DashViewProvider;
|
|
237
|
-
if (rest.viewManagerModel) return ViewManagerProvider;
|
|
238
|
-
if (rest.getData || rest.setData) return CustomProvider;
|
|
251
|
+
const reg = registrations.find(it => it.shortcutKeys.some(key => rest[key]));
|
|
252
|
+
if (reg) return reg.cls;
|
|
239
253
|
}
|
|
240
254
|
|
|
241
|
-
// 2) Map any string to
|
|
242
|
-
const ret = isString(type)
|
|
243
|
-
? {
|
|
244
|
-
pref: PrefProvider,
|
|
245
|
-
localStorage: LocalStorageProvider,
|
|
246
|
-
sessionStorage: SessionStorageProvider,
|
|
247
|
-
dashView: DashViewProvider,
|
|
248
|
-
viewManager: ViewManagerProvider,
|
|
249
|
-
custom: CustomProvider
|
|
250
|
-
}[type]
|
|
251
|
-
: type;
|
|
255
|
+
// 2) Map any string to a registered Provider Class, or return raw class
|
|
256
|
+
const ret = isString(type) ? registrations.find(it => it.type === type)?.cls : type;
|
|
252
257
|
|
|
253
258
|
throwIf(!ret, `Unknown Persistence Provider: ${type}`);
|
|
254
259
|
|
package/core/persist/index.ts
CHANGED
|
@@ -7,3 +7,26 @@ export * from './provider/DashViewProvider';
|
|
|
7
7
|
export * from './provider/PrefProvider';
|
|
8
8
|
export * from './provider/CustomProvider';
|
|
9
9
|
export * from './provider/ViewManagerProvider';
|
|
10
|
+
|
|
11
|
+
import {PersistenceProvider} from './PersistenceProvider';
|
|
12
|
+
import {CustomProvider} from './provider/CustomProvider';
|
|
13
|
+
import {DashViewProvider} from './provider/DashViewProvider';
|
|
14
|
+
import {LocalStorageProvider} from './provider/LocalStorageProvider';
|
|
15
|
+
import {PrefProvider} from './provider/PrefProvider';
|
|
16
|
+
import {SessionStorageProvider} from './provider/SessionStorageProvider';
|
|
17
|
+
import {ViewManagerProvider} from './provider/ViewManagerProvider';
|
|
18
|
+
|
|
19
|
+
// Register the built-in providers for lookup by `PersistenceProvider.create`. Registration
|
|
20
|
+
// lives here - in a module declared side-effectful via the package `sideEffects` entry and
|
|
21
|
+
// reached whenever anything imports from this package - rather than in the base class (which
|
|
22
|
+
// must not import its own subclasses - see note on `registerProviders`) or in the provider
|
|
23
|
+
// modules themselves (whose registrations would be pruned by a tree-shaking bundler, as
|
|
24
|
+
// nothing consumes their exports directly).
|
|
25
|
+
PersistenceProvider.registerProviders([
|
|
26
|
+
{type: 'pref', shortcutKeys: ['prefKey'], cls: PrefProvider},
|
|
27
|
+
{type: 'localStorage', shortcutKeys: ['localStorageKey'], cls: LocalStorageProvider},
|
|
28
|
+
{type: 'sessionStorage', shortcutKeys: ['sessionStorageKey'], cls: SessionStorageProvider},
|
|
29
|
+
{type: 'dashView', shortcutKeys: ['dashViewModel'], cls: DashViewProvider},
|
|
30
|
+
{type: 'viewManager', shortcutKeys: ['viewManagerModel'], cls: ViewManagerProvider},
|
|
31
|
+
{type: 'custom', shortcutKeys: ['getData', 'setData'], cls: CustomProvider}
|
|
32
|
+
]);
|
package/core/types/Interfaces.ts
CHANGED
|
@@ -184,12 +184,25 @@ export interface MessageSpec {
|
|
|
184
184
|
* Unique key identifying the message. If subsequent messages are triggered with this key, they
|
|
185
185
|
* will replace this message. Useful for usages that may be producing messages recursively, or
|
|
186
186
|
* via timers, and wish to avoid generating a large stack of duplicates.
|
|
187
|
+
*
|
|
188
|
+
* Also identifies the message for suppression purposes - required if `suppress` is set.
|
|
187
189
|
*/
|
|
188
190
|
messageKey?: string;
|
|
189
191
|
|
|
190
192
|
/** Config for input to be displayed (as a prompt). */
|
|
191
193
|
input?: MessageSpecInput;
|
|
192
194
|
|
|
195
|
+
/**
|
|
196
|
+
* True or config to display a "Don't show this message again" checkbox, allowing users to
|
|
197
|
+
* opt out of future copies of this message. If the user confirms the message with the
|
|
198
|
+
* checkbox checked, their response will be saved to browser storage and returned
|
|
199
|
+
* immediately by future calls with the same `messageKey` (which must also be set).
|
|
200
|
+
*
|
|
201
|
+
* Specify as a config object to customize the checkbox label, limit how long the saved
|
|
202
|
+
* response should remain in effect, or save it to session (vs. local) storage.
|
|
203
|
+
*/
|
|
204
|
+
suppress?: boolean | MessageSuppressSpec;
|
|
205
|
+
|
|
193
206
|
/** If specified, user will be required to type this text when confirming. */
|
|
194
207
|
extraConfirmText?: string;
|
|
195
208
|
|
|
@@ -243,6 +256,29 @@ export interface MessageSpecInput {
|
|
|
243
256
|
initialValue?: any;
|
|
244
257
|
}
|
|
245
258
|
|
|
259
|
+
/**
|
|
260
|
+
* Config for user opt-in message suppression - see {@link MessageSpec.suppress}.
|
|
261
|
+
*/
|
|
262
|
+
export interface MessageSuppressSpec {
|
|
263
|
+
/**
|
|
264
|
+
* Time (in ms) for which the user's saved response should remain in effect. Specify with
|
|
265
|
+
* datetime constants, e.g. `30 * DAYS`. Default null suppresses indefinitely.
|
|
266
|
+
*/
|
|
267
|
+
expiry?: number;
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Browser storage in which the user's response should be saved (default 'local'). Specify
|
|
271
|
+
* 'session' to suppress only for the lifetime of the current browser tab.
|
|
272
|
+
*/
|
|
273
|
+
storage?: 'local' | 'session';
|
|
274
|
+
|
|
275
|
+
/** Label for the suppress checkbox. Defaults to a generated label appropriate to `expiry`. */
|
|
276
|
+
label?: ReactNode;
|
|
277
|
+
|
|
278
|
+
/** Initial value of the suppress checkbox (default false). */
|
|
279
|
+
initialValue?: boolean;
|
|
280
|
+
}
|
|
281
|
+
|
|
246
282
|
//------------------------
|
|
247
283
|
// Menus
|
|
248
284
|
//------------------------
|
|
@@ -330,9 +366,17 @@ export function isMenuItem<T, C>(item: MenuItemLike<T, C>): item is MenuItem<T,
|
|
|
330
366
|
//------------------------
|
|
331
367
|
/**
|
|
332
368
|
* An option to be passed to Select controls.
|
|
369
|
+
*
|
|
370
|
+
* Additional custom fields are supported alongside the standard entries below and are passed
|
|
371
|
+
* through to callbacks such as `optionRenderer` and `filterFn`. For typed access to such fields,
|
|
372
|
+
* annotate the callback's argument with the app's own option type - e.g.
|
|
373
|
+
* `optionRenderer: (opt: MyOption) => ...`.
|
|
333
374
|
*/
|
|
334
375
|
export interface SelectOption {
|
|
335
376
|
value?: any;
|
|
336
377
|
label?: string;
|
|
337
378
|
options?: (SelectOption | any)[];
|
|
379
|
+
|
|
380
|
+
/** Custom fields, passed through to Select callbacks. */
|
|
381
|
+
[key: string]: any;
|
|
338
382
|
}
|
|
@@ -10,7 +10,7 @@ import {div, filler} from '@xh/hoist/cmp/layout';
|
|
|
10
10
|
import {hoistCmp, uses} from '@xh/hoist/core';
|
|
11
11
|
import {button} from '@xh/hoist/desktop/cmp/button';
|
|
12
12
|
import {formField} from '@xh/hoist/desktop/cmp/form';
|
|
13
|
-
import {textInput} from '@xh/hoist/desktop/cmp/input';
|
|
13
|
+
import {checkbox, textInput} from '@xh/hoist/desktop/cmp/input';
|
|
14
14
|
import {toolbar} from '@xh/hoist/desktop/cmp/toolbar';
|
|
15
15
|
import {dialog, dialogBody} from '@xh/hoist/kit/blueprint';
|
|
16
16
|
import {withDefault} from '@xh/hoist/utils/js';
|
|
@@ -82,6 +82,16 @@ const inputsCmp = hoistCmp.factory<MessageModel>(({model}) => {
|
|
|
82
82
|
})
|
|
83
83
|
);
|
|
84
84
|
}
|
|
85
|
+
if (formModel.getField('suppress')) {
|
|
86
|
+
items.push(
|
|
87
|
+
formField({
|
|
88
|
+
field: 'suppress',
|
|
89
|
+
label: null,
|
|
90
|
+
testId: 'xh-message-suppress',
|
|
91
|
+
item: checkbox({label: model.suppressLabel})
|
|
92
|
+
})
|
|
93
|
+
);
|
|
94
|
+
}
|
|
85
95
|
return form({
|
|
86
96
|
model: formModel,
|
|
87
97
|
fieldDefaults: {commitOnChange: true, minimal: true},
|
|
@@ -11,7 +11,18 @@ 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';
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
castArray,
|
|
16
|
+
differenceBy,
|
|
17
|
+
filter,
|
|
18
|
+
isEmpty,
|
|
19
|
+
keys,
|
|
20
|
+
fromPairs,
|
|
21
|
+
map,
|
|
22
|
+
sortBy,
|
|
23
|
+
take,
|
|
24
|
+
uniqBy
|
|
25
|
+
} from 'lodash';
|
|
15
26
|
import {ReactElement, ReactNode} from 'react';
|
|
16
27
|
import {DropzoneRef} from 'react-dropzone';
|
|
17
28
|
|
|
@@ -120,10 +131,10 @@ export class FileChooserModel extends HoistModel {
|
|
|
120
131
|
}
|
|
121
132
|
|
|
122
133
|
/**
|
|
123
|
-
* Add files to the selection.
|
|
134
|
+
* Add files to the selection, taking as many as the `maxFiles` limit allows.
|
|
124
135
|
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
136
|
+
* Does NOT enforce the `accept` / file-size constraints (those are applied only on
|
|
137
|
+
* drop/browse) - use with care.
|
|
127
138
|
*/
|
|
128
139
|
addFiles(files: Some<File>) {
|
|
129
140
|
this.addFilesInternal(files);
|
|
@@ -158,33 +169,36 @@ export class FileChooserModel extends HoistModel {
|
|
|
158
169
|
// In single-file mode, replace the current selection with the incoming file.
|
|
159
170
|
if (maxFiles === 1 && accepted.length === 1) this.clear();
|
|
160
171
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
}
|
|
172
|
+
const added = this.addFilesInternal(accepted);
|
|
173
|
+
if (!isEmpty(added)) this.onFileAccepted?.(added);
|
|
164
174
|
}
|
|
165
175
|
}
|
|
166
176
|
|
|
167
177
|
//------------------------
|
|
168
178
|
// Implementation
|
|
169
179
|
//------------------------
|
|
170
|
-
//
|
|
171
|
-
//
|
|
180
|
+
// Add incoming files, replacing any same-named file already selected and taking the rest up to
|
|
181
|
+
// the `maxFiles` limit. Warns on any surplus. Returns the files actually added.
|
|
172
182
|
@action
|
|
173
|
-
private addFilesInternal(files: Some<File>):
|
|
183
|
+
private addFilesInternal(files: Some<File>): File[] {
|
|
174
184
|
const {maxFiles} = this,
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
185
|
+
incoming = uniqBy(castArray(files), 'name'),
|
|
186
|
+
// Existing files the incoming batch does not replace - always retained.
|
|
187
|
+
retained = differenceBy(this.files, incoming, 'name'),
|
|
188
|
+
capacity = maxFiles != null ? Math.max(maxFiles - retained.length, 0) : incoming.length,
|
|
189
|
+
added = take(incoming, capacity),
|
|
190
|
+
surplus = incoming.length - added.length;
|
|
191
|
+
|
|
192
|
+
if (surplus) {
|
|
178
193
|
XH.warningToast(
|
|
179
194
|
maxFiles === 1
|
|
180
195
|
? 'Only one file allowed for upload.'
|
|
181
|
-
: `File limit of ${maxFiles}
|
|
196
|
+
: `File limit of ${maxFiles} reached - ${surplus} ${pluralize('file', surplus)} not added.`
|
|
182
197
|
);
|
|
183
|
-
return false;
|
|
184
198
|
}
|
|
185
199
|
|
|
186
|
-
this.files =
|
|
187
|
-
return
|
|
200
|
+
if (!isEmpty(added)) this.files = [...added, ...retained];
|
|
201
|
+
return added;
|
|
188
202
|
}
|
|
189
203
|
|
|
190
204
|
private defaultRejectMessage = (rejections: FileRejection[]): ReactElement => {
|
|
@@ -91,6 +91,14 @@ export interface SelectProps extends HoistProps, HoistInputProps, LayoutProps {
|
|
|
91
91
|
*/
|
|
92
92
|
enableWindowed?: boolean;
|
|
93
93
|
|
|
94
|
+
/**
|
|
95
|
+
* True to constrain the value to the current `options` - any selected value not found there is
|
|
96
|
+
* removed whenever the value or the list changes. Enforced only once `options` is non-null, so
|
|
97
|
+
* pass null (not `[]`) while options load - `[]` means "no valid choices" and clears the value.
|
|
98
|
+
* Throws if combined with `enableCreate` or `queryFn`.
|
|
99
|
+
*/
|
|
100
|
+
enforceValueInOptions?: boolean;
|
|
101
|
+
|
|
94
102
|
/**
|
|
95
103
|
* Function called to filter available options for a given query string input.
|
|
96
104
|
* Used for filtering of options provided by `options` prop when `enableFilter` is true.
|
|
@@ -323,6 +331,18 @@ class SelectInputModel extends HoistInputModel {
|
|
|
323
331
|
},
|
|
324
332
|
fireImmediately: true
|
|
325
333
|
});
|
|
334
|
+
|
|
335
|
+
if (this.componentProps.enforceValueInOptions) {
|
|
336
|
+
throwIf(
|
|
337
|
+
this.creatableMode || this.asyncMode,
|
|
338
|
+
'`enforceValueInOptions` is not supported with `enableCreate` or `queryFn`.'
|
|
339
|
+
);
|
|
340
|
+
this.addReaction({
|
|
341
|
+
track: () => [this.externalValue, this.internalOptions],
|
|
342
|
+
run: () => this.pruneValueToOptions(),
|
|
343
|
+
fireImmediately: true
|
|
344
|
+
});
|
|
345
|
+
}
|
|
326
346
|
}
|
|
327
347
|
|
|
328
348
|
reactSelectRef = createObservableRef<any>();
|
|
@@ -484,6 +504,22 @@ class SelectInputModel extends HoistInputModel {
|
|
|
484
504
|
);
|
|
485
505
|
}
|
|
486
506
|
|
|
507
|
+
// Enforce `enforceValueInOptions` - drop any current value not present in internalOptions.
|
|
508
|
+
// Null options signal that they have yet to load, and are not yet enforced against.
|
|
509
|
+
private pruneValueToOptions() {
|
|
510
|
+
const {externalValue, multiMode, emptyValue} = this;
|
|
511
|
+
if (isNil(this.componentProps.options)) return;
|
|
512
|
+
if (isNil(externalValue) || isEqual(externalValue, emptyValue)) return;
|
|
513
|
+
|
|
514
|
+
if (multiMode) {
|
|
515
|
+
const curr = castArray(externalValue),
|
|
516
|
+
keptOpts = curr.map(v => this.findOption(v, false)).filter(Boolean);
|
|
517
|
+
if (keptOpts.length !== curr.length) this.noteValueChange(keptOpts);
|
|
518
|
+
} else if (!this.findOption(externalValue, false)) {
|
|
519
|
+
this.noteValueChange(null);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
487
523
|
override toExternal(internal) {
|
|
488
524
|
if (isNil(internal)) return this.emptyValue;
|
|
489
525
|
|
|
@@ -21,6 +21,12 @@
|
|
|
21
21
|
background-color: var(--xh-intent-primary-trans2) !important;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
// Grip glyph swapped in via the grid's `icons` option - see ManageDialog.ts. Muted tone to
|
|
25
|
+
// match the ColChooser's handle, over ag-Grid's own drag-handle color.
|
|
26
|
+
&__drag-handle__grip {
|
|
27
|
+
color: var(--xh-text-color-muted);
|
|
28
|
+
}
|
|
29
|
+
|
|
24
30
|
// Gray out the drag handles and block drag initiation for an undraggable selection.
|
|
25
31
|
&__grid--drag-disabled .ag-drag-handle {
|
|
26
32
|
color: var(--xh-text-color-muted);
|
|
@@ -117,6 +117,15 @@ export const viewsGrid = hoistCmp.factory<GridModel>({
|
|
|
117
117
|
groupContracted: Icon.folder({
|
|
118
118
|
asHtml: true,
|
|
119
119
|
className: 'ag-group-contracted'
|
|
120
|
+
}),
|
|
121
|
+
// Replaces ag-Grid's own grip glyph, matching the ColChooser.
|
|
122
|
+
// Must be set here - the row-drag comp reads only grid-level
|
|
123
|
+
// icons, never the column's own.
|
|
124
|
+
rowDrag: Icon.grip({
|
|
125
|
+
asHtml: true,
|
|
126
|
+
prefix: 'fas',
|
|
127
|
+
className:
|
|
128
|
+
'xh-view-manager__manage-dialog__drag-handle__grip'
|
|
120
129
|
})
|
|
121
130
|
},
|
|
122
131
|
...dialogModel?.getRowDragAgOptions(model)
|
|
@@ -300,19 +300,13 @@ export class ManageDialogModel extends HoistModel {
|
|
|
300
300
|
|
|
301
301
|
/**
|
|
302
302
|
* Display state for `gridModel`'s top-level drop strip - rest/armed/hot/blocked, matching
|
|
303
|
-
* the in-flight drag
|
|
303
|
+
* the in-flight drag, if it originated in this grid.
|
|
304
304
|
*/
|
|
305
305
|
stripState(gridModel: GridModel): {mode: StripMode; hint: string} {
|
|
306
306
|
const type = this.gridTypeFor(gridModel),
|
|
307
307
|
payload = this.drag?.type === type ? this.drag.payload : null;
|
|
308
308
|
|
|
309
|
-
if (!payload) {
|
|
310
|
-
const hasSelection = gridModel.hasSelection && !this.isDragDisabled(gridModel);
|
|
311
|
-
return {
|
|
312
|
-
mode: 'rest',
|
|
313
|
-
hint: hasSelection ? 'Click to move the selection to the top level' : ''
|
|
314
|
-
};
|
|
315
|
-
}
|
|
309
|
+
if (!payload) return {mode: 'rest', hint: ''};
|
|
316
310
|
|
|
317
311
|
const name = this.dragPayloadName(payload);
|
|
318
312
|
if (!this.isValidDrop(payload, TOP_LEVEL_TARGET)) {
|
|
@@ -342,17 +336,6 @@ export class ManageDialogModel extends HoistModel {
|
|
|
342
336
|
};
|
|
343
337
|
}
|
|
344
338
|
|
|
345
|
-
/**
|
|
346
|
-
* Move the grid's current selection to the top level, as clicked on its top-level strip. No-op
|
|
347
|
-
* without a draggable selection, or if the selection is already at the top level.
|
|
348
|
-
*/
|
|
349
|
-
async moveSelectionToTopLevelAsync(gridModel: GridModel): Promise<void> {
|
|
350
|
-
const type = this.gridTypeFor(gridModel),
|
|
351
|
-
payload = this.getSelectionPayload(gridModel);
|
|
352
|
-
if (!payload || !this.isValidDrop(payload, TOP_LEVEL_TARGET)) return;
|
|
353
|
-
return this.doRowDragDropAsync(type, payload, TOP_LEVEL_TARGET);
|
|
354
|
-
}
|
|
355
|
-
|
|
356
339
|
//------------------------
|
|
357
340
|
// Implementation
|
|
358
341
|
//------------------------
|
|
@@ -541,22 +524,6 @@ export class ManageDialogModel extends HoistModel {
|
|
|
541
524
|
return {views: uniqBy(compact(views), 'token')};
|
|
542
525
|
}
|
|
543
526
|
|
|
544
|
-
/**
|
|
545
|
-
* As {@link getDragPayload}, but driven by the grid's selection - for the top-level strip's
|
|
546
|
-
* click-to-move. Null when the selection is empty or undraggable.
|
|
547
|
-
*/
|
|
548
|
-
private getSelectionPayload(gridModel: GridModel): DragPayload {
|
|
549
|
-
const recs = gridModel.selectedRecords;
|
|
550
|
-
if (!recs.length || this.isDragDisabled(gridModel)) return null;
|
|
551
|
-
|
|
552
|
-
if (recs.length === 1 && recs[0].data.isGroupRow) return {group: recs[0].data.group};
|
|
553
|
-
|
|
554
|
-
const views = recs.flatMap(r =>
|
|
555
|
-
r.data.isGroupRow ? r.descendants.map(d => d.data.view) : [r.data.view]
|
|
556
|
-
);
|
|
557
|
-
return {views: uniqBy(compact(views), 'token')};
|
|
558
|
-
}
|
|
559
|
-
|
|
560
527
|
/** Quoted/pluralized display name for a drag/selection payload, for the strip's hint text. */
|
|
561
528
|
private dragPayloadName(payload: DragPayload): string {
|
|
562
529
|
const {group, views} = payload;
|
|
@@ -961,7 +928,9 @@ export class ManageDialogModel extends HoistModel {
|
|
|
961
928
|
// Sort groups above loose views among siblings, then alpha by name.
|
|
962
929
|
sortBy: ['isGroupRow|desc', 'name'],
|
|
963
930
|
treeMode: true,
|
|
964
|
-
|
|
931
|
+
// Highlights only - the tree-border style adds a second, differently colored rule on
|
|
932
|
+
// top of each level-0 row, which doubles up with the row borders below.
|
|
933
|
+
treeStyle: TreeStyle.HIGHLIGHTS,
|
|
965
934
|
rowBorders: true,
|
|
966
935
|
selModel: 'multiple',
|
|
967
936
|
contextMenu,
|
|
@@ -992,7 +961,9 @@ export class ManageDialogModel extends HoistModel {
|
|
|
992
961
|
headerName: null,
|
|
993
962
|
width: 28,
|
|
994
963
|
resizable: false,
|
|
995
|
-
|
|
964
|
+
// Left-aligned, so the cell keeps its standard left padding and the grip lands
|
|
965
|
+
// at the same inset as the ColChooser's. Centering zeroes that padding, which
|
|
966
|
+
// pinned the grip against the grid's left border.
|
|
996
967
|
omit: !this.dragDropEnabled(type),
|
|
997
968
|
agOptions: {rowDrag: true}
|
|
998
969
|
},
|
|
@@ -11,13 +11,12 @@ import {Icon} from '@xh/hoist/icon';
|
|
|
11
11
|
import {GridApi, RowDropZoneParams} from '@xh/hoist/kit/ag-grid';
|
|
12
12
|
import {createObservableRef} from '@xh/hoist/utils/react';
|
|
13
13
|
import classNames from 'classnames';
|
|
14
|
-
import {KeyboardEvent} from 'react';
|
|
15
14
|
import {ManageDialogModel} from './ManageDialogModel';
|
|
16
15
|
|
|
17
16
|
/**
|
|
18
17
|
* Strip rendered above the tree grid within one tab of the ViewManager's Manage dialog, registered
|
|
19
18
|
* with ag-Grid as an external row-drop-zone accepting drops that move a view/group out of all
|
|
20
|
-
* groups.
|
|
19
|
+
* groups.
|
|
21
20
|
*
|
|
22
21
|
* Deliberately not a grid row - it sits outside the grid's scrolling viewport, so it needs no
|
|
23
22
|
* special-casing in the tree data and cannot be occluded by a sticky group-row header.
|
|
@@ -36,22 +35,12 @@ export const topLevelDropStrip = hoistCmp.factory<GridModel>({
|
|
|
36
35
|
|
|
37
36
|
const {mode, hint} = dialogModel.stripState(gridModel),
|
|
38
37
|
blocked = mode === 'blocked',
|
|
39
|
-
open = mode === 'armed' || mode === 'hot'
|
|
40
|
-
activate = () => dialogModel.moveSelectionToTopLevelAsync(gridModel).catchDefault();
|
|
38
|
+
open = mode === 'armed' || mode === 'hot';
|
|
41
39
|
|
|
42
40
|
return div({
|
|
43
41
|
ref: impl.ref,
|
|
44
42
|
className: classNames(className, mode !== 'rest' ? `${className}--${mode}` : null),
|
|
45
|
-
role: 'button',
|
|
46
|
-
tabIndex: 0,
|
|
47
|
-
'aria-label': 'Move selection to top level',
|
|
48
43
|
'aria-disabled': blocked,
|
|
49
|
-
onClick: activate,
|
|
50
|
-
onKeyDown: (e: KeyboardEvent) => {
|
|
51
|
-
if (e.key !== 'Enter' && e.key !== ' ') return;
|
|
52
|
-
e.preventDefault();
|
|
53
|
-
activate();
|
|
54
|
-
},
|
|
55
44
|
items: [
|
|
56
45
|
open
|
|
57
46
|
? Icon.folderOpen({className: `${className}__icon`})
|
|
@@ -11,7 +11,7 @@ import {hoistCmp, uses} from '@xh/hoist/core';
|
|
|
11
11
|
import {button} from '@xh/hoist/mobile/cmp/button';
|
|
12
12
|
import {dialog} from '@xh/hoist/mobile/cmp/dialog';
|
|
13
13
|
import {formField} from '@xh/hoist/mobile/cmp/form';
|
|
14
|
-
import {textInput} from '@xh/hoist/mobile/cmp/input';
|
|
14
|
+
import {checkbox, textInput} from '@xh/hoist/mobile/cmp/input';
|
|
15
15
|
import {withDefault} from '@xh/hoist/utils/js';
|
|
16
16
|
import './Message.scss';
|
|
17
17
|
|
|
@@ -95,6 +95,16 @@ const inputCmp = hoistCmp.factory<MessageModel>(({model}) => {
|
|
|
95
95
|
})
|
|
96
96
|
);
|
|
97
97
|
}
|
|
98
|
+
if (formModel.getField('suppress')) {
|
|
99
|
+
items.push(
|
|
100
|
+
formField({
|
|
101
|
+
label: model.suppressLabel,
|
|
102
|
+
field: 'suppress',
|
|
103
|
+
testId: 'xh-message-suppress',
|
|
104
|
+
item: checkbox()
|
|
105
|
+
})
|
|
106
|
+
);
|
|
107
|
+
}
|
|
98
108
|
return form({
|
|
99
109
|
fieldDefaults: {commitOnChange: true, minimal: true, label: null},
|
|
100
110
|
items
|
|
@@ -56,6 +56,14 @@ export interface SelectProps extends HoistProps, HoistInputProps, LayoutProps {
|
|
|
56
56
|
*/
|
|
57
57
|
enableFullscreen?: boolean;
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* True to constrain the value to the current `options` - any selected value not found there is
|
|
61
|
+
* removed whenever the value or the list changes. Enforced only once `options` is non-null, so
|
|
62
|
+
* pass null (not `[]`) while options load - `[]` means "no valid choices" and clears the value.
|
|
63
|
+
* Throws if combined with `enableCreate` or `queryFn`.
|
|
64
|
+
*/
|
|
65
|
+
enforceValueInOptions?: boolean;
|
|
66
|
+
|
|
59
67
|
/**
|
|
60
68
|
* Optional override for fullscreen z-index. Useful for enabling fullscreen from
|
|
61
69
|
* within components that have a higher z-index.
|
|
@@ -256,11 +264,31 @@ class SelectInputModel extends HoistInputModel {
|
|
|
256
264
|
fireImmediately: true
|
|
257
265
|
});
|
|
258
266
|
|
|
267
|
+
if (this.componentProps.enforceValueInOptions) {
|
|
268
|
+
throwIf(
|
|
269
|
+
this.creatableMode || this.asyncMode,
|
|
270
|
+
'`enforceValueInOptions` is not supported with `enableCreate` or `queryFn`.'
|
|
271
|
+
);
|
|
272
|
+
this.addReaction({
|
|
273
|
+
track: () => [this.externalValue, this.internalOptions],
|
|
274
|
+
run: () => this.pruneValueToOptions(),
|
|
275
|
+
fireImmediately: true
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
259
279
|
if (this.fullscreenMode) {
|
|
260
280
|
this.addReaction(this.fullscreenReaction());
|
|
261
281
|
}
|
|
262
282
|
}
|
|
263
283
|
|
|
284
|
+
// Enforce `enforceValueInOptions` - clear any current value not present in internalOptions.
|
|
285
|
+
// Null options signal that they have yet to load, and are not yet enforced against.
|
|
286
|
+
private pruneValueToOptions() {
|
|
287
|
+
const {externalValue} = this;
|
|
288
|
+
if (isNil(this.componentProps.options) || isNil(externalValue)) return;
|
|
289
|
+
if (!this.findOption(externalValue, false)) this.noteValueChange(null);
|
|
290
|
+
}
|
|
291
|
+
|
|
264
292
|
reactSelectRef = createObservableRef<any>();
|
|
265
293
|
get reactSelect() {
|
|
266
294
|
return this.reactSelectRef.current;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xh/hoist",
|
|
3
|
-
"version": "87.
|
|
3
|
+
"version": "87.1.0",
|
|
4
4
|
"description": "Hoist add-on for building and deploying React Applications.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -19,6 +19,12 @@
|
|
|
19
19
|
"./static/polyfills.js",
|
|
20
20
|
"./desktop/register.ts",
|
|
21
21
|
"./mobile/register.ts",
|
|
22
|
+
"./icon/index.ts",
|
|
23
|
+
"./mobx/index.ts",
|
|
24
|
+
"./kit/blueprint/index.ts",
|
|
25
|
+
"./kit/golden-layout/index.js",
|
|
26
|
+
"./kit/golden-layout/impl/js/**",
|
|
27
|
+
"./core/persist/index.ts",
|
|
22
28
|
"**/*.scss",
|
|
23
29
|
"**/*.css"
|
|
24
30
|
],
|
|
@@ -73,7 +79,7 @@
|
|
|
73
79
|
"onsenui": "~2.12.9",
|
|
74
80
|
"qs": "^6.15.3",
|
|
75
81
|
"react-day-picker": "^9.14.0",
|
|
76
|
-
"react-dropzone": "~
|
|
82
|
+
"react-dropzone": "~20.1.1",
|
|
77
83
|
"react-grid-layout": "~2.2.4",
|
|
78
84
|
"react-markdown": "~10.1.0",
|
|
79
85
|
"react-onsenui": "~1.13.3",
|
|
@@ -103,7 +109,7 @@
|
|
|
103
109
|
"@types/lodash": "^4.17.25",
|
|
104
110
|
"@types/react": "^19.2.18",
|
|
105
111
|
"@types/react-dom": "^19.2.5",
|
|
106
|
-
"@xh/eslint-config": "^
|
|
112
|
+
"@xh/eslint-config": "^8.0.0",
|
|
107
113
|
"@xh/hoist-dev-utils": "^14.0.1",
|
|
108
114
|
"ag-grid-community": "^35.3.1",
|
|
109
115
|
"ag-grid-react": "^35.3.1",
|