Compare commits

...

31 Commits

Author SHA1 Message Date
Alejandro Celaya
42d718960f Merge pull request #18 from acelaya/feature/0.2.0
Feature/0.2.0
2018-08-12 19:23:18 +02:00
Alejandro Celaya
7f317390e3 Added v0.2.0 to changelog 2018-08-12 19:18:34 +02:00
Alejandro Celaya
adec759579 Added workaround to add tags on blur on tags input which allows tags to be added on Android 2018-08-12 19:07:42 +02:00
Alejandro Celaya
f8eb5fb022 Creates shortUrlsListParams reducer test 2018-08-12 18:50:19 +02:00
Alejandro Celaya
d6e6c8c6c2 Fixed wrong value passed to DateInput 2018-08-12 10:18:26 +02:00
Alejandro Celaya
e0ab67899d Created server reducer test 2018-08-12 10:17:13 +02:00
Alejandro Celaya
6969233b6f Added reducer test to selectedServerReducer test 2018-08-12 09:34:14 +02:00
Alejandro Celaya
ec4c14e8de Created selectedServer reducer test 2018-08-12 09:22:18 +02:00
Alejandro Celaya
f23245a39c Created DateInput component test 2018-08-12 09:01:11 +02:00
Alejandro Celaya
faa828c58a Created AsideMenu component test 2018-08-12 08:49:08 +02:00
Alejandro Celaya
34155aae58 Refactored some components 2018-08-12 08:45:48 +02:00
Alejandro Celaya
86eb963176 Simplified AsideMenu component removing unneeded checks 2018-08-12 08:26:36 +02:00
Alejandro Celaya
073703ef5b Created Home component tests 2018-08-12 08:20:35 +02:00
Alejandro Celaya
49f0109d20 Renamed home-container CSS class to just home 2018-08-12 08:01:35 +02:00
Alejandro Celaya
f9773dbebe Added servers list to home page 2018-08-11 22:06:36 +02:00
Alejandro Celaya
c920403d5f Deleted no longer needed styles sheet 2018-08-11 21:41:01 +02:00
Alejandro Celaya
e1008fcff1 Replaced tags input component by a simpler one 2018-08-11 21:39:27 +02:00
Alejandro Celaya
7efc09d73c Improved badge color 2018-08-11 18:27:51 +02:00
Alejandro Celaya
b3be7df890 Improved tags filtering control 2018-08-10 22:27:50 +02:00
Alejandro Celaya
c80fea2877 Added ordering control to short URLs list in mobile resolutions 2018-08-10 22:16:50 +02:00
Alejandro Celaya
e4d5424c07 Fixed short URLs ordering in desktop resolutions 2018-08-10 21:38:24 +02:00
Alejandro Celaya
c5f59a17dd Fixed PWA name in manifest file 2018-08-10 07:32:13 +02:00
Alejandro Celaya
f8372876d7 Improved app icons quality 2018-08-09 20:28:31 +02:00
Alejandro Celaya
3821735a89 Updated DateInput to be clearable 2018-08-09 20:13:46 +02:00
Alejandro Celaya
6f38538dea Improved responsiveness in visits page 2018-08-09 19:50:22 +02:00
Alejandro Celaya
c8e5dfad5a Merge pull request #16 from acelaya/feature/0.1.1
Feature/0.1.1
2018-08-06 20:35:38 +02:00
Alejandro Celaya
f3a3854c1f Removed workdir from Dockerfile 2018-08-06 20:32:14 +02:00
Alejandro Celaya
33bec0f05d Created changelog 2018-08-06 20:19:20 +02:00
Alejandro Celaya
a5fb505aff Updated dockerfile to be used to build a distributable docker image 2018-08-06 20:16:07 +02:00
Alejandro Celaya
a2f174f687 Updated docker-compose files so that they just use an image and don't need the Dockerfile 2018-08-06 19:41:57 +02:00
Alejandro Celaya
671f20dd09 Deleted old readme file 2018-08-05 12:46:38 +02:00
55 changed files with 903 additions and 2689 deletions

4
.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
./build
./dist
./node_modules
./test

49
CHANGELOG.md Normal file
View 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*

View File

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

View File

@@ -2,4 +2,24 @@
[![Build Status](https://travis-ci.org/shlinkio/shlink-web-client.svg?branch=master)](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.

File diff suppressed because it is too large Load Diff

View File

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

View File

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

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.0 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.0 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}
/>
<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>
);
}

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

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

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,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">&nbsp;</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">&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"