@xh/hoist 87.0.0 → 87.1.1

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.
Files changed (33) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/appcontainer/MessageModel.ts +83 -7
  3. package/appcontainer/MessageSourceModel.ts +8 -0
  4. package/build/types/appcontainer/MessageModel.d.ts +14 -2
  5. package/build/types/core/XH.d.ts +4 -0
  6. package/build/types/core/persist/PersistenceProvider.d.ts +22 -1
  7. package/build/types/core/types/Interfaces.d.ts +38 -0
  8. package/build/types/desktop/cmp/filechooser/FileChooserModel.d.ts +3 -3
  9. package/build/types/desktop/cmp/input/Select.d.ts +7 -0
  10. package/build/types/desktop/cmp/viewmanager/dialog/ManageDialogModel.d.ts +1 -11
  11. package/build/types/desktop/cmp/viewmanager/dialog/TopLevelDropStrip.d.ts +1 -1
  12. package/build/types/mobile/cmp/input/Select.d.ts +7 -0
  13. package/cmp/grid/impl/RecordSortUtils.ts +10 -2
  14. package/cmp/relativetimestamp/RelativeTimestamp.ts +1 -1
  15. package/cmp/tab/TabModel.ts +1 -1
  16. package/core/XH.ts +4 -0
  17. package/core/persist/PersistOptions.ts +1 -6
  18. package/core/persist/PersistenceProvider.ts +34 -29
  19. package/core/persist/index.ts +23 -0
  20. package/core/types/Interfaces.ts +44 -0
  21. package/desktop/appcontainer/Message.ts +11 -1
  22. package/desktop/cmp/filechooser/FileChooserModel.ts +31 -17
  23. package/desktop/cmp/input/Select.scss +4 -0
  24. package/desktop/cmp/input/Select.ts +36 -0
  25. package/desktop/cmp/viewmanager/ViewManager.scss +6 -0
  26. package/desktop/cmp/viewmanager/dialog/ManageDialog.ts +9 -0
  27. package/desktop/cmp/viewmanager/dialog/ManageDialogModel.ts +8 -37
  28. package/desktop/cmp/viewmanager/dialog/TopLevelDropStrip.ts +2 -13
  29. package/mobile/appcontainer/Message.ts +11 -1
  30. package/mobile/cmp/input/Select.scss +4 -0
  31. package/mobile/cmp/input/Select.ts +28 -0
  32. package/package.json +9 -3
  33. package/utils/js/ClipboardUtils.ts +1 -1
package/CHANGELOG.md CHANGED
@@ -12,6 +12,51 @@
12
12
  3. Plain ASCII punctuation only. Use " - " for in-sentence breaks, never an em dash.
13
13
  -->
14
14
 
15
+ ## 87.1.1 - 2026-09-02
16
+
17
+ ### 🐞 Bug Fixes
18
+
19
+ * Fixed `GridModel.getSortedRecords()` throwing e.g. grid exports when grouped by a non-string
20
+ field. Group values are now coerced to string keys before sorting.
21
+
22
+ ## 87.1.0 - 2026-08-28
23
+
24
+ ### 🎁 New Features
25
+
26
+ * Added new `MessageSpec.suppress` config for `XH.message()` and its `alert`, `confirm`, and
27
+ `prompt` variants. Set to `true` (or a config object) to offer users a "Don't show this message
28
+ again" checkbox. Confirmed responses are saved to browser local or session storage - optionally
29
+ with an expiry - and returned immediately by future calls with the same `messageKey`.
30
+ * `FileChooser` now takes as many dropped files as its `maxFiles` limit allows, warning about only
31
+ the surplus rather than discarding the entire drop.
32
+ * Added an opt-in `enforceValueInOptions` prop to the desktop and mobile `Select`, constraining the
33
+ value to the current `options` and dropping any selection no longer found there. Enforced once
34
+ `options` is non-null, so pass null while options load.
35
+
36
+ ### ⚙️ Technical
37
+
38
+ * Upgraded `react-dropzone` to v20, which drops its UMD build and ships as an ESM + CJS package with
39
+ an `exports` map. Requires Node >= 22 to install.
40
+ * Added an opt-in `enforceValueInOptions` prop to the desktop and mobile `Select`, constraining the
41
+ value to the current `options` and dropping any selection no longer found there. Enforced once
42
+ `options` is non-null, so pass null while options load.
43
+ * Extended the package `sideEffects` declaration to cover the vendored golden-layout implementation
44
+ and the barrels with registration or configuration side effects on import - icon, mobx, blueprint
45
+ kit, golden-layout kit, and persist. Required by the tree-shaking in hoist-dev-utils v15.
46
+ * Restructured `PersistenceProvider` provider registration to remove a base/subclass import cycle
47
+ that breaks under tree-shaking bundlers' re-export optimization. No API change.
48
+
49
+ ### ⚙️ Typescript API Adjustments
50
+
51
+ * `SelectOption` now admits custom fields alongside the standard `value`/`label`, so extra data
52
+ carried on an option (and already passed through at runtime) can be read within `optionRenderer`,
53
+ `filterFn`, and friends without a cast. Annotate the callback's argument with the app's own option
54
+ type for fully typed access - e.g. `optionRenderer: (opt: MyOption) => ...`.
55
+
56
+ ### 📚 Libraries
57
+
58
+ * react-dropzone `15.0 → 20.1`
59
+
15
60
  ## 87.0.0 - 2026-08-25
16
61
 
17
62
  ### 💥 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 {warnIf} from '@xh/hoist/utils/js';
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
- if (this.formModel) {
117
- await this.formModel.validateAsync();
118
- if (!this.formModel.isValid) return;
119
- if (this.formModel.getField('value')) {
120
- resolvedVal = this.formModel.getData().value;
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
- constructor({ title, icon, message, messageKey, className, input, extraConfirmText, extraConfirmLabel, confirmProps, cancelProps, cancelAlign, onConfirm, onCancel, dismissable, cancelOnDismiss }: MessageSpec);
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
  }
@@ -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
- * Respects the `maxFiles` limit but does NOT enforce the `accept` / file-size constraints
75
- * (those are applied only on drop/browse) - use with care.
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 (if it originated in this grid) or, at rest, the current selection.
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. Also clickable, to move the current selection without a drag.
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.
@@ -105,15 +105,23 @@ function getGroupSorters(gridModel: GridModel): RecordSorter[] {
105
105
  const column = gridModel.getColumn(colId);
106
106
  if (!column) return null;
107
107
 
108
- const {field} = column;
108
+ const {field} = column,
109
+ {getValueFn, ctx} = sorterFor(gridModel, column);
109
110
  return {
110
- ...sorterFor(gridModel, column),
111
+ ctx,
112
+ // groupSortFn expects ag-Grid group keys - always string or null, never raw values.
113
+ getValueFn: params => toGroupKey(getValueFn(params)),
111
114
  compare: (a, b, nodeA, nodeB) => groupSortFn(a, b, field, {gridModel, nodeA, nodeB})
112
115
  };
113
116
  })
114
117
  );
115
118
  }
116
119
 
120
+ /** Mirror ag-Grid's ValueService.getKeyForNode - string/null pass through, else String(). */
121
+ function toGroupKey(value: any): string {
122
+ return value == null || typeof value === 'string' ? value : String(value);
123
+ }
124
+
117
125
  /** Value-resolution half of a sorter - the caller supplies the comparator. */
118
126
  function sorterFor(gridModel: GridModel, column: Column) {
119
127
  const {field, getValueFn} = column;
@@ -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.
@@ -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 = null;
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
- | 'pref'
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
- // 1) Recognize shortcut form
231
- const {type, ...rest} = opts;
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
- if (rest.prefKey) return PrefProvider;
234
- if (rest.localStorageKey) return LocalStorageProvider;
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 known Provider Class, or return raw class
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
 
@@ -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
+ ]);
@@ -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 {castArray, concat, filter, isEmpty, keys, fromPairs, map, sortBy, uniqBy} from 'lodash';
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
- * Respects the `maxFiles` limit but does NOT enforce the `accept` / file-size constraints
126
- * (those are applied only on drop/browse) - use with care.
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
- if (this.addFilesInternal(accepted)) {
162
- this.onFileAccepted?.(accepted);
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
- // De-dupe by name, then enforce `maxFiles` on the result. Warns and no-ops if exceeded;
171
- // returns true if the selection was updated.
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>): boolean {
183
+ private addFilesInternal(files: Some<File>): File[] {
174
184
  const {maxFiles} = this,
175
- deduped = uniqBy(concat(files, this.files), 'name');
176
-
177
- if (maxFiles != null && deduped.length > maxFiles) {
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} exceeded.`
196
+ : `File limit of ${maxFiles} reached - ${surplus} ${pluralize('file', surplus)} not added.`
182
197
  );
183
- return false;
184
198
  }
185
199
 
186
- this.files = deduped;
187
- return true;
200
+ if (!isEmpty(added)) this.files = [...added, ...retained];
201
+ return added;
188
202
  }
189
203
 
190
204
  private defaultRejectMessage = (rejections: FileRejection[]): ReactElement => {
@@ -174,6 +174,10 @@
174
174
  padding: var(--xh-pad-px);
175
175
  }
176
176
 
177
+ &--is-disabled {
178
+ color: var(--xh-input-disabled-text-color);
179
+ }
180
+
177
181
  &--is-selected {
178
182
  // Suppress default highlighting - checkmark indicate selection.
179
183
  color: var(--xh-text-color);
@@ -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 (if it originated in this grid) or, at rest, the current selection.
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
- treeStyle: TreeStyle.HIGHLIGHTS_AND_BORDERS,
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
- align: 'center',
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. Also clickable, to move the current selection without a drag.
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
@@ -83,6 +83,10 @@
83
83
  padding: var(--xh-pad-px);
84
84
  }
85
85
 
86
+ &--is-disabled {
87
+ color: var(--xh-input-disabled-text-color);
88
+ }
89
+
86
90
  &--is-selected {
87
91
  // Suppress default highlighting - checkmark indicate selection.
88
92
  color: var(--xh-text-color);
@@ -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.0.0",
3
+ "version": "87.1.1",
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": "~15.0.0",
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": "^7.0.0",
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",
@@ -46,7 +46,7 @@ function copyViaExecCommand(text: string): void {
46
46
  range.selectNode(span);
47
47
  selection.addRange(range);
48
48
 
49
- let success = false;
49
+ let success: boolean;
50
50
  try {
51
51
  success = document.execCommand('copy');
52
52
  } finally {