diff --git a/CHANGELOG.md b/CHANGELOG.md index 854d382d8..bcf385d7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ * `Store.isStore` * `View.isView` * `Filter.isFilter` +* Replaced `LeftRightChooserFilter.anyMatch` with `matchMode`. Changes are not expected to be + required as apps typically do not create this component directly. ### 🐞 Bug Fixes @@ -32,6 +34,8 @@ hamburger menu. Set to `true` to render the current user's initials instead or provide a function to render a custom element for the user. * Added `AggregationContext` as an additional argument to `CubeField.canAggregateFn`. +* Added `filterMatchMode` option to `ColChooserModel`, allowing customizing match to `start`, + `startWord`, or `any`. ### ⚙️ Typescript API Adjustments diff --git a/cmp/grid/GridModel.ts b/cmp/grid/GridModel.ts index 8946e8add..749f40002 100644 --- a/cmp/grid/GridModel.ts +++ b/cmp/grid/GridModel.ts @@ -146,7 +146,7 @@ export interface GridConfig { filterModel?: GridFilterModelConfig | boolean; /** Config with which to create a ColChooserModel, or boolean `true` to enable default.*/ - colChooserModel?: ColChooserConfig | boolean; + colChooserModel?: Omit | boolean; /** * Function to be called when the user triggers GridModel.restoreDefaultsAsync(). This diff --git a/cmp/grid/Types.ts b/cmp/grid/Types.ts index d058a4af5..70b99695b 100644 --- a/cmp/grid/Types.ts +++ b/cmp/grid/Types.ts @@ -5,10 +5,14 @@ * Copyright © 2026 Extremely Heavy Industries Inc. */ -import {GridFilterFieldSpecConfig} from '@xh/hoist/cmp/grid/filter/GridFilterFieldSpec'; -import {HSide, PersistOptions, Some} from '@xh/hoist/core'; -import {FilterBindTarget, FilterValueSource, Store, StoreRecord} from '@xh/hoist/data'; - +import type {HSide, PersistOptions, Some} from '@xh/hoist/core'; +import type { + FilterBindTarget, + FilterMatchMode, + FilterValueSource, + Store, + StoreRecord +} from '@xh/hoist/data'; import type { CellClassParams, CustomCellEditorProps, @@ -23,6 +27,7 @@ import type { import type {ReactElement, ReactNode} from 'react'; import type {Column, ColumnSpec} from './columns/Column'; import type {ColumnGroup, ColumnGroupSpec} from './columns/ColumnGroup'; +import type {GridFilterFieldSpecConfig} from './filter/GridFilterFieldSpec'; import type {GridModel} from './GridModel'; export interface ColumnState { @@ -124,6 +129,9 @@ export interface GridFilterBindTarget extends FilterBindTarget, FilterValueSourc export type GroupRowRenderer = (context: ICellRendererParams) => ReactNode; export interface ColChooserConfig { + /** GridModel to bind to. Not required if creating via `GridModel.colChooserModel` */ + gridModel?: GridModel; + /** * Immediately render changed columns on grid (default true). * Set to false to enable Save button for committing changes on save. Desktop only. @@ -147,6 +155,9 @@ export interface ColChooserConfig { /** Chooser height for popover and dialog. Desktop only. */ height?: string | number; + + /** Mode to use when filtering (default 'startWord'). Desktop only. */ + filterMatchMode?: FilterMatchMode; } export type ColumnOrGroup = Column | ColumnGroup; diff --git a/cmp/store/StoreFilterField.ts b/cmp/store/StoreFilterField.ts index 2ac90bac5..12c43759b 100644 --- a/cmp/store/StoreFilterField.ts +++ b/cmp/store/StoreFilterField.ts @@ -4,9 +4,9 @@ * * Copyright © 2026 Extremely Heavy Industries Inc. */ -import {GridModel} from '@xh/hoist/cmp/grid'; +import type {GridModel} from '@xh/hoist/cmp/grid'; import {DefaultHoistProps, hoistCmp, HoistModel, useLocalModel, XH} from '@xh/hoist/core'; -import {FilterTestFn, Store} from '@xh/hoist/data'; +import type {FilterMatchMode, FilterTestFn, Store} from '@xh/hoist/data'; import {storeFilterFieldImpl as desktopStoreFilterFieldImpl} from '@xh/hoist/dynamics/desktop'; import {storeFilterFieldImpl as mobileStoreFilterFieldImpl} from '@xh/hoist/dynamics/mobile'; import {StoreFilterFieldImplModel} from './impl/StoreFilterFieldImplModel'; @@ -53,7 +53,7 @@ export interface StoreFilterFieldProps extends DefaultHoistProps { includeFields?: string[]; /** Mode to use when filtering (default 'startWord'). */ - matchMode?: 'start' | 'startWord' | 'any'; + matchMode?: FilterMatchMode; /** Optional model for raw value binding - see comments on the `bind` prop for details. */ model?: HoistModel; diff --git a/cmp/store/impl/StoreFilterFieldImplModel.ts b/cmp/store/impl/StoreFilterFieldImplModel.ts index 5f9523849..429cb1ac7 100644 --- a/cmp/store/impl/StoreFilterFieldImplModel.ts +++ b/cmp/store/impl/StoreFilterFieldImplModel.ts @@ -4,10 +4,11 @@ * * Copyright © 2026 Extremely Heavy Industries Inc. */ -import {HoistModel, XH, lookup} from '@xh/hoist/core'; import {GridModel} from '@xh/hoist/cmp/grid'; +import {HoistModel, lookup, XH} from '@xh/hoist/core'; +import type {FilterMatchMode, StoreRecord} from '@xh/hoist/data'; import {CompoundFilter, FilterLike, Store, withFilterByKey} from '@xh/hoist/data'; -import {action, makeObservable, comparer} from '@xh/hoist/mobx'; +import {action, comparer, makeObservable} from '@xh/hoist/mobx'; import {stripTags, throwIf, warnIf, withDefault} from '@xh/hoist/utils/js'; import { debounce, @@ -33,8 +34,12 @@ export class StoreFilterFieldImplModel extends HoistModel { gridModel: GridModel; store: Store; - filter; - bufferedApplyFilter; + private filter: (rec: StoreRecord) => boolean; + private bufferedApplyFilter; + + get matchMode(): FilterMatchMode { + return this.componentProps.matchMode ?? 'startWord'; + } constructor() { super(); @@ -59,29 +64,30 @@ export class StoreFilterFieldImplModel extends HoistModel { this.bufferedApplyFilter = debounce(() => this.applyFilter(), filterBuffer); - this.addReaction({ - track: () => [this.filterText, gridModel?.columns, gridModel?.groupBy], - run: () => this.regenerateFilter(), - fireImmediately: true - }); - - this.addReaction({ - track: () => [this.componentProps.includeFields, this.componentProps.excludeFields], - run: () => this.regenerateFilter(), - equals: comparer.structural - }); + this.addReaction( + { + track: () => [this.filterText, gridModel?.columns, gridModel?.groupBy], + run: () => this.regenerateFilter(), + fireImmediately: true + }, + { + track: () => [this.componentProps.includeFields, this.componentProps.excludeFields], + run: () => this.regenerateFilter(), + equals: comparer.structural + } + ); } //------------------------------------------------------------------ // Trampoline value to bindable -- from bound model, or store //------------------------------------------------------------------ - get filterText() { + get filterText(): string { const {bind, model} = this.componentProps; return bind ? model[bind] : this.store.xhFilterText; } @action - setFilterText(v) { + setFilterText(v: string) { const {bind, model} = this.componentProps; if (bind) { model.setBindable(bind, v); @@ -122,7 +128,7 @@ export class StoreFilterFieldImplModel extends HoistModel { if (filterText && !isEmpty(activeFields)) { const regex = this.getRegex(filterText), valGetters = flatMap(activeFields, fieldPath => this.getValGetters(fieldPath)); - newFilter = rec => valGetters.some(fn => regex.test(fn(rec))); + newFilter = (rec: StoreRecord) => valGetters.some(fn => regex.test(fn(rec))); } if (filter === newFilter) return; @@ -140,9 +146,9 @@ export class StoreFilterFieldImplModel extends HoistModel { } } - getRegex(searchTerm) { + getRegex(searchTerm: string): RegExp { searchTerm = escapeRegExp(searchTerm); - switch (this.componentProps.matchMode ?? 'startWord') { + switch (this.matchMode) { case 'any': return new RegExp(searchTerm, 'i'); case 'start': @@ -153,11 +159,11 @@ export class StoreFilterFieldImplModel extends HoistModel { throw XH.exception('Unknown matchMode in StoreFilterField'); } - getActiveFields() { + getActiveFields(): string[] { const {gridModel, store, componentProps} = this, {includeFields, excludeFields} = componentProps; - let ret = store ? ['id', ...store.fields.map(f => f.name)] : []; + let ret = store ? ['id', ...store.fieldNames] : []; if (includeFields) ret = store ? intersection(ret, includeFields) : includeFields; if (excludeFields) ret = without(ret, ...excludeFields); @@ -196,7 +202,7 @@ export class StoreFilterFieldImplModel extends HoistModel { return ret; } - getValGetters(fieldName) { + getValGetters(fieldName: string) { const {gridModel} = this; // If a GridModel has been configured, the user is looking at rendered values in a grid and @@ -219,7 +225,7 @@ export class StoreFilterFieldImplModel extends HoistModel { return cols.map(column => { const {renderer, getValueFn} = column; - return record => { + return (record: StoreRecord) => { const ctx = { record, field: field.name, @@ -239,7 +245,7 @@ export class StoreFilterFieldImplModel extends HoistModel { // Otherwise just match raw. // Use expensive get() only when needed to support dot-separated paths. return fieldName.includes('.') - ? rec => get(rec.data, fieldName) - : rec => rec.data[fieldName]; + ? (rec: StoreRecord) => get(rec.data, fieldName) + : (rec: StoreRecord) => rec.data[fieldName]; } } diff --git a/data/filter/Types.ts b/data/filter/Types.ts index f3c327dd7..920e1e306 100644 --- a/data/filter/Types.ts +++ b/data/filter/Types.ts @@ -97,3 +97,11 @@ export interface FilterValueSource { export function isFilterValueSource(v: unknown): v is FilterValueSource { return (v as any)?.isFilterValueSource === true; } + +/** + * Option to customize matching behavior for {@link StoreFilterField} and related components. + * - `start`: match beginning of candidate strings only. + * - `startWord`: match beginning of words within candidate strings. + * - `any`: match anywhere within candidate strings. + */ +export type FilterMatchMode = 'start' | 'startWord' | 'any'; diff --git a/desktop/cmp/grid/find/GridFindField.ts b/desktop/cmp/grid/find/GridFindField.ts index db9ef7e2a..570965849 100644 --- a/desktop/cmp/grid/find/GridFindField.ts +++ b/desktop/cmp/grid/find/GridFindField.ts @@ -5,9 +5,10 @@ * Copyright © 2026 Extremely Heavy Industries Inc. */ import composeRefs from '@seznam/compose-react-refs/composeRefs'; -import {GridModel} from '@xh/hoist/cmp/grid'; +import type {GridModel} from '@xh/hoist/cmp/grid'; import {hbox, span, vbox} from '@xh/hoist/cmp/layout'; import {hoistCmp, LayoutProps, useLocalModel} from '@xh/hoist/core'; +import type {FilterMatchMode} from '@xh/hoist/data'; import {button} from '@xh/hoist/desktop/cmp/button'; import {textInput, TextInputProps} from '@xh/hoist/desktop/cmp/input'; import '@xh/hoist/desktop/register'; @@ -25,7 +26,7 @@ export interface GridFindFieldProps extends TextInputProps, LayoutProps { gridModel?: GridModel; /** Mode to use when searching (default 'startWord'). */ - matchMode?: 'start' | 'startWord' | 'any'; + matchMode?: FilterMatchMode; /** * Delay (in ms) to buffer searching the grid after the value changes from user input. diff --git a/desktop/cmp/grid/find/impl/GridFindFieldImplModel.ts b/desktop/cmp/grid/find/impl/GridFindFieldImplModel.ts index 157f66968..50402fa65 100644 --- a/desktop/cmp/grid/find/impl/GridFindFieldImplModel.ts +++ b/desktop/cmp/grid/find/impl/GridFindFieldImplModel.ts @@ -6,6 +6,7 @@ */ import {GridModel} from '@xh/hoist/cmp/grid'; import {HoistModel, XH} from '@xh/hoist/core'; +import type {FilterMatchMode, StoreRecord} from '@xh/hoist/data'; import {TextInputModel} from '@xh/hoist/desktop/cmp/input'; import {action, bindable, comparer, computed, makeObservable, observable} from '@xh/hoist/mobx'; import {stripTags, withDefault} from '@xh/hoist/utils/js'; @@ -32,22 +33,25 @@ export class GridFindFieldImplModel extends HoistModel { @bindable query: string = null; - get matchMode(): string { + get matchMode(): FilterMatchMode { return this.componentProps.matchMode ?? 'startWord'; } + get queryBuffer(): number { return this.componentProps.queryBuffer ?? 200; } + get includeFields(): string[] { return this.componentProps.includeFields; } + get excludeFields(): string[] { return this.componentProps.excludeFields; } @observable.ref results; inputRef = createObservableRef(); - _records = null; + _records: StoreRecord[] = null; get count(): number { return this.results?.length; @@ -81,7 +85,7 @@ export class GridFindFieldImplModel extends HoistModel { } @computed - get gridModel() { + get gridModel(): GridModel { const ret = withDefault(this.componentProps.gridModel, this.lookupModel(GridModel)); if (!ret) { this.logError("No GridModel available. Provide via a 'gridModel' prop, or context."); @@ -100,30 +104,30 @@ export class GridFindFieldImplModel extends HoistModel { } override onLinked() { - this.addReaction({ - track: () => this.query, - run: () => this.updateResults(true), - debounce: this.queryBuffer - }); - - this.addReaction({ - track: () => [ - this.gridModel?.store.records, - this.gridModel?.columns, - this.gridModel?.sortBy, - this.gridModel?.groupBy - ], - run: () => { - this._records = null; - if (this.hasQuery) this.updateResults(); + this.addReaction( + { + track: () => this.query, + run: () => this.updateResults(true), + debounce: this.queryBuffer + }, + { + track: () => [ + this.gridModel?.store.records, + this.gridModel?.columns, + this.gridModel?.sortBy, + this.gridModel?.groupBy + ], + run: () => { + this._records = null; + if (this.hasQuery) this.updateResults(); + } + }, + { + track: () => [this.includeFields, this.excludeFields, this.matchMode], + run: () => this.updateResults(), + equals: comparer.structural } - }); - - this.addReaction({ - track: () => [this.includeFields, this.excludeFields], - run: () => this.updateResults(), - equals: comparer.structural - }); + ); } selectPrev() { @@ -180,7 +184,7 @@ export class GridFindFieldImplModel extends HoistModel { } } - private getRecords() { + private getRecords(): StoreRecord[] { if (!this._records) { const records = this.sortRecordsRecursive([...this.gridModel.store.rootRecords]); this._records = this.sortRecordsByGroupBy(records); @@ -189,10 +193,10 @@ export class GridFindFieldImplModel extends HoistModel { } // Sort records with GridModel's sortBy(s) using the Column's comparator - private sortRecordsRecursive(records) { + private sortRecordsRecursive(records: StoreRecord[]): StoreRecord[] { const {gridModel} = this, {sortBy, treeMode, agApi, store} = gridModel, - ret = []; + ret: StoreRecord[] = []; [...sortBy].reverse().forEach(it => { const column = gridModel.getColumn(it.colId); @@ -225,7 +229,7 @@ export class GridFindFieldImplModel extends HoistModel { } // Sort records with GridModel's groupBy(s) using the GridModel's groupSortFn - private sortRecordsByGroupBy(records) { + private sortRecordsByGroupBy(records: StoreRecord[]) { const {gridModel} = this, {agApi, groupBy, groupSortFn, store} = gridModel; @@ -249,7 +253,7 @@ export class GridFindFieldImplModel extends HoistModel { return records; } - private getRegex(searchTerm) { + private getRegex(searchTerm: string): RegExp { searchTerm = escapeRegExp(searchTerm); switch (this.matchMode) { case 'any': @@ -262,12 +266,12 @@ export class GridFindFieldImplModel extends HoistModel { throw XH.exception('Unknown matchMode in GridFindField'); } - private getActiveFields() { + private getActiveFields(): string[] { const {gridModel, includeFields, excludeFields} = this, groupBy = gridModel.groupBy, visibleCols = gridModel.getVisibleLeafColumns(); - let ret = ['id', ...gridModel.store.fields.map(f => f.name)]; + let ret = ['id', ...gridModel.store.fieldNames]; if (includeFields) ret = intersection(ret, includeFields); if (excludeFields) ret = without(ret, ...excludeFields); @@ -301,7 +305,7 @@ export class GridFindFieldImplModel extends HoistModel { return ret; } - private getValGetters(fieldName) { + private getValGetters(fieldName: string) { const {gridModel} = this, {store} = gridModel, field = store.getField(fieldName); @@ -313,7 +317,7 @@ export class GridFindFieldImplModel extends HoistModel { return cols.map(column => { const {renderer, getValueFn} = column; - return record => { + return (record: StoreRecord) => { const ctx = { record, field: fieldName, @@ -332,7 +336,7 @@ export class GridFindFieldImplModel extends HoistModel { // Otherwise just match raw. // Use expensive get() only when needed to support dot-separated paths. return fieldName.includes('.') - ? rec => get(rec.data, fieldName) - : rec => rec.data[fieldName]; + ? (rec: StoreRecord) => get(rec.data, fieldName) + : (rec: StoreRecord) => rec.data[fieldName]; } } diff --git a/desktop/cmp/grid/impl/colchooser/ColChooser.ts b/desktop/cmp/grid/impl/colchooser/ColChooser.ts index 29f124da9..f797cabb9 100644 --- a/desktop/cmp/grid/impl/colchooser/ColChooser.ts +++ b/desktop/cmp/grid/impl/colchooser/ColChooser.ts @@ -32,14 +32,14 @@ export const colChooser = hoistCmp.factory({ className: 'xh-col-chooser', render({model, className}) { - const {commitOnChange, showRestoreDefaults, width, height} = model; + const {commitOnChange, showRestoreDefaults, width, height, filterMatchMode} = model; return panel({ className, items: [ leftRightChooser({width, height}), toolbar( - leftRightChooserFilter(), + leftRightChooserFilter({matchMode: filterMatchMode}), filler(), button({ omit: !showRestoreDefaults, diff --git a/desktop/cmp/grid/impl/colchooser/ColChooserModel.ts b/desktop/cmp/grid/impl/colchooser/ColChooserModel.ts index 7995f9589..91dd7ab60 100644 --- a/desktop/cmp/grid/impl/colchooser/ColChooserModel.ts +++ b/desktop/cmp/grid/impl/colchooser/ColChooserModel.ts @@ -4,8 +4,9 @@ * * Copyright © 2026 Extremely Heavy Industries Inc. */ -import {GridModel} from '@xh/hoist/cmp/grid'; +import {ColChooserConfig, GridModel} from '@xh/hoist/cmp/grid'; import {HoistModel, managed} from '@xh/hoist/core'; +import type {FilterMatchMode} from '@xh/hoist/data'; import {LeftRightChooserModel} from '@xh/hoist/desktop/cmp/leftrightchooser'; import {action, makeObservable, observable} from '@xh/hoist/mobx'; import {sortBy} from 'lodash'; @@ -29,8 +30,9 @@ export class ColChooserModel extends HoistModel { commitOnChange: boolean; showRestoreDefaults: boolean; autosizeOnCommit: boolean; - width: number; - height: number; + width: string | number; + height: string | number; + filterMatchMode: FilterMatchMode; constructor({ gridModel, @@ -38,8 +40,9 @@ export class ColChooserModel extends HoistModel { showRestoreDefaults = true, autosizeOnCommit = false, width = !commitOnChange && showRestoreDefaults ? 600 : 520, - height = 300 - }) { + height = 300, + filterMatchMode = 'startWord' + }: ColChooserConfig) { super(); makeObservable(this); @@ -49,6 +52,7 @@ export class ColChooserModel extends HoistModel { this.autosizeOnCommit = autosizeOnCommit; this.width = width; this.height = height; + this.filterMatchMode = filterMatchMode; this.lrModel = new LeftRightChooserModel({ leftTitle: 'Available Columns', diff --git a/desktop/cmp/leftrightchooser/LeftRightChooserFilter.ts b/desktop/cmp/leftrightchooser/LeftRightChooserFilter.ts index 620e696e3..c8c3e9a7d 100644 --- a/desktop/cmp/leftrightchooser/LeftRightChooserFilter.ts +++ b/desktop/cmp/leftrightchooser/LeftRightChooserFilter.ts @@ -4,7 +4,8 @@ * * Copyright © 2026 Extremely Heavy Industries Inc. */ -import {hoistCmp, HoistModel, HoistProps, lookup, useLocalModel, uses} from '@xh/hoist/core'; +import {hoistCmp, HoistModel, HoistProps, lookup, useLocalModel, uses, XH} from '@xh/hoist/core'; +import type {FilterMatchMode} from '@xh/hoist/data'; import {textInput} from '@xh/hoist/desktop/cmp/input'; import '@xh/hoist/desktop/register'; import {Icon} from '@xh/hoist/icon'; @@ -16,8 +17,8 @@ export interface LeftRightChooserFilterProps extends HoistProps this.value, + track: () => [this.value, this.matchMode], run: () => this.runFilter() }); } private runFilter() { - const {fields = ['text', 'group'], anyMatch = false} = this.componentProps; - let searchTerm = escapeRegExp(this.value); - - if (!anyMatch) { - searchTerm = `(^|\\W)${searchTerm}`; - } + const {fields = ['text', 'group']} = this.componentProps, + searchTerm = this.value, + regex = this.getRegex(searchTerm); const filter = raw => { return fields.some(f => { if (!searchTerm) return true; const fieldVal = raw.data[f]; - return fieldVal && new RegExp(searchTerm, 'ig').test(fieldVal); + return fieldVal && regex.test(fieldVal); }); }; this.model.setDisplayFilter(filter); } + private getRegex(searchTerm: string): RegExp { + searchTerm = escapeRegExp(searchTerm); + switch (this.matchMode) { + case 'any': + return new RegExp(searchTerm, 'i'); + case 'start': + return new RegExp(`^${searchTerm}`, 'i'); + case 'startWord': + return new RegExp(`(^|\\W)${searchTerm}`, 'i'); + } + throw XH.exception('Unknown matchMode in StoreFilterField'); + } + override destroy() { // This unusual bit of code is extremely important -- the model we are linking to might // survive the display of this component and should be restored. (This happens with GridColumnChooser)