diff --git a/src/assets/stylesheets/components/__components.scss b/src/assets/stylesheets/components/__components.scss index 5fdf73f..5222ce0 100644 --- a/src/assets/stylesheets/components/__components.scss +++ b/src/assets/stylesheets/components/__components.scss @@ -30,6 +30,7 @@ @import "search-complete"; @import "search-filters"; @import "search-filters-range"; +@import "search-filters-minmax"; @import "search-panel-filters"; @import "select"; @import "status-indicator"; diff --git a/src/assets/stylesheets/components/_search-filters-minmax.scss b/src/assets/stylesheets/components/_search-filters-minmax.scss new file mode 100644 index 0000000..becda3f --- /dev/null +++ b/src/assets/stylesheets/components/_search-filters-minmax.scss @@ -0,0 +1,90 @@ +.search-filters-minmax {} + +.search-filters-minmax-input { + + display: flex; + flex-direction: row; + flex-wrap: wrap; + + .form-input { + + padding: 0 .25em; + flex: 1 0 50%; + + &:first-child { + margin-left: -.25em; + } + + &:last-child { + margin-right: -.25em; + } + + } + + &.min-error { + + .form-input { + + &:first-child { + + .form-input-field { + border: 2px $color-red solid; + } + + } + + } + + } + + &.max-error { + + .form-input { + + &:nth-child(2) { + + .form-input-field { + border: 2px $color-red solid; + } + + } + + } + + span.error { + + &:last-child { + padding: 0 .25em; + margin-left: 0em; + } + + } + + } + + span.error { + + flex: 1 0 50%; + color: $color-red; + font-size: .6rem; + + } + + .form-label { + color: $color-gray6; + font-size: .8em; + font-weight: bold; + padding: 0; + } + + .form-input-field { + border: solid 2px $color-gray3; + } + +} + +.search-filters-minmax-note { + font-size: .9em; + color: $color-gray5; + padding-top: .6em; +} diff --git a/src/components/SearchFilters/SearchFilters.js b/src/components/SearchFilters/SearchFilters.js index c1f3d49..fb570c4 100644 --- a/src/components/SearchFilters/SearchFilters.js +++ b/src/components/SearchFilters/SearchFilters.js @@ -9,6 +9,7 @@ import InputButton from '../InputButton'; import Button from '../Button'; import SearchFiltersList from '../SearchFiltersList'; import SearchFiltersRange from '../SearchFiltersRange'; +import SearchFiltersMinMax from '../SearchFiltersMinMax'; import { ALL_VALUES_ITEM } from '../../data/search-filters'; const SearchFilters = ({ @@ -179,6 +180,20 @@ const SearchFilters = ({ ); })()} + {type === 'minmax' && + (() => { + return ( + + ); + })()} + {(!type || type === 'default') && (() => { return ( diff --git a/src/components/SearchFiltersMinMax/SearchFiltersMinMax.js b/src/components/SearchFiltersMinMax/SearchFiltersMinMax.js new file mode 100644 index 0000000..57c7fda --- /dev/null +++ b/src/components/SearchFiltersMinMax/SearchFiltersMinMax.js @@ -0,0 +1,151 @@ +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; + +import FormInputDebounced from '../FormInputDebounced'; + +const SearchFiltersMinMax = ({ + id, + label, + subLabel, + onChange, + value = {}, + limits = {} +}) => { + const { min: valueMin, max: valueMax } = value; + const { min: limitsMin, max: limitsMax } = limits; + + const namePrefix = `${id}-minmax`; + + const [valueMinLocal, setValueMinLocal] = useState(valueMin); + const [valueMaxLocal, setValueMaxLocal] = useState(valueMax); + + const [minError, updateMinErrorState] = useState(false); + const [maxError, updateMaxErrorState] = useState(false); + + useEffect(() => { + const { min, max } = value; + if (valueMinLocal !== min) { + setValueMinLocal(min); + } + if (valueMaxLocal !== max) { + setValueMaxLocal(max); + } + }, [value]); + + /** + * handleInputChange + * @description When the text inputs change, fire away + */ + + function handleInputChange ({ target = {} } = {}) { + const { id: targetId, value: targetValue } = target; + const inputName = targetId.replace(`${namePrefix}-`, ''); + + if (!targetValue) { + if (inputName === 'min') { + updateMinErrorState(true); + } else if (inputName === 'max') { + updateMaxErrorState(true); + } + return; + } + + const isCompleteFloat = targetValue && targetValue.substr(-1) !== '.'; + + let floatValue = isCompleteFloat && parseFloat(targetValue); + + // If we dont have an actual number, reset back to the value so we can + // avoid overwriting WIP numbers such as 0. + + if (typeof floatValue !== 'number') { + floatValue = targetValue; + } + + switch (inputName) { + case 'min': + setValueMinLocal(floatValue); + if (floatValue < limitsMin.min || floatValue > limitsMin.max) { + updateMinErrorState(true); + return; + } else { + updateMinErrorState(false); + } + break; + case 'max': + setValueMaxLocal(floatValue); + if (floatValue > limitsMax.max || floatValue < limitsMax.min) { + updateMaxErrorState(true); + return; + } else { + updateMaxErrorState(false); + } + break; + default: + } + + if (typeof onChange === 'function') { + onChange({ + target: { + name: id, + value: floatValue + } + }); + } + } + + return ( + <> + {(label || subLabel) && ( +
+ {label && {label}} + {subLabel && ( +
{subLabel}
+ )} +
+ )} +
+
+ + + {`${minError ? 'Invalid Min Value' : ''}`} + {`${maxError ? 'Invalid Max Value' : ''}`} +
+

+ Values outside of limits will default to min and max. +

+
+ + ); +}; + +SearchFiltersMinMax.propTypes = { + id: PropTypes.string, + label: PropTypes.string, + subLabel: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), + onChange: PropTypes.func, + value: PropTypes.object, + limits: PropTypes.shape({ + min: PropTypes.shape({ + min: PropTypes.number, + max: PropTypes.number + }), + max: PropTypes.shape({ + min: PropTypes.number, + max: PropTypes.number + }) + }) +}; + +export default SearchFiltersMinMax; diff --git a/src/components/SearchFiltersMinMax/SearchFiltersMinMax.test.js b/src/components/SearchFiltersMinMax/SearchFiltersMinMax.test.js new file mode 100644 index 0000000..99ed225 --- /dev/null +++ b/src/components/SearchFiltersMinMax/SearchFiltersMinMax.test.js @@ -0,0 +1,53 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import SearchFiltersMinMax from './'; + +describe('SearchFiltersMinMax', () => { + const notice = 'Values outside of limits will default to min and max.'; + it('renders a SearchFiltersMinMax', () => { + const MinMax = shallow(); + + expect(MinMax.find('div.search-filters-minmax-input').exists()).toEqual(true); + expect(MinMax.find('span.error').exists()).toEqual(true); + expect(MinMax.find('p.search-filters-minmax-note').text()).toEqual(notice); + }); + + it('displays an error when min is out of range', () => { + const limits = { + min: { + min: 0, + max: 25 + }, + max: { + min: 0, + max: 50 + } + }; + + const MinMax = shallow(); + MinMax.find('#test-minmax-min').simulate('change', { target: { id: 'test-minmax-min', value: '30' } }); + MinMax.update(); + + expect(MinMax.findWhere(node => node.text() === 'Invalid Min Value').exists()).toEqual(true); + }); + + it('displays an error when max is out of range', () => { + const limits = { + min: { + min: 0, + max: 25 + }, + max: { + min: 0, + max: 50 + } + }; + + const MinMax = shallow(); + MinMax.find('#test-minmax-max').simulate('change', { target: { id: 'test-minmax-max', value: '55' } }); + MinMax.update(); + + expect(MinMax.findWhere(node => node.text() === 'Invalid Max Value').exists()).toEqual(true); + }); +}); diff --git a/src/components/SearchFiltersMinMax/index.js b/src/components/SearchFiltersMinMax/index.js new file mode 100644 index 0000000..550c224 --- /dev/null +++ b/src/components/SearchFiltersMinMax/index.js @@ -0,0 +1 @@ +export { default } from './SearchFiltersMinMax'; diff --git a/src/components/SearchFiltersMinMax/stories/SearchFiltersMinMax.Default.stories.js b/src/components/SearchFiltersMinMax/stories/SearchFiltersMinMax.Default.stories.js new file mode 100644 index 0000000..b9b4966 --- /dev/null +++ b/src/components/SearchFiltersMinMax/stories/SearchFiltersMinMax.Default.stories.js @@ -0,0 +1,33 @@ +import React from 'react'; +import { storiesOf } from '@storybook/react'; + +import Story from '../../../../stories/helpers/Story'; + +import SearchFiltersMinMax from '../SearchFiltersMinMax'; + +const STORY_COMPONENT = 'Search Filters Min-Max'; +const STORY_NAME = 'Default'; + +const stories = storiesOf(`Components/${STORY_COMPONENT}`, module); + +stories.add(STORY_NAME, () => { + return ( + + + + ); +}); diff --git a/src/components/SearchFiltersRange/SearchFiltersRange.js b/src/components/SearchFiltersRange/SearchFiltersRange.js index 9dfc3b1..abe4896 100644 --- a/src/components/SearchFiltersRange/SearchFiltersRange.js +++ b/src/components/SearchFiltersRange/SearchFiltersRange.js @@ -29,10 +29,10 @@ const SearchFiltersRange = ({ useEffect(() => { if (maxError === true) { - handleInputChange({ target: { id: 'incidence_angle-range-max', value: valueMaxLocal.toString() } }); + handleInputChange({ target: { id: `${namePrefix}-max`, value: valueMaxLocal.toString() } }); } if (minError === true) { - handleInputChange({ target: { id: 'incidence_angle-range-min', value: valueMinLocal.toString() } }); + handleInputChange({ target: { id: `${namePrefix}-min`, value: valueMinLocal.toString() } }); } }, [valueMinLocal, valueMaxLocal, rangeValue]);