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 {
-
-
-
+
+
+
+ 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 (
-
-
-
- orderBy('dateCreated')}
- >
- {renderOrderIcon('dateCreated')}
- Created at
- |
- orderBy('shortCode')}
- >
- {renderOrderIcon('shortCode')}
- Short URL
- |
- orderBy('originalUrl')}
- >
- {renderOrderIcon('originalUrl')}
- Long URL
- |
- Tags |
- orderBy('visits')}
- >
- {renderOrderIcon('visits')} Visits
- |
- |
-
-
-
- {this.renderShortUrls()}
-
-
- );
- }
-
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.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
+ |
+ |
+
+
+
+ {this.renderShortUrls()}
+
+
+
+ );
+ }
}
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"