26
CHANGELOG.md
|
@ -1,5 +1,31 @@
|
|||
# CHANGELOG
|
||||
|
||||
## 0.2.0 - 2018-08-12
|
||||
|
||||
#### Added
|
||||
|
||||
* [#12](https://github.com/shlinkio/shlink-web-client/issues/12) Improved code coverage
|
||||
* [#20](https://github.com/shlinkio/shlink-web-client/issues/20) Added servers list in welcome page, as well as added link to create one when none exist.
|
||||
|
||||
#### Changed
|
||||
|
||||
* [#11](https://github.com/shlinkio/shlink-web-client/issues/11) Improved app icons fro progressive web apps.
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* [#19](https://github.com/shlinkio/shlink-web-client/issues/19) Added workaround in tags input so that it is possible to add tags on Android devices.
|
||||
* [#17](https://github.com/shlinkio/shlink-web-client/issues/17) Fixed short URLs list not being sortable in mobile resolutions.
|
||||
* [#13](https://github.com/shlinkio/shlink-web-client/issues/13) Improved visits page on mobile resolutions.
|
||||
|
||||
|
||||
## 0.1.1 - 2018-08-06
|
||||
|
||||
#### Added
|
||||
|
|
|
@ -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 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 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"
|
||||
|
|