From d645a2acbcccca1593a5216cef5daa4819b0330a Mon Sep 17 00:00:00 2001 From: Juri Sinitson <6381693+juri-sinitson@users.noreply.github.com> Date: Thu, 21 Nov 2024 13:55:05 +0100 Subject: [PATCH] fix(table-service): #15009 add unit tests and fix - Especially the fixing the global filter `notContains` which is the main scope of this issue. - Improve signature --- packages/primeng/src/api/filterservice.ts | 37 +- packages/primeng/src/api/fitlersarg.ts | 3 + .../primeng/src/table/table.service.spec.ts | 973 +++++++++++++++++- packages/primeng/src/table/table.ts | 214 +++- 4 files changed, 1147 insertions(+), 80 deletions(-) create mode 100644 packages/primeng/src/api/fitlersarg.ts diff --git a/packages/primeng/src/api/filterservice.ts b/packages/primeng/src/api/filterservice.ts index ed9cb176cfe..92769022568 100644 --- a/packages/primeng/src/api/filterservice.ts +++ b/packages/primeng/src/api/filterservice.ts @@ -215,7 +215,10 @@ export class FilterService { return false; } - return value.toDateString() === filter.toDateString(); + const valueDate = this.getDate(value); + const filterDate = this.getDate(filter); + + return valueDate.toDateString() === filterDate.toDateString(); }, dateIsNot: (value: any, filter: any): boolean => { @@ -227,7 +230,14 @@ export class FilterService { return false; } - return value.toDateString() !== filter.toDateString(); + const valueDate = this.getDate(value); + const filterDate = this.getDate(filter); + + if (isNaN(valueDate.getDate()) || isNaN(filterDate.getDate())) { + return true; + } + + return valueDate.toDateString() !== filterDate.toDateString(); }, dateBefore: (value: any, filter: any): boolean => { @@ -239,7 +249,10 @@ export class FilterService { return false; } - return value.getTime() < filter.getTime(); + const valueDate = this.getDate(value); + const filterDate = this.getDate(filter); + + return valueDate.getTime() < filterDate.getTime(); }, dateAfter: (value: any, filter: any): boolean => { @@ -250,13 +263,27 @@ export class FilterService { if (value === undefined || value === null) { return false; } - value.setHours(0, 0, 0, 0); - return value.getTime() > filter.getTime(); + const valueDate = this.getDate(value); + const filterDate = this.getDate(filter); + + valueDate.setHours(0, 0, 0, 0); + + return valueDate.getTime() > filterDate.getTime(); } }; register(rule: string, fn: Function) { this.filters[rule] = fn; } + + private getDate(value: any): Date { + if (value instanceof Date) { + return value; + } else { + // Prevent creating a date from an integer e.g. from + // an age. In this case in invalid date should be returned. + return new Date(`${value}`); + } + } } diff --git a/packages/primeng/src/api/fitlersarg.ts b/packages/primeng/src/api/fitlersarg.ts new file mode 100644 index 00000000000..0b5682b9efa --- /dev/null +++ b/packages/primeng/src/api/fitlersarg.ts @@ -0,0 +1,3 @@ +import { FilterMetadata } from './filtermetadata'; + +export type FiltersArg = { [key: string]: FilterMetadata | FilterMetadata[] | string }; diff --git a/packages/primeng/src/table/table.service.spec.ts b/packages/primeng/src/table/table.service.spec.ts index 0e848fe272c..1d2aef22976 100644 --- a/packages/primeng/src/table/table.service.spec.ts +++ b/packages/primeng/src/table/table.service.spec.ts @@ -1,34 +1,971 @@ import { TestBed } from '@angular/core/testing'; +import { FilterOperator } from 'primeng/api'; import { FiltersArg } from '../api/fitlersarg'; -import { TableService } from './table.service'; +import { globalFilterFieldName, TableService } from './table'; + +let service: TableService; + +beforeEach(() => { + TestBed.configureTestingModule({ + providers: [TableService] + }); + service = TestBed.inject(TableService); +}); describe('TableService', () => { describe('filter entries', () => { - describe('by single field aka locally', () => { - let service: TableService; + describe('general edge cases', () => { + it('should return empty array when data is falsy', () => { + const filters: FiltersArg = { + name: { value: 'foo', matchMode: 'startsWith' } + }; - beforeEach(() => { - // DEBUG! Prove this in the official docs - TestBed.configureTestingModule({ - providers: [TableService] - }); - service = TestBed.inject(TableService); + const result = service.filter(undefined, filters, [], undefined); + + expect(result).toEqual([]); }); - it('should filter by starts with', () => { - const data = [{ name: 'foo' }, { name: 'bar' }]; + it('should return empty array when data is empty', () => { + const data = []; + const filters: FiltersArg = { - name: { - value: 'f', - matchMode: 'startsWith' - } + name: { value: 'foo', matchMode: 'startsWith' } }; - const result = service.filter(data, filters, [], 'startsWith'); - expect(result).toEqual([{ name: 'foo' }]); + const result = service.filter(data, filters, [], undefined); + + expect(result).toEqual([]); + }); + + it('should return same data when filters are empty', () => { + const data = [{ name: 'foo' }, { name: 'bar' }]; + + const filters: FiltersArg = {}; + + const result = service.filter(data, filters, [], undefined); + + expect(result).toEqual(data); + }); + + it('should return same data when filters are undefined', () => { + const data = [{ name: 'foo' }, { name: 'bar' }]; + + const filters: FiltersArg = undefined; + + const result = service.filter(data, filters, [], undefined); + + expect(result).toEqual(data); + }); + + it('should return same data when filters are null', () => { + const data = [{ name: 'foo' }, { name: 'bar' }]; + + const filters: FiltersArg = null; + + const result = service.filter(data, filters, [], undefined); + + expect(result).toEqual(data); + }); + }); + + describe('edge cases', () => { + describe('(global) date filters return empty data when not a date', () => { + describe('dateIs', () => { + it('should return empty data when no date in data', () => { + const data = [ + { name: 'foo', city: 'New York' }, + { name: 'bar', city: 'Los Angeles' } + ]; + + const filters: FiltersArg = { + date: { value: new Date(2021, 1, 1), matchMode: 'dateIs' } + }; + + const result = service.filter(data, filters, ['name', 'city'], undefined); + + expect(result).toEqual([]); + }); + + it('should return empty data when no date in filter', () => { + const data = [ + { city: 'New York', date: new Date(2021, 1, 1) }, + { city: 'Los Angeles', date: new Date(2021, 1, 1) } + ]; + + const filters: FiltersArg = { + date: { value: 'today is 1th of Veb 2021', matchMode: 'dateIs' } + }; + + const result = service.filter(data, filters, ['city', 'date'], undefined); + + expect(result).toEqual([]); + }); + }); + + describe('dateIsNot', () => { + it('should return empty data when no date in data', () => { + const data = [ + { name: 'foo', city: 'New York' }, + { name: 'bar', city: 'Los Angeles' } + ]; + + const filters: FiltersArg = { + date: { value: new Date(2021, 1, 1), matchMode: 'dateIsNot' } + }; + + const result = service.filter(data, filters, ['name', 'city'], undefined); + + expect(result).toEqual([]); + }); + + it('should return empty data when no date in filter', () => { + const data = [ + { city: 'New York', date: new Date(2021, 1, 1) }, + { city: 'Los Angeles', date: new Date(2021, 1, 1) } + ]; + + const filters: FiltersArg = { + date: { value: 'der erste Ferbruar zwanzig einungzwanzig', matchMode: 'dateIsNot' } + }; + + const result = service.filter(data, filters, ['city', 'date'], undefined); + + expect(result).toEqual(data); + }); + }); + + describe('dateBefore', () => { + it('should return empty data when no date in data', () => { + const data = [ + { name: 'foo', city: 'New York' }, + { name: 'bar', city: 'Los Angeles' } + ]; + + const filters: FiltersArg = { + date: { value: new Date(2021, 1, 1), matchMode: 'dateBefore' } + }; + + const result = service.filter(data, filters, ['name', 'city'], undefined); + + expect(result).toEqual([]); + }); + + it('should return empty data when no date in filter', () => { + const data = [ + { city: 'New York', date: new Date(2021, 1, 1) }, + { city: 'Los Angeles', date: new Date(2021, 1, 1) } + ]; + + const filters: FiltersArg = { + date: { value: 'today is 1th of Veb 2021', matchMode: 'dateBefore' } + }; + + const result = service.filter(data, filters, ['city', 'date'], undefined); + + expect(result).toEqual([]); + }); + }); + + describe('dateAfter', () => { + it('should return empty data when no date in data', () => { + const data = [ + { name: 'foo', city: 'New York' }, + { name: 'bar', city: 'Los Angeles' } + ]; + + const filters: FiltersArg = { + date: { value: new Date(2021, 1, 1), matchMode: 'dateAfter' } + }; + + const result = service.filter(data, filters, ['name', 'city'], undefined); + + expect(result).toEqual([]); + }); + + it('should return empty data when no date in filter', () => { + const data = [ + { city: 'New York', date: new Date(2021, 1, 1) }, + { city: 'Los Angeles', date: new Date(2021, 1, 1) } + ]; + + const filters: FiltersArg = { + date: { value: 'today is 1th of Veb 2021', matchMode: 'dateAfter' } + }; + + const result = service.filter(data, filters, ['city', 'date'], undefined); + + expect(result).toEqual([]); + }); + }); + }); + }); + + describe('by single field aka locally', () => { + describe('typical cases', () => { + it('should filter with startsWith', () => { + const data = [{ name: 'foo' }, { name: 'bar' }]; + + const filters: FiltersArg = { + name: { value: 'f', matchMode: 'startsWith' } + }; + + const result = service.filter(data, filters, [], undefined); + + expect(result).toEqual([{ name: 'foo' }]); + }); + + it('should filter items with contains', () => { + const data = [{ name: 'apple' }, { name: 'banana' }, { name: 'apricot' }]; + + const filters: FiltersArg = { + name: { value: 'an', matchMode: 'contains' } + }; + + const result = service.filter(data, filters, [], undefined); + + expect(result).toEqual([{ name: 'banana' }]); + }); + + it('should filter items with notContains', () => { + const data = [{ name: 'apple' }, { name: 'banana' }, { name: 'apricot' }]; + + const filters: FiltersArg = { + name: { value: 'an', matchMode: 'notContains' } + }; + + const result = service.filter(data, filters, [], undefined); + + expect(result).toEqual([{ name: 'apple' }, { name: 'apricot' }]); + }); + + it('should filter items with endsWith', () => { + const data = [{ name: 'apple' }, { name: 'banana' }, { name: 'apricot' }]; + + const filters: FiltersArg = { + name: { value: 'le', matchMode: 'endsWith' } + }; + + const result = service.filter(data, filters, [], undefined); + + expect(result).toEqual([{ name: 'apple' }]); + }); + + it('should filter items with equals', () => { + const data = [{ name: 'apple' }, { name: 'banana' }, { name: 'apricot' }]; + + const filters: FiltersArg = { + name: { value: 'banana', matchMode: 'equals' } + }; + + const result = service.filter(data, filters, [], undefined); + + expect(result).toEqual([{ name: 'banana' }]); + }); + + it('should filter items with notEquals', () => { + const data = [{ name: 'apple' }, { name: 'banana' }, { name: 'apricot' }]; + + const filters: FiltersArg = { + name: { value: 'banana', matchMode: 'notEquals' } + }; + + const result = service.filter(data, filters, [], undefined); + + expect(result).toEqual([{ name: 'apple' }, { name: 'apricot' }]); + }); + + it('should filter items with in', () => { + const data = [{ name: 'apple' }, { name: 'banana' }, { name: 'apricot' }]; + + const filters: FiltersArg = { + name: { value: ['apple', 'apricot'], matchMode: 'in' } + }; + + const result = service.filter(data, filters, [], undefined); + + expect(result).toEqual([{ name: 'apple' }, { name: 'apricot' }]); + }); + + it('should filter items with between', () => { + const data = [{ value: 5 }, { value: 10 }, { value: 15 }]; + + const filters: FiltersArg = { + value: { value: [5, 10], matchMode: 'between' } + }; + + const result = service.filter(data, filters, [], undefined); + + expect(result).toEqual([{ value: 5 }, { value: 10 }]); + }); + + it('should filter items with lt', () => { + const data = [{ value: 5 }, { value: 10 }, { value: 15 }]; + + const filters: FiltersArg = { + value: { value: 10, matchMode: 'lt' } + }; + + const result = service.filter(data, filters, [], undefined); + + expect(result).toEqual([{ value: 5 }]); + }); + + it('should filter items with lte', () => { + const data = [{ value: 5 }, { value: 10 }, { value: 15 }]; + + const filters: FiltersArg = { + value: { value: 10, matchMode: 'lte' } + }; + + const result = service.filter(data, filters, [], undefined); + + expect(result).toEqual([{ value: 5 }, { value: 10 }]); + }); + + it('should filter items with gt', () => { + const data = [{ value: 5 }, { value: 10 }, { value: 15 }]; + + const filters: FiltersArg = { + value: { value: 10, matchMode: 'gt' } + }; + + const result = service.filter(data, filters, [], undefined); + + expect(result).toEqual([{ value: 15 }]); + }); + + it('should filter items with gte', () => { + const data = [{ value: 5 }, { value: 10 }, { value: 15 }]; + + const filters: FiltersArg = { + value: { value: 10, matchMode: 'gte' } + }; + + const result = service.filter(data, filters, [], undefined); + + expect(result).toEqual([{ value: 10 }, { value: 15 }]); + }); + + it('should filter items with is', () => { + const data = [{ name: 'apple' }, { name: 'banana' }]; + + const filters: FiltersArg = { + name: { value: 'banana', matchMode: 'is' } + }; + + const result = service.filter(data, filters, [], undefined); + + expect(result).toEqual([{ name: 'banana' }]); + }); + + it('should filter items with isNot', () => { + const data = [{ name: 'apple' }, { name: 'banana' }]; + + const filters: FiltersArg = { + name: { value: 'banana', matchMode: 'isNot' } + }; + + const result = service.filter(data, filters, [], undefined); + + expect(result).toEqual([{ name: 'apple' }]); + }); + + it('should filter items with before', () => { + const data = [{ value: new Date(2020, 1, 1) }, { value: new Date(2021, 1, 1) }]; + + const filters: FiltersArg = { + value: { value: new Date(2021, 1, 1), matchMode: 'before' } + }; + + const result = service.filter(data, filters, [], undefined); + + expect(result).toEqual([{ value: new Date(2020, 1, 1) }]); + }); + + it('should filter items with after', () => { + const data = [{ value: new Date(2020, 1, 1) }, { value: new Date(2021, 1, 1) }]; + + const filters: FiltersArg = { + value: { value: new Date(2020, 1, 1), matchMode: 'after' } + }; + + const result = service.filter(data, filters, [], undefined); + + expect(result).toEqual([{ value: new Date(2021, 1, 1) }]); + }); + + it('should filter items with dateIs', () => { + const data = [{ value: new Date(2020, 1, 1) }, { value: new Date(2021, 1, 1) }]; + + const filters: FiltersArg = { + value: { value: new Date(2021, 1, 1), matchMode: 'dateIs' } + }; + + const result = service.filter(data, filters, [], undefined); + + expect(result).toEqual([{ value: new Date(2021, 1, 1) }]); + }); + + it('should filter items with dateIsNot', () => { + const data = [{ value: new Date(2020, 1, 1) }, { value: new Date(2021, 1, 1) }]; + + const filters: FiltersArg = { + value: { value: new Date(2021, 1, 1), matchMode: 'dateIsNot' } + }; + + const result = service.filter(data, filters, [], undefined); + + expect(result).toEqual([{ value: new Date(2020, 1, 1) }]); + }); + + it('should filter items with dateBefore', () => { + const data = [{ value: new Date(2020, 1, 1) }, { value: new Date(2021, 1, 1) }]; + + const filters: FiltersArg = { + value: { value: new Date(2021, 1, 1), matchMode: 'dateBefore' } + }; + + const result = service.filter(data, filters, [], undefined); + + expect(result).toEqual([{ value: new Date(2020, 1, 1) }]); + }); + + it('should filter items with dateAfter', () => { + const data = [{ value: new Date(2020, 1, 1) }, { value: new Date(2021, 1, 1) }]; + + const filters: FiltersArg = { + value: { value: new Date(2020, 1, 1), matchMode: 'dateAfter' } + }; + + const result = service.filter(data, filters, [], undefined); + + expect(result).toEqual([{ value: new Date(2021, 1, 1) }]); + }); + }); + describe('edge cases', () => { + it('should filter by combined filters equals and notEquals with default and', () => { + const data = [{ name: 'foo' }, { name: 'bar' }, { name: 'baz' }]; + + const filters: FiltersArg = { + name: [ + { value: 'baz', matchMode: 'equals' }, + { value: 'bar', matchMode: 'notEquals' } + ] + }; + const result = service.filter(data, filters, [], undefined); + + expect(result).toEqual([{ name: 'baz' }]); + }); + + it('should filter by combined filters equals or notEquals', () => { + const data = [{ name: 'foo' }, { name: 'bar' }, { name: 'baz' }]; + + const filters: FiltersArg = { + name: [ + { value: 'baz', matchMode: 'equals', operator: FilterOperator.OR }, + { value: 'bar', matchMode: 'notEquals' } + ] + }; + const result = service.filter(data, filters, [], undefined); + + expect(result).toEqual([{ name: 'foo' }, { name: 'baz' }]); + }); + + it('should filter by combined filters equals and/or notEquals', () => { + const data = [{ name: 'foo' }, { name: 'bar' }, { name: 'baz' }, { name: 'fly' }]; + + const filters: FiltersArg = { + name: [ + // Don't break the filter loop by the OR operator + // otherwise this test will fail! + { value: 'baz', matchMode: 'equals', operator: FilterOperator.OR }, + { value: 'bar', matchMode: 'notEquals', operator: FilterOperator.AND }, + // Also making sure the operator of the last filter is ignored + { value: 'fly', matchMode: 'notEquals', operator: FilterOperator.AND } + ] + }; + const result = service.filter(data, filters, [], undefined); + + expect(result).toEqual([{ name: 'foo' }, { name: 'baz' }]); + }); + + it('should filter by combined filters and notEquals', () => { + const data = [{ name: 'foo' }, { name: 'bar' }, { name: 'baz' }]; + + const filters: FiltersArg = { + name: [ + { value: 'baz', matchMode: 'notEquals', operator: FilterOperator.AND }, + { value: 'bar', matchMode: 'notEquals', operator: FilterOperator.AND }, + { value: 'foo', matchMode: 'notEquals', operator: FilterOperator.AND } + ] + }; + const result = service.filter(data, filters, [], undefined); + + expect(result).toEqual([]); + }); + + it('should filter multiple props', () => { + const data = [ + { name: 'foo', val: 1 }, + { name: 'bar', val: 2 }, + { name: 'baz', val: 3 } + ]; + + const filters: FiltersArg = { + name: { value: 'baz', matchMode: 'equals' }, + val: { value: 3, matchMode: 'equals' } + }; + const result = service.filter(data, filters, [], undefined); + + expect(result).toEqual([{ name: 'baz', val: 3 }]); + }); + + it('should filter compare string and number', () => { + const data = [ + { name: 'foo', val: 1 }, + { name: 'bar', val: 2 }, + { name: 'baz', val: 3 } + ]; + + const filters: FiltersArg = { + name: { value: 'baz', matchMode: 'equals' }, + val: { value: '3 ', matchMode: 'equals' } + }; + const result = service.filter(data, filters, [], undefined); + + expect(result).toEqual([{ name: 'baz', val: 3 }]); + }); + + it('should filter with startsWith by default', () => { + const data = [{ name: 'foo' }, { name: 'bar' }]; + + const filters: FiltersArg = { + name: { value: 'f' } + }; + + const result = service.filter(data, filters, [], undefined); + + expect(result).toEqual([{ name: 'foo' }]); + }); + + it('should return empty data by an unknown prop', () => { + const data = [{ name: 'foo' }, { name: 'bar' }]; + + const filters: FiltersArg = { + name: { value: 'bar', matchMode: 'contains' }, + unknown: { value: 'foo', matchMode: 'contains' } + }; + + const result = service.filter(data, filters, [], undefined); + + expect(result).toEqual([]); + }); + }); + }); + + describe('by multiple/all fields aka globally', () => { + describe('typical cases', () => { + it('should filter with startsWith', () => { + const data = [ + { name: 'foo', age: 25, city: 'New York' }, + { name: 'bar', age: 30, city: 'Los Angeles' } + ]; + + const filters: FiltersArg = {}; + filters[globalFilterFieldName] = { value: 'new', matchMode: 'startsWith' }; + + const result = service.filter(data, filters, ['name', 'age', 'city'], undefined); + + expect(result).toEqual([{ name: 'foo', age: 25, city: 'New York' }]); + }); + + it('should filter with contains', () => { + const data = [ + { name: 'foo', age: 25, city: 'New York' }, + { name: 'bar', age: 30, city: 'Los Angeles' } + ]; + + const filters: FiltersArg = {}; + filters[globalFilterFieldName] = { value: 'ang', matchMode: 'contains' }; + + const result = service.filter(data, filters, ['name', 'age', 'city'], undefined); + + expect(result).toEqual([{ name: 'bar', age: 30, city: 'Los Angeles' }]); + }); + + it('should filter with notContains', () => { + const data = [ + { name: 'foo', age: 25, city: 'New York' }, + { name: 'bar', age: 30, city: 'Los Angeles' } + ]; + + const filters: FiltersArg = {}; + filters[globalFilterFieldName] = { value: 'ang', matchMode: 'notContains' }; + + const result = service.filter(data, filters, ['name', 'age', 'city'], undefined); + + expect(result).toEqual([{ name: 'foo', age: 25, city: 'New York' }]); + }); + + it('should filter with endsWith', () => { + const data = [ + { name: 'foo', age: 25, city: 'New York' }, + { name: 'bar', age: 30, city: 'Los Angeles' } + ]; + + const filters: FiltersArg = {}; + filters[globalFilterFieldName] = { value: 'York', matchMode: 'endsWith' }; + + const result = service.filter(data, filters, ['name', 'age', 'city'], undefined); + + expect(result).toEqual([{ name: 'foo', age: 25, city: 'New York' }]); + }); + + it('should filter with equals', () => { + const data = [ + { name: 'foo', age: 25, city: 'New York' }, + { name: 'bar', age: 30, city: 'Los Angeles' } + ]; + + const filters: FiltersArg = {}; + filters[globalFilterFieldName] = { value: 'New York', matchMode: 'equals' }; + + const result = service.filter(data, filters, ['name', 'age', 'city'], undefined); + + expect(result).toEqual([{ name: 'foo', age: 25, city: 'New York' }]); + }); + + it('should filter with notEquals', () => { + const data = [ + { name: 'foo', age: 25, city: 'New York' }, + { name: 'bar', age: 30, city: 'Los Angeles' } + ]; + + const filters: FiltersArg = {}; + filters[globalFilterFieldName] = { value: 'New York', matchMode: 'notEquals' }; + + const result = service.filter(data, filters, ['name', 'age', 'city'], undefined); + + expect(result).toEqual([{ name: 'bar', age: 30, city: 'Los Angeles' }]); + }); + + it('should filter with in', () => { + const data = [ + { name: 'foo', age: 25, city: 'New York' }, + { name: 'bar', age: 30, city: 'Los Angeles' } + ]; + + const filters: FiltersArg = {}; + filters[globalFilterFieldName] = { value: ['New York', 'Los Angeles'], matchMode: 'in' }; + + const result = service.filter(data, filters, ['name', 'age', 'city'], undefined); + + expect(result).toEqual(data); + }); + + it('should filter with between', () => { + const data = [ + { name: 'foo', age: 25, city: 'New York' }, + { name: 'bar', age: 30, city: 'Los Angeles' } + ]; + + const filters: FiltersArg = {}; + filters[globalFilterFieldName] = { value: [20, 30], matchMode: 'between' }; + + const result = service.filter(data, filters, ['name', 'age', 'city'], undefined); + + expect(result).toEqual(data); + }); + + it('should filter with lt', () => { + const data = [ + { name: 'foo', age: 25, city: 'New York' }, + { name: 'bar', age: 30, city: 'Los Angeles' } + ]; + + const filters: FiltersArg = {}; + filters[globalFilterFieldName] = { value: 30, matchMode: 'lt' }; + + const result = service.filter(data, filters, ['name', 'age', 'city'], undefined); + + expect(result).toEqual([{ name: 'foo', age: 25, city: 'New York' }]); + }); + + it('should filter with lte', () => { + const data = [ + { name: 'foo', age: 25, city: 'New York' }, + { name: 'bar', age: 30, city: 'Los Angeles' } + ]; + + const filters: FiltersArg = {}; + filters[globalFilterFieldName] = { value: 30, matchMode: 'lte' }; + + const result = service.filter(data, filters, ['name', 'age', 'city'], undefined); + + expect(result).toEqual(data); + }); + + it('should filter with gt', () => { + const data = [ + { name: 'foo', age: 25, city: 'New York' }, + { name: 'bar', age: 30, city: 'Los Angeles' } + ]; + + const filters: FiltersArg = {}; + filters[globalFilterFieldName] = { value: 25, matchMode: 'gt' }; + + const result = service.filter(data, filters, ['name', 'age', 'city'], undefined); + + expect(result).toEqual([{ name: 'bar', age: 30, city: 'Los Angeles' }]); + }); + + it('should filter with gte', () => { + const data = [ + { name: 'foo', age: 25, city: 'New York' }, + { name: 'bar', age: 30, city: 'Los Angeles' } + ]; + + const filters: FiltersArg = {}; + filters[globalFilterFieldName] = { value: 25, matchMode: 'gte' }; + + const result = service.filter(data, filters, ['name', 'age', 'city'], undefined); + + expect(result).toEqual(data); + }); + + it('should filter with is', () => { + const data = [ + { name: 'foo', age: 25, city: 'New York' }, + { name: 'bar', age: 30, city: 'Los Angeles' } + ]; + + const filters: FiltersArg = {}; + filters[globalFilterFieldName] = { value: 'New York', matchMode: 'is' }; + + const result = service.filter(data, filters, ['name', 'age', 'city'], undefined); + + expect(result).toEqual([{ name: 'foo', age: 25, city: 'New York' }]); + }); + + it('should filter with isNot', () => { + const data = [ + { name: 'foo', age: 25, city: 'New York' }, + { name: 'bar', age: 30, city: 'Los Angeles' } + ]; + + const filters: FiltersArg = {}; + filters[globalFilterFieldName] = { value: 'New York', matchMode: 'isNot' }; + + const result = service.filter(data, filters, ['name', 'age', 'city'], undefined); + + expect(result).toEqual([{ name: 'bar', age: 30, city: 'Los Angeles' }]); + }); + + it('should filter with before', () => { + const data = [ + { name: 'foo', age: 25, city: 'New York' }, + { name: 'bar', age: 30, city: 'Los Angeles' } + ]; + + const filters: FiltersArg = {}; + filters[globalFilterFieldName] = { value: 30, matchMode: 'before' }; + + const result = service.filter(data, filters, ['name', 'age', 'city'], undefined); + + expect(result).toEqual([{ name: 'foo', age: 25, city: 'New York' }]); + }); + + it('should filter with after', () => { + const data = [ + { name: 'foo', age: 25, city: 'New York' }, + { name: 'bar', age: 30, city: 'Los Angeles' } + ]; + + const filters: FiltersArg = {}; + filters[globalFilterFieldName] = { value: 25, matchMode: 'after' }; + + const result = service.filter(data, filters, ['name', 'age', 'city'], undefined); + + expect(result).toEqual([{ name: 'bar', age: 30, city: 'Los Angeles' }]); + }); + + it('should filter with dateIs', () => { + const data = [ + { name: 'foo', age: 25, date: new Date(2020, 1, 1) }, + { name: 'bar', age: 30, date: new Date(2021, 1, 1) } + ]; + + const filters: FiltersArg = {}; + filters[globalFilterFieldName] = { value: new Date(2021, 1, 1), matchMode: 'dateIs' }; + + const result = service.filter(data, filters, ['name', 'age', 'date'], undefined); + + expect(result).toEqual([{ name: 'bar', age: 30, date: new Date(2021, 1, 1) }]); + }); + + it('should filter with dateIsNot', () => { + const data = [ + { name: 'foo', age: 25, date: new Date(2020, 1, 1) }, + { name: 'bar', age: 30, date: new Date(2021, 1, 1) } + ]; + + const filters: FiltersArg = {}; + filters[globalFilterFieldName] = { value: new Date(2021, 1, 1), matchMode: 'dateIsNot' }; + + const result = service.filter(data, filters, ['name', 'age', 'date'], undefined); + + expect(result).toEqual([{ name: 'foo', age: 25, date: new Date(2020, 1, 1) }]); + }); + + it('should filter with dateBefore', () => { + const data = [ + { name: 'foo', age: 25, date: new Date(2020, 1, 1) }, + { name: 'bar', age: 30, date: new Date(2021, 1, 1) } + ]; + + const filters: FiltersArg = {}; + filters[globalFilterFieldName] = { value: new Date(2021, 1, 1), matchMode: 'dateBefore' }; + + const result = service.filter(data, filters, ['name', 'age', 'date'], undefined); + + expect(result).toEqual([{ name: 'foo', age: 25, date: new Date(2020, 1, 1) }]); + }); + + it('should filter with dateAfter', () => { + const data = [ + { name: 'foo', age: 25, date: new Date(2020, 1, 1) }, + { name: 'bar', age: 30, date: new Date(2021, 1, 1) } + ]; + + const filters: FiltersArg = {}; + filters[globalFilterFieldName] = { value: new Date(2020, 1, 1), matchMode: 'dateAfter' }; + + const result = service.filter(data, filters, ['name', 'age', 'date'], undefined); + + expect(result).toEqual([{ name: 'bar', age: 30, date: new Date(2021, 1, 1) }]); + }); + }); + + describe('edge cases', () => { + it('should fail when match mode does not exist', () => { + const data = [{ name: 'foo' }, { name: 'bar' }]; + + const filters: FiltersArg = {}; + filters[globalFilterFieldName] = { value: 'new', matchMode: 'notExistingMatchMode' }; + + expect(() => { + service.filter(data, filters, ['name', 'age', 'city'], undefined); + }).toThrowError('Unsupported match mode: notExistingMatchMode'); + }); + + it('should work with missing props', () => { + const data = [ + { name: 'foo', age: 25, city: 'New York' }, + { name: 'bar', age: 25, city: 'Los Angeles', global: 'development' }, + { name: 'baz', age: 35, city: 'Vancouver', global: 'research' } + ]; + + const filters: FiltersArg = {}; + filters[globalFilterFieldName] = { value: 'development', matchMode: 'notContains' }; + + const result = service.filter(data, filters, ['name', 'age', 'city', 'global'], undefined); + + expect(result).toEqual([ + { name: 'foo', age: 25, city: 'New York' }, + { name: 'baz', age: 35, city: 'Vancouver', global: 'research' } + ]); + }); + }); + }); + + describe('by multiple/all fields aka global and single field(s) aka local', () => { + describe('typical cases', () => { + it('should filter with startsWith and notContains', () => { + const data = [ + { name: 'foo', age: 25, city: 'New York' }, + { name: 'bar', age: 30, city: 'Los Angeles' }, + { name: 'baz', age: 35, city: 'New York' } + ]; + + const filters: FiltersArg = { + name: { value: 'ba', matchMode: 'startsWith' } + }; + filters[globalFilterFieldName] = { value: 'new', matchMode: 'notContains' }; + + const result = service.filter(data, filters, ['name', 'age', 'city'], undefined); + + expect(result).toEqual([{ name: 'bar', age: 30, city: 'Los Angeles' }]); + }); + + it('should filter with contains and notContains', () => { + const data = [ + { name: 'foo', age: 25, city: 'New York' }, + { name: 'bar', age: 30, city: 'Los Angeles' }, + { name: 'baz', age: 35, city: 'New York' } + ]; + + const filters: FiltersArg = { + name: { value: 'oo', matchMode: 'contains' } + }; + filters[globalFilterFieldName] = { value: 'ang', matchMode: 'notContains' }; + + const result = service.filter(data, filters, ['name', 'age', 'city'], undefined); + + expect(result).toEqual([{ name: 'foo', age: 25, city: 'New York' }]); + }); + + it('should return empty data when filter results not overlap', () => { + const data = [ + { name: 'foo', age: 25, city: 'Berlin' }, + { name: 'bar', age: 30, city: 'Los Angeles' }, + { name: 'baz', age: 35, city: 'New York' } + ]; + + const filters: FiltersArg = { + name: { value: 'ba', matchMode: 'notContains' } + }; + filters[globalFilterFieldName] = { value: '25', matchMode: 'notContains' }; + + const result = service.filter(data, filters, ['name', 'age', 'city'], undefined); + + expect(result).toEqual([]); + }); + + it('should work with a prop `global`', () => { + const data = [ + { name: 'foo', age: 25, city: 'New York', global: 'research' }, + { name: 'bar', age: 25, city: 'Los Angeles', global: 'development' }, + { name: 'baz', age: 35, city: 'Vancouver', global: 'research' } + ]; + + const filters: FiltersArg = { + age: { value: '25', matchMode: 'lte' } + }; + filters[globalFilterFieldName] = { value: 'development', matchMode: 'notContains' }; + + const result = service.filter(data, filters, ['name', 'age', 'city', 'global'], undefined); + + expect(result).toEqual([{ name: 'foo', age: 25, city: 'New York', global: 'research' }]); + }); + + it('should work with missing props', () => { + const data = [ + { name: 'foo', age: 25, city: 'New York' }, + { name: 'bar', age: 25, city: 'Los Angeles', global: 'development' }, + { name: 'baz', age: 35, city: 'Vancouver', global: 'research' } + ]; + + const filters: FiltersArg = { + age: { value: '25', matchMode: 'lte' } + }; + filters[globalFilterFieldName] = { value: 'development', matchMode: 'notContains' }; + + const result = service.filter(data, filters, ['name', 'age', 'city', 'global'], undefined); + + expect(result).toEqual([{ name: 'foo', age: 25, city: 'New York' }]); + }); }); }); - describe('by all fields aka globally', () => {}); }); }); diff --git a/packages/primeng/src/table/table.ts b/packages/primeng/src/table/table.ts index 0175cd8245d..fe2d0711958 100644 --- a/packages/primeng/src/table/table.ts +++ b/packages/primeng/src/table/table.ts @@ -38,8 +38,8 @@ import { FormsModule } from '@angular/forms'; import { BlockableUI, FilterMatchMode, FilterMetadata, FilterOperator, FilterService, LazyLoadMeta, OverlayService, PrimeTemplate, ScrollerOptions, SelectItem, SharedModule, SortMeta, TableState, TranslationKeys } from 'primeng/api'; import { BaseComponent } from 'primeng/basecomponent'; import { Button, ButtonModule } from 'primeng/button'; -import { DatePickerModule } from 'primeng/datepicker'; import { CheckboxModule } from 'primeng/checkbox'; +import { DatePickerModule } from 'primeng/datepicker'; import { ConnectedOverlayScrollHandler, DomHandler } from 'primeng/dom'; import { ArrowDownIcon } from 'primeng/icons/arrowdown'; import { ArrowUpIcon } from 'primeng/icons/arrowup'; @@ -84,6 +84,12 @@ import { TableSelectAllChangeEvent } from './table.interface'; +import { FiltersArg } from '../api/fitlersarg'; + +// We use this key to avoid collision with a +// filters used for a column/filed with the name 'global'. +export const globalFilterFieldName = '__##__global__##__'; + @Injectable() export class TableService { private sortSource = new Subject(); @@ -100,6 +106,8 @@ export class TableService { totalRecordsSource$ = this.totalRecordsSource.asObservable(); columnsSource$ = this.columnsSource.asObservable(); + constructor(private filterService: FilterService) {} + onSort(sortMeta: SortMeta | SortMeta[] | null) { this.sortSource.next(sortMeta); } @@ -123,7 +131,151 @@ export class TableService { onColumnsChange(columns: any[]) { this.columnsSource.next(columns); } + + filter(data: any[], filters: FiltersArg, globalFilterFieldsArray: any[], filterLocale: string | undefined): any[] { + if (!data || data.length === 0) { + return []; + } + + if (!filters || Object.keys(filters).length === 0) { + return data; + } + + let filteredValue = []; + + for (let dataItem of data) { + let localMatch = true; + let globalMatch = false; + let localFiltered = false; + + for (let prop in filters) { + if (filters.hasOwnProperty(prop) && prop !== globalFilterFieldName) { + localFiltered = true; + let filterField = prop; + let filterMeta = filters[filterField]; + + if (Array.isArray(filterMeta)) { + const initialOperator = FilterOperator.AND; + let operator: FilterOperator = initialOperator; + + for (let meta of filterMeta) { + const currentMatch = this.executeLocalFilter(filterField, dataItem, meta, filterLocale); + + switch (operator) { + case FilterOperator.OR: + localMatch ||= currentMatch; + break; + + case FilterOperator.AND: + localMatch &&= currentMatch; + break; + } + + operator = meta.operator || FilterOperator.AND; + } + } else { + localMatch = this.executeLocalFilter(filterField, dataItem, filterMeta, filterLocale); + } + + if (!localMatch) { + break; + } + } + } + + if (filters[globalFilterFieldName] && !globalMatch && globalFilterFieldsArray) { + globalMatch = this.initGlobalMatch((filters[globalFilterFieldName]).matchMode); + + for (let globalField of globalFilterFieldsArray) { + let globalFilterField = globalField.field || globalField; + const matchMode = (filters[globalFilterFieldName]).matchMode; + + const resolvedData = ObjectUtils.resolveFieldData(dataItem, globalFilterField); + + let currentMatch = (this.filterService).filters[matchMode](resolvedData, (filters[globalFilterFieldName]).value, filterLocale); + + if (!resolvedData && this.isNegating(matchMode)) { + currentMatch = true; + } + + if (this.concatWithOr(matchMode)) { + globalMatch ||= currentMatch; + if (currentMatch) { + break; + } + } else if (this.concatWithAnd(matchMode)) { + globalMatch &&= currentMatch; + if (!currentMatch) { + break; + } + } + } + } + + let matches: boolean; + if (filters[globalFilterFieldName]) { + matches = localFiltered ? localFiltered && localMatch && globalMatch : globalMatch; + } else { + matches = localFiltered && localMatch; + } + + if (matches) { + filteredValue.push(dataItem); + } + } + + return filteredValue; + } + + private initGlobalMatch(matchMode: string): boolean { + if (this.concatWithOr(matchMode)) { + return false; + } else if (this.concatWithAnd(matchMode)) { + return true; + } else { + throw new Error(`Unsupported match mode: ${matchMode}`); + } + } + + private concatWithOr(matchMode: string): boolean { + return [ + FilterMatchMode.STARTS_WITH, + FilterMatchMode.CONTAINS, + FilterMatchMode.ENDS_WITH, + FilterMatchMode.EQUALS, + FilterMatchMode.IN, + FilterMatchMode.LESS_THAN, + FilterMatchMode.LESS_THAN_OR_EQUAL_TO, + FilterMatchMode.GREATER_THAN, + FilterMatchMode.GREATER_THAN_OR_EQUAL_TO, + FilterMatchMode.BETWEEN, + FilterMatchMode.IS, + FilterMatchMode.BEFORE, + FilterMatchMode.AFTER, + FilterMatchMode.DATE_IS, + FilterMatchMode.DATE_BEFORE, + FilterMatchMode.DATE_AFTER + ].includes(matchMode); + } + + private concatWithAnd(matchMode: string): boolean { + return this.isNegating(matchMode); + } + + private isNegating(matchMode: string): boolean { + return [FilterMatchMode.NOT_CONTAINS, FilterMatchMode.NOT_EQUALS, FilterMatchMode.IS_NOT, FilterMatchMode.DATE_IS_NOT].includes(matchMode); + } + + private executeLocalFilter(field: string, rowData: any, filterMeta: FilterMetadata, filterLocale: string | undefined): boolean { + const filterValue = filterMeta.value; + const filterMatchMode = filterMeta.matchMode || FilterMatchMode.STARTS_WITH; + const dataFieldValue = ObjectUtils.resolveFieldData(rowData, field); + const filterConstraint = (this.filterService).filters[filterMatchMode]; + + return filterConstraint(dataFieldValue, filterValue, filterLocale); + } } + /** * Table displays data in tabular format. * @group Components @@ -2209,7 +2361,7 @@ export class Table extends BaseComponent implements OnInit, AfterViewInit, After } filterGlobal(value: any, matchMode: string) { - this.filter(value, 'global', matchMode); + this.filter(value, globalFilterFieldName, matchMode); } isFilterBlank(filter: any): boolean { @@ -2240,64 +2392,12 @@ export class Table extends BaseComponent implements OnInit, AfterViewInit, After } } else { let globalFilterFieldsArray; - if (this.filters['global']) { + if (this.filters[globalFilterFieldName]) { if (!this.columns && !this.globalFilterFields) throw new Error('Global filtering requires dynamic columns or globalFilterFields to be defined.'); else globalFilterFieldsArray = this.globalFilterFields || this.columns; } - this.filteredValue = []; - - for (let i = 0; i < this.value.length; i++) { - let localMatch = true; - let globalMatch = false; - let localFiltered = false; - - for (let prop in this.filters) { - if (this.filters.hasOwnProperty(prop) && prop !== 'global') { - localFiltered = true; - let filterField = prop; - let filterMeta = this.filters[filterField]; - - if (Array.isArray(filterMeta)) { - for (let meta of filterMeta) { - localMatch = this.executeLocalFilter(filterField, this.value[i], meta); - - if ((meta.operator === FilterOperator.OR && localMatch) || (meta.operator === FilterOperator.AND && !localMatch)) { - break; - } - } - } else { - localMatch = this.executeLocalFilter(filterField, this.value[i], filterMeta); - } - - if (!localMatch) { - break; - } - } - } - - if (this.filters['global'] && !globalMatch && globalFilterFieldsArray) { - for (let j = 0; j < globalFilterFieldsArray.length; j++) { - let globalFilterField = globalFilterFieldsArray[j].field || globalFilterFieldsArray[j]; - globalMatch = (this.filterService).filters[(this.filters['global']).matchMode](ObjectUtils.resolveFieldData(this.value[i], globalFilterField), (this.filters['global']).value, this.filterLocale); - - if (globalMatch) { - break; - } - } - } - - let matches: boolean; - if (this.filters['global']) { - matches = localFiltered ? localFiltered && localMatch && globalMatch : globalMatch; - } else { - matches = localFiltered && localMatch; - } - - if (matches) { - this.filteredValue.push(this.value[i]); - } - } + this.filteredValue = this.tableService.filter(this.value, this.filters, globalFilterFieldsArray, this.filterLocale); if (this.filteredValue.length === this.value.length) { this.filteredValue = null; @@ -2359,7 +2459,7 @@ export class Table extends BaseComponent implements OnInit, AfterViewInit, After sortField: this.sortField, sortOrder: this.sortOrder, filters: this.filters, - globalFilter: this.filters && this.filters['global'] ? (this.filters['global']).value : null, + globalFilter: this.filters && this.filters[globalFilterFieldName] ? (this.filters[globalFilterFieldName]).value : null, multiSortMeta: this.multiSortMeta, forceUpdate: () => this.cd.detectChanges() };