/* * 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 {HoistBase} from '@xh/hoist/core'; import {Field, FieldFilter, FieldType, FilterValueSource, genDisplayName} from '@xh/hoist/data'; import {compact, isArray, isEmpty} from 'lodash'; import {FieldFilterOperator} from './Types'; /** * Base configuration for field-level filtering options - defines available operators, value * enumeration, and display metadata. Not used directly by applications; extended by * {@link GridFilterFieldSpecConfig} (for column-header filters via {@link GridFilterModelConfig}) * and {@link FilterChooserFieldSpecConfig} (for {@link FilterChooserModel}). * * @see GridFilterFieldSpec * @see FilterChooserFieldSpec */ export interface BaseFilterFieldSpecConfig { /** Identifying field name to filter on. */ field: string; /** * Type of field, will default from related field on source if provided, or 'auto'. A `date` * (timestamp) source defaults to 'localDate' so filtering compares by calendar day (range and * equality operators use full-day bounds). Set explicitly to 'date' to filter by exact * timestamp instead. */ fieldType?: FieldType; /** DisplayName, will default from related field on source if provided */ displayName?: string; /** Operators available for filtering, will default to a supported set based on type.*/ ops?: FieldFilterOperator[]; /** Used to source matching data `Field` and extract values if configured. */ source?: FilterValueSource; /** * True to provide interfaces and auto-complete options * with enumerated matches for creating '=' or '!=' filters. Defaults to true for * enumerable fieldTypes. Always true if 'values' provided or if fieldType is BOOL. */ enableValues?: boolean; /** * True to require value entered to be an available value for '=' and '!=' operators. * Defaults to false. */ forceSelection?: boolean; /** Explicit list of available values for this field. */ values?: any[]; } /** * Defines field-level filtering options and provides metadata for presenting these options in * UI affordances such as FilterChooser to Grid Column Filters. * * @see FilterChooserFieldSpec * @see GridFilterFieldSpec */ export abstract class BaseFilterFieldSpec extends HoistBase { field: string; fieldType: FieldType; displayName: string; ops: FieldFilterOperator[]; source: FilterValueSource; enableValues: boolean; forceSelection: boolean; values: any[]; hasExplicitValues: boolean; constructor({ field, fieldType, displayName, ops, source, enableValues, forceSelection, values }: BaseFilterFieldSpecConfig) { super(); this.field = field; this.source = source; const sourceField = this.sourceField; // Default a `date` (timestamp) source to `localDate` so filtering compares by calendar day // rather than against midnight (#3338). Apps wanting exact-timestamp filtering can set // `fieldType: 'date'` explicitly. this.fieldType = fieldType ?? (sourceField?.type === 'date' ? 'localDate' : sourceField?.type) ?? 'auto'; this.displayName = displayName ?? sourceField?.displayName ?? genDisplayName(field); this.ops = this.parseOperators(ops); this.forceSelection = forceSelection ?? false; this.values = isArray(values) ? compact(values) : this.isBoolFieldType ? [true, false] : null; this.hasExplicitValues = !isEmpty(this.values); this.enableValues = this.hasExplicitValues || (enableValues ?? this.isEnumerableByDefault); } /** Full Field derived from source. */ get sourceField(): Field { return this.source?.getField(this.field); } /** * Determines operations supported by this field. * * Type 'range' indicates the field should use mathematical / logical operations: * `(>, >=, <, <=, =, !=)`. * * Type 'value' indicates the field should use equality operators: * `(=, !=, like, not like, begins, ends)` * against a suggested exact value or user-provided input. */ get filterType(): 'range' | 'value' | 'collection' { switch (this.fieldType) { case 'int': case 'number': case 'date': case 'localDate': return 'range'; case 'tags': return 'collection'; default: return 'value'; } } get isRangeType(): boolean { return this.filterType === 'range'; } get isValueType(): boolean { return this.filterType === 'value'; } get isCollectionType(): boolean { return this.filterType === 'collection'; } get isDateBasedFieldType(): boolean { const {fieldType} = this; return fieldType === 'date' || fieldType === 'localDate'; } get isNumericFieldType(): boolean { const {fieldType} = this; return fieldType === 'int' || fieldType === 'number'; } get isBoolFieldType(): boolean { return this.fieldType === 'bool'; } loadValues() { if (!this.hasExplicitValues && this.enableValues) { this.loadValuesFromSource(); } } supportsOperator(op: FieldFilterOperator): boolean { return this.ops.includes(op); } supportsSuggestions(op: FieldFilterOperator): boolean { return ( this.values && this.enableValues && this.supportsOperator(op) && (op === '=' || op === '!=' || op === 'includes' || op === 'excludes') ); } //------------------------ // Abstract //------------------------ abstract loadValuesFromSource(); //------------------------ // Implementation //------------------------ private parseOperators(ops: any): FieldFilterOperator[] { ops = ops ?? this.getDefaultOperators(); return ops.filter(it => FieldFilter.OPERATORS.includes(it)); } private getDefaultOperators(): FieldFilterOperator[] { if (this.isBoolFieldType) return ['=']; if (this.isCollectionType) return ['includes', 'excludes']; return this.isValueType ? ['=', '!=', 'like', 'not like', 'begins', 'ends'] : ['>', '>=', '<', '<=', '=', '!=']; } private get isLocalDateFilteringTimestamp(): boolean { return this.fieldType === 'localDate' && this.sourceField?.type === 'date'; } private get isEnumerableByDefault(): boolean { if (this.isLocalDateFilteringTimestamp) return false; switch (this.fieldType) { case 'int': case 'number': case 'date': return false; default: return true; } } }