From 1f6a6039ffc1631530572800039a9561901e203f Mon Sep 17 00:00:00 2001 From: Jeffrey Phillips Date: Mon, 18 Dec 2017 15:54:20 -0500 Subject: [PATCH] feat(Filter): Add Filter Component --- package.json | 2 +- src/components/Filter/Filter.js | 23 + src/components/Filter/Filter.stories.js | 47 ++ src/components/Filter/Filter.test.js | 77 ++ .../Filter/FilterCategorySelector.js | 93 +++ .../Filter/FilterCategoryValueSelector.js | 87 +++ src/components/Filter/FilterTypeSelector.js | 79 ++ src/components/Filter/FilterValueSelector.js | 85 +++ .../Filter/__mocks__/mockFilterExample.js | 521 +++++++++++++ .../Filter/__snapshots__/Filter.test.js.snap | 709 ++++++++++++++++++ src/components/Filter/index.js | 18 + src/index.js | 1 + 12 files changed, 1741 insertions(+), 1 deletion(-) create mode 100644 src/components/Filter/Filter.js create mode 100644 src/components/Filter/Filter.stories.js create mode 100644 src/components/Filter/Filter.test.js create mode 100644 src/components/Filter/FilterCategorySelector.js create mode 100644 src/components/Filter/FilterCategoryValueSelector.js create mode 100644 src/components/Filter/FilterTypeSelector.js create mode 100644 src/components/Filter/FilterValueSelector.js create mode 100644 src/components/Filter/__mocks__/mockFilterExample.js create mode 100644 src/components/Filter/__snapshots__/Filter.test.js.snap create mode 100644 src/components/Filter/index.js diff --git a/package.json b/package.json index 2d484af3350..eb96ce28c7c 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "homepage": "https://github.com/patternfly/patternfly-react#readme", "dependencies": { "classnames": "^2.2.5", - "patternfly": "^3.31.0", + "patternfly": "^3.35.0", "react-bootstrap": "^0.31.5", "react-c3js": "^0.1.20", "react-fontawesome": "^1.6.1", diff --git a/src/components/Filter/Filter.js b/src/components/Filter/Filter.js new file mode 100644 index 00000000000..ba9b048c018 --- /dev/null +++ b/src/components/Filter/Filter.js @@ -0,0 +1,23 @@ +import cx from 'classnames'; +import React from 'react'; +import PropTypes from 'prop-types'; + +const Filter = ({ children, className, ...rest }) => { + const classes = cx('filter-pf form-group', className); + return ( +
+
+
{children}
+
+
+ ); +}; + +Filter.propTypes = { + /** Children nodes */ + children: PropTypes.node, + /** Additional css classes */ + className: PropTypes.string +}; + +export default Filter; diff --git a/src/components/Filter/Filter.stories.js b/src/components/Filter/Filter.stories.js new file mode 100644 index 00000000000..8006ac3b915 --- /dev/null +++ b/src/components/Filter/Filter.stories.js @@ -0,0 +1,47 @@ +import React from 'react'; +import { storiesOf } from '@storybook/react'; +import { defaultTemplate } from '../../../storybook/decorators/storyTemplates'; +import { withInfo } from '@storybook/addon-info/dist/index'; +import { + Filter, + FilterTypeSelector, + FilterValueSelector, + FilterCategorySelector, + FilterCategoryValueSelector +} from '../../index'; + +import { + MockFilterExample, + mockFilterExampleSource +} from './__mocks__/mockFilterExample'; + +const stories = storiesOf('Filter', module); + +stories.addDecorator( + defaultTemplate({ + title: 'Filter', + documentationLink: + 'http://www.patternfly.org/pattern-library/forms-and-controls/filter/' + }) +); + +stories.add( + 'Filter', + withInfo({ + source: false, + propTables: [ + Filter, + FilterTypeSelector, + FilterValueSelector, + FilterCategorySelector, + FilterCategoryValueSelector + ], + propTablesExclude: [MockFilterExample], + text: ( +
+

Story Source

+
{mockFilterExampleSource}
+
+ ) + })(() => ) +); diff --git a/src/components/Filter/Filter.test.js b/src/components/Filter/Filter.test.js new file mode 100644 index 00000000000..bc5e3d52154 --- /dev/null +++ b/src/components/Filter/Filter.test.js @@ -0,0 +1,77 @@ +import React from 'react'; +import renderer from 'react-test-renderer'; +import { + Filter, + FilterTypeSelector, + FilterValueSelector, + FilterCategorySelector, + FilterCategoryValueSelector +} from '../../index'; +import { mockFilterExampleFields } from './__mocks__/mockFilterExample'; + +test('Filter input renders properly', () => { + const component = renderer.create( + + + + + ); + + const tree = component.toJSON(); + expect(tree).toMatchSnapshot(); +}); + +test('Filter select renders properly', () => { + const component = renderer.create( + + + + + ); + + const tree = component.toJSON(); + expect(tree).toMatchSnapshot(); +}); + +test('Filter categories renders properly', () => { + const component = renderer.create( + + + + + + + ); + + const tree = component.toJSON(); + expect(tree).toMatchSnapshot(); +}); diff --git a/src/components/Filter/FilterCategorySelector.js b/src/components/Filter/FilterCategorySelector.js new file mode 100644 index 00000000000..9689f217dd2 --- /dev/null +++ b/src/components/Filter/FilterCategorySelector.js @@ -0,0 +1,93 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { DropdownButton } from '../Button'; +import { MenuItem } from '../MenuItem'; +import cx from 'classnames'; + +const FilterCategorySelector = ({ + children, + className, + id, + filterCategories, + currentCategory, + placeholder, + onFilterCategorySelected, + ...rest +}) => { + let classes = cx('filter-pf-category-select', className); + + if (placeholder || (filterCategories && filterCategories.length > 1)) { + let title; + if (currentCategory) { + title = currentCategory.title || currentCategory; + } else { + title = placeholder || filterCategories[0].title || filterCategories[0]; + } + + let menuId = 'filterCategoryMenu'; + menuId += id ? `_${id}` : ''; + + return ( +
+
+ + {placeholder && ( + + onFilterCategorySelected && onFilterCategorySelected() + } + > + {placeholder} + + )} + {filterCategories && + filterCategories.map((item, index) => { + let classes = { + selected: item === currentCategory + }; + return ( + + onFilterCategorySelected && onFilterCategorySelected(item) + } + > + {item.title || item} + + ); + })} + +
+ {children} +
+ ); + } else { + return null; + } +}; + +FilterCategorySelector.propTypes = { + /** Children nodes */ + children: PropTypes.node, + /** Additional css classes */ + className: PropTypes.string, + /** ID for the component, necessary for accessibility if there are multiple filters on a page */ + id: PropTypes.string, + /** Array of filter categories, each can be a string or an object with a 'title' field */ + filterCategories: PropTypes.array.isRequired, + /** Current selected category */ + currentCategory: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), + /** Placeholder text when no category is selected */ + placeholder: PropTypes.string, + /** function(field, value) - Callback to call when a category is added */ + onFilterCategorySelected: PropTypes.func +}; + +export default FilterCategorySelector; diff --git a/src/components/Filter/FilterCategoryValueSelector.js b/src/components/Filter/FilterCategoryValueSelector.js new file mode 100644 index 00000000000..906c1121e22 --- /dev/null +++ b/src/components/Filter/FilterCategoryValueSelector.js @@ -0,0 +1,87 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { DropdownButton } from '../Button'; +import { MenuItem } from '../MenuItem'; +import cx from 'classnames'; + +const FilterCategoryValueSelector = ({ + className, + id, + categoryValues, + currentValue, + placeholder, + onCategoryValueSelected, + ...rest +}) => { + let classes = cx('filter-pf-select', className); + + if (placeholder || (categoryValues && categoryValues.length > 1)) { + let title; + if (currentValue) { + title = currentValue.title || currentValue; + } else { + title = placeholder || categoryValues[0].title || categoryValues[0]; + } + + let menuId = 'filterCategoryMenu'; + menuId += id ? `_${id}` : ''; + + return ( +
+ + {placeholder && ( + + onCategoryValueSelected && onCategoryValueSelected() + } + > + {placeholder} + + )} + {categoryValues && + categoryValues.map((item, index) => { + let classes = { + selected: item === currentValue + }; + return ( + + onCategoryValueSelected && onCategoryValueSelected(item) + } + > + {item.title || item} + + ); + })} + +
+ ); + } else { + return null; + } +}; + +FilterCategoryValueSelector.propTypes = { + /** Additional css classes */ + className: PropTypes.string, + /** ID for the filter component, necessary for accessibility if there are multiple filters on a page */ + id: PropTypes.string, + /** Array of valid values for the category to select from, each can be a string or an object with a 'title' field */ + categoryValues: PropTypes.array, + /** Currently selected category value */ + currentValue: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), + /** Placeholder text when no category value is selected */ + placeholder: PropTypes.string, + /** function(field, value) - Callback to call when a category value is selected */ + onCategoryValueSelected: PropTypes.func +}; + +export default FilterCategoryValueSelector; diff --git a/src/components/Filter/FilterTypeSelector.js b/src/components/Filter/FilterTypeSelector.js new file mode 100644 index 00000000000..04bcb6fd058 --- /dev/null +++ b/src/components/Filter/FilterTypeSelector.js @@ -0,0 +1,79 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { DropdownButton } from '../Button'; +import { MenuItem } from '../MenuItem'; +import cx from 'classnames'; + +const FilterTypeSelector = ({ + className, + id, + filterTypes, + currentFilterType, + placeholder, + onFilterTypeSelected, + ...rest +}) => { + const classes = cx('input-group-btn', className); + if (placeholder || (filterTypes && filterTypes.length > 1)) { + let title; + if (currentFilterType) { + title = currentFilterType.title || currentFilterType; + } else { + title = placeholder || filterTypes[0].title || filterTypes[0]; + } + + let menuId = 'filterFieldTypeMenu'; + menuId += id ? `_${id}` : ''; + + return ( +
+ + {placeholder && ( + onFilterTypeSelected && onFilterTypeSelected()} + > + {placeholder} + + )} + {filterTypes.map((item, index) => { + let classes = { + selected: item === currentFilterType + }; + return ( + + onFilterTypeSelected && onFilterTypeSelected(item) + } + > + {item.title || item} + + ); + })} + +
+ ); + } else { + return null; + } +}; + +FilterTypeSelector.propTypes = { + /** Additional css classes */ + className: PropTypes.string, + /** ID for the filter component, necessary for accessibility if there are multiple filters on a page */ + id: PropTypes.string, + /** Array of filter types, can be a string or an object with a 'title' field */ + filterTypes: PropTypes.array.isRequired, + /** Current selected filter type */ + currentFilterType: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), + /** Placeholder text when no filter type is selected */ + placeholder: PropTypes.string, + /** function(field, value) - Callback to call when a filter type is selected */ + onFilterTypeSelected: PropTypes.func +}; + +export default FilterTypeSelector; diff --git a/src/components/Filter/FilterValueSelector.js b/src/components/Filter/FilterValueSelector.js new file mode 100644 index 00000000000..aaf99d73b28 --- /dev/null +++ b/src/components/Filter/FilterValueSelector.js @@ -0,0 +1,85 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { DropdownButton } from '../Button'; +import { MenuItem } from '../MenuItem'; +import cx from 'classnames'; + +const FilterValueSelector = ({ + className, + id, + filterValues, + currentValue, + placeholder, + onFilterValueSelected, + ...rest +}) => { + let classes = cx('filter-pf-select', className); + + if (placeholder || (filterValues && filterValues.length > 1)) { + let title; + if (currentValue) { + title = currentValue.title || currentValue; + } else { + title = placeholder || filterValues[0].title || filterValues[0]; + } + + let menuId = 'filterCategoryMenu'; + menuId += id ? `_${id}` : ''; + + return ( +
+ + {placeholder && ( + onFilterValueSelected && onFilterValueSelected()} + > + {placeholder} + + )} + {filterValues && + filterValues.map((item, index) => { + let classes = { + selected: item === currentValue + }; + return ( + + onFilterValueSelected && onFilterValueSelected(item) + } + > + {item.title || item} + + ); + })} + +
+ ); + } else { + return null; + } +}; + +FilterValueSelector.propTypes = { + /** Additional css classes */ + className: PropTypes.string, + /** ID for the filter component, necessary for accessibility if there are multiple filters on a page */ + id: PropTypes.string, + /** Array of valid values to select from, each can be a string or an object with a 'title' field */ + filterValues: PropTypes.array.isRequired, + /** Currently selected value */ + currentValue: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), + /** Placeholder text when no value is selected */ + placeholder: PropTypes.string, + /** function(field, value) - Callback to call when a value is selected */ + onFilterValueSelected: PropTypes.func +}; + +export default FilterValueSelector; diff --git a/src/components/Filter/__mocks__/mockFilterExample.js b/src/components/Filter/__mocks__/mockFilterExample.js new file mode 100644 index 00000000000..66d8ef4953a --- /dev/null +++ b/src/components/Filter/__mocks__/mockFilterExample.js @@ -0,0 +1,521 @@ +import React from 'react'; +import { Grid, Col, Row, Filter } from '../../../index'; + +const bindMethods = (context, methods) => { + methods.forEach(method => { + context[method] = context[method].bind(context); + }); +}; +export const mockFilterExampleFields = [ + { + id: 'name', + title: 'Name', + placeholder: 'Filter by Name', + filterType: 'text' + }, + { + id: 'address', + title: 'Address', + placeholder: 'Filter by Address', + filterType: 'text' + }, + { + id: 'birthMonth', + title: 'Birth Month', + placeholder: 'Filter by Birth Month', + filterType: 'select', + filterValues: [ + { title: 'January', id: 'jan' }, + { title: 'February', id: 'feb' }, + { title: 'March', id: 'mar' }, + { title: 'April', id: 'apr' }, + { title: 'May', id: 'may' }, + { title: 'June', id: 'jun' }, + { title: 'July', id: 'jul' }, + { title: 'August', id: 'aug' }, + { title: 'September', id: 'sep' }, + { title: 'October', id: 'oct' }, + { title: 'November', id: 'nov' }, + { title: 'December', id: 'dec' } + ] + }, + { + id: 'car', + title: 'Car', + placeholder: 'Filter by Car Make', + filterType: 'complex-select', + filterValues: [{ title: 'Subaru', id: 'subie' }, 'Toyota'], + filterCategoriesPlaceholder: 'Filter by Car Model', + filterCategories: [ + { + id: 'subie', + title: 'Subaru', + filterValues: [ + { + title: 'Outback', + id: 'out' + }, + 'Crosstrek', + 'Impreza' + ] + }, + { + id: 'toyota', + title: 'Toyota', + filterValues: [ + { + title: 'Prius', + id: 'pri' + }, + 'Corolla', + 'Echo' + ] + } + ] + } +]; + +export class MockFilterExample extends React.Component { + constructor() { + super(); + + bindMethods(this, [ + 'updateCurrentValue', + 'onValueKeyPress', + 'selectFilterType', + 'filterValueSelected', + 'filterCategorySelected', + 'categoryValueSelected' + ]); + + this.state = { + currentFilterType: mockFilterExampleFields[0], + currentValue: '', + filtersText: '' + }; + } + + filterAdded = (field, value) => { + let filterText = ''; + if (field.title) { + filterText = field.title; + } else { + filterText = field; + } + filterText += ': '; + + if (value.filterCategory) { + filterText += + (value.filterCategory.title || value.filterCategory) + + '-' + + (value.filterValue.title || value.filterValue); + } else if (value.title) { + filterText += value.title; + } else { + filterText += value; + } + filterText += '\n'; + this.setState({ filtersText: this.state.filtersText + filterText }); + }; + + selectFilterType(filterType) { + const { currentFilterType } = this.state; + if (currentFilterType !== filterType) { + this.setState({ currentValue: '', currentFilterType: filterType }); + + if (filterType.filterType === 'complex-select') { + this.setState({ filterCategory: undefined, categoryValue: '' }); + } + } + } + + filterValueSelected(filterValue) { + const { currentFilterType, currentValue } = this.state; + + if (filterValue !== currentValue) { + this.setState({ currentValue: filterValue }); + if (filterValue) { + this.filterAdded(currentFilterType, filterValue); + } + } + } + + filterCategorySelected(category) { + const { filterCategory } = this.state; + if (filterCategory !== category) { + this.setState({ filterCategory: category, categoryValue: '' }); + } + } + + categoryValueSelected(value) { + const { currentValue, currentFilterType, filterCategory } = this.state; + + if (filterCategory && currentValue !== value) { + this.setState({ currentValue: value }); + if (value) { + let filterValue = { + filterCategory: filterCategory, + filterValue: value + }; + this.filterAdded(currentFilterType, filterValue); + } + } + } + + updateCurrentValue(event) { + this.setState({ currentValue: event.target.value }); + } + + onValueKeyPress(keyEvent) { + const { currentValue, currentFilterType } = this.state; + + if (keyEvent.key === 'Enter' && currentValue && currentValue.length > 0) { + this.setState({ currentValue: '' }); + this.filterAdded(currentFilterType, currentValue); + keyEvent.stopPropagation(); + keyEvent.preventDefault(); + } + } + + renderInput() { + const { currentFilterType, currentValue, filterCategory } = this.state; + if (!currentFilterType) { + return null; + } + + if (currentFilterType.filterType === 'select') { + return ( + + ); + } else if (currentFilterType.filterType === 'complex-select') { + return ( + + + + ); + } else { + return ( + this.updateCurrentValue(e)} + onKeyPress={e => this.onValueKeyPress(e)} + /> + ); + } + } + + render() { + const { currentFilterType } = this.state; + + return ( + + + + + + {this.renderInput()} + + + + + +
+ + + + + +