Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42d718960f | ||
|
|
7f317390e3 | ||
|
|
adec759579 | ||
|
|
f8eb5fb022 | ||
|
|
d6e6c8c6c2 | ||
|
|
e0ab67899d | ||
|
|
6969233b6f | ||
|
|
ec4c14e8de | ||
|
|
f23245a39c | ||
|
|
faa828c58a | ||
|
|
34155aae58 | ||
|
|
86eb963176 | ||
|
|
073703ef5b | ||
|
|
49f0109d20 | ||
|
|
f9773dbebe | ||
|
|
c920403d5f | ||
|
|
e1008fcff1 | ||
|
|
7efc09d73c | ||
|
|
b3be7df890 | ||
|
|
c80fea2877 | ||
|
|
e4d5424c07 | ||
|
|
c5f59a17dd | ||
|
|
f8372876d7 | ||
|
|
3821735a89 | ||
|
|
6f38538dea | ||
|
|
c8e5dfad5a | ||
|
|
f3a3854c1f | ||
|
|
33bec0f05d | ||
|
|
a5fb505aff | ||
|
|
a2f174f687 | ||
|
|
671f20dd09 |
4
.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
./build
|
||||
./dist
|
||||
./node_modules
|
||||
./test
|
||||
49
CHANGELOG.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# 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
|
||||
|
||||
* [#15](https://github.com/shlinkio/shlink-web-client/issues/15) Added a `Dockerfile` that can be used to generate a distributable docker image
|
||||
|
||||
#### Changed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* *Nothing*
|
||||
23
Dockerfile
@@ -1,12 +1,21 @@
|
||||
FROM node:10.4.1-alpine
|
||||
FROM nginx:1.15.2-alpine
|
||||
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
|
||||
|
||||
# Install yarn
|
||||
RUN apk add --no-cache --virtual yarn
|
||||
# Install node and yarn
|
||||
RUN apk add --no-cache --virtual nodejs && apk add --no-cache --virtual yarn
|
||||
|
||||
# Make home dir writable by anyone
|
||||
RUN chmod 777 /home
|
||||
ADD . ./shlink-web-client
|
||||
|
||||
CMD cd /home/shlink/www && \
|
||||
# Install dependencies and build project
|
||||
RUN cd ./shlink-web-client && \
|
||||
yarn install && \
|
||||
yarn start
|
||||
yarn build && \
|
||||
|
||||
# Move build contents to document root
|
||||
cd .. && \
|
||||
rm -r /usr/share/nginx/html/* && \
|
||||
mv ./shlink-web-client/build/* /usr/share/nginx/html && \
|
||||
rm -r ./shlink-web-client && \
|
||||
|
||||
# Delete and uninstall build tools
|
||||
yarn cache clean && apk del yarn && apk del nodejs
|
||||
|
||||
22
README.md
@@ -2,4 +2,24 @@
|
||||
|
||||
[](https://travis-ci.org/shlinkio/shlink-web-client)
|
||||
|
||||
A React-based client application for [Shlink](https://shlink.io)
|
||||
A ReactJS-based progressive web application for [Shlink](https://shlink.io).
|
||||
|
||||
## Installation
|
||||
|
||||
There are three ways in which you can use this application.
|
||||
|
||||
* The easiest way to use shlink-web-client is by just going to https://app.shlink.io.
|
||||
|
||||
The application runs 100% in the browser, so you can use that instance and access any shlink instance from it.
|
||||
|
||||
* Self hosting the application yourself.
|
||||
|
||||
Get the [latest release](https://github.com/shlinkio/shlink-web-client/releases/latest) and download the distributable zip file attached to it (`shlink-web-client_X.X.X_dist.zip`).
|
||||
|
||||
The package contains static files only, so just put it in a folder and serve it with the web server of your choice (just take into account that all the files are served using absolute paths, so you have to serve it from the root of your domain, not from a subpath).
|
||||
|
||||
* Use the official [docker image](https://hub.docker.com/r/shlinkio/shlink-web-client/)
|
||||
|
||||
If you want to deploy shlink-web-client in a container-based cluster (docker swarm, kubernetes, etc), just pick the image and do it.
|
||||
|
||||
It's a lightweight [nginx:alpine image](https://hub.docker.com/r/library/nginx/) serving the assets on port 80.
|
||||
|
||||
2444
README.md.back
@@ -1,8 +1,8 @@
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
shlink_web_client_node:
|
||||
user: 1000:1000
|
||||
volumes:
|
||||
- /etc/passwd:/etc/passwd:ro
|
||||
- /etc/group:/etc/group:ro
|
||||
shlink_web_client_node:
|
||||
user: 1000:1000
|
||||
volumes:
|
||||
- /etc/passwd:/etc/passwd:ro
|
||||
- /etc/group:/etc/group:ro
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
shlink_web_client_node:
|
||||
container_name: shlink_web_client_node
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./Dockerfile
|
||||
volumes:
|
||||
- ./:/home/shlink/www
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "56745:56745"
|
||||
shlink_web_client_node:
|
||||
container_name: shlink_web_client_node
|
||||
image: node:10.4.1-alpine
|
||||
command: /bin/sh -c "cd /home/shlink/www && yarn install && yarn start"
|
||||
volumes:
|
||||
- ./:/home/shlink/www
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "56745:56745"
|
||||
|
||||
@@ -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",
|
||||
|
||||
BIN
public/icons/icon-128x128.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
public/icons/icon-144x144.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
public/icons/icon-152x152.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
public/icons/icon-192x192.png
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
BIN
public/icons/icon-384x384.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
public/icons/icon-72x72.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
public/icons/icon-96x96.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 690 B |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 3.2 KiB |
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
<FontAwesomeIcon
|
||||
icon={calendarIcon}
|
||||
className="date-input-container__icon"
|
||||
onClick={() => this.inputRef.current.input.focus()}
|
||||
/>
|
||||
{showCalendarIcon && (
|
||||
<FontAwesomeIcon
|
||||
icon={calendarIcon}
|
||||
className="date-input-container__icon"
|
||||
onClick={() => this.inputRef.current.input.focus()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
54
src/common/react-tagsinput.scss
Normal 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;
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
{selectedTags.map(tag => <Tag
|
||||
text={tag}
|
||||
|
||||
@@ -19,3 +19,7 @@
|
||||
@include vertical-align();
|
||||
right: 15px;
|
||||
}
|
||||
|
||||
.search-bar__tags-icon {
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
<DateInput
|
||||
selected={this.state.startDate}
|
||||
placeholderText="Since"
|
||||
onChange={date => this.setState({ startDate: date }, () => this.loadVisits())}
|
||||
className="short-url-visits__date-input"
|
||||
/>
|
||||
<DateInput
|
||||
selected={this.state.endDate}
|
||||
placeholderText="Until"
|
||||
onChange={date => this.setState({ endDate: date }, () => this.loadVisits())}
|
||||
className="short-url-visits__date-input"
|
||||
/>
|
||||
</form>
|
||||
<div className="clearfix" />
|
||||
<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())}
|
||||
/>
|
||||
</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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
@import '../utils/base';
|
||||
|
||||
.short-url-visits__date-input {
|
||||
margin-left: 10px;
|
||||
@media(max-width: $smMax) {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<FontAwesomeIcon
|
||||
icon={this.state.orderDir === 'ASC' ? caretUpIcon : caretDownIcon}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<FontAwesomeIcon
|
||||
icon={this.state.orderDir === 'ASC' ? caretUpIcon : caretDownIcon}
|
||||
className="short-urls-list__header-icon"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
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"> </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{this.renderShortUrls()}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
renderShortUrls() {
|
||||
const { shortUrlsList, selectedServer, loading, error, shortUrlsListParams } = this.props;
|
||||
if (error) {
|
||||
@@ -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"> </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{this.renderShortUrls()}
|
||||
</tbody>
|
||||
</table>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(pick(['selectedServer', 'shortUrlsListParams']), { listShortUrls })(ShortUrlsList);
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -27,10 +27,11 @@
|
||||
|
||||
&:last-child {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
top: 3.5px;
|
||||
right: .5rem;
|
||||
width: auto;
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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' };
|
||||
|
||||
|
||||
@@ -16,6 +16,6 @@
|
||||
}
|
||||
|
||||
.tag__close-selected-tag.tag__close-selected-tag:hover {
|
||||
color: inherit;
|
||||
opacity: 1;
|
||||
color: inherit !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
25
test/common/AsideMenu.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
39
test/common/DateInput.test.js
Normal 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
@@ -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);
|
||||
});
|
||||
});
|
||||
69
test/servers/reducers/selectedServer.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
87
test/servers/reducers/server.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
32
test/shortUrls/reducers/shortUrlsListParams.test.js
Normal 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 })
|
||||
);
|
||||
});
|
||||
});
|
||||
72
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"
|
||||
|
||||