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 (
+
+ );
+};
+
+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 (
+
+ );
+ } 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 (
+
+
+
+ );
+ } 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 (
+
+
+
+ );
+ } 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 (
+
+
+
+ );
+ } 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()}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+export const mockFilterExampleSource = `
+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()}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+`;
diff --git a/src/components/Filter/__snapshots__/Filter.test.js.snap b/src/components/Filter/__snapshots__/Filter.test.js.snap
new file mode 100644
index 00000000000..07412084f6e
--- /dev/null
+++ b/src/components/Filter/__snapshots__/Filter.test.js.snap
@@ -0,0 +1,709 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Filter categories renders properly 1`] = `
+
+`;
+
+exports[`Filter input renders properly 1`] = `
+
+`;
+
+exports[`Filter select renders properly 1`] = `
+
+`;
diff --git a/src/components/Filter/index.js b/src/components/Filter/index.js
new file mode 100644
index 00000000000..f982eaf7b69
--- /dev/null
+++ b/src/components/Filter/index.js
@@ -0,0 +1,18 @@
+import Filter from './Filter';
+import FilterTypeSelector from './FilterTypeSelector';
+import FilterValueSelector from './FilterValueSelector';
+import FilterCategorySelector from './FilterCategorySelector';
+import FilterCategoryValueSelector from './FilterCategoryValueSelector';
+
+Filter.TypeSelector = FilterTypeSelector;
+Filter.ValueSelector = FilterValueSelector;
+Filter.CategorySelector = FilterCategorySelector;
+Filter.CategoryValueSelector = FilterCategoryValueSelector;
+
+export {
+ Filter,
+ FilterTypeSelector,
+ FilterValueSelector,
+ FilterCategorySelector,
+ FilterCategoryValueSelector
+};
diff --git a/src/index.js b/src/index.js
index 580c8f2e907..384fa52de45 100644
--- a/src/index.js
+++ b/src/index.js
@@ -4,6 +4,7 @@ export * from './components/Breadcrumb';
export * from './components/Button';
export * from './components/Dropdown';
export * from './components/DropdownKebab';
+export * from './components/Filter';
export * from './components/Grid';
export * from './components/Icon';
export * from './components/Chart';