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 ( + + + + {t('filter.date')}: + { + this.dateToInput.current.getInput().focus(); + }, + }} + /> + {' - '} + + + {selectFilters.map(filter => ( + + {t(`filter.${filter.name}`)}: + + + ))} + + {children(this.props.swaps.filter(swap => this.filterSwap(swap)))} + + ); + } +} + +SwapFilters.propTypes = { + children: PropTypes.func, + swaps: PropTypes.arrayOf(PropTypes.object), +}; + +SwapFilters.defaultProps = { + children: () => {}, + swaps: [], +}; + +export default SwapFilters; diff --git a/app/renderer/components/SwapFilters.scss b/app/renderer/components/SwapFilters.scss new file mode 100644 index 0000000000..eb55bf8ff1 --- /dev/null +++ b/app/renderer/components/SwapFilters.scss @@ -0,0 +1,71 @@ +.SwapFilters { + display: flex; + flex-shrink: 0; + color: var(--text-color); +} + +.SwapFilters__section { + & + & { + margin-left: 15px; + } + + label { + margin-right: 5px; + font-size: 14px; + color: var(--text-color); + } + + input { + color: var(--text-color2); + } + + .Input { + width: auto; + margin: 0; + } + + .Select { + display: inline-flex; + min-width: 169px; + + .Select-placeholder, + .Select-value, + .Select-input { + padding-left: 10px; + } + + .Select-placeholder { + font-weight: normal; + opacity: 0.5; + } + } + + /* stylelint-disable selector-class-pattern */ + .DayPickerInput .DayPicker-Day { + border-radius: unset; + } + + .DayPickerInput .DayPicker-Day--start { + border-top-left-radius: 50%; + border-bottom-left-radius: 50%; + } + + .DayPicker:not(.DayPicker--interactionDisabled) .DayPicker-Day--startAfter:not(.DayPicker-Day--disabled):not(.DayPicker-Day--selected):not(.DayPicker-Day--outside) { + border-top-left-radius: unset; + border-bottom-left-radius: unset; + } + + .DayPickerInput .DayPicker-Day--end { + border-top-right-radius: 50%; + border-bottom-right-radius: 50%; + } + + .DayPicker:not(.DayPicker--interactionDisabled) .DayPicker-Day--endBefore:not(.DayPicker-Day--disabled):not(.DayPicker-Day--selected):not(.DayPicker-Day--outside) { + border-top-right-radius: unset; + border-bottom-right-radius: unset; + } + + .DayPickerInput .DayPicker-Day--selected:not(.DayPicker-Day--start):not(.DayPicker-Day--end):not(.DayPicker-Day--outside) { + background-color: var(--primary-color-opacity-0-2); + } +} diff --git a/app/renderer/styles/variables.scss b/app/renderer/styles/variables.scss index 68590e4962..c90bcbd68d 100644 --- a/app/renderer/styles/variables.scss +++ b/app/renderer/styles/variables.scss @@ -45,6 +45,7 @@ --depth-chart-sell-stroke-color: #f80759; --order-list-item-hover: rgba(255, 255, 255, 0.05); --table-row-hover-color: hsla(216, 27%, 24%, 1); + --filter-background-color: hsla(216, 27%, 20%, 1); } html[data-theme='light'] { @@ -77,6 +78,7 @@ html[data-theme='light'] { --depth-chart-sell-stroke-color: #f53f78; --order-list-item-hover: rgba(0, 0, 0, 0.05); --table-row-hover-color: hsla(0, 0%, 91%, 1); + --filter-background-color: hsla(200, 10%, 93%, 1); } @mixin icon-button { diff --git a/app/renderer/util.js b/app/renderer/util.js index 3618555518..1fc4c2c2bd 100644 --- a/app/renderer/util.js +++ b/app/renderer/util.js @@ -47,4 +47,20 @@ export const setLoginWindowBounds = () => { }); }; +export const setInputValue = (element, value) => { + const {set: valueSetter} = Object.getOwnPropertyDescriptor(element, 'value') || {}; + const prototype = Object.getPrototypeOf(element); + const {set: prototypeValueSetter} = Object.getOwnPropertyDescriptor(prototype, 'value') || {}; + + if (prototypeValueSetter && valueSetter !== prototypeValueSetter) { + prototypeValueSetter.call(element, value); + } else if (valueSetter) { + valueSetter.call(element, value); + } else { + throw new Error('The given element does not have a value setter'); + } + + element.dispatchEvent(new Event('input', {bubbles: true})); +}; + export const config = remote.require('./config'); diff --git a/app/renderer/views/Trades.js b/app/renderer/views/Trades.js index 339749b568..b1f347c658 100644 --- a/app/renderer/views/Trades.js +++ b/app/renderer/views/Trades.js @@ -5,6 +5,7 @@ import appContainer from 'containers/App'; import tradesContainer from 'containers/Trades'; import View from 'components/View'; import SwapList from 'components/SwapList'; +import SwapFilters from 'components/SwapFilters'; import TabButton from 'components/TabButton'; import {formatCurrency} from '../util'; import {translate} from '../translate'; @@ -27,7 +28,12 @@ const OpenOrders = () => { const TradeHistory = () => { const {state} = appContainer; const filteredData = state.swapHistory.filter(swap => !swap.isActive); - return ; + + return ( + + {swaps => } + + ); }; const Trades = props => ( diff --git a/app/renderer/views/Trades.scss b/app/renderer/views/Trades.scss index 25c13ae71d..852852bc9d 100644 --- a/app/renderer/views/Trades.scss +++ b/app/renderer/views/Trades.scss @@ -30,6 +30,15 @@ } main { + display: flex; + flex-direction: column; + + .SwapFilters { + padding: 15px 20px; + background-color: var(--filter-background-color); + border-bottom: 1px solid var(--section-border-color); + } + .SwapList { .row { padding: 12px 25px 12px 20px; diff --git a/babel.config.js b/babel.config.js index e82ed62332..b091151ceb 100644 --- a/babel.config.js +++ b/babel.config.js @@ -46,6 +46,7 @@ module.exports = { 'transform-require-ignore', { extensions: [ + '.css', '.scss', ], }, diff --git a/package.json b/package.json index f16ca6cb45..7ebe1e119f 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "lodash": "^4.17.11", "mem": "^4.0.0", "modern-normalize": "^0.5.0", + "moment": "^2.22.2", "ow": "^0.8.0", "p-any": "^1.1.0", "p-event": "^2.1.0", @@ -43,6 +44,7 @@ "qrcode.react": "^0.8.0", "randoma": "^1.2.0", "react": "^16.6.0", + "react-day-picker": "^7.2.4", "react-dom": "^16.6.0", "react-extras": "^0.7.1", "react-i18next": "^8.0.7", diff --git a/test/renderer/components/DateInput.js b/test/renderer/components/DateInput.js new file mode 100644 index 0000000000..7330e69986 --- /dev/null +++ b/test/renderer/components/DateInput.js @@ -0,0 +1,79 @@ +import test from 'ava'; +import React from 'react'; +import {shallow} from 'enzyme'; +import delay from 'delay'; +import proxyquire from 'proxyquire'; +import {spy, stub} from 'sinon'; +import DayPickerInput from 'react-day-picker/DayPickerInput'; +import MomentLocaleUtils, {formatDate, parseDate} from 'react-day-picker/moment'; + +const language = 'foo'; +const setInputValue = spy(); + +const dateInput = proxyquire.noCallThru()('../../../app/renderer/components/DateInput', { + '../../../app/renderer/translate': { + instance: { + language, + }, + }, + '../../../app/renderer/util': { + setInputValue, + }, +}); + +const {default: DateInput, WrappedInput} = dateInput; + +test('render `DayPickerInput`', t => { + const component = shallow().dive(); + t.is(component.type(), DayPickerInput); +}); + +test('set `component` prop', t => { + const component = shallow().dive(); + t.is(component.prop('component'), WrappedInput); +}); + +test('set `format` prop', t => { + const component = shallow().dive(); + t.is(component.prop('format'), 'YYYY-MM-DD'); +}); + +test('set `formatDate` prop', t => { + const component = shallow().dive(); + t.is(component.prop('formatDate'), formatDate); +}); + +test('set `parseDate` prop', t => { + const component = shallow().dive(); + t.is(component.prop('parseDate'), parseDate); +}); + +test('set `dayPickerProps` prop', t => { + const dayPickerProps = {foo: 'foo'}; + const component = shallow().dive(); + t.deepEqual(component.prop('dayPickerProps'), { + ...dayPickerProps, + locale: language, + localeUtils: MomentLocaleUtils, + }); +}); + +test('has `autoCorrect` prop', async t => { + const value = 'foo'; + const invalidValue = 'bar'; + const locale = 'locale'; + const format = 'format'; + const formatDate = stub().returns(value); + const ref = {current: {props: {dayPickerProps: {locale}, format, formatDate}, getInput: () => ({value: invalidValue})}}; + const modifiers = {disabled: true}; + const onDayChange = spy(); + const event = {target: 'unicorn', persist: () => {}}; + const component = shallow().dive(); + const input = component.dive().find(WrappedInput); + component.simulate('dayChange', invalidValue, modifiers, ref.current); + input.simulate('blur', event); + await delay(600); + t.true(onDayChange.calledWith(invalidValue, modifiers, ref.current)); + t.true(formatDate.calledWith(component.state('value'), format, locale)); + t.true(setInputValue.calledWith(event.target, value)); +}); diff --git a/test/renderer/components/SwapFilters.js b/test/renderer/components/SwapFilters.js new file mode 100644 index 0000000000..d11151ceac --- /dev/null +++ b/test/renderer/components/SwapFilters.js @@ -0,0 +1,106 @@ +import test from 'ava'; +import React from 'react'; +import {shallow} from 'enzyme'; +import proxyquire from 'proxyquire'; +import {spy, stub} from 'sinon'; +import moment from 'moment'; +import Select from 'components/Select'; + +const t = stub(); +const translate = stub().withArgs('swap').returns(t); + +t.withArgs('filter.dateFrom').returns('dateFrom'); +t.withArgs('filter.dateTo').returns('dateTo'); + +const DateInput = proxyquire.noCallThru()('../../../app/renderer/components/DateInput', { + '../../../app/renderer/translate': { + instance: {}, + }, + '../../../app/renderer/util': { + setInputValue: () => {}, + }, +}).default; + +const SwapFilters = proxyquire.noCallThru()('../../../app/renderer/components/SwapFilters', { + '../../../app/renderer/components/DateInput': DateInput, + '../../../app/renderer/translate': { + translate, + }, +}).default; + +test('render `React.Fragment`', t => { + const component = shallow(); + t.is(component.type(), React.Fragment); +}); + +test('render `DateInput`', t => { + const filters = ['dateFrom', 'dateTo']; + const component = shallow(); + const inputs = component.find(DateInput); + + t.is(inputs.length, filters.length); + + inputs.forEach((input, index) => { + t.truthy(input.prop('dayPickerProps')); + t.is(input.prop('name'), filters[index]); + t.is(input.prop('placeholder'), `${filters[index]}...`); + t.is(input.prop('value'), component.state(filters[index])); + t.is(input.prop('onDayChange'), component.instance().handleDateChange); + }); +}); + +test('render `Select`', t => { + const filters = ['pair', 'type']; + const component = shallow(); + const selects = component.find(Select); + + t.is(selects.length, filters.length); + + selects.forEach((select, index) => { + t.true(select.prop('clearable')); + t.truthy(select.prop('onChange')); + t.is(select.prop('value'), component.state(filters[index])); + }); +}); + +test('set initial `state`', t => { + const yearAgo = moment().startOf('day').valueOf(); + const timeStarted = moment().startOf('day').valueOf(); + const swaps = [{ + baseCurrency: 'FOO', + quoteCurrency: 'BAR', + orderType: 'buy', + timeStarted, + }]; + const component = shallow(); + const {dateFrom, dateTo, pair, type} = component.state(); + t.is(dateFrom.getTime(), timeStarted); + t.is(moment(dateTo).startOf('day').valueOf(), moment().startOf('day').valueOf()); + t.is(pair, null); + t.is(type, null); + component.setProps({swaps: []}); + t.is(moment(dateFrom).startOf('day').valueOf(), yearAgo); +}); + +test('return filtered `swaps` as `children`', t => { + const dateFrom = new Date('2019-01-01Z'); + const swaps = [{ + baseCurrency: 'FOO', + quoteCurrency: 'BAR', + orderType: 'buy', + timeStarted: Date.parse('2018-01-01Z'), + }]; + const children = spy(); + const component = shallow({children}); + + component.find(DateInput).at(0).simulate('dayChange', dateFrom, null, { + props: { + name: 'dateFrom', + }, + }); + + t.is(component.state('dateFrom'), dateFrom); + t.is(children.callCount, 2); + t.true(children.withArgs(swaps).calledOnce); + t.true(children.withArgs([]).calledOnce); +}); diff --git a/webpack.config.js b/webpack.config.js index 8b06b68693..13ba728330 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -38,8 +38,8 @@ module.exports = (env, options) => ({ }, }, { - test: /\.scss$/, - exclude: /node_modules/, + test: /\.(css|scss)$/, + exclude: /node_modules(?!\/react-day-picker)/, use: [ { loader: 'style-loader', diff --git a/yarn.lock b/yarn.lock index 5d03a148b3..dc3e576716 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7106,6 +7106,11 @@ module-not-found-error@^1.0.0: resolved "https://registry.yarnpkg.com/module-not-found-error/-/module-not-found-error-1.0.1.tgz#cf8b4ff4f29640674d6cdd02b0e3bc523c2bbdc0" integrity sha1-z4tP9PKWQGdNbN0CsOO8UjwrvcA= +moment@^2.22.2: + version "2.22.2" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.22.2.tgz#3c257f9839fc0e93ff53149632239eb90783ff66" + integrity sha1-PCV/mDn8DpP/UxSWMiOeuQeD/2Y= + move-concurrently@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92" @@ -8966,6 +8971,13 @@ rc@^1.2.1: minimist "^1.2.0" strip-json-comments "~2.0.1" +react-day-picker@^7.2.4: + version "7.2.4" + resolved "https://registry.yarnpkg.com/react-day-picker/-/react-day-picker-7.2.4.tgz#3c3bea7aa1910bef7bad17f25ff15cd26a7d1848" + integrity sha512-LaePKijvAdXTrQhTyU7XfxxD5NQIFb4FH4vr1T862xNtmncoS8jZLliyHieMHNhZZ8RJU1pruqoQzkZQ05646w== + dependencies: + prop-types "^15.6.2" + react-dom@^16.6.0: version "16.6.0" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.6.0.tgz#6375b8391e019a632a89a0988bce85f0cc87a92f"