mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2026-02-26 11:46:39 +00:00
Compare commits
118 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d231ed3ede | ||
|
|
cf6f9028f2 | ||
|
|
7cf49d2c1a | ||
|
|
e37fb1b4bd | ||
|
|
faf5d0bf7b | ||
|
|
6fede88072 | ||
|
|
87ffbefa61 | ||
|
|
f33ae17781 | ||
|
|
2a2bae6d1a | ||
|
|
eb65e99024 | ||
|
|
52dbeb6201 | ||
|
|
fafe920b7b | ||
|
|
9d1e48ee90 | ||
|
|
3851342e1b | ||
|
|
b863c2e19d | ||
|
|
ed584d19e5 | ||
|
|
73256dcf5b | ||
|
|
c67a23c988 | ||
|
|
8f42e65ccd | ||
|
|
05deb1aff0 | ||
|
|
a74b7cdfad | ||
|
|
1c3119ee76 | ||
|
|
ca52911e42 | ||
|
|
9177bc7cef | ||
|
|
310831a26a | ||
|
|
8a486d991b | ||
|
|
b79333393b | ||
|
|
cb7062bb95 | ||
|
|
94c5b2c471 | ||
|
|
66bf26f1dc | ||
|
|
f5cc1abe75 | ||
|
|
bd4255108d | ||
|
|
06b63d1af2 | ||
|
|
2bd70fb9e6 | ||
|
|
e6034dfb14 | ||
|
|
c8ba6764c2 | ||
|
|
19337d6c05 | ||
|
|
a6ad3c2d4d | ||
|
|
b0dd885c09 | ||
|
|
2235592308 | ||
|
|
1219a16261 | ||
|
|
7949e224e0 | ||
|
|
ab2f311bb7 | ||
|
|
a5aab43666 | ||
|
|
74ebd4e572 | ||
|
|
bd29670108 | ||
|
|
9a20b4428d | ||
|
|
d7da8521ce | ||
|
|
bab3b252c1 | ||
|
|
7f05c5c2da | ||
|
|
2d5c2779c3 | ||
|
|
06db4f6556 | ||
|
|
ea5ec63a22 | ||
|
|
f46e737e77 | ||
|
|
6e63bdaafa | ||
|
|
79ccef9f7e | ||
|
|
a9653b3674 | ||
|
|
b5a188e802 | ||
|
|
38fc402b16 | ||
|
|
584d1ec1ce | ||
|
|
2ca7faa457 | ||
|
|
03806abda0 | ||
|
|
18d125430d | ||
|
|
f57f6b7745 | ||
|
|
75ff2b8f40 | ||
|
|
2ec04c0121 | ||
|
|
5145a41dac | ||
|
|
25c67f1c3e | ||
|
|
77b9181150 | ||
|
|
e4f7ded8e2 | ||
|
|
35a62f1fb1 | ||
|
|
24f2deda46 | ||
|
|
5d8af1a0e5 | ||
|
|
6d44ac1e0c | ||
|
|
fb0ebddf28 | ||
|
|
0aebaa4da1 | ||
|
|
f6baedc655 | ||
|
|
7db222664d | ||
|
|
8223f0fd64 | ||
|
|
f44ec42f51 | ||
|
|
dab75ab6a9 | ||
|
|
01672b88e1 | ||
|
|
78dc297022 | ||
|
|
c8cf75fa28 | ||
|
|
b011b4e1d8 | ||
|
|
9804a2d18d | ||
|
|
d1a5ee43e9 | ||
|
|
febecab33c | ||
|
|
99042c0979 | ||
|
|
6395e4e00b | ||
|
|
4a69907ca3 | ||
|
|
c8d682cc98 | ||
|
|
f4cc8d3a0c | ||
|
|
6ac89334fd | ||
|
|
f55d3a66aa | ||
|
|
972eafab34 | ||
|
|
fba156b271 | ||
|
|
96d538db15 | ||
|
|
b89bfa3c1c | ||
|
|
73e3f42614 | ||
|
|
e761f5e1bd | ||
|
|
4a6dd66ecd | ||
|
|
8e1c6908c6 | ||
|
|
f59e569e22 | ||
|
|
be50b24504 | ||
|
|
c181831a37 | ||
|
|
dbee62ac8c | ||
|
|
1e949b3a22 | ||
|
|
b02dcf6c53 | ||
|
|
ab7718e335 | ||
|
|
451c77d47f | ||
|
|
fa0d3d4047 | ||
|
|
397a183f65 | ||
|
|
bc8905ee7f | ||
|
|
853032ac7f | ||
|
|
3b0e282a52 | ||
|
|
bb28cb3862 | ||
|
|
d0f458bece |
@@ -1,6 +1,6 @@
|
||||
./.github
|
||||
./build
|
||||
./coverage
|
||||
./dist
|
||||
./node_modules
|
||||
./test
|
||||
./shlink-web-client.gif
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"no-magic-numbers": "off",
|
||||
"no-undefined": "off",
|
||||
"no-inline-comments": "off",
|
||||
"lines-around-comment": "off",
|
||||
"indent": ["error", 2, {
|
||||
"SwitchCase": 1
|
||||
}
|
||||
|
||||
25
.travis.yml
25
.travis.yml
@@ -20,21 +20,26 @@ before_script:
|
||||
script:
|
||||
- npm run lint
|
||||
- npm run test:ci
|
||||
- if [[ -z $TRAVIS_TAG ]]; then docker build -t shlink-web-client:test . ; fi
|
||||
- if [[ -z $TRAVIS_TAG ]]; then npm run mutate:ci ; fi
|
||||
- if [[ $TRAVIS_PULL_REQUEST != 'false' ]]; then docker build -t shlink-web-client:test . ; fi
|
||||
- if [[ $TRAVIS_PULL_REQUEST != 'false' ]]; then npm run mutate:ci ; fi
|
||||
|
||||
after_success:
|
||||
- node_modules/.bin/ocular coverage/clover.xml
|
||||
|
||||
# Before deploying, build dist file for current travis tag
|
||||
before_deploy:
|
||||
- npm run build ${TRAVIS_TAG#?}
|
||||
- if [[ ! -z $TRAVIS_TAG ]]; then npm run build ${TRAVIS_TAG#?} ; fi
|
||||
|
||||
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
|
||||
- provider: script
|
||||
script: bash ./scripts/docker/build
|
||||
on:
|
||||
all_branches: true
|
||||
condition: $TRAVIS_PULL_REQUEST == 'false'
|
||||
- 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
|
||||
|
||||
39
CHANGELOG.md
39
CHANGELOG.md
@@ -4,6 +4,45 @@ 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).
|
||||
|
||||
## 2.4.0 - 2020-04-10
|
||||
|
||||
#### Added
|
||||
|
||||
* [#199](https://github.com/shlinkio/shlink-web-client/issues/199) Added table to visits page which displays the information in a paginated, sortable and filterable list.
|
||||
|
||||
It also supports selecting multiple visits in the table which makes the corresponding data to be highlighted in the visits charts.
|
||||
|
||||
* [#241](https://github.com/shlinkio/shlink-web-client/issues/241) Added support to select charts bars in order to highlight related stats in other charts.
|
||||
|
||||
It also selects the visits in the new table, and you can even combine a selection in the chart and in the table.
|
||||
|
||||
* [#213](https://github.com/shlinkio/shlink-web-client/issues/213) The versions of both shlink-web-client and currently consumed Shlink server are now displayed in the footer.
|
||||
* [#221](https://github.com/shlinkio/shlink-web-client/issues/221) Improved how servers are handled, displaying meaningful errors when a not-found or a not-reachable server is tried to be loaded.
|
||||
* [#226](https://github.com/shlinkio/shlink-web-client/issues/226) Created servers can now be edited.
|
||||
* [#234](https://github.com/shlinkio/shlink-web-client/issues/234) Allowed short code length to be edited on any new short URL when using Shlink 2.1 or higher.
|
||||
* [#235](https://github.com/shlinkio/shlink-web-client/issues/235) Allowed editing the long URL for any existing short URL when suing Shlink 2.1 or higher.
|
||||
|
||||
#### Changed
|
||||
|
||||
* [#205](https://github.com/shlinkio/shlink-web-client/issues/205) Replaced `jest-each` package by jet's native `test.each` function.
|
||||
* [#209](https://github.com/shlinkio/shlink-web-client/issues/209) Replaced `Unknown` by `Direct` for visits from undetermined referrers.
|
||||
* [#212](https://github.com/shlinkio/shlink-web-client/issues/212) Moved copy-to-clipboard next to short URL.
|
||||
* [#208](https://github.com/shlinkio/shlink-web-client/issues/208) Short URLs list paginator is now progressive.
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* [#243](https://github.com/shlinkio/shlink-web-client/issues/243) Fixed loading state and resetting on short URL creation form.
|
||||
* [#239](https://github.com/shlinkio/shlink-web-client/issues/239) Fixed how user agents are parsed, reducing false results.
|
||||
|
||||
|
||||
## 2.3.1 - 2020-02-08
|
||||
|
||||
#### Added
|
||||
|
||||
10
Dockerfile
10
Dockerfile
@@ -1,6 +1,14 @@
|
||||
FROM node:12.14.1-alpine as node
|
||||
COPY . /shlink-web-client
|
||||
RUN cd /shlink-web-client && npm install && npm run build
|
||||
ARG VERSION="latest"
|
||||
ENV VERSION ${VERSION}
|
||||
RUN cd /shlink-web-client && \
|
||||
UNCOMPRESSED="shlink-web-client_${VERSION}_dist" && \
|
||||
DIST_FILE="./dist/${UNCOMPRESSED}.zip" && \
|
||||
# If a dist file already exists, just unzip it
|
||||
if [[ -f ${DIST_FILE} ]]; then unzip ${DIST_FILE} && mv ./${UNCOMPRESSED} ./build ; fi && \
|
||||
# If no dist file exsts, build from scratch
|
||||
if [[ ! -f ${DIST_FILE} ]]; then npm install && npm run build -- ${VERSION} --no-dist ; fi
|
||||
|
||||
FROM nginx:1.17.7-alpine
|
||||
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
# shlink-web-client
|
||||
|
||||
[](https://travis-ci.org/shlinkio/shlink-web-client)
|
||||
[](https://hub.docker.com/r/shlinkio/shlink-web-client/)
|
||||
[](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://hub.docker.com/r/shlinkio/shlink-web-client/)
|
||||
[](https://github.com/shlinkio/shlink-web-client/blob/master/LICENSE)
|
||||
[](https://acel.me/donate)
|
||||
[](https://slnk.to/donate)
|
||||
|
||||
A ReactJS-based progressive web application for [Shlink](https://shlink.io).
|
||||
|
||||

|
||||
|
||||
## Installation
|
||||
|
||||
There are three ways in which you can use this application.
|
||||
|
||||
1732
package-lock.json
generated
1732
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -33,6 +33,7 @@
|
||||
"axios": "^0.19.0",
|
||||
"bootstrap": "^4.3.1",
|
||||
"bottlejs": "^1.7.2",
|
||||
"bowser": "^2.9.0",
|
||||
"chart.js": "^2.8.0",
|
||||
"classnames": "^2.2.6",
|
||||
"compare-versions": "^3.5.1",
|
||||
@@ -43,13 +44,13 @@
|
||||
"prop-types": "^15.7.2",
|
||||
"qs": "^6.9.0",
|
||||
"ramda": "^0.26.1",
|
||||
"react": "^16.10.2",
|
||||
"react": "^16.13.1",
|
||||
"react-autosuggest": "^9.4.3",
|
||||
"react-chartjs-2": "^2.8.0",
|
||||
"react-color": "^2.17.3",
|
||||
"react-copy-to-clipboard": "^5.0.1",
|
||||
"react-datepicker": "~1.5.0",
|
||||
"react-dom": "^16.10.2",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-external-link": "^1.0.0",
|
||||
"react-leaflet": "^2.4.0",
|
||||
"react-moment": "^0.9.5",
|
||||
@@ -85,8 +86,8 @@
|
||||
"css-loader": "^3.2.0",
|
||||
"dotenv": "^8.1.0",
|
||||
"dotenv-expand": "^5.1.0",
|
||||
"enzyme": "^3.10.0",
|
||||
"enzyme-adapter-react-16": "^1.14.0",
|
||||
"enzyme": "^3.11.0",
|
||||
"enzyme-adapter-react-16": "^1.15.2",
|
||||
"eslint": "^5.11.1",
|
||||
"eslint-config-adidas-babel": "^1.1.0",
|
||||
"eslint-config-adidas-env": "^1.1.0",
|
||||
@@ -104,7 +105,6 @@
|
||||
"html-webpack-plugin": "^4.0.0-beta.8",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"jest": "^24.9.0",
|
||||
"jest-each": "^24.9.0",
|
||||
"jest-pnp-resolver": "^1.2.1",
|
||||
"jest-resolve": "^24.9.0",
|
||||
"mini-css-extract-plugin": "^0.8.0",
|
||||
|
||||
@@ -14,7 +14,6 @@ process.on('unhandledRejection', (err) => {
|
||||
// Ensure environment variables are read.
|
||||
require('../config/env');
|
||||
|
||||
const path = require('path');
|
||||
const chalk = require('chalk');
|
||||
const fs = require('fs-extra');
|
||||
const webpack = require('webpack');
|
||||
@@ -22,7 +21,6 @@ const bfj = require('bfj');
|
||||
const AdmZip = require('adm-zip');
|
||||
const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles');
|
||||
const formatWebpackMessages = require('react-dev-utils/formatWebpackMessages');
|
||||
const printHostingInstructions = require('react-dev-utils/printHostingInstructions');
|
||||
const FileSizeReporter = require('react-dev-utils/FileSizeReporter');
|
||||
const printBuildError = require('react-dev-utils/printBuildError');
|
||||
const { checkBrowsers } = require('react-dev-utils/browsersHelper');
|
||||
@@ -30,7 +28,6 @@ const paths = require('../config/paths');
|
||||
const configFactory = require('../config/webpack.config');
|
||||
|
||||
const { measureFileSizesBeforeBuild, printFileSizesAfterBuild } = FileSizeReporter;
|
||||
const useYarn = fs.existsSync(paths.yarnLockFile);
|
||||
|
||||
// These sizes are pretty large. We'll warn for bundles exceeding them.
|
||||
const WARN_AFTER_BUNDLE_GZIP_SIZE = 512 * 1024; // eslint-disable-line
|
||||
@@ -47,6 +44,8 @@ if (!checkRequiredFiles([ paths.appHtml, paths.appIndexJs ])) {
|
||||
const argvSliceStart = 2;
|
||||
const argv = process.argv.slice(argvSliceStart);
|
||||
const writeStatsJson = argv.indexOf('--stats') !== -1;
|
||||
const withoutDist = argv.indexOf('--no-dist') !== -1;
|
||||
const { version, hasVersion } = getVersionFromArgs(argv);
|
||||
|
||||
// Generate configuration
|
||||
const config = configFactory('production');
|
||||
@@ -85,6 +84,7 @@ checkBrowsers(paths.appPath, isInteractive)
|
||||
);
|
||||
} else {
|
||||
console.log(chalk.green('Compiled successfully.\n'));
|
||||
hasVersion && replaceVersionPlaceholder(version);
|
||||
}
|
||||
|
||||
console.log('File sizes after gzip:\n');
|
||||
@@ -96,20 +96,6 @@ checkBrowsers(paths.appPath, isInteractive)
|
||||
WARN_AFTER_CHUNK_GZIP_SIZE
|
||||
);
|
||||
console.log();
|
||||
|
||||
const appPackage = require(paths.appPackageJson);
|
||||
|
||||
const { publicUrl } = paths;
|
||||
const { output: { publicPath } } = config;
|
||||
const buildFolder = path.relative(process.cwd(), paths.appBuild);
|
||||
|
||||
printHostingInstructions(
|
||||
appPackage,
|
||||
publicUrl,
|
||||
publicPath,
|
||||
buildFolder,
|
||||
useYarn
|
||||
);
|
||||
},
|
||||
(err) => {
|
||||
console.log(chalk.red('Failed to compile.\n'));
|
||||
@@ -117,7 +103,7 @@ checkBrowsers(paths.appPath, isInteractive)
|
||||
process.exit(1);
|
||||
}
|
||||
)
|
||||
.then(zipDist)
|
||||
.then(() => hasVersion && !withoutDist && zipDist(version))
|
||||
.catch((err) => {
|
||||
if (err && err.message) {
|
||||
console.log(err.message);
|
||||
@@ -200,15 +186,7 @@ function copyPublicFolder() {
|
||||
});
|
||||
}
|
||||
|
||||
function zipDist() {
|
||||
const minArgsToContainVersion = 3;
|
||||
|
||||
// If no version was provided, do nothing
|
||||
if (process.argv.length < minArgsToContainVersion) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [ , , version ] = process.argv;
|
||||
function zipDist(version) {
|
||||
const versionFileName = `./dist/shlink-web-client_${version}_dist.zip`;
|
||||
|
||||
console.log(chalk.cyan(`Generating dist file for version ${chalk.bold(version)}...`));
|
||||
@@ -226,4 +204,24 @@ function zipDist() {
|
||||
console.log(chalk.red('An error occurred while generating dist file'));
|
||||
console.log(e);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
function getVersionFromArgs(argv) {
|
||||
const [ version ] = argv;
|
||||
|
||||
return { version, hasVersion: !!version };
|
||||
}
|
||||
|
||||
function replaceVersionPlaceholder(version) {
|
||||
const staticJsFilesPath = './build/static/js';
|
||||
const versionPlaceholder = '%_VERSION_%';
|
||||
|
||||
const isMainFile = (file) => file.startsWith('main.') && file.endsWith('.js');
|
||||
const [ mainJsFile ] = fs.readdirSync(staticJsFilesPath).filter(isMainFile);
|
||||
const filePath = `${staticJsFilesPath}/${mainJsFile}`;
|
||||
const fileContent = fs.readFileSync(filePath, 'utf-8');
|
||||
const replaced = fileContent.replace(versionPlaceholder, version);
|
||||
|
||||
fs.writeFileSync(filePath, replaced, 'utf-8');
|
||||
}
|
||||
|
||||
13
scripts/docker/build
Executable file
13
scripts/docker/build
Executable file
@@ -0,0 +1,13 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin
|
||||
|
||||
if [[ -z $TRAVIS_TAG ]]; then
|
||||
docker build -t shlinkio/shlink-web-client:latest .
|
||||
docker push shlinkio/shlink-web-client:latest
|
||||
else
|
||||
docker build --build-arg VERSION=${TRAVIS_TAG#?} -t shlinkio/shlink-web-client:${TRAVIS_TAG#?} -t shlinkio/shlink-web-client:stable .
|
||||
docker push shlinkio/shlink-web-client:${TRAVIS_TAG#?}
|
||||
docker push shlinkio/shlink-web-client:stable
|
||||
fi
|
||||
BIN
shlink-web-client.gif
Normal file
BIN
shlink-web-client.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.3 MiB |
@@ -3,14 +3,15 @@ import { Route, Switch } from 'react-router-dom';
|
||||
import './App.scss';
|
||||
import NotFound from './common/NotFound';
|
||||
|
||||
const App = (MainHeader, Home, MenuLayout, CreateServer) => () => (
|
||||
const App = (MainHeader, Home, MenuLayout, CreateServer, EditServer) => () => (
|
||||
<div className="container-fluid app-container">
|
||||
<MainHeader />
|
||||
|
||||
<div className="app">
|
||||
<Switch>
|
||||
<Route exact path="/server/create" component={CreateServer} />
|
||||
<Route exact path="/" component={Home} />
|
||||
<Route exact path="/server/create" component={CreateServer} />
|
||||
<Route exact path="/server/:serverId/edit" component={EditServer} />
|
||||
<Route path="/server/:serverId" component={MenuLayout} />
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
|
||||
@@ -1,16 +1,34 @@
|
||||
import { faList as listIcon, faLink as createIcon, faTags as tagsIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import {
|
||||
faList as listIcon,
|
||||
faLink as createIcon,
|
||||
faTags as tagsIcon,
|
||||
faPen as editIcon,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import React from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
import classnames from 'classnames';
|
||||
import classNames from 'classnames';
|
||||
import { serverType } from '../servers/prop-types';
|
||||
import './AsideMenu.scss';
|
||||
|
||||
const defaultProps = {
|
||||
className: '',
|
||||
showOnMobile: false,
|
||||
const AsideMenuItem = ({ children, to, className, ...rest }) => (
|
||||
<NavLink
|
||||
className={classNames('aside-menu__item', className)}
|
||||
activeClassName="aside-menu__item--selected"
|
||||
to={to}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
AsideMenuItem.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
to: PropTypes.string.isRequired,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
const propTypes = {
|
||||
selectedServer: serverType,
|
||||
className: PropTypes.string,
|
||||
@@ -20,43 +38,34 @@ const propTypes = {
|
||||
const AsideMenu = (DeleteServerButton) => {
|
||||
const AsideMenu = ({ selectedServer, className, showOnMobile }) => {
|
||||
const serverId = selectedServer ? selectedServer.id : '';
|
||||
const asideClass = classnames('aside-menu', className, {
|
||||
const asideClass = classNames('aside-menu', className, {
|
||||
'aside-menu--hidden': !showOnMobile,
|
||||
});
|
||||
const shortUrlsIsActive = (match, location) => location.pathname.match('/list-short-urls');
|
||||
const buildPath = (suffix) => `/server/${serverId}${suffix}`;
|
||||
|
||||
return (
|
||||
<aside className={asideClass}>
|
||||
<nav className="nav flex-column aside-menu__nav">
|
||||
<NavLink
|
||||
className="aside-menu__item"
|
||||
activeClassName="aside-menu__item--selected"
|
||||
to={`/server/${serverId}/list-short-urls/1`}
|
||||
isActive={shortUrlsIsActive}
|
||||
>
|
||||
<AsideMenuItem to={buildPath('/list-short-urls/1')} isActive={shortUrlsIsActive}>
|
||||
<FontAwesomeIcon icon={listIcon} />
|
||||
<span className="aside-menu__item-text">List short URLs</span>
|
||||
</NavLink>
|
||||
<NavLink
|
||||
className="aside-menu__item"
|
||||
activeClassName="aside-menu__item--selected"
|
||||
to={`/server/${serverId}/create-short-url`}
|
||||
>
|
||||
</AsideMenuItem>
|
||||
<AsideMenuItem to={buildPath('/create-short-url')}>
|
||||
<FontAwesomeIcon icon={createIcon} flip="horizontal" />
|
||||
<span className="aside-menu__item-text">Create short URL</span>
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
className="aside-menu__item"
|
||||
activeClassName="aside-menu__item--selected"
|
||||
to={`/server/${serverId}/manage-tags`}
|
||||
>
|
||||
</AsideMenuItem>
|
||||
<AsideMenuItem to={buildPath('/manage-tags')}>
|
||||
<FontAwesomeIcon icon={tagsIcon} />
|
||||
<span className="aside-menu__item-text">Manage tags</span>
|
||||
</NavLink>
|
||||
|
||||
</AsideMenuItem>
|
||||
<AsideMenuItem to={buildPath('/edit')} className="aside-menu__item--push">
|
||||
<FontAwesomeIcon icon={editIcon} />
|
||||
<span className="aside-menu__item-text">Edit this server</span>
|
||||
</AsideMenuItem>
|
||||
<DeleteServerButton
|
||||
className="aside-menu__item aside-menu__item--danger"
|
||||
textClassName="aside-menu__item-text"
|
||||
server={selectedServer}
|
||||
/>
|
||||
</nav>
|
||||
@@ -64,7 +73,6 @@ const AsideMenu = (DeleteServerButton) => {
|
||||
);
|
||||
};
|
||||
|
||||
AsideMenu.defaultProps = defaultProps;
|
||||
AsideMenu.propTypes = propTypes;
|
||||
|
||||
return AsideMenu;
|
||||
|
||||
@@ -67,6 +67,9 @@ $asideMenuMobileWidth: 280px;
|
||||
|
||||
.aside-menu__item--danger {
|
||||
color: $dangerColor;
|
||||
}
|
||||
|
||||
.aside-menu__item--push {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,52 +1,35 @@
|
||||
import { faChevronRight as chevronIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import React, { useEffect } from 'react';
|
||||
import { isEmpty, values } from 'ramda';
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ListGroup, ListGroupItem } from 'reactstrap';
|
||||
import PropTypes from 'prop-types';
|
||||
import './Home.scss';
|
||||
import ServersListGroup from '../servers/ServersListGroup';
|
||||
|
||||
export default class Home extends React.Component {
|
||||
static propTypes = {
|
||||
resetSelectedServer: PropTypes.func,
|
||||
servers: PropTypes.object,
|
||||
};
|
||||
const propTypes = {
|
||||
resetSelectedServer: PropTypes.func,
|
||||
servers: PropTypes.object,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.props.resetSelectedServer();
|
||||
}
|
||||
const Home = ({ resetSelectedServer, servers: { list, loading } }) => {
|
||||
const servers = values(list);
|
||||
const hasServers = !isEmpty(servers);
|
||||
|
||||
render() {
|
||||
const { servers: { list, loading } } = this.props;
|
||||
const servers = values(list);
|
||||
const hasServers = !isEmpty(servers);
|
||||
useEffect(() => {
|
||||
resetSelectedServer();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="home">
|
||||
<h1 className="home__title">Welcome to Shlink</h1>
|
||||
<h5 className="home__intro">
|
||||
{!loading && hasServers && <span>Please, select a server.</span>}
|
||||
{!loading && !hasServers && <span>Please, <Link to="/server/create">add a server</Link>.</span>}
|
||||
{loading && <span>Trying to load servers...</span>}
|
||||
</h5>
|
||||
return (
|
||||
<div className="home">
|
||||
<h1 className="home__title">Welcome to Shlink</h1>
|
||||
<ServersListGroup servers={servers}>
|
||||
{!loading && hasServers && <span>Please, select a server.</span>}
|
||||
{!loading && !hasServers && <span>Please, <Link to="/server/create">add a server</Link>.</span>}
|
||||
{loading && <span>Trying to load servers...</span>}
|
||||
</ServersListGroup>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
{!loading && hasServers && (
|
||||
<ListGroup className="home__servers-list">
|
||||
{servers.map(({ name, id }) => (
|
||||
<ListGroupItem
|
||||
key={id}
|
||||
tag={Link}
|
||||
to={`/server/${id}/list-short-urls/1`}
|
||||
className="home__servers-item"
|
||||
>
|
||||
{name}
|
||||
<FontAwesomeIcon icon={chevronIcon} className="home__servers-item-icon" />
|
||||
</ListGroupItem>
|
||||
))}
|
||||
</ListGroup>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
Home.propTypes = propTypes;
|
||||
|
||||
export default Home;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
@import '../utils/base';
|
||||
@import '../utils/mixins/vertical-align';
|
||||
|
||||
.home {
|
||||
text-align: center;
|
||||
@@ -17,21 +16,3 @@
|
||||
font-size: 2.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.home__servers-list {
|
||||
margin-top: 1rem;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.home__servers-item.home__servers-item {
|
||||
text-align: left;
|
||||
position: relative;
|
||||
padding: .75rem 2.5rem .75rem 1rem;
|
||||
}
|
||||
|
||||
.home__servers-item-icon {
|
||||
@include vertical-align();
|
||||
|
||||
right: 1rem;
|
||||
}
|
||||
|
||||
@@ -1,110 +1,82 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import { Swipeable } from 'react-swipeable';
|
||||
import { faBars as burgerIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import classnames from 'classnames';
|
||||
import classNames from 'classnames';
|
||||
import * as PropTypes from 'prop-types';
|
||||
import { serverType } from '../servers/prop-types';
|
||||
import { withSelectedServer } from '../servers/helpers/withSelectedServer';
|
||||
import { useToggle } from '../utils/helpers/hooks';
|
||||
import NotFound from './NotFound';
|
||||
import './MenuLayout.scss';
|
||||
|
||||
const MenuLayout = (TagsList, ShortUrls, AsideMenu, CreateShortUrl, ShortUrlVisits) =>
|
||||
class MenuLayout extends React.Component {
|
||||
static propTypes = {
|
||||
match: PropTypes.object,
|
||||
selectServer: PropTypes.func,
|
||||
location: PropTypes.object,
|
||||
selectedServer: serverType,
|
||||
const propTypes = {
|
||||
match: PropTypes.object,
|
||||
location: PropTypes.object,
|
||||
selectedServer: serverType,
|
||||
};
|
||||
|
||||
const MenuLayout = (TagsList, ShortUrls, AsideMenu, CreateShortUrl, ShortUrlVisits, ShlinkVersions, ServerError) => {
|
||||
const MenuLayoutComp = ({ match, location, selectedServer }) => {
|
||||
const [ sidebarVisible, toggleSidebar, showSidebar, hideSidebar ] = useToggle();
|
||||
const { params: { serverId } } = match;
|
||||
|
||||
useEffect(() => hideSidebar(), [ location ]);
|
||||
|
||||
if (selectedServer.serverNotReachable) {
|
||||
return <ServerError type="not-reachable" />;
|
||||
}
|
||||
|
||||
const burgerClasses = classNames('menu-layout__burger-icon', {
|
||||
'menu-layout__burger-icon--active': sidebarVisible,
|
||||
});
|
||||
const swipeMenuIfNoModalExists = (callback) => () => {
|
||||
if (document.querySelector('.modal')) {
|
||||
return;
|
||||
}
|
||||
|
||||
callback();
|
||||
};
|
||||
|
||||
state = { showSideBar: false };
|
||||
return (
|
||||
<React.Fragment>
|
||||
<FontAwesomeIcon icon={burgerIcon} className={burgerClasses} onClick={toggleSidebar} />
|
||||
|
||||
componentDidMount() {
|
||||
const { match, selectServer } = this.props;
|
||||
const { params: { serverId } } = match;
|
||||
|
||||
selectServer(serverId);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { location } = this.props;
|
||||
|
||||
// Hide sidebar when location changes
|
||||
if (location !== prevProps.location) {
|
||||
this.setState({ showSideBar: false });
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { selectedServer, match } = this.props;
|
||||
const { params: { serverId } } = match;
|
||||
const burgerClasses = classnames('menu-layout__burger-icon', {
|
||||
'menu-layout__burger-icon--active': this.state.showSideBar,
|
||||
});
|
||||
const swipeMenuIfNoModalExists = (showSideBar) => () => {
|
||||
if (document.querySelector('.modal')) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ showSideBar });
|
||||
};
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<FontAwesomeIcon
|
||||
icon={burgerIcon}
|
||||
className={burgerClasses}
|
||||
onClick={() => this.setState(({ showSideBar }) => ({ showSideBar: !showSideBar }))}
|
||||
/>
|
||||
|
||||
<Swipeable
|
||||
delta={40}
|
||||
className="menu-layout__swipeable"
|
||||
onSwipedLeft={swipeMenuIfNoModalExists(false)}
|
||||
onSwipedRight={swipeMenuIfNoModalExists(true)}
|
||||
>
|
||||
<div className="row menu-layout__swipeable-inner">
|
||||
<AsideMenu
|
||||
className="col-lg-2 col-md-3"
|
||||
selectedServer={selectedServer}
|
||||
showOnMobile={this.state.showSideBar}
|
||||
/>
|
||||
<div
|
||||
className="col-lg-10 offset-lg-2 col-md-9 offset-md-3"
|
||||
onClick={() => this.setState({ showSideBar: false })}
|
||||
>
|
||||
<Swipeable
|
||||
delta={40}
|
||||
className="menu-layout__swipeable"
|
||||
onSwipedLeft={swipeMenuIfNoModalExists(hideSidebar)}
|
||||
onSwipedRight={swipeMenuIfNoModalExists(showSidebar)}
|
||||
>
|
||||
<div className="row menu-layout__swipeable-inner">
|
||||
<AsideMenu className="col-lg-2 col-md-3" selectedServer={selectedServer} showOnMobile={sidebarVisible} />
|
||||
<div className="col-lg-10 offset-lg-2 col-md-9 offset-md-3" onClick={() => hideSidebar()}>
|
||||
<div className="menu-layout__container">
|
||||
<Switch>
|
||||
<Route exact path="/server/:serverId/list-short-urls/:page" component={ShortUrls} />
|
||||
<Route exact path="/server/:serverId/create-short-url" component={CreateShortUrl} />
|
||||
<Route exact path="/server/:serverId/short-code/:shortCode/visits" component={ShortUrlVisits} />
|
||||
<Route exact path="/server/:serverId/manage-tags" component={TagsList} />
|
||||
<Route
|
||||
exact
|
||||
path="/server/:serverId/list-short-urls/:page"
|
||||
component={ShortUrls}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/server/:serverId/create-short-url"
|
||||
component={CreateShortUrl}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/server/:serverId/short-code/:shortCode/visits"
|
||||
component={ShortUrlVisits}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/server/:serverId/manage-tags"
|
||||
component={TagsList}
|
||||
/>
|
||||
<Route
|
||||
render={() => <NotFound to={`/server/${serverId}/list-short-urls/1`} btnText="List short URLs" />}
|
||||
render={() => <NotFound to={`/server/${serverId}/list-short-urls/1`}>List short URLs</NotFound>}
|
||||
/>
|
||||
</Switch>
|
||||
</div>
|
||||
|
||||
<div className="menu-layout__footer text-center text-md-right">
|
||||
<ShlinkVersions />
|
||||
</div>
|
||||
</div>
|
||||
</Swipeable>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
</div>
|
||||
</Swipeable>
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
MenuLayoutComp.propTypes = propTypes;
|
||||
|
||||
return withSelectedServer(MenuLayoutComp, ServerError);
|
||||
};
|
||||
|
||||
export default MenuLayout;
|
||||
|
||||
@@ -32,3 +32,26 @@
|
||||
.menu-layout__burger-icon--active {
|
||||
color: white;
|
||||
}
|
||||
|
||||
$footer-height: 2.3rem;
|
||||
$footer-margin: .8rem;
|
||||
|
||||
.menu-layout__container {
|
||||
padding: 20px 0 ($footer-height + $footer-margin);
|
||||
min-height: 100%;
|
||||
margin-bottom: -($footer-height + $footer-margin);
|
||||
|
||||
@media (min-width: $mdMin) {
|
||||
padding: 30px 15px ($footer-height + $footer-margin);
|
||||
}
|
||||
}
|
||||
|
||||
.menu-layout__footer {
|
||||
height: $footer-height;
|
||||
margin-top: $footer-margin;
|
||||
padding: 0;
|
||||
|
||||
@media (min-width: $mdMin) {
|
||||
padding: 0 15px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,17 +4,18 @@ import * as PropTypes from 'prop-types';
|
||||
|
||||
const propTypes = {
|
||||
to: PropTypes.string,
|
||||
btnText: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
const NotFound = ({ to = '/', btnText = 'Home' }) => (
|
||||
const NotFound = ({ to = '/', children = 'Home' }) => (
|
||||
<div className="home">
|
||||
<h2>Oops! We could not find requested route.</h2>
|
||||
<p>
|
||||
Use your browser{'\''}s back button to navigate to the page you have previously come from, or just press this button.
|
||||
Use your browser's back button to navigate to the page you have previously come from, or just press this
|
||||
button.
|
||||
</p>
|
||||
<br />
|
||||
<Link to={to} className="btn btn-outline-primary btn-lg">{btnText}</Link>
|
||||
<Link to={to} className="btn btn-outline-primary btn-lg">{children}</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
29
src/common/ShlinkVersions.js
Normal file
29
src/common/ShlinkVersions.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { pipe } from 'ramda';
|
||||
import { serverType } from '../servers/prop-types';
|
||||
import { versionToPrintable, versionToSemVer } from '../utils/helpers/version';
|
||||
|
||||
const SHLINK_WEB_CLIENT_VERSION = '%_VERSION_%';
|
||||
|
||||
const propTypes = {
|
||||
selectedServer: serverType,
|
||||
className: PropTypes.string,
|
||||
clientVersion: PropTypes.string,
|
||||
};
|
||||
|
||||
const ShlinkVersions = ({ selectedServer, className, clientVersion = SHLINK_WEB_CLIENT_VERSION }) => {
|
||||
const { printableVersion: serverVersion } = selectedServer;
|
||||
const normalizedClientVersion = pipe(versionToSemVer(), versionToPrintable)(clientVersion);
|
||||
|
||||
return (
|
||||
<small className={classNames('text-muted', className)}>
|
||||
Client: <b>{normalizedClientVersion}</b> - Server: <b>{serverVersion}</b>
|
||||
</small>
|
||||
);
|
||||
};
|
||||
|
||||
ShlinkVersions.propTypes = propTypes;
|
||||
|
||||
export default ShlinkVersions;
|
||||
@@ -1,38 +1,18 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
|
||||
import { range, max, min } from 'ramda';
|
||||
import { isPageDisabled, keyForPage, progressivePagination } from '../utils/helpers/pagination';
|
||||
import './SimplePaginator.scss';
|
||||
|
||||
const propTypes = {
|
||||
pagesCount: PropTypes.number.isRequired,
|
||||
currentPage: PropTypes.number.isRequired,
|
||||
setCurrentPage: PropTypes.func.isRequired,
|
||||
centered: PropTypes.bool,
|
||||
};
|
||||
|
||||
export const ellipsis = '...';
|
||||
|
||||
const pagination = (currentPage, pageCount) => {
|
||||
const delta = 2;
|
||||
const pages = range(
|
||||
max(delta, currentPage - delta),
|
||||
min(pageCount - 1, currentPage + delta) + 1
|
||||
);
|
||||
|
||||
if (currentPage - delta > delta) {
|
||||
pages.unshift(ellipsis);
|
||||
}
|
||||
if (currentPage + delta < pageCount - 1) {
|
||||
pages.push(ellipsis);
|
||||
}
|
||||
|
||||
pages.unshift(1);
|
||||
pages.push(pageCount);
|
||||
|
||||
return pages;
|
||||
};
|
||||
|
||||
const SimplePaginator = ({ pagesCount, currentPage, setCurrentPage }) => {
|
||||
const SimplePaginator = ({ pagesCount, currentPage, setCurrentPage, centered = true }) => {
|
||||
if (pagesCount < 2) {
|
||||
return null;
|
||||
}
|
||||
@@ -40,17 +20,17 @@ const SimplePaginator = ({ pagesCount, currentPage, setCurrentPage }) => {
|
||||
const onClick = (page) => () => setCurrentPage(page);
|
||||
|
||||
return (
|
||||
<Pagination listClassName="flex-wrap justify-content-center mb-0 simple-paginator">
|
||||
<Pagination listClassName={classNames('flex-wrap mb-0 simple-paginator', { 'justify-content-center': centered })}>
|
||||
<PaginationItem disabled={currentPage <= 1}>
|
||||
<PaginationLink previous tag="span" onClick={onClick(currentPage - 1)} />
|
||||
</PaginationItem>
|
||||
{pagination(currentPage, pagesCount).map((page, index) => (
|
||||
{progressivePagination(currentPage, pagesCount).map((pageNumber, index) => (
|
||||
<PaginationItem
|
||||
key={page !== ellipsis ? page : `${page}_${index}`}
|
||||
active={page === currentPage}
|
||||
disabled={page === ellipsis}
|
||||
key={keyForPage(pageNumber, index)}
|
||||
disabled={isPageDisabled(pageNumber)}
|
||||
active={currentPage === pageNumber}
|
||||
>
|
||||
<PaginationLink tag="span" onClick={onClick(page)}>{page}</PaginationLink>
|
||||
<PaginationLink tag="span" onClick={onClick(pageNumber)}>{pageNumber}</PaginationLink>
|
||||
</PaginationItem>
|
||||
))}
|
||||
<PaginationItem disabled={currentPage >= pagesCount}>
|
||||
|
||||
@@ -4,6 +4,7 @@ import Home from '../Home';
|
||||
import MenuLayout from '../MenuLayout';
|
||||
import AsideMenu from '../AsideMenu';
|
||||
import ErrorHandler from '../ErrorHandler';
|
||||
import ShlinkVersions from '../ShlinkVersions';
|
||||
|
||||
const provideServices = (bottle, connect, withRouter) => {
|
||||
bottle.constant('window', global.window);
|
||||
@@ -25,13 +26,18 @@ const provideServices = (bottle, connect, withRouter) => {
|
||||
'ShortUrls',
|
||||
'AsideMenu',
|
||||
'CreateShortUrl',
|
||||
'ShortUrlVisits'
|
||||
'ShortUrlVisits',
|
||||
'ShlinkVersions',
|
||||
'ServerError'
|
||||
);
|
||||
bottle.decorator('MenuLayout', connect([ 'selectedServer', 'shortUrlsListParams' ], [ 'selectServer' ]));
|
||||
bottle.decorator('MenuLayout', withRouter);
|
||||
|
||||
bottle.serviceFactory('AsideMenu', AsideMenu, 'DeleteServerButton');
|
||||
|
||||
bottle.serviceFactory('ShlinkVersions', () => ShlinkVersions);
|
||||
bottle.decorator('ShlinkVersions', connect([ 'selectedServer' ]));
|
||||
|
||||
bottle.serviceFactory('ErrorHandler', ErrorHandler, 'window', 'console');
|
||||
};
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ const connect = (propsFromState, actionServiceNames = []) =>
|
||||
actionServiceNames.reduce(mapActionService, {})
|
||||
);
|
||||
|
||||
bottle.serviceFactory('App', App, 'MainHeader', 'Home', 'MenuLayout', 'CreateServer');
|
||||
bottle.serviceFactory('App', App, 'MainHeader', 'Home', 'MenuLayout', 'CreateServer', 'EditServer');
|
||||
|
||||
provideCommonServices(bottle, connect, withRouter);
|
||||
provideShortUrlsServices(bottle, connect);
|
||||
|
||||
@@ -10,10 +10,6 @@ body,
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
.nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bg-main {
|
||||
background-color: $mainColor !important;
|
||||
}
|
||||
@@ -28,14 +24,6 @@ body,
|
||||
color: inherit !important;
|
||||
}
|
||||
|
||||
.shlink-container {
|
||||
padding: 20px 0;
|
||||
|
||||
@media (min-width: $mdMin) {
|
||||
padding: 30px 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.badge-main {
|
||||
color: #fff;
|
||||
background-color: $mainColor;
|
||||
|
||||
@@ -7,6 +7,7 @@ import shortUrlCreationReducer from '../short-urls/reducers/shortUrlCreation';
|
||||
import shortUrlDeletionReducer from '../short-urls/reducers/shortUrlDeletion';
|
||||
import shortUrlTagsReducer from '../short-urls/reducers/shortUrlTags';
|
||||
import shortUrlMetaReducer from '../short-urls/reducers/shortUrlMeta';
|
||||
import shortUrlEditionReducer from '../short-urls/reducers/shortUrlEdition';
|
||||
import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits';
|
||||
import shortUrlDetailReducer from '../visits/reducers/shortUrlDetail';
|
||||
import tagsListReducer from '../tags/reducers/tagsList';
|
||||
@@ -22,6 +23,7 @@ export default combineReducers({
|
||||
shortUrlDeletion: shortUrlDeletionReducer,
|
||||
shortUrlTags: shortUrlTagsReducer,
|
||||
shortUrlMeta: shortUrlMetaReducer,
|
||||
shortUrlEdition: shortUrlEditionReducer,
|
||||
shortUrlVisits: shortUrlVisitsReducer,
|
||||
shortUrlDetail: shortUrlDetailReducer,
|
||||
tagsList: tagsListReducer,
|
||||
|
||||
@@ -1,91 +1,56 @@
|
||||
import { assoc, dissoc, pipe } from 'ramda';
|
||||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import PropTypes from 'prop-types';
|
||||
import './CreateServer.scss';
|
||||
import { ServerForm } from './helpers/ServerForm';
|
||||
|
||||
const SHOW_IMPORT_MSG_TIME = 4000;
|
||||
const propTypes = {
|
||||
createServer: PropTypes.func,
|
||||
history: PropTypes.shape({
|
||||
push: PropTypes.func,
|
||||
}),
|
||||
resetSelectedServer: PropTypes.func,
|
||||
};
|
||||
|
||||
const CreateServer = (ImportServersBtn, stateFlagTimeout) => class CreateServer extends React.Component {
|
||||
static propTypes = {
|
||||
createServer: PropTypes.func,
|
||||
history: PropTypes.shape({
|
||||
push: PropTypes.func,
|
||||
}),
|
||||
resetSelectedServer: PropTypes.func,
|
||||
};
|
||||
const CreateServer = (ImportServersBtn, useStateFlagTimeout) => {
|
||||
const CreateServerComp = ({ createServer, history: { push }, resetSelectedServer }) => {
|
||||
const [ serversImported, setServersImported ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
|
||||
const handleSubmit = (serverData) => {
|
||||
const id = uuid();
|
||||
const server = { id, ...serverData };
|
||||
|
||||
state = {
|
||||
name: '',
|
||||
url: '',
|
||||
apiKey: '',
|
||||
serversImported: false,
|
||||
};
|
||||
createServer(server);
|
||||
push(`/server/${id}/list-short-urls/1`);
|
||||
};
|
||||
|
||||
handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const { createServer, history: { push } } = this.props;
|
||||
const server = pipe(
|
||||
assoc('id', uuid()),
|
||||
dissoc('serversImported')
|
||||
)(this.state);
|
||||
|
||||
createServer(server);
|
||||
push(`/server/${server.id}/list-short-urls/1`);
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.props.resetSelectedServer();
|
||||
}
|
||||
|
||||
render() {
|
||||
const renderInputGroup = (id, placeholder, type = 'text') => (
|
||||
<div className="form-group row">
|
||||
<label htmlFor={id} className="col-lg-1 col-md-2 col-form-label create-server__label">
|
||||
{placeholder}:
|
||||
</label>
|
||||
<div className="col-lg-11 col-md-10">
|
||||
<input
|
||||
type={type}
|
||||
className="form-control"
|
||||
id={id}
|
||||
placeholder={placeholder}
|
||||
value={this.state[id]}
|
||||
required
|
||||
onChange={(e) => this.setState({ [id]: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
useEffect(() => {
|
||||
resetSelectedServer();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="create-server">
|
||||
<form onSubmit={this.handleSubmit}>
|
||||
{renderInputGroup('name', 'Name')}
|
||||
{renderInputGroup('url', 'URL', 'url')}
|
||||
{renderInputGroup('apiKey', 'API key')}
|
||||
<ServerForm onSubmit={handleSubmit}>
|
||||
<ImportServersBtn onImport={setServersImported} />
|
||||
<button className="btn btn-outline-primary">Create server</button>
|
||||
</ServerForm>
|
||||
|
||||
<div className="text-right">
|
||||
<ImportServersBtn
|
||||
onImport={() => stateFlagTimeout(this.setState.bind(this), 'serversImported', true, SHOW_IMPORT_MSG_TIME)}
|
||||
/>
|
||||
<button className="btn btn-outline-primary">Create server</button>
|
||||
</div>
|
||||
|
||||
{this.state.serversImported && (
|
||||
<div className="row create-server__import-success-msg">
|
||||
<div className="col-md-10 offset-md-1">
|
||||
<div className="p-2 mt-3 bg-main text-white text-center">
|
||||
Servers properly imported. You can now select one from the list :)
|
||||
</div>
|
||||
{serversImported && (
|
||||
<div className="row create-server__import-success-msg">
|
||||
<div className="col-md-10 offset-md-1">
|
||||
<div className="p-2 mt-3 bg-main text-white text-center">
|
||||
Servers properly imported. You can now select one from the list :)
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
CreateServerComp.propTypes = propTypes;
|
||||
|
||||
return CreateServerComp;
|
||||
};
|
||||
|
||||
export default CreateServer;
|
||||
|
||||
@@ -1,40 +1,36 @@
|
||||
import React from 'react';
|
||||
import { faMinusCircle as deleteIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useToggle } from '../utils/helpers/hooks';
|
||||
import { serverType } from './prop-types';
|
||||
|
||||
const DeleteServerButton = (DeleteServerModal) => class DeleteServerButton extends React.Component {
|
||||
static propTypes = {
|
||||
server: serverType,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
const propTypes = {
|
||||
server: serverType,
|
||||
className: PropTypes.string,
|
||||
textClassName: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
state = { isModalOpen: false };
|
||||
|
||||
render() {
|
||||
const { server, className } = this.props;
|
||||
const DeleteServerButton = (DeleteServerModal) => {
|
||||
const DeleteServerButtonComp = ({ server, className, children, textClassName }) => {
|
||||
const [ isModalOpen, , showModal, hideModal ] = useToggle();
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<span
|
||||
className={className}
|
||||
key="deleteServerBtn"
|
||||
onClick={() => this.setState({ isModalOpen: true })}
|
||||
>
|
||||
<FontAwesomeIcon icon={deleteIcon} />
|
||||
<span className="aside-menu__item-text">Delete this server</span>
|
||||
<span className={className} onClick={showModal}>
|
||||
{!children && <FontAwesomeIcon icon={deleteIcon} />}
|
||||
<span className={textClassName}>{children || 'Remove this server'}</span>
|
||||
</span>
|
||||
|
||||
<DeleteServerModal
|
||||
isOpen={this.state.isModalOpen}
|
||||
toggle={() => this.setState(({ isModalOpen }) => ({ isModalOpen: !isModalOpen }))}
|
||||
server={server}
|
||||
key="deleteServerModal"
|
||||
/>
|
||||
<DeleteServerModal server={server} isOpen={isModalOpen} toggle={hideModal} />
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
DeleteServerButtonComp.propTypes = propTypes;
|
||||
|
||||
return DeleteServerButtonComp;
|
||||
};
|
||||
|
||||
export default DeleteServerButton;
|
||||
|
||||
@@ -22,12 +22,14 @@ const DeleteServerModal = ({ server, toggle, isOpen, deleteServer, history }) =>
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} toggle={toggle} centered>
|
||||
<ModalHeader toggle={toggle}><span className="text-danger">Delete server</span></ModalHeader>
|
||||
<ModalHeader toggle={toggle}><span className="text-danger">Remove server</span></ModalHeader>
|
||||
<ModalBody>
|
||||
<p>Are you sure you want to delete server <b>{server ? server.name : ''}</b>?</p>
|
||||
<p>Are you sure you want to remove <b>{server ? server.name : ''}</b>?</p>
|
||||
<p>
|
||||
No data will be deleted, only the access to that server will be removed from this host.
|
||||
You can create it again at any moment.
|
||||
<i>
|
||||
No data will be deleted, only the access to this server will be removed from this host.
|
||||
You can create it again at any moment.
|
||||
</i>
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
|
||||
34
src/servers/EditServer.js
Normal file
34
src/servers/EditServer.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { ServerForm } from './helpers/ServerForm';
|
||||
import { withSelectedServer } from './helpers/withSelectedServer';
|
||||
import { serverType } from './prop-types';
|
||||
|
||||
const propTypes = {
|
||||
editServer: PropTypes.func,
|
||||
selectedServer: serverType,
|
||||
history: PropTypes.shape({
|
||||
push: PropTypes.func,
|
||||
}),
|
||||
};
|
||||
|
||||
export const EditServer = (ServerError) => {
|
||||
const EditServerComp = ({ editServer, selectedServer, history: { push } }) => {
|
||||
const handleSubmit = (serverData) => {
|
||||
editServer(selectedServer.id, serverData);
|
||||
push(`/server/${selectedServer.id}/list-short-urls/1`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="create-server">
|
||||
<ServerForm initialValues={selectedServer} onSubmit={handleSubmit}>
|
||||
<button className="btn btn-outline-primary">Save</button>
|
||||
</ServerForm>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
EditServerComp.propTypes = propTypes;
|
||||
|
||||
return withSelectedServer(EditServerComp, ServerError);
|
||||
};
|
||||
@@ -8,7 +8,6 @@ const ServersDropdown = (serversExporter) => class ServersDropdown extends React
|
||||
static propTypes = {
|
||||
servers: PropTypes.object,
|
||||
selectedServer: serverType,
|
||||
selectServer: PropTypes.func,
|
||||
listServers: PropTypes.func,
|
||||
history: PropTypes.shape({
|
||||
push: PropTypes.func,
|
||||
@@ -16,14 +15,10 @@ const ServersDropdown = (serversExporter) => class ServersDropdown extends React
|
||||
};
|
||||
|
||||
renderServers = () => {
|
||||
const { servers: { list, loading }, selectedServer, selectServer } = this.props;
|
||||
const { servers: { list, loading }, selectedServer } = this.props;
|
||||
const servers = values(list);
|
||||
const { push } = this.props.history;
|
||||
const loadServer = (id) => {
|
||||
selectServer(id)
|
||||
.then(() => push(`/server/${id}/list-short-urls/1`))
|
||||
.catch(() => {});
|
||||
};
|
||||
const loadServer = (id) => push(`/server/${id}/list-short-urls/1`);
|
||||
|
||||
if (loading) {
|
||||
return <DropdownItem disabled><i>Trying to load servers...</i></DropdownItem>;
|
||||
@@ -41,10 +36,7 @@ const ServersDropdown = (serversExporter) => class ServersDropdown extends React
|
||||
</DropdownItem>
|
||||
))}
|
||||
<DropdownItem divider />
|
||||
<DropdownItem
|
||||
className="servers-dropdown__export-item"
|
||||
onClick={() => serversExporter.exportServers()}
|
||||
>
|
||||
<DropdownItem className="servers-dropdown__export-item" onClick={() => serversExporter.exportServers()}>
|
||||
Export servers
|
||||
</DropdownItem>
|
||||
</React.Fragment>
|
||||
|
||||
42
src/servers/ServersListGroup.js
Normal file
42
src/servers/ServersListGroup.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { ListGroup, ListGroupItem } from 'reactstrap';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faChevronRight as chevronIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { serverType } from './prop-types';
|
||||
import './ServersListGroup.scss';
|
||||
|
||||
const propTypes = {
|
||||
servers: PropTypes.arrayOf(serverType).isRequired,
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
const ServerListItem = ({ id, name }) => (
|
||||
<ListGroupItem tag={Link} to={`/server/${id}/list-short-urls/1`} className="servers-list__server-item">
|
||||
{name}
|
||||
<FontAwesomeIcon icon={chevronIcon} className="servers-list__server-item-icon" />
|
||||
</ListGroupItem>
|
||||
);
|
||||
|
||||
ServerListItem.propTypes = {
|
||||
id: PropTypes.string,
|
||||
name: PropTypes.string,
|
||||
};
|
||||
|
||||
const ServersListGroup = ({ servers, children }) => (
|
||||
<React.Fragment>
|
||||
<div className="container">
|
||||
<h5>{children}</h5>
|
||||
</div>
|
||||
{servers.length > 0 && (
|
||||
<ListGroup className="servers-list__list-group mt-md-3">
|
||||
{servers.map(({ id, name }) => <ServerListItem key={id} id={id} name={name} />)}
|
||||
</ListGroup>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
ServersListGroup.propTypes = propTypes;
|
||||
|
||||
export default ServersListGroup;
|
||||
18
src/servers/ServersListGroup.scss
Normal file
18
src/servers/ServersListGroup.scss
Normal file
@@ -0,0 +1,18 @@
|
||||
@import '../utils/mixins/vertical-align';
|
||||
|
||||
.servers-list__list-group {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.servers-list__server-item.servers-list__server-item {
|
||||
text-align: left;
|
||||
position: relative;
|
||||
padding: .75rem 2.5rem .75rem 1rem;
|
||||
}
|
||||
|
||||
.servers-list__server-item-icon {
|
||||
@include vertical-align();
|
||||
|
||||
right: 1rem;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { compareVersions } from '../../utils/utils';
|
||||
import { serverType } from '../prop-types';
|
||||
import { versionMatch } from '../../utils/helpers/version';
|
||||
|
||||
const propTypes = {
|
||||
minVersion: PropTypes.string,
|
||||
@@ -16,10 +16,9 @@ const ForServerVersion = ({ minVersion, maxVersion, selectedServer, children })
|
||||
}
|
||||
|
||||
const { version } = selectedServer;
|
||||
const matchesMinVersion = !minVersion || compareVersions(version, '>=', minVersion);
|
||||
const matchesMaxVersion = !maxVersion || compareVersions(version, '<=', maxVersion);
|
||||
const matchesVersion = versionMatch(version, { maxVersion, minVersion });
|
||||
|
||||
if (!matchesMinVersion || !matchesMaxVersion) {
|
||||
if (!matchesVersion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
50
src/servers/helpers/ServerError.js
Normal file
50
src/servers/helpers/ServerError.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Message from '../../utils/Message';
|
||||
import ServersListGroup from '../ServersListGroup';
|
||||
import { serverType } from '../prop-types';
|
||||
import './ServerError.scss';
|
||||
|
||||
const propTypes = {
|
||||
servers: PropTypes.object,
|
||||
selectedServer: serverType,
|
||||
type: PropTypes.oneOf([ 'not-found', 'not-reachable' ]).isRequired,
|
||||
};
|
||||
|
||||
export const ServerError = (DeleteServerButton) => {
|
||||
const ServerErrorComp = ({ type, servers: { list }, selectedServer }) => (
|
||||
<div className="server-error__container flex-column">
|
||||
<div className="row w-100 mb-3 mb-md-5">
|
||||
<Message type="error">
|
||||
{type === 'not-found' && 'Could not find this Shlink server.'}
|
||||
{type === 'not-reachable' && (
|
||||
<React.Fragment>
|
||||
<p>Oops! Could not connect to this Shlink server.</p>
|
||||
Make sure you have internet connection, and the server is properly configured and on-line.
|
||||
</React.Fragment>
|
||||
)}
|
||||
</Message>
|
||||
</div>
|
||||
|
||||
<ServersListGroup servers={Object.values(list)}>
|
||||
These are the Shlink servers currently configured. Choose one of
|
||||
them or <Link to="/server/create">add a new one</Link>.
|
||||
</ServersListGroup>
|
||||
|
||||
{type === 'not-reachable' && (
|
||||
<div className="container mt-3 mt-md-5">
|
||||
<h5>
|
||||
Alternatively, if you think you may have miss-configured this server, you
|
||||
can <DeleteServerButton server={selectedServer} className="server-error__delete-btn">remove it</DeleteServerButton> or
|
||||
<Link to={`/server/${selectedServer.id}/edit`}>edit it</Link>.
|
||||
</h5>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
ServerErrorComp.propTypes = propTypes;
|
||||
|
||||
return ServerErrorComp;
|
||||
};
|
||||
17
src/servers/helpers/ServerError.scss
Normal file
17
src/servers/helpers/ServerError.scss
Normal file
@@ -0,0 +1,17 @@
|
||||
@import '../../utils/base';
|
||||
|
||||
.server-error__container {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.server-error__delete-btn {
|
||||
color: $dangerColor;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.server-error__delete-btn:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
41
src/servers/helpers/ServerForm.js
Normal file
41
src/servers/helpers/ServerForm.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { HorizontalFormGroup } from '../../utils/HorizontalFormGroup';
|
||||
|
||||
const propTypes = {
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
initialValues: PropTypes.shape({
|
||||
name: PropTypes.string.isRequired,
|
||||
url: PropTypes.string.isRequired,
|
||||
apiKey: PropTypes.string.isRequired,
|
||||
}),
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
export const ServerForm = ({ onSubmit, initialValues, children }) => {
|
||||
const [ name, setName ] = useState('');
|
||||
const [ url, setUrl ] = useState('');
|
||||
const [ apiKey, setApiKey ] = useState('');
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
onSubmit({ name, url, apiKey });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
initialValues && setName(initialValues.name);
|
||||
initialValues && setUrl(initialValues.url);
|
||||
initialValues && setApiKey(initialValues.apiKey);
|
||||
}, [ initialValues ]);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<HorizontalFormGroup value={name} onChange={setName}>Name</HorizontalFormGroup>
|
||||
<HorizontalFormGroup type="url" value={url} onChange={setUrl}>URL</HorizontalFormGroup>
|
||||
<HorizontalFormGroup value={apiKey} onChange={setApiKey}>API key</HorizontalFormGroup>
|
||||
|
||||
<div className="text-right">{children}</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
ServerForm.propTypes = propTypes;
|
||||
35
src/servers/helpers/withSelectedServer.js
Normal file
35
src/servers/helpers/withSelectedServer.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Message from '../../utils/Message';
|
||||
import { serverType } from '../prop-types';
|
||||
|
||||
const propTypes = {
|
||||
selectServer: PropTypes.func,
|
||||
selectedServer: serverType,
|
||||
match: PropTypes.object,
|
||||
};
|
||||
|
||||
export const withSelectedServer = (WrappedComponent, ServerError) => {
|
||||
const Component = (props) => {
|
||||
const { selectServer, selectedServer, match } = props;
|
||||
const { params: { serverId } } = match;
|
||||
|
||||
useEffect(() => {
|
||||
selectServer(serverId);
|
||||
}, [ serverId ]);
|
||||
|
||||
if (!selectedServer) {
|
||||
return <Message loading />;
|
||||
}
|
||||
|
||||
if (selectedServer.serverNotFound) {
|
||||
return <ServerError type="not-found" />;
|
||||
}
|
||||
|
||||
return <WrappedComponent {...props} />;
|
||||
};
|
||||
|
||||
Component.propTypes = propTypes;
|
||||
|
||||
return Component;
|
||||
};
|
||||
@@ -1,9 +1,20 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export const serverType = PropTypes.shape({
|
||||
const regularServerType = PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
name: PropTypes.string,
|
||||
url: PropTypes.string,
|
||||
apiKey: PropTypes.string,
|
||||
version: PropTypes.string,
|
||||
printableVersion: PropTypes.string,
|
||||
serverNotReachable: PropTypes.bool,
|
||||
});
|
||||
|
||||
const notFoundServerType = PropTypes.shape({
|
||||
serverNotFound: PropTypes.bool.isRequired,
|
||||
});
|
||||
|
||||
export const serverType = PropTypes.oneOfType([
|
||||
regularServerType,
|
||||
notFoundServerType,
|
||||
]);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createAction, handleActions } from 'redux-actions';
|
||||
import { identity, memoizeWith, pipe } from 'ramda';
|
||||
import { resetShortUrlParams } from '../../short-urls/reducers/shortUrlsListParams';
|
||||
import { versionIsValidSemVer } from '../../utils/utils';
|
||||
import { versionToPrintable, versionToSemVer as toSemVer } from '../../utils/helpers/version';
|
||||
|
||||
/* eslint-disable padding-line-between-statements */
|
||||
export const SELECT_SERVER = 'shlink/selectedServer/SELECT_SERVER';
|
||||
@@ -12,26 +13,50 @@ export const LATEST_VERSION_CONSTRAINT = 'latest';
|
||||
/* eslint-enable padding-line-between-statements */
|
||||
|
||||
const initialState = null;
|
||||
const versionToSemVer = pipe(
|
||||
(version) => version === LATEST_VERSION_CONSTRAINT ? MAX_FALLBACK_VERSION : version,
|
||||
toSemVer(MIN_FALLBACK_VERSION)
|
||||
);
|
||||
|
||||
const getServerVersion = memoizeWith(identity, (serverId, health) => health().then(({ version }) => ({
|
||||
version: versionToSemVer(version),
|
||||
printableVersion: versionToPrintable(version),
|
||||
})));
|
||||
|
||||
export const resetSelectedServer = createAction(RESET_SELECTED_SERVER);
|
||||
|
||||
export const selectServer = ({ findServerById }, buildShlinkApiClient) => (serverId) => async (dispatch) => {
|
||||
dispatch(resetSelectedServer());
|
||||
dispatch(resetShortUrlParams());
|
||||
|
||||
const selectedServer = findServerById(serverId);
|
||||
const { health } = await buildShlinkApiClient(selectedServer);
|
||||
const version = await health()
|
||||
.then(({ version }) => version === LATEST_VERSION_CONSTRAINT ? MAX_FALLBACK_VERSION : version)
|
||||
.then((version) => !versionIsValidSemVer(version) ? MIN_FALLBACK_VERSION : version)
|
||||
.catch(() => MIN_FALLBACK_VERSION);
|
||||
|
||||
dispatch({
|
||||
type: SELECT_SERVER,
|
||||
selectedServer: {
|
||||
...selectedServer,
|
||||
version,
|
||||
},
|
||||
});
|
||||
if (!selectedServer) {
|
||||
dispatch({
|
||||
type: SELECT_SERVER,
|
||||
selectedServer: { serverNotFound: true },
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { health } = buildShlinkApiClient(selectedServer);
|
||||
const { version, printableVersion } = await getServerVersion(serverId, health);
|
||||
|
||||
dispatch({
|
||||
type: SELECT_SERVER,
|
||||
selectedServer: {
|
||||
...selectedServer,
|
||||
version,
|
||||
printableVersion,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
dispatch({
|
||||
type: SELECT_SERVER,
|
||||
selectedServer: { ...selectedServer, serverNotReachable: true },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default handleActions({
|
||||
|
||||
@@ -52,6 +52,8 @@ export const listServers = ({ listServers, createServers }, { get }) => () => as
|
||||
|
||||
export const createServer = ({ createServer }, listServersAction) => pipe(createServer, listServersAction);
|
||||
|
||||
export const editServer = ({ editServer }, listServersAction) => pipe(editServer, listServersAction);
|
||||
|
||||
export const deleteServer = ({ deleteServer }, listServersAction) => pipe(deleteServer, listServersAction);
|
||||
|
||||
export const createServers = ({ createServers }, listServersAction) => pipe(
|
||||
|
||||
@@ -25,4 +25,14 @@ export default class ServersService {
|
||||
|
||||
deleteServer = ({ id }) =>
|
||||
this.storage.set(SERVERS_STORAGE_KEY, dissoc(id, this.listServers()));
|
||||
|
||||
editServer = (id, serverData) => {
|
||||
const allServers = this.listServers();
|
||||
|
||||
if (!allServers[id]) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.storage.set(SERVERS_STORAGE_KEY, assoc(id, { ...allServers[id], ...serverData }, allServers));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,22 +3,27 @@ import CreateServer from '../CreateServer';
|
||||
import ServersDropdown from '../ServersDropdown';
|
||||
import DeleteServerModal from '../DeleteServerModal';
|
||||
import DeleteServerButton from '../DeleteServerButton';
|
||||
import { EditServer } from '../EditServer';
|
||||
import ImportServersBtn from '../helpers/ImportServersBtn';
|
||||
import { resetSelectedServer, selectServer } from '../reducers/selectedServer';
|
||||
import { createServer, createServers, deleteServer, listServers } from '../reducers/server';
|
||||
import { createServer, createServers, deleteServer, editServer, listServers } from '../reducers/server';
|
||||
import ForServerVersion from '../helpers/ForServerVersion';
|
||||
import { ServerError } from '../helpers/ServerError';
|
||||
import ServersImporter from './ServersImporter';
|
||||
import ServersService from './ServersService';
|
||||
import ServersExporter from './ServersExporter';
|
||||
|
||||
const provideServices = (bottle, connect, withRouter) => {
|
||||
// Components
|
||||
bottle.serviceFactory('CreateServer', CreateServer, 'ImportServersBtn', 'stateFlagTimeout');
|
||||
bottle.serviceFactory('CreateServer', CreateServer, 'ImportServersBtn', 'useStateFlagTimeout');
|
||||
bottle.decorator('CreateServer', connect([ 'selectedServer' ], [ 'createServer', 'resetSelectedServer' ]));
|
||||
|
||||
bottle.serviceFactory('EditServer', EditServer, 'ServerError');
|
||||
bottle.decorator('EditServer', connect([ 'selectedServer' ], [ 'editServer', 'selectServer' ]));
|
||||
|
||||
bottle.serviceFactory('ServersDropdown', ServersDropdown, 'ServersExporter');
|
||||
bottle.decorator('ServersDropdown', withRouter);
|
||||
bottle.decorator('ServersDropdown', connect([ 'servers', 'selectedServer' ], [ 'listServers', 'selectServer' ]));
|
||||
bottle.decorator('ServersDropdown', connect([ 'servers', 'selectedServer' ], [ 'listServers' ]));
|
||||
|
||||
bottle.serviceFactory('DeleteServerModal', () => DeleteServerModal);
|
||||
bottle.decorator('DeleteServerModal', withRouter);
|
||||
@@ -32,6 +37,9 @@ const provideServices = (bottle, connect, withRouter) => {
|
||||
bottle.serviceFactory('ForServerVersion', () => ForServerVersion);
|
||||
bottle.decorator('ForServerVersion', connect([ 'selectedServer' ]));
|
||||
|
||||
bottle.serviceFactory('ServerError', ServerError, 'DeleteServerButton');
|
||||
bottle.decorator('ServerError', connect([ 'servers', 'selectedServer' ]));
|
||||
|
||||
// Services
|
||||
bottle.constant('csvjson', csvjson);
|
||||
bottle.service('ServersImporter', ServersImporter, 'csvjson');
|
||||
@@ -43,6 +51,7 @@ const provideServices = (bottle, connect, withRouter) => {
|
||||
bottle.serviceFactory('createServer', createServer, 'ServersService', 'listServers');
|
||||
bottle.serviceFactory('createServers', createServers, 'ServersService', 'listServers');
|
||||
bottle.serviceFactory('deleteServer', deleteServer, 'ServersService', 'listServers');
|
||||
bottle.serviceFactory('editServer', editServer, 'ServersService', 'listServers');
|
||||
bottle.serviceFactory('listServers', listServers, 'ServersService', 'axios');
|
||||
|
||||
bottle.serviceFactory('resetSelectedServer', () => resetSelectedServer);
|
||||
|
||||
@@ -1,55 +1,66 @@
|
||||
import { faAngleDoubleDown as downIcon, faAngleDoubleUp as upIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { assoc, dissoc, isEmpty, isNil, pipe, replace, trim } from 'ramda';
|
||||
import React from 'react';
|
||||
import { isEmpty, isNil, pipe, replace, trim } from 'ramda';
|
||||
import React, { useState } from 'react';
|
||||
import { Collapse, FormGroup, Input } from 'reactstrap';
|
||||
import * as PropTypes from 'prop-types';
|
||||
import DateInput from '../utils/DateInput';
|
||||
import Checkbox from '../utils/Checkbox';
|
||||
import { serverType } from '../servers/prop-types';
|
||||
import { compareVersions } from '../utils/utils';
|
||||
import { versionMatch } from '../utils/helpers/version';
|
||||
import { hasValue } from '../utils/utils';
|
||||
import { useToggle } from '../utils/helpers/hooks';
|
||||
import { createShortUrlResultType } from './reducers/shortUrlCreation';
|
||||
import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon';
|
||||
|
||||
const normalizeTag = pipe(trim, replace(/ /g, '-'));
|
||||
const formatDate = (date) => isNil(date) ? date : date.format();
|
||||
|
||||
const CreateShortUrl = (
|
||||
TagsSelector,
|
||||
CreateShortUrlResult,
|
||||
ForServerVersion
|
||||
) => class CreateShortUrl extends React.Component {
|
||||
static propTypes = {
|
||||
createShortUrl: PropTypes.func,
|
||||
shortUrlCreationResult: createShortUrlResultType,
|
||||
resetCreateShortUrl: PropTypes.func,
|
||||
selectedServer: serverType,
|
||||
};
|
||||
const propTypes = {
|
||||
createShortUrl: PropTypes.func,
|
||||
shortUrlCreationResult: createShortUrlResultType,
|
||||
resetCreateShortUrl: PropTypes.func,
|
||||
selectedServer: serverType,
|
||||
};
|
||||
|
||||
state = {
|
||||
longUrl: '',
|
||||
tags: [],
|
||||
customSlug: undefined,
|
||||
domain: undefined,
|
||||
validSince: undefined,
|
||||
validUntil: undefined,
|
||||
maxVisits: undefined,
|
||||
findIfExists: false,
|
||||
moreOptionsVisible: false,
|
||||
};
|
||||
const initialState = {
|
||||
longUrl: '',
|
||||
tags: [],
|
||||
customSlug: '',
|
||||
shortCodeLength: '',
|
||||
domain: '',
|
||||
validSince: undefined,
|
||||
validUntil: undefined,
|
||||
maxVisits: '',
|
||||
findIfExists: false,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { createShortUrl, shortUrlCreationResult, resetCreateShortUrl } = this.props;
|
||||
const CreateShortUrl = (TagsSelector, CreateShortUrlResult, ForServerVersion) => {
|
||||
const CreateShortUrlComp = ({ createShortUrl, shortUrlCreationResult, resetCreateShortUrl, selectedServer }) => {
|
||||
const [ shortUrlCreation, setShortUrlCreation ] = useState(initialState);
|
||||
const [ moreOptionsVisible, toggleMoreOptionsVisible ] = useToggle();
|
||||
|
||||
const changeTags = (tags) => this.setState({ tags: tags.map(normalizeTag) });
|
||||
const changeTags = (tags) => setShortUrlCreation({ ...shortUrlCreation, tags: tags.map(normalizeTag) });
|
||||
const reset = () => setShortUrlCreation(initialState);
|
||||
const save = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const shortUrlData = {
|
||||
...shortUrlCreation,
|
||||
validSince: formatDate(shortUrlCreation.validSince),
|
||||
validUntil: formatDate(shortUrlCreation.validUntil),
|
||||
};
|
||||
|
||||
createShortUrl(shortUrlData).then(reset).catch(() => {});
|
||||
};
|
||||
const renderOptionalInput = (id, placeholder, type = 'text', props = {}) => (
|
||||
<FormGroup>
|
||||
<Input
|
||||
id={id}
|
||||
type={type}
|
||||
placeholder={placeholder}
|
||||
value={this.state[id]}
|
||||
onChange={(e) => this.setState({ [id]: e.target.value })}
|
||||
value={shortUrlCreation[id]}
|
||||
onChange={(e) => setShortUrlCreation({ ...shortUrlCreation, [id]: e.target.value })}
|
||||
{...props}
|
||||
/>
|
||||
</FormGroup>
|
||||
@@ -57,105 +68,106 @@ const CreateShortUrl = (
|
||||
const renderDateInput = (id, placeholder, props = {}) => (
|
||||
<div className="form-group">
|
||||
<DateInput
|
||||
selected={this.state[id]}
|
||||
selected={shortUrlCreation[id]}
|
||||
placeholderText={placeholder}
|
||||
isClearable
|
||||
onChange={(date) => this.setState({ [id]: date })}
|
||||
onChange={(date) => setShortUrlCreation({ ...shortUrlCreation, [id]: date })}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
const save = (e) => {
|
||||
e.preventDefault();
|
||||
createShortUrl(pipe(
|
||||
dissoc('moreOptionsVisible'),
|
||||
assoc('validSince', formatDate(this.state.validSince)),
|
||||
assoc('validUntil', formatDate(this.state.validUntil))
|
||||
)(this.state));
|
||||
};
|
||||
const currentServerVersion = this.props.selectedServer ? this.props.selectedServer.version : '';
|
||||
const disableDomain = isEmpty(currentServerVersion) || compareVersions(currentServerVersion, '<', '1.19.0-beta.1');
|
||||
|
||||
const currentServerVersion = selectedServer && selectedServer.version;
|
||||
const disableDomain = !versionMatch(currentServerVersion, { minVersion: '1.19.0-beta.1' });
|
||||
const disableShortCodeLength = !versionMatch(currentServerVersion, { minVersion: '2.1.0' });
|
||||
|
||||
return (
|
||||
<div className="shlink-container">
|
||||
<form onSubmit={save}>
|
||||
<form onSubmit={save}>
|
||||
<div className="form-group">
|
||||
<input
|
||||
className="form-control form-control-lg"
|
||||
type="url"
|
||||
placeholder="Insert the URL to be shortened"
|
||||
required
|
||||
value={shortUrlCreation.longUrl}
|
||||
onChange={(e) => setShortUrlCreation({ ...shortUrlCreation, longUrl: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Collapse isOpen={moreOptionsVisible}>
|
||||
<div className="form-group">
|
||||
<input
|
||||
className="form-control form-control-lg"
|
||||
type="url"
|
||||
placeholder="Insert the URL to be shortened"
|
||||
required
|
||||
value={this.state.longUrl}
|
||||
onChange={(e) => this.setState({ longUrl: e.target.value })}
|
||||
/>
|
||||
<TagsSelector tags={shortUrlCreation.tags} onChange={changeTags} />
|
||||
</div>
|
||||
|
||||
<Collapse isOpen={this.state.moreOptionsVisible}>
|
||||
<div className="form-group">
|
||||
<TagsSelector tags={this.state.tags} onChange={changeTags} />
|
||||
<div className="row">
|
||||
<div className="col-sm-4">
|
||||
{renderOptionalInput('customSlug', 'Custom slug')}
|
||||
</div>
|
||||
|
||||
<div className="row">
|
||||
<div className="col-sm-6">
|
||||
{renderOptionalInput('customSlug', 'Custom slug')}
|
||||
</div>
|
||||
<div className="col-sm-6">
|
||||
{renderOptionalInput('domain', 'Domain', 'text', {
|
||||
disabled: disableDomain,
|
||||
...disableDomain && { title: 'Shlink 1.19.0 or higher is required to be able to provide the domain' },
|
||||
})}
|
||||
</div>
|
||||
<div className="col-sm-4">
|
||||
{renderOptionalInput('shortCodeLength', 'Short code length', 'number', {
|
||||
min: 4,
|
||||
disabled: disableShortCodeLength || hasValue(shortUrlCreation.customSlug),
|
||||
...disableShortCodeLength && {
|
||||
title: 'Shlink 2.1.0 or higher is required to be able to provide the short code length',
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="row">
|
||||
<div className="col-sm-6">
|
||||
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
|
||||
</div>
|
||||
<div className="col-sm-3">
|
||||
{renderDateInput('validSince', 'Enabled since...', { maxDate: this.state.validUntil })}
|
||||
</div>
|
||||
<div className="col-sm-3">
|
||||
{renderDateInput('validUntil', 'Enabled until...', { minDate: this.state.validSince })}
|
||||
</div>
|
||||
<div className="col-sm-4">
|
||||
{renderOptionalInput('domain', 'Domain', 'text', {
|
||||
disabled: disableDomain,
|
||||
...disableDomain && { title: 'Shlink 1.19.0 or higher is required to be able to provide the domain' },
|
||||
})}
|
||||
</div>
|
||||
|
||||
<ForServerVersion minVersion="1.16.0">
|
||||
<div className="mb-4 text-right">
|
||||
<Checkbox
|
||||
className="mr-2"
|
||||
checked={this.state.findIfExists}
|
||||
onChange={(findIfExists) => this.setState({ findIfExists })}
|
||||
>
|
||||
Use existing URL if found
|
||||
</Checkbox>
|
||||
<UseExistingIfFoundInfoIcon />
|
||||
</div>
|
||||
</ForServerVersion>
|
||||
</Collapse>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-secondary"
|
||||
onClick={() => this.setState(({ moreOptionsVisible }) => ({ moreOptionsVisible: !moreOptionsVisible }))}
|
||||
>
|
||||
<FontAwesomeIcon icon={this.state.moreOptionsVisible ? upIcon : downIcon} />
|
||||
|
||||
{this.state.moreOptionsVisible ? 'Less' : 'More'} options
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-primary float-right"
|
||||
disabled={shortUrlCreationResult.loading || isEmpty(this.state.longUrl)}
|
||||
>
|
||||
{shortUrlCreationResult.loading ? 'Creating...' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<CreateShortUrlResult {...shortUrlCreationResult} resetCreateShortUrl={resetCreateShortUrl} />
|
||||
</form>
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="col-sm-4">
|
||||
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
|
||||
</div>
|
||||
<div className="col-sm-4">
|
||||
{renderDateInput('validSince', 'Enabled since...', { maxDate: shortUrlCreation.validUntil })}
|
||||
</div>
|
||||
<div className="col-sm-4">
|
||||
{renderDateInput('validUntil', 'Enabled until...', { minDate: shortUrlCreation.validSince })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ForServerVersion minVersion="1.16.0">
|
||||
<div className="mb-4 text-right">
|
||||
<Checkbox
|
||||
className="mr-2"
|
||||
checked={shortUrlCreation.findIfExists}
|
||||
onChange={(findIfExists) => setShortUrlCreation({ ...shortUrlCreation, findIfExists })}
|
||||
>
|
||||
Use existing URL if found
|
||||
</Checkbox>
|
||||
<UseExistingIfFoundInfoIcon />
|
||||
</div>
|
||||
</ForServerVersion>
|
||||
</Collapse>
|
||||
|
||||
<div>
|
||||
<button type="button" className="btn btn-outline-secondary" onClick={toggleMoreOptionsVisible}>
|
||||
<FontAwesomeIcon icon={moreOptionsVisible ? upIcon : downIcon} />
|
||||
|
||||
{moreOptionsVisible ? 'Less' : 'More'} options
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-primary float-right"
|
||||
disabled={shortUrlCreationResult.saving || isEmpty(shortUrlCreation.longUrl)}
|
||||
>
|
||||
{shortUrlCreationResult.saving ? 'Creating...' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<CreateShortUrlResult {...shortUrlCreationResult} resetCreateShortUrl={resetCreateShortUrl} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
CreateShortUrlComp.propTypes = propTypes;
|
||||
|
||||
return CreateShortUrlComp;
|
||||
};
|
||||
|
||||
export default CreateShortUrl;
|
||||
|
||||
@@ -2,7 +2,8 @@ import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
|
||||
import PropTypes from 'prop-types';
|
||||
import { rangeOf } from '../utils/utils';
|
||||
import { isPageDisabled, keyForPage, progressivePagination } from '../utils/helpers/pagination';
|
||||
import './Paginator.scss';
|
||||
|
||||
const propTypes = {
|
||||
serverId: PropTypes.string.isRequired,
|
||||
@@ -12,7 +13,7 @@ const propTypes = {
|
||||
}),
|
||||
};
|
||||
|
||||
export default function Paginator({ paginator = {}, serverId }) {
|
||||
const Paginator = ({ paginator = {}, serverId }) => {
|
||||
const { currentPage, pagesCount = 0 } = paginator;
|
||||
|
||||
if (pagesCount <= 1) {
|
||||
@@ -20,8 +21,12 @@ export default function Paginator({ paginator = {}, serverId }) {
|
||||
}
|
||||
|
||||
const renderPages = () =>
|
||||
rangeOf(pagesCount, (pageNumber) => (
|
||||
<PaginationItem key={pageNumber} active={currentPage === pageNumber}>
|
||||
progressivePagination(currentPage, pagesCount).map((pageNumber, index) => (
|
||||
<PaginationItem
|
||||
key={keyForPage(pageNumber, index)}
|
||||
disabled={isPageDisabled(pageNumber)}
|
||||
active={currentPage === pageNumber}
|
||||
>
|
||||
<PaginationLink
|
||||
tag={Link}
|
||||
to={`/server/${serverId}/list-short-urls/${pageNumber}`}
|
||||
@@ -32,7 +37,7 @@ export default function Paginator({ paginator = {}, serverId }) {
|
||||
));
|
||||
|
||||
return (
|
||||
<Pagination listClassName="flex-wrap">
|
||||
<Pagination className="short-urls-paginator" listClassName="flex-wrap justify-content-center mb-0">
|
||||
<PaginationItem disabled={currentPage === 1}>
|
||||
<PaginationLink
|
||||
previous
|
||||
@@ -50,6 +55,8 @@ export default function Paginator({ paginator = {}, serverId }) {
|
||||
</PaginationItem>
|
||||
</Pagination>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Paginator.propTypes = propTypes;
|
||||
|
||||
export default Paginator;
|
||||
|
||||
7
src/short-urls/Paginator.scss
Normal file
7
src/short-urls/Paginator.scss
Normal file
@@ -0,0 +1,7 @@
|
||||
.short-urls-paginator {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
background-color: rgba(white, .8);
|
||||
padding: .75rem 0;
|
||||
border-top: 1px solid rgba(black, .125);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import moment from 'moment';
|
||||
import SearchField from '../utils/SearchField';
|
||||
import Tag from '../tags/helpers/Tag';
|
||||
import DateRangeRow from '../utils/DateRangeRow';
|
||||
import { formatDate } from '../utils/utils';
|
||||
import { formatDate } from '../utils/helpers/date';
|
||||
import { shortUrlsListParamsType } from './reducers/shortUrlsListParams';
|
||||
import './SearchBar.scss';
|
||||
|
||||
@@ -36,12 +36,16 @@ const SearchBar = (colorGenerator, ForServerVersion) => {
|
||||
|
||||
<ForServerVersion minVersion="1.21.0">
|
||||
<div className="mt-3">
|
||||
<DateRangeRow
|
||||
startDate={dateOrUndefined(shortUrlsListParams.startDate)}
|
||||
endDate={dateOrUndefined(shortUrlsListParams.endDate)}
|
||||
onStartDateChange={setDate('startDate')}
|
||||
onEndDateChange={setDate('endDate')}
|
||||
/>
|
||||
<div className="row">
|
||||
<div className="col-lg-8 offset-lg-4 col-xl-6 offset-xl-6">
|
||||
<DateRangeRow
|
||||
startDate={dateOrUndefined(shortUrlsListParams.startDate)}
|
||||
endDate={dateOrUndefined(shortUrlsListParams.endDate)}
|
||||
onStartDateChange={setDate('startDate')}
|
||||
onEndDateChange={setDate('endDate')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ForServerVersion>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Paginator from './Paginator';
|
||||
|
||||
@@ -14,16 +14,22 @@ const ShortUrls = (SearchBar, ShortUrlsList) => {
|
||||
const { match: { params }, shortUrlsList } = props;
|
||||
const { page, serverId } = params;
|
||||
const { data = [], pagination } = shortUrlsList;
|
||||
const [ urlsListKey, setUrlsListKey ] = useState(`${serverId}_${page}`);
|
||||
|
||||
// Using a key on a component makes react to create a new instance every time the key changes
|
||||
const urlsListKey = `${serverId}_${page}`;
|
||||
// Without it, pagination on the URL will not make the component to be refreshed
|
||||
useEffect(() => {
|
||||
setUrlsListKey(`${serverId}_${page}`);
|
||||
}, [ serverId, page ]);
|
||||
|
||||
return (
|
||||
<div className="shlink-container">
|
||||
<React.Fragment>
|
||||
<div className="form-group"><SearchBar /></div>
|
||||
<ShortUrlsList {...props} shortUrlsList={data} key={urlsListKey} />
|
||||
<Paginator paginator={pagination} serverId={serverId} />
|
||||
</div>
|
||||
<div>
|
||||
<ShortUrlsList {...props} shortUrlsList={data} key={urlsListKey} />
|
||||
<Paginator paginator={pagination} serverId={serverId} />
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -112,9 +112,9 @@ const ShortUrlsList = (ShortUrlsRow) => class ShortUrlsList extends React.Compon
|
||||
|
||||
return shortUrlsList.map((shortUrl) => (
|
||||
<ShortUrlsRow
|
||||
key={shortUrl.shortUrl}
|
||||
shortUrl={shortUrl}
|
||||
selectedServer={selectedServer}
|
||||
key={shortUrl.shortCode}
|
||||
refreshList={this.refreshList}
|
||||
shortUrlsListParams={shortUrlsListParams}
|
||||
/>
|
||||
@@ -161,7 +161,7 @@ const ShortUrlsList = (ShortUrlsRow) => class ShortUrlsList extends React.Compon
|
||||
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
|
||||
onClick={this.orderByColumn('visits')}
|
||||
>
|
||||
<span className="nowrap">{this.renderOrderIcon('visits')} Visits</span>
|
||||
<span className="indivisible">{this.renderOrderIcon('visits')} Visits</span>
|
||||
</th>
|
||||
<th className="short-urls-list__header-cell"> </th>
|
||||
</tr>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { Modal, ModalBody, ModalHeader } from 'reactstrap';
|
||||
import './UseExistingIfFoundInfoIcon.scss';
|
||||
import { useToggle } from '../utils/utils';
|
||||
import { useToggle } from '../utils/helpers/hooks';
|
||||
|
||||
const renderInfoModal = (isOpen, toggle) => (
|
||||
<Modal isOpen={isOpen} toggle={toggle} centered size="lg">
|
||||
@@ -38,7 +38,7 @@ const renderInfoModal = (isOpen, toggle) => (
|
||||
);
|
||||
|
||||
const UseExistingIfFoundInfoIcon = () => {
|
||||
const [ isModalOpen, toggleModal ] = useToggle(false);
|
||||
const [ isModalOpen, toggleModal ] = useToggle();
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { isEmpty, pipe } from 'ramda';
|
||||
import { shortUrlType } from '../reducers/shortUrlsList';
|
||||
import { shortUrlEditMetaType } from '../reducers/shortUrlMeta';
|
||||
import DateInput from '../../utils/DateInput';
|
||||
import { formatIsoDate } from '../../utils/utils';
|
||||
import { formatIsoDate } from '../../utils/helpers/date';
|
||||
|
||||
const propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
@@ -26,9 +26,7 @@ const dateOrUndefined = (shortUrl, dateName) => {
|
||||
return date && moment(date);
|
||||
};
|
||||
|
||||
const EditMetaModal = (
|
||||
{ isOpen, toggle, shortUrl, shortUrlMeta, editShortUrlMeta, resetShortUrlMeta }
|
||||
) => {
|
||||
const EditMetaModal = ({ isOpen, toggle, shortUrl, shortUrlMeta, editShortUrlMeta, resetShortUrlMeta }) => {
|
||||
const { saving, error } = shortUrlMeta;
|
||||
const url = shortUrl && (shortUrl.shortUrl || '');
|
||||
const [ validSince, setValidSince ] = useState(dateOrUndefined(shortUrl, 'validSince'));
|
||||
|
||||
57
src/short-urls/helpers/EditShortUrlModal.js
Normal file
57
src/short-urls/helpers/EditShortUrlModal.js
Normal file
@@ -0,0 +1,57 @@
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Modal, ModalBody, ModalFooter, ModalHeader, FormGroup, Input, Button } from 'reactstrap';
|
||||
import { ExternalLink } from 'react-external-link';
|
||||
import { shortUrlType } from '../reducers/shortUrlsList';
|
||||
import { ShortUrlEditionType } from '../reducers/shortUrlEdition';
|
||||
import { hasValue } from '../../utils/utils';
|
||||
|
||||
const propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
toggle: PropTypes.func.isRequired,
|
||||
shortUrl: shortUrlType.isRequired,
|
||||
shortUrlEdition: ShortUrlEditionType,
|
||||
editShortUrl: PropTypes.func,
|
||||
};
|
||||
|
||||
const EditShortUrlModal = ({ isOpen, toggle, shortUrl, shortUrlEdition, editShortUrl }) => {
|
||||
const { saving, error } = shortUrlEdition;
|
||||
const url = shortUrl && (shortUrl.shortUrl || '');
|
||||
const [ longUrl, setLongUrl ] = useState(shortUrl.longUrl);
|
||||
|
||||
const doEdit = () => editShortUrl(shortUrl.shortCode, shortUrl.domain, longUrl).then(toggle);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} toggle={toggle} centered>
|
||||
<ModalHeader toggle={toggle}>
|
||||
Edit long URL for <ExternalLink href={url} />
|
||||
</ModalHeader>
|
||||
<form onSubmit={(e) => e.preventDefault() || doEdit()}>
|
||||
<ModalBody>
|
||||
<FormGroup className="mb-0">
|
||||
<Input
|
||||
type="url"
|
||||
required
|
||||
placeholder="Long URL"
|
||||
value={longUrl}
|
||||
onChange={(e) => setLongUrl(e.target.value)}
|
||||
/>
|
||||
</FormGroup>
|
||||
{error && (
|
||||
<div className="p-2 mt-2 bg-danger text-white text-center">
|
||||
Something went wrong while saving the long URL :(
|
||||
</div>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="link" onClick={toggle}>Cancel</Button>
|
||||
<Button color="primary" disabled={saving || !hasValue(longUrl)}>{saving ? 'Saving...' : 'Save'}</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
EditShortUrlModal.propTypes = propTypes;
|
||||
|
||||
export default EditShortUrlModal;
|
||||
@@ -3,6 +3,9 @@ import React from 'react';
|
||||
import Moment from 'react-moment';
|
||||
import PropTypes from 'prop-types';
|
||||
import { ExternalLink } from 'react-external-link';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faCopy as copyIcon } from '@fortawesome/free-regular-svg-icons';
|
||||
import { CopyToClipboard } from 'react-copy-to-clipboard';
|
||||
import { shortUrlsListParamsType } from '../reducers/shortUrlsListParams';
|
||||
import { serverType } from '../../servers/prop-types';
|
||||
import { shortUrlType } from '../reducers/shortUrlsList';
|
||||
@@ -10,53 +13,57 @@ import Tag from '../../tags/helpers/Tag';
|
||||
import ShortUrlVisitsCount from './ShortUrlVisitsCount';
|
||||
import './ShortUrlsRow.scss';
|
||||
|
||||
const propTypes = {
|
||||
refreshList: PropTypes.func,
|
||||
shortUrlsListParams: shortUrlsListParamsType,
|
||||
selectedServer: serverType,
|
||||
shortUrl: shortUrlType,
|
||||
};
|
||||
|
||||
const ShortUrlsRow = (
|
||||
ShortUrlsRowMenu,
|
||||
colorGenerator,
|
||||
stateFlagTimeout
|
||||
) => class ShortUrlsRow extends React.Component {
|
||||
static propTypes = {
|
||||
refreshList: PropTypes.func,
|
||||
shortUrlsListParams: shortUrlsListParamsType,
|
||||
selectedServer: serverType,
|
||||
shortUrl: shortUrlType,
|
||||
};
|
||||
useStateFlagTimeout
|
||||
) => {
|
||||
const ShortUrlsRowComp = ({ shortUrl, selectedServer, refreshList, shortUrlsListParams }) => {
|
||||
const [ copiedToClipboard, setCopiedToClipboard ] = useStateFlagTimeout(false);
|
||||
const renderTags = (tags) => {
|
||||
if (isEmpty(tags)) {
|
||||
return <i className="indivisible"><small>No tags</small></i>;
|
||||
}
|
||||
|
||||
state = { copiedToClipboard: false };
|
||||
const selectedTags = shortUrlsListParams.tags || [];
|
||||
|
||||
renderTags(tags) {
|
||||
if (isEmpty(tags)) {
|
||||
return <i className="nowrap"><small>No tags</small></i>;
|
||||
}
|
||||
|
||||
const { refreshList, shortUrlsListParams } = this.props;
|
||||
const selectedTags = shortUrlsListParams.tags || [];
|
||||
|
||||
return tags.map((tag) => (
|
||||
<Tag
|
||||
colorGenerator={colorGenerator}
|
||||
key={tag}
|
||||
text={tag}
|
||||
onClick={() => refreshList({ tags: [ ...selectedTags, tag ] })}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
render() {
|
||||
const { shortUrl, selectedServer } = this.props;
|
||||
return tags.map((tag) => (
|
||||
<Tag
|
||||
colorGenerator={colorGenerator}
|
||||
key={tag}
|
||||
text={tag}
|
||||
onClick={() => refreshList({ tags: [ ...selectedTags, tag ] })}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<tr className="short-urls-row">
|
||||
<td className="nowrap short-urls-row__cell" data-th="Created at: ">
|
||||
<td className="indivisible short-urls-row__cell" data-th="Created at: ">
|
||||
<Moment format="YYYY-MM-DD HH:mm">{shortUrl.dateCreated}</Moment>
|
||||
</td>
|
||||
<td className="short-urls-row__cell" data-th="Short URL: ">
|
||||
<ExternalLink href={shortUrl.shortUrl} />
|
||||
<span className="indivisible short-urls-row__cell--relative">
|
||||
<ExternalLink href={shortUrl.shortUrl} />
|
||||
<CopyToClipboard text={shortUrl.shortUrl} onCopy={setCopiedToClipboard}>
|
||||
<FontAwesomeIcon icon={copyIcon} className="ml-2 short-urls-row__copy-btn" />
|
||||
</CopyToClipboard>
|
||||
<span className="badge badge-warning short-urls-row__copy-hint" hidden={!copiedToClipboard}>
|
||||
Copied short URL!
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="short-urls-row__cell short-urls-row__cell--break" data-th="Long URL: ">
|
||||
<ExternalLink href={shortUrl.longUrl} />
|
||||
</td>
|
||||
<td className="short-urls-row__cell" data-th="Tags: ">{this.renderTags(shortUrl.tags)}</td>
|
||||
<td className="short-urls-row__cell" data-th="Tags: ">{renderTags(shortUrl.tags)}</td>
|
||||
<td className="short-urls-row__cell text-md-right" data-th="Visits: ">
|
||||
<ShortUrlVisitsCount
|
||||
visitsCount={shortUrl.visitsCount}
|
||||
@@ -64,22 +71,16 @@ const ShortUrlsRow = (
|
||||
selectedServer={selectedServer}
|
||||
/>
|
||||
</td>
|
||||
<td className="short-urls-row__cell short-urls-row__cell--relative">
|
||||
<small
|
||||
className="badge badge-warning short-urls-row__copy-hint"
|
||||
hidden={!this.state.copiedToClipboard}
|
||||
>
|
||||
Copied short URL!
|
||||
</small>
|
||||
<ShortUrlsRowMenu
|
||||
selectedServer={selectedServer}
|
||||
shortUrl={shortUrl}
|
||||
onCopyToClipboard={() => stateFlagTimeout(this.setState.bind(this), 'copiedToClipboard')}
|
||||
/>
|
||||
<td className="short-urls-row__cell">
|
||||
<ShortUrlsRowMenu selectedServer={selectedServer} shortUrl={shortUrl} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
ShortUrlsRowComp.propTypes = propTypes;
|
||||
|
||||
return ShortUrlsRowComp;
|
||||
};
|
||||
|
||||
export default ShortUrlsRow;
|
||||
|
||||
@@ -43,11 +43,16 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.short-urls-row__copy-btn {
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.short-urls-row__copy-hint {
|
||||
@include vertical-align();
|
||||
right: 100%;
|
||||
@include vertical-align(translateX(10px));
|
||||
box-shadow: 0 3px 15px rgba(0, 0, 0, .25);
|
||||
|
||||
@media (max-width: $smMax) {
|
||||
right: calc(100% + 10px);
|
||||
@include vertical-align(translateX(calc(-100% - 20px)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { faCopy as copyIcon, faImage as pictureIcon } from '@fortawesome/free-regular-svg-icons';
|
||||
import { faImage as pictureIcon } from '@fortawesome/free-regular-svg-icons';
|
||||
import {
|
||||
faTags as tagsIcon,
|
||||
faChartPie as pieChartIcon,
|
||||
@@ -6,53 +6,37 @@ import {
|
||||
faQrcode as qrIcon,
|
||||
faMinusCircle as deleteIcon,
|
||||
faEdit as editIcon,
|
||||
faLink as linkIcon,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import React from 'react';
|
||||
import { CopyToClipboard } from 'react-copy-to-clipboard';
|
||||
import { ButtonDropdown, DropdownItem, DropdownMenu, DropdownToggle } from 'reactstrap';
|
||||
import PropTypes from 'prop-types';
|
||||
import { serverType } from '../../servers/prop-types';
|
||||
import { shortUrlType } from '../reducers/shortUrlsList';
|
||||
import { useToggle } from '../../utils/helpers/hooks';
|
||||
import PreviewModal from './PreviewModal';
|
||||
import QrCodeModal from './QrCodeModal';
|
||||
import VisitStatsLink from './VisitStatsLink';
|
||||
import './ShortUrlsRowMenu.scss';
|
||||
|
||||
const ShortUrlsRowMenu = (
|
||||
DeleteShortUrlModal,
|
||||
EditTagsModal,
|
||||
EditMetaModal,
|
||||
ForServerVersion
|
||||
) => class ShortUrlsRowMenu extends React.Component {
|
||||
static propTypes = {
|
||||
onCopyToClipboard: PropTypes.func,
|
||||
selectedServer: serverType,
|
||||
shortUrl: shortUrlType,
|
||||
};
|
||||
const propTypes = {
|
||||
selectedServer: serverType,
|
||||
shortUrl: shortUrlType,
|
||||
};
|
||||
|
||||
state = {
|
||||
isOpen: false,
|
||||
isQrModalOpen: false,
|
||||
isPreviewModalOpen: false,
|
||||
isTagsModalOpen: false,
|
||||
isMetaModalOpen: false,
|
||||
isDeleteModalOpen: false,
|
||||
};
|
||||
toggle = () => this.setState(({ isOpen }) => ({ isOpen: !isOpen }));
|
||||
|
||||
render() {
|
||||
const { onCopyToClipboard, shortUrl, selectedServer } = this.props;
|
||||
const ShortUrlsRowMenu = (DeleteShortUrlModal, EditTagsModal, EditMetaModal, EditShortUrlModal, ForServerVersion) => {
|
||||
const ShortUrlsRowMenuComp = ({ shortUrl, selectedServer }) => {
|
||||
const [ isOpen, toggle ] = useToggle();
|
||||
const [ isQrModalOpen, toggleQrCode ] = useToggle();
|
||||
const [ isPreviewModalOpen, togglePreview ] = useToggle();
|
||||
const [ isTagsModalOpen, toggleTags ] = useToggle();
|
||||
const [ isMetaModalOpen, toggleMeta ] = useToggle();
|
||||
const [ isDeleteModalOpen, toggleDelete ] = useToggle();
|
||||
const [ isEditModalOpen, toggleEdit ] = useToggle();
|
||||
const completeShortUrl = shortUrl && shortUrl.shortUrl ? shortUrl.shortUrl : '';
|
||||
const toggleModal = (prop) => () => this.setState((prevState) => ({ [prop]: !prevState[prop] }));
|
||||
const toggleQrCode = toggleModal('isQrModalOpen');
|
||||
const togglePreview = toggleModal('isPreviewModalOpen');
|
||||
const toggleTags = toggleModal('isTagsModalOpen');
|
||||
const toggleMeta = toggleModal('isMetaModalOpen');
|
||||
const toggleDelete = toggleModal('isDeleteModalOpen');
|
||||
|
||||
return (
|
||||
<ButtonDropdown toggle={this.toggle} isOpen={this.state.isOpen}>
|
||||
<ButtonDropdown toggle={toggle} isOpen={isOpen}>
|
||||
<DropdownToggle size="sm" caret outline className="short-urls-row-menu__dropdown-toggle">
|
||||
<FontAwesomeIcon icon={menuIcon} />
|
||||
</DropdownToggle>
|
||||
@@ -64,47 +48,48 @@ const ShortUrlsRowMenu = (
|
||||
<DropdownItem onClick={toggleTags}>
|
||||
<FontAwesomeIcon icon={tagsIcon} fixedWidth /> Edit tags
|
||||
</DropdownItem>
|
||||
<EditTagsModal shortUrl={shortUrl} isOpen={this.state.isTagsModalOpen} toggle={toggleTags} />
|
||||
<EditTagsModal shortUrl={shortUrl} isOpen={isTagsModalOpen} toggle={toggleTags} />
|
||||
|
||||
<ForServerVersion minVersion="1.18.0">
|
||||
<DropdownItem onClick={toggleMeta}>
|
||||
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit metadata
|
||||
</DropdownItem>
|
||||
<EditMetaModal shortUrl={shortUrl} isOpen={this.state.isMetaModalOpen} toggle={toggleMeta} />
|
||||
<EditMetaModal shortUrl={shortUrl} isOpen={isMetaModalOpen} toggle={toggleMeta} />
|
||||
</ForServerVersion>
|
||||
|
||||
<DropdownItem className="short-urls-row-menu__dropdown-item--danger" onClick={toggleDelete}>
|
||||
<FontAwesomeIcon icon={deleteIcon} fixedWidth /> Delete short URL
|
||||
</DropdownItem>
|
||||
<DeleteShortUrlModal shortUrl={shortUrl} isOpen={this.state.isDeleteModalOpen} toggle={toggleDelete} />
|
||||
|
||||
<DropdownItem divider />
|
||||
|
||||
<ForServerVersion maxVersion="1.x">
|
||||
<DropdownItem onClick={togglePreview}>
|
||||
<FontAwesomeIcon icon={pictureIcon} fixedWidth /> Preview
|
||||
<ForServerVersion minVersion="2.1.0">
|
||||
<DropdownItem onClick={toggleEdit}>
|
||||
<FontAwesomeIcon icon={linkIcon} fixedWidth /> Edit long URL
|
||||
</DropdownItem>
|
||||
<PreviewModal url={completeShortUrl} isOpen={this.state.isPreviewModalOpen} toggle={togglePreview} />
|
||||
<EditShortUrlModal shortUrl={shortUrl} isOpen={isEditModalOpen} toggle={toggleEdit} />
|
||||
</ForServerVersion>
|
||||
|
||||
<DropdownItem onClick={toggleQrCode}>
|
||||
<FontAwesomeIcon icon={qrIcon} fixedWidth /> QR code
|
||||
</DropdownItem>
|
||||
<QrCodeModal url={completeShortUrl} isOpen={this.state.isQrModalOpen} toggle={toggleQrCode} />
|
||||
<QrCodeModal url={completeShortUrl} isOpen={isQrModalOpen} toggle={toggleQrCode} />
|
||||
|
||||
<ForServerVersion maxVersion="1.x">
|
||||
<DropdownItem divider />
|
||||
<DropdownItem onClick={togglePreview}>
|
||||
<FontAwesomeIcon icon={pictureIcon} fixedWidth /> Preview
|
||||
</DropdownItem>
|
||||
<PreviewModal url={completeShortUrl} isOpen={isPreviewModalOpen} toggle={togglePreview} />
|
||||
</ForServerVersion>
|
||||
|
||||
<CopyToClipboard text={completeShortUrl} onCopy={onCopyToClipboard}>
|
||||
<DropdownItem>
|
||||
<FontAwesomeIcon icon={copyIcon} fixedWidth /> Copy to clipboard
|
||||
</DropdownItem>
|
||||
</CopyToClipboard>
|
||||
<DropdownItem divider />
|
||||
|
||||
<DropdownItem className="short-urls-row-menu__dropdown-item--danger" onClick={toggleDelete}>
|
||||
<FontAwesomeIcon icon={deleteIcon} fixedWidth /> Delete short URL
|
||||
</DropdownItem>
|
||||
<DeleteShortUrlModal shortUrl={shortUrl} isOpen={isDeleteModalOpen} toggle={toggleDelete} />
|
||||
</DropdownMenu>
|
||||
</ButtonDropdown>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
ShortUrlsRowMenuComp.propTypes = propTypes;
|
||||
|
||||
return ShortUrlsRowMenuComp;
|
||||
};
|
||||
|
||||
export default ShortUrlsRowMenu;
|
||||
|
||||
@@ -31,8 +31,7 @@ export default handleActions({
|
||||
|
||||
export const createShortUrl = (buildShlinkApiClient) => (data) => async (dispatch, getState) => {
|
||||
dispatch({ type: CREATE_SHORT_URL_START });
|
||||
|
||||
const { createShortUrl } = await buildShlinkApiClient(getState);
|
||||
const { createShortUrl } = buildShlinkApiClient(getState);
|
||||
|
||||
try {
|
||||
const result = await createShortUrl(data);
|
||||
@@ -40,6 +39,8 @@ export const createShortUrl = (buildShlinkApiClient) => (data) => async (dispatc
|
||||
dispatch({ type: CREATE_SHORT_URL, result });
|
||||
} catch (e) {
|
||||
dispatch({ type: CREATE_SHORT_URL_ERROR });
|
||||
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -32,8 +32,7 @@ export default handleActions({
|
||||
|
||||
export const deleteShortUrl = (buildShlinkApiClient) => (shortCode, domain) => async (dispatch, getState) => {
|
||||
dispatch({ type: DELETE_SHORT_URL_START });
|
||||
|
||||
const { deleteShortUrl } = await buildShlinkApiClient(getState);
|
||||
const { deleteShortUrl } = buildShlinkApiClient(getState);
|
||||
|
||||
try {
|
||||
await deleteShortUrl(shortCode, domain);
|
||||
|
||||
42
src/short-urls/reducers/shortUrlEdition.js
Normal file
42
src/short-urls/reducers/shortUrlEdition.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import { handleActions } from 'redux-actions';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
/* eslint-disable padding-line-between-statements */
|
||||
export const EDIT_SHORT_URL_START = 'shlink/shortUrlEdition/EDIT_SHORT_URL_START';
|
||||
export const EDIT_SHORT_URL_ERROR = 'shlink/shortUrlEdition/EDIT_SHORT_URL_ERROR';
|
||||
export const SHORT_URL_EDITED = 'shlink/shortUrlEdition/SHORT_URL_EDITED';
|
||||
/* eslint-enable padding-line-between-statements */
|
||||
|
||||
export const ShortUrlEditionType = PropTypes.shape({
|
||||
shortCode: PropTypes.string,
|
||||
longUrl: PropTypes.string,
|
||||
saving: PropTypes.bool.isRequired,
|
||||
error: PropTypes.bool.isRequired,
|
||||
});
|
||||
|
||||
const initialState = {
|
||||
shortCode: null,
|
||||
longUrl: null,
|
||||
saving: false,
|
||||
error: false,
|
||||
};
|
||||
|
||||
export default handleActions({
|
||||
[EDIT_SHORT_URL_START]: (state) => ({ ...state, saving: true, error: false }),
|
||||
[EDIT_SHORT_URL_ERROR]: (state) => ({ ...state, saving: false, error: true }),
|
||||
[SHORT_URL_EDITED]: (state, { shortCode, longUrl }) => ({ shortCode, longUrl, saving: false, error: false }),
|
||||
}, initialState);
|
||||
|
||||
export const editShortUrl = (buildShlinkApiClient) => (shortCode, domain, longUrl) => async (dispatch, getState) => {
|
||||
dispatch({ type: EDIT_SHORT_URL_START });
|
||||
const { updateShortUrlMeta } = buildShlinkApiClient(getState);
|
||||
|
||||
try {
|
||||
await updateShortUrlMeta(shortCode, domain, { longUrl });
|
||||
dispatch({ shortCode, longUrl, domain, type: SHORT_URL_EDITED });
|
||||
} catch (e) {
|
||||
dispatch({ type: EDIT_SHORT_URL_ERROR });
|
||||
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
@@ -37,7 +37,7 @@ export default handleActions({
|
||||
|
||||
export const editShortUrlMeta = (buildShlinkApiClient) => (shortCode, domain, meta) => async (dispatch, getState) => {
|
||||
dispatch({ type: EDIT_SHORT_URL_META_START });
|
||||
const { updateShortUrlMeta } = await buildShlinkApiClient(getState);
|
||||
const { updateShortUrlMeta } = buildShlinkApiClient(getState);
|
||||
|
||||
try {
|
||||
await updateShortUrlMeta(shortCode, domain, meta);
|
||||
|
||||
@@ -31,7 +31,7 @@ export default handleActions({
|
||||
|
||||
export const editShortUrlTags = (buildShlinkApiClient) => (shortCode, domain, tags) => async (dispatch, getState) => {
|
||||
dispatch({ type: EDIT_SHORT_URL_TAGS_START });
|
||||
const { updateShortUrlTags } = await buildShlinkApiClient(getState);
|
||||
const { updateShortUrlTags } = buildShlinkApiClient(getState);
|
||||
|
||||
try {
|
||||
const normalizedTags = await updateShortUrlTags(shortCode, domain, tags);
|
||||
|
||||
@@ -4,6 +4,7 @@ import PropTypes from 'prop-types';
|
||||
import { SHORT_URL_TAGS_EDITED } from './shortUrlTags';
|
||||
import { SHORT_URL_DELETED } from './shortUrlDeletion';
|
||||
import { SHORT_URL_META_EDITED, shortUrlMetaType } from './shortUrlMeta';
|
||||
import { SHORT_URL_EDITED } from './shortUrlEdition';
|
||||
|
||||
/* eslint-disable padding-line-between-statements */
|
||||
export const LIST_SHORT_URLS_START = 'shlink/shortUrlsList/LIST_SHORT_URLS_START';
|
||||
@@ -54,12 +55,12 @@ export default handleActions({
|
||||
),
|
||||
[SHORT_URL_TAGS_EDITED]: setPropFromActionOnMatchingShortUrl('tags'),
|
||||
[SHORT_URL_META_EDITED]: setPropFromActionOnMatchingShortUrl('meta'),
|
||||
[SHORT_URL_EDITED]: setPropFromActionOnMatchingShortUrl('longUrl'),
|
||||
}, initialState);
|
||||
|
||||
export const listShortUrls = (buildShlinkApiClient) => (params = {}) => async (dispatch, getState) => {
|
||||
dispatch({ type: LIST_SHORT_URLS_START });
|
||||
|
||||
const { listShortUrls } = await buildShlinkApiClient(getState);
|
||||
const { listShortUrls } = buildShlinkApiClient(getState);
|
||||
|
||||
try {
|
||||
const shortUrls = await listShortUrls(params);
|
||||
|
||||
@@ -9,6 +9,7 @@ import CreateShortUrl from '../CreateShortUrl';
|
||||
import DeleteShortUrlModal from '../helpers/DeleteShortUrlModal';
|
||||
import EditTagsModal from '../helpers/EditTagsModal';
|
||||
import EditMetaModal from '../helpers/EditMetaModal';
|
||||
import EditShortUrlModal from '../helpers/EditShortUrlModal';
|
||||
import CreateShortUrlResult from '../helpers/CreateShortUrlResult';
|
||||
import { listShortUrls } from '../reducers/shortUrlsList';
|
||||
import { createShortUrl, resetCreateShortUrl } from '../reducers/shortUrlCreation';
|
||||
@@ -16,6 +17,7 @@ import { deleteShortUrl, resetDeleteShortUrl } from '../reducers/shortUrlDeletio
|
||||
import { editShortUrlTags, resetShortUrlsTags } from '../reducers/shortUrlTags';
|
||||
import { editShortUrlMeta, resetShortUrlMeta } from '../reducers/shortUrlMeta';
|
||||
import { resetShortUrlParams } from '../reducers/shortUrlsListParams';
|
||||
import { editShortUrl } from '../reducers/shortUrlEdition';
|
||||
|
||||
const provideServices = (bottle, connect) => {
|
||||
// Components
|
||||
@@ -33,7 +35,7 @@ const provideServices = (bottle, connect) => {
|
||||
[ 'listShortUrls', 'resetShortUrlParams' ]
|
||||
));
|
||||
|
||||
bottle.serviceFactory('ShortUrlsRow', ShortUrlsRow, 'ShortUrlsRowMenu', 'ColorGenerator', 'stateFlagTimeout');
|
||||
bottle.serviceFactory('ShortUrlsRow', ShortUrlsRow, 'ShortUrlsRowMenu', 'ColorGenerator', 'useStateFlagTimeout');
|
||||
|
||||
bottle.serviceFactory(
|
||||
'ShortUrlsRowMenu',
|
||||
@@ -41,6 +43,7 @@ const provideServices = (bottle, connect) => {
|
||||
'DeleteShortUrlModal',
|
||||
'EditTagsModal',
|
||||
'EditMetaModal',
|
||||
'EditShortUrlModal',
|
||||
'ForServerVersion'
|
||||
);
|
||||
bottle.serviceFactory('CreateShortUrlResult', CreateShortUrlResult, 'stateFlagTimeout');
|
||||
@@ -60,6 +63,9 @@ const provideServices = (bottle, connect) => {
|
||||
bottle.serviceFactory('EditMetaModal', () => EditMetaModal);
|
||||
bottle.decorator('EditMetaModal', connect([ 'shortUrlMeta' ], [ 'editShortUrlMeta', 'resetShortUrlMeta' ]));
|
||||
|
||||
bottle.serviceFactory('EditShortUrlModal', () => EditShortUrlModal);
|
||||
bottle.decorator('EditShortUrlModal', connect([ 'shortUrlEdition' ], [ 'editShortUrl' ]));
|
||||
|
||||
// Actions
|
||||
bottle.serviceFactory('editShortUrlTags', editShortUrlTags, 'buildShlinkApiClient');
|
||||
bottle.serviceFactory('resetShortUrlsTags', () => resetShortUrlsTags);
|
||||
@@ -75,6 +81,8 @@ const provideServices = (bottle, connect) => {
|
||||
|
||||
bottle.serviceFactory('editShortUrlMeta', editShortUrlMeta, 'buildShlinkApiClient');
|
||||
bottle.serviceFactory('resetShortUrlMeta', () => resetShortUrlMeta);
|
||||
|
||||
bottle.serviceFactory('editShortUrl', editShortUrl, 'buildShlinkApiClient');
|
||||
};
|
||||
|
||||
export default provideServices;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { splitEvery } from 'ramda';
|
||||
import PropTypes from 'prop-types';
|
||||
import MuttedMessage from '../utils/MuttedMessage';
|
||||
import Message from '../utils/Message';
|
||||
import SearchField from '../utils/SearchField';
|
||||
|
||||
const { ceil } = Math;
|
||||
@@ -29,7 +29,7 @@ const TagsList = (TagCard) => class TagsList extends React.Component {
|
||||
const { tagsList, match } = this.props;
|
||||
|
||||
if (tagsList.loading) {
|
||||
return <MuttedMessage marginSize={0}>Loading...</MuttedMessage>;
|
||||
return <Message noMargin loading />;
|
||||
}
|
||||
|
||||
if (tagsList.error) {
|
||||
@@ -43,7 +43,7 @@ const TagsList = (TagCard) => class TagsList extends React.Component {
|
||||
const tagsCount = tagsList.filteredTags.length;
|
||||
|
||||
if (tagsCount < 1) {
|
||||
return <MuttedMessage>No tags found</MuttedMessage>;
|
||||
return <Message>No tags found</Message>;
|
||||
}
|
||||
|
||||
const tagsGroups = splitEvery(ceil(tagsCount / TAGS_GROUPS_AMOUNT), tagsList.filteredTags);
|
||||
@@ -69,14 +69,14 @@ const TagsList = (TagCard) => class TagsList extends React.Component {
|
||||
const { filterTags } = this.props;
|
||||
|
||||
return (
|
||||
<div className="shlink-container">
|
||||
<React.Fragment>
|
||||
{!this.props.tagsList.loading &&
|
||||
<SearchField className="mb-3" placeholder="Search tags..." onChange={filterTags} />
|
||||
}
|
||||
<div className="row">
|
||||
{this.renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -26,8 +26,7 @@ export default handleActions({
|
||||
|
||||
export const deleteTag = (buildShlinkApiClient) => (tag) => async (dispatch, getState) => {
|
||||
dispatch({ type: DELETE_TAG_START });
|
||||
|
||||
const { deleteTags } = await buildShlinkApiClient(getState);
|
||||
const { deleteTags } = buildShlinkApiClient(getState);
|
||||
|
||||
try {
|
||||
await deleteTags([ tag ]);
|
||||
|
||||
@@ -31,8 +31,7 @@ export const editTag = (buildShlinkApiClient, colorGenerator) => (oldName, newNa
|
||||
getState
|
||||
) => {
|
||||
dispatch({ type: EDIT_TAG_START });
|
||||
|
||||
const { editTag } = await buildShlinkApiClient(getState);
|
||||
const { editTag } = buildShlinkApiClient(getState);
|
||||
|
||||
try {
|
||||
await editTag(oldName, newName);
|
||||
|
||||
@@ -50,7 +50,7 @@ export const listTags = (buildShlinkApiClient, force = true) => () => async (dis
|
||||
dispatch({ type: LIST_TAGS_START });
|
||||
|
||||
try {
|
||||
const { listTags } = await buildShlinkApiClient(getState);
|
||||
const { listTags } = buildShlinkApiClient(getState);
|
||||
const tags = await listTags();
|
||||
|
||||
dispatch({ tags, type: LIST_TAGS });
|
||||
|
||||
@@ -12,6 +12,7 @@ const propTypes = {
|
||||
isClearable: PropTypes.bool,
|
||||
selected: PropTypes.oneOfType([ PropTypes.string, PropTypes.object ]),
|
||||
ref: PropTypes.object,
|
||||
disabled: PropTypes.bool,
|
||||
};
|
||||
|
||||
const DateInput = (props) => {
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
|
||||
.date-input-container__input {
|
||||
padding-right: 35px !important;
|
||||
}
|
||||
|
||||
.date-input-container__input:not(:disabled) {
|
||||
background-color: #fff !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,26 +9,29 @@ const propTypes = {
|
||||
endDate: dateType,
|
||||
onStartDateChange: PropTypes.func.isRequired,
|
||||
onEndDateChange: PropTypes.func.isRequired,
|
||||
disabled: PropTypes.bool,
|
||||
};
|
||||
|
||||
const DateRangeRow = ({ startDate, endDate, onStartDateChange, onEndDateChange }) => (
|
||||
const DateRangeRow = ({ startDate, endDate, onStartDateChange, onEndDateChange, disabled = false }) => (
|
||||
<div className="row">
|
||||
<div className="col-xl-3 col-lg-4 col-md-6 offset-xl-6 offset-lg-4">
|
||||
<div className="col-md-6">
|
||||
<DateInput
|
||||
selected={startDate}
|
||||
placeholderText="Since"
|
||||
isClearable
|
||||
maxDate={endDate}
|
||||
disabled={disabled}
|
||||
onChange={onStartDateChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-xl-3 col-lg-4 col-md-6">
|
||||
<div className="col-md-6">
|
||||
<DateInput
|
||||
className="date-range-row__date-input"
|
||||
selected={endDate}
|
||||
placeholderText="Until"
|
||||
isClearable
|
||||
minDate={startDate}
|
||||
disabled={disabled}
|
||||
onChange={onEndDateChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
32
src/utils/HorizontalFormGroup.js
Normal file
32
src/utils/HorizontalFormGroup.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
value: PropTypes.string.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
id: PropTypes.string,
|
||||
type: PropTypes.string,
|
||||
required: PropTypes.bool,
|
||||
};
|
||||
|
||||
export const HorizontalFormGroup = ({ children, value, onChange, id = uuid(), type = 'text', required = true }) => (
|
||||
<div className="form-group row">
|
||||
<label htmlFor={id} className="col-lg-1 col-md-2 col-form-label create-server__label">
|
||||
{children}:
|
||||
</label>
|
||||
<div className="col-lg-11 col-md-10">
|
||||
<input
|
||||
className="form-control"
|
||||
type={type}
|
||||
id={id}
|
||||
value={value}
|
||||
required={required}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
HorizontalFormGroup.propTypes = propTypes;
|
||||
48
src/utils/Message.js
Normal file
48
src/utils/Message.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { Card } from 'reactstrap';
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import { faCircleNotch as preloader } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
|
||||
const getClassForType = (type) => {
|
||||
const map = {
|
||||
error: 'border-danger',
|
||||
};
|
||||
|
||||
return map[type] || '';
|
||||
};
|
||||
const getTextClassForType = (type) => {
|
||||
const map = {
|
||||
error: 'text-danger',
|
||||
};
|
||||
|
||||
return map[type] || 'text-muted';
|
||||
};
|
||||
|
||||
const propTypes = {
|
||||
noMargin: PropTypes.bool,
|
||||
loading: PropTypes.bool,
|
||||
children: PropTypes.node,
|
||||
type: PropTypes.oneOf([ 'default', 'error' ]),
|
||||
};
|
||||
|
||||
const Message = ({ children, loading = false, noMargin = false, type = 'default' }) => {
|
||||
const cardClasses = classNames('bg-light', getClassForType(type), { 'mt-4': !noMargin });
|
||||
|
||||
return (
|
||||
<div className="col-md-10 offset-md-1">
|
||||
<Card className={cardClasses} body>
|
||||
<h3 className={classNames('text-center mb-0', getTextClassForType(type))}>
|
||||
{loading && <FontAwesomeIcon icon={preloader} spin />}
|
||||
{loading && <span className="ml-2">{children || 'Loading...'}</span>}
|
||||
{!loading && children}
|
||||
</h3>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Message.propTypes = propTypes;
|
||||
|
||||
export default Message;
|
||||
@@ -1,28 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Card } from 'reactstrap';
|
||||
import classnames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const DEFAULT_MARGIN_SIZE = 4;
|
||||
const propTypes = {
|
||||
marginSize: PropTypes.number,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
export default function MutedMessage({ children, marginSize = DEFAULT_MARGIN_SIZE }) {
|
||||
const cardClasses = classnames('bg-light', {
|
||||
[`mt-${marginSize}`]: marginSize > 0,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="col-md-10 offset-md-1">
|
||||
<Card className={cardClasses} body>
|
||||
<h3 className="text-center text-muted mb-0">
|
||||
{children}
|
||||
</h3>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
MutedMessage.propTypes = propTypes;
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faSearch as searchIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import PropTypes from 'prop-types';
|
||||
@@ -6,62 +6,59 @@ import classNames from 'classnames';
|
||||
import './SearchField.scss';
|
||||
|
||||
const DEFAULT_SEARCH_INTERVAL = 500;
|
||||
let timer;
|
||||
|
||||
export default class SearchField extends React.Component {
|
||||
static propTypes = {
|
||||
onChange: PropTypes.func.isRequired,
|
||||
className: PropTypes.string,
|
||||
placeholder: PropTypes.string,
|
||||
const propTypes = {
|
||||
onChange: PropTypes.func.isRequired,
|
||||
className: PropTypes.string,
|
||||
placeholder: PropTypes.string,
|
||||
large: PropTypes.bool,
|
||||
noBorder: PropTypes.bool,
|
||||
};
|
||||
|
||||
const SearchField = ({ onChange, className, placeholder = 'Search...', large = true, noBorder = false }) => {
|
||||
const [ searchTerm, setSearchTerm ] = useState('');
|
||||
|
||||
const resetTimer = () => {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
};
|
||||
static defaultProps = {
|
||||
className: '',
|
||||
placeholder: 'Search...',
|
||||
};
|
||||
|
||||
state = { showClearBtn: false, searchTerm: '' };
|
||||
timer = null;
|
||||
|
||||
searchTermChanged(searchTerm, timeout = DEFAULT_SEARCH_INTERVAL) {
|
||||
this.setState({
|
||||
showClearBtn: searchTerm !== '',
|
||||
searchTerm,
|
||||
});
|
||||
|
||||
const resetTimer = () => {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
};
|
||||
const searchTermChanged = (newSearchTerm, timeout = DEFAULT_SEARCH_INTERVAL) => {
|
||||
setSearchTerm(newSearchTerm);
|
||||
|
||||
resetTimer();
|
||||
|
||||
this.timer = setTimeout(() => {
|
||||
this.props.onChange(searchTerm);
|
||||
timer = setTimeout(() => {
|
||||
onChange(newSearchTerm);
|
||||
resetTimer();
|
||||
}, timeout);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { className, placeholder } = this.props;
|
||||
|
||||
return (
|
||||
<div className={classNames('search-field', className)}>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control form-control-lg search-field__input"
|
||||
placeholder={placeholder}
|
||||
value={this.state.searchTerm}
|
||||
onChange={(e) => this.searchTermChanged(e.target.value)}
|
||||
/>
|
||||
<FontAwesomeIcon icon={searchIcon} className="search-field__icon" />
|
||||
<div
|
||||
className="close search-field__close"
|
||||
hidden={!this.state.showClearBtn}
|
||||
id="search-field__close"
|
||||
onClick={() => this.searchTermChanged('', 0)}
|
||||
>
|
||||
×
|
||||
</div>
|
||||
return (
|
||||
<div className={classNames('search-field', className)}>
|
||||
<input
|
||||
type="text"
|
||||
className={classNames('form-control search-field__input', {
|
||||
'form-control-lg': large,
|
||||
'search-field__input--no-border': noBorder,
|
||||
})}
|
||||
placeholder={placeholder}
|
||||
value={searchTerm}
|
||||
onChange={(e) => searchTermChanged(e.target.value)}
|
||||
/>
|
||||
<FontAwesomeIcon icon={searchIcon} className="search-field__icon" />
|
||||
<div
|
||||
className="close search-field__close"
|
||||
hidden={searchTerm === ''}
|
||||
id="search-field__close"
|
||||
onClick={() => searchTermChanged('', 0)}
|
||||
>
|
||||
×
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
SearchField.propTypes = propTypes;
|
||||
|
||||
export default SearchField;
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
.search-field {
|
||||
position: relative;
|
||||
|
||||
&:focus-within {
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.search-field__input.search-field__input {
|
||||
@@ -9,6 +13,11 @@
|
||||
padding-right: 40px;
|
||||
}
|
||||
|
||||
.search-field__input--no-border.search-field__input--no-border {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.search-field__icon {
|
||||
@include vertical-align();
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ $mainColor: #4696e5;
|
||||
$lightHoverColor: #eee;
|
||||
$lightGrey: #ddd;
|
||||
$dangerColor: #dc3545;
|
||||
$mediumGrey: #dee2e6;
|
||||
|
||||
// Misc
|
||||
$headerHeight: 57px;
|
||||
|
||||
3
src/utils/helpers/date.js
Normal file
3
src/utils/helpers/date.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export const formatDate = (format = 'YYYY-MM-DD') => (date) => date && date.format ? date.format(format) : date;
|
||||
|
||||
export const formatIsoDate = (date) => date && date.format ? date.format() : date;
|
||||
20
src/utils/helpers/hooks.js
Normal file
20
src/utils/helpers/hooks.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
const DEFAULT_TIMEOUT_DELAY = 2000;
|
||||
|
||||
export const useStateFlagTimeout = (setTimeout) => (initialValue = true, delay = DEFAULT_TIMEOUT_DELAY) => {
|
||||
const [ flag, setFlag ] = useState(initialValue);
|
||||
const callback = () => {
|
||||
setFlag(!initialValue);
|
||||
setTimeout(() => setFlag(initialValue), delay);
|
||||
};
|
||||
|
||||
return [ flag, callback ];
|
||||
};
|
||||
|
||||
// Return [ flag, toggle, enable, disable ]
|
||||
export const useToggle = (initialValue = false) => {
|
||||
const [ flag, setFlag ] = useState(initialValue);
|
||||
|
||||
return [ flag, () => setFlag(!flag), () => setFlag(true), () => setFlag(false) ];
|
||||
};
|
||||
8
src/utils/helpers/numbers.js
Normal file
8
src/utils/helpers/numbers.js
Normal file
@@ -0,0 +1,8 @@
|
||||
const TEN_ROUNDING_NUMBER = 10;
|
||||
const { ceil } = Math;
|
||||
|
||||
const formatter = new Intl.NumberFormat('en-US');
|
||||
|
||||
export const prettify = (number) => formatter.format(number);
|
||||
|
||||
export const roundTen = (number) => ceil(number / TEN_ROUNDING_NUMBER) * TEN_ROUNDING_NUMBER;
|
||||
27
src/utils/helpers/pagination.js
Normal file
27
src/utils/helpers/pagination.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import { max, min, range } from 'ramda';
|
||||
|
||||
export const ELLIPSIS = '...';
|
||||
|
||||
export const progressivePagination = (currentPage, pageCount) => {
|
||||
const delta = 2;
|
||||
const pages = range(
|
||||
max(delta, currentPage - delta),
|
||||
min(pageCount - 1, currentPage + delta) + 1,
|
||||
);
|
||||
|
||||
if (currentPage - delta > delta) {
|
||||
pages.unshift(ELLIPSIS);
|
||||
}
|
||||
if (currentPage + delta < pageCount - 1) {
|
||||
pages.push(ELLIPSIS);
|
||||
}
|
||||
|
||||
pages.unshift(1);
|
||||
pages.push(pageCount);
|
||||
|
||||
return pages;
|
||||
};
|
||||
|
||||
export const keyForPage = (pageNumber, index) => pageNumber !== ELLIPSIS ? pageNumber : `${pageNumber}_${index}`;
|
||||
|
||||
export const isPageDisabled = (pageNumber) => pageNumber === ELLIPSIS;
|
||||
27
src/utils/helpers/version.js
Normal file
27
src/utils/helpers/version.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import { compare } from 'compare-versions';
|
||||
import { identity, memoizeWith } from 'ramda';
|
||||
import { hasValue } from '../utils';
|
||||
|
||||
export const versionMatch = (versionToMatch, { maxVersion, minVersion }) => {
|
||||
if (!hasValue(versionToMatch)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const matchesMinVersion = !minVersion || compare(versionToMatch, minVersion, '>=');
|
||||
const matchesMaxVersion = !maxVersion || compare(versionToMatch, maxVersion, '<=');
|
||||
|
||||
return !!(matchesMaxVersion && matchesMinVersion);
|
||||
};
|
||||
|
||||
const versionIsValidSemVer = memoizeWith(identity, (version) => {
|
||||
try {
|
||||
return compare(version, version, '=');
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
export const versionToPrintable = (version) => !versionIsValidSemVer(version) ? version : `v${version}`;
|
||||
|
||||
export const versionToSemVer = (defaultValue = 'latest') =>
|
||||
(version) => versionIsValidSemVer(version) ? version : defaultValue;
|
||||
37
src/utils/helpers/visits.js
Normal file
37
src/utils/helpers/visits.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import bowser from 'bowser';
|
||||
import { hasValue } from '../utils';
|
||||
|
||||
const DEFAULT = 'Others';
|
||||
const BROWSERS_WHITELIST = [
|
||||
'Android Browser',
|
||||
'Chrome',
|
||||
'Chromium',
|
||||
'Firefox',
|
||||
'Internet Explorer',
|
||||
'Microsoft Edge',
|
||||
'Opera',
|
||||
'Safari',
|
||||
'Samsung Internet for Android',
|
||||
'Vivaldi',
|
||||
'WeChat',
|
||||
];
|
||||
|
||||
export const parseUserAgent = (userAgent) => {
|
||||
if (!hasValue(userAgent)) {
|
||||
return { browser: DEFAULT, os: DEFAULT };
|
||||
}
|
||||
|
||||
const { browser: { name: browser }, os: { name: os } } = bowser.parse(userAgent);
|
||||
|
||||
return { os: os || DEFAULT, browser: browser && BROWSERS_WHITELIST.includes(browser) ? browser : DEFAULT };
|
||||
};
|
||||
|
||||
export const extractDomain = (url) => {
|
||||
if (!hasValue(url)) {
|
||||
return 'Direct';
|
||||
}
|
||||
|
||||
const domain = url.includes('://') ? url.split('/')[2] : url.split('/')[0];
|
||||
|
||||
return domain.split(':')[0];
|
||||
};
|
||||
37
src/utils/mixins/sticky-cell.scss
Normal file
37
src/utils/mixins/sticky-cell.scss
Normal file
@@ -0,0 +1,37 @@
|
||||
@import "../base";
|
||||
|
||||
@mixin sticky-cell() {
|
||||
z-index: 1;
|
||||
border: none !important;
|
||||
position: relative;
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
left: 0;
|
||||
bottom: -1px;
|
||||
right: -1px;
|
||||
background: $mediumGrey;
|
||||
z-index: -2;
|
||||
}
|
||||
|
||||
&:first-child:before {
|
||||
left: -1px;
|
||||
}
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 1px;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
background: white;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
&:first-child:after {
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
@mixin vertical-align {
|
||||
@mixin vertical-align($extraTransforms: null) {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
transform: translateY(-50%) $extraTransforms;
|
||||
}
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
import { wait } from '../utils';
|
||||
import ShlinkApiClient from './ShlinkApiClient';
|
||||
|
||||
const apiClients = {};
|
||||
|
||||
const getSelectedServerFromState = async (getState) => {
|
||||
const getSelectedServerFromState = (getState) => {
|
||||
const { selectedServer } = getState();
|
||||
|
||||
if (!selectedServer) {
|
||||
return wait(250).then(() => getSelectedServerFromState(getState));
|
||||
}
|
||||
|
||||
return selectedServer;
|
||||
};
|
||||
|
||||
const buildShlinkApiClient = (axios) => async (getStateOrSelectedServer) => {
|
||||
const buildShlinkApiClient = (axios) => (getStateOrSelectedServer) => {
|
||||
const { url, apiKey } = typeof getStateOrSelectedServer === 'function'
|
||||
? await getSelectedServerFromState(getStateOrSelectedServer)
|
||||
? getSelectedServerFromState(getStateOrSelectedServer)
|
||||
: getStateOrSelectedServer;
|
||||
const clientKey = `${url}_${apiKey}`;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import axios from 'axios';
|
||||
import { stateFlagTimeout } from '../utils';
|
||||
import { useStateFlagTimeout } from '../helpers/hooks';
|
||||
import Storage from './Storage';
|
||||
import ColorGenerator from './ColorGenerator';
|
||||
import buildShlinkApiClient from './ShlinkApiClientBuilder';
|
||||
@@ -14,6 +15,7 @@ const provideServices = (bottle) => {
|
||||
|
||||
bottle.constant('setTimeout', global.setTimeout);
|
||||
bottle.serviceFactory('stateFlagTimeout', stateFlagTimeout, 'setTimeout');
|
||||
bottle.serviceFactory('useStateFlagTimeout', useStateFlagTimeout, 'setTimeout');
|
||||
};
|
||||
|
||||
export default provideServices;
|
||||
|
||||
@@ -2,13 +2,9 @@ import L from 'leaflet';
|
||||
import marker2x from 'leaflet/dist/images/marker-icon-2x.png';
|
||||
import marker from 'leaflet/dist/images/marker-icon.png';
|
||||
import markerShadow from 'leaflet/dist/images/marker-shadow.png';
|
||||
import { range } from 'ramda';
|
||||
import { useState } from 'react';
|
||||
import { compare } from 'compare-versions';
|
||||
import { isEmpty, isNil, range } from 'ramda';
|
||||
|
||||
const TEN_ROUNDING_NUMBER = 10;
|
||||
const DEFAULT_TIMEOUT_DELAY = 2000;
|
||||
const { ceil } = Math;
|
||||
|
||||
export const stateFlagTimeout = (setTimeout) => (
|
||||
setState,
|
||||
@@ -45,30 +41,4 @@ export const fixLeafletIcons = () => {
|
||||
|
||||
export const rangeOf = (size, mappingFn, startAt = 1) => range(startAt, size + 1).map(mappingFn);
|
||||
|
||||
export const roundTen = (number) => ceil(number / TEN_ROUNDING_NUMBER) * TEN_ROUNDING_NUMBER;
|
||||
|
||||
export const useToggle = (initialValue = false) => {
|
||||
const [ flag, setFlag ] = useState(initialValue);
|
||||
|
||||
return [ flag, () => setFlag(!flag) ];
|
||||
};
|
||||
|
||||
export const wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
|
||||
export const compareVersions = (firstVersion, operator, secondVersion) => compare(
|
||||
firstVersion,
|
||||
secondVersion,
|
||||
operator
|
||||
);
|
||||
|
||||
export const versionIsValidSemVer = (version) => {
|
||||
try {
|
||||
return compareVersions(version, '=', version);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const formatDate = (format = 'YYYY-MM-DD') => (date) => date && date.format ? date.format(format) : date;
|
||||
|
||||
export const formatIsoDate = (date) => date && date.format ? date.format() : date;
|
||||
export const hasValue = (value) => !isNil(value) && !isEmpty(value);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Card, CardHeader, CardBody, CardFooter } from 'reactstrap';
|
||||
import { Doughnut, HorizontalBar } from 'react-chartjs-2';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { keys, values } from 'ramda';
|
||||
import { keys, values, zipObj } from 'ramda';
|
||||
import './GraphCard.scss';
|
||||
|
||||
const propTypes = {
|
||||
@@ -11,43 +11,79 @@ const propTypes = {
|
||||
isBarChart: PropTypes.bool,
|
||||
stats: PropTypes.object,
|
||||
max: PropTypes.number,
|
||||
highlightedStats: PropTypes.object,
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
|
||||
const generateGraphData = (title, isBarChart, labels, data) => ({
|
||||
const generateGraphData = (title, isBarChart, labels, data, highlightedData) => ({
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
title,
|
||||
label: highlightedData && 'Non-selected',
|
||||
data,
|
||||
backgroundColor: isBarChart ? 'rgba(70, 150, 229, 0.4)' : [
|
||||
'#97BBCD',
|
||||
'#DCDCDC',
|
||||
'#F7464A',
|
||||
'#46BFBD',
|
||||
'#FDB45C',
|
||||
'#949FB1',
|
||||
'#4D5360',
|
||||
'#57A773',
|
||||
'#414066',
|
||||
'#08B2E3',
|
||||
'#B6C454',
|
||||
'#DCDCDC',
|
||||
'#463730',
|
||||
],
|
||||
borderColor: isBarChart ? 'rgba(70, 150, 229, 1)' : 'white',
|
||||
borderWidth: 2,
|
||||
},
|
||||
],
|
||||
highlightedData && {
|
||||
title,
|
||||
label: 'Selected',
|
||||
data: highlightedData,
|
||||
backgroundColor: 'rgba(247, 127, 40, 0.4)',
|
||||
borderColor: '#F77F28',
|
||||
borderWidth: 2,
|
||||
},
|
||||
].filter(Boolean),
|
||||
});
|
||||
|
||||
const dropLabelIfHidden = (label) => label.startsWith('hidden') ? '' : label;
|
||||
|
||||
const renderGraph = (title, isBarChart, stats, max) => {
|
||||
const determineHeight = (isBarChart, labels) => {
|
||||
if (!isBarChart && labels.length > 8) {
|
||||
return 200;
|
||||
}
|
||||
|
||||
return isBarChart && labels.length > 20 ? labels.length * 8 : null;
|
||||
};
|
||||
|
||||
const renderGraph = (title, isBarChart, stats, max, highlightedStats, onClick) => {
|
||||
const hasHighlightedStats = highlightedStats && Object.keys(highlightedStats).length > 0;
|
||||
const Component = isBarChart ? HorizontalBar : Doughnut;
|
||||
const labels = keys(stats).map(dropLabelIfHidden);
|
||||
const data = values(stats);
|
||||
const data = values(!hasHighlightedStats ? stats : keys(highlightedStats).reduce((acc, highlightedKey) => {
|
||||
if (acc[highlightedKey]) {
|
||||
acc[highlightedKey] -= highlightedStats[highlightedKey];
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, { ...stats }));
|
||||
const highlightedData = hasHighlightedStats && values(
|
||||
{ ...zipObj(labels, labels.map(() => 0)), ...highlightedStats }
|
||||
);
|
||||
|
||||
const options = {
|
||||
legend: isBarChart ? { display: false } : { position: 'right' },
|
||||
scales: isBarChart && {
|
||||
xAxes: [
|
||||
{
|
||||
ticks: { beginAtZero: true, max },
|
||||
stacked: true,
|
||||
},
|
||||
],
|
||||
yAxes: [{ stacked: true }],
|
||||
},
|
||||
tooltips: {
|
||||
intersect: !isBarChart,
|
||||
@@ -55,18 +91,38 @@ const renderGraph = (title, isBarChart, stats, max) => {
|
||||
// Do not show tooltip on items with empty label when in a bar chart
|
||||
filter: ({ yLabel }) => !isBarChart || yLabel !== '',
|
||||
},
|
||||
onHover: isBarChart && (({ target }, chartElement) => {
|
||||
target.style.cursor = chartElement[0] ? 'pointer' : 'default';
|
||||
}),
|
||||
};
|
||||
const graphData = generateGraphData(title, isBarChart, labels, data);
|
||||
const height = isBarChart && labels.length > 20 ? labels.length * 8 : null;
|
||||
const graphData = generateGraphData(title, isBarChart, labels, data, highlightedData);
|
||||
const height = determineHeight(isBarChart, labels);
|
||||
|
||||
// Provide a key based on the height, so that every time the dataset changes, a new graph is rendered
|
||||
return <Component key={height} data={graphData} options={options} height={height} />;
|
||||
return (
|
||||
<Component
|
||||
key={height}
|
||||
data={graphData}
|
||||
options={options}
|
||||
height={height}
|
||||
getElementAtEvent={([ chart ]) => {
|
||||
if (!onClick || !chart) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { _index, _chart: { data } } = chart;
|
||||
const { labels } = data;
|
||||
|
||||
onClick(labels[_index]);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const GraphCard = ({ title, footer, isBarChart, stats, max }) => (
|
||||
const GraphCard = ({ title, footer, isBarChart, stats, max, highlightedStats, onClick }) => (
|
||||
<Card className="mt-4">
|
||||
<CardHeader className="graph-card__header">{typeof title === 'function' ? title() : title}</CardHeader>
|
||||
<CardBody>{renderGraph(title, isBarChart, stats, max)}</CardBody>
|
||||
<CardBody>{renderGraph(title, isBarChart, stats, max, highlightedStats, onClick)}</CardBody>
|
||||
{footer && <CardFooter className="graph-card__footer--sticky">{footer}</CardFooter>}
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,72 +1,119 @@
|
||||
import { faCircleNotch as preloader } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { isEmpty, mapObjIndexed, values } from 'ramda';
|
||||
import React from 'react';
|
||||
import { Card } from 'reactstrap';
|
||||
import { isEmpty, propEq, values } from 'ramda';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Button, Card, Collapse } from 'reactstrap';
|
||||
import PropTypes from 'prop-types';
|
||||
import qs from 'qs';
|
||||
import classNames from 'classnames';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faChevronDown as chevronDown } from '@fortawesome/free-solid-svg-icons';
|
||||
import DateRangeRow from '../utils/DateRangeRow';
|
||||
import MutedMessage from '../utils/MuttedMessage';
|
||||
import { formatDate } from '../utils/utils';
|
||||
import Message from '../utils/Message';
|
||||
import { formatDate } from '../utils/helpers/date';
|
||||
import { useToggle } from '../utils/helpers/hooks';
|
||||
import SortableBarGraph from './SortableBarGraph';
|
||||
import { shortUrlVisitsType } from './reducers/shortUrlVisits';
|
||||
import VisitsHeader from './VisitsHeader';
|
||||
import GraphCard from './GraphCard';
|
||||
import { shortUrlDetailType } from './reducers/shortUrlDetail';
|
||||
import VisitsTable from './VisitsTable';
|
||||
|
||||
const ShortUrlVisits = (
|
||||
{ processStatsFromVisits },
|
||||
OpenMapModalBtn
|
||||
) => class ShortUrlVisits extends React.PureComponent {
|
||||
static propTypes = {
|
||||
match: PropTypes.shape({
|
||||
params: PropTypes.object,
|
||||
}),
|
||||
location: PropTypes.shape({
|
||||
search: PropTypes.string,
|
||||
}),
|
||||
getShortUrlVisits: PropTypes.func,
|
||||
shortUrlVisits: shortUrlVisitsType,
|
||||
getShortUrlDetail: PropTypes.func,
|
||||
shortUrlDetail: shortUrlDetailType,
|
||||
cancelGetShortUrlVisits: PropTypes.func,
|
||||
};
|
||||
const propTypes = {
|
||||
match: PropTypes.shape({
|
||||
params: PropTypes.object,
|
||||
}),
|
||||
location: PropTypes.shape({
|
||||
search: PropTypes.string,
|
||||
}),
|
||||
getShortUrlVisits: PropTypes.func,
|
||||
shortUrlVisits: shortUrlVisitsType,
|
||||
getShortUrlDetail: PropTypes.func,
|
||||
shortUrlDetail: shortUrlDetailType,
|
||||
cancelGetShortUrlVisits: PropTypes.func,
|
||||
matchMedia: PropTypes.func,
|
||||
};
|
||||
|
||||
state = { startDate: undefined, endDate: undefined };
|
||||
loadVisits = (loadDetail = false) => {
|
||||
const { match: { params }, location: { search }, getShortUrlVisits, getShortUrlDetail } = this.props;
|
||||
const highlightedVisitsToStats = (highlightedVisits, prop) => highlightedVisits.reduce((acc, highlightedVisit) => {
|
||||
if (!acc[highlightedVisit[prop]]) {
|
||||
acc[highlightedVisit[prop]] = 0;
|
||||
}
|
||||
|
||||
acc[highlightedVisit[prop]] += 1;
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
const format = formatDate();
|
||||
let selectedBar;
|
||||
|
||||
const ShortUrlVisits = ({ processStatsFromVisits, normalizeVisits }, OpenMapModalBtn) => {
|
||||
const ShortUrlVisitsComp = ({
|
||||
match,
|
||||
location,
|
||||
shortUrlVisits,
|
||||
shortUrlDetail,
|
||||
getShortUrlVisits,
|
||||
getShortUrlDetail,
|
||||
cancelGetShortUrlVisits,
|
||||
matchMedia = window.matchMedia,
|
||||
}) => {
|
||||
const [ startDate, setStartDate ] = useState(undefined);
|
||||
const [ endDate, setEndDate ] = useState(undefined);
|
||||
const [ showTable, toggleTable ] = useToggle();
|
||||
const [ tableIsSticky, , setSticky, unsetSticky ] = useToggle();
|
||||
const [ highlightedVisits, setHighlightedVisits ] = useState([]);
|
||||
const [ isMobileDevice, setIsMobileDevice ] = useState(false);
|
||||
const determineIsMobileDevice = () => setIsMobileDevice(matchMedia('(max-width: 991px)').matches);
|
||||
const setSelectedVisits = (selectedVisits) => {
|
||||
selectedBar = undefined;
|
||||
setHighlightedVisits(selectedVisits);
|
||||
};
|
||||
const highlightVisitsForProp = (prop) => (value) => {
|
||||
const newSelectedBar = `${prop}_${value}`;
|
||||
|
||||
if (selectedBar === newSelectedBar) {
|
||||
setHighlightedVisits([]);
|
||||
selectedBar = undefined;
|
||||
} else {
|
||||
setHighlightedVisits(normalizedVisits.filter(propEq(prop, value)));
|
||||
selectedBar = newSelectedBar;
|
||||
}
|
||||
};
|
||||
|
||||
const { params } = match;
|
||||
const { shortCode } = params;
|
||||
const { startDate, endDate } = mapObjIndexed(formatDate(), this.state);
|
||||
const { search } = location;
|
||||
const { domain } = qs.parse(search, { ignoreQueryPrefix: true });
|
||||
|
||||
// While the "page" is loaded, use the timestamp + filtering dates as memoization IDs for stats calculations
|
||||
this.memoizationId = `${this.timeWhenMounted}_${shortCode}_${startDate}_${endDate}`;
|
||||
getShortUrlVisits(shortCode, { startDate, endDate, domain });
|
||||
const { visits, loading, loadingLarge, error } = shortUrlVisits;
|
||||
const showTableControls = !loading && visits.length > 0;
|
||||
const normalizedVisits = useMemo(() => normalizeVisits(visits), [ visits ]);
|
||||
const { os, browsers, referrers, countries, cities, citiesForMap } = useMemo(
|
||||
() => processStatsFromVisits(normalizedVisits),
|
||||
[ normalizedVisits ]
|
||||
);
|
||||
const mapLocations = values(citiesForMap);
|
||||
|
||||
if (loadDetail) {
|
||||
const loadVisits = () =>
|
||||
getShortUrlVisits(shortCode, { startDate: format(startDate), endDate: format(endDate), domain });
|
||||
|
||||
useEffect(() => {
|
||||
getShortUrlDetail(shortCode, domain);
|
||||
}
|
||||
};
|
||||
determineIsMobileDevice();
|
||||
window.addEventListener('resize', determineIsMobileDevice);
|
||||
|
||||
componentDidMount() {
|
||||
this.timeWhenMounted = new Date().getTime();
|
||||
this.loadVisits(true);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.props.cancelGetShortUrlVisits();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { shortUrlVisits, shortUrlDetail } = this.props;
|
||||
return () => {
|
||||
cancelGetShortUrlVisits();
|
||||
window.removeEventListener('resize', determineIsMobileDevice);
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
loadVisits();
|
||||
}, [ startDate, endDate ]);
|
||||
|
||||
const renderVisitsContent = () => {
|
||||
const { visits, loading, loadingLarge, error } = shortUrlVisits;
|
||||
|
||||
if (loading) {
|
||||
const message = loadingLarge ? 'This is going to take a while... :S' : 'Loading...';
|
||||
|
||||
return <MutedMessage><FontAwesomeIcon icon={preloader} spin /> {message}</MutedMessage>;
|
||||
return <Message loading>{message}</Message>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
@@ -78,14 +125,9 @@ const ShortUrlVisits = (
|
||||
}
|
||||
|
||||
if (isEmpty(visits)) {
|
||||
return <MutedMessage>There are no visits matching current filter :(</MutedMessage>;
|
||||
return <Message>There are no visits matching current filter :(</Message>;
|
||||
}
|
||||
|
||||
const { os, browsers, referrers, countries, cities, citiesForMap } = processStatsFromVisits(
|
||||
{ id: this.memoizationId, visits }
|
||||
);
|
||||
const mapLocations = values(citiesForMap);
|
||||
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-xl-4 col-lg-6">
|
||||
@@ -96,63 +138,115 @@ const ShortUrlVisits = (
|
||||
</div>
|
||||
<div className="col-xl-4">
|
||||
<SortableBarGraph
|
||||
title="Referrers"
|
||||
stats={referrers}
|
||||
withPagination={false}
|
||||
title="Referrers"
|
||||
highlightedStats={highlightedVisitsToStats(highlightedVisits, 'referer')}
|
||||
sortingItems={{
|
||||
name: 'Referrer name',
|
||||
amount: 'Visits amount',
|
||||
}}
|
||||
onClick={highlightVisitsForProp('referer')}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-lg-6">
|
||||
<SortableBarGraph
|
||||
stats={countries}
|
||||
title="Countries"
|
||||
stats={countries}
|
||||
highlightedStats={highlightedVisitsToStats(highlightedVisits, 'country')}
|
||||
sortingItems={{
|
||||
name: 'Country name',
|
||||
amount: 'Visits amount',
|
||||
}}
|
||||
onClick={highlightVisitsForProp('country')}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-lg-6">
|
||||
<SortableBarGraph
|
||||
stats={cities}
|
||||
title="Cities"
|
||||
stats={cities}
|
||||
highlightedStats={highlightedVisitsToStats(highlightedVisits, 'city')}
|
||||
extraHeaderContent={(activeCities) =>
|
||||
mapLocations.length > 0 &&
|
||||
<OpenMapModalBtn modalTitle="Cities" locations={mapLocations} activeCities={activeCities} />
|
||||
<OpenMapModalBtn modalTitle="Cities" locations={mapLocations} activeCities={activeCities} />
|
||||
}
|
||||
sortingItems={{
|
||||
name: 'City name',
|
||||
amount: 'Visits amount',
|
||||
}}
|
||||
onClick={highlightVisitsForProp('city')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const setDate = (dateField) => (date) => this.setState({ [dateField]: date }, this.loadVisits);
|
||||
|
||||
return (
|
||||
<div className="shlink-container">
|
||||
<React.Fragment>
|
||||
<VisitsHeader shortUrlDetail={shortUrlDetail} shortUrlVisits={shortUrlVisits} />
|
||||
|
||||
<section className="mt-4">
|
||||
<DateRangeRow
|
||||
startDate={this.state.startDate}
|
||||
endDate={this.state.endDate}
|
||||
onStartDateChange={setDate('startDate')}
|
||||
onEndDateChange={setDate('endDate')}
|
||||
/>
|
||||
<div className="row flex-md-row-reverse">
|
||||
<div className="col-lg-7 col-xl-6">
|
||||
<DateRangeRow
|
||||
disabled={loading}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-lg-5 col-xl-6 mt-4 mt-lg-0">
|
||||
{showTableControls && (
|
||||
<span className={classNames({ row: isMobileDevice })}>
|
||||
<span className={classNames({ 'col-6': isMobileDevice })}>
|
||||
<Button outline color="primary" block={isMobileDevice} onClick={toggleTable}>
|
||||
{showTable ? 'Hide' : 'Show'} table
|
||||
<FontAwesomeIcon icon={chevronDown} rotation={showTable ? 180 : undefined} className="ml-2" />
|
||||
</Button>
|
||||
</span>
|
||||
<span className={classNames({ 'col-6': isMobileDevice, 'ml-2': !isMobileDevice })}>
|
||||
<Button
|
||||
outline
|
||||
disabled={highlightedVisits.length === 0}
|
||||
block={isMobileDevice}
|
||||
onClick={() => setSelectedVisits([])}
|
||||
>
|
||||
Reset selection
|
||||
</Button>
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{showTableControls && (
|
||||
<Collapse
|
||||
isOpen={showTable}
|
||||
// Enable stickiness only when there's no CSS animation, to avoid weird rendering effects
|
||||
onEntered={setSticky}
|
||||
onExiting={unsetSticky}
|
||||
>
|
||||
<VisitsTable
|
||||
visits={normalizedVisits}
|
||||
selectedVisits={highlightedVisits}
|
||||
setSelectedVisits={setSelectedVisits}
|
||||
isSticky={tableIsSticky}
|
||||
/>
|
||||
</Collapse>
|
||||
)}
|
||||
|
||||
<section>
|
||||
{renderVisitsContent()}
|
||||
</section>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
ShortUrlVisitsComp.propTypes = propTypes;
|
||||
|
||||
return ShortUrlVisitsComp;
|
||||
};
|
||||
|
||||
export default ShortUrlVisits;
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { fromPairs, head, keys, pipe, prop, reverse, sortBy, splitEvery, toLower, toPairs, type } from 'ramda';
|
||||
import { fromPairs, head, keys, pipe, prop, reverse, sortBy, splitEvery, toLower, toPairs, type, zipObj } from 'ramda';
|
||||
import SortingDropdown from '../utils/SortingDropdown';
|
||||
import PaginationDropdown from '../utils/PaginationDropdown';
|
||||
import { rangeOf, roundTen } from '../utils/utils';
|
||||
import { rangeOf } from '../utils/utils';
|
||||
import { roundTen } from '../utils/helpers/numbers';
|
||||
import SimplePaginator from '../common/SimplePaginator';
|
||||
import GraphCard from './GraphCard';
|
||||
|
||||
const { max } = Math;
|
||||
const toLowerIfString = (value) => type(value) === 'String' ? toLower(value) : value;
|
||||
const pickKeyFromPair = ([ key ]) => key;
|
||||
const pickValueFromPair = ([ , value ]) => value;
|
||||
|
||||
export default class SortableBarGraph extends React.Component {
|
||||
static propTypes = {
|
||||
stats: PropTypes.object.isRequired,
|
||||
highlightedStats: PropTypes.object,
|
||||
title: PropTypes.string.isRequired,
|
||||
sortingItems: PropTypes.object.isRequired,
|
||||
extraHeaderContent: PropTypes.func,
|
||||
withPagination: PropTypes.bool,
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
|
||||
state = {
|
||||
@@ -27,7 +31,7 @@ export default class SortableBarGraph extends React.Component {
|
||||
itemsPerPage: Infinity,
|
||||
};
|
||||
|
||||
determineStats(stats, sortingItems) {
|
||||
getSortedPairsForStats(stats, sortingItems) {
|
||||
const pairs = toPairs(stats);
|
||||
const sortedPairs = !this.state.orderField ? pairs : sortBy(
|
||||
pipe(
|
||||
@@ -36,18 +40,33 @@ export default class SortableBarGraph extends React.Component {
|
||||
),
|
||||
pairs
|
||||
);
|
||||
const directionalPairs = !this.state.orderDir || this.state.orderDir === 'ASC' ? sortedPairs : reverse(sortedPairs);
|
||||
|
||||
if (directionalPairs.length <= this.state.itemsPerPage) {
|
||||
return { currentPageStats: fromPairs(directionalPairs) };
|
||||
return !this.state.orderDir || this.state.orderDir === 'ASC' ? sortedPairs : reverse(sortedPairs);
|
||||
}
|
||||
|
||||
determineStats(stats, highlightedStats, sortingItems) {
|
||||
const sortedPairs = this.getSortedPairsForStats(stats, sortingItems);
|
||||
const sortedKeys = sortedPairs.map(pickKeyFromPair);
|
||||
// The highlighted stats have to be ordered based on the regular stats, not on its own values
|
||||
const sortedHighlightedPairs = highlightedStats && toPairs(
|
||||
{ ...zipObj(sortedKeys, sortedKeys.map(() => 0)), ...highlightedStats }
|
||||
);
|
||||
|
||||
if (sortedPairs.length <= this.state.itemsPerPage) {
|
||||
return {
|
||||
currentPageStats: fromPairs(sortedPairs),
|
||||
currentPageHighlightedStats: sortedHighlightedPairs && fromPairs(sortedHighlightedPairs),
|
||||
};
|
||||
}
|
||||
|
||||
const pages = splitEvery(this.state.itemsPerPage, directionalPairs);
|
||||
const pages = splitEvery(this.state.itemsPerPage, sortedPairs);
|
||||
const highlightedPages = sortedHighlightedPairs && splitEvery(this.state.itemsPerPage, sortedHighlightedPairs);
|
||||
|
||||
return {
|
||||
currentPageStats: fromPairs(this.determineCurrentPagePairs(pages)),
|
||||
currentPageHighlightedStats: highlightedPages && fromPairs(this.determineCurrentPagePairs(highlightedPages)),
|
||||
pagination: this.renderPagination(pages.length),
|
||||
max: roundTen(max(...directionalPairs.map(pickValueFromPair))),
|
||||
max: roundTen(max(...sortedPairs.map(pickValueFromPair))),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -72,8 +91,20 @@ export default class SortableBarGraph extends React.Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { stats, sortingItems, title, extraHeaderContent, withPagination = true } = this.props;
|
||||
const { currentPageStats, pagination, max } = this.determineStats(stats, sortingItems);
|
||||
const {
|
||||
stats,
|
||||
highlightedStats,
|
||||
sortingItems,
|
||||
title,
|
||||
extraHeaderContent,
|
||||
withPagination = true,
|
||||
...rest
|
||||
} = this.props;
|
||||
const { currentPageStats, currentPageHighlightedStats, pagination, max } = this.determineStats(
|
||||
stats,
|
||||
highlightedStats && keys(highlightedStats).length > 0 ? highlightedStats : undefined,
|
||||
sortingItems
|
||||
);
|
||||
const activeCities = keys(currentPageStats);
|
||||
const computeTitle = () => (
|
||||
<React.Fragment>
|
||||
@@ -106,6 +137,16 @@ export default class SortableBarGraph extends React.Component {
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
return <GraphCard isBarChart title={computeTitle} stats={currentPageStats} footer={pagination} max={max} />;
|
||||
return (
|
||||
<GraphCard
|
||||
isBarChart
|
||||
title={computeTitle}
|
||||
stats={currentPageStats}
|
||||
highlightedStats={currentPageHighlightedStats}
|
||||
footer={pagination}
|
||||
max={max}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
206
src/visits/VisitsTable.js
Normal file
206
src/visits/VisitsTable.js
Normal file
@@ -0,0 +1,206 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Moment from 'react-moment';
|
||||
import classNames from 'classnames';
|
||||
import { min, splitEvery } from 'ramda';
|
||||
import {
|
||||
faCaretDown as caretDownIcon,
|
||||
faCaretUp as caretUpIcon,
|
||||
faCheck as checkIcon,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import SimplePaginator from '../common/SimplePaginator';
|
||||
import SearchField from '../utils/SearchField';
|
||||
import { determineOrderDir } from '../utils/utils';
|
||||
import { prettify } from '../utils/helpers/numbers';
|
||||
import './VisitsTable.scss';
|
||||
|
||||
const NormalizedVisitType = PropTypes.shape({
|
||||
|
||||
});
|
||||
|
||||
const propTypes = {
|
||||
visits: PropTypes.arrayOf(NormalizedVisitType).isRequired,
|
||||
selectedVisits: PropTypes.arrayOf(NormalizedVisitType),
|
||||
setSelectedVisits: PropTypes.func.isRequired,
|
||||
isSticky: PropTypes.bool,
|
||||
matchMedia: PropTypes.func,
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const visitMatchesSearch = ({ browser, os, referer, country, city }, searchTerm) =>
|
||||
`${browser} ${os} ${referer} ${country} ${city}`.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const searchVisits = (searchTerm, visits) => visits.filter((visit) => visitMatchesSearch(visit, searchTerm));
|
||||
const sortVisits = ({ field, dir }, visits) => visits.sort((a, b) => {
|
||||
const greaterThan = dir === 'ASC' ? 1 : -1;
|
||||
const smallerThan = dir === 'ASC' ? -1 : 1;
|
||||
|
||||
return a[field] > b[field] ? greaterThan : smallerThan;
|
||||
});
|
||||
const calculateVisits = (allVisits, searchTerm, order) => {
|
||||
const filteredVisits = searchTerm ? searchVisits(searchTerm, allVisits) : [ ...allVisits ];
|
||||
const sortedVisits = order.dir ? sortVisits(order, filteredVisits) : filteredVisits;
|
||||
const total = sortedVisits.length;
|
||||
const visitsGroups = splitEvery(PAGE_SIZE, sortedVisits);
|
||||
|
||||
return { visitsGroups, total };
|
||||
};
|
||||
|
||||
const VisitsTable = ({
|
||||
visits,
|
||||
selectedVisits = [],
|
||||
setSelectedVisits,
|
||||
isSticky = false,
|
||||
matchMedia = window.matchMedia,
|
||||
}) => {
|
||||
const headerCellsClass = classNames('visits-table__header-cell', {
|
||||
'visits-table__sticky': isSticky,
|
||||
});
|
||||
const matchMobile = () => matchMedia('(max-width: 767px)').matches;
|
||||
|
||||
const [ isMobileDevice, setIsMobileDevice ] = useState(matchMobile());
|
||||
const [ searchTerm, setSearchTerm ] = useState(undefined);
|
||||
const [ order, setOrder ] = useState({ field: undefined, dir: undefined });
|
||||
const resultSet = useMemo(() => calculateVisits(visits, searchTerm, order), [ searchTerm, order ]);
|
||||
|
||||
const [ page, setPage ] = useState(1);
|
||||
const end = page * PAGE_SIZE;
|
||||
const start = end - PAGE_SIZE;
|
||||
|
||||
const orderByColumn = (field) => () => setOrder({ field, dir: determineOrderDir(field, order.field, order.dir) });
|
||||
const renderOrderIcon = (field) => order.dir && order.field === field && (
|
||||
<FontAwesomeIcon
|
||||
icon={order.dir === 'ASC' ? caretUpIcon : caretDownIcon}
|
||||
className="visits-table__header-icon"
|
||||
/>
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const listener = () => setIsMobileDevice(matchMobile());
|
||||
|
||||
window.addEventListener('resize', listener);
|
||||
|
||||
return () => window.removeEventListener('resize', listener);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
setSelectedVisits([]);
|
||||
}, [ searchTerm ]);
|
||||
|
||||
return (
|
||||
<table className="table table-striped table-bordered table-hover table-sm visits-table">
|
||||
<thead className="visits-table__header">
|
||||
<tr>
|
||||
<th
|
||||
className={classNames('visits-table__header-cell text-center', {
|
||||
'visits-table__sticky': isSticky,
|
||||
})}
|
||||
onClick={() => setSelectedVisits(
|
||||
selectedVisits.length < resultSet.total ? resultSet.visitsGroups.flat() : []
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={checkIcon} className={classNames({ 'text-primary': selectedVisits.length > 0 })} />
|
||||
</th>
|
||||
<th className={headerCellsClass} onClick={orderByColumn('date')}>
|
||||
Date
|
||||
{renderOrderIcon('date')}
|
||||
</th>
|
||||
<th className={headerCellsClass} onClick={orderByColumn('country')}>
|
||||
Country
|
||||
{renderOrderIcon('country')}
|
||||
</th>
|
||||
<th className={headerCellsClass} onClick={orderByColumn('city')}>
|
||||
City
|
||||
{renderOrderIcon('city')}
|
||||
</th>
|
||||
<th className={headerCellsClass} onClick={orderByColumn('browser')}>
|
||||
Browser
|
||||
{renderOrderIcon('browser')}
|
||||
</th>
|
||||
<th className={headerCellsClass} onClick={orderByColumn('os')}>
|
||||
OS
|
||||
{renderOrderIcon('os')}
|
||||
</th>
|
||||
<th className={headerCellsClass} onClick={orderByColumn('referer')}>
|
||||
Referrer
|
||||
{renderOrderIcon('referer')}
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colSpan={7} className="p-0">
|
||||
<SearchField noBorder large={false} onChange={setSearchTerm} />
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(!resultSet.visitsGroups[page - 1] || resultSet.visitsGroups[page - 1].length === 0) && (
|
||||
<tr>
|
||||
<td colSpan={7} className="text-center">
|
||||
No visits found with current filtering
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{resultSet.visitsGroups[page - 1] && resultSet.visitsGroups[page - 1].map((visit, index) => {
|
||||
const isSelected = selectedVisits.includes(visit);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={index}
|
||||
style={{ cursor: 'pointer' }}
|
||||
className={classNames({ 'table-primary': isSelected })}
|
||||
onClick={() => setSelectedVisits(
|
||||
isSelected ? selectedVisits.filter((v) => v !== visit) : [ ...selectedVisits, visit ]
|
||||
)}
|
||||
>
|
||||
<td className="text-center">
|
||||
{isSelected && <FontAwesomeIcon icon={checkIcon} className="text-primary" />}
|
||||
</td>
|
||||
<td>
|
||||
<Moment format="YYYY-MM-DD HH:mm">{visit.date}</Moment>
|
||||
</td>
|
||||
<td>{visit.country}</td>
|
||||
<td>{visit.city}</td>
|
||||
<td>{visit.browser}</td>
|
||||
<td>{visit.os}</td>
|
||||
<td>{visit.referer}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
{resultSet.total > PAGE_SIZE && (
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colSpan={7} className={classNames('visits-table__footer-cell', { 'visits-table__sticky': isSticky })}>
|
||||
<div className="row">
|
||||
<div className="col-md-6">
|
||||
<SimplePaginator
|
||||
pagesCount={Math.ceil(resultSet.total / PAGE_SIZE)}
|
||||
currentPage={page}
|
||||
setCurrentPage={setPage}
|
||||
centered={isMobileDevice}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={classNames('col-md-6', {
|
||||
'd-flex align-items-center flex-row-reverse': !isMobileDevice,
|
||||
'text-center mt-3': isMobileDevice,
|
||||
})}
|
||||
>
|
||||
<div>
|
||||
Visits <b>{prettify(start + 1)}</b> to{' '}
|
||||
<b>{prettify(min(end, resultSet.total))}</b> of{' '}
|
||||
<b>{prettify(resultSet.total)}</b>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</table>
|
||||
);
|
||||
};
|
||||
|
||||
VisitsTable.propTypes = propTypes;
|
||||
|
||||
export default VisitsTable;
|
||||
35
src/visits/VisitsTable.scss
Normal file
35
src/visits/VisitsTable.scss
Normal file
@@ -0,0 +1,35 @@
|
||||
@import '../utils/base';
|
||||
@import '../utils/mixins/sticky-cell';
|
||||
|
||||
.visits-table {
|
||||
margin: 1.5rem 0 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.visits-table__header-cell {
|
||||
cursor: pointer;
|
||||
margin-bottom: 55px;
|
||||
|
||||
@include sticky-cell();
|
||||
|
||||
&.visits-table__sticky {
|
||||
top: $headerHeight - 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.visits-table__header-icon {
|
||||
float: right;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.visits-table__footer-cell.visits-table__footer-cell {
|
||||
bottom: 0;
|
||||
margin-top: 34px;
|
||||
padding: .5rem;
|
||||
|
||||
@include sticky-cell();
|
||||
}
|
||||
|
||||
.visits-table__sticky.visits-table__sticky {
|
||||
position: sticky;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faMapMarkedAlt as mapIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { Dropdown, DropdownItem, DropdownMenu, UncontrolledTooltip } from 'reactstrap';
|
||||
import * as PropTypes from 'prop-types';
|
||||
import { useToggle } from '../../utils/helpers/hooks';
|
||||
import './OpenMapModalBtn.scss';
|
||||
|
||||
const propTypes = {
|
||||
@@ -13,26 +14,25 @@ const propTypes = {
|
||||
|
||||
const OpenMapModalBtn = (MapModal) => {
|
||||
const OpenMapModalBtn = ({ modalTitle, locations = [], activeCities }) => {
|
||||
const [ mapIsOpened, setMapIsOpened ] = useState(false);
|
||||
const [ dropdownIsOpened, setDropdownIsOpened ] = useState(false);
|
||||
const [ mapIsOpened, , openMap, closeMap ] = useToggle();
|
||||
const [ dropdownIsOpened, toggleDropdown, openDropdown ] = useToggle();
|
||||
const [ locationsToShow, setLocationsToShow ] = useState([]);
|
||||
|
||||
const buttonRef = React.createRef();
|
||||
const filterLocations = (locations) => locations.filter(({ cityName }) => activeCities.includes(cityName));
|
||||
const toggleMap = () => setMapIsOpened(!mapIsOpened);
|
||||
const onClick = () => {
|
||||
if (!activeCities) {
|
||||
setLocationsToShow(locations);
|
||||
setMapIsOpened(true);
|
||||
openMap();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setDropdownIsOpened(true);
|
||||
openDropdown();
|
||||
};
|
||||
const openMapWithLocations = (filtered) => () => {
|
||||
setLocationsToShow(filtered ? filterLocations(locations) : locations);
|
||||
setMapIsOpened(true);
|
||||
openMap();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -41,13 +41,13 @@ const OpenMapModalBtn = (MapModal) => {
|
||||
<FontAwesomeIcon icon={mapIcon} />
|
||||
</button>
|
||||
<UncontrolledTooltip placement="left" target={() => buttonRef.current}>Show in map</UncontrolledTooltip>
|
||||
<Dropdown isOpen={dropdownIsOpened} toggle={() => setDropdownIsOpened(!dropdownIsOpened)} inNavbar>
|
||||
<Dropdown isOpen={dropdownIsOpened} toggle={toggleDropdown} inNavbar>
|
||||
<DropdownMenu right>
|
||||
<DropdownItem onClick={openMapWithLocations(false)}>Show all locations</DropdownItem>
|
||||
<DropdownItem onClick={openMapWithLocations(true)}>Show locations in current page</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
<MapModal toggle={toggleMap} isOpen={mapIsOpened} title={modalTitle} locations={locationsToShow} />
|
||||
<MapModal toggle={closeMap} isOpen={mapIsOpened} title={modalTitle} locations={locationsToShow} />
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -28,8 +28,7 @@ export default handleActions({
|
||||
|
||||
export const getShortUrlDetail = (buildShlinkApiClient) => (shortCode, domain) => async (dispatch, getState) => {
|
||||
dispatch({ type: GET_SHORT_URL_DETAIL_START });
|
||||
|
||||
const { getShortUrl } = await buildShlinkApiClient(getState);
|
||||
const { getShortUrl } = buildShlinkApiClient(getState);
|
||||
|
||||
try {
|
||||
const shortUrl = await getShortUrl(shortCode, domain);
|
||||
|
||||
@@ -10,8 +10,24 @@ export const GET_SHORT_URL_VISITS_LARGE = 'shlink/shortUrlVisits/GET_SHORT_URL_V
|
||||
export const GET_SHORT_URL_VISITS_CANCEL = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_CANCEL';
|
||||
/* eslint-enable padding-line-between-statements */
|
||||
|
||||
export const visitType = PropTypes.shape({
|
||||
referer: PropTypes.string,
|
||||
date: PropTypes.string,
|
||||
userAgent: PropTypes.string,
|
||||
visitLocations: PropTypes.shape({
|
||||
countryCode: PropTypes.string,
|
||||
countryName: PropTypes.string,
|
||||
regionName: PropTypes.string,
|
||||
cityName: PropTypes.string,
|
||||
latitude: PropTypes.number,
|
||||
longitude: PropTypes.number,
|
||||
timezone: PropTypes.string,
|
||||
isEmpty: PropTypes.bool,
|
||||
}),
|
||||
});
|
||||
|
||||
export const shortUrlVisitsType = PropTypes.shape({
|
||||
visits: PropTypes.array,
|
||||
visits: PropTypes.arrayOf(visitType),
|
||||
loading: PropTypes.bool,
|
||||
error: PropTypes.bool,
|
||||
});
|
||||
@@ -51,8 +67,7 @@ export default handleActions({
|
||||
|
||||
export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, query) => async (dispatch, getState) => {
|
||||
dispatch({ type: GET_SHORT_URL_VISITS_START });
|
||||
|
||||
const { getShortUrlVisits } = await buildShlinkApiClient(getState);
|
||||
const { getShortUrlVisits } = buildShlinkApiClient(getState);
|
||||
const itemsPerPage = 5000;
|
||||
const isLastPage = ({ currentPage, pagesCount }) => currentPage >= pagesCount;
|
||||
|
||||
|
||||
@@ -1,102 +1,52 @@
|
||||
import { isNil, isEmpty, memoizeWith, prop } from 'ramda';
|
||||
import { isNil, map } from 'ramda';
|
||||
import { extractDomain, parseUserAgent } from '../../utils/helpers/visits';
|
||||
import { hasValue } from '../../utils/utils';
|
||||
|
||||
const osFromUserAgent = (userAgent) => {
|
||||
const lowerUserAgent = userAgent.toLowerCase();
|
||||
|
||||
switch (true) {
|
||||
case lowerUserAgent.indexOf('linux') >= 0:
|
||||
return 'Linux';
|
||||
case lowerUserAgent.indexOf('windows') >= 0:
|
||||
return 'Windows';
|
||||
case lowerUserAgent.indexOf('mac') >= 0:
|
||||
return 'MacOS';
|
||||
case lowerUserAgent.indexOf('mobi') >= 0:
|
||||
return 'Mobile';
|
||||
default:
|
||||
return 'Others';
|
||||
}
|
||||
};
|
||||
|
||||
const browserFromUserAgent = (userAgent) => {
|
||||
const lowerUserAgent = userAgent.toLowerCase();
|
||||
|
||||
switch (true) {
|
||||
case lowerUserAgent.indexOf('opera') >= 0 || lowerUserAgent.indexOf('opr') >= 0:
|
||||
return 'Opera';
|
||||
case lowerUserAgent.indexOf('firefox') >= 0:
|
||||
return 'Firefox';
|
||||
case lowerUserAgent.indexOf('chrome') >= 0:
|
||||
return 'Chrome';
|
||||
case lowerUserAgent.indexOf('safari') >= 0:
|
||||
return 'Safari';
|
||||
case lowerUserAgent.indexOf('msie') >= 0:
|
||||
return 'Internet Explorer';
|
||||
default:
|
||||
return 'Others';
|
||||
}
|
||||
};
|
||||
|
||||
const extractDomain = (url) => {
|
||||
const domain = url.indexOf('://') > -1 ? url.split('/')[2] : url.split('/')[0];
|
||||
|
||||
return domain.split(':')[0];
|
||||
};
|
||||
|
||||
const visitLocationHasProperty = (visitLocation, propertyName) =>
|
||||
!isNil(visitLocation)
|
||||
&& !isNil(visitLocation[propertyName])
|
||||
&& !isEmpty(visitLocation[propertyName]);
|
||||
|
||||
const updateOsStatsForVisit = (osStats, { userAgent }) => {
|
||||
const os = isNil(userAgent) ? 'Others' : osFromUserAgent(userAgent);
|
||||
const visitHasProperty = (visit, propertyName) => !isNil(visit) && hasValue(visit[propertyName]);
|
||||
|
||||
const updateOsStatsForVisit = (osStats, { os }) => {
|
||||
osStats[os] = (osStats[os] || 0) + 1;
|
||||
};
|
||||
|
||||
const updateBrowsersStatsForVisit = (browsersStats, { userAgent }) => {
|
||||
const browser = isNil(userAgent) ? 'Others' : browserFromUserAgent(userAgent);
|
||||
|
||||
const updateBrowsersStatsForVisit = (browsersStats, { browser }) => {
|
||||
browsersStats[browser] = (browsersStats[browser] || 0) + 1;
|
||||
};
|
||||
|
||||
const updateReferrersStatsForVisit = (referrersStats, { referer }) => {
|
||||
const notHasDomain = isNil(referer) || isEmpty(referer);
|
||||
const domain = notHasDomain ? 'Unknown' : extractDomain(referer);
|
||||
|
||||
const updateReferrersStatsForVisit = (referrersStats, { referer: domain }) => {
|
||||
referrersStats[domain] = (referrersStats[domain] || 0) + 1;
|
||||
};
|
||||
|
||||
const updateLocationsStatsForVisit = (propertyName) => (stats, { visitLocation }) => {
|
||||
const hasLocationProperty = visitLocationHasProperty(visitLocation, propertyName);
|
||||
const value = hasLocationProperty ? visitLocation[propertyName] : 'Unknown';
|
||||
const updateLocationsStatsForVisit = (propertyName) => (stats, visit) => {
|
||||
const hasLocationProperty = visitHasProperty(visit, propertyName);
|
||||
const value = hasLocationProperty ? visit[propertyName] : 'Unknown';
|
||||
|
||||
stats[value] = (stats[value] || 0) + 1;
|
||||
};
|
||||
|
||||
const updateCountriesStatsForVisit = updateLocationsStatsForVisit('countryName');
|
||||
const updateCitiesStatsForVisit = updateLocationsStatsForVisit('cityName');
|
||||
const updateCountriesStatsForVisit = updateLocationsStatsForVisit('country');
|
||||
const updateCitiesStatsForVisit = updateLocationsStatsForVisit('city');
|
||||
|
||||
const updateCitiesForMapForVisit = (citiesForMapStats, { visitLocation }) => {
|
||||
if (!visitLocationHasProperty(visitLocation, 'cityName')) {
|
||||
const updateCitiesForMapForVisit = (citiesForMapStats, visit) => {
|
||||
if (!visitHasProperty(visit, 'city') || visit.city === 'Unknown') {
|
||||
return;
|
||||
}
|
||||
|
||||
const { cityName, latitude, longitude } = visitLocation;
|
||||
const currentCity = citiesForMapStats[cityName] || {
|
||||
cityName,
|
||||
const { city, latitude, longitude } = visit;
|
||||
const currentCity = citiesForMapStats[city] || {
|
||||
cityName: city,
|
||||
count: 0,
|
||||
latLong: [ parseFloat(latitude), parseFloat(longitude) ],
|
||||
};
|
||||
|
||||
currentCity.count++;
|
||||
|
||||
citiesForMapStats[cityName] = currentCity;
|
||||
citiesForMapStats[city] = currentCity;
|
||||
};
|
||||
|
||||
export const processStatsFromVisits = memoizeWith(prop('id'), ({ visits }) =>
|
||||
visits.reduce(
|
||||
export const processStatsFromVisits = (normalizedVisits) =>
|
||||
normalizedVisits.reduce(
|
||||
(stats, visit) => {
|
||||
// We mutate the original object because it has a big side effect when large data sets are processed
|
||||
// We mutate the original object because it has a big performance impact when large data sets are processed
|
||||
updateOsStatsForVisit(stats.os, visit);
|
||||
updateBrowsersStatsForVisit(stats.browsers, visit);
|
||||
updateReferrersStatsForVisit(stats.referrers, visit);
|
||||
@@ -107,4 +57,19 @@ export const processStatsFromVisits = memoizeWith(prop('id'), ({ visits }) =>
|
||||
return stats;
|
||||
},
|
||||
{ os: {}, browsers: {}, referrers: {}, countries: {}, cities: {}, citiesForMap: {} }
|
||||
));
|
||||
);
|
||||
|
||||
export const normalizeVisits = map(({ userAgent, date, referer, visitLocation }) => {
|
||||
const { browser, os } = parseUserAgent(userAgent);
|
||||
|
||||
return {
|
||||
date,
|
||||
browser,
|
||||
os,
|
||||
referer: extractDomain(referer),
|
||||
country: (visitLocation && visitLocation.countryName) || 'Unknown',
|
||||
city: (visitLocation && visitLocation.cityName) || 'Unknown',
|
||||
latitude: visitLocation && visitLocation.latitude,
|
||||
longitude: visitLocation && visitLocation.longitude,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ describe('<App />', () => {
|
||||
const MainHeader = () => '';
|
||||
|
||||
beforeEach(() => {
|
||||
const App = appFactory(MainHeader, identity, identity, identity);
|
||||
const App = appFactory(MainHeader, identity, identity, identity, identity);
|
||||
|
||||
wrapper = shallow(<App />);
|
||||
});
|
||||
@@ -20,13 +20,14 @@ describe('<App />', () => {
|
||||
it('renders app main routes', () => {
|
||||
const routes = wrapper.find(Route);
|
||||
const expectedPaths = [
|
||||
'/server/create',
|
||||
'/',
|
||||
'/server/create',
|
||||
'/server/:serverId/edit',
|
||||
'/server/:serverId',
|
||||
];
|
||||
|
||||
expect.assertions(expectedPaths.length + 1);
|
||||
expect(routes).toHaveLength(4);
|
||||
expect(routes).toHaveLength(expectedPaths.length + 1);
|
||||
expectedPaths.forEach((path, index) => {
|
||||
expect(routes.at(index).prop('path')).toEqual(path);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { shallow } from 'enzyme';
|
||||
import React from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import asideMenuCreator from '../../src/common/AsideMenu';
|
||||
|
||||
describe('<AsideMenu />', () => {
|
||||
@@ -15,9 +14,9 @@ describe('<AsideMenu />', () => {
|
||||
afterEach(() => wrapped.unmount());
|
||||
|
||||
it('contains links to different sections', () => {
|
||||
const links = wrapped.find(NavLink);
|
||||
const links = wrapped.find('[to]');
|
||||
|
||||
expect(links).toHaveLength(3);
|
||||
expect(links).toHaveLength(4);
|
||||
links.forEach((link) => expect(link.prop('to')).toContain('abc123'));
|
||||
});
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { shallow } from 'enzyme';
|
||||
import { values } from 'ramda';
|
||||
import React from 'react';
|
||||
import Home from '../../src/common/Home';
|
||||
|
||||
describe('<Home />', () => {
|
||||
let wrapped;
|
||||
const defaultProps = {
|
||||
resetSelectedServer: () => '',
|
||||
resetSelectedServer: jest.fn(),
|
||||
servers: { loading: false, list: {} },
|
||||
};
|
||||
const createComponent = (props) => {
|
||||
@@ -17,26 +16,12 @@ describe('<Home />', () => {
|
||||
return wrapped;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
if (wrapped !== undefined) {
|
||||
wrapped.unmount();
|
||||
wrapped = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
it('resets selected server when mounted', () => {
|
||||
const resetSelectedServer = jest.fn();
|
||||
|
||||
expect(resetSelectedServer).not.toHaveBeenCalled();
|
||||
createComponent({ resetSelectedServer });
|
||||
expect(resetSelectedServer).toHaveBeenCalled();
|
||||
});
|
||||
afterEach(() => wrapped && wrapped.unmount());
|
||||
|
||||
it('shows link to create server when no servers exist', () => {
|
||||
const wrapped = createComponent();
|
||||
|
||||
expect(wrapped.find('Link')).toHaveLength(1);
|
||||
expect(wrapped.find('ListGroup')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('shows message when loading servers', () => {
|
||||
@@ -45,21 +30,17 @@ describe('<Home />', () => {
|
||||
|
||||
expect(span).toHaveLength(1);
|
||||
expect(span.text()).toContain('Trying to load servers...');
|
||||
expect(wrapped.find('ListGroup')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('shows servers list when list of servers is not empty', () => {
|
||||
const servers = {
|
||||
loading: false,
|
||||
list: {
|
||||
1: { name: 'foo', id: '123' },
|
||||
2: { name: 'bar', id: '456' },
|
||||
},
|
||||
};
|
||||
const wrapped = createComponent({ servers });
|
||||
it('Asks to select a server when not loadign and servers exist', () => {
|
||||
const list = [
|
||||
{ name: 'foo', id: '1' },
|
||||
{ name: 'bar', id: '2' },
|
||||
];
|
||||
const wrapped = createComponent({ servers: { list } });
|
||||
const span = wrapped.find('span');
|
||||
|
||||
expect(wrapped.find('Link')).toHaveLength(0);
|
||||
expect(wrapped.find('ListGroup')).toHaveLength(1);
|
||||
expect(wrapped.find('ListGroupItem')).toHaveLength(values(servers).length);
|
||||
expect(span).toHaveLength(1);
|
||||
expect(span.text()).toContain('Please, select a server.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,7 +38,7 @@ describe('<NotFound />', () => {
|
||||
});
|
||||
|
||||
it('shows a link with provided props', () => {
|
||||
const { wrapper } = createWrapper({ to: '/foo/bar', btnText: 'Hello' });
|
||||
const { wrapper } = createWrapper({ to: '/foo/bar', children: 'Hello' });
|
||||
const link = wrapper.find(Link);
|
||||
|
||||
expect(link.prop('to')).toEqual('/foo/bar');
|
||||
|
||||
26
test/common/ShlinkVersions.test.js
Normal file
26
test/common/ShlinkVersions.test.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import ShlinkVersions from '../../src/common/ShlinkVersions';
|
||||
|
||||
describe('<ShlinkVersions />', () => {
|
||||
let wrapper;
|
||||
const createWrapper = (props) => {
|
||||
wrapper = shallow(<ShlinkVersions {...props} />);
|
||||
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
afterEach(() => wrapper && wrapper.unmount());
|
||||
|
||||
it.each([
|
||||
[ '1.2.3', 'foo', 'Client: v1.2.3 - Server: foo' ],
|
||||
[ 'foo', '1.2.3', 'Client: latest - Server: 1.2.3' ],
|
||||
[ 'latest', 'latest', 'Client: latest - Server: latest' ],
|
||||
[ '5.5.0', '0.2.8', 'Client: v5.5.0 - Server: 0.2.8' ],
|
||||
[ 'not-semver', 'something', 'Client: latest - Server: something' ],
|
||||
])('displays expected versions', (clientVersion, printableVersion, expected) => {
|
||||
const wrapper = createWrapper({ clientVersion, selectedServer: { printableVersion } });
|
||||
|
||||
expect(wrapper.text()).toEqual(expected);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,9 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { identity } from 'ramda';
|
||||
import each from 'jest-each';
|
||||
import { PaginationItem } from 'reactstrap';
|
||||
import SimplePaginator, { ellipsis } from '../../src/common/SimplePaginator';
|
||||
import SimplePaginator from '../../src/common/SimplePaginator';
|
||||
import { ELLIPSIS } from '../../src/utils/helpers/pagination';
|
||||
|
||||
describe('<SimplePaginator />', () => {
|
||||
let wrapper;
|
||||
@@ -15,38 +15,38 @@ describe('<SimplePaginator />', () => {
|
||||
|
||||
afterEach(() => wrapper && wrapper.unmount());
|
||||
|
||||
each([ -3, -2, 0, 1 ]).it('renders empty when the amount of pages is smaller than 2', (pagesCount) => {
|
||||
it.each([ -3, -2, 0, 1 ])('renders empty when the amount of pages is smaller than 2', (pagesCount) => {
|
||||
expect(createWrapper(pagesCount).text()).toEqual('');
|
||||
});
|
||||
|
||||
describe('ellipsis are rendered where expected', () => {
|
||||
describe('ELLIPSIS are rendered where expected', () => {
|
||||
const getItemsForPages = (pagesCount, currentPage) => {
|
||||
const paginator = createWrapper(pagesCount, currentPage);
|
||||
const items = paginator.find(PaginationItem);
|
||||
const itemsWithEllipsis = items.filterWhere((item) => item.key() && item.key().includes(ellipsis));
|
||||
const itemsWithEllipsis = items.filterWhere((item) => item.key() && item.key().includes(ELLIPSIS));
|
||||
|
||||
return { items, itemsWithEllipsis };
|
||||
};
|
||||
|
||||
it('renders first ellipsis', () => {
|
||||
it('renders first ELLIPSIS', () => {
|
||||
const { items, itemsWithEllipsis } = getItemsForPages(9, 7);
|
||||
|
||||
expect(items.at(2).html()).toContain(ellipsis);
|
||||
expect(items.at(2).html()).toContain(ELLIPSIS);
|
||||
expect(itemsWithEllipsis).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('renders last ellipsis', () => {
|
||||
it('renders last ELLIPSIS', () => {
|
||||
const { items, itemsWithEllipsis } = getItemsForPages(9, 2);
|
||||
|
||||
expect(items.at(items.length - 3).html()).toContain(ellipsis);
|
||||
expect(items.at(items.length - 3).html()).toContain(ELLIPSIS);
|
||||
expect(itemsWithEllipsis).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('renders both ellipsis', () => {
|
||||
it('renders both ELLIPSIS', () => {
|
||||
const { items, itemsWithEllipsis } = getItemsForPages(20, 9);
|
||||
|
||||
expect(items.at(2).html()).toContain(ellipsis);
|
||||
expect(items.at(items.length - 3).html()).toContain(ellipsis);
|
||||
expect(items.at(2).html()).toContain(ELLIPSIS);
|
||||
expect(items.at(items.length - 3).html()).toContain(ELLIPSIS);
|
||||
expect(itemsWithEllipsis).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user