diff --git a/src/module.d.ts b/src/module.d.ts index facf4148..6d575f38 100644 --- a/src/module.d.ts +++ b/src/module.d.ts @@ -1,4 +1,5 @@ import * as React from 'react'; +import * as Immutable from 'immutable'; import { connect as originalConnect } from 'react-redux'; import { ActionCreator, Middleware } from 'redux'; @@ -203,6 +204,18 @@ const SettingsContainer: (OriginalComponent: any) => any; const SettingsComponents: PropertyBag>; +export interface FilterProps { + setFilter?: (filter: GriddleFilter) => void; + placeholder?: string; + className?: string; + style?: React.CSSProperties; + + [x: string]: any; +} + +class Filter extends React.Component { +} + } // namespace components export interface GriddleComponents { @@ -216,10 +229,10 @@ export interface GriddleComponents { StyleContainer?: (OriginalComponent: GriddleComponent) => GriddleComponent; StyleContainerEnhancer?: (OriginalComponent: GriddleComponent) => GriddleComponent; - Filter?: GriddleComponent; - FilterEnhancer?: (OriginalComponent: GriddleComponent) => GriddleComponent; - FilterContainer?: (OriginalComponent: GriddleComponent) => GriddleComponent; - FilterContainerEnhancer?: (OriginalComponent: GriddleComponent) => GriddleComponent; + Filter?: GriddleComponent; + FilterEnhancer?: (OriginalComponent: GriddleComponent) => GriddleComponent; + FilterContainer?: (OriginalComponent: GriddleComponent) => GriddleComponent; + FilterContainerEnhancer?: (OriginalComponent: GriddleComponent) => GriddleComponent; SettingsWrapper?: GriddleComponent; SettingsWrapperEnhancer?: (OriginalComponent: GriddleComponent) => GriddleComponent; @@ -294,12 +307,15 @@ export interface GriddleComponents { PreviousButtonContainerEnhancer?: (OriginalComponent: GriddleComponent) => GriddleComponent; } +export type RowFilter = (row: any, index: number, data: Immutable.List) => boolean; +export type GriddleFilter = string | RowFilter | PropertyBag; + export interface GriddleActions extends PropertyBag | undefined> { onSort?: (sortProperties: any) => void; onNext?: () => void; onPrevious?: () => void; onGetPage?: (pageNumber: number) => void; - setFilter?: (filterText: string) => void; + setFilter?: (filter: GriddleFilter) => void; } export interface GriddleEvents extends GriddleActions { diff --git a/src/plugins/local/reducers/__tests__/localReducerTests.js b/src/plugins/local/reducers/__tests__/localReducerTests.js index 42e2080d..6a12d873 100644 --- a/src/plugins/local/reducers/__tests__/localReducerTests.js +++ b/src/plugins/local/reducers/__tests__/localReducerTests.js @@ -40,12 +40,46 @@ test('sets page size', test => { test.is(state.getIn(['pageProperties', 'pageSize']), 11); }); -test('sets filter', test => { +test('sets filter null', test => { const state = reducers.GRIDDLE_SET_FILTER(new Immutable.Map(), { - filter: 'onetwothree', + filter: null, }); - test.is(state.get('filter'), 'onetwothree'); + test.is(state.get('filter'), null); + test.is(state.getIn(['pageProperties', 'currentPage']), 1) +}); + +test('sets filter string', test => { + const filter = 'onetwothree'; + const state = reducers.GRIDDLE_SET_FILTER(new Immutable.Map(), { + filter + }); + + test.is(state.get('filter'), filter); + test.is(state.getIn(['pageProperties', 'currentPage']), 1) +}); + +test('sets filter function', test => { + const filter = (v, i) => i % 2; + const state = reducers.GRIDDLE_SET_FILTER(new Immutable.Map(), { + filter, + }); + + test.is(state.get('filter'), filter); + test.is(state.getIn(['pageProperties', 'currentPage']), 1) +}); + +test('sets filter object', test => { + const filter = { + id: (v, i) => i % 2, + name: 'ben', + }; + const state = reducers.GRIDDLE_SET_FILTER(new Immutable.Map(), { + filter, + }); + + test.not(state.get('filter'), filter); + test.deepEqual(state.get('filter').toJS(), filter); test.is(state.getIn(['pageProperties', 'currentPage']), 1) }); diff --git a/src/plugins/local/reducers/index.js b/src/plugins/local/reducers/index.js index 7b8dec64..4cc9ad85 100644 --- a/src/plugins/local/reducers/index.js +++ b/src/plugins/local/reducers/index.js @@ -1,3 +1,4 @@ +import Immutable from 'immutable'; import { maxPageSelector, currentPageSelector } from '../selectors/localSelectors'; import * as dataReducers from '../../../reducers//dataReducer'; @@ -63,7 +64,7 @@ export function GRIDDLE_PREVIOUS_PAGE(state, action) { */ export function GRIDDLE_SET_FILTER(state, action) { return state - .set('filter', action.filter) + .set('filter', action.filter && Immutable.fromJS(action.filter)) .setIn(['pageProperties', 'currentPage'], 1); }; diff --git a/src/plugins/local/selectors/__tests__/localSelectorsTest.js b/src/plugins/local/selectors/__tests__/localSelectorsTest.js index 51978194..ba7514ac 100644 --- a/src/plugins/local/selectors/__tests__/localSelectorsTest.js +++ b/src/plugins/local/selectors/__tests__/localSelectorsTest.js @@ -4,9 +4,9 @@ import Immutable from 'immutable'; import * as selectors from '../localSelectors'; test('gets data', test => { - const state = new Immutable.Map({ data: 'hi' }); + const state = new Immutable.Map({ data: 'hi' }); - test.deepEqual(selectors.dataSelector(state), 'hi'); + test.deepEqual(selectors.dataSelector(state), 'hi'); }); test('gets current page', test => { @@ -65,8 +65,8 @@ test('gets empty string when filter not present', test => { test('gets sort properties', test => { const state = new Immutable.fromJS({ sortProperties: [ - { id: 'one', sortAscending: true }, - { id: 'two', sortAscending: false } + { id: 'one', sortAscending: true }, + { id: 'two', sortAscending: false }, ] }); @@ -113,7 +113,7 @@ test('gets column orders', test => { test('gets visible columns when columns specified without order', test => { const state = new Immutable.fromJS({ data: [ - { one: 'hi', two: 'hello', three: 'this should not show up'} + { one: 'hi', two: 'hello', three: 'this should not show up' }, ], renderProperties: { columnProperties: { @@ -129,7 +129,7 @@ test('gets visible columns when columns specified without order', test => { test('gets visible columns in order when columns specified', test => { const state = new Immutable.fromJS({ data: [ - { one: 'hi', two: 'hello', three: 'this should not show up'} + { one: 'hi', two: 'hello', three: 'this should not show up' }, ], renderProperties: { columnProperties: { @@ -145,7 +145,7 @@ test('gets visible columns in order when columns specified', test => { test('gets all columns as visible columns when no columns specified', test => { const state = new Immutable.fromJS({ data: [ - { one: 'hi', two: 'hello', three: 'this should not show up'} + { one: 'hi', two: 'hello', three: 'this should not show up' }, ] }); @@ -175,7 +175,7 @@ test('hasNextSelector returns true when more pages', test => { test('hasNextSelector returns false when no more pages', test => { const state = new Immutable.fromJS({ - data: [ + data: [ { one: 1 }, { two: 2 }, { three: 3 }, @@ -218,31 +218,137 @@ test('filteredDataSelector returns all data when no filter present', test => { const state = new Immutable.fromJS({ data: [ { id: '1', name: 'luke skywalker' }, - { id: '2', name: 'han solo' } - ] + { id: '2', name: 'han solo' }, + ], + }); + + test.deepEqual(selectors.filteredDataSelector(state).toJSON(), + state.get('data').toJSON()); +}); + +test('filteredDataSelector returns all data when filter is null', test => { + const state = new Immutable.fromJS({ + filter: null, + data: [ + { id: '1', name: 'luke skywalker' }, + { id: '2', name: 'han solo' }, + ], + }); + + test.deepEqual(selectors.filteredDataSelector(state).toJSON(), + state.get('data').toJSON()); +}); + +test('filteredDataSelector returns all data when filter is empty string', test => { + const state = new Immutable.fromJS({ + filter: '', + data: [ + { id: '1', name: 'luke skywalker' }, + { id: '2', name: 'han solo' }, + ], + }); + + test.deepEqual(selectors.filteredDataSelector(state).toJSON(), + state.get('data').toJSON()); +}); + +test('filteredDataSelector filters data when filter string present', test => { + const state = new Immutable.fromJS({ + filter: 'luke', + data: [ + { id: '1', name: 'luke skywalker' }, + { id: '2', name: 'han solo' }, + ], }); test.deepEqual(selectors.filteredDataSelector(state).toJSON(), [ { id: '1', name: 'luke skywalker' }, - { id: '2', name: 'han solo' } ]); }); -test('filteredDataSelector filters data when filter string present', test => { +test('filteredDataSelector filters data when filter function present', test => { const state = new Immutable.fromJS({ - filter: 'luke', + filter: function (row, i, data) { + test.deepEqual(data, state.get('data')); + return row.get("name") === 'luke skywalker' && i % 2; + }, data: [ + { id: '0', name: 'luke skywalker' }, { id: '1', name: 'luke skywalker' }, - { id: '2', name: 'han solo' } - ] + { id: '2', name: 'han solo' }, + ], }); test.deepEqual(selectors.filteredDataSelector(state).toJSON(), [ - { id: '1', name: 'luke skywalker' } + { id: '1', name: 'luke skywalker' }, + ]); +}); + +test('filteredDataSelector returns all data when filter object has null or empty string', test => { + const state = new Immutable.fromJS({ + filter: { + id: null, + name: '', + }, + data: [ + { id: '1', name: 'luke skywalker' }, + { id: '2', name: 'han solo' }, + ], + }); + + test.deepEqual(selectors.filteredDataSelector(state).toJSON(), + state.get('data').toJSON()); +}); + +test('filteredDataSelector filters data when filter object present', test => { + const state = Immutable.fromJS({ + filter: { name: 'luke' }, + data: [ + { id: '1', name: 'luke skywalker' }, + { id: '2', name: 'han solo' }, + ], + }); + test.deepEqual(selectors.filteredDataSelector(state).toJSON(), [ + { id: '1', name: 'luke skywalker' }, + ]); +}); + +test('filteredDataSelector filters data with multiple filters', test => { + const state = Immutable.fromJS({ + filter: { id: '1', name: 'luke' }, + data: [ + { id: '1', name: 'luke skywalker' }, + { id: '1', name: 'han solo' }, + { id: '3', name: 'luke skywalker' }, + { id: '2', name: 'han solo' }, + ], + }); + test.deepEqual(selectors.filteredDataSelector(state).toJSON(), [ + { id: '1', name: 'luke skywalker' }, + ]); +}); + +test('filteredDataSelector filters data when filter object with filter function', test => { + const state = Immutable.fromJS({ + filter: { + name: function (rowName, i, data) { + test.deepEqual(data, state.get('data')); + return rowName.length === 14 && i % 2; + }, + }, + data: [ + { id: '0', name: 'luke skywalker' }, + { id: '1', name: 'luke skywalker' }, + { id: '2', name: 'han solo' }, + ], + }); + + test.deepEqual(selectors.filteredDataSelector(state).toJSON(), [ + { id: '1', name: 'luke skywalker' }, ]); }); -test('filteredDataSelector filters data respecting filterable', test => { +test('filteredDataSelector filter by string respects filterable', test => { const state = new Immutable.fromJS({ renderProperties: { columnProperties: { @@ -251,18 +357,49 @@ test('filteredDataSelector filters data respecting filterable', test => { }, weapon: { filterable: true, - } - } + }, + }, }, filter: 'H', data: [ { id: '1', name: 'luke skywalker', weapon: 'light saber' }, { id: '2', name: 'han solo', weapon: 'blaster' }, - ] + ], }); test.deepEqual(selectors.filteredDataSelector(state).toJSON(), [ - { id: '1', name: 'luke skywalker', weapon: 'light saber' } + { id: '1', name: 'luke skywalker', weapon: 'light saber' }, + ]); +}); + +test('filteredDataSelector filter by object respects filterable', test => { + const state = new Immutable.fromJS({ + renderProperties: { + columnProperties: { + id: { + filterable: false, + }, + name: { + filterable: false, + }, + weapon: { + filterable: true, + } + }, + }, + filter: { + id: () => false, + name: 'z', + weapon: 'g', + }, + data: [ + { id: '1', name: 'luke skywalker', weapon: 'light saber' }, + { id: '2', name: 'han solo', weapon: 'blaster' }, + ], + }); + + test.deepEqual(selectors.filteredDataSelector(state).toJSON(), [ + { id: '1', name: 'luke skywalker', weapon: 'light saber' }, ]); }); @@ -348,7 +485,7 @@ test('sortedDataSelector works with multiple sortOptions', test => { { id: '1', name: 'luke skywalker', food: 'orange' }, { id: '2', name: 'han solo', food: 'banana' }, { id: '3', name: 'han solo', food: 'apple' }, - { id: '4', name: 'luke skywalker', food: 'apple'} + { id: '4', name: 'luke skywalker', food: 'apple' }, ], sortProperties: [ { id: 'name', sortAscending: true }, @@ -370,7 +507,7 @@ test('current page data selector gets correct page', test => { { id: '1', name: 'luke skywalker', food: 'orange' }, { id: '2', name: 'han solo', food: 'banana' }, { id: '3', name: 'han solo', food: 'apple' }, - { id: '4', name: 'luke skywalker', food: 'apple'} + { id: '4', name: 'luke skywalker', food: 'apple' }, ], pageProperties: { currentPage: 3, @@ -387,7 +524,7 @@ test('visible data selector gets only visible columns', test => { { id: '1', name: 'luke skywalker', food: 'orange' }, { id: '2', name: 'han solo', food: 'banana' }, { id: '3', name: 'han solo', food: 'apple' }, - { id: '4', name: 'luke skywalker', food: 'apple'} + { id: '4', name: 'luke skywalker', food: 'apple' }, ], renderProperties: { columnProperties: { @@ -414,7 +551,7 @@ test('visibleRowIdsSelector gets row ids', test => { { id: '1', name: 'luke skywalker', food: 'orange', griddleKey: 1 }, { id: '2', name: 'han solo', food: 'banana', griddleKey: 2 }, { id: '3', name: 'han solo', food: 'apple', griddleKey: 3 }, - { id: '4', name: 'luke skywalker', food: 'apple', griddleKey: 4} + { id: '4', name: 'luke skywalker', food: 'apple', griddleKey: 4 }, ], renderProperties: { columnProperties: { @@ -438,7 +575,7 @@ test('hidden columns selector shows all columns that are not visible', test => { { id: '1', name: 'luke skywalker', food: 'orange' }, { id: '2', name: 'han solo', food: 'banana' }, { id: '3', name: 'han solo', food: 'apple' }, - { id: '4', name: 'luke skywalker', food: 'apple'} + { id: '4', name: 'luke skywalker', food: 'apple' }, ], renderProperties: { columnProperties: { @@ -457,12 +594,12 @@ test('hidden columns selector shows all columns that are not visible', test => { }); test('columnIdsSelector gets all column ids', test => { - const state = new Immutable.fromJS({ + const state = new Immutable.fromJS({ data: [ { id: '1', name: 'luke skywalker', food: 'orange' }, { id: '2', name: 'han solo', food: 'banana' }, { id: '3', name: 'han solo', food: 'apple' }, - { id: '4', name: 'luke skywalker', food: 'apple'} + { id: '4', name: 'luke skywalker', food: 'apple' }, ], renderProperties: { columnProperties: { @@ -495,7 +632,7 @@ test('columnTitlesSelector gets all column titles', test => { { id: '1', name: 'luke skywalker', food: 'orange' }, { id: '2', name: 'han solo', food: 'banana' }, { id: '3', name: 'han solo', food: 'apple' }, - { id: '4', name: 'luke skywalker', food: 'apple'} + { id: '4', name: 'luke skywalker', food: 'apple' }, ], renderProperties: { columnProperties: { diff --git a/src/plugins/local/selectors/localSelectors.js b/src/plugins/local/selectors/localSelectors.js index 41d9e35b..33ec878d 100644 --- a/src/plugins/local/selectors/localSelectors.js +++ b/src/plugins/local/selectors/localSelectors.js @@ -37,6 +37,58 @@ export const metaDataColumnsSelector = dataSelectors.metaDataColumnsSelector; const columnPropertiesSelector = state => state.getIn(['renderProperties', 'columnProperties']); +const substringSearch = (value, filter) => { + if (!filter) { + return true; + } + + const filterToLower = filter.toLowerCase(); + return value && value.toString().toLowerCase().indexOf(filterToLower) > -1; +}; + +const filterable = (columnProperties, key) => { + if (key === 'griddleKey') { + return false; + } + if (columnProperties) { + if (columnProperties.get(key) === undefined || + columnProperties.getIn([key, 'filterable']) === false) { + return false; + } + } + return true; +}; + +const textFilterRowSearch = (columnProperties, filter) => (row) => { + return row.keySeq() + .some((key) => { + if (!filterable(columnProperties, key)) { + return false; + } + return substringSearch(row.get(key), filter); + }); +}; + +const objectFilterRowSearch = (columnProperties, filter) => (row, i, data) => { + return row.keySeq().every((key) => { + if (!filterable(columnProperties, key)) { + return true; + } + const keyFilter = filter.get(key); + switch (typeof (keyFilter)) { + case 'string': + return substringSearch(row.get(key), keyFilter); + break; + case 'function': + return keyFilter(row.get(key), i, data); + break; + default: + return true; + break; + } + }); +}; + /** Gets the data filtered by the current filter */ export const filteredDataSelector = createSelector( @@ -48,22 +100,16 @@ export const filteredDataSelector = createSelector( return data; } - const filterToLower = filter.toLowerCase(); - return data.filter(row => - row.keySeq() - .some((key) => { - if (key === 'griddleKey') { - return false; - } else if (columnProperties) { - if (columnProperties.get(key) === undefined || - columnProperties.getIn([key, 'filterable']) === false) { - return false; - } - } - const value = row.get(key); - return value && - value.toString().toLowerCase().indexOf(filterToLower) > -1; - })); + switch (typeof (filter)) { + case 'string': + return data.filter(textFilterRowSearch(columnProperties, filter)); + case 'object': + return data.filter(objectFilterRowSearch(columnProperties, filter)); + case 'function': + return data.filter(filter); + default: + return data; + } } ); @@ -185,7 +231,7 @@ export const columnIdsSelector = createSelector( visibleDataSelector, renderPropertiesSelector, (visibleData, renderProperties) => { - if(visibleData.size > 0) { + if (visibleData.size > 0) { return Object.keys(visibleData.get(0).toJSON()).map(k => renderProperties.getIn(['columnProperties', k, 'id']) || k ) diff --git a/stories/index.tsx b/stories/index.tsx index 05b8b844..aff876e0 100644 --- a/stories/index.tsx +++ b/stories/index.tsx @@ -1210,6 +1210,37 @@ storiesOf('Filter', module) ) }) + .add('with Custom Filter for the column "name"', () => { + class CustomFilter extends components.Filter { + public setFilter = (e: any) => { + this.props.setFilter({ + name: e.target.value, + }); + } + public render() { + return ( + + ); + } + } + + return ( + + + + + ) + }) storiesOf('Redux', module) .add('with custom filter connected to another Redux store', () => {