import { ColumnType, DateComparisonOptions, FilterCondition, IAttachment, ICellData, ILookupTypeOptions, ITypeOptions, Logger, OperatorType, } from '../types'; import { exhaustiveSwitch, exhaustiveSwitchThrow, getFinalColumnType, getIsEmpty, isNotNullable, } from '../typeUtils'; import { isAnyDayAfter, isAnyDayBefore, isAnyDayEqualOrAfter, isAnyDayEqualOrBefore, isSameDay, } from './helpers/date'; import { DATE_COMPARISON_VALUE, FILE_TYPE_FILTERS_EXTENSIONS, SHOW_DATE_DATE_INPUT, SHOW_DATE_NUMBER_INPUT, } from './constants'; import { IAdaptedFilterCtrl, ITableColumn, TypedFilterCondition, } from './types'; import { formatISO, isValid, sub } from 'date-fns'; import every from 'lodash/every'; import get from 'lodash/get'; import intersection from 'lodash/intersection'; import isEqual from 'lodash/isEqual'; import isFinite from 'lodash/isFinite'; import some from 'lodash/some'; interface IAdaptedFilter { columnType: ColumnType; columnId: string; condition: FilterCondition; filter: unknown; lookupTypeOptions?: ILookupTypeOptions; } export default class BooleanFilter { private filterModel: IAdaptedFilterCtrl[] | null = null; private groups: IAdaptedFilter[][] = []; private meId: string | null = null; private logger: Logger | null = null; private shouldIgnoreLookup?: boolean; public setModel( model: IAdaptedFilterCtrl[], tableColumnsById: { [id: string]: ITableColumn | undefined }, meId?: string, logger?: Logger, shouldIgnoreLookup?: boolean ) { /** * After `gridApi.setFilterModel(model)` is called, this is first executed. Afterwards, `doesFilterPass` * is called. The grid will pass undefined/null to clear the filter. * * If the operator is 'OR', then we put it in a nested array ex. `[[{}], [{}], [{}]]` * If the operator is 'AND', then we will group it ex. `[[{}, {}, {}]]` */ this.filterModel = model; this.groups = []; if (!model) return; if (meId) this.meId = meId; if (logger) this.logger = logger; this.shouldIgnoreLookup = shouldIgnoreLookup; const orFilters = this.filterModel.filter( (item) => item.operatorType === OperatorType.OR ); const andFilters = this.filterModel.filter( (item) => item.operatorType === OperatorType.AND ); if (orFilters.length) { this.groups.push( orFilters .map((filterEntry) => this.getAdaptedFilter( filterEntry, tableColumnsById[filterEntry.columnId] ) ) .filter(isNotNullable) ); } andFilters.forEach((filterEntry) => { const adaptedFilter = this.getAdaptedFilter( filterEntry, tableColumnsById[filterEntry.columnId] ); if (adaptedFilter) this.groups.push([adaptedFilter]); }); } // The grid will ask each active filter, in turn, whether each row in the grid passes. If any // filter fails, then the row will be excluded from the final set. A params object is supplied // with attributes node (the rowNode the grid creates that wraps the data) and data (the data // object that you provided to the grid for that row). public doesFilterPass({ data }: { data: ICellData }) { const passesFilters = (() => { if ( this.groups.length === 0 || (this.groups.length === 1 && !this.groups[0].length) ) { return true; } return every(this.groups, (group) => some( group, ({ columnType, columnId, condition, filter, lookupTypeOptions, }: IAdaptedFilter) => { const columnValue = Object.prototype.hasOwnProperty.call( data, columnId ) ? data[columnId] : null; return this.checkFilterValue( columnType, condition, columnValue, filter, lookupTypeOptions ); } ) ); })(); return passesFilters; } private getAdaptedFilter( filterEntry: IAdaptedFilterCtrl, column: ITableColumn | undefined ): IAdaptedFilter | undefined { if (!column) return; /** * 1. Clean the filters. We return false for any filters that are not applicable. This is colType specific * 2. Adapt column specific filters, ex. date needs to be adapted. */ const { filterCondition, filterString, attachmentFileTypeOptions, filterByMe, } = filterEntry; const isEmpty = getIsEmpty(filterString); let adaptedFilter; const typeOptions = { ...column.typeOptions, type: column.type }; const type = getFinalColumnType( typeOptions as ITypeOptions, this.shouldIgnoreLookup ?? true ); try { if ( filterCondition !== FilterCondition.IS_EMPTY && filterCondition !== FilterCondition.IS_NOT_EMPTY ) { switch (type) { case ColumnType.DATETIME: case ColumnType.CREATED_AT: const dateFilterValue = this.getFilterValueFromDate( filterEntry, filterString, isEmpty ); if (!dateFilterValue) return; adaptedFilter = dateFilterValue; break; case ColumnType.NUMBER: case ColumnType.CURRENCY: case ColumnType.RATING: case ColumnType.AUTO_NUMBER: if (isEmpty) return; adaptedFilter = Number(filterString); break; case ColumnType.TEXT: case ColumnType.PHONE: case ColumnType.LONG_TEXT: case ColumnType.UNIQUE_ID: case ColumnType.RECORD_REFERENCE: case ColumnType.SUBTABLE: case ColumnType.LOOKUP: if (isEmpty) return; adaptedFilter = filterString.toString().toLocaleLowerCase(); break; case ColumnType.CHECKBOX: adaptedFilter = filterString === 'true' || filterString === true; break; case ColumnType.MULTI_ATTACHMENT: if (filterCondition === FilterCondition.FILE_NAME && isEmpty) return; adaptedFilter = filterCondition === FilterCondition.FILE_TYPE ? attachmentFileTypeOptions : filterString.toString().toLowerCase(); break; case ColumnType.COLLABORATOR: case ColumnType.CREATED_BY: if (isEmpty && !filterByMe) return; adaptedFilter = [...(filterString as any)]; if (filterByMe && !adaptedFilter.includes(this.meId)) { adaptedFilter.push(this.meId); } break; case ColumnType.EMAIL: case ColumnType.FORMULA: case ColumnType.PROGRESS: case ColumnType.INTEGRATION_REFERENCE: case ColumnType.MULTI_SELECT: case ColumnType.NOOP: case ColumnType.ROLLUP: case ColumnType.SELECT: case ColumnType.STATUS: if (isEmpty) return; adaptedFilter = filterString; break; default: return exhaustiveSwitch({ switchValue: type, }); } } } catch (e) { if (this.logger) this.logger.error(e); return; } return { condition: filterCondition, columnType: type, columnId: column.id, filter: adaptedFilter, lookupTypeOptions: type === ColumnType.LOOKUP ? (column.typeOptions as ILookupTypeOptions) : undefined, }; } private getFilterValueFromDate( filterEntry: IAdaptedFilterCtrl, value: any, isEmpty: boolean ) { /** * For date time there are 4 special types, we will convert them all to filter values. * 1. For Intersect types (two dates) we accept it and let it go to next phase * 2. Preset option like "one week ago". * 3. A Number is typed like "5", in which case "# days ..." is selected * 4. A date is selected from a calendar UI, in which case "exact date" is selected */ // if (filterEntry.filterCondition === DateFiltersType.INTERSECTS) return value; if (filterEntry.dateComparisonOptions) { const dateCompFn = DATE_COMPARISON_VALUE[filterEntry.dateComparisonOptions]; if (dateCompFn) return dateCompFn(); if ( !isEmpty && SHOW_DATE_NUMBER_INPUT[filterEntry.dateComparisonOptions] ) { const numberValue = Number(value); return !isFinite(numberValue) ? null : formatISO( sub(new Date(), { days: (filterEntry.dateComparisonOptions === DateComparisonOptions.NUM_DAYS_AGO ? 1 : -1) * Number(value), }) ); } if (!isEmpty && SHOW_DATE_DATE_INPUT[filterEntry.dateComparisonOptions]) { return isValid(new Date(value)) ? formatISO(new Date(value)) : null; } } return null; } private checkFilterValue( columnType: ColumnType, condition: string, columnValue: any, filterValue: any, lookupTypeOptions?: ILookupTypeOptions ): boolean { switch (columnType) { case ColumnType.TEXT: case ColumnType.PHONE: case ColumnType.EMAIL: case ColumnType.LONG_TEXT: case ColumnType.UNIQUE_ID: { const textValues = Array.isArray(columnValue) ? columnValue : [columnValue]; const tesxtFilterConditions = condition as TypedFilterCondition< typeof columnType >; return textValues.some((value) => { const adaptedComp = value && value.toString().toLowerCase(); switch (tesxtFilterConditions) { case FilterCondition.CONTAINS: return adaptedComp && adaptedComp.includes(filterValue); case FilterCondition.ENDS_WITH: return adaptedComp && adaptedComp.endsWith(filterValue); case FilterCondition.EQUALS: return adaptedComp === filterValue; case FilterCondition.IS_EMPTY: return getIsEmpty(adaptedComp); case FilterCondition.IS_NOT_EMPTY: return !getIsEmpty(adaptedComp); case FilterCondition.NOT_CONTAINS: return !adaptedComp || !adaptedComp.includes(filterValue); case FilterCondition.NOT_EQUAL: return !adaptedComp || adaptedComp !== filterValue; case FilterCondition.STARTS_WITH: return adaptedComp && adaptedComp.startsWith(filterValue); default: return exhaustiveSwitchThrow({ switchValue: tesxtFilterConditions, }); } }); } case ColumnType.RATING: case ColumnType.CURRENCY: case ColumnType.AUTO_NUMBER: case ColumnType.NUMBER: { const numberValues = Array.isArray(columnValue) ? columnValue : [columnValue]; const isEmpty = getIsEmpty(columnValue); const numberFilterConditions = condition as TypedFilterCondition< typeof columnType >; return numberValues.some((value) => { const numberValue = Number(value); switch (numberFilterConditions) { case FilterCondition.EQUALS: return !isEmpty && numberValue === filterValue; case FilterCondition.NOT_EQUAL: return isEmpty || numberValue !== filterValue; case FilterCondition.LESS_THAN: return !isEmpty && numberValue < filterValue; case FilterCondition.LESS_THAN_OR_EQUAL: return !isEmpty && numberValue <= filterValue; case FilterCondition.GREATER_THAN: return !isEmpty && numberValue > filterValue; case FilterCondition.GREATER_THAN_OR_EQUAL: return !isEmpty && numberValue >= filterValue; case FilterCondition.IS_EMPTY: return isEmpty; case FilterCondition.IS_NOT_EMPTY: return !isEmpty; default: return exhaustiveSwitchThrow({ switchValue: numberFilterConditions, }); } }); } case ColumnType.DATETIME: case ColumnType.CREATED_AT: { const columnValues = Array.isArray(columnValue) ? columnValue : [columnValue]; const dateFilterConditions = condition as TypedFilterCondition< typeof columnType >; return columnValues.some((columnValue) => { const columnDateValue = new Date(columnValue); switch (dateFilterConditions) { case FilterCondition.EQUALS: return isValid(columnDateValue) ? isSameDay(columnValue, filterValue) : filterValue === null; case FilterCondition.NOT_EQUAL: return isValid(columnDateValue) ? !isSameDay(columnValue, filterValue) : filterValue !== null; case FilterCondition.LESS_THAN: return ( isValid(columnDateValue) && isAnyDayBefore(columnValue, filterValue) ); case FilterCondition.LESS_THAN_OR_EQUAL: return ( isValid(columnDateValue) && isAnyDayEqualOrBefore(columnValue, filterValue) ); case FilterCondition.GREATER_THAN: return ( isValid(columnDateValue) && isAnyDayAfter(columnValue, filterValue) ); case FilterCondition.GREATER_THAN_OR_EQUAL: return ( isValid(columnDateValue) && isAnyDayEqualOrAfter(columnValue, filterValue) ); case FilterCondition.IS_EMPTY: return getIsEmpty(columnValue); case FilterCondition.IS_NOT_EMPTY: return !getIsEmpty(columnValue); default: return exhaustiveSwitchThrow({ switchValue: dateFilterConditions, }); } }); } case ColumnType.SELECT: case ColumnType.CREATED_BY: { /** Converting everything to arrays make it a lot easier for us to filter */ const columnIds = !columnValue ? [] : Array.isArray(columnValue) ? columnValue : [columnValue]; /** * The filterValue may be a read-only array, which may cause data read and write errors in a strict environment. * Through deconstruction, converted into a normal array */ const filterIds = filterValue === null ? [] : Array.isArray(filterValue) ? [...filterValue] : [filterValue]; const selectFilterConditions = condition as TypedFilterCondition< typeof columnType >; switch (selectFilterConditions) { case FilterCondition.EQUALS: return isEqual(columnIds.sort(), filterIds.sort()); case FilterCondition.HAS_ALL_OF: return ( columnIds.length > 0 && filterIds.every((id) => columnIds.includes(id)) ); case FilterCondition.NOT_EQUAL: return !isEqual(columnIds.sort(), filterIds.sort()); case FilterCondition.EQUALS_ANY_OF: case FilterCondition.HAS_ANY_OF: return ( columnIds.length > 0 && filterIds.some((id) => columnIds.includes(id)) ); case FilterCondition.EQUALS_NONE_OF: case FilterCondition.HAS_NONE_OF: return !( columnIds.length > 0 && filterIds.some((id) => columnIds.includes(id)) ); case FilterCondition.IS_EMPTY: return getIsEmpty(columnValue); case FilterCondition.IS_NOT_EMPTY: return !getIsEmpty(columnValue); default: exhaustiveSwitchThrow({ switchValue: selectFilterConditions, }); } break; } case ColumnType.STATUS: { /** Converting everything to arrays make it a lot easier for us to filter */ const columnIds = !columnValue ? [] : Array.isArray(columnValue) ? columnValue : [columnValue]; /** * The filterValue may be a read-only array, which may cause data read and write errors in a strict environment. * Through deconstruction, converted into a normal array */ const filterIds = filterValue === null ? [] : Array.isArray(filterValue) ? [...filterValue] : [filterValue]; const statusFilterConditions = condition as TypedFilterCondition< typeof columnType >; switch (statusFilterConditions) { case FilterCondition.HAS_ALL_OF: return ( columnIds.length > 0 && filterIds.every((id) => columnIds.includes(id)) ); case FilterCondition.HAS_ANY_OF: return ( columnIds.length > 0 && filterIds.some((id) => columnIds.includes(id)) ); case FilterCondition.IS_EMPTY: return getIsEmpty(columnValue); case FilterCondition.IS_NOT_EMPTY: return !getIsEmpty(columnValue); case FilterCondition.HAS_NONE_OF: return !( columnIds.length > 0 && filterIds.some((id) => columnIds.includes(id)) ); case FilterCondition.EQUALS: return isEqual(columnIds.sort(), filterIds.sort()); case FilterCondition.NOT_EQUAL: return !isEqual(columnIds.sort(), filterIds.sort()); case FilterCondition.EQUALS_ANY_OF: return columnIds.length > 0 && filterIds.includes(columnIds[0]); case FilterCondition.EQUALS_NONE_OF: return !(columnIds.length > 0 && filterIds.includes(columnIds[0])); default: exhaustiveSwitchThrow({ switchValue: statusFilterConditions, }); } break; } case ColumnType.MULTI_SELECT: { /** * The filterValue may be a read-only array, which may cause data read and write errors in a strict environment. * Through deconstruction, converted into a normal array */ const columnIds = !columnValue ? [] : [...columnValue]; const filterIds = !filterValue ? [] : [...filterValue]; const multiSelectFilterConditions = condition as TypedFilterCondition< typeof columnType >; switch (multiSelectFilterConditions) { case FilterCondition.EQUALS: return isEqual(columnIds.sort(), filterIds.sort()); case FilterCondition.HAS_ANY_OF: return intersection(columnIds, filterIds).length > 0; case FilterCondition.HAS_ALL_OF: return ( intersection(columnIds, filterIds).length === filterIds.length ); case FilterCondition.HAS_NONE_OF: return intersection(columnIds, filterIds).length === 0; case FilterCondition.IS_EMPTY: return getIsEmpty(columnValue); case FilterCondition.IS_NOT_EMPTY: return !getIsEmpty(columnValue); default: exhaustiveSwitchThrow({ switchValue: multiSelectFilterConditions, }); } break; } case ColumnType.COLLABORATOR: { /** * The filterValue may be a read-only array, which may cause data read and write errors in a strict environment. * Through deconstruction, converted into a normal array */ const columnIds = !columnValue ? [] : [...columnValue]; const filterIds = !filterValue ? [] : [...filterValue]; const collaboratorFilterConditions = condition as TypedFilterCondition< typeof columnType >; switch (collaboratorFilterConditions) { case FilterCondition.EQUALS: return isEqual(columnIds.slice().sort(), filterIds.slice().sort()); case FilterCondition.HAS_ANY_OF: return intersection(columnIds, filterIds).length > 0; case FilterCondition.HAS_ALL_OF: return ( intersection(columnIds, filterIds).length === filterIds.length ); case FilterCondition.HAS_NONE_OF: return intersection(columnIds, filterIds).length === 0; case FilterCondition.IS_EMPTY: return getIsEmpty(columnValue); case FilterCondition.IS_NOT_EMPTY: return !getIsEmpty(columnValue); default: exhaustiveSwitchThrow({ switchValue: collaboratorFilterConditions, }); } break; } case ColumnType.MULTI_ATTACHMENT: { const newColumnValue = !columnValue ? [] : (columnValue as IAttachment[]); const attachmentFilterConditions = condition as TypedFilterCondition< typeof columnType >; switch (attachmentFilterConditions) { case FilterCondition.FILE_NAME: const allFileNames = newColumnValue .map((attachment: any) => attachment.fileName) .join('\n'); return allFileNames.toLowerCase().includes(filterValue); case FilterCondition.FILE_TYPE: const filterFileExtensions = FILE_TYPE_FILTERS_EXTENSIONS[ filterValue as keyof typeof FILE_TYPE_FILTERS_EXTENSIONS ]; const allFileNamesList = `${newColumnValue .map((attachment: any) => attachment.fileName.toLowerCase()) .join('\n')}\n`; return ( !filterFileExtensions || some(filterFileExtensions, (extension) => allFileNamesList.includes(`${extension}\n`) ) ); case FilterCondition.IS_EMPTY: return getIsEmpty(columnValue); case FilterCondition.IS_NOT_EMPTY: return !getIsEmpty(columnValue); default: exhaustiveSwitchThrow({ switchValue: attachmentFilterConditions, }); } break; } case ColumnType.RECORD_REFERENCE: { const columnReferenceNames = !columnValue ? '' : columnValue .map((option: any) => get(option, 'visibleName', '')?.toString().toLowerCase() ) .join(', '); const recordReferenceFilterConditions = condition as TypedFilterCondition; switch (recordReferenceFilterConditions) { case FilterCondition.EQUALS: return columnReferenceNames === filterValue; case FilterCondition.NOT_EQUAL: return columnReferenceNames !== filterValue; case FilterCondition.ENDS_WITH: return columnReferenceNames.endsWith(filterValue); case FilterCondition.STARTS_WITH: return columnReferenceNames.startsWith(filterValue); case FilterCondition.CONTAINS: return columnReferenceNames.includes(filterValue); case FilterCondition.NOT_CONTAINS: return !columnReferenceNames.includes(filterValue); case FilterCondition.IS_EMPTY: return getIsEmpty(columnValue); case FilterCondition.IS_NOT_EMPTY: return !getIsEmpty(columnValue); default: exhaustiveSwitchThrow({ switchValue: recordReferenceFilterConditions, }); } break; } case ColumnType.CHECKBOX: const booleanValues = Array.isArray(columnValue) ? columnValue : [columnValue]; const checkboxFilterConditions = condition as TypedFilterCondition< typeof columnType >; switch (checkboxFilterConditions) { case FilterCondition.EQUALS_EACH_OF: return booleanValues.every(value => !!value === filterValue); case FilterCondition.IS_EMPTY: return getIsEmpty(booleanValues); case FilterCondition.IS_NOT_EMPTY: return !getIsEmpty(booleanValues); case FilterCondition.CONTAINS: return booleanValues.some(value => !!value === filterValue); case FilterCondition.EQUALS: const isTrue = columnValue === true || (Array.isArray(columnValue) && !!columnValue[0]); return !!isTrue === filterValue; default: exhaustiveSwitchThrow({ switchValue: checkboxFilterConditions, }); } break; case ColumnType.LOOKUP: { if (!lookupTypeOptions) return false; return this.checkFilterValue( lookupTypeOptions.lookupColumnType, condition, columnValue, filterValue ); } case ColumnType.FORMULA: case ColumnType.PROGRESS: case ColumnType.INTEGRATION_REFERENCE: case ColumnType.NOOP: case ColumnType.ROLLUP: case ColumnType.SUBTABLE: return false; default: return exhaustiveSwitchThrow({ switchValue: columnType, }); } throw new Error( `Invalid filter column type ${columnType} or filter condition ${condition}` ); } }