diff --git a/CHANGELOG.md b/CHANGELOG.md index 49db70ab..ed8c3c2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # CHANGELOG +## 0.2.0 - 2018-08-12 + +#### Added + +* [#12](https://github.com/shlinkio/shlink-web-client/issues/12) Improved code coverage +* [#20](https://github.com/shlinkio/shlink-web-client/issues/20) Added servers list in welcome page, as well as added link to create one when none exist. + +#### Changed + +* [#11](https://github.com/shlinkio/shlink-web-client/issues/11) Improved app icons fro progressive web apps. + +#### Deprecated + +* *Nothing* + +#### Removed + +* *Nothing* + +#### Fixed + +* [#19](https://github.com/shlinkio/shlink-web-client/issues/19) Added workaround in tags input so that it is possible to add tags on Android devices. +* [#17](https://github.com/shlinkio/shlink-web-client/issues/17) Fixed short URLs list not being sortable in mobile resolutions. +* [#13](https://github.com/shlinkio/shlink-web-client/issues/13) Improved visits page on mobile resolutions. + + ## 0.1.1 - 2018-08-06 #### Added diff --git a/package.json b/package.json index b89f28c7..661710f9 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "start": "node scripts/start.js", "build": "node scripts/build.js", - "test": "node scripts/test.js --env=jsdom" + "test": "node scripts/test.js --env=jsdom --colors" }, "dependencies": { "@fortawesome/fontawesome": "^1.1.8", @@ -18,6 +18,7 @@ "chart.js": "^2.7.2", "moment": "^2.22.2", "promise": "8.0.1", + "prop-types": "^15.6.2", "qs": "^6.5.2", "ramda": "^0.25.0", "react": "^16.3.2", @@ -28,7 +29,7 @@ "react-moment": "^0.7.6", "react-redux": "^5.0.7", "react-router-dom": "^4.2.2", - "react-tag-autocomplete": "^5.5.1", + "react-tagsinput": "^3.19.0", "reactstrap": "^6.0.1", "redux": "^4.0.0", "redux-thunk": "^2.3.0", @@ -70,6 +71,7 @@ "react-dev-utils": "^5.0.1", "resolve": "1.6.0", "sass-loader": "^7.0.1", + "sinon": "^6.1.5", "style-loader": "0.19.0", "sw-precache-webpack-plugin": "0.11.4", "url-loader": "0.6.2", diff --git a/public/icons/icon-128x128.png b/public/icons/icon-128x128.png new file mode 100644 index 00000000..f30426eb Binary files /dev/null and b/public/icons/icon-128x128.png differ diff --git a/public/icons/icon-144x144.png b/public/icons/icon-144x144.png new file mode 100644 index 00000000..c72619d4 Binary files /dev/null and b/public/icons/icon-144x144.png differ diff --git a/public/icons/icon-152x152.png b/public/icons/icon-152x152.png new file mode 100644 index 00000000..b09061e4 Binary files /dev/null and b/public/icons/icon-152x152.png differ diff --git a/public/icons/icon-192x192.png b/public/icons/icon-192x192.png new file mode 100644 index 00000000..965a023a Binary files /dev/null and b/public/icons/icon-192x192.png differ diff --git a/public/icons/icon-384x384.png b/public/icons/icon-384x384.png new file mode 100644 index 00000000..8ff8e04f Binary files /dev/null and b/public/icons/icon-384x384.png differ diff --git a/public/icons/icon-72x72.png b/public/icons/icon-72x72.png new file mode 100644 index 00000000..ce4ec050 Binary files /dev/null and b/public/icons/icon-72x72.png differ diff --git a/public/icons/icon-96x96.png b/public/icons/icon-96x96.png new file mode 100644 index 00000000..b9c88639 Binary files /dev/null and b/public/icons/icon-96x96.png differ diff --git a/public/icons/shlink-128.png b/public/icons/shlink-128.png deleted file mode 100644 index 1d82aa73..00000000 Binary files a/public/icons/shlink-128.png and /dev/null differ diff --git a/public/icons/shlink-16.png b/public/icons/shlink-16.png deleted file mode 100644 index ded45981..00000000 Binary files a/public/icons/shlink-16.png and /dev/null differ diff --git a/public/icons/shlink-24.png b/public/icons/shlink-24.png deleted file mode 100644 index ea9251f0..00000000 Binary files a/public/icons/shlink-24.png and /dev/null differ diff --git a/public/icons/shlink-32.png b/public/icons/shlink-32.png deleted file mode 100644 index 813c1b0b..00000000 Binary files a/public/icons/shlink-32.png and /dev/null differ diff --git a/public/icons/shlink-64.png b/public/icons/shlink-64.png deleted file mode 100644 index 0dcd6f22..00000000 Binary files a/public/icons/shlink-64.png and /dev/null differ diff --git a/public/manifest.json b/public/manifest.json index ba000d91..d0a49250 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -1,35 +1,45 @@ { "short_name": "Shlink", - "name": "Shlink web client", + "name": "Shlink", + "start_url": "/", + "display": "standalone", + "theme_color": "#4696e5", + "background_color": "#4696e5", "icons": [ { - "src": "./icons/shlink-128.png", + "src": "./icons/icon-72x72.png", + "type": "image/png", + "sizes": "72x72" + }, + { + "src": "./icons/icon-96x96.png", + "type": "image/png", + "sizes": "96x96" + }, + { + "src": "./icons/icon-128x128.png", "type": "image/png", "sizes": "128x128" }, { - "src": "./icons/shlink-64.png", + "src": "./icons/icon-144x144.png", "type": "image/png", - "sizes": "64x64" + "sizes": "144x144" }, { - "src": "./icons/shlink-32.png", + "src": "./icons/icon-152x152.png", "type": "image/png", - "sizes": "32x32" + "sizes": "152x152" }, { - "src": "./icons/shlink-24.png", + "src": "./icons/icon-192x192.png", "type": "image/png", - "sizes": "24x24" + "sizes": "192x192" }, { - "src": "./icons/shlink-16.png", + "src": "./icons/icon-384x384.png", "type": "image/png", - "sizes": "16x16" + "sizes": "384x384" } - ], - "start_url": "/", - "display": "standalone", - "theme_color": "#4696e5", - "background_color": "#4696e5" + ] } diff --git a/src/common/AsideMenu.js b/src/common/AsideMenu.js index a098d4cf..db2e783e 100644 --- a/src/common/AsideMenu.js +++ b/src/common/AsideMenu.js @@ -5,14 +5,10 @@ import React from 'react'; import { NavLink } from 'react-router-dom'; import DeleteServerButton from '../servers/DeleteServerButton'; import './AsideMenu.scss'; +import PropTypes from 'prop-types'; -export default function AsideMenu({ selectedServer, history }) { +export default function AsideMenu({ selectedServer }) { const serverId = selectedServer ? selectedServer.id : ''; - const isListShortUrlsActive = (match, { pathname }) => { - // FIXME. Should use the 'match' params, but they are not being properly resolved. Investigate - const serverIdFromPathname = pathname.split('/')[2]; - return serverIdFromPathname === serverId && pathname.indexOf('list-short-urls') !== -1; - }; return ( ); } + +AsideMenu.propTypes = { + selectedServer: PropTypes.shape({ + id: PropTypes.string, + name: PropTypes.string, + url: PropTypes.string, + apiKey: PropTypes.string, + }), +}; diff --git a/src/common/DateInput.js b/src/common/DateInput.js index 7df47732..2981810a 100644 --- a/src/common/DateInput.js +++ b/src/common/DateInput.js @@ -3,6 +3,7 @@ import FontAwesomeIcon from '@fortawesome/react-fontawesome'; import React from 'react'; import DatePicker from 'react-datepicker'; import './DateInput.scss'; +import { isNil } from 'ramda'; export default class DateInput extends React.Component { constructor(props) { @@ -11,6 +12,9 @@ export default class DateInput extends React.Component { } render() { + const { isClearable, selected } = this.props; + const showCalendarIcon = !isClearable || isNil(selected); + return (
- this.inputRef.current.input.focus()} - /> + {showCalendarIcon && ( + this.inputRef.current.input.focus()} + /> + )}
); } diff --git a/src/common/DateInput.scss b/src/common/DateInput.scss index f9f41b71..ba4563d5 100644 --- a/src/common/DateInput.scss +++ b/src/common/DateInput.scss @@ -1,4 +1,5 @@ @import '../utils/mixins/vertical-align'; +@import '../utils/base'; .date-input-container { position: relative; @@ -11,6 +12,18 @@ .date-input-container__icon { @include vertical-align(); - right: 15px; + right: .75rem; cursor: pointer; } + +.react-datepicker__close-icon.react-datepicker__close-icon { + @include vertical-align(); + right: 0; +} + +.react-datepicker__close-icon.react-datepicker__close-icon::after { + right: .75rem; + line-height: 11px; + background-color: #333; + font-size: 14px; +} diff --git a/src/common/Home.js b/src/common/Home.js index 38e32b21..8efcd317 100644 --- a/src/common/Home.js +++ b/src/common/Home.js @@ -1,7 +1,12 @@ -import React from 'react'; -import { connect } from 'react-redux'; -import './Home.scss'; -import { resetSelectedServer } from '../servers/reducers/selectedServer'; +import chevronIcon from '@fortawesome/fontawesome-free-solid/faChevronRight' +import FontAwesomeIcon from '@fortawesome/react-fontawesome' +import { isEmpty, pick, values } from 'ramda' +import React from 'react' +import { connect } from 'react-redux' +import { Link } from 'react-router-dom' +import { ListGroup, ListGroupItem } from 'reactstrap' +import { resetSelectedServer } from '../servers/reducers/selectedServer' +import './Home.scss' export class Home extends React.Component { componentDidMount() { @@ -9,13 +14,35 @@ export class Home extends React.Component { } render() { + const servers = values(this.props.servers); + const hasServers = !isEmpty(servers); + return ( -
-

Welcome to Shlink

-
Please, select a server.
+
+

Welcome to Shlink

+
+ {hasServers && Please, select a server.} + {!hasServers && Please, add a server.} +
+ + {hasServers && ( + + {servers.map(({ name, id }) => ( + + {name} + + + ))} + + )}
); } } -export default connect(null, { resetSelectedServer })(Home); +export default connect(pick(['servers']), { resetSelectedServer })(Home); diff --git a/src/common/Home.scss b/src/common/Home.scss index 7641664b..0333f63c 100644 --- a/src/common/Home.scss +++ b/src/common/Home.scss @@ -1,6 +1,7 @@ @import '../utils/base'; +@import '../utils/mixins/vertical-align'; -.home-container { +.home { text-align: center; height: calc(100vh - #{$headerHeight}); display: flex; @@ -9,6 +10,23 @@ flex-flow: column; } -.home-container__title { +.home__title { font-size: 36px; } + +.home__servers-list { + margin-top: 1rem; + width: 100%; + max-width: 400px; +} + +.home__servers-item.home__servers-item { + text-align: left; + position: relative; + padding: .75rem 2.5rem 0.75rem 1rem; +} + +.home__servers-item-icon { + @include vertical-align(); + right: 1rem; +} diff --git a/src/common/MenuLayout.js b/src/common/MenuLayout.js index 7105746a..4b3a983f 100644 --- a/src/common/MenuLayout.js +++ b/src/common/MenuLayout.js @@ -16,9 +16,11 @@ export class MenuLayout extends React.Component { } render() { + const { selectedServer } = this.props; + return (
- +
this.setState({ isModalOpen: !this.state.isModalOpen })} - history={history} server={server} key="deleteServerModal" /> diff --git a/src/servers/DeleteServerModal.js b/src/servers/DeleteServerModal.js index b0f62fad..95a0b554 100644 --- a/src/servers/DeleteServerModal.js +++ b/src/servers/DeleteServerModal.js @@ -1,9 +1,12 @@ +import PropTypes from 'prop-types'; import React from 'react'; import { connect } from 'react-redux'; +import { withRouter } from 'react-router-dom'; import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap'; +import { compose } from 'redux'; import { deleteServer } from './reducers/server'; -export const DeleteServerModal = ({ server, deleteServer, toggle, history, isOpen }) => { +export const DeleteServerModal = ({ server, toggle, isOpen, deleteServer, history }) => { const closeModal = () => { deleteServer(server); toggle(); @@ -15,7 +18,10 @@ export const DeleteServerModal = ({ server, deleteServer, toggle, history, isOpe Delete server

Are you sure you want to delete server {server ? server.name : ''}?

-

No data will be deleted, only the access to that server will be removed from this host. You can create it again at any moment.

+

+ No data will be deleted, only the access to that server will be removed from this host. + You can create it again at any moment. +

@@ -25,4 +31,18 @@ export const DeleteServerModal = ({ server, deleteServer, toggle, history, isOpe ); }; -export default connect(null, { deleteServer })(DeleteServerModal); +DeleteServerModal.propTypes = { + toggle: PropTypes.func.isRequired, + isOpen: PropTypes.bool.isRequired, + server: PropTypes.shape({ + id: PropTypes.string, + name: PropTypes.string, + url: PropTypes.string, + apiKey: PropTypes.string, + }), +}; + +export default compose( + withRouter, + connect(null, { deleteServer }) +)(DeleteServerModal); diff --git a/src/servers/reducers/selectedServer.js b/src/servers/reducers/selectedServer.js index 18833b34..3ed83fa6 100644 --- a/src/servers/reducers/selectedServer.js +++ b/src/servers/reducers/selectedServer.js @@ -1,9 +1,10 @@ import ShlinkApiClient from '../../api/ShlinkApiClient'; import ServersService from '../../servers/services/ServersService'; import { resetShortUrlParams } from '../../short-urls/reducers/shortUrlsListParams' +import { curry } from 'ramda'; -const SELECT_SERVER = 'shlink/selectedServer/SELECT_SERVER'; -const RESET_SELECTED_SERVER = 'shlink/selectedServer/RESET_SELECTED_SERVER'; +export const SELECT_SERVER = 'shlink/selectedServer/SELECT_SERVER'; +export const RESET_SELECTED_SERVER = 'shlink/selectedServer/RESET_SELECTED_SERVER'; const defaultState = null; @@ -20,7 +21,7 @@ export default function reducer(state = defaultState, action) { export const resetSelectedServer = () => ({ type: RESET_SELECTED_SERVER }); -export const selectServer = serverId => dispatch => { +export const _selectServer = (ShlinkApiClient, ServersService, serverId) => dispatch => { dispatch(resetShortUrlParams()); const selectedServer = ServersService.findServerById(serverId); @@ -31,3 +32,4 @@ export const selectServer = serverId => dispatch => { selectedServer }) }; +export const selectServer = curry(_selectServer)(ShlinkApiClient, ServersService); diff --git a/src/servers/reducers/server.js b/src/servers/reducers/server.js index 5daa566b..08c86c2e 100644 --- a/src/servers/reducers/server.js +++ b/src/servers/reducers/server.js @@ -1,8 +1,9 @@ import ServersService from '../services/ServersService'; +import { curry } from 'ramda'; -const FETCH_SERVERS = 'shlink/servers/FETCH_SERVERS'; -const CREATE_SERVER = 'shlink/servers/CREATE_SERVER'; -const DELETE_SERVER = 'shlink/servers/DELETE_SERVER'; +export const FETCH_SERVERS = 'shlink/servers/FETCH_SERVERS'; +export const CREATE_SERVER = 'shlink/servers/CREATE_SERVER'; +export const DELETE_SERVER = 'shlink/servers/DELETE_SERVER'; export default function reducer(state = {}, action) { switch (action.type) { @@ -17,19 +18,20 @@ export default function reducer(state = {}, action) { } } -export const listServers = () => { - return { - type: FETCH_SERVERS, - servers: ServersService.listServers(), - }; -}; +export const _listServers = ServersService => ({ + type: FETCH_SERVERS, + servers: ServersService.listServers(), +}); +export const listServers = () => _listServers(ServersService); -export const createServer = server => { +export const _createServer = (ServersService, server) => { ServersService.createServer(server); - return listServers(); + return _listServers(ServersService); }; +export const createServer = curry(_createServer)(ServersService); -export const deleteServer = server => { +export const _deleteServer = (ServersService, server) => { ServersService.deleteServer(server); - return listServers(); + return _listServers(ServersService); }; +export const deleteServer = curry(_deleteServer)(ServersService); diff --git a/src/short-urls/CreateShortUrl.js b/src/short-urls/CreateShortUrl.js index 94f61c27..59655469 100644 --- a/src/short-urls/CreateShortUrl.js +++ b/src/short-urls/CreateShortUrl.js @@ -1,14 +1,12 @@ import downIcon from '@fortawesome/fontawesome-free-solid/faAngleDoubleDown'; import upIcon from '@fortawesome/fontawesome-free-solid/faAngleDoubleUp'; import FontAwesomeIcon from '@fortawesome/react-fontawesome'; -import { assoc, dissoc, isNil, pick, pipe, pluck, replace } from 'ramda'; +import { assoc, dissoc, isNil, pick, pipe, replace, trim } from 'ramda'; import React from 'react'; import { connect } from 'react-redux'; -import ReactTags from 'react-tag-autocomplete'; +import TagsInput from 'react-tagsinput' import { Collapse } from 'reactstrap'; -import '../../node_modules/react-datepicker/dist/react-datepicker.css'; import DateInput from '../common/DateInput'; -import './CreateShortUrl.scss'; import CreateShortUrlResult from './helpers/CreateShortUrlResult'; import { createShortUrl, resetCreateShortUrl } from './reducers/shortUrlCreationResult'; @@ -26,14 +24,7 @@ export class CreateShortUrl extends React.Component { render() { const { createShortUrl, shortUrlCreationResult, resetCreateShortUrl } = this.props; - const addTag = tag => this.setState({ - tags: [].concat(this.state.tags, assoc('name', replace(/ /g, '-', tag.name), tag)) - }); - const removeTag = i => { - const tags = this.state.tags.slice(0); - tags.splice(i, 1); - this.setState({ tags }); - }; + const changeTags = tags => this.setState({ tags: tags.map(pipe(trim, replace(/ /g, '-'))) }); const renderOptionalInput = (id, placeholder, type = 'text', props = {}) => this.setState({ [id]: date })} + isClearable {...props} />; const formatDate = date => isNil(date) ? date : date.format(); @@ -55,7 +47,6 @@ export class CreateShortUrl extends React.Component { e.preventDefault(); createShortUrl(pipe( dissoc('moreOptionsVisible'), // Remove moreOptionsVisible property - assoc('tags', pluck('name', this.state.tags)), // Map tags array to use only their names assoc('validSince', formatDate(this.state.validSince)), assoc('validUntil', formatDate(this.state.validUntil)) )(this.state)); @@ -77,12 +68,12 @@ export class CreateShortUrl extends React.Component {
-
diff --git a/src/short-urls/CreateShortUrl.scss b/src/short-urls/CreateShortUrl.scss deleted file mode 100644 index 7967eb77..00000000 --- a/src/short-urls/CreateShortUrl.scss +++ /dev/null @@ -1,24 +0,0 @@ -@import '../../node_modules/react-tag-autocomplete/example/styles.css'; -@import '../utils/mixins/box-shadow'; -@import '../utils/mixins/border-radius'; - -.create-short-url__btn:not(:first-child) { - margin-left: 5px; -} - -.react-tags { - @include border-radius(.25rem); - transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out, -webkit-box-shadow .15s ease-in-out; -} -.react-tags.is-focused { - color: #495057; - background-color: #fff; - border-color: #80bdff; - outline: 0; - @include box-shadow(0 0 0 0.2rem rgba(0,123,255,.25)); -} - -.react-datepicker__input-container, -.react-datepicker-wrapper { - display: block !important; -} diff --git a/src/short-urls/SearchBar.js b/src/short-urls/SearchBar.js index 5feaf65a..d82ae9f2 100644 --- a/src/short-urls/SearchBar.js +++ b/src/short-urls/SearchBar.js @@ -1,4 +1,5 @@ import searchIcon from '@fortawesome/fontawesome-free-solid/faSearch'; +import tagsIcon from '@fortawesome/fontawesome-free-solid/faTags'; import FontAwesomeIcon from '@fortawesome/react-fontawesome'; import React from 'react'; import { connect } from 'react-redux'; @@ -41,7 +42,7 @@ export class SearchBar extends React.Component { {!isEmpty(selectedTags) && (

- Filtering by tags: +   {selectedTags.map(tag =>
@@ -123,7 +123,7 @@ export class ShortUrlsVisits extends React.Component {

{ shortUrl.visitsCount && - Visits: {shortUrl.visitsCount} + Visits: {shortUrl.visitsCount} } Visit stats for {shortLink}

@@ -144,23 +144,26 @@ export class ShortUrlsVisits extends React.Component { -
-
e.preventDefault()} className="form-inline mt-4 float-md-right"> - - this.setState({ startDate: date }, () => this.loadVisits())} - className="short-url-visits__date-input" - /> - this.setState({ endDate: date }, () => this.loadVisits())} - className="short-url-visits__date-input" - /> - -
+
+
+
+ this.setState({ startDate: date }, () => this.loadVisits())} + /> +
+
+ this.setState({ endDate: date }, () => this.loadVisits())} + className="short-url-visits__date-input" + /> +
+
diff --git a/src/short-urls/ShortUrlVisits.scss b/src/short-urls/ShortUrlVisits.scss index 4ebf855e..75aadecc 100644 --- a/src/short-urls/ShortUrlVisits.scss +++ b/src/short-urls/ShortUrlVisits.scss @@ -1,3 +1,7 @@ +@import '../utils/base'; + .short-url-visits__date-input { - margin-left: 10px; + @media(max-width: $smMax) { + margin-top: 0.5rem; + } } diff --git a/src/short-urls/ShortUrlsList.js b/src/short-urls/ShortUrlsList.js index 68afb2a3..ef997589 100644 --- a/src/short-urls/ShortUrlsList.js +++ b/src/short-urls/ShortUrlsList.js @@ -1,12 +1,20 @@ -import caretDownIcon from '@fortawesome/fontawesome-free-solid/faCaretDown'; -import caretUpIcon from '@fortawesome/fontawesome-free-solid/faCaretUp'; -import FontAwesomeIcon from '@fortawesome/react-fontawesome'; -import { isEmpty, pick } from 'ramda'; -import React from 'react'; -import { connect } from 'react-redux'; -import { ShortUrlsRow } from './helpers/ShortUrlsRow'; -import { listShortUrls } from './reducers/shortUrlsList'; -import './ShortUrlsList.scss'; +import caretDownIcon from '@fortawesome/fontawesome-free-solid/faCaretDown' +import caretUpIcon from '@fortawesome/fontawesome-free-solid/faCaretUp' +import FontAwesomeIcon from '@fortawesome/react-fontawesome' +import { head, isEmpty, pick, toPairs } from 'ramda' +import React from 'react' +import { connect } from 'react-redux' +import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap' +import { ShortUrlsRow } from './helpers/ShortUrlsRow' +import { listShortUrls } from './reducers/shortUrlsList' +import './ShortUrlsList.scss' + +const SORTABLE_FIELDS = { + dateCreated: 'Created at', + shortCode: 'Short URL', + originalUrl: 'Long URL', + visits: 'Visits', +}; export class ShortUrlsList extends React.Component { refreshList = extraParams => { @@ -16,14 +24,42 @@ export class ShortUrlsList extends React.Component { ...extraParams }); }; + determineOrderDir = field => { + if (this.state.orderField !== field) { + return 'ASC'; + } + + const newOrderMap = { + 'ASC': 'DESC', + 'DESC': undefined, + }; + return this.state.orderDir ? newOrderMap[this.state.orderDir] : 'ASC'; + } + orderBy = field => { + const newOrderDir = this.determineOrderDir(field); + this.setState({ orderField: newOrderDir !== undefined ? field : undefined, orderDir: newOrderDir }); + this.refreshList({ orderBy: { [field]: newOrderDir } }) + }; + renderOrderIcon = (field, className = 'short-urls-list__header-icon') => { + if (this.state.orderField !== field) { + return null; + } + + return ( + + ); + }; constructor(props) { super(props); - const orderBy = props.shortUrlsListParams.orderBy; + const { orderBy } = props.shortUrlsListParams; this.state = { - orderField: orderBy ? Object.keys(orderBy)[0] : 'dateCreated', - orderDir: orderBy ? Object.values(orderBy)[0] : 'ASC', + orderField: orderBy ? head(Object.keys(orderBy)) : undefined, + orderDir: orderBy ? head(Object.values(orderBy)) : undefined, } } @@ -32,67 +68,6 @@ export class ShortUrlsList extends React.Component { this.refreshList({ page: params.page }); } - render() { - const orderBy = field => { - const newOrderDir = this.state.orderField !== field ? 'ASC' : (this.state.orderDir === 'DESC' ? 'ASC' : 'DESC'); - this.setState({ orderField: field, orderDir: newOrderDir }); - this.refreshList({ orderBy: { [field]: newOrderDir } }) - }; - const renderOrderIcon = field => { - if (this.state.orderField !== field) { - return null; - } - - return ( - - ); - }; - - return ( - - - - - - - - - - - - - {this.renderShortUrls()} - -
orderBy('dateCreated')} - > - {renderOrderIcon('dateCreated')} - Created at - orderBy('shortCode')} - > - {renderOrderIcon('shortCode')} - Short URL - orderBy('originalUrl')} - > - {renderOrderIcon('originalUrl')} - Long URL - Tags orderBy('visits')} - > - {renderOrderIcon('visits')} Visits -  
- ); - } - renderShortUrls() { const { shortUrlsList, selectedServer, loading, error, shortUrlsListParams } = this.props; if (error) { @@ -117,6 +92,71 @@ export class ShortUrlsList extends React.Component { /> )); } + + renderMobileOrderingControls() { + return ( +
+ + + Order by + + + {toPairs(SORTABLE_FIELDS).map(([key, value]) => + this.orderBy(key)}> + {value} + {this.renderOrderIcon(key, 'short-urls-list__header-icon--mobile')} + )} + + +
+ ); + } + + render() { + return ( + + {this.renderMobileOrderingControls()} + + + + + + + + + + + + + {this.renderShortUrls()} + +
this.orderBy('dateCreated')} + > + {this.renderOrderIcon('dateCreated')} + Created at + this.orderBy('shortCode')} + > + {this.renderOrderIcon('shortCode')} + Short URL + this.orderBy('originalUrl')} + > + {this.renderOrderIcon('originalUrl')} + Long URL + Tags this.orderBy('visits')} + > + {this.renderOrderIcon('visits')} Visits +  
+
+ ); + } } export default connect(pick(['selectedServer', 'shortUrlsListParams']), { listShortUrls })(ShortUrlsList); diff --git a/src/short-urls/ShortUrlsList.scss b/src/short-urls/ShortUrlsList.scss index df2cf01d..020081dd 100644 --- a/src/short-urls/ShortUrlsList.scss +++ b/src/short-urls/ShortUrlsList.scss @@ -13,3 +13,16 @@ .short-urls-list__header-icon { margin-right: 5px; } + +.short-urls-list__header-icon--mobile { + margin: 3.5px 0 0; + float: right; +} + +.short-urls-list__header-cell--with-action { + cursor: pointer; +} + +.short-urls-list__order-dropdown { + width: 100%; +} diff --git a/src/short-urls/helpers/ShortUrlsRow.scss b/src/short-urls/helpers/ShortUrlsRow.scss index 4f6037bc..da6e8f86 100644 --- a/src/short-urls/helpers/ShortUrlsRow.scss +++ b/src/short-urls/helpers/ShortUrlsRow.scss @@ -27,10 +27,11 @@ &:last-child { position: absolute; - top: 3px; + top: 3.5px; right: .5rem; width: auto; padding: 0; + border: none; } } } diff --git a/src/short-urls/reducers/shortUrlCreationResult.js b/src/short-urls/reducers/shortUrlCreationResult.js index 88675f6c..c74b97d5 100644 --- a/src/short-urls/reducers/shortUrlCreationResult.js +++ b/src/short-urls/reducers/shortUrlCreationResult.js @@ -1,4 +1,5 @@ import ShlinkApiClient from '../../api/ShlinkApiClient'; +import { curry } from 'ramda'; const CREATE_SHORT_URL_START = 'shlink/createShortUrl/CREATE_SHORT_URL_START'; const CREATE_SHORT_URL_ERROR = 'shlink/createShortUrl/CREATE_SHORT_URL_ERROR'; @@ -37,7 +38,7 @@ export default function reducer(state = defaultState, action) { } } -export const createShortUrl = data => async dispatch => { +export const _createShortUrl = (ShlinkApiClient, data) => async dispatch => { dispatch({ type: CREATE_SHORT_URL_START }); try { @@ -47,5 +48,6 @@ export const createShortUrl = data => async dispatch => { dispatch({ type: CREATE_SHORT_URL_ERROR }); } }; +export const createShortUrl = curry(_createShortUrl)(ShlinkApiClient); export const resetCreateShortUrl = () => ({ type: RESET_CREATE_SHORT_URL }); diff --git a/src/short-urls/reducers/shortUrlVisits.js b/src/short-urls/reducers/shortUrlVisits.js index 0f9c8ed4..a247fa2a 100644 --- a/src/short-urls/reducers/shortUrlVisits.js +++ b/src/short-urls/reducers/shortUrlVisits.js @@ -1,4 +1,5 @@ import ShlinkApiClient from '../../api/ShlinkApiClient'; +import { curry } from 'ramda'; const GET_SHORT_URL_VISITS_START = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_START'; const GET_SHORT_URL_VISITS_ERROR = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_ERROR'; @@ -36,7 +37,7 @@ export default function dispatch (state = initialState, action) { } } -export const getShortUrlVisits = (shortCode, dates) => dispatch => { +export const _getShortUrlVisits = (ShlinkApiClient, shortCode, dates) => dispatch => { dispatch({ type: GET_SHORT_URL_VISITS_START }); Promise.all([ @@ -46,3 +47,4 @@ export const getShortUrlVisits = (shortCode, dates) => dispatch => { .then(([visits, shortUrl]) => dispatch({ visits, shortUrl, type: GET_SHORT_URL_VISITS })) .catch(() => dispatch({ type: GET_SHORT_URL_VISITS_ERROR })); }; +export const getShortUrlVisits = curry(_getShortUrlVisits)(ShlinkApiClient); diff --git a/src/short-urls/reducers/shortUrlsList.js b/src/short-urls/reducers/shortUrlsList.js index f9771eae..9d76e44c 100644 --- a/src/short-urls/reducers/shortUrlsList.js +++ b/src/short-urls/reducers/shortUrlsList.js @@ -30,7 +30,7 @@ export default function reducer(state = initialState, action) { } } -export const listShortUrls = (params = {}) => async dispatch => { +export const _listShortUrls = (ShlinkApiClient, params = {}) => async dispatch => { dispatch({ type: LIST_SHORT_URLS_START }); try { @@ -40,3 +40,4 @@ export const listShortUrls = (params = {}) => async dispatch => { dispatch({ type: LIST_SHORT_URLS_ERROR, params }); } }; +export const listShortUrls = (params = {}) => _listShortUrls(ShlinkApiClient, params); diff --git a/src/short-urls/reducers/shortUrlsListParams.js b/src/short-urls/reducers/shortUrlsListParams.js index 3217167d..29464264 100644 --- a/src/short-urls/reducers/shortUrlsListParams.js +++ b/src/short-urls/reducers/shortUrlsListParams.js @@ -1,6 +1,6 @@ import { LIST_SHORT_URLS } from './shortUrlsList'; -const RESET_SHORT_URL_PARAMS = 'shlink/shortUrlsListParams/RESET_SHORT_URL_PARAMS'; +export const RESET_SHORT_URL_PARAMS = 'shlink/shortUrlsListParams/RESET_SHORT_URL_PARAMS'; const defaultState = { page: '1' }; diff --git a/src/utils/Tag.scss b/src/utils/Tag.scss index 24a838e3..757abd36 100644 --- a/src/utils/Tag.scss +++ b/src/utils/Tag.scss @@ -16,6 +16,6 @@ } .tag__close-selected-tag.tag__close-selected-tag:hover { - color: inherit; - opacity: 1; + color: inherit !important; + opacity: 1 !important; } diff --git a/test/common/AsideMenu.test.js b/test/common/AsideMenu.test.js new file mode 100644 index 00000000..db5e8f71 --- /dev/null +++ b/test/common/AsideMenu.test.js @@ -0,0 +1,25 @@ +import { shallow } from 'enzyme' +import React from 'react' +import AsideMenu from '../../src/common/AsideMenu' + +describe('', () => { + let wrapped; + + beforeEach(() => { + wrapped = shallow(); + }); + afterEach(() => { + wrapped.unmount(); + }); + + it('contains links to selected server', () => { + const links = wrapped.find('NavLink'); + + expect(links).toHaveLength(2); + links.forEach(link => expect(link.prop('to')).toContain('abc123')); + }); + + it('contains a button to delete server', () => { + expect(wrapped.find('DeleteServerButton')).toHaveLength(1); + }); +}); diff --git a/test/common/DateInput.test.js b/test/common/DateInput.test.js new file mode 100644 index 00000000..a2df216e --- /dev/null +++ b/test/common/DateInput.test.js @@ -0,0 +1,39 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import DateInput from '../../src/common/DateInput'; +import FontAwesomeIcon from '@fortawesome/react-fontawesome'; +import moment from 'moment'; + +describe('', () => { + let wrapped; + + const createComponent = (props = {}) => { + wrapped = shallow(); + return wrapped; + }; + afterEach(() => { + if (wrapped !== undefined) { + wrapped.unmount(); + wrapped = undefined; + } + }); + + it('wrapps a DatePicker', () => { + wrapped = createComponent(); + }); + + it('shows calendar icon when input is not clearable', () => { + wrapped = createComponent({ isClearable: false }); + expect(wrapped.find(FontAwesomeIcon)).toHaveLength(1); + }); + + it('shows calendar icon when input is clearable but selected value is nil', () => { + wrapped = createComponent({ isClearable: true, selected: null }); + expect(wrapped.find(FontAwesomeIcon)).toHaveLength(1); + }); + + it('does not show calendar icon when input is clearable', () => { + wrapped = createComponent({ isClearable: true, selected: moment() }); + expect(wrapped.find(FontAwesomeIcon)).toHaveLength(0); + }); +}); diff --git a/test/common/Home.test.js b/test/common/Home.test.js new file mode 100644 index 00000000..1495604f --- /dev/null +++ b/test/common/Home.test.js @@ -0,0 +1,52 @@ +import { shallow } from 'enzyme'; +import { values } from 'ramda'; +import React from 'react'; +import * as sinon from 'sinon'; +import { Home } from '../../src/common/Home'; + +describe('', () => { + let wrapped; + const defaultProps = { + resetSelectedServer: () => {}, + servers: {}, + }; + const createComponent = props => { + const actualProps = { ...defaultProps, ...props }; + wrapped = shallow(); + return wrapped; + }; + + afterEach(() => { + if (wrapped !== undefined) { + wrapped.unmount(); + wrapped = undefined; + } + }); + + it('resets selected server when mounted', () => { + const resetSelectedServer = sinon.spy(); + + expect(resetSelectedServer.called).toEqual(false); + createComponent({ resetSelectedServer }); + expect(resetSelectedServer.called).toEqual(true); + }); + + it('shows link to create server when no servers exist', () => { + const wrapped = createComponent(); + + expect(wrapped.find('Link')).toHaveLength(1); + expect(wrapped.find('ListGroup')).toHaveLength(0); + }); + + it('shows servers list when list of servers is not empty', () => { + const servers = { + 1: { name: 'foo', id: '123' }, + 2: { name: 'bar', id: '456' }, + } + const wrapped = createComponent({ servers }); + + expect(wrapped.find('Link')).toHaveLength(0); + expect(wrapped.find('ListGroup')).toHaveLength(1); + expect(wrapped.find('ListGroupItem')).toHaveLength(values(servers).length); + }); +}); diff --git a/test/servers/reducers/selectedServer.test.js b/test/servers/reducers/selectedServer.test.js new file mode 100644 index 00000000..ca73ede4 --- /dev/null +++ b/test/servers/reducers/selectedServer.test.js @@ -0,0 +1,69 @@ +import reduce, { + _selectServer, + RESET_SELECTED_SERVER, + resetSelectedServer, + SELECT_SERVER, +} from '../../../src/servers/reducers/selectedServer'; +import * as sinon from 'sinon'; +import { RESET_SHORT_URL_PARAMS } from '../../../src/short-urls/reducers/shortUrlsListParams'; + +describe('selectedServerReducer', () => { + describe('reduce', () => { + it('returns default when action is not handled', () => + expect(reduce(null, { type: 'unknown' })).toEqual(null) + ); + + it('returns default when action is RESET_SELECTED_SERVER', () => + expect(reduce(null, { type: RESET_SELECTED_SERVER })).toEqual(null) + ); + + it('returns selected server when action is SELECT_SERVER', () => { + const selectedServer = { id: 'abc123' }; + expect(reduce(null, { type: SELECT_SERVER, selectedServer })).toEqual(selectedServer); + }); + }); + + describe('resetSelectedServer', () => { + it('returns proper action', () => { + expect(resetSelectedServer()).toEqual({ type: RESET_SELECTED_SERVER }); + }); + }); + + describe('selectServer', () => { + const ShlinkApiClientMock = { + setConfig: sinon.spy() + }; + const serverId = 'abc123'; + const selectedServer = { + id: serverId + }; + const ServersServiceMock = { + findServerById: sinon.fake.returns(selectedServer) + }; + + afterEach(() => { + ShlinkApiClientMock.setConfig.resetHistory(); + ServersServiceMock.findServerById.resetHistory(); + }); + + it('dispatches proper actions', () => { + const dispatch = sinon.spy(); + + _selectServer(ShlinkApiClientMock, ServersServiceMock, serverId)(dispatch); + + expect(dispatch.callCount).toEqual(2); + expect(dispatch.firstCall.calledWith({ type: RESET_SHORT_URL_PARAMS })).toEqual(true); + expect(dispatch.secondCall.calledWith({ + type: SELECT_SERVER, + selectedServer + })).toEqual(true); + }); + + it('invokes dependencies', () => { + _selectServer(ShlinkApiClientMock, ServersServiceMock, serverId)(() => {}); + + expect(ShlinkApiClientMock.setConfig.callCount).toEqual(1); + expect(ServersServiceMock.findServerById.callCount).toEqual(1); + }); + }); +}); diff --git a/test/servers/reducers/server.test.js b/test/servers/reducers/server.test.js new file mode 100644 index 00000000..cd3f24b1 --- /dev/null +++ b/test/servers/reducers/server.test.js @@ -0,0 +1,87 @@ +import reduce, { + _createServer, + _deleteServer, + _listServers, + CREATE_SERVER, + DELETE_SERVER, + FETCH_SERVERS, +} from '../../../src/servers/reducers/server'; +import * as sinon from 'sinon'; + +describe('serverReducer', () => { + const servers = { + abc123: { id: 'abc123' }, + def456: { id: 'def456' } + }; + const ServersServiceMock = { + listServers: sinon.fake.returns(servers), + createServer: sinon.fake(), + deleteServer: sinon.fake(), + }; + + describe('reduce', () => { + it('returns servers when action is FETCH_SERVERS', () => + expect(reduce({}, { type: FETCH_SERVERS, servers })).toEqual(servers) + ); + + it('returns servers when action is DELETE_SERVER', () => + expect(reduce({}, { type: DELETE_SERVER, servers })).toEqual(servers) + ); + + it('adds server to list when action is CREATE_SERVER', () => { + const server = { id: 'abc123' }; + expect(reduce({}, { type: CREATE_SERVER, server })).toEqual({ + [server.id]: server + }) + }); + + it('returns default when action is unknown', () => + expect(reduce({}, { type: 'unknown' })).toEqual({}) + ); + }); + + describe('action creators', () => { + beforeEach(() => { + ServersServiceMock.listServers.resetHistory(); + ServersServiceMock.createServer.resetHistory(); + ServersServiceMock.deleteServer.resetHistory(); + }); + + describe('listServers', () => { + it('fetches servers and returns them as part of the action', () => { + const result = _listServers(ServersServiceMock); + + expect(result).toEqual({ type: FETCH_SERVERS, servers }); + expect(ServersServiceMock.listServers.callCount).toEqual(1); + expect(ServersServiceMock.createServer.callCount).toEqual(0); + expect(ServersServiceMock.deleteServer.callCount).toEqual(0); + }); + }); + + describe('createServer', () => { + it('adds new server and then fetches servers again', () => { + const serverToCreate = { id: 'abc123' }; + const result = _createServer(ServersServiceMock, serverToCreate); + + expect(result).toEqual({ type: FETCH_SERVERS, servers }); + expect(ServersServiceMock.listServers.callCount).toEqual(1); + expect(ServersServiceMock.createServer.callCount).toEqual(1); + expect(ServersServiceMock.createServer.firstCall.calledWith(serverToCreate)).toEqual(true); + expect(ServersServiceMock.deleteServer.callCount).toEqual(0); + }); + }); + + describe('deleteServer', () => { + it('deletes a server and then fetches servers again', () => { + const serverToDelete = { id: 'abc123' }; + const result = _deleteServer(ServersServiceMock, serverToDelete); + + expect(result).toEqual({ type: FETCH_SERVERS, servers }); + expect(ServersServiceMock.listServers.callCount).toEqual(1); + expect(ServersServiceMock.createServer.callCount).toEqual(0); + expect(ServersServiceMock.deleteServer.callCount).toEqual(1); + expect(ServersServiceMock.deleteServer.firstCall.calledWith(serverToDelete)).toEqual(true); + }); + }); + }); +}); diff --git a/test/shortUrls/reducers/shortUrlsListParams.test.js b/test/shortUrls/reducers/shortUrlsListParams.test.js new file mode 100644 index 00000000..f22507d3 --- /dev/null +++ b/test/shortUrls/reducers/shortUrlsListParams.test.js @@ -0,0 +1,32 @@ +import reduce, { + RESET_SHORT_URL_PARAMS, + resetShortUrlParams, +} from '../../../src/short-urls/reducers/shortUrlsListParams'; +import { LIST_SHORT_URLS } from '../../../src/short-urls/reducers/shortUrlsList'; + +describe('shortUrlsListParamsReducer', () => { + describe('reduce', () => { + const defaultState = { page: '1' }; + + it('returns default value when action is anknown', () => + expect(reduce(defaultState, { type: 'unknown' })).toEqual(defaultState) + ); + + it('returns params when action is LIST_SHORT_URLS', () => + expect(reduce(defaultState, { type: LIST_SHORT_URLS, params: { searchTerm: 'foo' } })).toEqual({ + ...defaultState, + searchTerm: 'foo' + }) + ); + + it('returns default value when action is RESET_SHORT_URL_PARAMS', () => + expect(reduce(defaultState, { type: RESET_SHORT_URL_PARAMS })).toEqual(defaultState) + ); + }); + + describe('resetShortUrlParams', () => { + it('returns proper action', () => + expect(resetShortUrlParams()).toEqual({ type: RESET_SHORT_URL_PARAMS }) + ); + }); +}); diff --git a/yarn.lock b/yarn.lock index d9056d4c..7c4c492c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -31,6 +31,22 @@ humps "^2.0.1" prop-types "^15.5.7" +"@sinonjs/commons@^1.0.1": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.0.2.tgz#3e0ac737781627b8844257fadc3d803997d0526e" + dependencies: + type-detect "4.0.8" + +"@sinonjs/formatio@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@sinonjs/formatio/-/formatio-2.0.0.tgz#84db7e9eb5531df18a8c5e0bfb6e449e55e654b2" + dependencies: + samsam "1.3.0" + +"@sinonjs/samsam@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-2.0.0.tgz#9163742ac35c12d3602dece74317643b35db6a80" + "@types/node@*": version "10.5.6" resolved "https://registry.yarnpkg.com/@types/node/-/node-10.5.6.tgz#1640f021dd0eaf12e731e54198c12ad2e020dc8e" @@ -2151,7 +2167,7 @@ detect-port-alt@1.1.6: address "^1.0.1" debug "^2.6.0" -diff@^3.2.0: +diff@^3.2.0, diff@^3.5.0: version "3.5.0" resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12" @@ -4451,6 +4467,10 @@ jsx-ast-utils@^2.0.0: dependencies: array-includes "^3.0.3" +just-extend@^1.1.27: + version "1.1.27" + resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-1.1.27.tgz#ec6e79410ff914e472652abfa0e603c03d60e905" + killable@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/killable/-/killable-1.0.0.tgz#da8b84bd47de5395878f95d64d02f2449fe05e6b" @@ -4598,6 +4618,10 @@ lodash.flattendeep@^4.4.0: version "4.4.0" resolved "https://registry.yarnpkg.com/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz#fb030917f86a3134e5bc9bec0d69e0013ddfedb2" +lodash.get@^4.4.2: + version "4.4.2" + resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" + lodash.isfunction@^3.0.9: version "3.0.9" resolved "https://registry.yarnpkg.com/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz#06de25df4db327ac931981d1bdb067e5af68d051" @@ -4647,6 +4671,10 @@ loglevel@^1.4.1: version "1.6.1" resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.6.1.tgz#e0fc95133b6ef276cdc8887cdaf24aa6f156f8fa" +lolex@^2.3.2, lolex@^2.7.1: + version "2.7.1" + resolved "https://registry.yarnpkg.com/lolex/-/lolex-2.7.1.tgz#e40a8c4d1f14b536aa03e42a537c7adbaf0c20be" + longest@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/longest/-/longest-1.0.1.tgz#30a0b2da38f73770e8294a0d22e6625ed77d0097" @@ -4975,6 +5003,16 @@ next-tick@1: version "1.0.0" resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.0.0.tgz#ca86d1fe8828169b0120208e3dc8424b9db8342c" +nise@^1.4.2: + version "1.4.2" + resolved "https://registry.yarnpkg.com/nise/-/nise-1.4.2.tgz#a9a3800e3994994af9e452333d549d60f72b8e8c" + dependencies: + "@sinonjs/formatio" "^2.0.0" + just-extend "^1.1.27" + lolex "^2.3.2" + path-to-regexp "^1.7.0" + text-encoding "^0.6.4" + no-case@^2.2.0: version "2.3.2" resolved "https://registry.yarnpkg.com/no-case/-/no-case-2.3.2.tgz#60b813396be39b3f1288a4c1ed5d1e7d28b464ac" @@ -6190,9 +6228,9 @@ react-router@^4.3.1: prop-types "^15.6.1" warning "^4.0.1" -react-tag-autocomplete@^5.5.1: - version "5.5.1" - resolved "https://registry.yarnpkg.com/react-tag-autocomplete/-/react-tag-autocomplete-5.5.1.tgz#6b3f253d3d69eb546925118cdf43138a9aafe113" +react-tagsinput@^3.19.0: + version "3.19.0" + resolved "https://registry.yarnpkg.com/react-tagsinput/-/react-tagsinput-3.19.0.tgz#6e3b45595f2d295d4657bf194491988f948caabf" react-test-renderer@^16.0.0-0: version "16.4.2" @@ -6623,6 +6661,10 @@ safe-regex@^1.1.0: version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" +samsam@1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/samsam/-/samsam-1.3.0.tgz#8d1d9350e25622da30de3e44ba692b5221ab7c50" + sane@~1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/sane/-/sane-1.6.0.tgz#9610c452307a135d29c1fdfe2547034180c46775" @@ -6819,6 +6861,20 @@ signal-exit@^3.0.0, signal-exit@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d" +sinon@^6.1.5: + version "6.1.5" + resolved "https://registry.yarnpkg.com/sinon/-/sinon-6.1.5.tgz#41451502d43cd5ffb9d051fbf507952400e81d09" + dependencies: + "@sinonjs/commons" "^1.0.1" + "@sinonjs/formatio" "^2.0.0" + "@sinonjs/samsam" "^2.0.0" + diff "^3.5.0" + lodash.get "^4.4.2" + lolex "^2.7.1" + nise "^1.4.2" + supports-color "^5.4.0" + type-detect "^4.0.8" + slash@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/slash/-/slash-1.0.0.tgz#c41f2f6c39fc16d1cd17ad4b5d896114ae470d55" @@ -7237,6 +7293,10 @@ test-exclude@^4.2.1: read-pkg-up "^1.0.1" require-main-filename "^1.0.1" +text-encoding@^0.6.4: + version "0.6.4" + resolved "https://registry.yarnpkg.com/text-encoding/-/text-encoding-0.6.4.tgz#e399a982257a276dae428bb92845cb71bdc26d19" + text-table@0.2.0, text-table@~0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" @@ -7366,6 +7426,10 @@ type-check@~0.3.2: dependencies: prelude-ls "~1.1.2" +type-detect@4.0.8, type-detect@^4.0.8: + version "4.0.8" + resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" + type-is@~1.6.15, type-is@~1.6.16: version "1.6.16" resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.16.tgz#f89ce341541c672b25ee7ae3c73dee3b2be50194"