mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2026-02-25 03:06:36 +00:00
Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e996a08c02 | ||
|
|
cc206c2843 | ||
|
|
591c3b76f9 | ||
|
|
07b1d5be2e | ||
|
|
f94b5b7c68 | ||
|
|
824a2facac | ||
|
|
4445c79540 | ||
|
|
85cb849ba5 | ||
|
|
53132fa900 | ||
|
|
c774a00610 | ||
|
|
1697ef9306 | ||
|
|
79a16a2c2c | ||
|
|
30192cb349 | ||
|
|
8d0c0bcc99 | ||
|
|
70ebb0362a | ||
|
|
cccf57a35a | ||
|
|
756e0c637e | ||
|
|
44541d5e97 | ||
|
|
655045c975 | ||
|
|
6784c30fa0 | ||
|
|
a65aadd4b2 | ||
|
|
3c12bc1434 | ||
|
|
822afa6db7 | ||
|
|
0c1c471714 | ||
|
|
b1b215e84a | ||
|
|
7a63f737ac | ||
|
|
4adf618026 | ||
|
|
f1c464fd3e | ||
|
|
99833b51a9 | ||
|
|
05936c52b3 | ||
|
|
368de2b4c7 | ||
|
|
6634fc41c5 | ||
|
|
4ad8e909d4 | ||
|
|
56ad6d9e1b | ||
|
|
169c69df2c | ||
|
|
0e8631ae9d | ||
|
|
812e391e34 | ||
|
|
4c1a044fd3 | ||
|
|
bb17dbe680 | ||
|
|
160de66b44 | ||
|
|
02b38cf84a | ||
|
|
2101dadfd7 | ||
|
|
782a5c1d35 | ||
|
|
de9f20b7a6 | ||
|
|
644caf7dfb | ||
|
|
f26deb51eb | ||
|
|
606397b542 | ||
|
|
bc8eaaaee4 | ||
|
|
7d665f3933 | ||
|
|
fc1af04243 | ||
|
|
f2d03203ae | ||
|
|
2d6dda3576 | ||
|
|
9b3bfe56bb | ||
|
|
5d5a2be498 | ||
|
|
64c1b56973 | ||
|
|
d37e7ca7ce | ||
|
|
eb0f219403 | ||
|
|
0c1656285b | ||
|
|
bbce53ade6 | ||
|
|
3e63734e2b | ||
|
|
f0b0fdf114 | ||
|
|
f84e3c5b60 | ||
|
|
28bd39f974 | ||
|
|
8b17ff88ed | ||
|
|
0d97c084c2 | ||
|
|
b7ca32ff8f | ||
|
|
b454810357 | ||
|
|
fd57d70a0b | ||
|
|
b0bce7498a | ||
|
|
1519f89318 | ||
|
|
0b089e24de |
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
17
.travis.yml
17
.travis.yml
@@ -1,5 +1,7 @@
|
||||
language: node_js
|
||||
|
||||
sudo: false
|
||||
|
||||
node_js:
|
||||
- "stable"
|
||||
|
||||
@@ -16,7 +18,18 @@ script:
|
||||
- yarn test:ci
|
||||
- yarn build # Make sure the app can be built without errors
|
||||
|
||||
after_script:
|
||||
after_success:
|
||||
- yarn ocular coverage/clover.xml
|
||||
|
||||
sudo: false
|
||||
# Before deploying, build dist file for current travis tag
|
||||
before_deploy:
|
||||
- yarn build ${TRAVIS_TAG#?}
|
||||
|
||||
deploy:
|
||||
provider: releases
|
||||
api_key:
|
||||
secure: jBvPwC7EAbViaNR83rwMSt5XQDK0Iu9rgvEMa7GoyShbHcvUCCPd73Tu9quNpKi6NKsDY3INHgtch3vgonjGNGDGJ+yDyIBzXcvsAX2x3UcHpRbgY12uiINVmQxBI1+OVQB016Nm+cKC/i5Z36K4EmDbYfo+MrKndngM6AjcQFTwI8EwniIMaQgg4gNes//K8NhP5u0c3gwG+Q6jEGnq6uH3kcRgh6/epIZYpQyxjqWqKwF77sgcYj+X2Nf6XxtB5neuCi301UKLoLx8G0skh/Lm6KAIO4s9iIhIFa3UpoF21Ka0TxLpd2JxalLryCnFGlWWE6lxC9Htmc0TeRowJQlGdJXCskJ37xT9MljKY0fwNMu06VS/FUgykuCv+jP3zQu51pKu7Ew7+WeNPjautoOTu54VkdGyHcf2ThBNEyJQuiEwAQe4u7yAxY6R5ovEdvHBSIg4w1E5/Mxy5SMTCUlIAv6H7QQ1X9Z/zJm9HH5KeKz5tsHvQ/RIdSpgHXq/tC8o4Yup/LCFucXfrgvy/8pJoO1UpOlmvm62974NFfo0EG5YWwv6brUqz3QXpMjb8sWqgjltYMYJX3J7WZ34rIc+zt4NAmfhqgczaOC4pUGCiJ8jX3rMWIaQRn1AJ+5V337jL9fNDpTHny4phQjHrMJ1e0HZuNp0Xb5Q8wgqDPM=
|
||||
file: "./dist/shlink-web-client_${TRAVIS_TAG#?}_dist.zip"
|
||||
skip_cleanup: true
|
||||
on:
|
||||
tags: true
|
||||
|
||||
82
CHANGELOG.md
82
CHANGELOG.md
@@ -1,5 +1,85 @@
|
||||
# CHANGELOG
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
|
||||
|
||||
## 1.2.0 - 2018-11-01
|
||||
|
||||
#### Added
|
||||
|
||||
* [#65](https://github.com/shlinkio/shlink-web-client/issues/65) Added sorting to both countries and referrers stats graphs.
|
||||
* [#14](https://github.com/shlinkio/shlink-web-client/issues/14) Documented how to build the project so that it can be served from a subpath.
|
||||
|
||||
#### Changed
|
||||
|
||||
* [#50](https://github.com/shlinkio/shlink-web-client/issues/50) Improved tests and increased code coverage.
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* [#66](https://github.com/shlinkio/shlink-web-client/issues/66) Fixed tooltips in graphs with too small bars not being displayed.
|
||||
|
||||
|
||||
## 1.1.1 - 2018-10-20
|
||||
|
||||
#### Added
|
||||
|
||||
* [#57](https://github.com/shlinkio/shlink-web-client/issues/57) Automated release generation in travis build.
|
||||
|
||||
#### Changed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* [#63](https://github.com/shlinkio/shlink-web-client/issues/63) Improved how bar charts are rendered in stats page, making them try to calculate a bigger height for big data sets.
|
||||
* [#56](https://github.com/shlinkio/shlink-web-client/issues/56) Ensured `ColorGenerator` matches keys in a case insensitive way.
|
||||
* [#53](https://github.com/shlinkio/shlink-web-client/issues/53) Fixed missing margin between date fields in visits page for mobile devices.
|
||||
|
||||
|
||||
## 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 +113,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.
|
||||
|
||||
29
README.md
29
README.md
@@ -1,10 +1,11 @@
|
||||
# shlink-web-client
|
||||
|
||||
[](https://travis-ci.org/shlinkio/shlink-web-client)
|
||||
[](https://scrutinizer-ci.com/gshlinkio/shlink-web-client/?branch=master)
|
||||
[](https://scrutinizer-ci.com/g/shlinkio/shlink-web-client/?branch=master)
|
||||
[](https://scrutinizer-ci.com/g/shlinkio/shlink-web-client/?branch=master)
|
||||
[](https://github.com/shlinkio/shlink-web-client/releases/latest)
|
||||
[](https://github.com/shlinkio/shlink-web-client/blob/master/LICENSE)
|
||||
[](https://acel.me/donate)
|
||||
|
||||
A ReactJS-based progressive web application for [Shlink](https://shlink.io).
|
||||
|
||||
@@ -20,10 +21,30 @@ There are three ways in which you can use this application.
|
||||
|
||||
Get the [latest release](https://github.com/shlinkio/shlink-web-client/releases/latest) and download the distributable zip file attached to it (`shlink-web-client_X.X.X_dist.zip`).
|
||||
|
||||
The package contains static files only, so just put it in a folder and serve it with the web server of your choice (just take into account that all the files are served using absolute paths, so you have to serve it from the root of your domain, not from a subpath).
|
||||
The package contains static files only, so just put it in a folder and serve it with the web server of your choice.
|
||||
|
||||
Provided dist files are configured to be served from the root of your domain. If you need to serve shlink-web-client from a subpath, you will have to build it yourself following [these simple steps](#serve-shlink-in-subpath).
|
||||
|
||||
* Use the official [docker image](https://hub.docker.com/r/shlinkio/shlink-web-client/)
|
||||
|
||||
If you want to deploy shlink-web-client in a container-based cluster (docker swarm, kubernetes, etc), just pick the image and do it.
|
||||
If you want to deploy shlink-web-client in a container-based cluster (kubernetes, docker swarm, etc), just pick the image and do it.
|
||||
|
||||
It's a lightweight [nginx:alpine image](https://hub.docker.com/r/library/nginx/) serving the assets on port 80.
|
||||
It's a lightweight [nginx:alpine](https://hub.docker.com/r/library/nginx/) image serving the assets on port 80.
|
||||
|
||||
## Serve project in subpath
|
||||
|
||||
Official distributable files have been build so that they are served from the root of a domain.
|
||||
|
||||
If you need to host shlink-web-client yourself and serve it from a subpath, follow these steps:
|
||||
|
||||
* Download [node](https://nodejs.org/en/download/package-manager/) 10.4 or later (if you don't have it yet).
|
||||
* Download [yarn](https://yarnpkg.com/en/docs/install) package manager.
|
||||
* Download shlink-web-client source files for the version you want to build.
|
||||
* For example, if you want to build `v1.0.1`, use this link https://github.com/shlinkio/shlink-web-client/archive/v1.0.1.zip
|
||||
* Replace the `v1.0.1` part in the link with the one of the version you want to build.
|
||||
* Decompress the file and `cd` into the resulting folder.
|
||||
* Install project dependencies by running `yarn install`.
|
||||
* Open the `package.json` file in the root of the project, locate the `homepage` property and replace the value (which should be an empty string) by the path from which you want to serve shlink-web-client.
|
||||
* For example: `"homepage": "/my-projects/shlink-web-client",`.
|
||||
* Build the distributable contents by running `yarn build`.
|
||||
* Once the command finishes, you will have a `build` folder with all the static assets you need to run shlink-web-client. Just place them wherever you want them to be served from.
|
||||
|
||||
33
jest.config.js
Normal file
33
jest.config.js
Normal 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',
|
||||
],
|
||||
};
|
||||
42
package.json
42
package.json
@@ -3,13 +3,15 @@
|
||||
"description": "A React-based progressive web application for shlink",
|
||||
"version": "1.0.0",
|
||||
"private": false,
|
||||
"homepage": "",
|
||||
"scripts": {
|
||||
"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",
|
||||
"serve:build": "yarn serve ./build",
|
||||
"build": "node scripts/build.js",
|
||||
"test": "node scripts/test.js --env=jsdom --colors",
|
||||
"test:ci": "yarn test --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=clover",
|
||||
@@ -30,7 +32,8 @@
|
||||
"prop-types": "^15.6.2",
|
||||
"qs": "^6.5.2",
|
||||
"ramda": "^0.25.0",
|
||||
"react": "^16.3.2",
|
||||
"react": "^16.6",
|
||||
"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 +104,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"
|
||||
|
||||
@@ -4,17 +4,19 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="theme-color" content="#4696e5">
|
||||
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is added to the
|
||||
homescreen on Android. See https://developers.google.com/web/fundamentals/engage-and-retain/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
|
||||
|
||||
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/icons/shlink-128.png">
|
||||
<link rel="apple-touch-icon" sizes="64x64" href="%PUBLIC_URL%/icons/shlink-64.png">
|
||||
<link rel="apple-touch-icon" sizes="32x32" href="%PUBLIC_URL%/icons/shlink-32.png">
|
||||
<link rel="apple-touch-icon" sizes="24x24" href="%PUBLIC_URL%/icons/shlink-24.png">
|
||||
<link rel="apple-touch-icon" sizes="16x16" href="%PUBLIC_URL%/icons/shlink-16.png">
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
|
||||
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
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';
|
||||
|
||||
export default class DateInput extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.inputRef = React.createRef();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { 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 || ''}`}
|
||||
dateFormat="YYYY-MM-DD"
|
||||
readOnly
|
||||
ref={this.inputRef}
|
||||
/>
|
||||
{showCalendarIcon && (
|
||||
<FontAwesomeIcon
|
||||
icon={calendarIcon}
|
||||
className="date-input-container__icon"
|
||||
onClick={() => this.inputRef.current.input.focus()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,12 @@ import PropTypes from 'prop-types';
|
||||
import { resetSelectedServer } from '../servers/reducers/selectedServer';
|
||||
import './Home.scss';
|
||||
|
||||
const propTypes = {
|
||||
resetSelectedServer: PropTypes.func,
|
||||
servers: PropTypes.object,
|
||||
};
|
||||
|
||||
export class HomeComponent extends React.Component {
|
||||
static propTypes = {
|
||||
resetSelectedServer: PropTypes.func,
|
||||
servers: PropTypes.object,
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
match: PropTypes.object,
|
||||
selectServer: PropTypes.func,
|
||||
location: PropTypes.object,
|
||||
selectedServer: serverType,
|
||||
};
|
||||
|
||||
export class MenuLayoutComponent extends React.Component {
|
||||
static propTypes = {
|
||||
match: PropTypes.object,
|
||||
selectServer: PropTypes.func,
|
||||
location: PropTypes.object,
|
||||
selectedServer: serverType,
|
||||
};
|
||||
|
||||
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
|
||||
|
||||
@@ -2,18 +2,18 @@ import React from 'react';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const propTypes = {
|
||||
location: PropTypes.object,
|
||||
window: PropTypes.shape({
|
||||
scrollTo: PropTypes.func,
|
||||
}),
|
||||
children: PropTypes.node,
|
||||
};
|
||||
const defaultProps = {
|
||||
window,
|
||||
};
|
||||
|
||||
export class ScrollToTopComponent extends React.Component {
|
||||
static propTypes = {
|
||||
location: PropTypes.object,
|
||||
window: PropTypes.shape({
|
||||
scrollTo: PropTypes.func,
|
||||
}),
|
||||
children: PropTypes.node,
|
||||
};
|
||||
static defaultProps = {
|
||||
window,
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { render } from 'react-dom';
|
||||
import { Provider } from 'react-redux';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { applyMiddleware, compose, createStore } from 'redux';
|
||||
import ReduxThunk from 'redux-thunk';
|
||||
import { homepage } from '../package.json';
|
||||
import App from './App';
|
||||
import './index.scss';
|
||||
import ScrollToTop from './common/ScrollToTop';
|
||||
@@ -20,9 +21,9 @@ const store = createStore(reducers, composeEnhancers(
|
||||
applyMiddleware(ReduxThunk)
|
||||
));
|
||||
|
||||
ReactDOM.render(
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<BrowserRouter>
|
||||
<BrowserRouter basename={homepage}>
|
||||
<ScrollToTop>
|
||||
<App />
|
||||
</ScrollToTop>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -18,12 +18,14 @@ body,
|
||||
background-color: $mainColor !important;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
.dropdown-item:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
.dropdown-item.active,
|
||||
.dropdown-item:active {
|
||||
@extend .bg-main;
|
||||
|
||||
.dropdown-item.active:not(:disabled),
|
||||
.dropdown-item:active:not(:disabled) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -5,12 +5,12 @@ import PropTypes from 'prop-types';
|
||||
import DeleteServerModal from './DeleteServerModal';
|
||||
import { serverType } from './prop-types';
|
||||
|
||||
const propTypes = {
|
||||
server: serverType,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
export default class DeleteServerButton extends React.Component {
|
||||
static propTypes = {
|
||||
server: serverType,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
state = { isModalOpen: false };
|
||||
|
||||
render() {
|
||||
@@ -37,5 +37,3 @@ export default class DeleteServerButton extends React.Component {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
DeleteServerButton.propTypes = propTypes;
|
||||
|
||||
@@ -9,20 +9,20 @@ import serversExporter from '../servers/services/ServersExporter';
|
||||
import { listServers } from './reducers/server';
|
||||
import { serverType } from './prop-types';
|
||||
|
||||
const defaultProps = {
|
||||
serversExporter,
|
||||
};
|
||||
const propTypes = {
|
||||
servers: PropTypes.object,
|
||||
serversExporter: PropTypes.shape({
|
||||
exportServers: PropTypes.func,
|
||||
}),
|
||||
selectedServer: serverType,
|
||||
selectServer: PropTypes.func,
|
||||
listServers: PropTypes.func,
|
||||
};
|
||||
|
||||
export class ServersDropdownComponent extends React.Component {
|
||||
static defaultProps = {
|
||||
serversExporter,
|
||||
};
|
||||
static propTypes = {
|
||||
servers: PropTypes.object,
|
||||
serversExporter: PropTypes.shape({
|
||||
exportServers: PropTypes.func,
|
||||
}),
|
||||
selectedServer: serverType,
|
||||
selectServer: PropTypes.func,
|
||||
listServers: PropTypes.func,
|
||||
};
|
||||
|
||||
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 }
|
||||
|
||||
@@ -7,18 +7,18 @@ import PropTypes from 'prop-types';
|
||||
import { createServers } from '../reducers/server';
|
||||
import serversImporter, { serversImporterType } from '../services/ServersImporter';
|
||||
|
||||
const defaultProps = {
|
||||
serversImporter,
|
||||
onImport: () => ({}),
|
||||
};
|
||||
const propTypes = {
|
||||
onImport: PropTypes.func,
|
||||
serversImporter: serversImporterType,
|
||||
createServers: PropTypes.func,
|
||||
fileRef: PropTypes.oneOfType([ PropTypes.object, PropTypes.node ]),
|
||||
};
|
||||
|
||||
export class ImportServersBtnComponent extends React.Component {
|
||||
static defaultProps = {
|
||||
serversImporter,
|
||||
onImport: () => ({}),
|
||||
};
|
||||
static propTypes = {
|
||||
onImport: PropTypes.func,
|
||||
serversImporter: serversImporterType,
|
||||
createServers: PropTypes.func,
|
||||
fileRef: PropTypes.oneOfType([ PropTypes.object, PropTypes.node ]),
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
@@ -5,12 +5,22 @@ import { assoc, dissoc, isNil, pick, pipe, replace, trim } from 'ramda';
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { Collapse } from 'reactstrap';
|
||||
import DateInput from '../common/DateInput';
|
||||
import TagsSelector from '../utils/TagsSelector';
|
||||
import * as PropTypes from 'prop-types';
|
||||
import DateInput from '../utils/DateInput';
|
||||
import TagsSelector from '../tags/helpers/TagsSelector';
|
||||
import CreateShortUrlResult from './helpers/CreateShortUrlResult';
|
||||
import { createShortUrl, resetCreateShortUrl } from './reducers/shortUrlCreationResult';
|
||||
import { createShortUrl, createShortUrlResultType, resetCreateShortUrl } from './reducers/shortUrlCreation';
|
||||
|
||||
const normalizeTag = pipe(trim, replace(/ /g, '-'));
|
||||
const formatDate = (date) => isNil(date) ? date : date.format();
|
||||
|
||||
export class CreateShortUrlComponent extends React.Component {
|
||||
static propTypes = {
|
||||
createShortUrl: PropTypes.func,
|
||||
shortUrlCreationResult: createShortUrlResultType,
|
||||
resetCreateShortUrl: PropTypes.func,
|
||||
};
|
||||
|
||||
state = {
|
||||
longUrl: '',
|
||||
tags: [],
|
||||
@@ -24,27 +34,31 @@ export class CreateShortUrlComponent extends React.Component {
|
||||
render() {
|
||||
const { createShortUrl, shortUrlCreationResult, resetCreateShortUrl } = this.props;
|
||||
|
||||
const changeTags = (tags) => this.setState({ tags: tags.map(pipe(trim, replace(/ /g, '-'))) });
|
||||
const changeTags = (tags) => this.setState({ tags: tags.map(normalizeTag) });
|
||||
const renderOptionalInput = (id, placeholder, type = 'text', props = {}) => (
|
||||
<input
|
||||
className="form-control"
|
||||
type={type}
|
||||
placeholder={placeholder}
|
||||
value={this.state[id]}
|
||||
onChange={(e) => this.setState({ [id]: e.target.value })}
|
||||
{...props}
|
||||
/>
|
||||
<div className="form-group">
|
||||
<input
|
||||
className="form-control"
|
||||
id={id}
|
||||
type={type}
|
||||
placeholder={placeholder}
|
||||
value={this.state[id]}
|
||||
onChange={(e) => this.setState({ [id]: e.target.value })}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
const createDateInput = (id, placeholder, props = {}) => (
|
||||
<DateInput
|
||||
selected={this.state[id]}
|
||||
placeholderText={placeholder}
|
||||
isClearable
|
||||
onChange={(date) => this.setState({ [id]: date })}
|
||||
{...props}
|
||||
/>
|
||||
const renderDateInput = (id, placeholder, props = {}) => (
|
||||
<div className="form-group">
|
||||
<DateInput
|
||||
selected={this.state[id]}
|
||||
placeholderText={placeholder}
|
||||
isClearable
|
||||
onChange={(date) => this.setState({ [id]: date })}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
const formatDate = (date) => isNil(date) ? date : date.format();
|
||||
const save = (e) => {
|
||||
e.preventDefault();
|
||||
createShortUrl(pipe(
|
||||
@@ -75,20 +89,12 @@ export class CreateShortUrlComponent extends React.Component {
|
||||
|
||||
<div className="row">
|
||||
<div className="col-sm-6">
|
||||
<div className="form-group">
|
||||
{renderOptionalInput('customSlug', 'Custom slug')}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
|
||||
</div>
|
||||
{renderOptionalInput('customSlug', 'Custom slug')}
|
||||
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
|
||||
</div>
|
||||
<div className="col-sm-6">
|
||||
<div className="form-group">
|
||||
{createDateInput('validSince', 'Enabled since...', { maxDate: this.state.validUntil })}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
{createDateInput('validUntil', 'Enabled until...', { minDate: this.state.validSince })}
|
||||
</div>
|
||||
{renderDateInput('validSince', 'Enabled since...', { maxDate: this.state.validUntil })}
|
||||
{renderDateInput('validUntil', 'Enabled until...', { minDate: this.state.validSince })}
|
||||
</div>
|
||||
</div>
|
||||
</Collapse>
|
||||
|
||||
@@ -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>
|
||||
));
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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:
|
||||
|
||||
{loading && <small>Loading...</small>}
|
||||
{!loading && renderCreated()}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
Long URL:
|
||||
|
||||
{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;
|
||||
@@ -1,17 +1,18 @@
|
||||
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, keys, values } from 'ramda';
|
||||
import { head, isEmpty, keys, pick, values } from 'ramda';
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
|
||||
import qs from 'qs';
|
||||
import PropTypes from 'prop-types';
|
||||
import { serverType } from '../servers/prop-types';
|
||||
import SortingDropdown from '../utils/SortingDropdown';
|
||||
import { determineOrderDir } from '../utils/utils';
|
||||
import { ShortUrlsRow } from './helpers/ShortUrlsRow';
|
||||
import { listShortUrls, shortUrlType } from './reducers/shortUrlsList';
|
||||
import { resetShortUrlParams, shortUrlsListParamsType } from './reducers/shortUrlsListParams';
|
||||
import './ShortUrlsList.scss';
|
||||
import { shortUrlsListParamsType } from './reducers/shortUrlsListParams';
|
||||
|
||||
const SORTABLE_FIELDS = {
|
||||
dateCreated: 'Created at',
|
||||
@@ -20,18 +21,19 @@ const SORTABLE_FIELDS = {
|
||||
visits: 'Visits',
|
||||
};
|
||||
|
||||
const propTypes = {
|
||||
listShortUrls: PropTypes.func,
|
||||
shortUrlsListParams: shortUrlsListParamsType,
|
||||
match: PropTypes.object,
|
||||
location: PropTypes.object,
|
||||
loading: PropTypes.bool,
|
||||
error: PropTypes.bool,
|
||||
shortUrlsList: PropTypes.arrayOf(shortUrlType),
|
||||
selectedServer: serverType,
|
||||
};
|
||||
|
||||
export class ShortUrlsListComponent extends React.Component {
|
||||
static propTypes = {
|
||||
listShortUrls: PropTypes.func,
|
||||
resetShortUrlParams: PropTypes.func,
|
||||
shortUrlsListParams: shortUrlsListParamsType,
|
||||
match: PropTypes.object,
|
||||
location: PropTypes.object,
|
||||
loading: PropTypes.bool,
|
||||
error: PropTypes.bool,
|
||||
shortUrlsList: PropTypes.arrayOf(shortUrlType),
|
||||
selectedServer: serverType,
|
||||
};
|
||||
|
||||
refreshList = (extraParams) => {
|
||||
const { listShortUrls, shortUrlsListParams } = this.props;
|
||||
|
||||
@@ -40,25 +42,13 @@ export class ShortUrlsListComponent 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';
|
||||
handleOrderBy = (orderField, orderDir) => {
|
||||
this.setState({ orderField, orderDir });
|
||||
this.refreshList({ orderBy: { [orderField]: orderDir } });
|
||||
};
|
||||
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') => {
|
||||
orderByColumn = (columnName) => () =>
|
||||
this.handleOrderBy(columnName, determineOrderDir(columnName, this.state.orderField, this.state.orderDir));
|
||||
renderOrderIcon = (field) => {
|
||||
if (this.state.orderField !== field) {
|
||||
return null;
|
||||
}
|
||||
@@ -66,7 +56,7 @@ export class ShortUrlsListComponent extends React.Component {
|
||||
return (
|
||||
<FontAwesomeIcon
|
||||
icon={this.state.orderDir === 'ASC' ? caretUpIcon : caretDownIcon}
|
||||
className={className}
|
||||
className="short-urls-list__header-icon"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -89,6 +79,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;
|
||||
|
||||
@@ -119,50 +115,37 @@ export class ShortUrlsListComponent 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 key={key} 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()}
|
||||
<div className="d-block d-md-none mb-3">
|
||||
<SortingDropdown
|
||||
items={SORTABLE_FIELDS}
|
||||
orderField={this.state.orderField}
|
||||
orderDir={this.state.orderDir}
|
||||
onChange={this.handleOrderBy}
|
||||
/>
|
||||
</div>
|
||||
<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')}
|
||||
onClick={this.orderByColumn('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')}
|
||||
onClick={this.orderByColumn('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')}
|
||||
onClick={this.orderByColumn('originalUrl')}
|
||||
>
|
||||
{this.renderOrderIcon('originalUrl')}
|
||||
Long URL
|
||||
@@ -170,7 +153,7 @@ export class ShortUrlsListComponent extends React.Component {
|
||||
<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')}
|
||||
onClick={this.orderByColumn('visits')}
|
||||
>
|
||||
<span className="nowrap">{this.renderOrderIcon('visits')} Visits</span>
|
||||
</th>
|
||||
@@ -186,11 +169,9 @@ export class ShortUrlsListComponent extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
ShortUrlsListComponent.propTypes = propTypes;
|
||||
|
||||
const ShortUrlsList = connect(
|
||||
pick([ 'selectedServer', 'shortUrlsListParams' ]),
|
||||
{ listShortUrls }
|
||||
{ listShortUrls, resetShortUrlParams }
|
||||
)(ShortUrlsListComponent);
|
||||
|
||||
export default ShortUrlsList;
|
||||
|
||||
@@ -14,15 +14,6 @@
|
||||
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%;
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
resetCreateShortUrl: PropTypes.func,
|
||||
error: PropTypes.bool,
|
||||
result: createShortUrlResultType,
|
||||
};
|
||||
|
||||
export default class CreateShortUrlResult extends React.Component {
|
||||
static propTypes = {
|
||||
resetCreateShortUrl: PropTypes.func,
|
||||
error: PropTypes.bool,
|
||||
result: createShortUrlResultType,
|
||||
};
|
||||
|
||||
state = { showCopyTooltip: false };
|
||||
|
||||
componentDidMount() {
|
||||
@@ -62,5 +62,3 @@ export default class CreateShortUrlResult extends React.Component {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CreateShortUrlResult.propTypes = propTypes;
|
||||
|
||||
103
src/short-urls/helpers/DeleteShortUrlModal.js
Normal file
103
src/short-urls/helpers/DeleteShortUrlModal.js
Normal file
@@ -0,0 +1,103 @@
|
||||
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';
|
||||
const hasThresholdError = shortUrlDeletion.error && shortUrlDeletion.errorData.error === THRESHOLD_REACHED;
|
||||
const hasErrorOtherThanThreshold = shortUrlDeletion.error && shortUrlDeletion.errorData.error !== THRESHOLD_REACHED;
|
||||
|
||||
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 })}
|
||||
/>
|
||||
|
||||
{hasThresholdError && (
|
||||
<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>
|
||||
)}
|
||||
{hasErrorOtherThanThreshold && (
|
||||
<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;
|
||||
@@ -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,18 +13,18 @@ import {
|
||||
import ExternalLink from '../../utils/ExternalLink';
|
||||
import { shortUrlType } from '../reducers/shortUrlsList';
|
||||
|
||||
const propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
toggle: PropTypes.func.isRequired,
|
||||
url: PropTypes.string.isRequired,
|
||||
shortUrl: shortUrlType.isRequired,
|
||||
shortUrlTags: shortUrlTagsType,
|
||||
editShortUrlTags: PropTypes.func,
|
||||
shortUrlTagsEdited: PropTypes.func,
|
||||
resetShortUrlsTags: PropTypes.func,
|
||||
};
|
||||
|
||||
export class EditTagsModalComponent extends React.Component {
|
||||
static propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
toggle: PropTypes.func.isRequired,
|
||||
url: PropTypes.string.isRequired,
|
||||
shortUrl: shortUrlType.isRequired,
|
||||
shortUrlTags: shortUrlTagsType,
|
||||
editShortUrlTags: PropTypes.func,
|
||||
shortUrlTagsEdited: PropTypes.func,
|
||||
resetShortUrlsTags: PropTypes.func,
|
||||
};
|
||||
|
||||
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 }
|
||||
|
||||
@@ -10,20 +10,20 @@ const propTypes = {
|
||||
isOpen: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default function PreviewModal({ url, toggle, isOpen }) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} toggle={toggle} size="lg">
|
||||
<ModalHeader toggle={toggle}>
|
||||
Preview for <ExternalLink href={url}>{url}</ExternalLink>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className="text-center">
|
||||
<p className="preview-modal__loader">Loading...</p>
|
||||
<img src={`${url}/preview`} className="preview-modal__img" alt="Preview" />
|
||||
</div>
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
const PreviewModal = ({ url, toggle, isOpen }) => (
|
||||
<Modal isOpen={isOpen} toggle={toggle} size="lg">
|
||||
<ModalHeader toggle={toggle}>
|
||||
Preview for <ExternalLink href={url}>{url}</ExternalLink>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className="text-center">
|
||||
<p className="preview-modal__loader">Loading...</p>
|
||||
<img src={`${url}/preview`} className="preview-modal__img" alt="Preview" />
|
||||
</div>
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
PreviewModal.propTypes = propTypes;
|
||||
|
||||
export default PreviewModal;
|
||||
|
||||
@@ -10,19 +10,19 @@ const propTypes = {
|
||||
isOpen: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default function QrCodeModal({ url, toggle, isOpen }) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} toggle={toggle} centered>
|
||||
<ModalHeader toggle={toggle}>
|
||||
QR code for <ExternalLink href={url}>{url}</ExternalLink>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className="text-center">
|
||||
<img src={`${url}/qr-code`} className="qr-code-modal__img" alt="QR code" />
|
||||
</div>
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
const QrCodeModal = ({ url, toggle, isOpen }) => (
|
||||
<Modal isOpen={isOpen} toggle={toggle} centered>
|
||||
<ModalHeader toggle={toggle}>
|
||||
QR code for <ExternalLink href={url}>{url}</ExternalLink>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className="text-center">
|
||||
<img src={`${url}/qr-code`} className="qr-code-modal__img" alt="QR code" />
|
||||
</div>
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
QrCodeModal.propTypes = propTypes;
|
||||
|
||||
export default QrCodeModal;
|
||||
|
||||
@@ -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 = {
|
||||
refreshList: PropTypes.func,
|
||||
shortUrlsListParams: shortUrlsListParamsType,
|
||||
selectedServer: serverType,
|
||||
shortUrl: shortUrlType,
|
||||
};
|
||||
|
||||
export class ShortUrlsRow extends React.Component {
|
||||
static propTypes = {
|
||||
refreshList: PropTypes.func,
|
||||
shortUrlsListParams: shortUrlsListParamsType,
|
||||
selectedServer: serverType,
|
||||
shortUrl: shortUrlType,
|
||||
};
|
||||
|
||||
state = { copiedToClipboard: false };
|
||||
|
||||
renderTags(tags) {
|
||||
@@ -73,5 +73,3 @@ export class ShortUrlsRow extends React.Component {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ShortUrlsRow.propTypes = propTypes;
|
||||
|
||||
@@ -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';
|
||||
@@ -14,41 +15,46 @@ import { serverType } from '../../servers/prop-types';
|
||||
import { shortUrlType } from '../reducers/shortUrlsList';
|
||||
import PreviewModal from './PreviewModal';
|
||||
import QrCodeModal from './QrCodeModal';
|
||||
import './ShortUrlsRowMenu.scss';
|
||||
import EditTagsModal from './EditTagsModal';
|
||||
|
||||
const propTypes = {
|
||||
completeShortUrl: PropTypes.string,
|
||||
onCopyToClipboard: PropTypes.func,
|
||||
selectedServer: serverType,
|
||||
shortUrl: shortUrlType,
|
||||
};
|
||||
import DeleteShortUrlModal from './DeleteShortUrlModal';
|
||||
import './ShortUrlsRowMenu.scss';
|
||||
|
||||
export class ShortUrlsRowMenu extends React.Component {
|
||||
static propTypes = {
|
||||
completeShortUrl: PropTypes.string,
|
||||
onCopyToClipboard: PropTypes.func,
|
||||
selectedServer: serverType,
|
||||
shortUrl: shortUrlType,
|
||||
};
|
||||
|
||||
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">
|
||||
<ButtonDropdown toggle={this.toggle} isOpen={this.state.isOpen}>
|
||||
<DropdownToggle size="sm" caret className="short-urls-row-menu__dropdown-toggle btn-outline-secondary">
|
||||
<FontAwesomeIcon icon={menuIcon} />
|
||||
</DropdownToggle>
|
||||
<DropdownMenu>
|
||||
<DropdownMenu right>
|
||||
<DropdownItem tag={Link} to={`/server/${serverId}/short-code/${shortUrl.shortCode}/visits`}>
|
||||
<FontAwesomeIcon icon={pieChartIcon} /> Visit Stats
|
||||
<FontAwesomeIcon icon={pieChartIcon} /> Visit stats
|
||||
</DropdownItem>
|
||||
|
||||
<DropdownItem onClick={toggleTags}>
|
||||
<FontAwesomeIcon icon={tagsIcon} /> 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} /> 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;
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
.short-urls-row-menu__dropdown-toggle:before {
|
||||
@import '../../utils/base';
|
||||
|
||||
.short-urls-row-menu__dropdown-toggle:after {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@ import PropTypes from 'prop-types';
|
||||
import shlinkApiClient from '../../api/ShlinkApiClient';
|
||||
|
||||
/* eslint-disable padding-line-between-statements, newline-after-var */
|
||||
const CREATE_SHORT_URL_START = 'shlink/createShortUrl/CREATE_SHORT_URL_START';
|
||||
const CREATE_SHORT_URL_ERROR = 'shlink/createShortUrl/CREATE_SHORT_URL_ERROR';
|
||||
const CREATE_SHORT_URL = 'shlink/createShortUrl/CREATE_SHORT_URL';
|
||||
const RESET_CREATE_SHORT_URL = 'shlink/createShortUrl/RESET_CREATE_SHORT_URL';
|
||||
export const CREATE_SHORT_URL_START = 'shlink/createShortUrl/CREATE_SHORT_URL_START';
|
||||
export const CREATE_SHORT_URL_ERROR = 'shlink/createShortUrl/CREATE_SHORT_URL_ERROR';
|
||||
export const CREATE_SHORT_URL = 'shlink/createShortUrl/CREATE_SHORT_URL';
|
||||
export const RESET_CREATE_SHORT_URL = 'shlink/createShortUrl/RESET_CREATE_SHORT_URL';
|
||||
/* eslint-enable padding-line-between-statements, newline-after-var */
|
||||
|
||||
export const createShortUrlResultType = PropTypes.shape({
|
||||
@@ -29,6 +29,7 @@ export default function reducer(state = defaultState, action) {
|
||||
return {
|
||||
...state,
|
||||
saving: true,
|
||||
error: false,
|
||||
};
|
||||
case CREATE_SHORT_URL_ERROR:
|
||||
return {
|
||||
76
src/short-urls/reducers/shortUrlDeletion.js
Normal file
76
src/short-urls/reducers/shortUrlDeletion.js
Normal 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 });
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
tag: PropTypes.string,
|
||||
currentServerId: PropTypes.string,
|
||||
colorGenerator: colorGeneratorType,
|
||||
};
|
||||
const defaultProps = {
|
||||
colorGenerator,
|
||||
};
|
||||
|
||||
export default class TagCard extends React.Component {
|
||||
static propTypes = {
|
||||
tag: PropTypes.string,
|
||||
currentServerId: PropTypes.string,
|
||||
};
|
||||
|
||||
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 = () =>
|
||||
@@ -32,43 +28,21 @@ export default class TagCard extends React.Component {
|
||||
return (
|
||||
<Card className="tag-card">
|
||||
<CardBody className="tag-card__body">
|
||||
<button
|
||||
className="btn btn-light btn-sm tag-card__btn tag-card__btn--last"
|
||||
onClick={toggleDelete}
|
||||
>
|
||||
<button className="btn btn-light btn-sm tag-card__btn tag-card__btn--last" onClick={toggleDelete}>
|
||||
<FontAwesomeIcon icon={deleteIcon} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-light btn-sm tag-card__btn"
|
||||
onClick={toggleEdit}
|
||||
>
|
||||
<button className="btn btn-light btn-sm tag-card__btn" onClick={toggleEdit}>
|
||||
<FontAwesomeIcon icon={editIcon} />
|
||||
</button>
|
||||
<h5 className="tag-card__tag-title">
|
||||
<div
|
||||
style={{ backgroundColor: colorGenerator.getColorForKey(tag) }}
|
||||
className="tag-card__tag-bullet"
|
||||
/>
|
||||
<Link to={`/server/${currentServerId}/list-short-urls/1?tag=${tag}`}>
|
||||
{tag}
|
||||
</Link>
|
||||
<TagBullet tag={tag} />
|
||||
<Link to={`/server/${currentServerId}/list-short-urls/1?tag=${tag}`}>{tag}</Link>
|
||||
</h5>
|
||||
</CardBody>
|
||||
|
||||
<DeleteTagConfirmModal
|
||||
tag={tag}
|
||||
toggle={toggleDelete}
|
||||
isOpen={this.state.isDeleteModalOpen}
|
||||
/>
|
||||
<EditTagModal
|
||||
tag={tag}
|
||||
toggle={toggleEdit}
|
||||
isOpen={this.state.isEditModalOpen}
|
||||
/>
|
||||
<DeleteTagConfirmModal tag={tag} toggle={toggleDelete} isOpen={this.state.isDeleteModalOpen} />
|
||||
<EditTagModal tag={tag} toggle={toggleEdit} isOpen={this.state.isEditModalOpen} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
TagCard.propTypes = propTypes;
|
||||
TagCard.defaultProps = defaultProps;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -4,25 +4,28 @@ 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 = {
|
||||
filterTags: PropTypes.func,
|
||||
listTags: PropTypes.func,
|
||||
tagsList: PropTypes.shape({
|
||||
loading: PropTypes.bool,
|
||||
}),
|
||||
match: PropTypes.object,
|
||||
};
|
||||
const TAGS_GROUPS_AMOUNT = 4;
|
||||
|
||||
export class TagsListComponent extends React.Component {
|
||||
componentDidMount() {
|
||||
const { listTags } = this.props;
|
||||
static propTypes = {
|
||||
filterTags: PropTypes.func,
|
||||
forceListTags: PropTypes.func,
|
||||
tagsList: PropTypes.shape({
|
||||
loading: PropTypes.bool,
|
||||
error: PropTypes.bool,
|
||||
filteredTags: PropTypes.arrayOf(PropTypes.string),
|
||||
}),
|
||||
match: PropTypes.object,
|
||||
};
|
||||
|
||||
listTags();
|
||||
componentDidMount() {
|
||||
const { forceListTags } = this.props;
|
||||
|
||||
forceListTags();
|
||||
}
|
||||
|
||||
renderContent() {
|
||||
@@ -46,7 +49,7 @@ export class TagsListComponent extends React.Component {
|
||||
return <MuttedMessage>No tags found</MuttedMessage>;
|
||||
}
|
||||
|
||||
const tagsGroups = splitEvery(ceil(tagsCount / TAGS_GROUP_SIZE), tagsList.filteredTags);
|
||||
const tagsGroups = splitEvery(ceil(tagsCount / TAGS_GROUPS_AMOUNT), tagsList.filteredTags);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
@@ -70,13 +73,9 @@ export class TagsListComponent extends React.Component {
|
||||
|
||||
return (
|
||||
<div className="shlink-container">
|
||||
{!this.props.tagsList.loading && (
|
||||
<SearchField
|
||||
className="mb-3"
|
||||
placeholder="Search tags..."
|
||||
onChange={filterTags}
|
||||
/>
|
||||
)}
|
||||
{!this.props.tagsList.loading &&
|
||||
<SearchField className="mb-3" placeholder="Search tags..." onChange={filterTags} />
|
||||
}
|
||||
<div className="row">
|
||||
{this.renderContent()}
|
||||
</div>
|
||||
@@ -85,8 +84,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;
|
||||
|
||||
@@ -5,16 +5,16 @@ import PropTypes from 'prop-types';
|
||||
import { pick } from 'ramda';
|
||||
import { deleteTag, tagDeleted, tagDeleteType } from '../reducers/tagDelete';
|
||||
|
||||
const 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 {
|
||||
static propTypes = {
|
||||
tag: PropTypes.string.isRequired,
|
||||
toggle: PropTypes.func.isRequired,
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
deleteTag: PropTypes.func,
|
||||
tagDelete: tagDeleteType,
|
||||
tagDeleted: PropTypes.func,
|
||||
};
|
||||
|
||||
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 }
|
||||
|
||||
@@ -10,23 +10,23 @@ import colorGenerator, { colorGeneratorType } from '../../utils/ColorGenerator';
|
||||
import { editTag, tagEdited } from '../reducers/tagEdit';
|
||||
import './EditTagModal.scss';
|
||||
|
||||
const propTypes = {
|
||||
tag: PropTypes.string,
|
||||
editTag: PropTypes.func,
|
||||
toggle: PropTypes.func,
|
||||
tagEdited: PropTypes.func,
|
||||
colorGenerator: colorGeneratorType,
|
||||
isOpen: PropTypes.bool,
|
||||
tagEdit: PropTypes.shape({
|
||||
error: PropTypes.bool,
|
||||
editing: PropTypes.bool,
|
||||
}),
|
||||
};
|
||||
const defaultProps = {
|
||||
colorGenerator,
|
||||
};
|
||||
|
||||
export class EditTagModalComponent extends React.Component {
|
||||
static propTypes = {
|
||||
tag: PropTypes.string,
|
||||
editTag: PropTypes.func,
|
||||
toggle: PropTypes.func,
|
||||
tagEdited: PropTypes.func,
|
||||
colorGenerator: colorGeneratorType,
|
||||
isOpen: PropTypes.bool,
|
||||
tagEdit: PropTypes.shape({
|
||||
error: PropTypes.bool,
|
||||
editing: PropTypes.bool,
|
||||
}),
|
||||
};
|
||||
static defaultProps = {
|
||||
colorGenerator,
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
@@ -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 = {
|
||||
24
src/tags/helpers/TagBullet.js
Normal file
24
src/tags/helpers/TagBullet.js
Normal 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;
|
||||
10
src/tags/helpers/TagBullet.scss
Normal file
10
src/tags/helpers/TagBullet.scss
Normal 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;
|
||||
}
|
||||
93
src/tags/helpers/TagsSelector.js
Normal file
93
src/tags/helpers/TagsSelector.js
Normal 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;
|
||||
16
src/tags/helpers/TagsSelector.scss
Normal file
16
src/tags/helpers/TagsSelector.scss
Normal 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;
|
||||
}
|
||||
@@ -3,9 +3,9 @@ import PropTypes from 'prop-types';
|
||||
import shlinkApiClient from '../../api/ShlinkApiClient';
|
||||
|
||||
/* eslint-disable padding-line-between-statements, newline-after-var */
|
||||
const DELETE_TAG_START = 'shlink/deleteTag/DELETE_TAG_START';
|
||||
const DELETE_TAG_ERROR = 'shlink/deleteTag/DELETE_TAG_ERROR';
|
||||
const DELETE_TAG = 'shlink/deleteTag/DELETE_TAG';
|
||||
export const DELETE_TAG_START = 'shlink/deleteTag/DELETE_TAG_START';
|
||||
export const DELETE_TAG_ERROR = 'shlink/deleteTag/DELETE_TAG_ERROR';
|
||||
export const DELETE_TAG = 'shlink/deleteTag/DELETE_TAG';
|
||||
export const TAG_DELETED = 'shlink/deleteTag/TAG_DELETED';
|
||||
/* eslint-enable padding-line-between-statements, newline-after-var */
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@ import shlinkApiClient from '../../api/ShlinkApiClient';
|
||||
import colorGenerator from '../../utils/ColorGenerator';
|
||||
|
||||
/* eslint-disable padding-line-between-statements, newline-after-var */
|
||||
const EDIT_TAG_START = 'shlink/editTag/EDIT_TAG_START';
|
||||
const EDIT_TAG_ERROR = 'shlink/editTag/EDIT_TAG_ERROR';
|
||||
const EDIT_TAG = 'shlink/editTag/EDIT_TAG';
|
||||
export const EDIT_TAG_START = 'shlink/editTag/EDIT_TAG_START';
|
||||
export const EDIT_TAG_ERROR = 'shlink/editTag/EDIT_TAG_ERROR';
|
||||
export const EDIT_TAG = 'shlink/editTag/EDIT_TAG';
|
||||
/* eslint-enable padding-line-between-statements, newline-after-var */
|
||||
|
||||
export const TAG_EDITED = 'shlink/editTag/TAG_EDITED';
|
||||
@@ -42,20 +42,19 @@ export default function reducer(state = defaultState, action) {
|
||||
}
|
||||
}
|
||||
|
||||
export const _editTag = (shlinkApiClient, colorGenerator, oldName, newName, color) =>
|
||||
async (dispatch) => {
|
||||
dispatch({ type: EDIT_TAG_START });
|
||||
export const _editTag = (shlinkApiClient, colorGenerator, oldName, newName, color) => async (dispatch) => {
|
||||
dispatch({ type: EDIT_TAG_START });
|
||||
|
||||
try {
|
||||
await shlinkApiClient.editTag(oldName, newName);
|
||||
colorGenerator.setColorForKey(newName, color);
|
||||
dispatch({ type: EDIT_TAG, oldName, newName });
|
||||
} catch (e) {
|
||||
dispatch({ type: EDIT_TAG_ERROR });
|
||||
try {
|
||||
await shlinkApiClient.editTag(oldName, newName);
|
||||
colorGenerator.setColorForKey(newName, color);
|
||||
dispatch({ type: EDIT_TAG, oldName, newName });
|
||||
} catch (e) {
|
||||
dispatch({ type: EDIT_TAG_ERROR });
|
||||
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
export const editTag = curry(_editTag)(shlinkApiClient, colorGenerator);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -11,6 +11,7 @@ const buildRandomColor = () =>
|
||||
.map(() => letters[floor(random() * letters.length)])
|
||||
.join('')
|
||||
}`;
|
||||
const normalizeKey = (key) => key.toLowerCase().trim();
|
||||
|
||||
export class ColorGenerator {
|
||||
constructor(storage) {
|
||||
@@ -19,21 +20,24 @@ export class ColorGenerator {
|
||||
}
|
||||
|
||||
getColorForKey = (key) => {
|
||||
const color = this.colors[key];
|
||||
const normalizedKey = normalizeKey(key);
|
||||
const color = this.colors[normalizedKey];
|
||||
|
||||
// If a color has not been set yet, generate a random one and save it
|
||||
if (!color) {
|
||||
this.setColorForKey(key, buildRandomColor());
|
||||
|
||||
return this.getColorForKey(key);
|
||||
return this.setColorForKey(normalizedKey, buildRandomColor());
|
||||
}
|
||||
|
||||
return color;
|
||||
};
|
||||
|
||||
setColorForKey = (key, color) => {
|
||||
this.colors[key] = color;
|
||||
const normalizedKey = normalizeKey(key);
|
||||
|
||||
this.colors[normalizedKey] = color;
|
||||
this.storage.set('colors', this.colors);
|
||||
|
||||
return color;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
42
src/utils/DateInput.js
Normal file
42
src/utils/DateInput.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { isNil } from 'ramda';
|
||||
import DatePicker from 'react-datepicker';
|
||||
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
|
||||
import calendarIcon from '@fortawesome/fontawesome-free-regular/faCalendarAlt';
|
||||
import * as PropTypes from 'prop-types';
|
||||
import './DateInput.scss';
|
||||
|
||||
const propTypes = {
|
||||
className: PropTypes.string,
|
||||
isClearable: PropTypes.bool,
|
||||
selected: PropTypes.oneOfType([ PropTypes.string, PropTypes.object ]),
|
||||
ref: PropTypes.object,
|
||||
};
|
||||
|
||||
const DateInput = (props) => {
|
||||
const { className, isClearable, selected, ref = React.createRef() } = props;
|
||||
const showCalendarIcon = !isClearable || isNil(selected);
|
||||
|
||||
return (
|
||||
<div className="date-input-container">
|
||||
<DatePicker
|
||||
{...props}
|
||||
className={`date-input-container__input form-control ${className || ''}`}
|
||||
dateFormat="YYYY-MM-DD"
|
||||
readOnly
|
||||
ref={ref}
|
||||
/>
|
||||
{showCalendarIcon && (
|
||||
<FontAwesomeIcon
|
||||
icon={calendarIcon}
|
||||
className="date-input-container__icon"
|
||||
onClick={() => ref.current.input.focus()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
DateInput.propTypes = propTypes;
|
||||
|
||||
export default DateInput;
|
||||
@@ -1,5 +1,5 @@
|
||||
@import '../utils/mixins/vertical-align';
|
||||
@import '../utils/base';
|
||||
@import './mixins/vertical-align';
|
||||
@import './base';
|
||||
|
||||
.date-input-container {
|
||||
position: relative;
|
||||
@@ -11,7 +11,7 @@ export default function ExternalLink(props) {
|
||||
|
||||
return (
|
||||
<a target="_blank" rel="noopener noreferrer" href={href} {...rest}>
|
||||
{children}
|
||||
{children || href}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,17 +6,18 @@ import classnames from 'classnames';
|
||||
import './SearchField.scss';
|
||||
|
||||
const DEFAULT_SEARCH_INTERVAL = 500;
|
||||
const propTypes = {
|
||||
onChange: PropTypes.func.isRequired,
|
||||
className: PropTypes.string,
|
||||
placeholder: PropTypes.string,
|
||||
};
|
||||
const defaultProps = {
|
||||
className: '',
|
||||
placeholder: 'Search...',
|
||||
};
|
||||
|
||||
export default class SearchField extends React.Component {
|
||||
static propTypes = {
|
||||
onChange: PropTypes.func.isRequired,
|
||||
className: PropTypes.string,
|
||||
placeholder: PropTypes.string,
|
||||
};
|
||||
static defaultProps = {
|
||||
className: '',
|
||||
placeholder: 'Search...',
|
||||
};
|
||||
|
||||
state = { showClearBtn: false, searchTerm: '' };
|
||||
timer = null;
|
||||
|
||||
@@ -64,6 +65,3 @@ export default class SearchField extends React.Component {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SearchField.propTypes = propTypes;
|
||||
SearchField.defaultProps = defaultProps;
|
||||
|
||||
68
src/utils/SortingDropdown.js
Normal file
68
src/utils/SortingDropdown.js
Normal file
@@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
import { UncontrolledDropdown, DropdownToggle, DropdownMenu, DropdownItem } from 'reactstrap';
|
||||
import { toPairs } from 'ramda';
|
||||
import PropTypes from 'prop-types';
|
||||
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
|
||||
import sortAscIcon from '@fortawesome/fontawesome-free-solid/faSortAmountUp';
|
||||
import sortDescIcon from '@fortawesome/fontawesome-free-solid/faSortAmountDown';
|
||||
import classNames from 'classnames';
|
||||
import { determineOrderDir } from '../utils/utils';
|
||||
import './SortingDropdown.scss';
|
||||
|
||||
const propTypes = {
|
||||
items: PropTypes.object.isRequired,
|
||||
orderField: PropTypes.string,
|
||||
orderDir: PropTypes.oneOf([ 'ASC', 'DESC' ]),
|
||||
onChange: PropTypes.func.isRequired,
|
||||
isButton: PropTypes.bool,
|
||||
right: PropTypes.bool,
|
||||
};
|
||||
const defaultProps = {
|
||||
isButton: true,
|
||||
right: false,
|
||||
};
|
||||
|
||||
const SortingDropdown = ({ items, orderField, orderDir, onChange, isButton, right }) => {
|
||||
const handleItemClick = (fieldKey) => () => {
|
||||
const newOrderDir = determineOrderDir(fieldKey, orderField, orderDir);
|
||||
|
||||
onChange(newOrderDir ? fieldKey : undefined, newOrderDir);
|
||||
};
|
||||
|
||||
return (
|
||||
<UncontrolledDropdown>
|
||||
<DropdownToggle
|
||||
caret
|
||||
color={isButton ? 'secondary' : 'link'}
|
||||
className={classNames({ 'btn-block': isButton, 'btn-sm sorting-dropdown__paddingless': !isButton })}
|
||||
>
|
||||
Order by
|
||||
</DropdownToggle>
|
||||
<DropdownMenu
|
||||
right={right}
|
||||
className={classNames('sorting-dropdown__menu', { 'sorting-dropdown__menu--link': !isButton })}
|
||||
>
|
||||
{toPairs(items).map(([ fieldKey, fieldValue ]) => (
|
||||
<DropdownItem key={fieldKey} active={orderField === fieldKey} onClick={handleItemClick(fieldKey)}>
|
||||
{fieldValue}
|
||||
{orderField === fieldKey && (
|
||||
<FontAwesomeIcon
|
||||
icon={orderDir === 'ASC' ? sortAscIcon : sortDescIcon}
|
||||
className="sorting-dropdown__sort-icon"
|
||||
/>
|
||||
)}
|
||||
</DropdownItem>
|
||||
))}
|
||||
<DropdownItem divider />
|
||||
<DropdownItem disabled={!orderField} onClick={() => onChange()}>
|
||||
<i>Clear selection</i>
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</UncontrolledDropdown>
|
||||
);
|
||||
};
|
||||
|
||||
SortingDropdown.propTypes = propTypes;
|
||||
SortingDropdown.defaultProps = defaultProps;
|
||||
|
||||
export default SortingDropdown;
|
||||
16
src/utils/SortingDropdown.scss
Normal file
16
src/utils/SortingDropdown.scss
Normal file
@@ -0,0 +1,16 @@
|
||||
.sorting-dropdown__menu {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sorting-dropdown__menu--link.sorting-dropdown__menu--link {
|
||||
min-width: 11rem;
|
||||
}
|
||||
|
||||
.sorting-dropdown__sort-icon {
|
||||
margin: 3.5px 0 0;
|
||||
float: right;
|
||||
}
|
||||
|
||||
.sorting-dropdown__paddingless.sorting-dropdown__paddingless {
|
||||
padding: 0;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -4,3 +4,16 @@ export const stateFlagTimeout = (setState, flagName, initialValue = true, delay
|
||||
setState({ [flagName]: initialValue });
|
||||
setTimeout(() => setState({ [flagName]: !initialValue }), delay);
|
||||
};
|
||||
|
||||
export const determineOrderDir = (clickedField, currentOrderField, currentOrderDir) => {
|
||||
if (currentOrderField !== clickedField) {
|
||||
return 'ASC';
|
||||
}
|
||||
|
||||
const newOrderMap = {
|
||||
ASC: 'DESC',
|
||||
DESC: undefined,
|
||||
};
|
||||
|
||||
return currentOrderDir ? newOrderMap[currentOrderDir] : 'ASC';
|
||||
};
|
||||
|
||||
97
src/visits/GraphCard.js
Normal file
97
src/visits/GraphCard.js
Normal file
@@ -0,0 +1,97 @@
|
||||
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,
|
||||
children: PropTypes.node,
|
||||
isBarChart: PropTypes.bool,
|
||||
stats: PropTypes.object,
|
||||
matchMedia: PropTypes.func,
|
||||
};
|
||||
const defaultProps = {
|
||||
matchMedia: global.window ? global.window.matchMedia : () => {},
|
||||
};
|
||||
|
||||
const generateGraphData = (title, isBarChart, labels, data) => ({
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
title,
|
||||
data,
|
||||
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 determineGraphAspectRatio = (barsCount, isBarChart, matchMedia) => {
|
||||
const determineAspectRationModifier = () => {
|
||||
switch (true) {
|
||||
case matchMedia('(max-width: 1200px)').matches:
|
||||
return 1.5; // eslint-disable-line no-magic-numbers
|
||||
case matchMedia('(max-width: 992px)').matches:
|
||||
return 1.75; // eslint-disable-line no-magic-numbers
|
||||
case matchMedia('(max-width: 768px)').matches:
|
||||
return 2; // eslint-disable-line no-magic-numbers
|
||||
case matchMedia('(max-width: 576px)').matches:
|
||||
return 2.25; // eslint-disable-line no-magic-numbers
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
|
||||
const MAX_BARS_WITHOUT_HEIGHT = 20;
|
||||
const DEFAULT_ASPECT_RATION = 2;
|
||||
const shouldCalculateAspectRatio = isBarChart && barsCount > MAX_BARS_WITHOUT_HEIGHT;
|
||||
|
||||
return shouldCalculateAspectRatio
|
||||
? MAX_BARS_WITHOUT_HEIGHT / determineAspectRationModifier() * DEFAULT_ASPECT_RATION / barsCount
|
||||
: DEFAULT_ASPECT_RATION;
|
||||
};
|
||||
|
||||
const renderGraph = (title, isBarChart, stats, matchMedia) => {
|
||||
const Component = isBarChart ? HorizontalBar : Doughnut;
|
||||
const labels = keys(stats);
|
||||
const data = values(stats);
|
||||
const aspectRatio = determineGraphAspectRatio(labels.length, isBarChart, matchMedia);
|
||||
const options = {
|
||||
aspectRatio,
|
||||
legend: isBarChart ? { display: false } : { position: 'right' },
|
||||
scales: isBarChart ? {
|
||||
xAxes: [
|
||||
{
|
||||
ticks: { beginAtZero: true },
|
||||
},
|
||||
],
|
||||
} : null,
|
||||
tooltips: {
|
||||
intersect: !isBarChart,
|
||||
},
|
||||
};
|
||||
|
||||
return <Component data={generateGraphData(title, isBarChart, labels, data)} options={options} height={null} />;
|
||||
};
|
||||
|
||||
const GraphCard = ({ title, children, isBarChart, stats, matchMedia }) => (
|
||||
<Card className="mt-4">
|
||||
<CardHeader className="graph-card__header">{children || title}</CardHeader>
|
||||
<CardBody>{renderGraph(title, isBarChart, stats, matchMedia)}</CardBody>
|
||||
</Card>
|
||||
);
|
||||
|
||||
GraphCard.propTypes = propTypes;
|
||||
GraphCard.defaultProps = defaultProps;
|
||||
|
||||
export default GraphCard;
|
||||
163
src/visits/ShortUrlVisits.js
Normal file
163
src/visits/ShortUrlVisits.js
Normal file
@@ -0,0 +1,163 @@
|
||||
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 '../utils/DateInput';
|
||||
import MutedMessage from '../utils/MuttedMessage';
|
||||
import SortableBarGraph from './SortableBarGraph';
|
||||
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">
|
||||
<SortableBarGraph
|
||||
stats={processCountriesStats(visits)}
|
||||
title="Countries"
|
||||
sortingItems={{
|
||||
name: 'Country name',
|
||||
amount: 'Visits amount',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<SortableBarGraph
|
||||
stats={processReferrersStats(visits)}
|
||||
title="Referrers"
|
||||
sortingItems={{
|
||||
name: 'Referrer name',
|
||||
amount: 'Visits amount',
|
||||
}}
|
||||
/>
|
||||
</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
|
||||
className="short-url-visits__date-input"
|
||||
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;
|
||||
47
src/visits/SortableBarGraph.js
Normal file
47
src/visits/SortableBarGraph.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { fromPairs, head, keys, prop, reverse, sortBy, toPairs } from 'ramda';
|
||||
import SortingDropdown from '../utils/SortingDropdown';
|
||||
import GraphCard from './GraphCard';
|
||||
|
||||
export default class SortableBarGraph extends React.Component {
|
||||
static propTypes = {
|
||||
stats: PropTypes.object.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
sortingItems: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
orderField: undefined,
|
||||
orderDir: undefined,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { stats, sortingItems, title } = this.props;
|
||||
const sortStats = () => {
|
||||
if (!this.state.orderField) {
|
||||
return stats;
|
||||
}
|
||||
|
||||
const sortedPairs = sortBy(prop(this.state.orderField === head(keys(sortingItems)) ? 0 : 1), toPairs(stats));
|
||||
|
||||
return fromPairs(this.state.orderDir === 'ASC' ? sortedPairs : reverse(sortedPairs));
|
||||
};
|
||||
|
||||
return (
|
||||
<GraphCard stats={sortStats()} isBarChart>
|
||||
{title}
|
||||
<div className="float-right">
|
||||
<SortingDropdown
|
||||
isButton={false}
|
||||
right
|
||||
orderField={this.state.orderField}
|
||||
orderDir={this.state.orderDir}
|
||||
items={sortingItems}
|
||||
onChange={(orderField, orderDir) => this.setState({ orderField, orderDir })}
|
||||
/>
|
||||
</div>
|
||||
</GraphCard>
|
||||
);
|
||||
}
|
||||
}
|
||||
55
src/visits/VisitsHeader.js
Normal file
55
src/visits/VisitsHeader.js
Normal 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:
|
||||
|
||||
{renderDate()}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
Long URL:
|
||||
|
||||
{loading && <small>Loading...</small>}
|
||||
{!loading && <ExternalLink href={longLink} />}
|
||||
</div>
|
||||
</Card>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
VisitsHeader.propTypes = propTypes;
|
||||
3
src/visits/VisitsHeader.scss
Normal file
3
src/visits/VisitsHeader.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
.visits-header__created-at {
|
||||
cursor: default;
|
||||
}
|
||||
60
src/visits/reducers/shortUrlDetail.js
Normal file
60
src/visits/reducers/shortUrlDetail.js
Normal 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);
|
||||
@@ -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);
|
||||
@@ -9,9 +9,7 @@ describe('<AsideMenu />', () => {
|
||||
beforeEach(() => {
|
||||
wrapped = shallow(<AsideMenu selectedServer={{ id: 'abc123' }} />);
|
||||
});
|
||||
afterEach(() => {
|
||||
wrapped.unmount();
|
||||
});
|
||||
afterEach(() => wrapped.unmount());
|
||||
|
||||
it('contains links to different sections', () => {
|
||||
const links = wrapped.find(NavLink);
|
||||
|
||||
66
test/short-urls/CreateShortUrl.test.js
Normal file
66
test/short-urls/CreateShortUrl.test.js
Normal file
@@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import moment from 'moment';
|
||||
import * as sinon from 'sinon';
|
||||
import { identity } from 'ramda';
|
||||
import { CreateShortUrlComponent as CreateShortUrl } from '../../src/short-urls/CreateShortUrl';
|
||||
import TagsSelector from '../../src/tags/helpers/TagsSelector';
|
||||
import DateInput from '../../src/utils/DateInput';
|
||||
|
||||
describe('<CreateShortUrl />', () => {
|
||||
let wrapper;
|
||||
const shortUrlCreationResult = {
|
||||
loading: false,
|
||||
};
|
||||
const createShortUrl = sinon.spy();
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = shallow(
|
||||
<CreateShortUrl shortUrlCreationResult={shortUrlCreationResult} createShortUrl={createShortUrl} />
|
||||
);
|
||||
});
|
||||
afterEach(() => {
|
||||
wrapper.unmount();
|
||||
createShortUrl.resetHistory();
|
||||
});
|
||||
|
||||
it('saves short URL with data set in form controls', (done) => {
|
||||
const validSince = moment('2017-01-01');
|
||||
const validUntil = moment('2017-01-06');
|
||||
|
||||
const urlInput = wrapper.find('.form-control-lg');
|
||||
const tagsInput = wrapper.find(TagsSelector);
|
||||
const customSlugInput = wrapper.find('#customSlug');
|
||||
const maxVisitsInput = wrapper.find('#maxVisits');
|
||||
const dateInputs = wrapper.find(DateInput);
|
||||
const validSinceInput = dateInputs.at(0);
|
||||
const validUntilInput = dateInputs.at(1);
|
||||
|
||||
urlInput.simulate('change', { target: { value: 'https://long-domain.com/foo/bar' } });
|
||||
tagsInput.simulate('change', [ 'tag_foo', 'tag_bar' ]);
|
||||
customSlugInput.simulate('change', { target: { value: 'my-slug' } });
|
||||
maxVisitsInput.simulate('change', { target: { value: '20' } });
|
||||
validSinceInput.simulate('change', validSince);
|
||||
validUntilInput.simulate('change', validUntil);
|
||||
|
||||
setImmediate(() => {
|
||||
const form = wrapper.find('form');
|
||||
|
||||
form.simulate('submit', { preventDefault: identity });
|
||||
expect(createShortUrl.callCount).toEqual(1);
|
||||
expect(createShortUrl.getCall(0).args).toEqual(
|
||||
[
|
||||
{
|
||||
longUrl: 'https://long-domain.com/foo/bar',
|
||||
tags: [ 'tag_foo', 'tag_bar' ],
|
||||
customSlug: 'my-slug',
|
||||
validSince: validSince.format(),
|
||||
validUntil: validUntil.format(),
|
||||
maxVisits: '20',
|
||||
},
|
||||
]
|
||||
);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
28
test/short-urls/Paginator.test.js
Normal file
28
test/short-urls/Paginator.test.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { PaginationItem } from 'reactstrap';
|
||||
import Paginator from '../../src/short-urls/Paginator';
|
||||
|
||||
describe('<Paginator />', () => {
|
||||
let wrapper;
|
||||
|
||||
afterEach(() => 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);
|
||||
});
|
||||
});
|
||||
59
test/short-urls/SearchBar.test.js
Normal file
59
test/short-urls/SearchBar.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
26
test/short-urls/ShortUrls.test.js
Normal file
26
test/short-urls/ShortUrls.test.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { ShortUrlsComponent as ShortUrls } from '../../src/short-urls/ShortUrls';
|
||||
import Paginator from '../../src/short-urls/Paginator';
|
||||
import ShortUrlsList from '../../src/short-urls/ShortUrlsList';
|
||||
import SearchBar from '../../src/short-urls/SearchBar';
|
||||
|
||||
describe('<ShortUrlsList />', () => {
|
||||
let wrapper;
|
||||
|
||||
beforeEach(() => {
|
||||
const params = {
|
||||
serverId: '1',
|
||||
page: '1',
|
||||
};
|
||||
|
||||
wrapper = shallow(<ShortUrls match={{ params }} shortUrlsList={{ data: [] }} />);
|
||||
});
|
||||
afterEach(() => wrapper.unmount());
|
||||
|
||||
it('wraps a SearchBar, ShortUrlsList as Paginator', () => {
|
||||
expect(wrapper.find(SearchBar)).toHaveLength(1);
|
||||
expect(wrapper.find(ShortUrlsList)).toHaveLength(1);
|
||||
expect(wrapper.find(Paginator)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
48
test/short-urls/helpers/CreateShortUrlResult.test.js
Normal file
48
test/short-urls/helpers/CreateShortUrlResult.test.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { identity } from 'ramda';
|
||||
import { CopyToClipboard } from 'react-copy-to-clipboard';
|
||||
import { Tooltip } from 'reactstrap';
|
||||
import CreateShortUrlResult from '../../../src/short-urls/helpers/CreateShortUrlResult';
|
||||
|
||||
describe('<CreateShortUrlResult />', () => {
|
||||
let wrapper;
|
||||
const createWrapper = (result, error = false) => {
|
||||
wrapper = shallow(<CreateShortUrlResult resetCreateShortUrl={identity} result={result} error={error} />);
|
||||
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
afterEach(() => wrapper && wrapper.unmount());
|
||||
|
||||
it('renders an error when error is true', () => {
|
||||
const wrapper = createWrapper({}, true);
|
||||
const errorCard = wrapper.find('.bg-danger');
|
||||
|
||||
expect(errorCard).toHaveLength(1);
|
||||
expect(errorCard.html()).toContain('An error occurred while creating the URL :(');
|
||||
});
|
||||
|
||||
it('renders nothing when no result is provided', () => {
|
||||
const wrapper = createWrapper();
|
||||
|
||||
expect(wrapper.html()).toBeNull();
|
||||
});
|
||||
|
||||
it('renders a result message when result is provided', () => {
|
||||
const wrapper = createWrapper({ shortUrl: 'https://doma.in/abc123' });
|
||||
|
||||
expect(wrapper.html()).toContain('<b>Great!</b> The short URL is <b>https://doma.in/abc123</b>');
|
||||
expect(wrapper.find(CopyToClipboard)).toHaveLength(1);
|
||||
expect(wrapper.find(Tooltip)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('Shows tooltip when copy to clipboard button is clicked', () => {
|
||||
const wrapper = createWrapper({ shortUrl: 'https://doma.in/abc123' });
|
||||
const copyBtn = wrapper.find(CopyToClipboard);
|
||||
|
||||
expect(wrapper.state('showCopyTooltip')).toEqual(false);
|
||||
copyBtn.simulate('copy');
|
||||
expect(wrapper.state('showCopyTooltip')).toEqual(true);
|
||||
});
|
||||
});
|
||||
115
test/short-urls/helpers/DeleteShortUrlModal.test.js
Normal file
115
test/short-urls/helpers/DeleteShortUrlModal.test.js
Normal file
@@ -0,0 +1,115 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { identity } from 'ramda';
|
||||
import * as sinon from 'sinon';
|
||||
import { DeleteShortUrlModalComponent as DeleteShortUrlModal } from '../../../src/short-urls/helpers/DeleteShortUrlModal';
|
||||
|
||||
describe('<DeleteShortUrlModal />', () => {
|
||||
let wrapper;
|
||||
const shortUrl = {
|
||||
tags: [],
|
||||
shortCode: 'abc123',
|
||||
originalUrl: 'https://long-domain.com/foo/bar',
|
||||
};
|
||||
const deleteShortUrl = sinon.fake.returns(Promise.resolve());
|
||||
const createWrapper = (shortUrlDeletion) => {
|
||||
wrapper = shallow(
|
||||
<DeleteShortUrlModal
|
||||
isOpen
|
||||
shortUrl={shortUrl}
|
||||
shortUrlDeletion={shortUrlDeletion}
|
||||
toggle={identity}
|
||||
deleteShortUrl={deleteShortUrl}
|
||||
resetDeleteShortUrl={identity}
|
||||
shortUrlDeleted={identity}
|
||||
/>
|
||||
);
|
||||
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
wrapper && wrapper.unmount();
|
||||
deleteShortUrl.resetHistory();
|
||||
});
|
||||
|
||||
it('shows threshold error message when threshold error occurs', () => {
|
||||
const wrapper = createWrapper({
|
||||
loading: false,
|
||||
error: true,
|
||||
shortCode: 'abc123',
|
||||
errorData: { error: 'INVALID_SHORTCODE_DELETION' },
|
||||
});
|
||||
const warning = wrapper.find('.bg-warning');
|
||||
|
||||
expect(warning).toHaveLength(1);
|
||||
expect(warning.html()).toContain('This short URL has received too many visits and therefore, it cannot be deleted');
|
||||
});
|
||||
|
||||
it('shows generic error when non-threshold error occurs', () => {
|
||||
const wrapper = createWrapper({
|
||||
loading: false,
|
||||
error: true,
|
||||
shortCode: 'abc123',
|
||||
errorData: { error: 'OTHER_ERROR' },
|
||||
});
|
||||
const error = wrapper.find('.bg-danger');
|
||||
|
||||
expect(error).toHaveLength(1);
|
||||
expect(error.html()).toContain('Something went wrong while deleting the URL :(');
|
||||
});
|
||||
|
||||
it('disables submit button when loading', () => {
|
||||
const wrapper = createWrapper({
|
||||
loading: true,
|
||||
error: false,
|
||||
shortCode: 'abc123',
|
||||
errorData: {},
|
||||
});
|
||||
const submit = wrapper.find('.btn-danger');
|
||||
|
||||
expect(submit).toHaveLength(1);
|
||||
expect(submit.prop('disabled')).toEqual(true);
|
||||
expect(submit.html()).toContain('Deleting...');
|
||||
});
|
||||
|
||||
it('enables submit button when proper short code is provided', (done) => {
|
||||
const shortCode = 'abc123';
|
||||
const wrapper = createWrapper({
|
||||
loading: false,
|
||||
error: false,
|
||||
shortCode,
|
||||
errorData: {},
|
||||
});
|
||||
const input = wrapper.find('.form-control');
|
||||
|
||||
input.simulate('change', { target: { value: shortCode } });
|
||||
setImmediate(() => {
|
||||
const submit = wrapper.find('.btn-danger');
|
||||
|
||||
expect(submit.prop('disabled')).toEqual(false);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('tries to delete short URL when form is submit', (done) => {
|
||||
const shortCode = 'abc123';
|
||||
const wrapper = createWrapper({
|
||||
loading: false,
|
||||
error: false,
|
||||
shortCode,
|
||||
errorData: {},
|
||||
});
|
||||
const input = wrapper.find('.form-control');
|
||||
|
||||
input.simulate('change', { target: { value: shortCode } });
|
||||
setImmediate(() => {
|
||||
const form = wrapper.find('form');
|
||||
|
||||
expect(deleteShortUrl.callCount).toEqual(0);
|
||||
form.simulate('submit', { preventDefault: identity });
|
||||
expect(deleteShortUrl.callCount).toEqual(1);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
28
test/short-urls/helpers/PreviewModal.test.js
Normal file
28
test/short-urls/helpers/PreviewModal.test.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import PreviewModal from '../../../src/short-urls/helpers/PreviewModal';
|
||||
import ExternalLink from '../../../src/utils/ExternalLink';
|
||||
|
||||
describe('<PreviewModal />', () => {
|
||||
let wrapper;
|
||||
const url = 'https://doma.in/abc123';
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = shallow(<PreviewModal url={url} />);
|
||||
});
|
||||
afterEach(() => wrapper.unmount());
|
||||
|
||||
it('shows an external link to the URL', () => {
|
||||
const externalLink = wrapper.find(ExternalLink);
|
||||
|
||||
expect(externalLink).toHaveLength(1);
|
||||
expect(externalLink.prop('href')).toEqual(url);
|
||||
});
|
||||
|
||||
it('displays an image with the preview of the URL', () => {
|
||||
const img = wrapper.find('img');
|
||||
|
||||
expect(img).toHaveLength(1);
|
||||
expect(img.prop('src')).toEqual(`${url}/preview`);
|
||||
});
|
||||
});
|
||||
28
test/short-urls/helpers/QrCodeModal.test.js
Normal file
28
test/short-urls/helpers/QrCodeModal.test.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import QrCodeModal from '../../../src/short-urls/helpers/QrCodeModal';
|
||||
import ExternalLink from '../../../src/utils/ExternalLink';
|
||||
|
||||
describe('<QrCodeModal />', () => {
|
||||
let wrapper;
|
||||
const url = 'https://doma.in/abc123';
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = shallow(<QrCodeModal url={url} />);
|
||||
});
|
||||
afterEach(() => wrapper.unmount());
|
||||
|
||||
it('shows an external link to the URL', () => {
|
||||
const externalLink = wrapper.find(ExternalLink);
|
||||
|
||||
expect(externalLink).toHaveLength(1);
|
||||
expect(externalLink.prop('href')).toEqual(url);
|
||||
});
|
||||
|
||||
it('displays an image with the QR code of the URL', () => {
|
||||
const img = wrapper.find('img');
|
||||
|
||||
expect(img).toHaveLength(1);
|
||||
expect(img.prop('src')).toEqual(`${url}/qr-code`);
|
||||
});
|
||||
});
|
||||
94
test/short-urls/reducers/shortUrlCreation.test.js
Normal file
94
test/short-urls/reducers/shortUrlCreation.test.js
Normal file
@@ -0,0 +1,94 @@
|
||||
import * as sinon from 'sinon';
|
||||
import reducer, {
|
||||
CREATE_SHORT_URL_START,
|
||||
CREATE_SHORT_URL_ERROR,
|
||||
CREATE_SHORT_URL,
|
||||
RESET_CREATE_SHORT_URL,
|
||||
_createShortUrl,
|
||||
resetCreateShortUrl,
|
||||
} from '../../../src/short-urls/reducers/shortUrlCreation';
|
||||
|
||||
describe('shortUrlCreationReducer', () => {
|
||||
describe('reducer', () => {
|
||||
it('returns loading on CREATE_SHORT_URL_START', () => {
|
||||
expect(reducer({}, { type: CREATE_SHORT_URL_START })).toEqual({
|
||||
saving: true,
|
||||
error: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error on CREATE_SHORT_URL_ERROR', () => {
|
||||
expect(reducer({}, { type: CREATE_SHORT_URL_ERROR })).toEqual({
|
||||
saving: false,
|
||||
error: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns result on CREATE_SHORT_URL', () => {
|
||||
expect(reducer({}, { type: CREATE_SHORT_URL, result: 'foo' })).toEqual({
|
||||
saving: false,
|
||||
error: false,
|
||||
result: 'foo',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns default state on RESET_CREATE_SHORT_URL', () => {
|
||||
expect(reducer({}, { type: RESET_CREATE_SHORT_URL })).toEqual({
|
||||
result: null,
|
||||
saving: false,
|
||||
error: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns provided state on unknown action', () =>
|
||||
expect(reducer({}, { type: 'unknown' })).toEqual({}));
|
||||
});
|
||||
|
||||
describe('resetCreateShortUrl', () => {
|
||||
it('returns proper action', () =>
|
||||
expect(resetCreateShortUrl()).toEqual({ type: RESET_CREATE_SHORT_URL }));
|
||||
});
|
||||
|
||||
describe('createShortUrl', () => {
|
||||
const createApiClientMock = (result) => ({
|
||||
createShortUrl: sinon.fake.returns(result),
|
||||
});
|
||||
const dispatch = sinon.spy();
|
||||
|
||||
afterEach(() => dispatch.resetHistory());
|
||||
|
||||
it('calls API on success', async () => {
|
||||
const expectedDispatchCalls = 2;
|
||||
const result = 'foo';
|
||||
const apiClientMock = createApiClientMock(Promise.resolve(result));
|
||||
const dispatchable = _createShortUrl(apiClientMock, {});
|
||||
|
||||
await dispatchable(dispatch);
|
||||
|
||||
expect(apiClientMock.createShortUrl.callCount).toEqual(1);
|
||||
|
||||
expect(dispatch.callCount).toEqual(expectedDispatchCalls);
|
||||
expect(dispatch.getCall(0).args).toEqual([{ type: CREATE_SHORT_URL_START }]);
|
||||
expect(dispatch.getCall(1).args).toEqual([{ type: CREATE_SHORT_URL, result }]);
|
||||
});
|
||||
|
||||
it('throws on error', async () => {
|
||||
const expectedDispatchCalls = 2;
|
||||
const error = 'Error';
|
||||
const apiClientMock = createApiClientMock(Promise.reject(error));
|
||||
const dispatchable = _createShortUrl(apiClientMock, {});
|
||||
|
||||
try {
|
||||
await dispatchable(dispatch);
|
||||
} catch (e) {
|
||||
expect(e).toEqual(error);
|
||||
}
|
||||
|
||||
expect(apiClientMock.createShortUrl.callCount).toEqual(1);
|
||||
|
||||
expect(dispatch.callCount).toEqual(expectedDispatchCalls);
|
||||
expect(dispatch.getCall(0).args).toEqual([{ type: CREATE_SHORT_URL_START }]);
|
||||
expect(dispatch.getCall(1).args).toEqual([{ type: CREATE_SHORT_URL_ERROR }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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', () =>
|
||||
|
||||
46
test/tags/TagCard.test.js
Normal file
46
test/tags/TagCard.test.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { Link } from 'react-router-dom';
|
||||
import TagCard from '../../src/tags/TagCard';
|
||||
import TagBullet from '../../src/tags/helpers/TagBullet';
|
||||
|
||||
describe('<TagCard />', () => {
|
||||
let wrapper;
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = shallow(<TagCard tag="ssr" currentServerId="1" />);
|
||||
});
|
||||
afterEach(() => wrapper.unmount());
|
||||
|
||||
it('shows a TagBullet and a link to the list filtering by the tag', () => {
|
||||
const link = wrapper.find(Link);
|
||||
const bullet = wrapper.find(TagBullet);
|
||||
|
||||
expect(link.prop('to')).toEqual('/server/1/list-short-urls/1?tag=ssr');
|
||||
expect(bullet.prop('tag')).toEqual('ssr');
|
||||
});
|
||||
|
||||
it('displays delete modal when delete btn is clicked', (done) => {
|
||||
const delBtn = wrapper.find('.tag-card__btn').at(0);
|
||||
|
||||
expect(wrapper.state('isDeleteModalOpen')).toEqual(false);
|
||||
delBtn.simulate('click');
|
||||
|
||||
setImmediate(() => {
|
||||
expect(wrapper.state('isDeleteModalOpen')).toEqual(true);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('displays edit modal when edit btn is clicked', (done) => {
|
||||
const editBtn = wrapper.find('.tag-card__btn').at(1);
|
||||
|
||||
expect(wrapper.state('isEditModalOpen')).toEqual(false);
|
||||
editBtn.simulate('click');
|
||||
|
||||
setImmediate(() => {
|
||||
expect(wrapper.state('isEditModalOpen')).toEqual(true);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
76
test/tags/TagsList.test.js
Normal file
76
test/tags/TagsList.test.js
Normal file
@@ -0,0 +1,76 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { identity, range } from 'ramda';
|
||||
import * as sinon from 'sinon';
|
||||
import { TagsListComponent as TagsList } from '../../src/tags/TagsList';
|
||||
import MuttedMessage from '../../src/utils/MuttedMessage';
|
||||
import TagCard from '../../src/tags/TagCard';
|
||||
import SearchField from '../../src/utils/SearchField';
|
||||
|
||||
describe('<TagsList />', () => {
|
||||
let wrapper;
|
||||
const filterTags = sinon.spy();
|
||||
const createWrapper = (tagsList) => {
|
||||
const params = { serverId: '1' };
|
||||
|
||||
wrapper = shallow(
|
||||
<TagsList forceListTags={identity} filterTags={filterTags} match={{ params }} tagsList={tagsList} />
|
||||
);
|
||||
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
wrapper && wrapper.unmount();
|
||||
filterTags.resetHistory();
|
||||
});
|
||||
|
||||
it('shows a loading message when tags are being loaded', () => {
|
||||
const wrapper = createWrapper({ loading: true });
|
||||
const loadingMsg = wrapper.find(MuttedMessage);
|
||||
|
||||
expect(loadingMsg).toHaveLength(1);
|
||||
expect(loadingMsg.html()).toContain('Loading...');
|
||||
});
|
||||
|
||||
it('shows an error when tags failed to be loaded', () => {
|
||||
const wrapper = createWrapper({ error: true });
|
||||
const errorMsg = wrapper.find('.bg-danger');
|
||||
|
||||
expect(errorMsg).toHaveLength(1);
|
||||
expect(errorMsg.html()).toContain('Error loading tags :(');
|
||||
});
|
||||
|
||||
it('shows a message when the list of tags is empty', () => {
|
||||
const wrapper = createWrapper({ filteredTags: [] });
|
||||
const msg = wrapper.find(MuttedMessage);
|
||||
|
||||
expect(msg).toHaveLength(1);
|
||||
expect(msg.html()).toContain('No tags found');
|
||||
});
|
||||
|
||||
it('renders the proper amount of groups and cards based on the amount of tags', () => {
|
||||
const amountOfTags = 10;
|
||||
const amountOfGroups = 4;
|
||||
const wrapper = createWrapper({ filteredTags: range(0, amountOfTags).map((i) => `tag_${i}`) });
|
||||
const cards = wrapper.find(TagCard);
|
||||
const groups = wrapper.find('.col-md-6');
|
||||
|
||||
expect(cards).toHaveLength(amountOfTags);
|
||||
expect(groups).toHaveLength(amountOfGroups);
|
||||
});
|
||||
|
||||
it('triggers tags filtering when search field changes', (done) => {
|
||||
const wrapper = createWrapper({ filteredTags: [] });
|
||||
const searchField = wrapper.find(SearchField);
|
||||
|
||||
expect(searchField).toHaveLength(1);
|
||||
expect(filterTags.callCount).toEqual(0);
|
||||
searchField.simulate('change');
|
||||
|
||||
setImmediate(() => {
|
||||
expect(filterTags.callCount).toEqual(1);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
91
test/tags/reducers/tagDelete.test.js
Normal file
91
test/tags/reducers/tagDelete.test.js
Normal file
@@ -0,0 +1,91 @@
|
||||
import * as sinon from 'sinon';
|
||||
import reducer, {
|
||||
DELETE_TAG_START,
|
||||
DELETE_TAG_ERROR,
|
||||
DELETE_TAG,
|
||||
TAG_DELETED,
|
||||
tagDeleted,
|
||||
_deleteTag,
|
||||
} from '../../../src/tags/reducers/tagDelete';
|
||||
|
||||
describe('tagDeleteReducer', () => {
|
||||
describe('reducer', () => {
|
||||
it('returns loading on DELETE_TAG_START', () => {
|
||||
expect(reducer({}, { type: DELETE_TAG_START })).toEqual({
|
||||
deleting: true,
|
||||
error: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error on DELETE_TAG_ERROR', () => {
|
||||
expect(reducer({}, { type: DELETE_TAG_ERROR })).toEqual({
|
||||
deleting: false,
|
||||
error: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns tag names on DELETE_TAG', () => {
|
||||
expect(reducer({}, { type: DELETE_TAG })).toEqual({
|
||||
deleting: false,
|
||||
error: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns provided state on unknown action', () =>
|
||||
expect(reducer({}, { type: 'unknown' })).toEqual({}));
|
||||
});
|
||||
|
||||
describe('tagDeleted', () => {
|
||||
it('returns action based on provided params', () =>
|
||||
expect(tagDeleted('foo')).toEqual({
|
||||
type: TAG_DELETED,
|
||||
tag: 'foo',
|
||||
}));
|
||||
});
|
||||
|
||||
describe('deleteTag', () => {
|
||||
const createApiClientMock = (result) => ({
|
||||
deleteTags: sinon.fake.returns(result),
|
||||
});
|
||||
const dispatch = sinon.spy();
|
||||
|
||||
afterEach(() => dispatch.resetHistory());
|
||||
|
||||
it('calls API on success', async () => {
|
||||
const expectedDispatchCalls = 2;
|
||||
const tag = 'foo';
|
||||
const apiClientMock = createApiClientMock(Promise.resolve());
|
||||
const dispatchable = _deleteTag(apiClientMock, tag);
|
||||
|
||||
await dispatchable(dispatch);
|
||||
|
||||
expect(apiClientMock.deleteTags.callCount).toEqual(1);
|
||||
expect(apiClientMock.deleteTags.getCall(0).args).toEqual([[ tag ]]);
|
||||
|
||||
expect(dispatch.callCount).toEqual(expectedDispatchCalls);
|
||||
expect(dispatch.getCall(0).args).toEqual([{ type: DELETE_TAG_START }]);
|
||||
expect(dispatch.getCall(1).args).toEqual([{ type: DELETE_TAG }]);
|
||||
});
|
||||
|
||||
it('throws on error', async () => {
|
||||
const expectedDispatchCalls = 2;
|
||||
const error = 'Error';
|
||||
const tag = 'foo';
|
||||
const apiClientMock = createApiClientMock(Promise.reject(error));
|
||||
const dispatchable = _deleteTag(apiClientMock, tag);
|
||||
|
||||
try {
|
||||
await dispatchable(dispatch);
|
||||
} catch (e) {
|
||||
expect(e).toEqual(error);
|
||||
}
|
||||
|
||||
expect(apiClientMock.deleteTags.callCount).toEqual(1);
|
||||
expect(apiClientMock.deleteTags.getCall(0).args).toEqual([[ tag ]]);
|
||||
|
||||
expect(dispatch.callCount).toEqual(expectedDispatchCalls);
|
||||
expect(dispatch.getCall(0).args).toEqual([{ type: DELETE_TAG_START }]);
|
||||
expect(dispatch.getCall(1).args).toEqual([{ type: DELETE_TAG_ERROR }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
110
test/tags/reducers/tagEdit.test.js
Normal file
110
test/tags/reducers/tagEdit.test.js
Normal file
@@ -0,0 +1,110 @@
|
||||
import * as sinon from 'sinon';
|
||||
import reducer, {
|
||||
EDIT_TAG_START,
|
||||
EDIT_TAG_ERROR,
|
||||
EDIT_TAG,
|
||||
TAG_EDITED,
|
||||
tagEdited,
|
||||
_editTag,
|
||||
} from '../../../src/tags/reducers/tagEdit';
|
||||
|
||||
describe('tagEditReducer', () => {
|
||||
describe('reducer', () => {
|
||||
it('returns loading on EDIT_TAG_START', () => {
|
||||
expect(reducer({}, { type: EDIT_TAG_START })).toEqual({
|
||||
editing: true,
|
||||
error: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error on EDIT_TAG_ERROR', () => {
|
||||
expect(reducer({}, { type: EDIT_TAG_ERROR })).toEqual({
|
||||
editing: false,
|
||||
error: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns tag names on EDIT_TAG', () => {
|
||||
expect(reducer({}, { type: EDIT_TAG, oldName: 'foo', newName: 'bar' })).toEqual({
|
||||
editing: false,
|
||||
error: false,
|
||||
oldName: 'foo',
|
||||
newName: 'bar',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns provided state on unknown action', () =>
|
||||
expect(reducer({}, { type: 'unknown' })).toEqual({}));
|
||||
});
|
||||
|
||||
describe('tagEdited', () => {
|
||||
it('returns action based on provided params', () =>
|
||||
expect(tagEdited('foo', 'bar', '#ff0000')).toEqual({
|
||||
type: TAG_EDITED,
|
||||
oldName: 'foo',
|
||||
newName: 'bar',
|
||||
color: '#ff0000',
|
||||
}));
|
||||
});
|
||||
|
||||
describe('editTag', () => {
|
||||
const createApiClientMock = (result) => ({
|
||||
editTag: sinon.fake.returns(result),
|
||||
});
|
||||
const colorGenerator = {
|
||||
setColorForKey: sinon.spy(),
|
||||
};
|
||||
const dispatch = sinon.spy();
|
||||
|
||||
afterEach(() => {
|
||||
colorGenerator.setColorForKey.resetHistory();
|
||||
dispatch.resetHistory();
|
||||
});
|
||||
|
||||
it('calls API on success', async () => {
|
||||
const expectedDispatchCalls = 2;
|
||||
const oldName = 'foo';
|
||||
const newName = 'bar';
|
||||
const color = '#ff0000';
|
||||
const apiClientMock = createApiClientMock(Promise.resolve());
|
||||
const dispatchable = _editTag(apiClientMock, colorGenerator, oldName, newName, color);
|
||||
|
||||
await dispatchable(dispatch);
|
||||
|
||||
expect(apiClientMock.editTag.callCount).toEqual(1);
|
||||
expect(apiClientMock.editTag.getCall(0).args).toEqual([ oldName, newName ]);
|
||||
|
||||
expect(colorGenerator.setColorForKey.callCount).toEqual(1);
|
||||
expect(colorGenerator.setColorForKey.getCall(0).args).toEqual([ newName, color ]);
|
||||
|
||||
expect(dispatch.callCount).toEqual(expectedDispatchCalls);
|
||||
expect(dispatch.getCall(0).args).toEqual([{ type: EDIT_TAG_START }]);
|
||||
expect(dispatch.getCall(1).args).toEqual([{ type: EDIT_TAG, oldName, newName }]);
|
||||
});
|
||||
|
||||
it('throws on error', async () => {
|
||||
const expectedDispatchCalls = 2;
|
||||
const error = 'Error';
|
||||
const oldName = 'foo';
|
||||
const newName = 'bar';
|
||||
const color = '#ff0000';
|
||||
const apiClientMock = createApiClientMock(Promise.reject(error));
|
||||
const dispatchable = _editTag(apiClientMock, colorGenerator, oldName, newName, color);
|
||||
|
||||
try {
|
||||
await dispatchable(dispatch);
|
||||
} catch (e) {
|
||||
expect(e).toEqual(error);
|
||||
}
|
||||
|
||||
expect(apiClientMock.editTag.callCount).toEqual(1);
|
||||
expect(apiClientMock.editTag.getCall(0).args).toEqual([ oldName, newName ]);
|
||||
|
||||
expect(colorGenerator.setColorForKey.callCount).toEqual(0);
|
||||
|
||||
expect(dispatch.callCount).toEqual(expectedDispatchCalls);
|
||||
expect(dispatch.getCall(0).args).toEqual([{ type: EDIT_TAG_START }]);
|
||||
expect(dispatch.getCall(1).args).toEqual([{ type: EDIT_TAG_ERROR }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
48
test/utils/ColorGenerator.test.js
Normal file
48
test/utils/ColorGenerator.test.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import * as sinon from 'sinon';
|
||||
import { ColorGenerator } from '../../src/utils/ColorGenerator';
|
||||
|
||||
describe('ColorGenerator', () => {
|
||||
let colorGenerator;
|
||||
const storageMock = {
|
||||
set: sinon.fake(),
|
||||
get: sinon.fake.returns(undefined),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
storageMock.set.resetHistory();
|
||||
storageMock.get.resetHistory();
|
||||
|
||||
colorGenerator = new ColorGenerator(storageMock);
|
||||
});
|
||||
|
||||
it('sets a color in the storage and makes it available after that', () => {
|
||||
const color = '#ff0000';
|
||||
|
||||
colorGenerator.setColorForKey('foo', color);
|
||||
|
||||
expect(colorGenerator.getColorForKey('foo')).toEqual(color);
|
||||
expect(storageMock.set.callCount).toEqual(1);
|
||||
expect(storageMock.get.callCount).toEqual(1);
|
||||
});
|
||||
|
||||
it('generates a random color when none is available for requested key', () => {
|
||||
expect(colorGenerator.getColorForKey('bar')).toEqual(expect.stringMatching(/^#(?:[0-9a-fA-F]{6})$/));
|
||||
expect(storageMock.set.callCount).toEqual(1);
|
||||
expect(storageMock.get.callCount).toEqual(1);
|
||||
});
|
||||
|
||||
it('trims and lower cases keys before trying to match', () => {
|
||||
const color = '#ff0000';
|
||||
|
||||
colorGenerator.setColorForKey('foo', color);
|
||||
|
||||
expect(colorGenerator.getColorForKey(' foo')).toEqual(color);
|
||||
expect(colorGenerator.getColorForKey('foO')).toEqual(color);
|
||||
expect(colorGenerator.getColorForKey('FoO')).toEqual(color);
|
||||
expect(colorGenerator.getColorForKey('FOO')).toEqual(color);
|
||||
expect(colorGenerator.getColorForKey('FOO ')).toEqual(color);
|
||||
expect(colorGenerator.getColorForKey(' FoO ')).toEqual(color);
|
||||
expect(storageMock.set.callCount).toEqual(1);
|
||||
expect(storageMock.get.callCount).toEqual(1);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
|
||||
import moment from 'moment';
|
||||
import DateInput from '../../src/common/DateInput';
|
||||
import DateInput from '../../src/utils/DateInput';
|
||||
|
||||
describe('<DateInput />', () => {
|
||||
let wrapped;
|
||||
@@ -13,12 +13,7 @@ describe('<DateInput />', () => {
|
||||
return wrapped;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
if (wrapped !== undefined) {
|
||||
wrapped.unmount();
|
||||
wrapped = undefined;
|
||||
}
|
||||
});
|
||||
afterEach(() => wrapped && wrapped.unmount());
|
||||
|
||||
it('wrapps a DatePicker', () => {
|
||||
wrapped = createComponent();
|
||||
78
test/utils/SortingDropdown.test.js
Normal file
78
test/utils/SortingDropdown.test.js
Normal file
@@ -0,0 +1,78 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { DropdownItem } from 'reactstrap';
|
||||
import { identity, values } from 'ramda';
|
||||
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
|
||||
import caretDownIcon from '@fortawesome/fontawesome-free-solid/faSortAmountDown';
|
||||
import * as sinon from 'sinon';
|
||||
import SortingDropdown from '../../src/utils/SortingDropdown';
|
||||
|
||||
describe('<SortingDropdown />', () => {
|
||||
let wrapper;
|
||||
const items = {
|
||||
foo: 'Foo',
|
||||
bar: 'Bar',
|
||||
baz: 'Hello World',
|
||||
};
|
||||
const createWrapper = (props) => {
|
||||
wrapper = shallow(<SortingDropdown items={items} onChange={identity} {...props} />);
|
||||
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
afterEach(() => wrapper && wrapper.unmount());
|
||||
|
||||
it('properly renders provided list of items', () => {
|
||||
const wrapper = createWrapper();
|
||||
const dropdownItems = wrapper.find(DropdownItem);
|
||||
const secondIndex = 2;
|
||||
const clearItemsCount = 2;
|
||||
|
||||
expect(dropdownItems).toHaveLength(values(items).length + clearItemsCount);
|
||||
expect(dropdownItems.at(0).html()).toContain('Foo');
|
||||
expect(dropdownItems.at(1).html()).toContain('Bar');
|
||||
expect(dropdownItems.at(secondIndex).html()).toContain('Hello World');
|
||||
});
|
||||
|
||||
it('properly marks selected field as active with proper icon', () => {
|
||||
const wrapper = createWrapper({ orderField: 'bar', orderDir: 'DESC' });
|
||||
const activeItem = wrapper.find('DropdownItem[active=true]');
|
||||
const activeItemIcon = activeItem.first().find(FontAwesomeIcon);
|
||||
|
||||
expect(activeItem).toHaveLength(1);
|
||||
expect(activeItemIcon.prop('icon')).toEqual(caretDownIcon);
|
||||
});
|
||||
|
||||
it('triggers change function when item is clicked and no order field was provided', () => {
|
||||
const onChange = sinon.spy();
|
||||
const wrapper = createWrapper({ onChange });
|
||||
const firstItem = wrapper.find(DropdownItem).first();
|
||||
|
||||
firstItem.simulate('click');
|
||||
|
||||
expect(onChange.callCount).toEqual(1);
|
||||
expect(onChange.calledWith('foo', 'ASC')).toEqual(true);
|
||||
});
|
||||
|
||||
it('triggers change function when item is clicked and an order field was provided', () => {
|
||||
const onChange = sinon.spy();
|
||||
const wrapper = createWrapper({ onChange, orderField: 'baz', orderDir: 'ASC' });
|
||||
const firstItem = wrapper.find(DropdownItem).first();
|
||||
|
||||
firstItem.simulate('click');
|
||||
|
||||
expect(onChange.callCount).toEqual(1);
|
||||
expect(onChange.calledWith('foo', 'ASC')).toEqual(true);
|
||||
});
|
||||
|
||||
it('updates order dir when already selected item is clicked', () => {
|
||||
const onChange = sinon.spy();
|
||||
const wrapper = createWrapper({ onChange, orderField: 'foo', orderDir: 'ASC' });
|
||||
const firstItem = wrapper.find(DropdownItem).first();
|
||||
|
||||
firstItem.simulate('click');
|
||||
|
||||
expect(onChange.callCount).toEqual(1);
|
||||
expect(onChange.calledWith('foo', 'DESC')).toEqual(true);
|
||||
});
|
||||
});
|
||||
67
test/visits/GraphCard.test.js
Normal file
67
test/visits/GraphCard.test.js
Normal file
@@ -0,0 +1,67 @@
|
||||
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,
|
||||
};
|
||||
const matchMedia = () => ({ matches: false });
|
||||
|
||||
afterEach(() => wrapper && wrapper.unmount());
|
||||
|
||||
it('renders Doughnut when is not a bar chart', () => {
|
||||
wrapper = shallow(<GraphCard matchMedia={matchMedia} 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 matchMedia={matchMedia} 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 },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
91
test/visits/ShortUrlVisits.test.js
Normal file
91
test/visits/ShortUrlVisits.test.js
Normal file
@@ -0,0 +1,91 @@
|
||||
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/utils/DateInput';
|
||||
import SortableBarGraph from '../../src/visits/SortableBarGraph';
|
||||
|
||||
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 sortableBarGraphs = wrapper.find(SortableBarGraph);
|
||||
const expectedGraphsCount = 4;
|
||||
|
||||
expect(graphs.length + sortableBarGraphs.length).toEqual(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');
|
||||
});
|
||||
});
|
||||
44
test/visits/VisitsHeader.test.js
Normal file
44
test/visits/VisitsHeader.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
94
test/visits/reducers/shortUrlDetail.test.js
Normal file
94
test/visits/reducers/shortUrlDetail.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
94
test/visits/reducers/shortUrlVisits.test.js
Normal file
94
test/visits/reducers/shortUrlVisits.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
69
yarn.lock
69
yarn.lock
@@ -1997,7 +1997,7 @@ copy-to-clipboard@^3:
|
||||
|
||||
core-js@^1.0.0:
|
||||
version "1.2.7"
|
||||
resolved "https://registry.yarnpkg.com/core-js/-/core-js-1.2.7.tgz#652294c14651db28fa93bd2d5ff2983a4f08c636"
|
||||
resolved "http://registry.npmjs.org/core-js/-/core-js-1.2.7.tgz#652294c14651db28fa93bd2d5ff2983a4f08c636"
|
||||
|
||||
core-js@^2.4.0, core-js@^2.5.0:
|
||||
version "2.5.7"
|
||||
@@ -3927,12 +3927,18 @@ iconv-lite@0.4.19:
|
||||
version "0.4.19"
|
||||
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.19.tgz#f7468f60135f5e5dad3399c0a81be9a1603a082b"
|
||||
|
||||
iconv-lite@^0.4.17, iconv-lite@^0.4.4, iconv-lite@~0.4.13:
|
||||
iconv-lite@^0.4.17, iconv-lite@^0.4.4:
|
||||
version "0.4.23"
|
||||
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.23.tgz#297871f63be507adcfbfca715d0cd0eed84e9a63"
|
||||
dependencies:
|
||||
safer-buffer ">= 2.1.2 < 3"
|
||||
|
||||
iconv-lite@~0.4.13:
|
||||
version "0.4.24"
|
||||
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
|
||||
dependencies:
|
||||
safer-buffer ">= 2.1.2 < 3"
|
||||
|
||||
icss-replace-symbols@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/icss-replace-symbols/-/icss-replace-symbols-1.1.0.tgz#06ea6f83679a7749e386cfe1fe812ae5db223ded"
|
||||
@@ -5691,6 +5697,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 +6727,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 +6905,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"
|
||||
@@ -6888,14 +6920,14 @@ react-transition-group@^2.3.1:
|
||||
prop-types "^15.6.2"
|
||||
react-lifecycles-compat "^3.0.4"
|
||||
|
||||
react@^16.3.2:
|
||||
version "16.4.2"
|
||||
resolved "https://registry.yarnpkg.com/react/-/react-16.4.2.tgz#2cd90154e3a9d9dd8da2991149fdca3c260e129f"
|
||||
react@^16.6:
|
||||
version "16.6.0"
|
||||
resolved "https://registry.yarnpkg.com/react/-/react-16.6.0.tgz#b34761cfaf3e30f5508bc732fb4736730b7da246"
|
||||
dependencies:
|
||||
fbjs "^0.8.16"
|
||||
loose-envify "^1.1.0"
|
||||
object-assign "^4.1.1"
|
||||
prop-types "^15.6.0"
|
||||
prop-types "^15.6.2"
|
||||
scheduler "^0.10.0"
|
||||
|
||||
reactcss@^1.2.0:
|
||||
version "1.2.3"
|
||||
@@ -7426,6 +7458,13 @@ sax@^1.2.1, sax@^1.2.4, sax@~1.2.1:
|
||||
version "1.2.4"
|
||||
resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
|
||||
|
||||
scheduler@^0.10.0:
|
||||
version "0.10.0"
|
||||
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.10.0.tgz#7988de90fe7edccc774ea175a783e69c40c521e1"
|
||||
dependencies:
|
||||
loose-envify "^1.1.0"
|
||||
object-assign "^4.1.1"
|
||||
|
||||
schema-utils@^0.3.0:
|
||||
version "0.3.0"
|
||||
resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-0.3.0.tgz#f5877222ce3e931edae039f17eb3716e7137f8cf"
|
||||
@@ -7439,6 +7478,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 +7629,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"
|
||||
@@ -8363,8 +8410,8 @@ typedarray@^0.0.6:
|
||||
resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
|
||||
|
||||
ua-parser-js@^0.7.18:
|
||||
version "0.7.18"
|
||||
resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.18.tgz#a7bfd92f56edfb117083b69e31d2aa8882d4b1ed"
|
||||
version "0.7.19"
|
||||
resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.19.tgz#94151be4c0a7fb1d001af7022fdaca4642659e4b"
|
||||
|
||||
uglify-js@3.4.x, uglify-js@^3.0.13:
|
||||
version "3.4.6"
|
||||
@@ -8803,8 +8850,8 @@ whatwg-fetch@2.0.3:
|
||||
resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-2.0.3.tgz#9c84ec2dcf68187ff00bc64e1274b442176e1c84"
|
||||
|
||||
whatwg-fetch@>=0.10.0:
|
||||
version "2.0.4"
|
||||
resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-2.0.4.tgz#dde6a5df315f9d39991aa17621853d720b85566f"
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.0.0.tgz#fc804e458cc460009b1a2b966bc8817d2578aefb"
|
||||
|
||||
whatwg-url@^4.3.0:
|
||||
version "4.8.0"
|
||||
|
||||
Reference in New Issue
Block a user