Merge pull request #18 from acelaya/feature/0.2.0

Feature/0.2.0
This commit is contained in:
Alejandro Celaya 2018-08-12 19:23:18 +02:00 committed by GitHub
commit 42d718960f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
49 changed files with 825 additions and 222 deletions

View file

@ -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

View file

@ -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",

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
public/icons/icon-72x72.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

BIN
public/icons/icon-96x96.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 690 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

View file

@ -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"
]
}

View file

@ -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 (
<aside className="aside-menu col-lg-2 col-md-3">
@ -21,7 +17,6 @@ export default function AsideMenu({ selectedServer, history }) {
className="aside-menu__item"
activeClassName="aside-menu__item--selected"
to={`/server/${serverId}/list-short-urls/1`}
isActive={isListShortUrlsActive}
>
<FontAwesomeIcon icon={listIcon} />
<span className="aside-menu__item-text">List short URLs</span>
@ -37,10 +32,18 @@ export default function AsideMenu({ selectedServer, history }) {
<DeleteServerButton
className="aside-menu__item aside-menu__item--danger"
history={history}
server={selectedServer}
/>
</nav>
</aside>
);
}
AsideMenu.propTypes = {
selectedServer: PropTypes.shape({
id: PropTypes.string,
name: PropTypes.string,
url: PropTypes.string,
apiKey: PropTypes.string,
}),
};

View file

@ -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 (
<div className="date-input-container">
<DatePicker
@ -20,11 +24,13 @@ export default class DateInput extends React.Component {
readOnly
ref={this.inputRef}
/>
{showCalendarIcon && (
<FontAwesomeIcon
icon={calendarIcon}
className="date-input-container__icon"
onClick={() => this.inputRef.current.input.focus()}
/>
)}
</div>
);
}

View file

@ -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;
}

View file

@ -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 (
<div className="home-container">
<h1 className="home-container__title">Welcome to Shlink</h1>
<h5 className="home-container__intro">Please, select a server.</h5>
<div className="home">
<h1 className="home__title">Welcome to Shlink</h1>
<h5 className="home__intro">
{hasServers && <span>Please, select a server.</span>}
{!hasServers && <span>Please, <Link to="/server/create">add a server</Link>.</span>}
</h5>
{hasServers && (
<ListGroup className="home__servers-list">
{servers.map(({ name, id }) => (
<ListGroupItem
key={id}
tag={Link}
to={`/server/${id}/list-short-urls/1`}
className="home__servers-item"
>
{name}
<FontAwesomeIcon icon={chevronIcon} className="home__servers-item-icon" />
</ListGroupItem>
))}
</ListGroup>
)}
</div>
);
}
}
export default connect(null, { resetSelectedServer })(Home);
export default connect(pick(['servers']), { resetSelectedServer })(Home);

View file

@ -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;
}

View file

@ -16,9 +16,11 @@ export class MenuLayout extends React.Component {
}
render() {
const { selectedServer } = this.props;
return (
<div className="row">
<AsideMenu {...this.props} />
<AsideMenu selectedServer={selectedServer} />
<div className="col-lg-10 offset-lg-2 col-md-9 offset-md-3">
<Switch>
<Route

View file

@ -0,0 +1,54 @@
.react-tagsinput {
background-color: #fff;
border: 1px solid #ccc;
border-radius: .25rem;
overflow: hidden;
min-height: calc(2.6rem + 2px);
padding: 6px 0 0 6px;
}
.react-tagsinput--focused {
border-color: #80bdff;
-webkit-box-shadow: 0 0 0 0.2rem rgba(0,123,255,.25);
box-shadow: 0 0 0 0.2rem rgba(0,123,255,.25);
-webkit-transition: border-color .15s ease-in-out,-webkit-box-shadow .15s ease-in-out;
transition: border-color .15s ease-in-out,-webkit-box-shadow .15s ease-in-out;
-o-transition: border-color .15s ease-in-out,box-shadow .15s ease-in-out;
transition: border-color .15s ease-in-out,box-shadow .15s ease-in-out;
transition: border-color .15s ease-in-out,box-shadow .15s ease-in-out,-webkit-box-shadow .15s ease-in-out;
}
.react-tagsinput-tag {
font-size: 1rem;
background-color: #f1f1f1;
border-radius: 2px;
border: 1px solid #d1d1d1;
display: inline-block;
font-weight: 400;
margin: 0 5px 6px 0;
padding: 6px 8px;
line-height: 1;
}
.react-tagsinput-tag:hover {
border-color: #b1b1b1;
}
.react-tagsinput-remove {
cursor: pointer;
font-weight: bold;
margin-left: 8px;
}
.react-tagsinput-tag a::before {
content: "\2715";
color: #aaa;
}
.react-tagsinput-input {
background: transparent;
border: 0;
outline: none;
padding: 3px 5px;
width: 155px;
margin-bottom: 6px;
}

View file

@ -6,6 +6,9 @@ import { BrowserRouter } from 'react-router-dom';
import { applyMiddleware, compose, createStore } from 'redux';
import ReduxThunk from 'redux-thunk';
import '../node_modules/react-datepicker/dist/react-datepicker.css';
import './common/react-tagsinput.scss';
import App from './App';
import './index.scss';
import ScrollToTop from './common/ScrollToTop'

View file

@ -26,3 +26,13 @@
padding: 30px 30px 30px 20px;
}
}
.badge-main {
color: #fff;
background-color: $mainColor;
}
.react-datepicker__input-container,
.react-datepicker-wrapper {
display: block !important;
}

View file

@ -7,7 +7,7 @@ export default class DeleteServerButton extends React.Component {
state = { isModalOpen: false };
render() {
const { history, server } = this.props;
const { server } = this.props;
return [
(
@ -24,7 +24,6 @@ export default class DeleteServerButton extends React.Component {
<DeleteServerModal
isOpen={this.state.isModalOpen}
toggle={() => this.setState({ isModalOpen: !this.state.isModalOpen })}
history={history}
server={server}
key="deleteServerModal"
/>

View file

@ -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
<ModalHeader toggle={toggle}><span className="text-danger">Delete server</span></ModalHeader>
<ModalBody>
<p>Are you sure you want to delete server <b>{server ? server.name : ''}</b>?</p>
<p>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.</p>
<p>
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.
</p>
</ModalBody>
<ModalFooter>
<button className="btn btn-link" onClick={toggle}>Cancel</button>
@ -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);

View file

@ -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);

View file

@ -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 {
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);

View file

@ -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 = {}) =>
<input
className="form-control"
@ -48,6 +39,7 @@ export class CreateShortUrl extends React.Component {
selected={this.state[id]}
placeholderText={placeholder}
onChange={date => 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 {
<Collapse isOpen={this.state.moreOptionsVisible}>
<div className="form-group">
<ReactTags
tags={this.state.tags}
handleAddition={addTag}
handleDelete={removeTag}
allowNew={true}
placeholder="Add tags you want to apply to the URL"
<TagsInput
value={this.state.tags}
onChange={changeTags}
inputProps={{ placeholder: 'Add tags to the URL' }}
onlyUnique
addOnBlur // FIXME Workaround to be able to add tags on Android
/>
</div>

View file

@ -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;
}

View file

@ -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) && (
<h4 className="search-bar__selected-tag mt-2">
<small>Filtering by tags:</small>
<FontAwesomeIcon icon={tagsIcon} className="search-bar__tags-icon" />
&nbsp;
{selectedTags.map(tag => <Tag
text={tag}

View file

@ -19,3 +19,7 @@
@include vertical-align();
right: 15px;
}
.search-bar__tags-icon {
vertical-align: bottom;
}

View file

@ -1,15 +1,15 @@
import preloader from '@fortawesome/fontawesome-free-solid/faCircleNotch';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import { isEmpty, mapObjIndexed, pick } from 'ramda';
import React from 'react';
import { Doughnut, HorizontalBar } from 'react-chartjs-2';
import Moment from 'react-moment';
import { connect } from 'react-redux';
import { Card, CardBody, CardHeader, UncontrolledTooltip } from 'reactstrap';
import DateInput from '../common/DateInput';
import VisitsParser from '../visits/services/VisitsParser';
import { getShortUrlVisits } from './reducers/shortUrlVisits';
import './ShortUrlVisits.scss';
import preloader from '@fortawesome/fontawesome-free-solid/faCircleNotch'
import FontAwesomeIcon from '@fortawesome/react-fontawesome'
import { isEmpty, mapObjIndexed, pick } from 'ramda'
import React from 'react'
import { Doughnut, HorizontalBar } from 'react-chartjs-2'
import Moment from 'react-moment'
import { connect } from 'react-redux'
import { Card, CardBody, CardHeader, UncontrolledTooltip } from 'reactstrap'
import DateInput from '../common/DateInput'
import VisitsParser from '../visits/services/VisitsParser'
import { getShortUrlVisits } from './reducers/shortUrlVisits'
import './ShortUrlVisits.scss'
const MutedMessage = ({ children }) =>
<div className="col-md-10 offset-md-1">
@ -123,7 +123,7 @@ export class ShortUrlsVisits extends React.Component {
<h2>
{
shortUrl.visitsCount &&
<span className="badge badge-primary float-right">Visits: {shortUrl.visitsCount}</span>
<span className="badge badge-main float-right">Visits: {shortUrl.visitsCount}</span>
}
Visit stats for <a target="_blank" href={shortLink}>{shortLink}</a>
</h2>
@ -144,23 +144,26 @@ export class ShortUrlsVisits extends React.Component {
</Card>
</header>
<section>
<form onSubmit={e => e.preventDefault()} className="form-inline mt-4 float-md-right">
<label>Period</label>
<section className="mt-4">
<div className="row">
<div className="col-xl-3 col-lg-4 col-md-6 offset-xl-6 offset-lg-4">
<DateInput
selected={this.state.startDate}
placeholderText="Since"
isClearable
onChange={date => this.setState({ startDate: date }, () => this.loadVisits())}
className="short-url-visits__date-input"
/>
</div>
<div className="col-xl-3 col-lg-4 col-md-6">
<DateInput
selected={this.state.endDate}
placeholderText="Until"
isClearable
onChange={date => this.setState({ endDate: date }, () => this.loadVisits())}
className="short-url-visits__date-input"
/>
</form>
<div className="clearfix" />
</div>
</div>
</section>
<section>

View file

@ -1,3 +1,7 @@
@import '../utils/base';
.short-url-visits__date-input {
margin-left: 10px;
@media(max-width: $smMax) {
margin-top: 0.5rem;
}
}

View file

@ -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,29 +24,23 @@ export class ShortUrlsList extends React.Component {
...extraParams
});
};
constructor(props) {
super(props);
const orderBy = props.shortUrlsListParams.orderBy;
this.state = {
orderField: orderBy ? Object.keys(orderBy)[0] : 'dateCreated',
orderDir: orderBy ? Object.values(orderBy)[0] : 'ASC',
}
determineOrderDir = field => {
if (this.state.orderField !== field) {
return 'ASC';
}
componentDidMount() {
const { match: { params } } = this.props;
this.refreshList({ page: params.page });
const newOrderMap = {
'ASC': 'DESC',
'DESC': undefined,
};
return this.state.orderDir ? newOrderMap[this.state.orderDir] : 'ASC';
}
render() {
const orderBy = field => {
const newOrderDir = this.state.orderField !== field ? 'ASC' : (this.state.orderDir === 'DESC' ? 'ASC' : 'DESC');
this.setState({ orderField: field, orderDir: newOrderDir });
orderBy = field => {
const newOrderDir = this.determineOrderDir(field);
this.setState({ orderField: newOrderDir !== undefined ? field : undefined, orderDir: newOrderDir });
this.refreshList({ orderBy: { [field]: newOrderDir } })
};
const renderOrderIcon = field => {
renderOrderIcon = (field, className = 'short-urls-list__header-icon') => {
if (this.state.orderField !== field) {
return null;
}
@ -46,51 +48,24 @@ export class ShortUrlsList extends React.Component {
return (
<FontAwesomeIcon
icon={this.state.orderDir === 'ASC' ? caretUpIcon : caretDownIcon}
className="short-urls-list__header-icon"
className={className}
/>
);
};
return (
<table className="table table-striped table-hover">
<thead className="short-urls-list__header">
<tr>
<th
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
onClick={() => orderBy('dateCreated')}
>
{renderOrderIcon('dateCreated')}
Created at
</th>
<th
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
onClick={() => orderBy('shortCode')}
>
{renderOrderIcon('shortCode')}
Short URL
</th>
<th
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
onClick={() => orderBy('originalUrl')}
>
{renderOrderIcon('originalUrl')}
Long URL
</th>
<th className="short-urls-list__header-cell">Tags</th>
<th
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
onClick={() => orderBy('visits')}
>
<span className="nowrap">{renderOrderIcon('visits')} Visits</span>
</th>
<th className="short-urls-list__header-cell">&nbsp;</th>
</tr>
</thead>
<tbody>
{this.renderShortUrls()}
</tbody>
</table>
);
constructor(props) {
super(props);
const { orderBy } = props.shortUrlsListParams;
this.state = {
orderField: orderBy ? head(Object.keys(orderBy)) : undefined,
orderDir: orderBy ? head(Object.values(orderBy)) : undefined,
}
}
componentDidMount() {
const { match: { params } } = this.props;
this.refreshList({ page: params.page });
}
renderShortUrls() {
@ -117,6 +92,71 @@ export class ShortUrlsList extends React.Component {
/>
));
}
renderMobileOrderingControls() {
return (
<div className="d-block d-md-none mb-3">
<UncontrolledDropdown>
<DropdownToggle caret className="btn-block">
Order by
</DropdownToggle>
<DropdownMenu className="short-urls-list__order-dropdown">
{toPairs(SORTABLE_FIELDS).map(([key, value]) =>
<DropdownItem active={this.state.orderField === key} onClick={() => this.orderBy(key)}>
{value}
{this.renderOrderIcon(key, 'short-urls-list__header-icon--mobile')}
</DropdownItem>)}
</DropdownMenu>
</UncontrolledDropdown>
</div>
);
}
render() {
return (
<React.Fragment>
{this.renderMobileOrderingControls()}
<table className="table table-striped table-hover">
<thead className="short-urls-list__header">
<tr>
<th
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
onClick={() => this.orderBy('dateCreated')}
>
{this.renderOrderIcon('dateCreated')}
Created at
</th>
<th
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
onClick={() => this.orderBy('shortCode')}
>
{this.renderOrderIcon('shortCode')}
Short URL
</th>
<th
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
onClick={() => this.orderBy('originalUrl')}
>
{this.renderOrderIcon('originalUrl')}
Long URL
</th>
<th className="short-urls-list__header-cell">Tags</th>
<th
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
onClick={() => this.orderBy('visits')}
>
<span className="nowrap">{this.renderOrderIcon('visits')} Visits</span>
</th>
<th className="short-urls-list__header-cell">&nbsp;</th>
</tr>
</thead>
<tbody>
{this.renderShortUrls()}
</tbody>
</table>
</React.Fragment>
);
}
}
export default connect(pick(['selectedServer', 'shortUrlsListParams']), { listShortUrls })(ShortUrlsList);

View file

@ -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%;
}

View file

@ -27,10 +27,11 @@
&:last-child {
position: absolute;
top: 3px;
top: 3.5px;
right: .5rem;
width: auto;
padding: 0;
border: none;
}
}
}

View file

@ -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 });

View file

@ -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);

View file

@ -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);

View file

@ -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' };

View file

@ -16,6 +16,6 @@
}
.tag__close-selected-tag.tag__close-selected-tag:hover {
color: inherit;
opacity: 1;
color: inherit !important;
opacity: 1 !important;
}

View file

@ -0,0 +1,25 @@
import { shallow } from 'enzyme'
import React from 'react'
import AsideMenu from '../../src/common/AsideMenu'
describe('<AsideMenu />', () => {
let wrapped;
beforeEach(() => {
wrapped = shallow(<AsideMenu selectedServer={{ id: 'abc123' }} />);
});
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);
});
});

View file

@ -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('<DateInput />', () => {
let wrapped;
const createComponent = (props = {}) => {
wrapped = shallow(<DateInput {...props} />);
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);
});
});

52
test/common/Home.test.js Normal file
View file

@ -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('<Home />', () => {
let wrapped;
const defaultProps = {
resetSelectedServer: () => {},
servers: {},
};
const createComponent = props => {
const actualProps = { ...defaultProps, ...props };
wrapped = shallow(<Home {...actualProps} />);
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);
});
});

View file

@ -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);
});
});
});

View file

@ -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);
});
});
});
});

View file

@ -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 })
);
});
});

View file

@ -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"