From 6074f21d37d001495e588d8387a1b1b0055d51bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kevin=20M=C3=A5rtensson?= Date: Fri, 9 Nov 2018 05:17:53 +0100 Subject: [PATCH] Add filters to trade history (#544) --- app/locales/en-US/swap.json | 9 ++ app/renderer/components/DateInput.js | 104 ++++++++++++++ app/renderer/components/DateInput.scss | 49 +++++++ app/renderer/components/Input.js | 39 +++++- app/renderer/components/Select.scss | 2 +- app/renderer/components/SwapFilters.js | 169 +++++++++++++++++++++++ app/renderer/components/SwapFilters.scss | 71 ++++++++++ app/renderer/styles/variables.scss | 2 + app/renderer/util.js | 16 +++ app/renderer/views/Trades.js | 8 +- app/renderer/views/Trades.scss | 9 ++ babel.config.js | 1 + package.json | 2 + test/renderer/components/DateInput.js | 79 +++++++++++ test/renderer/components/SwapFilters.js | 106 ++++++++++++++ webpack.config.js | 4 +- yarn.lock | 12 ++ 17 files changed, 674 insertions(+), 8 deletions(-) create mode 100644 app/renderer/components/DateInput.js create mode 100644 app/renderer/components/DateInput.scss create mode 100644 app/renderer/components/SwapFilters.js create mode 100644 app/renderer/components/SwapFilters.scss create mode 100644 test/renderer/components/DateInput.js create mode 100644 test/renderer/components/SwapFilters.js diff --git a/app/locales/en-US/swap.json b/app/locales/en-US/swap.json index 60af046d82..59ce766720 100644 --- a/app/locales/en-US/swap.json +++ b/app/locales/en-US/swap.json @@ -54,5 +54,14 @@ "toggleAdvancedButton": { "more": "More", "less": "Less" + }, + "filter": { + "buy": "Buy", + "date": "Date", + "dateFrom": "From", + "dateTo": "To", + "pair": "Pair", + "sell": "Sell", + "type": "Type" } } diff --git a/app/renderer/components/DateInput.js b/app/renderer/components/DateInput.js new file mode 100644 index 0000000000..779f8b8863 --- /dev/null +++ b/app/renderer/components/DateInput.js @@ -0,0 +1,104 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import DayPickerInput from 'react-day-picker/DayPickerInput'; +import moment from 'moment'; +import MomentLocaleUtils, {formatDate, parseDate} from 'react-day-picker/moment'; +import 'react-day-picker/lib/style.css'; +import Input from 'components/Input'; +import {instance} from '../translate'; +import {setInputValue} from '../util'; +import './DateInput.scss'; + +const WrappedInput = React.forwardRef((props, ref) => { + const onChange = (value, event) => props.onChange(event); + const validateInput = value => moment(value).isValid(); + + return ; +}); + +class DateInput extends React.Component { + static propTypes = { + autoCorrect: PropTypes.bool, + forwardedRef: PropTypes.oneOfType([ + PropTypes.func, + PropTypes.object, + ]), + onDayChange: PropTypes.func, + } + + constructor(props) { + super(props); + this.inputRef = this.props.forwardedRef || React.createRef(); + this.state = { + hasError: false, + isInvalid: false, + value: this.props.value, + }; + } + + handleBlur = event => { + const {autoCorrect, onBlur} = this.props; + const {value} = this.state; + + if (autoCorrect && this.state.isInvalid) { + this.setState({hasError: true}); + + setTimeout(() => { + const {dayPickerProps, format, formatDate} = this.inputRef.current.props; + setInputValue(event.target, formatDate(value, format, dayPickerProps.locale)); + }, 600); + } + + if (typeof onBlur === 'function') { + onBlur(event); + } + } + + handleDayChange = (day, modifiers, input) => { + const {onDayChange} = this.props; + const inputValue = input.getInput().value; + const isInvalid = modifiers.disabled || (!day && inputValue); + + this.setState(state => ({ + hasError: false, + isInvalid, + value: isInvalid ? state.value : day, + }), () => { + onDayChange(day, modifiers, input); + }); + }; + + render() { + const {hasError} = this.state; + + return ( + + ); + } +} + +export default React.forwardRef((props, ref) => ( + +)); + +export { + WrappedInput, +}; diff --git a/app/renderer/components/DateInput.scss b/app/renderer/components/DateInput.scss new file mode 100644 index 0000000000..4d52aed5d5 --- /dev/null +++ b/app/renderer/components/DateInput.scss @@ -0,0 +1,49 @@ +/* stylelint-disable selector-class-pattern */ +.DayPickerInput-Overlay { + margin-top: 4px; + text-align: center; + color: var(--text-color); + background-color: var(--input-background-color); + border: 1px solid var(--input-border-color); + border-radius: 4px; + box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.18); +} + +.DayPicker-Months { + flex-wrap: unset; +} + +.DayPicker-NavButton, +.DayPicker-Day { + cursor: default; +} + +.DayPicker:not(.DayPicker--interactionDisabled) .DayPicker-Day:not(.DayPicker-Day--disabled):not(.DayPicker-Day--selected):not(.DayPicker-Day--outside):hover { + color: var(--text-color2); + background-color: var(--primary-color); + border-radius: 2px; +} + +.DayPicker-Day--today { + color: var(--secondary-color); + font-weight: normal; + + &:hover:not(.DayPicker-Day--disabled):not(.DayPicker-Day--outside) { + color: var(--text-color2); + } +} + +.DayPicker-Day--selected:not(.DayPicker-Day--disabled):not(.DayPicker-Day--outside) { + color: var(--text-color2); + background-color: var(--primary-color); + + &:hover { + background-color: var(--primary-color); + } +} + +.DayPicker-Day--disabled:not(.DayPicker-Day--today), +.DayPicker-Day--outside:not(.DayPicker-Day--today) { + color: var(--text-color); + opacity: 0.5; +} diff --git a/app/renderer/components/Input.js b/app/renderer/components/Input.js index 6d43bf7117..7670286962 100644 --- a/app/renderer/components/Input.js +++ b/app/renderer/components/Input.js @@ -2,6 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import {classNames} from 'react-extras'; import propTypesRange from 'prop-types-range'; +import _ from 'lodash'; import './Input.scss'; const stripLeadingZeros = string => string.replace(/^0+(?=\d)/, ''); @@ -46,13 +47,27 @@ class Input extends React.Component { } static getDerivedStateFromProps(props, state) { - return props.value === state.prevValue ? null : { - value: props.value, - prevValue: props.value, + const isValueChanged = props.value !== state.prevValue; + const isLevelChanged = props.level !== state.prevLevel; + + if (!isValueChanged && !isLevelChanged) { + return null; + } + + return { + ...isValueChanged && { + value: props.value, + prevValue: props.value, + }, + ...isLevelChanged && { + level: props.level, + prevLevel: props.level, + }, }; } state = { + level: this.props.level, value: this.props.value || '', }; @@ -76,6 +91,8 @@ class Input extends React.Component { event.persist(); } + this._checkValidity(event); + this.setState({value}, () => { if (onChange) { onChange(value, event); @@ -83,6 +100,14 @@ class Input extends React.Component { }); }; + _checkValidity = _.debounce(event => { + const {pattern} = this.props; + const {value} = event.target; + const isValid = typeof pattern === 'function' ? (!value || pattern(value)) : event.target.checkValidity(); + + this.setState({level: isValid ? null : 'error'}); + }, 500); + _shouldTruncateFractions(value) { const {fractionalDigits} = this.props; @@ -113,7 +138,6 @@ class Input extends React.Component { let { forwardedRef, className, - level, message, errorMessage, disabled, @@ -125,10 +149,12 @@ class Input extends React.Component { icon, iconSize, iconName, + pattern, view: View, button: Button, ...props } = this.props; + let {level} = this.state; if (errorMessage) { level = 'error'; @@ -143,6 +169,10 @@ class Input extends React.Component { icon = `/assets/${iconName}-icon.svg`; } + if (typeof pattern === 'function') { + pattern = null; + } + const containerClassName = classNames( 'Input', { @@ -182,6 +212,7 @@ class Input extends React.Component { value={value} type={type} disabled={disabled} + pattern={pattern} readOnly={readOnly} onChange={this.handleChange} /> diff --git a/app/renderer/components/Select.scss b/app/renderer/components/Select.scss index 3bb37701e2..3f0d508d22 100644 --- a/app/renderer/components/Select.scss +++ b/app/renderer/components/Select.scss @@ -198,7 +198,7 @@ height: 5px; position: absolute; top: 50%; - margin: -3px 0 0 -3px; + margin: -4px 0 0 -3px; transform: rotate(45deg); border-left: none; border-top: none; diff --git a/app/renderer/components/SwapFilters.js b/app/renderer/components/SwapFilters.js new file mode 100644 index 0000000000..734be75fb4 --- /dev/null +++ b/app/renderer/components/SwapFilters.js @@ -0,0 +1,169 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import _ from 'lodash'; +import title from 'title'; +import moment from 'moment'; +import DateInput from 'components/DateInput'; +import Select from 'components/Select'; +import {translate} from '../translate'; +import './SwapFilters.scss'; + +const t = translate('swap'); + +const getFromDate = swaps => { + const oldestSwap = swaps.reduce((timeStarted, swap) => swap.timeStarted < timeStarted ? swap.timeStarted : timeStarted, Date.now()); + const yearAgo = moment().startOf('day').subtract(1, 'year').valueOf(); + + return new Date((oldestSwap > yearAgo ? oldestSwap : yearAgo)); +}; + +class SwapFilters extends React.Component { + state = { + dateFrom: getFromDate(this.props.swaps), + dateTo: new Date(), + pair: null, + type: null, + }; + + dateToInput = React.createRef(); + + filterSwap = swap => { + const {dateFrom, dateTo, pair, type} = this.state; + + if (pair) { + const [baseCurrency, quoteCurrency] = pair.split('/'); + + if (swap.baseCurrency !== baseCurrency || swap.quoteCurrency !== quoteCurrency) { + return false; + } + } + + if (type && swap.orderType !== type) { + return false; + } + + if (dateFrom && swap.timeStarted < dateFrom.getTime()) { + return false; + } + + if (dateTo && swap.timeStarted > dateTo.getTime()) { + return false; + } + + return true; + } + + handleDateChange = (value, modifiers, input) => { + this.setState({[input.props.name]: value}); + } + + handleSelectChange = field => selectedOption => { + this.setState({[field]: selectedOption === null ? selectedOption : selectedOption.value}); + } + + render() { + const {children, swaps} = this.props; + const {dateFrom, dateTo} = this.state; + const modifiers = { + start: dateFrom, + end: dateTo, + startAfter: day => moment(day).isSame(moment(dateFrom).add(1, 'day'), 'day'), + endBefore: day => moment(day).isSame(moment(dateTo).subtract(1, 'day'), 'day'), + }; + const selectFilters = [ + { + name: 'pair', + searchable: true, + options: _.uniqBy(swaps, swap => `${swap.baseCurrency}${swap.quoteCurrency}`).map(swap => { + const pair = `${swap.baseCurrency}/${swap.quoteCurrency}`; + + return { + label: pair, + value: pair, + }; + }), + }, + { + name: 'type', + searchable: false, + options: _.uniqBy(swaps, 'orderType').map(swap => ({ + label: title(swap.orderType), + value: swap.orderType, + })), + }, + ]; + + return ( + +
+
+ + { + this.dateToInput.current.getInput().focus(); + }, + }} + /> + {' - '} + +
+ {selectFilters.map(filter => ( +
+ +