/* * This file belongs to Hoist, an application development toolkit * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) * * Copyright © 2026 Extremely Heavy Industries Inc. */ import { HoistModel, managed, PersistableState, PersistenceProvider, persistOptions, PersistOptions, TaskObserver, Thunkable, XH } from '@xh/hoist/core'; import { appendFilter, CompoundFilter, FieldFilter, Filter, FilterBindTarget, FilterValueSource, isFilterValueSource, parseFilter } from '@xh/hoist/data'; import {CompoundFilterSpec, FieldFilterSpec, FilterLike} from '@xh/hoist/data/filter/Types'; import {action, makeObservable, observable} from '@xh/hoist/mobx'; import {wait} from '@xh/hoist/promise'; import {executeIfFunction, throwIf, withDefault} from '@xh/hoist/utils/js'; import {createObservableRef} from '@xh/hoist/utils/react'; import { cloneDeep, compact, every, first, flatMap, flatten, forEach, groupBy, isArray, isEmpty, isFinite, isObject, isString, map, partition, sortBy, uniq, uniqBy } from 'lodash'; import {ReactNode} from 'react'; import {FilterChooserFieldSpec, FilterChooserFieldSpecConfig} from './FilterChooserFieldSpec'; import {compoundFilterOption, fieldFilterOption, FilterChooserOption} from './impl/Option'; import {QueryEngine} from './impl/QueryEngine'; /** * Configuration for a {@link FilterChooserModel} - an interactive, tokenized filter builder * that binds to a {@link Store} or Cube {@link View}. * * @see FilterChooserModel */ export interface FilterChooserConfig { /** * Specifies the fields this model supports for filtering and customizes how their available values * will be parsed and displayed. If a `valueSource` is provided, these may be specified as field * names in that source or omitted entirely, indicating that all fields should be filter-enabled. */ fieldSpecs?: Array; /** Default properties to be assigned to all FilterChooserFieldSpecs created by this model. */ fieldSpecDefaults?: Partial; /** * Target (typically a {@link Store} or Cube {@link View}) to which this model's filter should * be automatically applied as it changes. * * Note this binding is bi-directional - the target's filter will also be *set onto* this model * if it changes on the target, to support e.g. sync'd filtering between a FilterChooser and * Grid filtering bound to the same target Store. * * Leave undefined if you wish to combine this model's values with other filters, send it to * the server, or otherwise observe and handle value changes manually. */ bind?: FilterBindTarget; /** * Source (typically a {@link Store} or Cube {@link View}) from which this model can lookup * matching Field-level defaults for `fieldSpecs` and provide suggested data values (if so * configured) from user input. * * Defaults to {@link bind} if the a bind target is provided and is a valid source. */ valueSource?: FilterValueSource; /** * Configuration for a filter appropriate to be rendered and managed by FilterChooser, or a function * to produce the same. Note that FilterChooser currently can only edit and create a flat collection * of FieldFilters, to be 'AND'ed together. */ initialValue?: Thunkable; /** * Initial favorites as an array of filter configurations, or a function to produce such an array. */ initialFavorites?: Thunkable; /** * true to offer all field suggestions when the control is focused with an empty query, * to aid discoverability. */ suggestFieldsWhenEmpty?: boolean; /** * true (default) to sort field suggestions by displayed label. Set to false to preserve * the order provided to `fieldSpecs`. */ sortFieldSuggestions?: boolean; /** * Maximum number of filter tags to render before disabling the control. * Limits the performance impact of rendering large filters. */ maxTags?: number; /** Maximum number of dropdown options to show before truncating. */ maxResults?: number; /** * Blurb displayed above field suggestions when the control is focused but user has yet * to enter a query, or null to suppress default. */ introHelpText?: ReactNode; /** Options governing persistence. */ persistWith?: FilterChooserPersistOptions; } /** * Model for a Select-based filter control that allows users to search for and compose filters * across multiple data fields. * * Manages the current filter value, user-managed favorites, and available field specs. Supports * bidirectional binding to a {@link Store} or Cube {@link View} via the `bind` config - filters * are automatically applied to the target as they change, and external filter changes on the * target are reflected back into this model. * * Field specs define which fields are available for filtering and how their values are parsed * and displayed. If a `valueSource` is provided, field specs can be auto-populated from the * source's fields. * * Supports persistence of both the current filter value and favorites via `persistWith`. * * @see FilterChooser * @see FilterChooserFieldSpec */ export class FilterChooserModel extends HoistModel { @observable.ref value: FilterChooserFilter = null; @observable.ref favorites: FilterChooserFilter[] = []; bind: FilterBindTarget; valueSource: FilterValueSource; @managed fieldSpecs: FilterChooserFieldSpec[] = []; suggestFieldsWhenEmpty: boolean; sortFieldSuggestions: boolean; maxTags: number; maxResults: number; introHelpText: ReactNode; persistFavorites: boolean = false; /** Tracks execution of filtering operation on bound object.*/ @managed filterTask = TaskObserver.trackAll(); // Implementation fields for Control @managed queryEngine: QueryEngine; @observable.ref selectOptions: FilterChooserOption[]; @observable.ref selectValue: string[]; @observable favoritesIsOpen = false; @observable unsupportedFilter = false; inputRef = createObservableRef(); get tagCount(): number { return this.selectValue?.length ?? 0; } constructor({ fieldSpecs, fieldSpecDefaults, bind = null, valueSource, initialValue = null, initialFavorites = [], suggestFieldsWhenEmpty = true, sortFieldSuggestions = true, maxTags = 100, maxResults = 50, persistWith, introHelpText }: FilterChooserConfig = {}) { super(); makeObservable(this); this.bind = bind; this.valueSource = valueSource; if (!this.valueSource && isFilterValueSource(bind)) { this.valueSource = bind; } this.fieldSpecs = this.parseFieldSpecs(fieldSpecs, fieldSpecDefaults); this.suggestFieldsWhenEmpty = !!suggestFieldsWhenEmpty; this.sortFieldSuggestions = sortFieldSuggestions; this.maxTags = maxTags; this.maxResults = maxResults; this.introHelpText = withDefault(introHelpText, this.getDefaultIntroHelpText()); this.queryEngine = new QueryEngine(this); this.setValueInternal(executeIfFunction(initialValue), false); this.setFavorites(executeIfFunction(initialFavorites)); if (persistWith) this.initPersist(persistWith); this.updateSelectValueAndBind(); if (bind) { // Inbound sync: when the bind target's filter changes externally (e.g. via // Grid column filters on the same Store), update this model's value to match. // FunctionFilters are stripped as they are unsupported by FilterChooser. this.addReaction({ track: () => bind.filter, run: filter => { this.setValue(filter?.removeFunctionFilters()); } }); } } /** * Set the value displayed by this control. * * Supports one or more FieldFilters to be 'AND'ed together, or * an 'AND' CompoundFilter containing such a collection of FieldFilters. * * 'OR' CompoundFilters or nested CompoundFilters are partially supported - * they will be displayed as tags and can be removed, but not created using * the control. * * Any other Filter is unsupported and will cause the control to show a placeholder error. */ setValue(rawValue: FilterLike) { this.setValueInternal(rawValue, true); } //--------------------------- // Value Handling/Processing //--------------------------- setSelectValue(selectValue: string[]) { // Rehydrate stringified values const parsedValues = compact(flatten(selectValue)).map(it => JSON.parse(it)); // Separate actual selected filters from field suggestion. // (the former is just a transient value on the select control only) const [filters, suggestions] = partition(parsedValues, 'op'); // Round-trip actual filters through main value setter above. this.setValue(this.toValueFilter(filters)); // And then programmatically re-enter any suggestion if (suggestions.length === 1) this.autoComplete(suggestions[0]); } //------------- // Querying //--------------- async queryAsync(query: string): Promise { return this.queryEngine.queryAsync(query); } //-------------------- // Autocomplete //-------------------- autoComplete(value) { const rsSelectCmp = (this.inputRef.current as any)?.reactSelectRef?.current; if (!rsSelectCmp) return; // Push the suggestion text back into the Select's filter input, then re-open the menu. const currentVal = (rsSelectCmp.props?.inputValue as string) ?? '', newVal = value.displayName, inputValue = newVal.length > currentVal.length ? newVal : currentVal; rsSelectCmp.props.onInputChange(inputValue, { action: 'input-change', prevInputValue: currentVal }); wait() .then(() => { rsSelectCmp.focus(); rsSelectCmp.openMenu('first'); }) .thenAction(() => { // Force-resync our bound selectValue in case state manager nudged react-select's // internal selection. Not via setSelectValue() (early-returns when unchanged). this.selectValue = cloneDeep(this.selectValue); }); } //--------------------------------- // Options //--------------------------------- createFilterOption(filter: Filter): FilterChooserOption { if (filter instanceof FieldFilter) { return fieldFilterOption({filter, fieldSpec: this.getFieldSpec(filter.field)}); } else if (filter instanceof CompoundFilter) { const fieldNames = uniq( filter.filters.map(it => this.getFieldSpec((it as FieldFilter).field)?.displayName) ); return compoundFilterOption({filter, fieldNames}); } } //-------------------- // Favorites //-------------------- get favoritesOptions() { return this.favorites.map(value => ({ value, filterOptions: this.toDisplayFilters(value).map(f => this.createFilterOption(f)) })); } @action openFavoritesMenu() { this.favoritesIsOpen = true; } @action closeFavoritesMenu() { this.favoritesIsOpen = false; } @action setFavorites(favorites: FilterChooserFilterLike[]) { this.favorites = favorites.map(parseFilter).filter(this.validateFilter.bind(this)); } @action addFavorite(filter: FilterChooserFilter) { if (isEmpty(filter) || this.isFavorite(filter)) return; this.favorites = [...this.favorites, filter]; } @action removeFavorite(filter: FilterChooserFilter) { this.favorites = this.favorites.filter(f => !f.equals(filter)); } findFavorite(filter: FilterChooserFilter): Filter { return this.favorites?.find(f => f.equals(filter)); } isFavorite(filter: FilterChooserFilter): boolean { return !!this.findFavorite(filter); } //-------------------------------- // FilterChooserFieldSpec handling //-------------------------------- parseFieldSpecs( specs: Array, fieldSpecDefaults: Partial ): Array { const {valueSource} = this; throwIf( !valueSource && (!specs || specs.some(isString)), 'Must provide a valueSource if fieldSpecs are not provided, or provided as strings.' ); // If no specs provided, include all source fields. if (!specs) specs = valueSource.fieldNames; return specs.map(spec => { if (isString(spec)) spec = {field: spec}; return new FilterChooserFieldSpec({ source: valueSource, ...fieldSpecDefaults, ...spec }); }); } getFieldSpec(fieldName: string): FilterChooserFieldSpec { return this.fieldSpecs.find(it => it.field === fieldName); } validateFilter(f: Filter): f is FilterChooserFilter { if (f === null) return true; if (f instanceof FieldFilter) { if (!this.getFieldSpec(f.field)) { this.logError(`Invalid FieldFilter - no fieldSpec configured for ${f.field}.`); return false; } return true; } if (f instanceof CompoundFilter) { return f.filters.every(it => this.validateFilter(it)); } this.logError('Invalid Filter - unhandled type', f); return false; } getDefaultIntroHelpText(): string { return 'Select or enter a field name (below) or begin typing to match available field values.'; } // ------------------------------- // Implementation // ------------------------------- // Take the raw flat displayed FieldFilter specs and combine them with appropriate semantics // into a proper Filter for the value of ths model. Field Filters on the same field are going // to be combined into a FieldFilter with array values as well as potentially appropriate // compound filters to combine different ops. See toDisplayFilters() for the inverse of this // operation. private toValueFilter(specs: FieldFilterSpec[] = []): FilterLike { const ret: FilterLike[] = []; // group filters by field -- we'll produce up to two ANDable filters per field const fieldMap = groupBy(specs, 'field'); forEach(fieldMap, specs => { // a) combine filters with SAME operator in to a single FieldFilter const opMap = groupBy(specs, 'op'); specs = flatMap(opMap, specs => { const firstSpec = first(specs); return specs.length > 1 && FieldFilter.ARRAY_OPERATORS.includes(firstSpec.op) ? {...firstSpec, value: map(specs, 'value')} : specs; }); // Process like operators together, potentially creating sub-OR clauses. [ FieldFilter.INCLUDE_LIKE_OPERATORS, FieldFilter.EXCLUDE_LIKE_OPERATORS, FieldFilter.RANGE_LIKE_OPERATORS ].forEach(type => { const filters = specs.filter(s => type.includes(s.op)); if (this.implicitOrFieldFilters(filters)) { ret.push([{op: 'OR', filters}]); } else { ret.push(...filters); } }); }); return ret; } // Transfer the value filter to the canonical set of individual filters for display. // See toValueFilter() for the inverse of this operation. private toDisplayFilters(filter: Filter): Filter[] { if (!filter) return []; const unsupported = s => { throw XH.exception(`Unsupported Filter in FilterChooserModel: ${s}`); }; let ret: Filter[] = [filter]; // 0) Can always unwind the top level AND -- its implicit. if (filter instanceof CompoundFilter && filter.op == 'AND') { ret = filter.filters; } // 1) Further flatten 2nd-Level CompoundFilters to FieldFilters. // OR'ed filters on the same field can be decomposed if they will later be re-combined ret = ret.flatMap(f => { return f instanceof CompoundFilter && (f.op == 'AND' || (f.field && this.implicitOrFieldFilters(f.filters as FieldFilter[]))) ? f.filters : f; }); // 2) Recognize misc unsupported filters. if (!ret.every(f => f instanceof FieldFilter || f instanceof CompoundFilter)) { unsupported('Filters must be FieldFilters or CompoundFilters.'); } const fieldFilters = ret.filter(it => it instanceof FieldFilter) as FieldFilter[]; const groupMap = groupBy(fieldFilters, ({op, field}) => `${op}|${field}`); forEach(groupMap, filters => { const {op} = filters[0]; if (filters.length > 1 && FieldFilter.ARRAY_OPERATORS.includes(op)) { unsupported(`Multiple filters cannot be provided with ${op} operator`); } }); // 3) Finally unroll multi-value filters to one filter per value. return flatMap(ret, f => { return f instanceof FieldFilter && isArray(f.value) ? f.value.map(value => new FieldFilter({...f, value})) : f; }); } // Should Field Filters on a particular Field be implicitly OR'ed together? private implicitOrFieldFilters(filters: Array): boolean { const {INCLUDE_LIKE_OPERATORS, RANGE_LIKE_OPERATORS} = FieldFilter; if (filters.length < 2) return false; // For INCLUDE_LIKE, treat them like "equals" and OR them if (every(filters, f => INCLUDE_LIKE_OPERATORS.includes(f.op))) return true; // For RANGE_LIKE, recognize simple "exterior" bifurcated range as an OR, otherwise AND if (filters.length == 2 && every(filters, f => RANGE_LIKE_OPERATORS.includes(f.op))) { const [a, b] = sortBy(filters, 'op'); return a.op.startsWith('<') && b.op.startsWith('>') && a.value <= b.value; } return false; } private initPersist({ persistValue = true, persistFavorites = true, path = 'filterChooser', ...rootPersistWith }: FilterChooserPersistOptions) { if (persistValue) { const status = {initialized: false}; PersistenceProvider.create({ persistOptions: persistOptions( {path: `${path}.value`}, rootPersistWith, isObject(persistValue) ? persistValue : null ), target: { getPersistableState: () => new PersistableState(this.value?.toJSON() ?? null), setPersistableState: ({value}) => this.setValueInternal(value, status.initialized) }, owner: this }); status.initialized = true; } if (persistFavorites) { const provider = PersistenceProvider.create({ persistOptions: persistOptions( {path: `${path}.favorites`}, rootPersistWith, isObject(persistFavorites) ? persistFavorites : null ), target: { getPersistableState: () => new PersistableState(this.favorites.map(f => f.toJSON())), setPersistableState: ({value}) => this.setFavorites(value) }, owner: this }); if (provider) this.persistFavorites = true; } } @action private setValueInternal(rawValue: FilterLike, updateSelectValueAndBind: boolean) { const {maxTags} = this; try { const value = parseFilter(rawValue); if (this.value?.equals(value)) return; // 1) Ensure FilterChooser can handle the requested value. const isValid = this.validateFilter(value), displayFilters = isValid ? this.toDisplayFilters(value) : null; this.unsupportedFilter = !isValid || (maxTags && displayFilters.length > maxTags); if (this.unsupportedFilter) { this.value = null; this.selectOptions = null; this.selectValue = null; return; } // 2) Main path - filter has been validated as supported, set internal value. this.logDebug('Setting value', value); this.value = value as FilterChooserFilter; // 3) Set props on select input needed to display // Build list of options, used for displaying tags. We combine the needed // options for the current filter tags with any previous ones to ensure // tags are rendered correctly throughout the transition. const newOptions = displayFilters.map(f => this.createFilterOption(f)), previousOptions = this.selectOptions ?? [], options = uniqBy([...newOptions, ...previousOptions], 'value'); this.selectOptions = !isEmpty(options) ? options : null; if (updateSelectValueAndBind) this.updateSelectValueAndBind(displayFilters); } catch (e) { this.logError('Failed to set value', e); this.value = null; this.selectOptions = null; this.selectValue = null; this.unsupportedFilter = true; } } /** * Update the select value and bind the filter to the bound model. Runs asynchronously after * selectOptions are set to ensure the component is ready to render the tags correctly. */ private updateSelectValueAndBind(displayFilters = this.toDisplayFilters(this.value)) { const {bind, value} = this; wait() .thenAction(() => { // No-op if we've already re-entered this method by the time this async routine runs. if (this.value !== value) { return; } this.selectValue = sortBy( displayFilters.map(f => JSON.stringify(f)), f => { const idx = this.selectValue?.indexOf(f); return isFinite(idx) && idx > -1 ? idx : displayFilters.length; } ); // Outbound sync: replace the FieldFilter portion of the bind target's // filter with this model's value, preserving any FunctionFilters // installed by other components (e.g. StoreFilterField). if (bind) { const filter = appendFilter(bind.filter?.removeFieldFilters(), value); bind.setFilter(filter); } }) .linkTo(this.filterTask); } } interface FilterChooserPersistOptions extends PersistOptions { /** True (default) to include value or provide value-specific PersistOptions. */ persistValue?: boolean | PersistOptions; /** True (default) to include favorites or provide favorites-specific PersistOptions. */ persistFavorites?: boolean | PersistOptions; } /** A variant of {@link Filter} that excludes FunctionFilter (unsupported by FilterChooser). */ export type FilterChooserFilter = CompoundFilter | FieldFilter; export type FilterChooserFilterSpec = CompoundFilterSpec | FieldFilterSpec; /** A variant of {@link FilterLike} that excludes FunctionFilters and FilterTestFn. */ export type FilterChooserFilterLike = | FilterChooserFilter | FilterChooserFilterSpec | FilterChooserFilterLike[];