diff --git a/src/components.d.ts b/src/components.d.ts index 2a832df8..c4dff85e 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -14,8 +14,8 @@ import { ColumnCollection } from "./services/column.data.provider"; import { DataInput } from "./plugins/export/types"; import { VNode } from "@stencil/core"; import { ColumnSource, RowSource } from "./components/data/columnService"; +import { MultiFilterItem, ShowData } from "./plugins/filter/filter.pop"; import { LogicFunction } from "./plugins/filter/filter.types"; -import { FilterItem, ShowData } from "./plugins/filter/filter.pop"; import { DataSourceState, Groups } from "./store/dataSource/data.store"; import { ViewportData } from "./components/revo-grid/viewport.interfaces"; import { ElementScroll } from "./components/revo-grid/viewport.scrolling.service"; @@ -238,6 +238,7 @@ export namespace Components { interface RevogrFilterPanel { "filterCaptions": FilterCaptions | undefined; "filterEntities": Record; + "filterItems": MultiFilterItem; "filterNames": Record; "filterTypes": Record; "getChanges": () => Promise; @@ -696,9 +697,10 @@ declare namespace LocalJSX { interface RevogrFilterPanel { "filterCaptions"?: FilterCaptions | undefined; "filterEntities"?: Record; + "filterItems"?: MultiFilterItem; "filterNames"?: Record; "filterTypes"?: Record; - "onFilterChange"?: (event: CustomEvent) => void; + "onFilterChange"?: (event: CustomEvent) => void; "uuid"?: string; } interface RevogrFocus { diff --git a/src/plugins/filter/conditions/equal.ts b/src/plugins/filter/conditions/equal.ts index 5e7e738e..a36d47c2 100644 --- a/src/plugins/filter/conditions/equal.ts +++ b/src/plugins/filter/conditions/equal.ts @@ -7,7 +7,13 @@ const eq: LogicFunction = (value: LogicFunctionParam, extra?: LogicFunctionExtra if (typeof value !== 'string') { value = JSON.stringify(value); } - return value.toLocaleLowerCase() === extra.toString().toLocaleLowerCase(); + + const filterVal = extra.toString().toLocaleLowerCase(); + if (filterVal.length === 0) { + return true; + } + + return value.toLocaleLowerCase() === filterVal; }; export const notEq: LogicFunction = (value: LogicFunctionParam, extra?: LogicFunctionExtraParam) => !eq(value, extra); diff --git a/src/plugins/filter/filter.button.tsx b/src/plugins/filter/filter.button.tsx index eec6ed88..c2b2a93a 100644 --- a/src/plugins/filter/filter.button.tsx +++ b/src/plugins/filter/filter.button.tsx @@ -4,6 +4,8 @@ import { RevoGrid } from '../../interfaces'; export const FILTER_BUTTON_CLASS = 'rv-filter'; export const FILTER_BUTTON_ACTIVE = 'active'; export const FILTER_PROP = 'hasFilter'; +export const AND_OR_BUTTON = 'and-or-button'; +export const TRASH_BUTTON = 'trash-button'; type Props = { column: RevoGrid.ColumnRegular; @@ -27,6 +29,19 @@ export const FilterButton = ({ column }: Props) => { ); }; +export const TrashButton = () => { + return ( +
+ + + +
+ ); +}; +export const AndOrButton = ({ isAnd }: any) => { + return ; +}; + export function isFilterBtn(e: HTMLElement) { if (e.classList.contains(FILTER_BUTTON_CLASS)) { return true; diff --git a/src/plugins/filter/filter.plugin.tsx b/src/plugins/filter/filter.plugin.tsx index f8e69a57..48371497 100644 --- a/src/plugins/filter/filter.plugin.tsx +++ b/src/plugins/filter/filter.plugin.tsx @@ -2,7 +2,7 @@ import { h } from '@stencil/core'; import BasePlugin from '../basePlugin'; import { RevoGrid } from '../../interfaces'; import { FILTER_PROP, isFilterBtn } from './filter.button'; -import { FilterItem } from './filter.pop'; +import { MultiFilterItem } from './filter.pop'; import { filterEntities, filterNames, FilterType, filterTypes } from './filter.service'; import { LogicFunction } from './filter.types'; @@ -17,12 +17,12 @@ export type FilterCaptions = { save: string; reset: string; cancel: string; -} +}; export type FilterLocalization = { captions: FilterCaptions; filterNames: Record; -} +}; /** * @typedef ColumnFilterConfig @@ -31,6 +31,7 @@ export type FilterLocalization = { * @property {string[]|undefined} include - filters to be included, if defined everything else out of scope will be ignored * @property {Record|undefined} customFilters - hash map of {FilterType:CustomFilter}. * @property {FilterLocalization|undefined} localization - translation for filter popup captions. + * @property {MultiFilterItem|undefined} multiFilterItems - data for multi filtering. * A way to define your own filter types per column */ export type ColumnFilterConfig = { @@ -38,6 +39,7 @@ export type ColumnFilterConfig = { include?: string[]; customFilters?: Record; localization?: FilterLocalization; + multiFilterItems?: MultiFilterItem; }; type HeaderEvent = CustomEvent; type FilterCollectionItem = { @@ -53,6 +55,7 @@ export const FILTER_TRIMMED_TYPE = 'filter'; export default class FilterPlugin extends BasePlugin { private pop: HTMLRevogrFilterPanelElement; private filterCollection: FilterCollection = {}; + private multiFilterItems: MultiFilterItem = {}; private possibleFilters: Record = { ...filterTypes }; private possibleFilterNames: Record = { ...filterNames }; private possibleFilterEntities: Record = { ...filterEntities }; @@ -62,11 +65,27 @@ export default class FilterPlugin extends BasePlugin { if (config) { this.initConfig(config); } + const headerclick = (e: HeaderEvent) => this.headerclick(e); - const aftersourceset = () => { - if (Object.keys(this.filterCollection).length) { - this.filterByProps(this.filterCollection); + + const aftersourceset = async () => { + const filterCollectionProps = Object.keys(this.filterCollection); + if (filterCollectionProps.length > 0) { + // handle old way of filtering by reworking FilterCollection to new MultiFilterItem + filterCollectionProps.forEach((prop, index) => { + if (!this.multiFilterItems[prop]) { + this.multiFilterItems[prop] = [ + { + id: index, + type: this.filterCollection[prop].type, + value: this.filterCollection[prop].value, + relation: 'and', + }, + ]; + } + }); } + await this.runFiltering(); }; this.addEventListener('headerclick', headerclick); this.addEventListener('aftersourceset', aftersourceset); @@ -74,9 +93,10 @@ export default class FilterPlugin extends BasePlugin { this.revogrid.registerVNode([ this.onFilterChange(e.detail)} ref={e => (this.pop = e)} />, @@ -87,6 +107,9 @@ export default class FilterPlugin extends BasePlugin { if (config.collection) { this.filterCollection = { ...config.collection }; } + if (config.multiFilterItems) { + this.multiFilterItems = { ...config.multiFilterItems }; + } if (config.customFilters) { for (let cType in config.customFilters) { const cFilter = config.customFilters[cType]; @@ -184,44 +207,20 @@ export default class FilterPlugin extends BasePlugin { } // called on internal component change - private async onFilterChange(filterItem: FilterItem) { - this.filterByProps({ [filterItem.prop]: filterItem }); - } - - /** - * Apply filters collection to extend existing one or override - * @method - * @param conditions - list of filters to apply - */ - async filterByProps(conditions: Record, override = false) { - if (override) { - this.filterCollection = {}; - } - for (const prop in conditions) { - const { type, value } = conditions[prop]; - if (type === 'none') { - delete this.filterCollection[prop]; - } else { - const filter = this.possibleFilterEntities[type]; - this.filterCollection[prop] = { - filter, - value, - type, - }; - } - } - await this.runFiltering(); + private async onFilterChange(filterItems: MultiFilterItem) { + this.multiFilterItems = filterItems; + this.runFiltering(); } /** * Triggers grid filtering */ - async doFiltering(collection: FilterCollection, items: RevoGrid.DataType[], columns: RevoGrid.ColumnRegular[]) { + async doFiltering(collection: FilterCollection, items: RevoGrid.DataType[], columns: RevoGrid.ColumnRegular[], filterItems: MultiFilterItem) { const columnsToUpdate: RevoGrid.ColumnRegular[] = []; - // todo improvement: loop through collection of props + columns.forEach(rgCol => { const column = { ...rgCol }; - const hasFilter = collection[column.prop]; + const hasFilter = filterItems[column.prop]; if (column[FILTER_PROP] && !hasFilter) { delete column[FILTER_PROP]; columnsToUpdate.push(column); @@ -231,33 +230,55 @@ export default class FilterPlugin extends BasePlugin { column[FILTER_PROP] = true; } }); - const itemsToFilter = this.getRowFilter(items, collection); + const itemsToFilter = this.getRowFilter(items, filterItems); // check is filter event prevented - const { defaultPrevented, detail } = this.emit('beforefiltertrimmed', { collection, itemsToFilter, source: items }); + const { defaultPrevented, detail } = this.emit('beforefiltertrimmed', { collection, itemsToFilter, source: items, filterItems }); if (defaultPrevented) { return; } + // check is trimmed event prevented const isAddedEvent = await this.revogrid.addTrimmed(detail.itemsToFilter, FILTER_TRIMMED_TYPE); if (isAddedEvent.defaultPrevented) { return; } + + // applies the hasFilter to the columns to show filter icon await this.revogrid.updateColumns(columnsToUpdate); this.emit('afterFilterApply'); } async clearFiltering() { - this.filterCollection = {}; + this.multiFilterItems = {}; await this.runFiltering(); } private async runFiltering() { + const collection: FilterCollection = {}; + + // handle old filterCollection to return the first filter only (if any) from multiFilterItems + const filterProps = Object.keys(this.multiFilterItems); + + for (const prop of filterProps) { + // check if we have any filter for a column + if (this.multiFilterItems[prop].length > 0) { + const firstFilterItem = this.multiFilterItems[prop][0]; + collection[prop] = { + filter: filterEntities[firstFilterItem.type], + type: firstFilterItem.type, + value: firstFilterItem.value, + }; + } + } + + this.filterCollection = collection; + const { source, columns } = await this.getData(); - const { defaultPrevented, detail } = this.emit('beforefilterapply', { collection: this.filterCollection, source, columns }); + const { defaultPrevented, detail } = this.emit('beforefilterapply', { collection: this.filterCollection, source, columns, filterItems: this.multiFilterItems }); if (defaultPrevented) { return; } - this.doFiltering(detail.collection, detail.source, detail.columns); + this.doFiltering(detail.collection, detail.source, detail.columns, detail.filterItems); } private async getData() { @@ -265,20 +286,63 @@ export default class FilterPlugin extends BasePlugin { const columns = await this.revogrid.getColumns(); return { source, - columns + columns, }; } - private getRowFilter(rows: RevoGrid.DataType[], collection: FilterCollection) { + private getRowFilter(rows: RevoGrid.DataType[], filterItems: MultiFilterItem) { + const propKeys = Object.keys(filterItems); + const trimmed: Record = {}; + let propFilterSatisfiedCount: number = 0; + let lastFilterResults: boolean[] = []; + + // each rows rows.forEach((model, rowIndex) => { - for (const prop in collection) { - const filterItem = collection[prop]; - const filter = filterItem.filter; - if (!filter(model[prop], filterItem.value)) { - trimmed[rowIndex] = true; - } - } + // working on all props + for (const prop of propKeys) { + const propFilters = filterItems[prop]; + + propFilterSatisfiedCount = 0; + lastFilterResults = []; + + // testing each filter for a prop + for (const [filterIndex, filterData] of propFilters.entries()) { + // the filter LogicFunction based on the type + const filter = filterEntities[filterData.type]; + + // THE MAGIC OF FILTERING IS HERE + if (filterData.relation === 'or') { + lastFilterResults = []; + if (filter(model[prop], filterData.value)) { + continue; + } + propFilterSatisfiedCount++; + } else { + // 'and' relation will need to know the next filter + // so we save this current filter to include it in the next filter + lastFilterResults.push(!filter(model[prop], filterData.value)); + + // check first if we have a filter on the next index to pair it with this current filter + const nextFilterData = propFilters[filterIndex + 1]; + // stop the sequence if there is no next filter or if the next filter is not an 'and' relation + if (!nextFilterData || nextFilterData.relation !== 'and') { + // let's just continue since for sure propFilterSatisfiedCount cannot be satisfied + if (lastFilterResults.indexOf(true) === -1) { + lastFilterResults = []; + continue; + } + + // we need to add all of the lastFilterResults since we need to satisfy all + propFilterSatisfiedCount += lastFilterResults.length; + lastFilterResults = []; + } + } + } // end of propFilters forEach + + // add to the list of removed/trimmed rows of filter condition is satisfied + if (propFilterSatisfiedCount === propFilters.length) trimmed[rowIndex] = true; + } // end of for-of propKeys }); return trimmed; } diff --git a/src/plugins/filter/filter.pop.tsx b/src/plugins/filter/filter.pop.tsx index 3bd86555..57b341d5 100644 --- a/src/plugins/filter/filter.pop.tsx +++ b/src/plugins/filter/filter.pop.tsx @@ -1,11 +1,12 @@ import { Component, h, Host, Listen, Prop, State, Event, EventEmitter, VNode, Method } from '@stencil/core'; import { FilterType } from './filter.service'; import { RevoGrid } from '../../interfaces'; -import { isFilterBtn } from './filter.button'; +import { AndOrButton, isFilterBtn, TrashButton } from './filter.button'; import { RevoButton } from '../../components/button/button'; import '../../utils/closestPolifill'; import { LogicFunction } from './filter.types'; import { FilterCaptions } from './filter.plugin'; +import debounce from 'lodash/debounce'; /** * @typedef FilterItem @@ -20,6 +21,17 @@ export type FilterItem = { value?: any; }; +export type FilterData = { + id: number; + type: FilterType; + value?: any; + relation: 'and' | 'or'; +}; + +export type MultiFilterItem = { + [prop: string]: FilterData[]; +}; + export type ShowData = { x: number; y: number; @@ -27,25 +39,32 @@ export type ShowData = { const defaultType: FilterType = 'none'; +const FILTER_LIST_CLASS = 'multi-filter-list'; +const FILTER_LIST_CLASS_ACTION = 'multi-filter-list-action'; + @Component({ tag: 'revogr-filter-panel', styleUrl: 'filter.style.scss', }) export class FilterPanel { - private extraElement: HTMLInputElement | undefined; private filterCaptionsInternal: FilterCaptions = { - title: "Filter by condition", - save: "Save", - reset: "Reset", - cancel: "Cancel", + title: 'Filter by condition', + save: 'Save', + reset: 'Reset', + cancel: 'Cancel', }; + @State() isFilterIdSet = false; + @State() filterId = 0; + @State() currentFilterId: number = -1; + @State() currentFilterType: FilterType = defaultType; @State() changes: ShowData | undefined; @Prop({ mutable: true, reflect: true }) uuid: string; + @Prop() filterItems: MultiFilterItem = {}; @Prop() filterTypes: Record = {}; @Prop() filterNames: Record = {}; @Prop() filterEntities: Record = {}; @Prop() filterCaptions: FilterCaptions | undefined; - @Event() filterChange: EventEmitter; + @Event() filterChange: EventEmitter; @Listen('mousedown', { target: 'document' }) onMouseDown(e: MouseEvent): void { if (this.changes && !e.defaultPrevented) { const el = e.target as HTMLElement; @@ -65,11 +84,30 @@ export class FilterPanel { return this.changes; } - renderConditions(type: FilterType) { + componentWillRender() { + if (!this.isFilterIdSet) { + this.isFilterIdSet = true; + const filterItems = Object.keys(this.filterItems); + for (const prop of filterItems) { + // we set the proper filterId so there won't be any conflict when removing filters + this.filterId += this.filterItems[prop].length; + } + } + } + + renderSelectOptions(type: FilterType, isDefaultTypeRemoved = false) { const options: VNode[] = []; - for (let gIndex in this.filterTypes) { - options.push(); + const prop = this.changes?.prop; + if (!isDefaultTypeRemoved) { + options.push( + , + ); + } + + for (let gIndex in this.filterTypes) { options.push( ...this.filterTypes[gIndex].map(k => (