Merge pull request #41 from acelaya/feature/1.1.0

Feature/1.1.0
This commit is contained in:
Alejandro Celaya 2018-09-16 13:05:03 +02:00 committed by GitHub
commit 606397b542
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
63 changed files with 1496 additions and 577 deletions

View file

@ -2,6 +2,7 @@
"extends": [
"adidas-env/browser",
"adidas-env/module",
"adidas-env/node",
"adidas-es6",
"adidas-babel",
"adidas-react"
@ -35,6 +36,7 @@
"react/jsx-first-prop-new-line": ["error", "multiline-multiprop"],
"react/jsx-closing-bracket-location": ["error", "tag-aligned"],
"react/no-array-index-key": "off",
"react/no-did-update-set-state": "off"
"react/no-did-update-set-state": "off",
"react/display-name": "off"
}
}

View file

@ -1,5 +1,32 @@
# CHANGELOG
## 1.1.0 - 2018-09-16
#### Added
* [#47](https://github.com/shlinkio/shlink-web-client/issues/47) Added support to delete short URLs (requires [shlink v1.12.0](https://github.com/shlinkio/shlink/releases/tag/v1.12.0) or greater).
#### Changed
* [#35](https://github.com/shlinkio/shlink-web-client/issues/35) Visits component split into two, which makes the header not to be refreshed when filtering by date, and also the visits global counter now reflects the actual number of visits which fulfill current filter.
* [#36](https://github.com/shlinkio/shlink-web-client/issues/36) Tags selector now autocompletes existing tag names, to prevent typos and ease reusing existing tags.
* [#39](https://github.com/shlinkio/shlink-web-client/issues/39) Defined `propTypes` as static properties in class components.
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#49](https://github.com/shlinkio/shlink-web-client/issues/49) Ensured filtering parameters are reseted when list component is unmounted so that params are not mixed when coming back.
* [#45](https://github.com/shlinkio/shlink-web-client/issues/45) Ensured graphs x-axis start at `0` and don't use decimals.
* [#51](https://github.com/shlinkio/shlink-web-client/issues/51) When editing short URL tags, the value returned form server is used when refreshing the list, which is normalized.
## 1.0.1 - 2018-09-02
#### Added
@ -33,7 +60,7 @@
* Export all servers in a CSV file.
* Import the CSV in a different device.
* [#4](https://github.com/shlinkio/shlink-web-client/issues/4) Added tags management.
* [#3](https://github.com/shlinkio/shlink-web-client/issues/3) Added tags management.
* List existing tags, and filter the list.
* Change their name and color.

View file

@ -5,6 +5,7 @@
[![Scrutinizer Code Quality](https://img.shields.io/scrutinizer/g/shlinkio/shlink-web-client.svg?style=flat-square)](https://scrutinizer-ci.com/g/shlinkio/shlink-web-client/?branch=master)
[![GitHub release](https://img.shields.io/github/release/shlinkio/shlink-web-client.svg?style=flat-square)](https://github.com/shlinkio/shlink-web-client/releases/latest)
[![GitHub license](https://img.shields.io/github/license/shlinkio/shlink-web-client.svg?style=flat-square)](https://github.com/shlinkio/shlink-web-client/blob/master/LICENSE)
[![Paypal Donate](https://img.shields.io/badge/Donate-paypal-blue.svg?style=flat-square&logo=paypal&colorA=cccccc)](https://acel.me/donate)
A ReactJS-based progressive web application for [Shlink](https://shlink.io).

33
jest.config.js Normal file
View file

@ -0,0 +1,33 @@
module.exports = {
coverageDirectory: '<rootDir>/coverage',
collectCoverageFrom: [
'src/**/*.{js,jsx,mjs}',
'!src/registerServiceWorker.js',
'!src/index.js',
],
setupFiles: [
'<rootDir>/config/polyfills.js',
'<rootDir>/config/setupEnzyme.js',
],
testMatch: [ '<rootDir>/test/**/*.test.{js,jsx,mjs}' ],
testEnvironment: 'node',
testURL: 'http://localhost',
transform: {
'^.+\\.(js|jsx|mjs)$': '<rootDir>/node_modules/babel-jest',
'^.+\\.css$': '<rootDir>/config/jest/cssTransform.js',
'^(?!.*\\.(js|jsx|mjs|css|json)$)': '<rootDir>/config/jest/fileTransform.js',
},
transformIgnorePatterns: [ '[/\\\\]node_modules[/\\\\].+\\.(js|jsx|mjs)$' ],
moduleNameMapper: {
'^react-native$': 'react-native-web',
},
moduleFileExtensions: [
'web.js',
'js',
'json',
'web.jsx',
'jsx',
'node',
'mjs',
],
};

View file

@ -7,7 +7,7 @@
"lint": "yarn lint:js && yarn lint:css",
"lint:js": "eslint src test scripts config",
"lint:js:fix": "yarn lint:js --fix",
"lint:css": "stylelint src/**/*.scss",
"lint:css": "stylelint src/*.scss src/**/*.scss",
"lint:css:fix": "yarn lint:css --fix",
"start": "node scripts/start.js",
"build": "node scripts/build.js",
@ -31,6 +31,7 @@
"qs": "^6.5.2",
"ramda": "^0.25.0",
"react": "^16.3.2",
"react-autosuggest": "^9.4.0",
"react-chartjs-2": "^2.7.4",
"react-color": "^2.14.1",
"react-copy-to-clipboard": "^5.0.1",
@ -101,41 +102,6 @@
"webpack-manifest-plugin": "1.3.2",
"whatwg-fetch": "2.0.3"
},
"jest": {
"coverageDirectory": "<rootDir>/coverage",
"collectCoverageFrom": [
"src/**/*.{js,jsx,mjs}"
],
"setupFiles": [
"<rootDir>/config/polyfills.js",
"<rootDir>/config/setupEnzyme.js"
],
"testMatch": [
"<rootDir>/test/**/*.test.{js,jsx,mjs}"
],
"testEnvironment": "node",
"testURL": "http://localhost",
"transform": {
"^.+\\.(js|jsx|mjs)$": "<rootDir>/node_modules/babel-jest",
"^.+\\.css$": "<rootDir>/config/jest/cssTransform.js",
"^(?!.*\\.(js|jsx|mjs|css|json)$)": "<rootDir>/config/jest/fileTransform.js"
},
"transformIgnorePatterns": [
"[/\\\\]node_modules[/\\\\].+\\.(js|jsx|mjs)$"
],
"moduleNameMapper": {
"^react-native$": "react-native-web"
},
"moduleFileExtensions": [
"web.js",
"js",
"json",
"web.jsx",
"jsx",
"node",
"mjs"
]
},
"babel": {
"presets": [
"react-app"

View file

@ -44,6 +44,11 @@ export class ShlinkApiClient {
.then((resp) => resp.data)
.catch((e) => this._handleAuthError(e, this.getShortUrl, [ shortCode ]));
deleteShortUrl = (shortCode) =>
this._performRequest(`/short-codes/${shortCode}`, 'DELETE')
.then(() => ({}))
.catch((e) => this._handleAuthError(e, this.deleteShortUrl, [ shortCode ]));
updateShortUrlTags = (shortCode, tags) =>
this._performRequest(`/short-codes/${shortCode}/tags`, 'PUT', {}, { tags })
.then((resp) => resp.data.tags)

View file

@ -2,24 +2,24 @@ import calendarIcon from '@fortawesome/fontawesome-free-regular/faCalendarAlt';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import React from 'react';
import DatePicker from 'react-datepicker';
import './DateInput.scss';
import { isNil } from 'ramda';
import './DateInput.scss';
export default class DateInput extends React.Component {
constructor(props) {
super(props);
this.inputRef = React.createRef();
this.inputRef = props.ref || React.createRef();
}
render() {
const { isClearable, selected } = this.props;
const { className, isClearable, selected } = this.props;
const showCalendarIcon = !isClearable || isNil(selected);
return (
<div className="date-input-container">
<DatePicker
{...this.props}
className={`date-input-container__input form-control ${this.props.className || ''}`}
className={`date-input-container__input form-control ${className || ''}`}
dateFormat="YYYY-MM-DD"
readOnly
ref={this.inputRef}

View file

@ -9,12 +9,12 @@ import PropTypes from 'prop-types';
import { resetSelectedServer } from '../servers/reducers/selectedServer';
import './Home.scss';
const propTypes = {
export class HomeComponent extends React.Component {
static propTypes = {
resetSelectedServer: PropTypes.func,
servers: PropTypes.object,
};
};
export class HomeComponent extends React.Component {
componentDidMount() {
this.props.resetSelectedServer();
}
@ -51,8 +51,6 @@ export class HomeComponent extends React.Component {
}
}
HomeComponent.propTypes = propTypes;
const Home = connect(pick([ 'servers' ]), { resetSelectedServer })(HomeComponent);
export default Home;

View file

@ -10,11 +10,11 @@ import ServersDropdown from '../servers/ServersDropdown';
import './MainHeader.scss';
import shlinkLogo from './shlink-logo-white.png';
const propTypes = {
location: PropTypes.object,
};
export class MainHeaderComponent extends React.Component {
static propTypes = {
location: PropTypes.object,
};
state = { isOpen: false };
handleToggle = () => {
this.setState(({ isOpen }) => ({
@ -64,8 +64,6 @@ export class MainHeaderComponent extends React.Component {
}
}
MainHeaderComponent.propTypes = propTypes;
const MainHeader = withRouter(MainHeaderComponent);
export default MainHeader;

View file

@ -8,7 +8,7 @@ import burgerIcon from '@fortawesome/fontawesome-free-solid/faBars';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import classnames from 'classnames';
import * as PropTypes from 'prop-types';
import ShortUrlsVisits from '../short-urls/ShortUrlVisits';
import ShortUrlsVisits from '../visits/ShortUrlVisits';
import { selectServer } from '../servers/reducers/selectedServer';
import CreateShortUrl from '../short-urls/CreateShortUrl';
import ShortUrls from '../short-urls/ShortUrls';
@ -17,14 +17,14 @@ import TagsList from '../tags/TagsList';
import { serverType } from '../servers/prop-types';
import AsideMenu from './AsideMenu';
const propTypes = {
export class MenuLayoutComponent extends React.Component {
static propTypes = {
match: PropTypes.object,
selectServer: PropTypes.func,
location: PropTypes.object,
selectedServer: serverType,
};
};
export class MenuLayoutComponent extends React.Component {
state = { showSideBar: false };
// FIXME Shouldn't use componentWillMount, but this code has to be run before children components are rendered
@ -105,8 +105,6 @@ export class MenuLayoutComponent extends React.Component {
}
}
MenuLayoutComponent.propTypes = propTypes;
const MenuLayout = compose(
connect(pick([ 'selectedServer', 'shortUrlsListParams' ]), { selectServer }),
withRouter

View file

@ -2,18 +2,18 @@ import React from 'react';
import { withRouter } from 'react-router-dom';
import PropTypes from 'prop-types';
const propTypes = {
export class ScrollToTopComponent extends React.Component {
static propTypes = {
location: PropTypes.object,
window: PropTypes.shape({
scrollTo: PropTypes.func,
}),
children: PropTypes.node,
};
const defaultProps = {
};
static defaultProps = {
window,
};
};
export class ScrollToTopComponent extends React.Component {
componentDidUpdate(prevProps) {
const { location, window } = this.props;
@ -27,9 +27,6 @@ export class ScrollToTopComponent extends React.Component {
}
}
ScrollToTopComponent.defaultProps = defaultProps;
ScrollToTopComponent.propTypes = propTypes;
const ScrollToTop = withRouter(ScrollToTopComponent);
export default ScrollToTop;

View file

@ -5,15 +5,12 @@
overflow: hidden;
min-height: 2.6rem;
padding: 6px 0 0 6px;
transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
}
.react-tagsinput--focused {
border-color: #80bdff;
-webkit-box-shadow: 0 0 0 .2rem rgba(0, 123, 255, .25);
box-shadow: 0 0 0 .2rem rgba(0, 123, 255, .25);
-webkit-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, -webkit-box-shadow .15s ease-in-out;
}
.react-tagsinput-tag {
@ -44,6 +41,6 @@
border: 0;
outline: none;
padding: 3px 5px;
width: 155px;
width: 100%;
margin-bottom: 6px;
}

View file

@ -3,7 +3,7 @@
html,
body,
#root {
height: 100%
height: 100%;
}
* {
@ -21,9 +21,11 @@ body,
.dropdown-item {
cursor: pointer;
}
.dropdown-item.active,
.dropdown-item:active {
@extend .bg-main;
background-color: $lightGrey !important;
color: inherit !important;
}
.shlink-container {
@ -46,7 +48,6 @@ body,
.navbar-brand {
@media (max-width: $smMax) {
margin-right: auto !important; // This is needed to override a third party style
margin: 0 auto;
margin: 0 auto !important;
}
}

View file

@ -3,9 +3,11 @@ import serversReducer from '../servers/reducers/server';
import selectedServerReducer from '../servers/reducers/selectedServer';
import shortUrlsListReducer from '../short-urls/reducers/shortUrlsList';
import shortUrlsListParamsReducer from '../short-urls/reducers/shortUrlsListParams';
import shortUrlCreationResultReducer from '../short-urls/reducers/shortUrlCreationResult';
import shortUrlVisitsReducer from '../short-urls/reducers/shortUrlVisits';
import shortUrlCreationReducer from '../short-urls/reducers/shortUrlCreation';
import shortUrlDeletionReducer from '../short-urls/reducers/shortUrlDeletion';
import shortUrlTagsReducer from '../short-urls/reducers/shortUrlTags';
import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits';
import shortUrlDetailReducer from '../visits/reducers/shortUrlDetail';
import tagsListReducer from '../tags/reducers/tagsList';
import tagDeleteReducer from '../tags/reducers/tagDelete';
import tagEditReducer from '../tags/reducers/tagEdit';
@ -15,9 +17,11 @@ export default combineReducers({
selectedServer: selectedServerReducer,
shortUrlsList: shortUrlsListReducer,
shortUrlsListParams: shortUrlsListParamsReducer,
shortUrlCreationResult: shortUrlCreationResultReducer,
shortUrlVisits: shortUrlVisitsReducer,
shortUrlCreationResult: shortUrlCreationReducer,
shortUrlDeletion: shortUrlDeletionReducer,
shortUrlTags: shortUrlTagsReducer,
shortUrlVisits: shortUrlVisitsReducer,
shortUrlDetail: shortUrlDetailReducer,
tagsList: tagsListReducer,
tagDelete: tagDeleteReducer,
tagEdit: tagEditReducer,

View file

@ -5,12 +5,12 @@ import PropTypes from 'prop-types';
import DeleteServerModal from './DeleteServerModal';
import { serverType } from './prop-types';
const propTypes = {
export default class DeleteServerButton extends React.Component {
static propTypes = {
server: serverType,
className: PropTypes.string,
};
};
export default class DeleteServerButton extends React.Component {
state = { isModalOpen: false };
render() {
@ -37,5 +37,3 @@ export default class DeleteServerButton extends React.Component {
);
}
}
DeleteServerButton.propTypes = propTypes;

View file

@ -9,10 +9,11 @@ import serversExporter from '../servers/services/ServersExporter';
import { listServers } from './reducers/server';
import { serverType } from './prop-types';
const defaultProps = {
export class ServersDropdownComponent extends React.Component {
static defaultProps = {
serversExporter,
};
const propTypes = {
};
static propTypes = {
servers: PropTypes.object,
serversExporter: PropTypes.shape({
exportServers: PropTypes.func,
@ -20,9 +21,8 @@ const propTypes = {
selectedServer: serverType,
selectServer: PropTypes.func,
listServers: PropTypes.func,
};
};
export class ServersDropdownComponent extends React.Component {
renderServers = () => {
const { servers, selectedServer, selectServer, serversExporter } = this.props;
@ -70,9 +70,6 @@ export class ServersDropdownComponent extends React.Component {
}
}
ServersDropdownComponent.defaultProps = defaultProps;
ServersDropdownComponent.propTypes = propTypes;
const ServersDropdown = connect(
pick([ 'servers', 'selectedServer' ]),
{ listServers, selectServer }

View file

@ -7,18 +7,18 @@ import PropTypes from 'prop-types';
import { createServers } from '../reducers/server';
import serversImporter, { serversImporterType } from '../services/ServersImporter';
const defaultProps = {
export class ImportServersBtnComponent extends React.Component {
static defaultProps = {
serversImporter,
onImport: () => ({}),
};
const propTypes = {
};
static propTypes = {
onImport: PropTypes.func,
serversImporter: serversImporterType,
createServers: PropTypes.func,
fileRef: PropTypes.oneOfType([ PropTypes.object, PropTypes.node ]),
};
};
export class ImportServersBtnComponent extends React.Component {
constructor(props) {
super(props);
this.fileRef = props.fileRef || React.createRef();
@ -58,9 +58,6 @@ export class ImportServersBtnComponent extends React.Component {
}
}
ImportServersBtnComponent.defaultProps = defaultProps;
ImportServersBtnComponent.propTypes = propTypes;
const ImportServersBtn = connect(null, { createServers })(ImportServersBtnComponent);
export default ImportServersBtn;

View file

@ -6,9 +6,9 @@ import React from 'react';
import { connect } from 'react-redux';
import { Collapse } from 'reactstrap';
import DateInput from '../common/DateInput';
import TagsSelector from '../utils/TagsSelector';
import TagsSelector from '../tags/helpers/TagsSelector';
import CreateShortUrlResult from './helpers/CreateShortUrlResult';
import { createShortUrl, resetCreateShortUrl } from './reducers/shortUrlCreationResult';
import { createShortUrl, resetCreateShortUrl } from './reducers/shortUrlCreation';
export class CreateShortUrlComponent extends React.Component {
state = {

View file

@ -20,13 +20,13 @@ export default function Paginator({ paginator = {}, serverId }) {
}
const renderPages = () =>
range(1, pagesCount + 1).map((i) => (
<PaginationItem key={i} active={currentPage === i}>
range(1, pagesCount + 1).map((pageNumber) => (
<PaginationItem key={pageNumber} active={currentPage === pageNumber}>
<PaginationLink
tag={Link}
to={`/server/${serverId}/list-short-urls/${i}`}
to={`/server/${serverId}/list-short-urls/${pageNumber}`}
>
{i}
{pageNumber}
</PaginationLink>
</PaginationItem>
));

View file

@ -4,7 +4,7 @@ import React from 'react';
import { connect } from 'react-redux';
import { isEmpty, pick } from 'ramda';
import PropTypes from 'prop-types';
import Tag from '../utils/Tag';
import Tag from '../tags/helpers/Tag';
import SearchField from '../utils/SearchField';
import { listShortUrls } from './reducers/shortUrlsList';
import './SearchBar.scss';

View file

@ -1,221 +0,0 @@
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 PropTypes from 'prop-types';
import DateInput from '../common/DateInput';
import {
processOsStats,
processBrowserStats,
processCountriesStats,
processReferrersStats,
} from '../visits/services/VisitsParser';
import MutedMessage from '../utils/MuttedMessage';
import ExternalLink from '../utils/ExternalLink';
import { serverType } from '../servers/prop-types';
import { getShortUrlVisits, shortUrlVisitsType } from './reducers/shortUrlVisits';
import './ShortUrlVisits.scss';
const propTypes = {
processOsStats: PropTypes.func,
processBrowserStats: PropTypes.func,
processCountriesStats: PropTypes.func,
processReferrersStats: PropTypes.func,
match: PropTypes.object,
getShortUrlVisits: PropTypes.func,
selectedServer: serverType,
shortUrlVisits: shortUrlVisitsType,
};
const defaultProps = {
processOsStats,
processBrowserStats,
processCountriesStats,
processReferrersStats,
};
export class ShortUrlsVisitsComponent extends React.Component {
state = { startDate: undefined, endDate: undefined };
loadVisits = () => {
const { match: { params }, getShortUrlVisits } = this.props;
getShortUrlVisits(params.shortCode, mapObjIndexed(
(value) => value && value.format ? value.format('YYYY-MM-DD') : value,
this.state
));
};
componentDidMount() {
this.loadVisits();
}
render() {
const {
match: { params },
selectedServer,
processOsStats,
processBrowserStats,
processCountriesStats,
processReferrersStats,
shortUrlVisits: { visits, loading, error, shortUrl },
} = this.props;
const serverUrl = selectedServer ? selectedServer.url : '';
const shortLink = `${serverUrl}/${params.shortCode}`;
const generateGraphData = (stats, label, isBarChart) => ({
labels: Object.keys(stats),
datasets: [
{
label,
data: Object.values(stats),
backgroundColor: isBarChart ? 'rgba(70, 150, 229, 0.4)' : [
'#97BBCD',
'#DCDCDC',
'#F7464A',
'#46BFBD',
'#FDB45C',
'#949FB1',
'#4D5360',
],
borderColor: isBarChart ? 'rgba(70, 150, 229, 1)' : 'white',
borderWidth: 2,
},
],
});
const renderGraphCard = (title, stats, isBarChart, label) => (
<div className="col-md-6">
<Card className="mt-4">
<CardHeader>{title}</CardHeader>
<CardBody>
{!isBarChart && (
<Doughnut
data={generateGraphData(stats, label || title, isBarChart)}
options={{
legend: {
position: 'right',
},
}}
/>
)}
{isBarChart && (
<HorizontalBar
data={generateGraphData(stats, label || title, isBarChart)}
options={{
legend: {
display: false,
},
}}
/>
)}
</CardBody>
</Card>
</div>
);
const renderContent = () => {
if (loading) {
return <MutedMessage><FontAwesomeIcon icon={preloader} spin /> Loading...</MutedMessage>;
}
if (error) {
return (
<Card className="mt-4" body inverse color="danger">
An error occurred while loading visits :(
</Card>
);
}
if (isEmpty(visits)) {
return <MutedMessage>There have been no visits matching current filter :(</MutedMessage>;
}
return (
<div className="row">
{renderGraphCard('Operating systems', processOsStats(visits), false)}
{renderGraphCard('Browsers', processBrowserStats(visits), false)}
{renderGraphCard('Countries', processCountriesStats(visits), true, 'Visits')}
{renderGraphCard('Referrers', processReferrersStats(visits), true, 'Visits')}
</div>
);
};
const renderCreated = () => (
<span>
<b id="created"><Moment fromNow>{shortUrl.dateCreated}</Moment></b>
<UncontrolledTooltip placement="bottom" target="created">
<Moment format="YYYY-MM-DD HH:mm">{shortUrl.dateCreated}</Moment>
</UncontrolledTooltip>
</span>
);
return (
<div className="shlink-container">
<header>
<Card className="bg-light">
<CardBody>
<h2>
{
shortUrl.visitsCount &&
<span className="badge badge-main float-right">Visits: {shortUrl.visitsCount}</span>
}
Visit stats for <ExternalLink href={shortLink}>{shortLink}</ExternalLink>
</h2>
<hr />
{shortUrl.dateCreated && (
<div>
Created:
&nbsp;
{loading && <small>Loading...</small>}
{!loading && renderCreated()}
</div>
)}
<div>
Long URL:
&nbsp;
{loading && <small>Loading...</small>}
{!loading && <ExternalLink href={shortUrl.longUrl}>{shortUrl.longUrl}</ExternalLink>}
</div>
</CardBody>
</Card>
</header>
<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
className="short-url-visits__date-input"
onChange={(date) => this.setState({ endDate: date }, () => this.loadVisits())}
/>
</div>
</div>
</section>
<section>
{renderContent()}
</section>
</div>
);
}
}
ShortUrlsVisitsComponent.propTypes = propTypes;
ShortUrlsVisitsComponent.defaultProps = defaultProps;
const ShortUrlsVisits = connect(
pick([ 'selectedServer', 'shortUrlVisits' ]),
{ getShortUrlVisits }
)(ShortUrlsVisitsComponent);
export default ShortUrlsVisits;

View file

@ -11,7 +11,7 @@ import { serverType } from '../servers/prop-types';
import { ShortUrlsRow } from './helpers/ShortUrlsRow';
import { listShortUrls, shortUrlType } from './reducers/shortUrlsList';
import './ShortUrlsList.scss';
import { shortUrlsListParamsType } from './reducers/shortUrlsListParams';
import { shortUrlsListParamsType, resetShortUrlParams } from './reducers/shortUrlsListParams';
const SORTABLE_FIELDS = {
dateCreated: 'Created at',
@ -20,8 +20,10 @@ const SORTABLE_FIELDS = {
visits: 'Visits',
};
const propTypes = {
export class ShortUrlsListComponent extends React.Component {
static propTypes = {
listShortUrls: PropTypes.func,
resetShortUrlParams: PropTypes.func,
shortUrlsListParams: shortUrlsListParamsType,
match: PropTypes.object,
location: PropTypes.object,
@ -29,9 +31,8 @@ const propTypes = {
error: PropTypes.bool,
shortUrlsList: PropTypes.arrayOf(shortUrlType),
selectedServer: serverType,
};
};
export class ShortUrlsListComponent extends React.Component {
refreshList = (extraParams) => {
const { listShortUrls, shortUrlsListParams } = this.props;
@ -89,6 +90,12 @@ export class ShortUrlsListComponent extends React.Component {
this.refreshList({ page: params.page, tags: query.tag ? [ query.tag ] : shortUrlsListParams.tags });
}
componentWillUnmount() {
const { resetShortUrlParams } = this.props;
resetShortUrlParams();
}
renderShortUrls() {
const { shortUrlsList, selectedServer, loading, error, shortUrlsListParams } = this.props;
@ -186,11 +193,9 @@ export class ShortUrlsListComponent extends React.Component {
}
}
ShortUrlsListComponent.propTypes = propTypes;
const ShortUrlsList = connect(
pick([ 'selectedServer', 'shortUrlsListParams' ]),
{ listShortUrls }
{ listShortUrls, resetShortUrlParams }
)(ShortUrlsListComponent);
export default ShortUrlsList;

View file

@ -5,17 +5,17 @@ import React from 'react';
import { CopyToClipboard } from 'react-copy-to-clipboard';
import { Card, CardBody, Tooltip } from 'reactstrap';
import PropTypes from 'prop-types';
import { createShortUrlResultType } from '../reducers/shortUrlCreationResult';
import { createShortUrlResultType } from '../reducers/shortUrlCreation';
import { stateFlagTimeout } from '../../utils/utils';
import './CreateShortUrlResult.scss';
const propTypes = {
export default class CreateShortUrlResult extends React.Component {
static propTypes = {
resetCreateShortUrl: PropTypes.func,
error: PropTypes.bool,
result: createShortUrlResultType,
};
};
export default class CreateShortUrlResult extends React.Component {
state = { showCopyTooltip: false };
componentDidMount() {
@ -62,5 +62,3 @@ export default class CreateShortUrlResult extends React.Component {
);
}
}
CreateShortUrlResult.propTypes = propTypes;

View file

@ -0,0 +1,101 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import PropTypes from 'prop-types';
import { pick, identity } from 'ramda';
import { shortUrlType } from '../reducers/shortUrlsList';
import {
deleteShortUrl,
resetDeleteShortUrl,
shortUrlDeleted,
shortUrlDeletionType,
} from '../reducers/shortUrlDeletion';
import './QrCodeModal.scss';
export class DeleteShortUrlModalComponent extends Component {
static propTypes = {
shortUrl: shortUrlType,
toggle: PropTypes.func,
isOpen: PropTypes.bool,
shortUrlDeletion: shortUrlDeletionType,
deleteShortUrl: PropTypes.func,
resetDeleteShortUrl: PropTypes.func,
shortUrlDeleted: PropTypes.func,
};
state = { inputValue: '' };
handleDeleteUrl = (e) => {
e.preventDefault();
const { deleteShortUrl, shortUrl, toggle, shortUrlDeleted } = this.props;
const { shortCode } = shortUrl;
deleteShortUrl(shortCode)
.then(() => {
shortUrlDeleted(shortCode);
toggle();
})
.catch(identity);
};
componentWillUnmount() {
const { resetDeleteShortUrl } = this.props;
resetDeleteShortUrl();
}
render() {
const { shortUrl, toggle, isOpen, shortUrlDeletion } = this.props;
const THRESHOLD_REACHED = 'INVALID_SHORTCODE_DELETION';
return (
<Modal isOpen={isOpen} toggle={toggle} centered>
<form onSubmit={this.handleDeleteUrl}>
<ModalHeader toggle={toggle}>
<span className="text-danger">Delete short URL</span>
</ModalHeader>
<ModalBody>
<p><b className="text-danger">Caution!</b> You are about to delete a short URL.</p>
<p>This action cannot be undone. Once you have deleted it, all the visits stats will be lost.</p>
<input
type="text"
className="form-control"
placeholder="Insert the short code of the URL"
value={this.state.inputValue}
onChange={(e) => this.setState({ inputValue: e.target.value })}
/>
{shortUrlDeletion.error && shortUrlDeletion.errorData.error === THRESHOLD_REACHED && (
<div className="p-2 mt-2 bg-warning text-center">
This short URL has received too many visits and therefore, it cannot be deleted
</div>
)}
{shortUrlDeletion.error && shortUrlDeletion.errorData.error !== THRESHOLD_REACHED && (
<div className="p-2 mt-2 bg-danger text-white text-center">
Something went wrong while deleting the URL :(
</div>
)}
</ModalBody>
<ModalFooter>
<button type="button" className="btn btn-link" onClick={toggle}>Cancel</button>
<button
type="submit"
className="btn btn-danger"
disabled={this.state.inputValue !== shortUrl.shortCode || shortUrlDeletion.loading}
>
{shortUrlDeletion.loading ? 'Deleting...' : 'Delete'}
</button>
</ModalFooter>
</form>
</Modal>
);
}
}
const DeleteShortUrlModal = connect(
pick([ 'shortUrlDeletion' ]),
{ deleteShortUrl, resetDeleteShortUrl, shortUrlDeleted }
)(DeleteShortUrlModalComponent);
export default DeleteShortUrlModal;

View file

@ -3,7 +3,7 @@ import { connect } from 'react-redux';
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import PropTypes from 'prop-types';
import { pick } from 'ramda';
import TagsSelector from '../../utils/TagsSelector';
import TagsSelector from '../../tags/helpers/TagsSelector';
import {
editShortUrlTags,
resetShortUrlsTags,
@ -13,7 +13,8 @@ import {
import ExternalLink from '../../utils/ExternalLink';
import { shortUrlType } from '../reducers/shortUrlsList';
const propTypes = {
export class EditTagsModalComponent extends React.Component {
static propTypes = {
isOpen: PropTypes.bool.isRequired,
toggle: PropTypes.func.isRequired,
url: PropTypes.string.isRequired,
@ -22,9 +23,8 @@ const propTypes = {
editShortUrlTags: PropTypes.func,
shortUrlTagsEdited: PropTypes.func,
resetShortUrlsTags: PropTypes.func,
};
};
export class EditTagsModalComponent extends React.Component {
saveTags = () => {
const { editShortUrlTags, shortUrl, toggle } = this.props;
@ -40,8 +40,8 @@ export class EditTagsModalComponent extends React.Component {
return;
}
const { shortUrlTagsEdited, shortUrl } = this.props;
const { tags } = this.state;
const { shortUrlTagsEdited, shortUrl, shortUrlTags } = this.props;
const { tags } = shortUrlTags;
shortUrlTagsEdited(shortUrl.shortCode, tags);
};
@ -90,8 +90,6 @@ export class EditTagsModalComponent extends React.Component {
}
}
EditTagsModalComponent.propTypes = propTypes;
const EditTagsModal = connect(
pick([ 'shortUrlTags' ]),
{ editShortUrlTags, resetShortUrlsTags, shortUrlTagsEdited }

View file

@ -2,7 +2,7 @@ import { isEmpty } from 'ramda';
import React from 'react';
import Moment from 'react-moment';
import PropTypes from 'prop-types';
import Tag from '../../utils/Tag';
import Tag from '../../tags/helpers/Tag';
import { shortUrlsListParamsType } from '../reducers/shortUrlsListParams';
import { serverType } from '../../servers/prop-types';
import ExternalLink from '../../utils/ExternalLink';
@ -11,14 +11,14 @@ import { stateFlagTimeout } from '../../utils/utils';
import { ShortUrlsRowMenu } from './ShortUrlsRowMenu';
import './ShortUrlsRow.scss';
const propTypes = {
export class ShortUrlsRow extends React.Component {
static propTypes = {
refreshList: PropTypes.func,
shortUrlsListParams: shortUrlsListParamsType,
selectedServer: serverType,
shortUrl: shortUrlType,
};
};
export class ShortUrlsRow extends React.Component {
state = { copiedToClipboard: false };
renderTags(tags) {
@ -73,5 +73,3 @@ export class ShortUrlsRow extends React.Component {
);
}
}
ShortUrlsRow.propTypes = propTypes;

View file

@ -4,6 +4,7 @@ import tagsIcon from '@fortawesome/fontawesome-free-solid/faTags';
import pieChartIcon from '@fortawesome/fontawesome-free-solid/faChartPie';
import menuIcon from '@fortawesome/fontawesome-free-solid/faEllipsisV';
import qrIcon from '@fortawesome/fontawesome-free-solid/faQrcode';
import deleteIcon from '@fortawesome/fontawesome-free-solid/faMinusCircle';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import React from 'react';
import { CopyToClipboard } from 'react-copy-to-clipboard';
@ -16,29 +17,33 @@ import PreviewModal from './PreviewModal';
import QrCodeModal from './QrCodeModal';
import './ShortUrlsRowMenu.scss';
import EditTagsModal from './EditTagsModal';
import DeleteShortUrlModal from './DeleteShortUrlModal';
const propTypes = {
export class ShortUrlsRowMenu extends React.Component {
static propTypes = {
completeShortUrl: PropTypes.string,
onCopyToClipboard: PropTypes.func,
selectedServer: serverType,
shortUrl: shortUrlType,
};
};
export class ShortUrlsRowMenu extends React.Component {
state = {
isOpen: false,
isQrModalOpen: false,
isPreviewOpen: false,
isTagsModalOpen: false,
isDeleteModalOpen: false,
};
toggle = () => this.setState(({ isOpen }) => ({ isOpen: !isOpen }));
render() {
const { completeShortUrl, onCopyToClipboard, selectedServer, shortUrl } = this.props;
const serverId = selectedServer ? selectedServer.id : '';
const toggleQrCode = () => this.setState(({ isQrModalOpen }) => ({ isQrModalOpen: !isQrModalOpen }));
const togglePreview = () => this.setState(({ isPreviewOpen }) => ({ isPreviewOpen: !isPreviewOpen }));
const toggleTags = () => this.setState(({ isTagsModalOpen }) => ({ isTagsModalOpen: !isTagsModalOpen }));
const toggleModal = (prop) => () => this.setState((prevState) => ({ [prop]: !prevState[prop] }));
const toggleQrCode = toggleModal('isQrModalOpen');
const togglePreview = toggleModal('isPreviewOpen');
const toggleTags = toggleModal('isTagsModalOpen');
const toggleDelete = toggleModal('isDeleteModalOpen');
return (
<ButtonDropdown toggle={this.toggle} isOpen={this.state.isOpen} direction="left">
@ -47,8 +52,9 @@ export class ShortUrlsRowMenu extends React.Component {
</DropdownToggle>
<DropdownMenu>
<DropdownItem tag={Link} to={`/server/${serverId}/short-code/${shortUrl.shortCode}/visits`}>
<FontAwesomeIcon icon={pieChartIcon} /> &nbsp;Visit Stats
<FontAwesomeIcon icon={pieChartIcon} /> &nbsp;Visit stats
</DropdownItem>
<DropdownItem onClick={toggleTags}>
<FontAwesomeIcon icon={tagsIcon} /> &nbsp;Edit tags
</DropdownItem>
@ -59,6 +65,15 @@ export class ShortUrlsRowMenu extends React.Component {
toggle={toggleTags}
/>
<DropdownItem className="short-urls-row-menu__dropdown-item--danger" onClick={toggleDelete}>
<FontAwesomeIcon icon={deleteIcon} /> &nbsp;Delete short URL
</DropdownItem>
<DeleteShortUrlModal
shortUrl={shortUrl}
isOpen={this.state.isDeleteModalOpen}
toggle={toggleDelete}
/>
<DropdownItem divider />
<DropdownItem onClick={togglePreview}>
@ -91,5 +106,3 @@ export class ShortUrlsRowMenu extends React.Component {
);
}
}
ShortUrlsRowMenu.propTypes = propTypes;

View file

@ -1,6 +1,19 @@
@import '../../utils/base';
.short-urls-row-menu__dropdown-toggle:before {
display: none !important;
}
.short-urls-row-menu__dropdown-toggle--hidden {
visibility: hidden;
}
.short-urls-row-menu__dropdown-item--danger.short-urls-row-menu__dropdown-item--danger {
color: $dangerColor;
&:hover,
&:active,
&.active {
color: $dangerColor !important;
}
}

View file

@ -0,0 +1,76 @@
import { curry } from 'ramda';
import PropTypes from 'prop-types';
import shlinkApiClient from '../../api/ShlinkApiClient';
/* eslint-disable padding-line-between-statements, newline-after-var */
const DELETE_SHORT_URL_START = 'shlink/deleteShortUrl/DELETE_SHORT_URL_START';
const DELETE_SHORT_URL_ERROR = 'shlink/deleteShortUrl/DELETE_SHORT_URL_ERROR';
const DELETE_SHORT_URL = 'shlink/deleteShortUrl/DELETE_SHORT_URL';
const RESET_DELETE_SHORT_URL = 'shlink/deleteShortUrl/RESET_DELETE_SHORT_URL';
export const SHORT_URL_DELETED = 'shlink/deleteShortUrl/SHORT_URL_DELETED';
/* eslint-enable padding-line-between-statements, newline-after-var */
export const shortUrlDeletionType = PropTypes.shape({
shortCode: PropTypes.string.isRequired,
loading: PropTypes.bool.isRequired,
error: PropTypes.bool.isRequired,
errorData: PropTypes.shape({
error: PropTypes.string,
message: PropTypes.string,
}).isRequired,
});
const defaultState = {
shortCode: '',
loading: false,
error: false,
errorData: {},
};
export default function reducer(state = defaultState, action) {
switch (action.type) {
case DELETE_SHORT_URL_START:
return {
...state,
loading: true,
error: false,
};
case DELETE_SHORT_URL_ERROR:
return {
...state,
loading: false,
error: true,
errorData: action.errorData,
};
case DELETE_SHORT_URL:
return {
...state,
shortCode: action.shortCode,
loading: false,
error: false,
};
case RESET_DELETE_SHORT_URL:
return defaultState;
default:
return state;
}
}
export const _deleteShortUrl = (shlinkApiClient, shortCode) => async (dispatch) => {
dispatch({ type: DELETE_SHORT_URL_START });
try {
await shlinkApiClient.deleteShortUrl(shortCode);
dispatch({ type: DELETE_SHORT_URL, shortCode });
} catch (e) {
dispatch({ type: DELETE_SHORT_URL_ERROR, errorData: e.response.data });
throw e;
}
};
export const deleteShortUrl = curry(_deleteShortUrl)(shlinkApiClient);
export const resetDeleteShortUrl = () => ({ type: RESET_DELETE_SHORT_URL });
export const shortUrlDeleted = (shortCode) => ({ type: SHORT_URL_DELETED, shortCode });

View file

@ -56,9 +56,9 @@ export const _editShortUrlTags = (shlinkApiClient, shortCode, tags) => async (di
dispatch({ type: EDIT_SHORT_URL_TAGS_START });
try {
// Update short URL tags
await shlinkApiClient.updateShortUrlTags(shortCode, tags);
dispatch({ tags, shortCode, type: EDIT_SHORT_URL_TAGS });
const normalizedTags = await shlinkApiClient.updateShortUrlTags(shortCode, tags);
dispatch({ tags: normalizedTags, shortCode, type: EDIT_SHORT_URL_TAGS });
} catch (e) {
dispatch({ type: EDIT_SHORT_URL_TAGS_ERROR });

View file

@ -1,7 +1,8 @@
import { assoc, assocPath } from 'ramda';
import { assoc, assocPath, reject } from 'ramda';
import PropTypes from 'prop-types';
import shlinkApiClient from '../../api/ShlinkApiClient';
import { SHORT_URL_TAGS_EDITED } from './shortUrlTags';
import { SHORT_URL_DELETED } from './shortUrlDeletion';
/* eslint-disable padding-line-between-statements, newline-after-var */
const LIST_SHORT_URLS_START = 'shlink/shortUrlsList/LIST_SHORT_URLS_START';
@ -43,6 +44,12 @@ export default function reducer(state = initialState, action) {
shortUrl.shortCode === action.shortCode
? assoc('tags', action.tags, shortUrl)
: shortUrl), state);
case SHORT_URL_DELETED:
return assocPath(
[ 'shortUrls', 'data' ],
reject((shortUrl) => shortUrl.shortCode === action.shortCode, state.shortUrls.data),
state,
);
default:
return state;
}

View file

@ -5,25 +5,21 @@ import editIcon from '@fortawesome/fontawesome-free-solid/faPencilAlt';
import PropTypes from 'prop-types';
import React from 'react';
import { Link } from 'react-router-dom';
import colorGenerator, { colorGeneratorType } from '../utils/ColorGenerator';
import TagBullet from './helpers/TagBullet';
import './TagCard.scss';
import DeleteTagConfirmModal from './helpers/DeleteTagConfirmModal';
import EditTagModal from './helpers/EditTagModal';
const propTypes = {
export default class TagCard extends React.Component {
static propTypes = {
tag: PropTypes.string,
currentServerId: PropTypes.string,
colorGenerator: colorGeneratorType,
};
const defaultProps = {
colorGenerator,
};
};
export default class TagCard extends React.Component {
state = { isDeleteModalOpen: false, isEditModalOpen: false };
render() {
const { tag, colorGenerator, currentServerId } = this.props;
const { tag, currentServerId } = this.props;
const toggleDelete = () =>
this.setState(({ isDeleteModalOpen }) => ({ isDeleteModalOpen: !isDeleteModalOpen }));
const toggleEdit = () =>
@ -45,10 +41,7 @@ export default class TagCard extends React.Component {
<FontAwesomeIcon icon={editIcon} />
</button>
<h5 className="tag-card__tag-title">
<div
style={{ backgroundColor: colorGenerator.getColorForKey(tag) }}
className="tag-card__tag-bullet"
/>
<TagBullet tag={tag} />
<Link to={`/server/${currentServerId}/list-short-urls/1?tag=${tag}`}>
{tag}
</Link>
@ -69,6 +62,3 @@ export default class TagCard extends React.Component {
);
}
}
TagCard.propTypes = propTypes;
TagCard.defaultProps = defaultProps;

View file

@ -16,17 +16,6 @@
padding-right: 5px;
}
.tag-card__tag-bullet {
$width: 20px;
border-radius: 50%;
width: $width;
height: $width;
display: inline-block;
vertical-align: -4px;
margin-right: 7px;
}
.tag-card__btn {
float: right;
}

View file

@ -4,25 +4,26 @@ import { pick, splitEvery } from 'ramda';
import PropTypes from 'prop-types';
import MuttedMessage from '../utils/MuttedMessage';
import SearchField from '../utils/SearchField';
import { filterTags, listTags } from './reducers/tagsList';
import { filterTags, forceListTags } from './reducers/tagsList';
import TagCard from './TagCard';
const { ceil } = Math;
const TAGS_GROUP_SIZE = 4;
const propTypes = {
export class TagsListComponent extends React.Component {
static propTypes = {
filterTags: PropTypes.func,
listTags: PropTypes.func,
forceListTags: PropTypes.func,
tagsList: PropTypes.shape({
loading: PropTypes.bool,
}),
match: PropTypes.object,
};
};
export class TagsListComponent extends React.Component {
componentDidMount() {
const { listTags } = this.props;
const { forceListTags } = this.props;
listTags();
forceListTags(true);
}
renderContent() {
@ -85,8 +86,6 @@ export class TagsListComponent extends React.Component {
}
}
TagsListComponent.propTypes = propTypes;
const TagsList = connect(pick([ 'tagsList' ]), { listTags, filterTags })(TagsListComponent);
const TagsList = connect(pick([ 'tagsList' ]), { forceListTags, filterTags })(TagsListComponent);
export default TagsList;

View file

@ -5,16 +5,16 @@ import PropTypes from 'prop-types';
import { pick } from 'ramda';
import { deleteTag, tagDeleted, tagDeleteType } from '../reducers/tagDelete';
const propTypes = {
export class DeleteTagConfirmModalComponent extends React.Component {
static propTypes = {
tag: PropTypes.string.isRequired,
toggle: PropTypes.func.isRequired,
isOpen: PropTypes.bool.isRequired,
deleteTag: PropTypes.func,
tagDelete: tagDeleteType,
tagDeleted: PropTypes.func,
};
};
export class DeleteTagConfirmModalComponent extends React.Component {
doDelete = () => {
const { tag, toggle, deleteTag } = this.props;
@ -68,8 +68,6 @@ export class DeleteTagConfirmModalComponent extends React.Component {
}
}
DeleteTagConfirmModalComponent.propTypes = propTypes;
const DeleteTagConfirmModal = connect(
pick([ 'tagDelete' ]),
{ deleteTag, tagDeleted }

View file

@ -10,7 +10,8 @@ import colorGenerator, { colorGeneratorType } from '../../utils/ColorGenerator';
import { editTag, tagEdited } from '../reducers/tagEdit';
import './EditTagModal.scss';
const propTypes = {
export class EditTagModalComponent extends React.Component {
static propTypes = {
tag: PropTypes.string,
editTag: PropTypes.func,
toggle: PropTypes.func,
@ -21,12 +22,11 @@ const propTypes = {
error: PropTypes.bool,
editing: PropTypes.bool,
}),
};
const defaultProps = {
};
static defaultProps = {
colorGenerator,
};
};
export class EditTagModalComponent extends React.Component {
saveTag = (e) => {
e.preventDefault();
const { tag: oldName, editTag, toggle } = this.props;
@ -133,9 +133,6 @@ export class EditTagModalComponent extends React.Component {
}
}
EditTagModalComponent.propTypes = propTypes;
EditTagModalComponent.defaultProps = defaultProps;
const EditTagModal = connect(pick([ 'tagEdit' ]), { editTag, tagEdited })(EditTagModalComponent);
export default EditTagModal;

View file

@ -1,6 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import colorGenerator, { colorGeneratorType } from '../utils/ColorGenerator';
import colorGenerator, { colorGeneratorType } from '../../utils/ColorGenerator';
import './Tag.scss';
const propTypes = {

View file

@ -0,0 +1,24 @@
import React from 'react';
import * as PropTypes from 'prop-types';
import colorGenerator, { colorGeneratorType } from '../../utils/ColorGenerator';
import './TagBullet.scss';
const propTypes = {
tag: PropTypes.string.isRequired,
colorGenerator: colorGeneratorType,
};
const defaultProps = {
colorGenerator,
};
export default function TagBullet({ tag, colorGenerator }) {
return (
<div
style={{ backgroundColor: colorGenerator.getColorForKey(tag) }}
className="tag-bullet"
/>
);
}
TagBullet.propTypes = propTypes;
TagBullet.defaultProps = defaultProps;

View file

@ -0,0 +1,10 @@
.tag-bullet {
$width: 20px;
border-radius: 50%;
width: $width;
height: $width;
display: inline-block;
vertical-align: -4px;
margin-right: 7px;
}

View file

@ -0,0 +1,93 @@
import React from 'react';
import { connect } from 'react-redux';
import TagsInput from 'react-tagsinput';
import PropTypes from 'prop-types';
import Autosuggest from 'react-autosuggest';
import { pick, identity } from 'ramda';
import { listTags } from '../reducers/tagsList';
import colorGenerator, { colorGeneratorType } from '../../utils/ColorGenerator';
import './TagsSelector.scss';
import TagBullet from './TagBullet';
export class TagsSelectorComponent extends React.Component {
static propTypes = {
tags: PropTypes.arrayOf(PropTypes.string).isRequired,
onChange: PropTypes.func.isRequired,
placeholder: PropTypes.string,
colorGenerator: colorGeneratorType,
tagsList: PropTypes.shape({
tags: PropTypes.arrayOf(PropTypes.string),
}),
};
static defaultProps = {
colorGenerator,
placeholder: 'Add tags to the URL',
};
componentDidMount() {
const { listTags } = this.props;
listTags();
}
render() {
const { tags, onChange, placeholder, colorGenerator, tagsList } = this.props;
const renderTag = ({ tag, key, disabled, onRemove, classNameRemove, getTagDisplayValue, ...other }) => (
<span key={key} style={{ backgroundColor: colorGenerator.getColorForKey(tag) }} {...other}>
{getTagDisplayValue(tag)}
{!disabled && <span className={classNameRemove} onClick={() => onRemove(key)} />}
</span>
);
const renderAutocompleteInput = (data) => {
const { addTag, ...otherProps } = data;
const handleOnChange = (e, { method }) => {
method === 'enter' ? e.preventDefault() : otherProps.onChange(e);
};
// eslint-disable-next-line no-extra-parens
const inputValue = (otherProps.value && otherProps.value.trim().toLowerCase()) || '';
const inputLength = inputValue.length;
const suggestions = tagsList.tags.filter((state) => state.toLowerCase().slice(0, inputLength) === inputValue);
return (
<Autosuggest
ref={otherProps.ref}
suggestions={suggestions}
inputProps={{ ...otherProps, onChange: handleOnChange }}
highlightFirstSuggestion
shouldRenderSuggestions={(value) => value && value.trim().length > 0}
getSuggestionValue={(suggestion) => suggestion}
renderSuggestion={(suggestion) => (
<React.Fragment>
<TagBullet tag={suggestion} />
{suggestion}
</React.Fragment>
)}
onSuggestionSelected={(e, { suggestion }) => {
addTag(suggestion);
}}
onSuggestionsClearRequested={identity}
onSuggestionsFetchRequested={identity}
/>
);
};
return (
<TagsInput
value={tags}
inputProps={{ placeholder }}
onlyUnique
renderTag={renderTag}
renderInput={renderAutocompleteInput}
// FIXME Workaround to be able to add tags on Android
addOnBlur
onChange={onChange}
/>
);
}
}
const TagsSelector = connect(pick([ 'tagsList' ]), { listTags })(TagsSelectorComponent);
export default TagsSelector;

View file

@ -0,0 +1,16 @@
@import '../../utils/base';
.react-autosuggest__suggestions-list {
list-style-type: none;
padding: 0;
margin-bottom: 6px;
}
.react-autosuggest__suggestion {
margin-left: -6px;
padding: 5px 8px;
}
.react-autosuggest__suggestion--highlighted {
background-color: $lightGrey;
}

View file

@ -1,4 +1,4 @@
import { reject } from 'ramda';
import { isEmpty, reject } from 'ramda';
import shlinkApiClient from '../../api/ShlinkApiClient';
import { TAG_DELETED } from './tagDelete';
import { TAG_EDITED } from './tagEdit';
@ -59,16 +59,20 @@ export default function reducer(state = defaultState, action) {
case FILTER_TAGS:
return {
...state,
filteredTags: state.tags.filter(
(tag) => tag.toLowerCase().match(action.searchTerm),
),
filteredTags: state.tags.filter((tag) => tag.toLowerCase().match(action.searchTerm)),
};
default:
return state;
}
}
export const _listTags = (shlinkApiClient) => async (dispatch) => {
export const _listTags = (shlinkApiClient, force = false) => async (dispatch, getState) => {
const { tagsList } = getState();
if (!force && (tagsList.loading || !isEmpty(tagsList.tags))) {
return;
}
dispatch({ type: LIST_TAGS_START });
try {
@ -82,6 +86,8 @@ export const _listTags = (shlinkApiClient) => async (dispatch) => {
export const listTags = () => _listTags(shlinkApiClient);
export const forceListTags = () => _listTags(shlinkApiClient, true);
export const filterTags = (searchTerm) => ({
type: FILTER_TAGS,
searchTerm,

View file

@ -11,7 +11,7 @@ export default function ExternalLink(props) {
return (
<a target="_blank" rel="noopener noreferrer" href={href} {...rest}>
{children}
{children || href}
</a>
);
}

View file

@ -6,17 +6,18 @@ import classnames from 'classnames';
import './SearchField.scss';
const DEFAULT_SEARCH_INTERVAL = 500;
const propTypes = {
export default class SearchField extends React.Component {
static propTypes = {
onChange: PropTypes.func.isRequired,
className: PropTypes.string,
placeholder: PropTypes.string,
};
const defaultProps = {
};
static defaultProps = {
className: '',
placeholder: 'Search...',
};
};
export default class SearchField extends React.Component {
state = { showClearBtn: false, searchTerm: '' };
timer = null;
@ -64,6 +65,3 @@ export default class SearchField extends React.Component {
);
}
}
SearchField.propTypes = propTypes;
SearchField.defaultProps = defaultProps;

View file

@ -1,44 +0,0 @@
import React from 'react';
import TagsInput from 'react-tagsinput';
import PropTypes from 'prop-types';
import colorGenerator, { colorGeneratorType } from './ColorGenerator';
const defaultProps = {
colorGenerator,
placeholder: 'Add tags to the URL',
};
const propTypes = {
tags: PropTypes.arrayOf(PropTypes.string).isRequired,
onChange: PropTypes.func.isRequired,
placeholder: PropTypes.string,
colorGenerator: colorGeneratorType,
};
export default function TagsSelector({ tags, onChange, placeholder, colorGenerator }) {
const renderTag = (props) => {
const { tag, key, disabled, onRemove, classNameRemove, getTagDisplayValue, ...other } = props;
return (
<span key={key} style={{ backgroundColor: colorGenerator.getColorForKey(tag) }} {...other}>
{getTagDisplayValue(tag)}
{!disabled && <span className={classNameRemove} onClick={() => onRemove(key)} />}
</span>
);
};
return (
<TagsInput
value={tags}
inputProps={{ placeholder }}
onlyUnique
renderTag={renderTag}
// FIXME Workaround to be able to add tags on Android
addOnBlur
onChange={onChange}
/>
);
}
TagsSelector.defaultProps = defaultProps;
TagsSelector.propTypes = propTypes;

58
src/visits/GraphCard.js Normal file
View file

@ -0,0 +1,58 @@
import { Card, CardHeader, CardBody } from 'reactstrap';
import { Doughnut, HorizontalBar } from 'react-chartjs-2';
import PropTypes from 'prop-types';
import React from 'react';
import { keys, values } from 'ramda';
const propTypes = {
title: PropTypes.string,
isBarChart: PropTypes.bool,
stats: PropTypes.object,
};
export function GraphCard({ title, isBarChart, stats }) {
const generateGraphData = (stats) => ({
labels: keys(stats),
datasets: [
{
title,
data: values(stats),
backgroundColor: isBarChart ? 'rgba(70, 150, 229, 0.4)' : [
'#97BBCD',
'#DCDCDC',
'#F7464A',
'#46BFBD',
'#FDB45C',
'#949FB1',
'#4D5360',
],
borderColor: isBarChart ? 'rgba(70, 150, 229, 1)' : 'white',
borderWidth: 2,
},
],
});
const renderGraph = () => {
const Component = isBarChart ? HorizontalBar : Doughnut;
const options = {
legend: isBarChart ? { display: false } : { position: 'right' },
scales: isBarChart ? {
xAxes: [
{
ticks: { beginAtZero: true },
},
],
} : null,
};
return <Component data={generateGraphData(stats)} options={options} />;
};
return (
<Card className="mt-4">
<CardHeader>{title}</CardHeader>
<CardBody>{renderGraph()}</CardBody>
</Card>
);
}
GraphCard.propTypes = propTypes;

View file

@ -0,0 +1,147 @@
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 { connect } from 'react-redux';
import { Card } from 'reactstrap';
import PropTypes from 'prop-types';
import DateInput from '../common/DateInput';
import MutedMessage from '../utils/MuttedMessage';
import { getShortUrlVisits, shortUrlVisitsType } from './reducers/shortUrlVisits';
import {
processBrowserStats,
processCountriesStats,
processOsStats,
processReferrersStats,
} from './services/VisitsParser';
import { VisitsHeader } from './VisitsHeader';
import { GraphCard } from './GraphCard';
import { getShortUrlDetail, shortUrlDetailType } from './reducers/shortUrlDetail';
import './ShortUrlVisits.scss';
export class ShortUrlsVisitsComponent extends React.Component {
static propTypes = {
processOsStats: PropTypes.func,
processBrowserStats: PropTypes.func,
processCountriesStats: PropTypes.func,
processReferrersStats: PropTypes.func,
match: PropTypes.shape({
params: PropTypes.object,
}),
getShortUrlVisits: PropTypes.func,
shortUrlVisits: shortUrlVisitsType,
getShortUrlDetail: PropTypes.func,
shortUrlDetail: shortUrlDetailType,
};
static defaultProps = {
processOsStats,
processBrowserStats,
processCountriesStats,
processReferrersStats,
};
state = { startDate: undefined, endDate: undefined };
loadVisits = () => {
const { match: { params }, getShortUrlVisits } = this.props;
getShortUrlVisits(params.shortCode, mapObjIndexed(
(value) => value && value.format ? value.format('YYYY-MM-DD') : value,
this.state
));
};
componentDidMount() {
const { match: { params }, getShortUrlDetail } = this.props;
this.loadVisits();
getShortUrlDetail(params.shortCode);
}
render() {
const {
processOsStats,
processBrowserStats,
processCountriesStats,
processReferrersStats,
shortUrlVisits,
shortUrlDetail,
} = this.props;
const renderVisitsContent = () => {
const { visits, loading, error } = shortUrlVisits;
if (loading) {
return <MutedMessage><FontAwesomeIcon icon={preloader} spin /> Loading...</MutedMessage>;
}
if (error) {
return (
<Card className="mt-4" body inverse color="danger">
An error occurred while loading visits :(
</Card>
);
}
if (isEmpty(visits)) {
return <MutedMessage>There are no visits matching current filter :(</MutedMessage>;
}
return (
<div className="row">
<div className="col-md-6">
<GraphCard title="Operating systems" stats={processOsStats(visits)} />
</div>
<div className="col-md-6">
<GraphCard title="Browsers" stats={processBrowserStats(visits)} />
</div>
<div className="col-md-6">
<GraphCard title="Countries" stats={processCountriesStats(visits)} isBarChart />
</div>
<div className="col-md-6">
<GraphCard title="Referrers" stats={processReferrersStats(visits)} isBarChart />
</div>
</div>
);
};
return (
<div className="shlink-container">
<VisitsHeader shortUrlDetail={shortUrlDetail} shortUrlVisits={shortUrlVisits} />
<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
maxDate={this.state.endDate}
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
minDate={this.state.startDate}
onChange={(date) => this.setState({ endDate: date }, () => this.loadVisits())}
/>
</div>
</div>
</section>
<section>
{renderVisitsContent()}
</section>
</div>
);
}
}
const ShortUrlsVisits = connect(
pick([ 'shortUrlVisits', 'shortUrlDetail' ]),
{ getShortUrlVisits, getShortUrlDetail }
)(ShortUrlsVisitsComponent);
export default ShortUrlsVisits;

View file

@ -0,0 +1,55 @@
import { Card, UncontrolledTooltip } from 'reactstrap';
import Moment from 'react-moment';
import React from 'react';
import ExternalLink from '../utils/ExternalLink';
import './VisitsHeader.scss';
import { shortUrlDetailType } from './reducers/shortUrlDetail';
import { shortUrlVisitsType } from './reducers/shortUrlVisits';
const propTypes = {
shortUrlDetail: shortUrlDetailType.isRequired,
shortUrlVisits: shortUrlVisitsType.isRequired,
};
export function VisitsHeader({ shortUrlDetail, shortUrlVisits }) {
const { shortUrl, loading } = shortUrlDetail;
const { visits } = shortUrlVisits;
const shortLink = shortUrl && shortUrl.shortUrl ? shortUrl.shortUrl : '';
const longLink = shortUrl && shortUrl.longUrl ? shortUrl.longUrl : '';
const renderDate = () => (
<span>
<b id="created" className="visits-header__created-at"><Moment fromNow>{shortUrl.dateCreated}</Moment></b>
<UncontrolledTooltip placement="bottom" target="created">
<Moment format="YYYY-MM-DD HH:mm">{shortUrl.dateCreated}</Moment>
</UncontrolledTooltip>
</span>
);
return (
<header>
<Card className="bg-light" body>
<h2>
<span className="badge badge-main float-right">Visits: {visits.length}</span>
Visit stats for <ExternalLink href={shortLink} />
</h2>
<hr />
{shortUrl.dateCreated && (
<div>
Created:
&nbsp;
{renderDate()}
</div>
)}
<div>
Long URL:
&nbsp;
{loading && <small>Loading...</small>}
{!loading && <ExternalLink href={longLink} />}
</div>
</Card>
</header>
);
}
VisitsHeader.propTypes = propTypes;

View file

@ -0,0 +1,3 @@
.visits-header__created-at {
cursor: default;
}

View file

@ -0,0 +1,60 @@
import { curry } from 'ramda';
import PropTypes from 'prop-types';
import shlinkApiClient from '../../api/ShlinkApiClient';
import { shortUrlType } from '../../short-urls/reducers/shortUrlsList';
/* eslint-disable padding-line-between-statements, newline-after-var */
export const GET_SHORT_URL_DETAIL_START = 'shlink/shortUrlDetail/GET_SHORT_URL_DETAIL_START';
export const GET_SHORT_URL_DETAIL_ERROR = 'shlink/shortUrlDetail/GET_SHORT_URL_DETAIL_ERROR';
export const GET_SHORT_URL_DETAIL = 'shlink/shortUrlDetail/GET_SHORT_URL_DETAIL';
/* eslint-enable padding-line-between-statements, newline-after-var */
export const shortUrlDetailType = PropTypes.shape({
shortUrl: shortUrlType,
loading: PropTypes.bool,
error: PropTypes.bool,
});
const initialState = {
shortUrl: {},
loading: false,
error: false,
};
export default function reducer(state = initialState, action) {
switch (action.type) {
case GET_SHORT_URL_DETAIL_START:
return {
...state,
loading: true,
};
case GET_SHORT_URL_DETAIL_ERROR:
return {
...state,
loading: false,
error: true,
};
case GET_SHORT_URL_DETAIL:
return {
shortUrl: action.shortUrl,
loading: false,
error: false,
};
default:
return state;
}
}
export const _getShortUrlDetail = (shlinkApiClient, shortCode) => async (dispatch) => {
dispatch({ type: GET_SHORT_URL_DETAIL_START });
try {
const shortUrl = await shlinkApiClient.getShortUrl(shortCode);
dispatch({ shortUrl, type: GET_SHORT_URL_DETAIL });
} catch (e) {
dispatch({ type: GET_SHORT_URL_DETAIL_ERROR });
}
};
export const getShortUrlDetail = curry(_getShortUrlDetail)(shlinkApiClient);

View file

@ -1,29 +1,26 @@
import { curry } from 'ramda';
import PropTypes from 'prop-types';
import shlinkApiClient from '../../api/ShlinkApiClient';
import { shortUrlType } from './shortUrlsList';
/* eslint-disable padding-line-between-statements, newline-after-var */
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';
const GET_SHORT_URL_VISITS = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS';
export const GET_SHORT_URL_VISITS_START = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_START';
export const GET_SHORT_URL_VISITS_ERROR = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_ERROR';
export const GET_SHORT_URL_VISITS = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS';
/* eslint-enable padding-line-between-statements, newline-after-var */
export const shortUrlVisitsType = PropTypes.shape({
shortUrl: shortUrlType,
visits: PropTypes.array,
loading: PropTypes.bool,
error: PropTypes.bool,
});
const initialState = {
shortUrl: {},
visits: [],
loading: false,
error: false,
};
export default function dispatch(state = initialState, action) {
export default function reducer(state = initialState, action) {
switch (action.type) {
case GET_SHORT_URL_VISITS_START:
return {
@ -38,7 +35,6 @@ export default function dispatch(state = initialState, action) {
};
case GET_SHORT_URL_VISITS:
return {
shortUrl: action.shortUrl,
visits: action.visits,
loading: false,
error: false,
@ -48,15 +44,16 @@ export default function dispatch(state = initialState, action) {
}
}
export const _getShortUrlVisits = (shlinkApiClient, shortCode, dates) => (dispatch) => {
export const _getShortUrlVisits = (shlinkApiClient, shortCode, dates) => async (dispatch) => {
dispatch({ type: GET_SHORT_URL_VISITS_START });
Promise.all([
shlinkApiClient.getShortUrlVisits(shortCode, dates),
shlinkApiClient.getShortUrl(shortCode),
])
.then(([ visits, shortUrl ]) => dispatch({ visits, shortUrl, type: GET_SHORT_URL_VISITS }))
.catch(() => dispatch({ type: GET_SHORT_URL_VISITS_ERROR }));
try {
const visits = await shlinkApiClient.getShortUrlVisits(shortCode, dates);
dispatch({ visits, type: GET_SHORT_URL_VISITS });
} catch (e) {
dispatch({ type: GET_SHORT_URL_VISITS_ERROR });
}
};
export const getShortUrlVisits = curry(_getShortUrlVisits)(shlinkApiClient);

View file

@ -0,0 +1,32 @@
import React from 'react';
import { shallow } from 'enzyme';
import { PaginationItem } from 'reactstrap';
import Paginator from '../../src/short-urls/Paginator';
describe('<Paginator />', () => {
let wrapper;
afterEach(() => {
if (wrapper) {
wrapper.unmount();
}
});
it('renders nothing if the number of pages is below 2', () => {
wrapper = shallow(<Paginator serverId="abc123" />);
expect(wrapper.text()).toEqual('');
});
it('renders previous, next and the list of pages', () => {
const paginator = {
currentPage: 1,
pagesCount: 5,
};
const extraPagesPrevNext = 2;
const expectedItems = paginator.pagesCount + extraPagesPrevNext;
wrapper = shallow(<Paginator serverId="abc123" paginator={paginator} />);
expect(wrapper.find(PaginationItem)).toHaveLength(expectedItems);
});
});

View file

@ -0,0 +1,59 @@
import React from 'react';
import { shallow } from 'enzyme';
import sinon from 'sinon';
import { SearchBarComponent } from '../../src/short-urls/SearchBar';
import SearchField from '../../src/utils/SearchField';
import Tag from '../../src/tags/helpers/Tag';
describe('<SearchBar />', () => {
let wrapper;
const listShortUrlsMock = sinon.spy();
afterEach(() => {
listShortUrlsMock.resetHistory();
if (wrapper) {
wrapper.unmount();
}
});
it('renders a SearchField', () => {
wrapper = shallow(<SearchBarComponent shortUrlsListParams={{}} />);
expect(wrapper.find(SearchField)).toHaveLength(1);
});
it('renders no tags when the list of tags is empty', () => {
wrapper = shallow(<SearchBarComponent shortUrlsListParams={{}} />);
expect(wrapper.find(Tag)).toHaveLength(0);
});
it('renders the proper amount of tags', () => {
const tags = [ 'foo', 'bar', 'baz' ];
wrapper = shallow(<SearchBarComponent shortUrlsListParams={{ tags }} />);
expect(wrapper.find(Tag)).toHaveLength(tags.length);
});
it('updates short URLs list when search field changes', () => {
wrapper = shallow(<SearchBarComponent shortUrlsListParams={{}} listShortUrls={listShortUrlsMock} />);
const searchField = wrapper.find(SearchField);
expect(listShortUrlsMock.callCount).toEqual(0);
searchField.simulate('change');
expect(listShortUrlsMock.callCount).toEqual(1);
});
it('updates short URLs list when a tag is removed', () => {
wrapper = shallow(
<SearchBarComponent shortUrlsListParams={{ tags: [ 'foo' ] }} listShortUrls={listShortUrlsMock} />
);
const tag = wrapper.find(Tag).first();
expect(listShortUrlsMock.callCount).toEqual(0);
tag.simulate('close');
expect(listShortUrlsMock.callCount).toEqual(1);
});
});

View file

@ -5,7 +5,7 @@ import reducer, {
import { LIST_SHORT_URLS } from '../../../src/short-urls/reducers/shortUrlsList';
describe('shortUrlsListParamsReducer', () => {
describe('reducerr', () => {
describe('reducer', () => {
const defaultState = { page: '1' };
it('returns default value when action is unknown', () =>

View file

@ -0,0 +1,70 @@
import React from 'react';
import { shallow } from 'enzyme';
import { Doughnut, HorizontalBar } from 'react-chartjs-2';
import { keys, values } from 'ramda';
import { GraphCard } from '../../src/visits/GraphCard';
describe('<GraphCard />', () => {
let wrapper;
const stats = {
foo: 123,
bar: 456,
};
afterEach(() => {
if (wrapper) {
wrapper.unmount();
}
});
it('renders Doughnut when is not a bar chart', () => {
wrapper = shallow(<GraphCard title="The chart" stats={stats} />);
const doughnut = wrapper.find(Doughnut);
const horizontal = wrapper.find(HorizontalBar);
expect(doughnut).toHaveLength(1);
expect(horizontal).toHaveLength(0);
const { labels, datasets: [{ title, data, backgroundColor, borderColor }] } = doughnut.prop('data');
const { legend, scales } = doughnut.prop('options');
expect(title).toEqual('The chart');
expect(labels).toEqual(keys(stats));
expect(data).toEqual(values(stats));
expect(backgroundColor).toEqual([
'#97BBCD',
'#DCDCDC',
'#F7464A',
'#46BFBD',
'#FDB45C',
'#949FB1',
'#4D5360',
]);
expect(borderColor).toEqual('white');
expect(legend).toEqual({ position: 'right' });
expect(scales).toBeNull();
});
it('renders HorizontalBar when is not a bar chart', () => {
wrapper = shallow(<GraphCard isBarChart title="The chart" stats={stats} />);
const doughnut = wrapper.find(Doughnut);
const horizontal = wrapper.find(HorizontalBar);
expect(doughnut).toHaveLength(0);
expect(horizontal).toHaveLength(1);
const { datasets: [{ backgroundColor, borderColor }] } = horizontal.prop('data');
const { legend, scales } = horizontal.prop('options');
expect(backgroundColor).toEqual('rgba(70, 150, 229, 0.4)');
expect(borderColor).toEqual('rgba(70, 150, 229, 1)');
expect(legend).toEqual({ display: false });
expect(scales).toEqual({
xAxes: [
{
ticks: { beginAtZero: true },
},
],
});
});
});

View file

@ -0,0 +1,89 @@
import React from 'react';
import { shallow } from 'enzyme';
import { identity } from 'ramda';
import { Card } from 'reactstrap';
import * as sinon from 'sinon';
import { ShortUrlsVisitsComponent as ShortUrlsVisits } from '../../src/visits/ShortUrlVisits';
import MutedMessage from '../../src/utils/MuttedMessage';
import { GraphCard } from '../../src/visits/GraphCard';
import DateInput from '../../src/common/DateInput';
describe('<ShortUrlVisits />', () => {
let wrapper;
const statsProcessor = () => ({});
const getShortUrlVisitsMock = sinon.spy();
const match = {
params: { shortCode: 'abc123' },
};
const createComponent = (shortUrlVisits) => {
wrapper = shallow(
<ShortUrlsVisits
getShortUrlDetail={identity}
getShortUrlVisits={getShortUrlVisitsMock}
processBrowserStats={statsProcessor}
processCountriesStats={statsProcessor}
processOsStats={statsProcessor}
processReferrersStats={statsProcessor}
match={match}
shortUrlVisits={shortUrlVisits}
shortUrlDetail={{}}
/>
);
return wrapper;
};
afterEach(() => {
getShortUrlVisitsMock.resetHistory();
if (wrapper) {
wrapper.unmount();
}
});
it('Renders a preloader when visits are loading', () => {
const wrapper = createComponent({ loading: true });
const loadingMessage = wrapper.find(MutedMessage);
expect(loadingMessage).toHaveLength(1);
expect(loadingMessage.html()).toContain('Loading...');
});
it('renders an error message when visits could not be loaded', () => {
const wrapper = createComponent({ loading: false, error: true });
const errorMessage = wrapper.find(Card);
expect(errorMessage).toHaveLength(1);
expect(errorMessage.html()).toContain('An error occurred while loading visits :(');
});
it('renders a message when visits are loaded but the list is empty', () => {
const wrapper = createComponent({ loading: false, error: false, visits: [] });
const message = wrapper.find(MutedMessage);
expect(message).toHaveLength(1);
expect(message.html()).toContain('There are no visits matching current filter :(');
});
it('renders all graphics when visits are properly loaded', () => {
const wrapper = createComponent({ loading: false, error: false, visits: [{}, {}, {}] });
const graphs = wrapper.find(GraphCard);
const expectedGraphsCount = 4;
expect(graphs).toHaveLength(expectedGraphsCount);
});
it('reloads visits when selected dates change', () => {
const wrapper = createComponent({ loading: false, error: false, visits: [{}, {}, {}] });
const dateInput = wrapper.find(DateInput).first();
const expectedGetShortUrlVisitsCalls = 4;
dateInput.simulate('change', '2016-01-01T00:00:00+01:00');
dateInput.simulate('change', '2016-01-02T00:00:00+01:00');
dateInput.simulate('change', '2016-01-03T00:00:00+01:00');
expect(getShortUrlVisitsMock.callCount).toEqual(expectedGetShortUrlVisitsCalls);
expect(wrapper.state('startDate')).toEqual('2016-01-03T00:00:00+01:00');
});
});

View file

@ -0,0 +1,44 @@
import React from 'react';
import { shallow } from 'enzyme';
import Moment from 'react-moment';
import { VisitsHeader } from '../../src/visits/VisitsHeader';
import ExternalLink from '../../src/utils/ExternalLink';
describe('<VisitsHeader />', () => {
let wrapper;
const shortUrlDetail = {
shortUrl: {
longUrl: 'https://foo.bar/bar/foo',
dateCreated: '2018-01-01T10:00:00+01:00',
},
loading: false,
};
const shortUrlVisits = {
visits: [{}, {}, {}],
};
beforeEach(() => {
wrapper = shallow(
<VisitsHeader shortUrlDetail={shortUrlDetail} shortUrlVisits={shortUrlVisits} shortLink="foo" />
);
});
afterEach(() => wrapper.unmount());
it('shows the amount of visits', () => {
const visitsBadge = wrapper.find('.badge');
expect(visitsBadge.text()).toEqual(`Visits: ${shortUrlVisits.visits.length}`);
});
it('shows when the URL was created', () => {
const moment = wrapper.find(Moment).first();
expect(moment.prop('children')).toEqual(shortUrlDetail.shortUrl.dateCreated);
});
it('shows the long URL', () => {
const longUrlLink = wrapper.find(ExternalLink).last();
expect(longUrlLink.prop('href')).toEqual(shortUrlDetail.shortUrl.longUrl);
});
});

View file

@ -0,0 +1,94 @@
import * as sinon from 'sinon';
import reducer, {
_getShortUrlDetail,
GET_SHORT_URL_DETAIL_START,
GET_SHORT_URL_DETAIL_ERROR,
GET_SHORT_URL_DETAIL,
} from '../../../src/visits/reducers/shortUrlDetail';
describe('shortUrlDetailReducer', () => {
describe('reducer', () => {
it('returns loading on GET_SHORT_URL_DETAIL_START', () => {
const state = reducer({ loading: false }, { type: GET_SHORT_URL_DETAIL_START });
const { loading } = state;
expect(loading).toEqual(true);
});
it('stops loading and returns error on GET_SHORT_URL_DETAIL_ERROR', () => {
const state = reducer({ loading: true, error: false }, { type: GET_SHORT_URL_DETAIL_ERROR });
const { loading, error } = state;
expect(loading).toEqual(false);
expect(error).toEqual(true);
});
it('return short URL on GET_SHORT_URL_DETAIL', () => {
const actionShortUrl = { longUrl: 'foo', shortCode: 'bar' };
const state = reducer({ loading: true, error: false }, { type: GET_SHORT_URL_DETAIL, shortUrl: actionShortUrl });
const { loading, error, shortUrl } = state;
expect(loading).toEqual(false);
expect(error).toEqual(false);
expect(shortUrl).toEqual(actionShortUrl);
});
it('returns default state on unknown action', () => {
const defaultState = {
shortUrl: {},
loading: false,
error: false,
};
const state = reducer(defaultState, { type: 'unknown' });
expect(state).toEqual(defaultState);
});
});
describe('getShortUrlDetail', () => {
const buildApiClientMock = (returned) => ({
getShortUrl: sinon.fake.returns(returned),
});
const dispatchMock = sinon.spy();
beforeEach(() => dispatchMock.resetHistory());
it('dispatches start and error when promise is rejected', async () => {
const ShlinkApiClient = buildApiClientMock(Promise.reject());
const expectedDispatchCalls = 2;
await _getShortUrlDetail(ShlinkApiClient, 'abc123')(dispatchMock);
const [ firstCallArg ] = dispatchMock.getCall(0).args;
const { type: firstCallType } = firstCallArg;
const [ secondCallArg ] = dispatchMock.getCall(1).args;
const { type: secondCallType } = secondCallArg;
expect(dispatchMock.callCount).toEqual(expectedDispatchCalls);
expect(ShlinkApiClient.getShortUrl.callCount).toEqual(1);
expect(firstCallType).toEqual(GET_SHORT_URL_DETAIL_START);
expect(secondCallType).toEqual(GET_SHORT_URL_DETAIL_ERROR);
});
it('dispatches start and success when promise is resolved', async () => {
const resolvedShortUrl = { longUrl: 'foo', shortCode: 'bar' };
const ShlinkApiClient = buildApiClientMock(Promise.resolve(resolvedShortUrl));
const expectedDispatchCalls = 2;
await _getShortUrlDetail(ShlinkApiClient, 'abc123')(dispatchMock);
const [ firstCallArg ] = dispatchMock.getCall(0).args;
const { type: firstCallType } = firstCallArg;
const [ secondCallArg ] = dispatchMock.getCall(1).args;
const { type: secondCallType, shortUrl } = secondCallArg;
expect(dispatchMock.callCount).toEqual(expectedDispatchCalls);
expect(ShlinkApiClient.getShortUrl.callCount).toEqual(1);
expect(firstCallType).toEqual(GET_SHORT_URL_DETAIL_START);
expect(secondCallType).toEqual(GET_SHORT_URL_DETAIL);
expect(shortUrl).toEqual(resolvedShortUrl);
});
});
});

View file

@ -0,0 +1,94 @@
import * as sinon from 'sinon';
import reducer, {
_getShortUrlVisits,
GET_SHORT_URL_VISITS_START,
GET_SHORT_URL_VISITS_ERROR,
GET_SHORT_URL_VISITS,
} from '../../../src/visits/reducers/shortUrlVisits';
describe('shortUrlVisitsReducer', () => {
describe('reducer', () => {
it('returns loading on GET_SHORT_URL_VISITS_START', () => {
const state = reducer({ loading: false }, { type: GET_SHORT_URL_VISITS_START });
const { loading } = state;
expect(loading).toEqual(true);
});
it('stops loading and returns error on GET_SHORT_URL_VISITS_ERROR', () => {
const state = reducer({ loading: true, error: false }, { type: GET_SHORT_URL_VISITS_ERROR });
const { loading, error } = state;
expect(loading).toEqual(false);
expect(error).toEqual(true);
});
it('return visits on GET_SHORT_URL_VISITS', () => {
const actionVisits = [{}, {}];
const state = reducer({ loading: true, error: false }, { type: GET_SHORT_URL_VISITS, visits: actionVisits });
const { loading, error, visits } = state;
expect(loading).toEqual(false);
expect(error).toEqual(false);
expect(visits).toEqual(actionVisits);
});
it('returns default state on unknown action', () => {
const defaultState = {
visits: [],
loading: false,
error: false,
};
const state = reducer(defaultState, { type: 'unknown' });
expect(state).toEqual(defaultState);
});
});
describe('getShortUrlVisits', () => {
const buildApiClientMock = (returned) => ({
getShortUrlVisits: sinon.fake.returns(returned),
});
const dispatchMock = sinon.spy();
beforeEach(() => dispatchMock.resetHistory());
it('dispatches start and error when promise is rejected', async () => {
const ShlinkApiClient = buildApiClientMock(Promise.reject());
const expectedDispatchCalls = 2;
await _getShortUrlVisits(ShlinkApiClient, 'abc123')(dispatchMock);
const [ firstCallArg ] = dispatchMock.getCall(0).args;
const { type: firstCallType } = firstCallArg;
const [ secondCallArg ] = dispatchMock.getCall(1).args;
const { type: secondCallType } = secondCallArg;
expect(dispatchMock.callCount).toEqual(expectedDispatchCalls);
expect(ShlinkApiClient.getShortUrlVisits.callCount).toEqual(1);
expect(firstCallType).toEqual(GET_SHORT_URL_VISITS_START);
expect(secondCallType).toEqual(GET_SHORT_URL_VISITS_ERROR);
});
it('dispatches start and success when promise is resolved', async () => {
const resolvedVisits = [{}, {}];
const ShlinkApiClient = buildApiClientMock(Promise.resolve(resolvedVisits));
const expectedDispatchCalls = 2;
await _getShortUrlVisits(ShlinkApiClient, 'abc123')(dispatchMock);
const [ firstCallArg ] = dispatchMock.getCall(0).args;
const { type: firstCallType } = firstCallArg;
const [ secondCallArg ] = dispatchMock.getCall(1).args;
const { type: secondCallType, visits } = secondCallArg;
expect(dispatchMock.callCount).toEqual(expectedDispatchCalls);
expect(ShlinkApiClient.getShortUrlVisits.callCount).toEqual(1);
expect(firstCallType).toEqual(GET_SHORT_URL_VISITS_START);
expect(secondCallType).toEqual(GET_SHORT_URL_VISITS);
expect(visits).toEqual(resolvedVisits);
});
});
});

View file

@ -5691,6 +5691,10 @@ object-assign@4.1.1, object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^
version "4.1.1"
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
object-assign@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-3.0.0.tgz#9bedd5ca0897949bca47e7ff408062d549f587f2"
object-copy@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/object-copy/-/object-copy-0.1.0.tgz#7e7d858b781bd7c991a41ba975ed3812754e998c"
@ -6717,6 +6721,22 @@ rc@^1.0.1, rc@^1.1.6, rc@^1.2.7:
minimist "^1.2.0"
strip-json-comments "~2.0.1"
react-autosuggest@^9.4.0:
version "9.4.0"
resolved "https://registry.yarnpkg.com/react-autosuggest/-/react-autosuggest-9.4.0.tgz#3146bc9afa4f171bed067c542421edec5ca94294"
dependencies:
prop-types "^15.5.10"
react-autowhatever "^10.1.2"
shallow-equal "^1.0.0"
react-autowhatever@^10.1.2:
version "10.1.2"
resolved "https://registry.yarnpkg.com/react-autowhatever/-/react-autowhatever-10.1.2.tgz#200ffc41373b2189e3f6140ac7bdb82363a79fd3"
dependencies:
prop-types "^15.5.8"
react-themeable "^1.1.0"
section-iterator "^2.0.0"
react-chartjs-2@^2.7.4:
version "2.7.4"
resolved "https://registry.yarnpkg.com/react-chartjs-2/-/react-chartjs-2-2.7.4.tgz#e41ea4e81491dc78347111126a48e96ee57db1a6"
@ -6879,6 +6899,12 @@ react-test-renderer@^16.0.0-0:
prop-types "^15.6.0"
react-is "^16.4.2"
react-themeable@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/react-themeable/-/react-themeable-1.1.0.tgz#7d4466dd9b2b5fa75058727825e9f152ba379a0e"
dependencies:
object-assign "^3.0.0"
react-transition-group@^2.3.1:
version "2.4.0"
resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-2.4.0.tgz#1d9391fabfd82e016f26fabd1eec329dbd922b5a"
@ -7439,6 +7465,10 @@ scss-tokenizer@^0.2.3:
js-base64 "^2.1.8"
source-map "^0.4.2"
section-iterator@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/section-iterator/-/section-iterator-2.0.0.tgz#bf444d7afeeb94ad43c39ad2fb26151627ccba2a"
select-hose@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca"
@ -7586,6 +7616,10 @@ shallow-clone@^1.0.0:
kind-of "^5.0.0"
mixin-object "^2.0.1"
shallow-equal@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/shallow-equal/-/shallow-equal-1.0.0.tgz#508d1838b3de590ab8757b011b25e430900945f7"
shebang-command@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea"