@xh/hoist 86.2.0 → 86.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.gitattributes +4 -0
- package/CHANGELOG.md +58 -0
- package/admin/AppModel.ts +3 -4
- package/admin/tabs/activity/tracking/ActivityTrackingModel.ts +56 -6
- package/admin/tabs/activity/tracking/ActivityTrackingPanel.ts +9 -26
- package/admin/tabs/clients/ClientsModel.ts +4 -1
- package/admin/tabs/clients/activity/ClientDetailModel.ts +5 -1
- package/admin/tabs/userData/roles/RolePanel.ts +2 -2
- package/appcontainer/RouterModel.ts +6 -6
- package/build/types/admin/AppModel.d.ts +3 -4
- package/build/types/admin/tabs/activity/tracking/ActivityTrackingModel.d.ts +14 -3
- package/build/types/admin/tabs/clients/ClientsModel.d.ts +3 -0
- package/build/types/appcontainer/RouterModel.d.ts +5 -5
- package/build/types/cmp/grid/Grid.d.ts +2 -1
- package/build/types/cmp/grid/GridModel.d.ts +2 -2
- package/build/types/cmp/grid/Types.d.ts +8 -1
- package/build/types/cmp/input/SegmentedControlOption.d.ts +12 -0
- package/build/types/core/HoistAppModel.d.ts +2 -3
- package/build/types/core/types/Types.d.ts +12 -0
- package/build/types/desktop/cmp/filter/FilterChooser.d.ts +5 -0
- package/build/types/desktop/cmp/filter/PopoverFilterChooser.d.ts +3 -1
- package/build/types/desktop/cmp/grid/impl/colchooser/ColChooserModel.d.ts +2 -2
- package/build/types/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomRowModel.d.ts +9 -1
- package/build/types/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomTabModel.d.ts +8 -2
- package/build/types/desktop/cmp/input/Select.d.ts +8 -0
- package/build/types/kit/react-dropzone/index.d.ts +1 -0
- package/build/types/kit/swiper/index.d.ts +4 -3
- package/build/types/mobile/cmp/grid/impl/ColChooserModel.d.ts +2 -2
- package/build/types/mobile/cmp/input/Select.d.ts +8 -0
- package/cmp/grid/Grid.ts +10 -2
- package/cmp/grid/GridModel.ts +4 -3
- package/cmp/grid/Types.ts +9 -1
- package/cmp/grid/impl/MenuSupport.ts +1 -1
- package/cmp/input/SegmentedControlOption.ts +14 -0
- package/cmp/store/impl/StoreFilterFieldImplModel.ts +5 -6
- package/core/HoistAppModel.ts +2 -3
- package/core/types/Types.ts +14 -5
- package/desktop/cmp/filechooser/FileChooser.ts +6 -3
- package/desktop/cmp/filechooser/FileChooserModel.ts +12 -5
- package/desktop/cmp/filter/FilterChooser.scss +41 -0
- package/desktop/cmp/filter/FilterChooser.ts +147 -7
- package/desktop/cmp/filter/PopoverFilterChooser.ts +7 -84
- package/desktop/cmp/grid/editors/impl/InlineEditorModel.ts +6 -1
- package/desktop/cmp/grid/find/impl/GridFindFieldImplModel.ts +3 -5
- package/desktop/cmp/grid/impl/colchooser/ColChooserModel.ts +2 -2
- package/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomRow.ts +12 -12
- package/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomRowModel.ts +18 -0
- package/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomTab.scss +5 -0
- package/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomTab.ts +13 -2
- package/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomTabModel.ts +97 -14
- package/desktop/cmp/input/SegmentedControl.ts +7 -3
- package/desktop/cmp/input/Select.ts +24 -16
- package/docs/routing.md +4 -2
- package/docs/upgrade-notes/v86-upgrade-notes.md +73 -8
- package/kit/react-dropzone/index.ts +1 -0
- package/mcp/README.md +12 -0
- package/mcp/data/ts-registry.spec.ts +156 -0
- package/mcp/data/ts-registry.ts +13 -6
- package/mcp/formatters/typescript.ts +7 -3
- package/mcp/util/paths.ts +21 -0
- package/mobile/cmp/grid/impl/ColChooserModel.ts +2 -2
- package/mobile/cmp/input/SegmentedControl.ts +7 -3
- package/mobile/cmp/input/Select.ts +21 -15
- package/mobx/README.md +2 -1
- package/package.json +5 -5
- package/desktop/cmp/filter/PopoverFilterChooser.scss +0 -42
|
@@ -80,6 +80,7 @@ export const [SegmentedControl, segmentedControl] = hoistCmp.withFactory<Segment
|
|
|
80
80
|
interface NormalizedOption extends SegmentedControlOption {
|
|
81
81
|
label: string;
|
|
82
82
|
intent?: Intent;
|
|
83
|
+
testId?: string;
|
|
83
84
|
_key: string;
|
|
84
85
|
}
|
|
85
86
|
|
|
@@ -92,13 +93,14 @@ class SegmentedControlModel extends HoistInputModel {
|
|
|
92
93
|
return options.map((o: any, idx: number) => {
|
|
93
94
|
const key = String(idx);
|
|
94
95
|
if (isObject(o)) {
|
|
95
|
-
const {label, value, icon, disabled, intent} = o as SegmentedControlOption;
|
|
96
|
+
const {label, value, icon, disabled, intent, testId} = o as SegmentedControlOption;
|
|
96
97
|
return {
|
|
97
98
|
value: this.toInternal(value),
|
|
98
99
|
label: label ?? (icon ? '' : String(value)),
|
|
99
100
|
icon,
|
|
100
101
|
disabled,
|
|
101
102
|
intent,
|
|
103
|
+
testId,
|
|
102
104
|
_key: key
|
|
103
105
|
};
|
|
104
106
|
} else {
|
|
@@ -163,13 +165,15 @@ const cmp = hoistCmp.factory<SegmentedControlModel>(({model, className, ...props
|
|
|
163
165
|
// applied via a per-button className that our SCSS keys its solid/hint coloring off of.
|
|
164
166
|
const defaultIntent = intent && intent !== 'none' ? intent : null,
|
|
165
167
|
bpOptions = model.normalizedOptions.map(opt => {
|
|
166
|
-
const optIntent = opt.intent ?? defaultIntent
|
|
168
|
+
const optIntent = opt.intent ?? defaultIntent,
|
|
169
|
+
optTestId = opt.testId ?? (testId ? `${testId}-${String(opt.value)}` : null);
|
|
167
170
|
return {
|
|
168
171
|
value: opt._key,
|
|
169
172
|
label: opt.label,
|
|
170
173
|
icon: opt.icon,
|
|
171
174
|
disabled: opt.disabled,
|
|
172
|
-
className: optIntent ? `xh-segmented-control-option--${optIntent}` : null
|
|
175
|
+
className: optIntent ? `xh-segmented-control-option--${optIntent}` : null,
|
|
176
|
+
...(optTestId ? {[TEST_ID]: optTestId} : null)
|
|
173
177
|
};
|
|
174
178
|
});
|
|
175
179
|
|
|
@@ -31,7 +31,7 @@ import {debouncePromise, wait} from '@xh/hoist/promise';
|
|
|
31
31
|
import {elemWithin, getTestId, mergeDeep, TEST_ID, throwIf, withDefault} from '@xh/hoist/utils/js';
|
|
32
32
|
import {createObservableRef, getLayoutProps} from '@xh/hoist/utils/react';
|
|
33
33
|
import classNames from 'classnames';
|
|
34
|
-
import {castArray, escapeRegExp, isEmpty, isEqual, isNil, isPlainObject,
|
|
34
|
+
import {castArray, escapeRegExp, isEmpty, isEqual, isNil, isPlainObject, unionWith} from 'lodash';
|
|
35
35
|
import {ReactElement, ReactNode} from 'react';
|
|
36
36
|
import {components} from 'react-select';
|
|
37
37
|
import {calcWindowedMenuWidth} from './impl/CalcWindowedMenuWidth';
|
|
@@ -191,6 +191,15 @@ export interface SelectProps extends HoistProps, HoistInputProps, LayoutProps {
|
|
|
191
191
|
|
|
192
192
|
/** Field on provided options for sourcing each option's value (default `value`). */
|
|
193
193
|
valueField?: string;
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Function to generate a `SelectOption` for a (non-null) selected value not present in the
|
|
197
|
+
* current options list. Return null to fall back to the default value-as-label behavior.
|
|
198
|
+
*
|
|
199
|
+
* Useful with queryFn-based selects, readonly forms, or any case where options may not be
|
|
200
|
+
* loaded when a value is set, ensuring the value renders with its proper label.
|
|
201
|
+
*/
|
|
202
|
+
generateOptionFn?: (value: any) => SelectOption;
|
|
194
203
|
}
|
|
195
204
|
|
|
196
205
|
/**
|
|
@@ -437,7 +446,8 @@ class SelectInputModel extends HoistInputModel {
|
|
|
437
446
|
|
|
438
447
|
// Convert external value into option object(s). Options created if missing - this takes the
|
|
439
448
|
// external value from the model, and we will respect that even if we don't know about it.
|
|
440
|
-
// (Exception for a null value, which
|
|
449
|
+
// (Exception for a null value, which is never synthesized - accepted only if provided via
|
|
450
|
+
// options.)
|
|
441
451
|
override toInternal(external) {
|
|
442
452
|
if (this.multiMode) {
|
|
443
453
|
if (external == null || isEqual(external, this.emptyValue)) external = []; // avoid [null]
|
|
@@ -457,7 +467,10 @@ class SelectInputModel extends HoistInputModel {
|
|
|
457
467
|
}
|
|
458
468
|
}
|
|
459
469
|
|
|
460
|
-
|
|
470
|
+
if (!createIfNotFound) return null;
|
|
471
|
+
|
|
472
|
+
// Value not among options - let the app generate an option for it, else synthesize one.
|
|
473
|
+
return this.componentProps.generateOptionFn?.(value) ?? this.valueToOption(value);
|
|
461
474
|
}
|
|
462
475
|
|
|
463
476
|
override toExternal(internal) {
|
|
@@ -481,11 +494,11 @@ class SelectInputModel extends HoistInputModel {
|
|
|
481
494
|
// Normalize / clone a single source value into a normalized option object. Supports Strings
|
|
482
495
|
// and Objects. Objects are validated/defaulted to ensure a label+value or label+options sublist,
|
|
483
496
|
// with other fields brought along to support Selects emitting value objects with ad hoc properties.
|
|
484
|
-
private toOption(src, depth) {
|
|
497
|
+
private toOption(src, depth): SelectOption {
|
|
485
498
|
return isPlainObject(src) ? this.objectToOption(src, depth) : this.valueToOption(src);
|
|
486
499
|
}
|
|
487
500
|
|
|
488
|
-
private objectToOption(src, depth) {
|
|
501
|
+
private objectToOption(src, depth): SelectOption {
|
|
489
502
|
const {componentProps} = this,
|
|
490
503
|
labelField = withDefault(componentProps.labelField, 'label'),
|
|
491
504
|
valueField = withDefault(componentProps.valueField, 'value');
|
|
@@ -508,7 +521,7 @@ class SelectInputModel extends HoistInputModel {
|
|
|
508
521
|
};
|
|
509
522
|
}
|
|
510
523
|
|
|
511
|
-
private valueToOption(src) {
|
|
524
|
+
private valueToOption(src): SelectOption {
|
|
512
525
|
return {label: src != null ? src.toString() : '-null-', value: src};
|
|
513
526
|
}
|
|
514
527
|
|
|
@@ -521,13 +534,9 @@ class SelectInputModel extends HoistInputModel {
|
|
|
521
534
|
|
|
522
535
|
// Carry forward and add to any existing internalOpts to allow our value
|
|
523
536
|
// converters to continue all selected values in multiMode.
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
const matchOpt = matchesByVal[currOpt.value];
|
|
528
|
-
if (!matchOpt) newOpts.push(currOpt); // avoiding dupes
|
|
529
|
-
});
|
|
530
|
-
this.internalOptions = newOpts;
|
|
537
|
+
this.internalOptions = unionWith(matchOpts, this.internalOptions, (a, b) =>
|
|
538
|
+
isEqual(a.value, b.value)
|
|
539
|
+
);
|
|
531
540
|
|
|
532
541
|
// But only return the matching options back to the combo.
|
|
533
542
|
return matchOpts;
|
|
@@ -544,8 +553,7 @@ class SelectInputModel extends HoistInputModel {
|
|
|
544
553
|
// Option Rendering
|
|
545
554
|
//----------------------
|
|
546
555
|
formatOptionLabel = (opt, params) => {
|
|
547
|
-
//
|
|
548
|
-
// If we need to expose customization here, we could consider a dedicated prop.
|
|
556
|
+
// Display the standard label string in the value container (context == 'value').
|
|
549
557
|
if (params.context !== 'menu') {
|
|
550
558
|
return opt.label;
|
|
551
559
|
}
|
|
@@ -561,7 +569,7 @@ class SelectInputModel extends HoistInputModel {
|
|
|
561
569
|
return div(opt.label);
|
|
562
570
|
}
|
|
563
571
|
|
|
564
|
-
return castArray(this.externalValue).
|
|
572
|
+
return castArray(this.externalValue).some(v => isEqual(v, opt.value))
|
|
565
573
|
? hbox({
|
|
566
574
|
items: [
|
|
567
575
|
div({
|
package/docs/routing.md
CHANGED
|
@@ -150,10 +150,12 @@ numeric-only parameters). See the
|
|
|
150
150
|
### Conditional Route Exclusion
|
|
151
151
|
|
|
152
152
|
Routes support an `omit` property (a Hoist extension, not part of Router5) that allows
|
|
153
|
-
declarative exclusion of routes at registration time. This is useful for role-gated sections
|
|
153
|
+
declarative exclusion of routes at registration time. This is useful for role-gated sections.
|
|
154
|
+
`getRoutes()` returns `HoistRoute[]` - Hoist's `Route` type extended with the optional `omit` key -
|
|
155
|
+
so the property type-checks without any cast:
|
|
154
156
|
|
|
155
157
|
```typescript
|
|
156
|
-
override getRoutes() {
|
|
158
|
+
override getRoutes(): HoistRoute[] {
|
|
157
159
|
return [{
|
|
158
160
|
name: 'default',
|
|
159
161
|
path: '/app',
|
|
@@ -221,7 +221,21 @@ grep -rn "fileChooser\|FileChooser" client-app/src/
|
|
|
221
221
|
| `minSize` (prop) | `FileChooserModel` config `minFileSize` |
|
|
222
222
|
| `enableMulti` / `enableAddMulti` (props) | `FileChooserModel` config `maxFiles` (set `1` for single-file; omit for unlimited) |
|
|
223
223
|
| `showFileGrid` (prop) | Removed - default display is a grid; customize via the `fileDisplay` content prop |
|
|
224
|
-
| `targetText` (prop) | `FileChooserModel` config `emptyDisplayPrompt` (+ `emptyDisplayHint`)
|
|
224
|
+
| `targetText` (prop) | **Preferred:** `FileChooserModel` config `emptyDisplayPrompt` (+ `emptyDisplayHint`). Use the `emptyDisplay` content prop only for a fully custom layout. |
|
|
225
|
+
|
|
226
|
+
> **`emptyDisplay` and `fileDisplay` switch on file *presence*; v85's `targetText` did not.**
|
|
227
|
+
> In v85 the drop target that rendered `targetText` was **always visible** - even with files
|
|
228
|
+
> selected - and the file grid (if enabled) sat alongside it. v86 has no always-visible text slot:
|
|
229
|
+
> `emptyDisplay` renders **only when no files are selected**, and `fileDisplay` (defaulting to the
|
|
230
|
+
> grid/card) renders **only when files are present**. There is therefore no mechanical mapping for a
|
|
231
|
+
> `targetText` - you must decide which presence-state its content belonged to:
|
|
232
|
+
> - A drop prompt ("Drag and drop files here") is empty-state content - set it as
|
|
233
|
+
> `emptyDisplayPrompt` (a plain string, preferred - keeps Hoist's icon, styling, and the
|
|
234
|
+
> auto-generated constraints hint). When files exist, the grid/`fileDisplay` takes over.
|
|
235
|
+
> - A message that only makes sense once a file is chosen (e.g. "Ready to upload") belongs in
|
|
236
|
+
> `fileDisplay`, which replaces the default grid - so set it only if you don't need that grid.
|
|
237
|
+
> - If your `targetText` varied on an **app flag** rather than on file presence, preserve that flag
|
|
238
|
+
> *inside* the slot you choose - do not let the empty/file switch silently stand in for it.
|
|
225
239
|
|
|
226
240
|
Before (v85 - config as component props, model takes no args):
|
|
227
241
|
```typescript
|
|
@@ -283,17 +297,68 @@ constructor() {
|
|
|
283
297
|
}
|
|
284
298
|
```
|
|
285
299
|
|
|
286
|
-
**
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
300
|
+
**Prefer the styled text prompt over rebuilding the UI.** Most `targetText` values were a string or
|
|
301
|
+
a simple `placeholder(Icon.upload(), '...')` - which is exactly what the default empty display
|
|
302
|
+
already renders. Map these to `emptyDisplayPrompt` (a plain string on the model), optionally with
|
|
303
|
+
`emptyDisplayHint`: you keep Hoist's upload icon, styling, and the auto-generated summary of accepted
|
|
304
|
+
types and size/count limits. This holds even when `targetText` was a `placeholder(...)` element -
|
|
305
|
+
collapse it back to the prompt string rather than reaching for a content prop.
|
|
290
306
|
|
|
291
307
|
```typescript
|
|
292
|
-
// Before (v85)
|
|
308
|
+
// Before (v85) - a plain-text or simple-placeholder targetText
|
|
309
|
+
fileChooser({model, targetText: 'Drop loan docs here'})
|
|
293
310
|
fileChooser({model, targetText: placeholder(Icon.upload(), 'Drop loan docs here')})
|
|
294
311
|
|
|
295
|
-
// After (v86)
|
|
296
|
-
|
|
312
|
+
// After (v86) - map targetText to a styled prompt on the model; icon, styling, and the constraints
|
|
313
|
+
// hint come for free, and the component render needs no change
|
|
314
|
+
new FileChooserModel({emptyDisplayPrompt: 'Drop loan docs here'});
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
Reserve the `emptyDisplay` / `fileDisplay` content props for layouts the prompt/hint genuinely can't
|
|
318
|
+
express. Note they are **not** interchangeable targets for a `targetText`: `emptyDisplay` renders
|
|
319
|
+
only when empty and `fileDisplay` only when files are present, so content meant for the
|
|
320
|
+
file-populated state must go to `fileDisplay` - never `emptyDisplay`.
|
|
321
|
+
|
|
322
|
+
**If your `targetText` was conditional** - a ternary or computed expression - work out what the
|
|
323
|
+
condition actually tracked before migrating, because v86 already switches content on file presence.
|
|
324
|
+
|
|
325
|
+
In the common case the condition tracked *file presence* (a flag toggled as files are added and
|
|
326
|
+
removed). v86's built-in switch replaces it directly: the no-file prompt becomes `emptyDisplayPrompt`
|
|
327
|
+
and the flag is dropped. The plain drop text needs no content prop at all - only the custom "ready"
|
|
328
|
+
affirmation, meant for the file-populated state, uses `fileDisplay` (which overrides the default
|
|
329
|
+
grid). `enableMulti` moves to `maxFiles` on the model:
|
|
330
|
+
|
|
331
|
+
```typescript
|
|
332
|
+
// Before (v85) - targetText always visible, toggled by a presence-derived flag
|
|
333
|
+
fileChooser({
|
|
334
|
+
model,
|
|
335
|
+
enableMulti: !singleDoc,
|
|
336
|
+
targetText: hasFile
|
|
337
|
+
? placeholder(Icon.check({intent: 'success'}), 'Ready to upload')
|
|
338
|
+
: placeholder(Icon.upload(), 'Drag and drop files here')
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
// After (v86) - the presence switch is built in, so the flag is no longer needed
|
|
342
|
+
this.chooserModel = new FileChooserModel({
|
|
343
|
+
maxFiles: singleDoc ? 1 : null,
|
|
344
|
+
emptyDisplayPrompt: 'Drag and drop files here'
|
|
345
|
+
});
|
|
346
|
+
fileChooser({
|
|
347
|
+
model: this.chooserModel,
|
|
348
|
+
fileDisplay: placeholder(Icon.check({intent: 'success'}), 'Ready to upload')
|
|
349
|
+
})
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
The trap is a condition that is **independent of file presence** (e.g. an "uploads open" flag). Its
|
|
353
|
+
branches are *not* an empty/file pair, so splitting them across `emptyDisplay` / `fileDisplay` would
|
|
354
|
+
drop the condition and silently re-key the content onto presence. Keep such a flag *inside* the slot
|
|
355
|
+
its content belongs to - here, still on the styled prompt:
|
|
356
|
+
|
|
357
|
+
```typescript
|
|
358
|
+
// independent flag - preserved on the styled prompt (model config), not mapped onto presence
|
|
359
|
+
new FileChooserModel({
|
|
360
|
+
emptyDisplayPrompt: acceptingUploads ? 'Drag and drop files here' : 'Uploads are closed'
|
|
361
|
+
});
|
|
297
362
|
```
|
|
298
363
|
|
|
299
364
|
### 4. Remove Deleted Mobile `DateInput` Props
|
|
@@ -8,5 +8,6 @@ import {elementFactory} from '@xh/hoist/core';
|
|
|
8
8
|
import Dropzone from 'react-dropzone';
|
|
9
9
|
|
|
10
10
|
export {Dropzone};
|
|
11
|
+
export {ErrorCode} from 'react-dropzone';
|
|
11
12
|
export type {Accept, FileRejection, FileWithPath} from 'react-dropzone';
|
|
12
13
|
export const dropzone = elementFactory(Dropzone);
|
package/mcp/README.md
CHANGED
|
@@ -725,6 +725,18 @@ The `resolveDocPath()` utility in `util/paths.ts` validates that resolved paths
|
|
|
725
725
|
repository root. It rejects paths containing `..` segments. Always use this function when resolving
|
|
726
726
|
file paths from external input.
|
|
727
727
|
|
|
728
|
+
### Path Separators (Cross-Platform)
|
|
729
|
+
|
|
730
|
+
ts-morph's `SourceFile.getFilePath()` always returns **forward-slash** paths on every platform
|
|
731
|
+
(e.g. `D:/hoist-react/cmp/grid/GridModel.ts` on Windows), whereas `resolveRepoRoot()` returns a
|
|
732
|
+
native path from Node's `path` module -- **backslash-separated** on Windows (`D:\hoist-react`).
|
|
733
|
+
Comparing or slicing one against the other (e.g. `filePath.startsWith(repoRoot + '/')`) silently
|
|
734
|
+
fails on Windows, filtering out every source file and yielding an empty symbol index. When
|
|
735
|
+
comparing against or slicing a ts-morph path, use `resolveRepoRootPosix()` (and `toPosixPath()` for
|
|
736
|
+
any incoming file-path argument) from `util/paths.ts` rather than `resolveRepoRoot()`. Filesystem
|
|
737
|
+
access that stays within Node's `path`/`fs` APIs (e.g. the doc registry, the index cache) can keep
|
|
738
|
+
using `resolveRepoRoot()`, since those are separator-consistent on both sides.
|
|
739
|
+
|
|
728
740
|
### Registry Sync
|
|
729
741
|
|
|
730
742
|
The doc registry is hardcoded, not filesystem-scanned. When documentation files are added or
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test harness for the TypeScript symbol registry's cross-platform path handling.
|
|
3
|
+
* Run with:
|
|
4
|
+
* npx tsx mcp/data/ts-registry.spec.ts
|
|
5
|
+
*
|
|
6
|
+
* The hoist-react repo has no general test framework configured, so this script
|
|
7
|
+
* is a self-contained, exit-coded driver. It guards the path normalization the
|
|
8
|
+
* symbol index depends on: ts-morph's `getFilePath()` reports forward-slash paths
|
|
9
|
+
* on every platform, while Node's `path` reports backslashes on Windows. The two
|
|
10
|
+
* must be reconciled (to POSIX form) before comparison - a regression silently
|
|
11
|
+
* filters out every source file and empties the entire symbol index on Windows.
|
|
12
|
+
* See the "Path Separators (Cross-Platform)" pitfall in mcp/README.md.
|
|
13
|
+
*
|
|
14
|
+
* Two layers:
|
|
15
|
+
* - Synthetic path-helper cases (`toPosixPath`, `resolveRepoRootPosix`,
|
|
16
|
+
* `toRelativePath`, and the exact index-guard comparison) - these simulate a
|
|
17
|
+
* Windows path explicitly, so they are meaningful on any host OS, including
|
|
18
|
+
* Linux/macOS CI.
|
|
19
|
+
* - Live index integration - exercises `searchSymbols` / `getSymbolDetail` /
|
|
20
|
+
* `getMembers` against the real hoist-react sources to prove symbols resolve
|
|
21
|
+
* end-to-end (and that repo-relative `--file` disambiguation works).
|
|
22
|
+
*
|
|
23
|
+
* Run after every change to ts-registry.ts, index-cache.ts, or util/paths.ts.
|
|
24
|
+
*/
|
|
25
|
+
import {resolveRepoRoot, resolveRepoRootPosix, toPosixPath} from '../util/paths.js';
|
|
26
|
+
import {toRelativePath} from '../formatters/typescript.js';
|
|
27
|
+
import {searchSymbols, getSymbolDetail, getMembers} from './ts-registry.js';
|
|
28
|
+
|
|
29
|
+
// Suppress info logs during test run; we only want test output. (Warns still print.)
|
|
30
|
+
process.env.HOIST_MCP_QUIET = '1';
|
|
31
|
+
|
|
32
|
+
//---------------------------------------------------------------------
|
|
33
|
+
// Minimal assertion helpers (match the exit-coded style of the sibling
|
|
34
|
+
// doc-id-resolver.spec.ts).
|
|
35
|
+
//---------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
let passed = 0;
|
|
38
|
+
let failed = 0;
|
|
39
|
+
const failures: string[] = [];
|
|
40
|
+
|
|
41
|
+
function ok(label: string, cond: boolean, detail?: string): void {
|
|
42
|
+
if (cond) {
|
|
43
|
+
passed++;
|
|
44
|
+
console.log(` PASS ${label}`);
|
|
45
|
+
} else {
|
|
46
|
+
failed++;
|
|
47
|
+
failures.push(detail ? `${label} -- ${detail}` : label);
|
|
48
|
+
console.log(` FAIL ${label}`);
|
|
49
|
+
if (detail) console.log(` ${detail}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function eq(label: string, actual: unknown, expected: unknown): void {
|
|
54
|
+
ok(
|
|
55
|
+
label,
|
|
56
|
+
actual === expected,
|
|
57
|
+
`got ${JSON.stringify(actual)}, expected ${JSON.stringify(expected)}`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
//---------------------------------------------------------------------
|
|
62
|
+
// Synthetic path-helper cases (host-OS-independent).
|
|
63
|
+
//---------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
console.log('toPosixPath cases:');
|
|
66
|
+
eq(
|
|
67
|
+
'converts backslashes',
|
|
68
|
+
toPosixPath('D:\\hoist-react\\core\\XH.ts'),
|
|
69
|
+
'D:/hoist-react/core/XH.ts'
|
|
70
|
+
);
|
|
71
|
+
eq('leaves forward slashes untouched', toPosixPath('/repo/core/XH.ts'), '/repo/core/XH.ts');
|
|
72
|
+
eq('normalizes mixed separators', toPosixPath('a\\b/c\\d'), 'a/b/c/d');
|
|
73
|
+
eq('is idempotent', toPosixPath(toPosixPath('D:\\a\\b')), 'D:/a/b');
|
|
74
|
+
|
|
75
|
+
console.log('\nresolveRepoRootPosix cases:');
|
|
76
|
+
const rootPosix = resolveRepoRootPosix();
|
|
77
|
+
ok('has no backslashes', !rootPosix.includes('\\'), `got "${rootPosix}"`);
|
|
78
|
+
eq('equals toPosixPath(resolveRepoRoot())', rootPosix, toPosixPath(resolveRepoRoot()));
|
|
79
|
+
|
|
80
|
+
console.log('\nIndex-guard regression cases (simulated Windows paths):');
|
|
81
|
+
// The exact scenario that broke: on Windows, resolveRepoRoot() yields a
|
|
82
|
+
// backslash path while ts-morph yields forward slashes. Assert the naive guard
|
|
83
|
+
// fails and the POSIX-normalized guard succeeds - regardless of the host OS.
|
|
84
|
+
const winRootNative = 'D:\\hoist-react'; // what resolveRepoRoot() returns on Windows
|
|
85
|
+
const winFile = 'D:/hoist-react/cmp/grid/GridModel.ts'; // what ts-morph getFilePath() returns on Windows
|
|
86
|
+
ok(
|
|
87
|
+
'naive backslash-root comparison fails (the original bug)',
|
|
88
|
+
!winFile.startsWith(winRootNative + '/')
|
|
89
|
+
);
|
|
90
|
+
ok(
|
|
91
|
+
'posix-normalized root comparison succeeds (the fix)',
|
|
92
|
+
winFile.startsWith(toPosixPath(winRootNative) + '/')
|
|
93
|
+
);
|
|
94
|
+
eq(
|
|
95
|
+
'relative slice under posix root is correct',
|
|
96
|
+
winFile.slice(toPosixPath(winRootNative).length + 1),
|
|
97
|
+
'cmp/grid/GridModel.ts'
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
console.log('\ntoRelativePath cases:');
|
|
101
|
+
const sampleRel = 'cmp/grid/GridModel.ts';
|
|
102
|
+
eq('strips a posix absolute path', toRelativePath(`${rootPosix}/${sampleRel}`), sampleRel);
|
|
103
|
+
// A backslash absolute path (same root) must still strip AND normalize to posix.
|
|
104
|
+
const backslashAbs = `${rootPosix.replace(/\//g, '\\')}\\cmp\\grid\\GridModel.ts`;
|
|
105
|
+
eq('strips + normalizes a backslash absolute path', toRelativePath(backslashAbs), sampleRel);
|
|
106
|
+
|
|
107
|
+
//---------------------------------------------------------------------
|
|
108
|
+
// Live index integration (needs the real hoist-react sources; builds or
|
|
109
|
+
// loads the cached index on first query).
|
|
110
|
+
//---------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
console.log('\nLive index cases:');
|
|
113
|
+
|
|
114
|
+
const results = await searchSymbols('GridModel', {limit: 5});
|
|
115
|
+
const gm = results.find(r => r.name === 'GridModel' && r.kind === 'class');
|
|
116
|
+
ok('searchSymbols("GridModel") finds the class', !!gm, `got ${results.length} results`);
|
|
117
|
+
if (gm) {
|
|
118
|
+
eq('GridModel resolves to cmp/grid/GridModel.ts', toRelativePath(gm.filePath), sampleRel);
|
|
119
|
+
eq('GridModel sourcePackage is cmp/grid', gm.sourcePackage, 'cmp/grid');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const detail = await getSymbolDetail('GridModel');
|
|
123
|
+
ok(
|
|
124
|
+
'getSymbolDetail("GridModel") resolves',
|
|
125
|
+
!!detail && detail.kind === 'class',
|
|
126
|
+
`got ${JSON.stringify(detail && {name: detail.name, kind: detail.kind})}`
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
const members = await getMembers('GridModel');
|
|
130
|
+
ok(
|
|
131
|
+
'getMembers("GridModel") returns members',
|
|
132
|
+
!!members && members.members.length > 0,
|
|
133
|
+
`count=${members?.members.length ?? 0}`
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
// --file disambiguation (the secondary Windows bug): a repo-relative path passed
|
|
137
|
+
// to findIndexEntry must resolve to the matching entry. `View` exists in both
|
|
138
|
+
// cmp/viewmanager and data/cube; the path selects the data/cube one.
|
|
139
|
+
const view = await getSymbolDetail('View', 'data/cube/View.ts');
|
|
140
|
+
ok(
|
|
141
|
+
'getSymbolDetail("View", "data/cube/View.ts") disambiguates by repo-relative path',
|
|
142
|
+
!!view && toRelativePath(view.filePath) === 'data/cube/View.ts',
|
|
143
|
+
`relPath=${view ? toRelativePath(view.filePath) : 'null'}`
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
//---------------------------------------------------------------------
|
|
147
|
+
// Tally
|
|
148
|
+
//---------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
console.log(`\nTotal: ${passed} passed, ${failed} failed.`);
|
|
151
|
+
|
|
152
|
+
if (failed > 0) {
|
|
153
|
+
console.log('\nFailure details:');
|
|
154
|
+
for (const f of failures) console.log(` - ${f}`);
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
package/mcp/data/ts-registry.ts
CHANGED
|
@@ -24,7 +24,7 @@ import type {
|
|
|
24
24
|
import {resolve} from 'node:path';
|
|
25
25
|
|
|
26
26
|
import {log} from '../util/logger.js';
|
|
27
|
-
import {resolveRepoRoot} from '../util/paths.js';
|
|
27
|
+
import {resolveRepoRoot, resolveRepoRootPosix, toPosixPath} from '../util/paths.js';
|
|
28
28
|
import {computeFingerprint, loadCache, writeCache} from './index-cache.js';
|
|
29
29
|
|
|
30
30
|
//------------------------------------------------------------------
|
|
@@ -295,7 +295,9 @@ function buildSymbolIndex(proj: Project): {
|
|
|
295
295
|
} {
|
|
296
296
|
const index = new Map<string, SymbolEntry[]>();
|
|
297
297
|
const mIndex = new Map<string, MemberIndexEntry[]>();
|
|
298
|
-
|
|
298
|
+
// POSIX form: compared against ts-morph `getFilePath()`, which always uses
|
|
299
|
+
// forward slashes even on Windows.
|
|
300
|
+
const repoRoot = resolveRepoRootPosix();
|
|
299
301
|
|
|
300
302
|
const counts = {total: 0, exported: 0, byKind: {} as Record<string, number>};
|
|
301
303
|
let memberCount = 0;
|
|
@@ -549,7 +551,7 @@ function buildSymbolIndex(proj: Project): {
|
|
|
549
551
|
// Index Promise prototype extensions from promise/Promise.ts
|
|
550
552
|
const promiseFile = proj.getSourceFile(sf => sf.getFilePath().endsWith('/promise/Promise.ts'));
|
|
551
553
|
if (promiseFile) {
|
|
552
|
-
indexPromiseExtensions(promiseFile, index, mIndex,
|
|
554
|
+
indexPromiseExtensions(promiseFile, index, mIndex, resolveRepoRootPosix());
|
|
553
555
|
}
|
|
554
556
|
|
|
555
557
|
// Populate `memberNames` on symbol entries for every member-indexed owner
|
|
@@ -799,7 +801,8 @@ function ensureProject(): Project {
|
|
|
799
801
|
* JSDoc).
|
|
800
802
|
*/
|
|
801
803
|
function enrichMemberIndexFromImplements(proj: Project): void {
|
|
802
|
-
|
|
804
|
+
// POSIX form: compared against ts-morph `getFilePath()` (see buildSymbolIndex).
|
|
805
|
+
const repoRoot = resolveRepoRootPosix();
|
|
803
806
|
|
|
804
807
|
for (const sourceFile of proj.getSourceFiles()) {
|
|
805
808
|
const filePath = sourceFile.getFilePath();
|
|
@@ -1374,8 +1377,12 @@ function findIndexEntry(name: string, filePath?: string): SymbolEntry | null {
|
|
|
1374
1377
|
|
|
1375
1378
|
if (filePath) {
|
|
1376
1379
|
// Resolve relative paths against repo root -- search results display repo-relative
|
|
1377
|
-
// paths, so callers will typically pass those rather than absolute paths.
|
|
1378
|
-
|
|
1380
|
+
// paths, so callers will typically pass those rather than absolute paths. Normalize
|
|
1381
|
+
// to POSIX so the comparison matches entry.filePath (ts-morph forward slashes) on
|
|
1382
|
+
// Windows, and recognize Windows drive-letter absolutes (e.g. `D:/...`) as absolute.
|
|
1383
|
+
const posix = toPosixPath(filePath);
|
|
1384
|
+
const isAbsolute = posix.startsWith('/') || /^[a-zA-Z]:\//.test(posix);
|
|
1385
|
+
const resolved = isAbsolute ? posix : toPosixPath(resolve(resolveRepoRoot(), posix));
|
|
1379
1386
|
return exact.find(e => e.filePath === resolved) ?? null;
|
|
1380
1387
|
}
|
|
1381
1388
|
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import {z} from 'zod';
|
|
13
13
|
import type {MemberInfo, MemberIndexEntry, SymbolEntry, SymbolDetail} from '../data/ts-registry.js';
|
|
14
|
-
import {
|
|
14
|
+
import {resolveRepoRootPosix, toPosixPath} from '../util/paths.js';
|
|
15
15
|
|
|
16
16
|
/** Remove blank lines from a JSDoc string to produce more compact output. */
|
|
17
17
|
function collapseJsDoc(jsDoc: string): string {
|
|
@@ -31,8 +31,12 @@ export function truncateType(typeStr: string): string {
|
|
|
31
31
|
|
|
32
32
|
/** Convert an absolute file path to a repo-relative path. */
|
|
33
33
|
export function toRelativePath(filePath: string): string {
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
// Symbol filePaths originate from ts-morph (forward slashes on all platforms);
|
|
35
|
+
// compare in POSIX form so the repo-root prefix strips correctly on Windows,
|
|
36
|
+
// where `resolveRepoRoot()` would otherwise yield a backslash path.
|
|
37
|
+
const root = resolveRepoRootPosix();
|
|
38
|
+
const posix = toPosixPath(filePath);
|
|
39
|
+
return posix.startsWith(root) ? posix.slice(root.length + 1) : posix;
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
/**
|
package/mcp/util/paths.ts
CHANGED
|
@@ -39,6 +39,27 @@ export function resolveRepoRoot(): string {
|
|
|
39
39
|
return repoRoot;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/** Convert a filesystem path to POSIX (forward-slash) form. */
|
|
43
|
+
export function toPosixPath(p: string): string {
|
|
44
|
+
return p.replace(/\\/g, '/');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Repo root in POSIX (forward-slash) form, matching ts-morph's `getFilePath()`
|
|
49
|
+
* convention, which always reports forward slashes on every platform.
|
|
50
|
+
*
|
|
51
|
+
* On Windows, {@link resolveRepoRoot} returns a backslash path (e.g.
|
|
52
|
+
* `D:\hoist-react`) while ts-morph reports `D:/hoist-react/...`; comparing or
|
|
53
|
+
* slicing the two directly fails, silently filtering out every source file and
|
|
54
|
+
* yielding an empty symbol index. Use this whenever comparing against or
|
|
55
|
+
* slicing a ts-morph source-file path (see `data/ts-registry.ts` and
|
|
56
|
+
* `formatters/typescript.ts`). Filesystem access that stays within Node's
|
|
57
|
+
* `path` APIs should keep using {@link resolveRepoRoot}.
|
|
58
|
+
*/
|
|
59
|
+
export function resolveRepoRootPosix(): string {
|
|
60
|
+
return toPosixPath(resolveRepoRoot());
|
|
61
|
+
}
|
|
62
|
+
|
|
42
63
|
/**
|
|
43
64
|
* Resolve the `@xh/hoist` library version from the repo root `package.json`.
|
|
44
65
|
*
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Copyright © 2026 Extremely Heavy Industries Inc.
|
|
6
6
|
*/
|
|
7
|
-
import {GridModel} from '@xh/hoist/cmp/grid';
|
|
7
|
+
import {GridModel, IColChooserModel} from '@xh/hoist/cmp/grid';
|
|
8
8
|
import {HoistModel, HSide, XH} from '@xh/hoist/core';
|
|
9
9
|
import '@xh/hoist/mobile/register';
|
|
10
10
|
import {bindable, action, makeObservable, observable} from '@xh/hoist/mobx';
|
|
@@ -17,7 +17,7 @@ import {clone, find, sortBy} from 'lodash';
|
|
|
17
17
|
* It is not necessary to manually create instances of this class within an application.
|
|
18
18
|
* @internal
|
|
19
19
|
*/
|
|
20
|
-
export class ColChooserModel extends HoistModel {
|
|
20
|
+
export class ColChooserModel extends HoistModel implements IColChooserModel {
|
|
21
21
|
override xhImpl = true;
|
|
22
22
|
|
|
23
23
|
gridModel: GridModel;
|
|
@@ -85,6 +85,7 @@ export const [SegmentedControl, segmentedControl] = hoistCmp.withFactory<Segment
|
|
|
85
85
|
interface NormalizedOption extends SegmentedControlOption {
|
|
86
86
|
label: string;
|
|
87
87
|
intent?: Intent;
|
|
88
|
+
testId?: string;
|
|
88
89
|
_key: string;
|
|
89
90
|
}
|
|
90
91
|
|
|
@@ -97,13 +98,14 @@ class SegmentedControlModel extends HoistInputModel {
|
|
|
97
98
|
return options.map((o: any, idx: number) => {
|
|
98
99
|
const key = String(idx);
|
|
99
100
|
if (isObject(o)) {
|
|
100
|
-
const {label, value, icon, disabled, intent} = o as SegmentedControlOption;
|
|
101
|
+
const {label, value, icon, disabled, intent, testId} = o as SegmentedControlOption;
|
|
101
102
|
return {
|
|
102
103
|
value: this.toInternal(value),
|
|
103
104
|
label: label ?? (icon ? '' : String(value)),
|
|
104
105
|
icon,
|
|
105
106
|
disabled,
|
|
106
107
|
intent,
|
|
108
|
+
testId,
|
|
107
109
|
_key: key
|
|
108
110
|
};
|
|
109
111
|
} else {
|
|
@@ -168,7 +170,8 @@ const cmp = hoistCmp.factory<SegmentedControlModel>(({model, className, ...props
|
|
|
168
170
|
|
|
169
171
|
const buttons = model.normalizedOptions.map(opt => {
|
|
170
172
|
const optIntent = opt.intent ?? defaultIntent,
|
|
171
|
-
selected = opt._key === selectedKey
|
|
173
|
+
selected = opt._key === selectedKey,
|
|
174
|
+
optTestId = opt.testId ?? (testId ? `${testId}-${String(opt.value)}` : null);
|
|
172
175
|
// Wrap the label so it can truncate with an ellipsis when the segment is too narrow,
|
|
173
176
|
// rather than hard-clipping mid-character. Pass null for icon-only options so the Button
|
|
174
177
|
// renders the icon alone (an empty span would suppress that).
|
|
@@ -187,7 +190,8 @@ const cmp = hoistCmp.factory<SegmentedControlModel>(({model, className, ...props
|
|
|
187
190
|
selected && 'xh-segmented-control-option--selected',
|
|
188
191
|
optIntent && `xh-segmented-control-option--${optIntent}`
|
|
189
192
|
),
|
|
190
|
-
onClick: () => model.onValueChange(opt._key)
|
|
193
|
+
onClick: () => model.onValueChange(opt._key),
|
|
194
|
+
testId: optTestId
|
|
191
195
|
});
|
|
192
196
|
});
|
|
193
197
|
|