mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2026-02-26 11:46:39 +00:00
Compare commits
91 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
984c1ea716 | ||
|
|
df38cf6ca9 | ||
|
|
1b60b0e2a8 | ||
|
|
11f9c7c507 | ||
|
|
ebe649aaac | ||
|
|
656b68d422 | ||
|
|
cd1f186e28 | ||
|
|
d0b3edaa2f | ||
|
|
2268b85ade | ||
|
|
d7e3b7b912 | ||
|
|
4bd83eecfb | ||
|
|
b7fd2308ad | ||
|
|
a6958941ad | ||
|
|
c98b28ff0f | ||
|
|
6a372badfa | ||
|
|
b6ab9a1bdd | ||
|
|
daf9e7cf64 | ||
|
|
ef42dcd666 | ||
|
|
1b6028ae6d | ||
|
|
9340512980 | ||
|
|
9d0b4cc065 | ||
|
|
c5cb0dcb26 | ||
|
|
a42f5ab13e | ||
|
|
68b0577526 | ||
|
|
61867366e7 | ||
|
|
c670d86955 | ||
|
|
4565a64cd8 | ||
|
|
f36e42d9c1 | ||
|
|
0a3a97242b | ||
|
|
68253c3bc4 | ||
|
|
544384d85e | ||
|
|
91daec852f | ||
|
|
dcc5b9cc8c | ||
|
|
1d26cd93fb | ||
|
|
e47dfaf36f | ||
|
|
09e2c69e46 | ||
|
|
07d3567244 | ||
|
|
9bdbe90716 | ||
|
|
02a4380f7c | ||
|
|
4e483dc5d4 | ||
|
|
52631e629e | ||
|
|
3a53298417 | ||
|
|
fb0f14fc16 | ||
|
|
7a94b1730d | ||
|
|
f856bc218a | ||
|
|
bfbb21e1cc | ||
|
|
18e18f533b | ||
|
|
6eead70511 | ||
|
|
6fd30ed51a | ||
|
|
67c674f073 | ||
|
|
289d8784c0 | ||
|
|
18e026e4ca | ||
|
|
8741f42fe8 | ||
|
|
665d6209d9 | ||
|
|
59fda29894 | ||
|
|
61c027f9a1 | ||
|
|
241c9b73b0 | ||
|
|
85dc1d0825 | ||
|
|
e38887aa26 | ||
|
|
54fec79945 | ||
|
|
fad0bf1c9d | ||
|
|
be2f86050f | ||
|
|
a7f941e8e4 | ||
|
|
b08c6748c7 | ||
|
|
bdd7932e07 | ||
|
|
bcf5dcf180 | ||
|
|
8b2cbf7aea | ||
|
|
277b5e43f8 | ||
|
|
7dd6a31609 | ||
|
|
86bf1515d4 | ||
|
|
bbc47b387e | ||
|
|
3953e98a77 | ||
|
|
09b8bd501d | ||
|
|
6bddaaa055 | ||
|
|
dd728d4d13 | ||
|
|
9ba8bc8f3d | ||
|
|
16dee3664b | ||
|
|
6fcf588bfd | ||
|
|
6a6c427b0e | ||
|
|
41f885d8ec | ||
|
|
7516ca8dd9 | ||
|
|
aa59a95f91 | ||
|
|
8a5161c0e8 | ||
|
|
d8ae69e861 | ||
|
|
a485d0b507 | ||
|
|
ed40b79c8d | ||
|
|
91488ae294 | ||
|
|
a22a1938c1 | ||
|
|
0f73cb9f8c | ||
|
|
f3129399de | ||
|
|
37e6c27461 |
@@ -4,3 +4,4 @@
|
||||
./node_modules
|
||||
./test
|
||||
./shlink-web-client.gif
|
||||
./dist
|
||||
|
||||
45
.travis.yml
45
.travis.yml
@@ -1,7 +1,21 @@
|
||||
dist: bionic
|
||||
|
||||
language: node_js
|
||||
|
||||
node_js:
|
||||
- "12.14.1"
|
||||
jobs:
|
||||
fast_finish: true
|
||||
include:
|
||||
- name: "Docker publish"
|
||||
node_js: '12.16.3'
|
||||
if: NOT type = pull_request
|
||||
env:
|
||||
- DOCKER_PUBLISH="true"
|
||||
- name: "CI"
|
||||
node_js: '12.16.3'
|
||||
env:
|
||||
- DOCKER_PUBLISH="false"
|
||||
allow_failures:
|
||||
- name: "Docker publish"
|
||||
|
||||
cache:
|
||||
directories:
|
||||
@@ -11,35 +25,34 @@ services:
|
||||
- docker
|
||||
|
||||
install:
|
||||
- npm ci
|
||||
- if [[ ${DOCKER_PUBLISH} == 'true' ]]; then sudo bash ./scripts/docker/install-docker ; fi
|
||||
- if [[ ${DOCKER_PUBLISH} == 'false' ]]; then npm ci ; fi
|
||||
|
||||
before_script:
|
||||
- echo "Building commit range ${TRAVIS_COMMIT_RANGE}"
|
||||
- export MUTATION_FILES=$(git diff ${TRAVIS_COMMIT_RANGE:-origin/master} --name-only | grep -E 'src\/(.*).(js|ts|jsx|tsx)$' | paste -sd ",")
|
||||
- if [[ ${DOCKER_PUBLISH} == 'false' ]]; then echo "Building commit range ${TRAVIS_COMMIT_RANGE}" ; fi
|
||||
- if [[ ${DOCKER_PUBLISH} == 'false' ]]; then export MUTATION_FILES=$(git diff ${TRAVIS_COMMIT_RANGE:-origin/master} --name-only | grep -E 'src\/(.*).(js|ts|jsx|tsx)$' | paste -sd ",") ; fi
|
||||
|
||||
script:
|
||||
- npm run lint
|
||||
- npm run test:ci
|
||||
- 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
|
||||
- if [[ ${DOCKER_PUBLISH} == 'true' ]]; then bash ./scripts/docker/build ; fi
|
||||
- if [[ ${DOCKER_PUBLISH} == 'false' ]]; then npm run lint ; fi
|
||||
- if [[ ${DOCKER_PUBLISH} == 'false' ]]; then npm run test:ci ; fi
|
||||
- if [[ ${DOCKER_PUBLISH} == 'false' ]]; then docker build -t shlink-web-client:test . ; fi
|
||||
- if [[ ${DOCKER_PUBLISH} == 'false' ]]; then npm run mutate:ci ; fi
|
||||
|
||||
after_success:
|
||||
- node_modules/.bin/ocular coverage/clover.xml
|
||||
- if [[ ${DOCKER_PUBLISH} == 'false' ]]; then node_modules/.bin/ocular coverage/clover.xml ; fi
|
||||
|
||||
# Before deploying, build dist file for current travis tag
|
||||
before_deploy:
|
||||
- if [[ ! -z $TRAVIS_TAG ]]; then npm run build ${TRAVIS_TAG#?} ; fi
|
||||
- if [[ ! -z $TRAVIS_TAG && ${DOCKER_PUBLISH} == 'false' ]]; then npm run build ${TRAVIS_TAG#?} ; fi
|
||||
|
||||
deploy:
|
||||
- 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:
|
||||
all_branches: true
|
||||
condition: ${DOCKER_PUBLISH} == 'false'
|
||||
tags: true
|
||||
|
||||
41
CHANGELOG.md
41
CHANGELOG.md
@@ -4,6 +4,47 @@ 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.5.0 - 2020-05-31
|
||||
|
||||
#### Added
|
||||
|
||||
* [#148](https://github.com/shlinkio/shlink-web-client/issues/148) Added support for real-time updates when consuming a Shlink version that is integrated with a mercure hub server.
|
||||
|
||||
The integration is transparent. When a server is opened, shlink-web-client will try to get the mercure info from it.
|
||||
|
||||
* If it works, it will setup the necessary `EventSource`s, dispatching redux actions when an event is pushed, which will in turn update the UI.
|
||||
* If it fails, it will assume it is either not configured or not supported by the Shlink version.
|
||||
|
||||
* [#265](https://github.com/shlinkio/shlink-web-client/issues/265) Updated tags section to allow displaying number of short URLs using every tag and number of visits for all short URLs using the tag.
|
||||
|
||||
This will work only when using Shlink v2.2.0 or above. For previous versions, the tags page will continue behaving the same.
|
||||
|
||||
* [#261](https://github.com/shlinkio/shlink-web-client/issues/261) Added new page to show visit stats by tag.
|
||||
|
||||
This new page will return a "not found" error when the server is lower than v2.2.0, as older versions do not support fetching stats by tag.
|
||||
|
||||
* [#253](https://github.com/shlinkio/shlink-web-client/issues/253) Created new settings page that will be used to define customizations in the app.
|
||||
|
||||
* [#149](https://github.com/shlinkio/shlink-web-client/issues/149) and [#198](https://github.com/shlinkio/shlink-web-client/issues/198) Added new line chart to visits and tags stats which displays amount of visits during selected time period, grouped by month, week, day or hour.
|
||||
|
||||
#### Changed
|
||||
|
||||
* [#218](https://github.com/shlinkio/shlink-web-client/issues/218) Added back button to sections not displayed in left menu.
|
||||
* [#255](https://github.com/shlinkio/shlink-web-client/issues/255) Improved how servers and settings are persisted in the local storage.
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* [#262](https://github.com/shlinkio/shlink-web-client/issues/262) Fixed charts displaying decimal numbers, when visits are absolute and that makes no sense.
|
||||
|
||||
|
||||
## 2.4.0 - 2020-04-10
|
||||
|
||||
#### Added
|
||||
|
||||
11
Dockerfile
11
Dockerfile
@@ -1,16 +1,11 @@
|
||||
FROM node:12.14.1-alpine as node
|
||||
FROM node:12.16.3-alpine as node
|
||||
COPY . /shlink-web-client
|
||||
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
|
||||
npm install && npm run build -- ${VERSION} --no-dist
|
||||
|
||||
FROM nginx:1.17.7-alpine
|
||||
FROM nginx:1.17.10-alpine
|
||||
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
|
||||
RUN rm -r /usr/share/nginx/html && rm /etc/nginx/conf.d/default.conf
|
||||
COPY config/docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
@@ -3,7 +3,7 @@ version: '3'
|
||||
services:
|
||||
shlink_web_client_node:
|
||||
container_name: shlink_web_client_node
|
||||
image: node:12.14.1-alpine
|
||||
image: node:12.16.3-alpine
|
||||
command: /bin/sh -c "cd /home/shlink/www && npm install && npm run start"
|
||||
volumes:
|
||||
- ./:/home/shlink/www
|
||||
|
||||
607
package-lock.json
generated
607
package-lock.json
generated
@@ -1,8 +1,7 @@
|
||||
{
|
||||
"name": "shlink-web-client",
|
||||
"version": "2.3.0",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"lockfileVersion": 1,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": {
|
||||
"version": "7.0.0",
|
||||
@@ -327,6 +326,12 @@
|
||||
"@babel/types": "^7.4.4"
|
||||
}
|
||||
},
|
||||
"@babel/helper-validator-identifier": {
|
||||
"version": "7.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.1.tgz",
|
||||
"integrity": "sha512-5vW/JXLALhczRCWP0PnFDMCJAchlBvM7f4uk/jXritBnIa6E1KmqmtrS3yn1LAnxFBypQ3eneLuXjsnfQsgILw==",
|
||||
"dev": true
|
||||
},
|
||||
"@babel/helper-wrap-function": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.2.0.tgz",
|
||||
@@ -1464,68 +1469,140 @@
|
||||
"dev": true
|
||||
},
|
||||
"@stryker-mutator/api": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@stryker-mutator/api/-/api-2.1.0.tgz",
|
||||
"integrity": "sha512-zas7PNOEFnepjT/OfTte2Tu6vQ5Q30ZGGRigBqY75fe7PJZXon/1cGyB+SS6nN29tlY29t5Err5zOFX/qtxf8g==",
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@stryker-mutator/api/-/api-3.2.4.tgz",
|
||||
"integrity": "sha512-WzeLrerYih+xi2HyUtSFKRJq23O4P3HZ669w42MS+q0ivxd953Hr+ZXNfuK6so3jXVzygt5hv3FTyEenm+w0Qg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"mutation-testing-report-schema": "^1.0.0",
|
||||
"tslib": "~1.10.0"
|
||||
"mutation-testing-report-schema": "~1.3.0",
|
||||
"surrial": "~2.0.2",
|
||||
"tslib": "~2.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"tslib": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.10.0.tgz",
|
||||
"integrity": "sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==",
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.0.0.tgz",
|
||||
"integrity": "sha512-lTqkx847PI7xEDYJntxZH89L2/aXInsyF2luSafe/+0fHOMjlBNXdH6th7f70qxLDhul7KZK0zC8V5ZIyHl0/g==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"@stryker-mutator/core": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@stryker-mutator/core/-/core-2.1.0.tgz",
|
||||
"integrity": "sha512-CucANfEfYKuc3gDPJlrMw0UElr8YD3LrrC/dGN9/GyGgPFdgb4zX9XHw0Q/deGQTk51h9BieuSMLqleVxP5UqQ==",
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@stryker-mutator/core/-/core-3.2.4.tgz",
|
||||
"integrity": "sha512-bTPFctIx1iH0YPx0dJxfMfJKyxv9+NIONjEPfrZsJjaUoQFv787s9+bnzD/JUYCYoG6Hj7XjMFsFs97xI318yA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@stryker-mutator/api": "^2.1.0",
|
||||
"@stryker-mutator/util": "^2.1.0",
|
||||
"chalk": "~2.4.1",
|
||||
"commander": "~3.0.1",
|
||||
"@stryker-mutator/api": "^3.2.4",
|
||||
"@stryker-mutator/util": "^3.2.4",
|
||||
"ajv": "^6.12.0",
|
||||
"chalk": "~4.0.0",
|
||||
"commander": "~5.1.0",
|
||||
"file-url": "~3.0.0",
|
||||
"get-port": "~5.0.0",
|
||||
"glob": "~7.1.2",
|
||||
"inquirer": "~7.0.0",
|
||||
"inquirer": "~7.1.0",
|
||||
"istanbul-lib-instrument": "~3.3.0",
|
||||
"lodash": "~4.17.4",
|
||||
"log4js": "~5.1.0",
|
||||
"mkdirp": "~0.5.1",
|
||||
"mutation-testing-metrics": "^1.1.1",
|
||||
"lodash.flatmap": "^4.5.0",
|
||||
"lodash.groupby": "^4.6.0",
|
||||
"log4js": "6.2.1",
|
||||
"mkdirp": "~1.0.3",
|
||||
"mutation-testing-elements": "~1.3.0",
|
||||
"mutation-testing-metrics": "~1.3.0",
|
||||
"progress": "~2.0.0",
|
||||
"rimraf": "~3.0.0",
|
||||
"rxjs": "~6.5.1",
|
||||
"source-map": "~0.7.3",
|
||||
"surrial": "~1.0.0",
|
||||
"surrial": "~2.0.2",
|
||||
"tree-kill": "~1.2.0",
|
||||
"tslib": "~1.10.0",
|
||||
"typed-inject": "~2.0.0",
|
||||
"typed-rest-client": "~1.5.0"
|
||||
"tslib": "~2.0.0",
|
||||
"typed-inject": "~2.2.1",
|
||||
"typed-rest-client": "~1.7.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"ajv": {
|
||||
"version": "6.12.2",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.2.tgz",
|
||||
"integrity": "sha512-k+V+hzjm5q/Mr8ef/1Y9goCmlsK4I6Sm74teeyGvFk1XrOsbsKLjEdrvny42CZ+a8sXbk8KWpY/bDwS+FLL2UQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
"json-schema-traverse": "^0.4.1",
|
||||
"uri-js": "^4.2.2"
|
||||
}
|
||||
},
|
||||
"ansi-styles": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz",
|
||||
"integrity": "sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/color-name": "^1.1.1",
|
||||
"color-convert": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"chalk": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.0.0.tgz",
|
||||
"integrity": "sha512-N9oWFcegS0sFr9oh1oz2d7Npos6vNoWW9HvtCg5N1KRFpUhaAhvTv5Y58g880fZaEYSNm3qDz8SU1UrGvp+n7A==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
}
|
||||
},
|
||||
"color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"color-name": "~1.1.4"
|
||||
}
|
||||
},
|
||||
"fast-deep-equal": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.1.tgz",
|
||||
"integrity": "sha512-8UEa58QDLauDNfpbrX55Q9jrGHThw2ZMdOky5Gl1CDtVeJDPVrG4Jxx1N8jw2gkWaff5UUuX1KJd+9zGe2B+ZA==",
|
||||
"dev": true
|
||||
},
|
||||
"has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||
"dev": true
|
||||
},
|
||||
"mkdirp": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
|
||||
"integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
|
||||
"dev": true
|
||||
},
|
||||
"rimraf": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.0.tgz",
|
||||
"integrity": "sha512-NDGVxTsjqfunkds7CqsOiEnxln4Bo7Nddl3XhS4pXg5OzwkLqJ971ZVAAnB+DDLnF76N+VnDEiBHaVV8I06SUg==",
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
|
||||
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"glob": "^7.1.3"
|
||||
}
|
||||
},
|
||||
"rxjs": {
|
||||
"version": "6.5.3",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.5.3.tgz",
|
||||
"integrity": "sha512-wuYsAYYFdWTAnAaPoKGNhfpWwKZbJW+HgAJ+mImp+Epl7BG8oNWBCTyRM8gba9k4lk8BgWdoYm21Mo/RYhhbgA==",
|
||||
"version": "6.5.5",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.5.5.tgz",
|
||||
"integrity": "sha512-WfQI+1gohdf0Dai/Bbmk5L5ItH5tYqm3ki2c5GdWhKjalzjg93N3avFjVStyZZz+A2Em+ZxKH5bNghw9UeylGQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"tslib": "^1.9.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"tslib": {
|
||||
"version": "1.13.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.13.0.tgz",
|
||||
"integrity": "sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"source-map": {
|
||||
@@ -1534,112 +1611,148 @@
|
||||
"integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==",
|
||||
"dev": true
|
||||
},
|
||||
"tslib": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.10.0.tgz",
|
||||
"integrity": "sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"@stryker-mutator/html-reporter": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@stryker-mutator/html-reporter/-/html-reporter-2.1.0.tgz",
|
||||
"integrity": "sha512-yCHhBVlGbm3CwK+8hsWPI0RI78mGlas1TntnT3z4VIWUS+RAoZj3ZMf42tB7D9SMp1l2nq5Fe0QKuIf9FfFHDw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@stryker-mutator/api": "^2.1.0",
|
||||
"@stryker-mutator/util": "^2.1.0",
|
||||
"file-url": "~3.0.0",
|
||||
"mkdirp": "~0.5.1",
|
||||
"mutation-testing-elements": "^1.0.2",
|
||||
"rimraf": "~3.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"rimraf": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.0.tgz",
|
||||
"integrity": "sha512-NDGVxTsjqfunkds7CqsOiEnxln4Bo7Nddl3XhS4pXg5OzwkLqJ971ZVAAnB+DDLnF76N+VnDEiBHaVV8I06SUg==",
|
||||
"supports-color": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz",
|
||||
"integrity": "sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"glob": "^7.1.3"
|
||||
"has-flag": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"tslib": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.0.0.tgz",
|
||||
"integrity": "sha512-lTqkx847PI7xEDYJntxZH89L2/aXInsyF2luSafe/+0fHOMjlBNXdH6th7f70qxLDhul7KZK0zC8V5ZIyHl0/g==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"@stryker-mutator/javascript-mutator": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@stryker-mutator/javascript-mutator/-/javascript-mutator-2.1.0.tgz",
|
||||
"integrity": "sha512-RIPRTDvpVStjqxytvmiKwIRBtsMq6cwZHBG8s33Xkxv7g8r/7Q7JetmmKDCWz1uoybE0gx+XEWLipFdRdxFt+g==",
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@stryker-mutator/javascript-mutator/-/javascript-mutator-3.2.4.tgz",
|
||||
"integrity": "sha512-Jp64+nBz97Nnn1h9aoBCH7OUtppGf2i1sugeTebp2T4b8cLZsOo2zMEKffCLEesC7+1KNEH0JKzenSen6rM4rw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/generator": "~7.5.0",
|
||||
"@babel/parser": "~7.5.5",
|
||||
"@babel/traverse": "~7.5.0",
|
||||
"@stryker-mutator/api": "^2.1.0",
|
||||
"lodash": "~4.17.4",
|
||||
"tslib": "~1.10.0"
|
||||
"@babel/generator": "~7.9.4",
|
||||
"@babel/parser": "~7.9.4",
|
||||
"@babel/traverse": "~7.9.5",
|
||||
"@stryker-mutator/api": "^3.2.4",
|
||||
"tslib": "~2.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/code-frame": {
|
||||
"version": "7.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.5.5.tgz",
|
||||
"integrity": "sha512-27d4lZoomVyo51VegxI20xZPuSHusqbQag/ztrBC7wegWoQ1nLREPVSKSW8byhTlzTKyNE4ifaTA6lCp7JjpFw==",
|
||||
"version": "7.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.1.tgz",
|
||||
"integrity": "sha512-IGhtTmpjGbYzcEDOw7DcQtbQSXcG9ftmAXtWTu9V936vDye4xjjekktFAtgZsWpzTj/X01jocB46mTywm/4SZw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/highlight": "^7.0.0"
|
||||
"@babel/highlight": "^7.10.1"
|
||||
}
|
||||
},
|
||||
"@babel/generator": {
|
||||
"version": "7.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.5.5.tgz",
|
||||
"integrity": "sha512-ETI/4vyTSxTzGnU2c49XHv2zhExkv9JHLTwDAFz85kmcwuShvYG2H08FwgIguQf4JC75CBnXAUM5PqeF4fj0nQ==",
|
||||
"version": "7.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.9.6.tgz",
|
||||
"integrity": "sha512-+htwWKJbH2bL72HRluF8zumBxzuX0ZZUFl3JLNyoUjM/Ho8wnVpPXM6aUz8cfKDqQ/h7zHqKt4xzJteUosckqQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/types": "^7.5.5",
|
||||
"@babel/types": "^7.9.6",
|
||||
"jsesc": "^2.5.1",
|
||||
"lodash": "^4.17.13",
|
||||
"source-map": "^0.5.0",
|
||||
"trim-right": "^1.0.1"
|
||||
"source-map": "^0.5.0"
|
||||
}
|
||||
},
|
||||
"@babel/helper-function-name": {
|
||||
"version": "7.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.10.1.tgz",
|
||||
"integrity": "sha512-fcpumwhs3YyZ/ttd5Rz0xn0TpIwVkN7X0V38B9TWNfVF42KEkhkAAuPCQ3oXmtTRtiPJrmZ0TrfS0GKF0eMaRQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/helper-get-function-arity": "^7.10.1",
|
||||
"@babel/template": "^7.10.1",
|
||||
"@babel/types": "^7.10.1"
|
||||
}
|
||||
},
|
||||
"@babel/helper-get-function-arity": {
|
||||
"version": "7.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.10.1.tgz",
|
||||
"integrity": "sha512-F5qdXkYGOQUb0hpRaPoetF9AnsXknKjWMZ+wmsIRsp5ge5sFh4c3h1eH2pRTTuy9KKAA2+TTYomGXAtEL2fQEw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/types": "^7.10.1"
|
||||
}
|
||||
},
|
||||
"@babel/helper-split-export-declaration": {
|
||||
"version": "7.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.10.1.tgz",
|
||||
"integrity": "sha512-UQ1LVBPrYdbchNhLwj6fetj46BcFwfS4NllJo/1aJsT+1dLTEnXJL0qHqtY7gPzF8S2fXBJamf1biAXV3X077g==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/types": "^7.10.1"
|
||||
}
|
||||
},
|
||||
"@babel/highlight": {
|
||||
"version": "7.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.10.1.tgz",
|
||||
"integrity": "sha512-8rMof+gVP8mxYZApLF/JgNDAkdKa+aJt3ZYxF8z6+j/hpeXL7iMsKCPHa2jNMHu/qqBwzQF4OHNoYi8dMA/rYg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/helper-validator-identifier": "^7.10.1",
|
||||
"chalk": "^2.0.0",
|
||||
"js-tokens": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"@babel/parser": {
|
||||
"version": "7.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.9.6.tgz",
|
||||
"integrity": "sha512-AoeIEJn8vt+d/6+PXDRPaksYhnlbMIiejioBZvvMQsOjW/JYK6k/0dKnvvP3EhK5GfMBWDPtrxRtegWdAcdq9Q==",
|
||||
"dev": true
|
||||
},
|
||||
"@babel/template": {
|
||||
"version": "7.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.10.1.tgz",
|
||||
"integrity": "sha512-OQDg6SqvFSsc9A0ej6SKINWrpJiNonRIniYondK2ViKhB06i3c0s+76XUft71iqBEe9S1OKsHwPAjfHnuvnCig==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/code-frame": "^7.10.1",
|
||||
"@babel/parser": "^7.10.1",
|
||||
"@babel/types": "^7.10.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"lodash": {
|
||||
"version": "4.17.15",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz",
|
||||
"integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==",
|
||||
"@babel/parser": {
|
||||
"version": "7.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.10.2.tgz",
|
||||
"integrity": "sha512-PApSXlNMJyB4JiGVhCOlzKIif+TKFTvu0aQAhnTvfP/z3vVSN6ZypH5bfUNwFXXjRQtUEBNFd2PtmCmG2Py3qQ==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"@babel/parser": {
|
||||
"version": "7.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.5.5.tgz",
|
||||
"integrity": "sha512-E5BN68cqR7dhKan1SfqgPGhQ178bkVKpXTPEXnFJBrEt8/DKRZlybmy+IgYLTeN7tp1R5Ccmbm2rBk17sHYU3g==",
|
||||
"dev": true
|
||||
},
|
||||
"@babel/traverse": {
|
||||
"version": "7.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.5.5.tgz",
|
||||
"integrity": "sha512-MqB0782whsfffYfSjH4TM+LMjrJnhCNEDMDIjeTpl+ASaUvxcjoiVCo/sM1GhS1pHOXYfWVCYneLjMckuUxDaQ==",
|
||||
"version": "7.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.9.6.tgz",
|
||||
"integrity": "sha512-b3rAHSjbxy6VEAvlxM8OV/0X4XrG72zoxme6q1MOoe2vd0bEc+TwayhuC1+Dfgqh1QEG+pj7atQqvUprHIccsg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/code-frame": "^7.5.5",
|
||||
"@babel/generator": "^7.5.5",
|
||||
"@babel/helper-function-name": "^7.1.0",
|
||||
"@babel/helper-split-export-declaration": "^7.4.4",
|
||||
"@babel/parser": "^7.5.5",
|
||||
"@babel/types": "^7.5.5",
|
||||
"@babel/code-frame": "^7.8.3",
|
||||
"@babel/generator": "^7.9.6",
|
||||
"@babel/helper-function-name": "^7.9.5",
|
||||
"@babel/helper-split-export-declaration": "^7.8.3",
|
||||
"@babel/parser": "^7.9.6",
|
||||
"@babel/types": "^7.9.6",
|
||||
"debug": "^4.1.0",
|
||||
"globals": "^11.1.0",
|
||||
"lodash": "^4.17.13"
|
||||
},
|
||||
"dependencies": {
|
||||
"lodash": {
|
||||
"version": "4.17.15",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz",
|
||||
"integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"@babel/types": {
|
||||
"version": "7.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.10.2.tgz",
|
||||
"integrity": "sha512-AD3AwWBSz0AWF0AkCN9VPiWrvldXq+/e3cHa4J89vo4ymjz1XwrBFFVZmkJTsQIPNk+ZVomPSXUJqq8yyjZsng==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/helper-validator-identifier": "^7.10.1",
|
||||
"lodash": "^4.17.13",
|
||||
"to-fast-properties": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"debug": {
|
||||
@@ -1651,6 +1764,12 @@
|
||||
"ms": "^2.1.1"
|
||||
}
|
||||
},
|
||||
"lodash": {
|
||||
"version": "4.17.15",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz",
|
||||
"integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==",
|
||||
"dev": true
|
||||
},
|
||||
"ms": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
||||
@@ -1664,20 +1783,20 @@
|
||||
"dev": true
|
||||
},
|
||||
"tslib": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.10.0.tgz",
|
||||
"integrity": "sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==",
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.0.0.tgz",
|
||||
"integrity": "sha512-lTqkx847PI7xEDYJntxZH89L2/aXInsyF2luSafe/+0fHOMjlBNXdH6th7f70qxLDhul7KZK0zC8V5ZIyHl0/g==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"@stryker-mutator/jest-runner": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@stryker-mutator/jest-runner/-/jest-runner-2.1.0.tgz",
|
||||
"integrity": "sha512-Ogw9LQbvcbWfFA+aqIu6ZTdznTsf8Kst7QgA9qqX/7JuTtv0K23BjUD864FLJfTHIkOCclwa8SSynSNME4LO5A==",
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@stryker-mutator/jest-runner/-/jest-runner-3.2.4.tgz",
|
||||
"integrity": "sha512-MooAGDubqw/cbLnrQ1K27AI9vgeQlr/GqALW0x/lp7YXsSutf9UPk+efjo18g4wHO9+gBM7EdjkRydf6QHuynQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@stryker-mutator/api": "^2.1.0",
|
||||
"@stryker-mutator/api": "^3.2.4",
|
||||
"semver": "~6.3.0"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -1690,9 +1809,9 @@
|
||||
}
|
||||
},
|
||||
"@stryker-mutator/util": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@stryker-mutator/util/-/util-2.1.0.tgz",
|
||||
"integrity": "sha512-J0DzkKEQU39dzfc4tO9crgW3tPOW0nuKIkEDtrDK2P8qQBRMZyP7Gla10LDQSqSYIBSAVzLKZgrin64s5g6KXA==",
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@stryker-mutator/util/-/util-3.2.4.tgz",
|
||||
"integrity": "sha512-s/yB6Ok0NZfGlQD1A/3BPdwCyS2YA5yQzIFNsFE8q/GqmMlZ5SjliEFNgp7u3LUYpqqBStmmz6qzP/PoL1BxqQ==",
|
||||
"dev": true
|
||||
},
|
||||
"@svgr/babel-plugin-add-jsx-attribute": {
|
||||
@@ -1867,6 +1986,12 @@
|
||||
"@babel/types": "^7.3.0"
|
||||
}
|
||||
},
|
||||
"@types/color-name": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz",
|
||||
"integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/events": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.0.tgz",
|
||||
@@ -4246,6 +4371,11 @@
|
||||
"shallow-clone": "^0.1.2"
|
||||
}
|
||||
},
|
||||
"clone-function": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/clone-function/-/clone-function-1.0.6.tgz",
|
||||
"integrity": "sha1-QoRxk3dQvKnEjsv7wW9uIy90oD0="
|
||||
},
|
||||
"clone-regexp": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/clone-regexp/-/clone-regexp-1.0.1.tgz",
|
||||
@@ -4347,9 +4477,9 @@
|
||||
}
|
||||
},
|
||||
"commander": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-3.0.2.tgz",
|
||||
"integrity": "sha512-Gar0ASD4BDyKC4hl4DwHqDrmvjoxWKZigVnAbn5H1owvm4CxCPdb0HQDehwNYMJpla5+M2tPmPARzhtYuwpHow==",
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz",
|
||||
"integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==",
|
||||
"dev": true
|
||||
},
|
||||
"common-tags": {
|
||||
@@ -5042,9 +5172,9 @@
|
||||
}
|
||||
},
|
||||
"date-format": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/date-format/-/date-format-2.1.0.tgz",
|
||||
"integrity": "sha512-bYQuGLeFxhkxNOF3rcMtiZxvCBAquGzZm6oWA1oZ0g2THUzivaRhv8uOhdr19LmoobSOLoIAxeUK2RdbM8IFTA==",
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/date-format/-/date-format-3.0.0.tgz",
|
||||
"integrity": "sha512-eyTcpKOcamdhWJXj56DpQMo1ylSQpcGtGKXcU0Tb97+K56/CF5amAqqqNj0+KvA0iw2ynxtHWFsPDSClCxe48w==",
|
||||
"dev": true
|
||||
},
|
||||
"date-now": {
|
||||
@@ -6641,6 +6771,11 @@
|
||||
"integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=",
|
||||
"dev": true
|
||||
},
|
||||
"event-source-polyfill": {
|
||||
"version": "1.0.12",
|
||||
"resolved": "https://registry.npmjs.org/event-source-polyfill/-/event-source-polyfill-1.0.12.tgz",
|
||||
"integrity": "sha512-WjOTn0LIbaN08z/8gNt3GYAomAdm6cZ2lr/QdvhTTEipr5KR6lds2ziUH+p/Iob4Lk6NClKhwPOmn1NjQEcJCg=="
|
||||
},
|
||||
"eventemitter3": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.0.tgz",
|
||||
@@ -8823,41 +8958,61 @@
|
||||
"dev": true
|
||||
},
|
||||
"inquirer": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/inquirer/-/inquirer-7.0.0.tgz",
|
||||
"integrity": "sha512-rSdC7zelHdRQFkWnhsMu2+2SO41mpv2oF2zy4tMhmiLWkcKbOAs87fWAJhVXttKVwhdZvymvnuM95EyEXg2/tQ==",
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/inquirer/-/inquirer-7.1.0.tgz",
|
||||
"integrity": "sha512-5fJMWEmikSYu0nv/flMc475MhGbB7TSPd/2IpFV4I4rMklboCH2rQjYY5kKiYGHqUF9gvaambupcJFFG9dvReg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"ansi-escapes": "^4.2.1",
|
||||
"chalk": "^2.4.2",
|
||||
"chalk": "^3.0.0",
|
||||
"cli-cursor": "^3.1.0",
|
||||
"cli-width": "^2.0.0",
|
||||
"external-editor": "^3.0.3",
|
||||
"figures": "^3.0.0",
|
||||
"lodash": "^4.17.15",
|
||||
"mute-stream": "0.0.8",
|
||||
"run-async": "^2.2.0",
|
||||
"rxjs": "^6.4.0",
|
||||
"run-async": "^2.4.0",
|
||||
"rxjs": "^6.5.3",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^5.1.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"through": "^2.3.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"ansi-escapes": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.2.1.tgz",
|
||||
"integrity": "sha512-Cg3ymMAdN10wOk/VYfLV7KCQyv7EDirJ64500sU7n9UlmioEtDuU5Gd+hj73hXSU/ex7tHJSssmyftDdkMLO8Q==",
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.1.tgz",
|
||||
"integrity": "sha512-JWF7ocqNrp8u9oqpgV+wH5ftbt+cfvv+PTjOvKLT3AdYly/LmORARfEVT1iyjwN+4MqE5UmVKoAdIBqeoCHgLA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"type-fest": "^0.5.2"
|
||||
"type-fest": "^0.11.0"
|
||||
}
|
||||
},
|
||||
"ansi-regex": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz",
|
||||
"integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==",
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz",
|
||||
"integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==",
|
||||
"dev": true
|
||||
},
|
||||
"ansi-styles": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz",
|
||||
"integrity": "sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/color-name": "^1.1.1",
|
||||
"color-convert": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"chalk": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz",
|
||||
"integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
}
|
||||
},
|
||||
"cli-cursor": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz",
|
||||
@@ -8867,15 +9022,30 @@
|
||||
"restore-cursor": "^3.1.0"
|
||||
}
|
||||
},
|
||||
"color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"color-name": "~1.1.4"
|
||||
}
|
||||
},
|
||||
"figures": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/figures/-/figures-3.0.0.tgz",
|
||||
"integrity": "sha512-HKri+WoWoUgr83pehn/SIgLOMZ9nAWC6dcGj26RY2R4F50u4+RTUz0RCrUlOV3nKRAICW1UGzyb+kcX2qK1S/g==",
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz",
|
||||
"integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"escape-string-regexp": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||
"dev": true
|
||||
},
|
||||
"is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
@@ -8919,39 +9089,54 @@
|
||||
"signal-exit": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"run-async": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz",
|
||||
"integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==",
|
||||
"dev": true
|
||||
},
|
||||
"rxjs": {
|
||||
"version": "6.5.3",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.5.3.tgz",
|
||||
"integrity": "sha512-wuYsAYYFdWTAnAaPoKGNhfpWwKZbJW+HgAJ+mImp+Epl7BG8oNWBCTyRM8gba9k4lk8BgWdoYm21Mo/RYhhbgA==",
|
||||
"version": "6.5.5",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.5.5.tgz",
|
||||
"integrity": "sha512-WfQI+1gohdf0Dai/Bbmk5L5ItH5tYqm3ki2c5GdWhKjalzjg93N3avFjVStyZZz+A2Em+ZxKH5bNghw9UeylGQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"tslib": "^1.9.0"
|
||||
}
|
||||
},
|
||||
"string-width": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.1.0.tgz",
|
||||
"integrity": "sha512-NrX+1dVVh+6Y9dnQ19pR0pP4FiEIlUvdTGn8pw6CKTNq5sgib2nIhmUNT5TAmhWmvKr3WcxBcP3E8nWezuipuQ==",
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz",
|
||||
"integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^5.2.0"
|
||||
"strip-ansi": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"strip-ansi": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz",
|
||||
"integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==",
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz",
|
||||
"integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"ansi-regex": "^4.1.0"
|
||||
"ansi-regex": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"supports-color": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz",
|
||||
"integrity": "sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"has-flag": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"type-fest": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.5.2.tgz",
|
||||
"integrity": "sha512-DWkS49EQKVX//Tbupb9TFa19c7+MK1XmzkrZUR8TAktmE/DizXoaoJV6TZ/tSIPXipqNiRI6CyAe7x69Jb6RSw==",
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.11.0.tgz",
|
||||
"integrity": "sha512-OdjXJxnCN1AvyLSzeKIgXTXxV+99ZuXl3Hpo9XpJAv9MBcHrrJOQ5kV7ypXOuQie+AmWG25hLbiKdwYTifzcfQ==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
@@ -10566,6 +10751,12 @@
|
||||
"integrity": "sha1-yQRGkMIeBClL6qUXcS/e0fqI3pg=",
|
||||
"dev": true
|
||||
},
|
||||
"lodash.flatmap": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.flatmap/-/lodash.flatmap-4.5.0.tgz",
|
||||
"integrity": "sha1-74y/QI9uSCaGYzRTBcaswLd4cC4=",
|
||||
"dev": true
|
||||
},
|
||||
"lodash.flattendeep": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz",
|
||||
@@ -10652,16 +10843,16 @@
|
||||
}
|
||||
},
|
||||
"log4js": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/log4js/-/log4js-5.1.0.tgz",
|
||||
"integrity": "sha512-QtXrBGZiIwfwBrH9zF2uQarvBuJ5+Icqx9fW+nQL4pnmPITJw8n6kh3bck5IkcTDBQatDeKqUMXXX41fp0TIqw==",
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/log4js/-/log4js-6.2.1.tgz",
|
||||
"integrity": "sha512-7n+Oqxxz7VcQJhIlqhcYZBTpbcQ7XsR0MUIfJkx/n3VUjkAS4iUr+4UJlhxf28RvP9PMGQXbgTUhLApnu0XXgA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"date-format": "^2.1.0",
|
||||
"date-format": "^3.0.0",
|
||||
"debug": "^4.1.1",
|
||||
"flatted": "^2.0.1",
|
||||
"rfdc": "^1.1.4",
|
||||
"streamroller": "^2.1.0"
|
||||
"streamroller": "^2.2.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"debug": {
|
||||
@@ -11264,25 +11455,24 @@
|
||||
"dev": true
|
||||
},
|
||||
"mutation-testing-elements": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/mutation-testing-elements/-/mutation-testing-elements-1.1.1.tgz",
|
||||
"integrity": "sha512-fnBf8pz1gW0MbnxSYPshJcghjzNz/h7WLxiF+Ux4wkBhwSs+YYl+IVaAdmITYylHvLzLkJOR9fuNcPNv5VgCKw==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/mutation-testing-elements/-/mutation-testing-elements-1.3.1.tgz",
|
||||
"integrity": "sha512-XXP/enxyOd8X6lK/lu4nlPGLmwH3wfMwj9eatxLp4er0zrmv0p5gGZVkj4KnuuGfp7rnlVNBI/5EZShPJgK3HA==",
|
||||
"dev": true
|
||||
},
|
||||
"mutation-testing-metrics": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/mutation-testing-metrics/-/mutation-testing-metrics-1.1.1.tgz",
|
||||
"integrity": "sha512-yO50rOZnTN3yZ3l0iOQ4JC9rFnmC74o50qxPb4FV+dT7werWgh8NILMYADJ6tbGAlRnDnBVpnaHE8AAZzJoVoA==",
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/mutation-testing-metrics/-/mutation-testing-metrics-1.3.0.tgz",
|
||||
"integrity": "sha512-T7UkUGljyCLMEWGK6YtRTjt4fxqi5+052gjDBkKBR6T5Po6DbwwIx6DAvFyBYzjBzUx6wUhXt7UaxB/wy+JyEg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"lodash.groupby": "^4.6.0",
|
||||
"mutation-testing-report-schema": "^1.1.1"
|
||||
"mutation-testing-report-schema": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"mutation-testing-report-schema": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/mutation-testing-report-schema/-/mutation-testing-report-schema-1.1.1.tgz",
|
||||
"integrity": "sha512-rinrA3i16hHQAE2L0FRinKrIIq1X52LijmeW/EjO3o3gkV5LrG3njGbE6Yezj4/edGBiriJXpLz82hkMG7aKNg==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/mutation-testing-report-schema/-/mutation-testing-report-schema-1.3.1.tgz",
|
||||
"integrity": "sha512-2T2A5qBg+2SZ7CtAvW5m4W95VJxZ/UsSWVwzv3VZpm7c2VoGgIWZGPiTC76a+gorxJobyCzkWv0902UNs4Wl5Q==",
|
||||
"dev": true
|
||||
},
|
||||
"mute-stream": {
|
||||
@@ -11715,6 +11905,11 @@
|
||||
"kind-of": "^3.0.3"
|
||||
}
|
||||
},
|
||||
"object-foreach": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/object-foreach/-/object-foreach-0.1.2.tgz",
|
||||
"integrity": "sha1-10IcW0DjtqPvV6xiQ2jSHY+NLew="
|
||||
},
|
||||
"object-hash": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-1.3.1.tgz",
|
||||
@@ -11739,6 +11934,15 @@
|
||||
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
|
||||
"dev": true
|
||||
},
|
||||
"object-merge": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/object-merge/-/object-merge-2.5.1.tgz",
|
||||
"integrity": "sha1-B36JFc446nKUeIRIxd0znjTfQic=",
|
||||
"requires": {
|
||||
"clone-function": ">=1.0.1",
|
||||
"object-foreach": ">=0.1.2"
|
||||
}
|
||||
},
|
||||
"object-visit": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/object-visit/-/object-visit-1.0.1.tgz",
|
||||
@@ -14516,6 +14720,14 @@
|
||||
"to-camel-case": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"redux-localstorage-simple": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/redux-localstorage-simple/-/redux-localstorage-simple-2.2.0.tgz",
|
||||
"integrity": "sha512-BmgnJ3NkxTDvNsnHAZrRVDgODafg2Vtb17q2F2LEhuJ+EderZBJA6aqRsyqZC32BJWpu8PPtferv4Io9dpUf3w==",
|
||||
"requires": {
|
||||
"object-merge": "2.5.1"
|
||||
}
|
||||
},
|
||||
"redux-thunk": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.3.0.tgz",
|
||||
@@ -16106,9 +16318,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"streamroller": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/streamroller/-/streamroller-2.2.0.tgz",
|
||||
"integrity": "sha512-0CTjQK2fHo/qAUMoCd/J5BvxqoPyAQFaMorv2Z4cxjLYPYRn0Q9PG8Z7LHvyK2mDHSpeS9R7AvMr0vSI/Mq0HA==",
|
||||
"version": "2.2.4",
|
||||
"resolved": "https://registry.npmjs.org/streamroller/-/streamroller-2.2.4.tgz",
|
||||
"integrity": "sha512-OG79qm3AujAM9ImoqgWEY1xG4HX+Lw+yY6qZj9R1K2mhF5bEmQ849wvrb+4vt4jLMLzwXttJlQbOdPOQVRv7DQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"date-format": "^2.1.0",
|
||||
@@ -16116,6 +16328,12 @@
|
||||
"fs-extra": "^8.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"date-format": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/date-format/-/date-format-2.1.0.tgz",
|
||||
"integrity": "sha512-bYQuGLeFxhkxNOF3rcMtiZxvCBAquGzZm6oWA1oZ0g2THUzivaRhv8uOhdr19LmoobSOLoIAxeUK2RdbM8IFTA==",
|
||||
"dev": true
|
||||
},
|
||||
"debug": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
|
||||
@@ -17011,9 +17229,9 @@
|
||||
}
|
||||
},
|
||||
"surrial": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/surrial/-/surrial-1.0.0.tgz",
|
||||
"integrity": "sha512-dkvhz3QvgraMeFWI9V+BinpNCNoaSNxKcxb0umRpkWeFlZ0WSbIfeTm9YtLA6a4kv/Q2pOMQOtMlcv/b5h6qpg==",
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/surrial/-/surrial-2.0.2.tgz",
|
||||
"integrity": "sha512-YQ0XyrdBI8Kx10lIK81zOGXdGtc0P+3FTqEtCdaKzfEJKJWDju2QPp+XhzihmN2KOTRDtkKSyQQXZHYP+SqapA==",
|
||||
"dev": true
|
||||
},
|
||||
"svg-parser": {
|
||||
@@ -17541,9 +17759,9 @@
|
||||
}
|
||||
},
|
||||
"tree-kill": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.1.tgz",
|
||||
"integrity": "sha512-4hjqbObwlh2dLyW4tcz0Ymw0ggoaVDMveUB9w8kFSQScdRLo0gxO9J7WFcUBo+W3C1TLdFIEwNOWebgZZ0RH9Q==",
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
|
||||
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
|
||||
"dev": true
|
||||
},
|
||||
"trim": {
|
||||
@@ -17558,12 +17776,6 @@
|
||||
"integrity": "sha1-WIeWa7WCpFA6QetST301ARgVphM=",
|
||||
"dev": true
|
||||
},
|
||||
"trim-right": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/trim-right/-/trim-right-1.0.1.tgz",
|
||||
"integrity": "sha1-yy4SAwZ+DI3h9hQJS5/kVwTqYAM=",
|
||||
"dev": true
|
||||
},
|
||||
"trim-trailing-lines": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/trim-trailing-lines/-/trim-trailing-lines-1.1.2.tgz",
|
||||
@@ -17610,9 +17822,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"tunnel": {
|
||||
"version": "0.0.4",
|
||||
"resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.4.tgz",
|
||||
"integrity": "sha1-LTeFoVjBdMmhbcLARuxfxfF0IhM=",
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz",
|
||||
"integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==",
|
||||
"dev": true
|
||||
},
|
||||
"tunnel-agent": {
|
||||
@@ -17656,19 +17868,28 @@
|
||||
}
|
||||
},
|
||||
"typed-inject": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/typed-inject/-/typed-inject-2.0.0.tgz",
|
||||
"integrity": "sha512-x4gVaWZzGBZMR15rTMWdijKlMg/rBc7YkjTx9koW7Lp+9UYr+GQZLRMtpQm7MJv4J6r1AVhclwouZ0z+G6tYAw==",
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/typed-inject/-/typed-inject-2.2.1.tgz",
|
||||
"integrity": "sha512-+PFtxIKTfrfuqba42XYmTotRCoPC8U7/cIQSu9bHhRlHLDazrbagEDzJ8mjnVVUzgrAlseFx0qKwvf6ua5Yzmg==",
|
||||
"dev": true
|
||||
},
|
||||
"typed-rest-client": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-1.5.0.tgz",
|
||||
"integrity": "sha512-DVZRlmsfnTjp6ZJaatcdyvvwYwbWvR4YDNFDqb+qdTxpvaVP99YCpBkA8rxsLtAPjBVoDe4fNsnMIdZTiPuKWg==",
|
||||
"version": "1.7.3",
|
||||
"resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-1.7.3.tgz",
|
||||
"integrity": "sha512-CwTpx/TkRHGZoHkJhBcp4X8K3/WtlzSHVQR0OIFnt10j4tgy4ypgq/SrrgVpA1s6tAL49Q6J3R5C0Cgfh2ddqA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"tunnel": "0.0.4",
|
||||
"qs": "^6.9.1",
|
||||
"tunnel": "0.0.6",
|
||||
"underscore": "1.8.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"qs": {
|
||||
"version": "6.9.4",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.9.4.tgz",
|
||||
"integrity": "sha512-A1kFqHekCTM7cz0udomYUoYNWjBebHm/5wzU/XqrBRBNWectVH0QIiN+NEcZ0Dte5hvzHwbr8+XQmguPhJ6WdQ==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"typed-styles": {
|
||||
|
||||
13
package.json
13
package.json
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"name": "shlink-web-client",
|
||||
"description": "A React-based progressive web application for shlink",
|
||||
"version": "2.3.0",
|
||||
"private": false,
|
||||
"homepage": "",
|
||||
"repository": "https://github.com/shlinkio/shlink-web-client",
|
||||
@@ -19,7 +18,8 @@
|
||||
"test:ci": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=clover",
|
||||
"test:pretty": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=html",
|
||||
"mutate": "./node_modules/.bin/stryker run",
|
||||
"mutate:ci": "npm run mutate -- --mutate=$MUTATION_FILES"
|
||||
"mutate:ci": "npm run mutate -- --mutate=$MUTATION_FILES",
|
||||
"check": "npm run test & npm run lint & wait"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^5.11.2",
|
||||
@@ -38,6 +38,7 @@
|
||||
"classnames": "^2.2.6",
|
||||
"compare-versions": "^3.5.1",
|
||||
"csvjson": "^5.1.0",
|
||||
"event-source-polyfill": "^1.0.12",
|
||||
"leaflet": "^1.5.1",
|
||||
"moment": "^2.24.0",
|
||||
"promise": "^8.0.3",
|
||||
@@ -61,15 +62,15 @@
|
||||
"reactstrap": "^8.0.1",
|
||||
"redux": "^4.0.4",
|
||||
"redux-actions": "^2.6.5",
|
||||
"redux-localstorage-simple": "^2.2.0",
|
||||
"redux-thunk": "^2.3.0",
|
||||
"uuid": "^3.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.6.2",
|
||||
"@stryker-mutator/core": "^2.1.0",
|
||||
"@stryker-mutator/html-reporter": "^2.1.0",
|
||||
"@stryker-mutator/javascript-mutator": "^2.1.0",
|
||||
"@stryker-mutator/jest-runner": "^2.1.0",
|
||||
"@stryker-mutator/core": "^3.2.4",
|
||||
"@stryker-mutator/javascript-mutator": "^3.2.4",
|
||||
"@stryker-mutator/jest-runner": "^3.2.4",
|
||||
"@svgr/webpack": "^4.3.3",
|
||||
"adm-zip": "^0.4.13",
|
||||
"autoprefixer": "^9.6.3",
|
||||
|
||||
@@ -1,13 +1,33 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
#PLATFORMS="linux/arm/v7,linux/arm64/v8,linux/amd64"
|
||||
PLATFORMS="linux/amd64"
|
||||
DOCKER_IMAGE="shlinkio/shlink-web-client"
|
||||
BUILDX_VER=v0.4.1
|
||||
export DOCKER_CLI_EXPERIMENTAL=enabled
|
||||
|
||||
mkdir -vp ~/.docker/cli-plugins/ ~/dockercache
|
||||
curl --silent -L "https://github.com/docker/buildx/releases/download/${BUILDX_VER}/buildx-${BUILDX_VER}.linux-amd64" > ~/.docker/cli-plugins/docker-buildx
|
||||
chmod a+x ~/.docker/cli-plugins/docker-buildx
|
||||
|
||||
docker buildx create --use
|
||||
|
||||
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
|
||||
docker buildx build --push \
|
||||
--platform ${PLATFORMS} \
|
||||
-t ${DOCKER_IMAGE}: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
|
||||
TAGS="-t ${DOCKER_IMAGE}:${TRAVIS_TAG#?}"
|
||||
# Push stable tag only if this is not an alpha or beta release
|
||||
[[ $TRAVIS_TAG != *"alpha"* && $TRAVIS_TAG != *"beta"* ]] && TAGS="${TAGS} -t ${DOCKER_IMAGE}:stable"
|
||||
|
||||
docker buildx build --push \
|
||||
--build-arg SHLINK_VERSION=${TRAVIS_TAG#?} \
|
||||
--platform ${PLATFORMS} \
|
||||
${TAGS} .
|
||||
fi
|
||||
|
||||
12
scripts/docker/install-docker
Executable file
12
scripts/docker/install-docker
Executable file
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -ex
|
||||
|
||||
# install latest docker version
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
|
||||
add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"
|
||||
apt-get update
|
||||
apt-get -y -o Dpkg::Options::="--force-confnew" install docker-ce
|
||||
|
||||
# enable multiarch execution
|
||||
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
|
||||
48
src/App.js
48
src/App.js
@@ -1,22 +1,40 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import './App.scss';
|
||||
import NotFound from './common/NotFound';
|
||||
import './App.scss';
|
||||
|
||||
const App = (MainHeader, Home, MenuLayout, CreateServer, EditServer) => () => (
|
||||
<div className="container-fluid app-container">
|
||||
<MainHeader />
|
||||
const propTypes = {
|
||||
fetchServers: PropTypes.func,
|
||||
servers: PropTypes.object,
|
||||
};
|
||||
|
||||
<div className="app">
|
||||
<Switch>
|
||||
<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>
|
||||
const App = (MainHeader, Home, MenuLayout, CreateServer, EditServer, Settings) => ({ fetchServers, servers }) => {
|
||||
// On first load, try to fetch the remote servers if the list is empty
|
||||
useEffect(() => {
|
||||
if (Object.keys(servers).length === 0) {
|
||||
fetchServers();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="container-fluid app-container">
|
||||
<MainHeader />
|
||||
|
||||
<div className="app">
|
||||
<Switch>
|
||||
<Route exact path="/" component={Home} />
|
||||
<Route exact path="/settings" component={Settings} />
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
App.propTypes = propTypes;
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -10,9 +10,9 @@ const propTypes = {
|
||||
servers: PropTypes.object,
|
||||
};
|
||||
|
||||
const Home = ({ resetSelectedServer, servers: { list, loading } }) => {
|
||||
const servers = values(list);
|
||||
const hasServers = !isEmpty(servers);
|
||||
const Home = ({ resetSelectedServer, servers }) => {
|
||||
const serversList = values(servers);
|
||||
const hasServers = !isEmpty(serversList);
|
||||
|
||||
useEffect(() => {
|
||||
resetSelectedServer();
|
||||
@@ -21,10 +21,9 @@ const Home = ({ resetSelectedServer, servers: { list, loading } }) => {
|
||||
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 servers={serversList}>
|
||||
{hasServers && <span>Please, select a server.</span>}
|
||||
{!hasServers && <span>Please, <Link to="/server/create">add a server</Link>.</span>}
|
||||
</ServersListGroup>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,37 +1,28 @@
|
||||
import { faPlus as plusIcon, faChevronDown as arrowIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faPlus as plusIcon, faChevronDown as arrowIcon, faCogs as cogsIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Collapse, Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } from 'reactstrap';
|
||||
import classnames from 'classnames';
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useToggle } from '../utils/helpers/hooks';
|
||||
import shlinkLogo from './shlink-logo-white.png';
|
||||
import './MainHeader.scss';
|
||||
|
||||
const MainHeader = (ServersDropdown) => class MainHeader extends React.Component {
|
||||
static propTypes = {
|
||||
location: PropTypes.object,
|
||||
};
|
||||
const propTypes = {
|
||||
location: PropTypes.object,
|
||||
};
|
||||
|
||||
state = { isOpen: false };
|
||||
handleToggle = () => {
|
||||
this.setState(({ isOpen }) => ({
|
||||
isOpen: !isOpen,
|
||||
}));
|
||||
};
|
||||
const MainHeader = (ServersDropdown) => {
|
||||
const MainHeaderComp = ({ location }) => {
|
||||
const [ isOpen, toggleOpen, , close ] = useToggle();
|
||||
const { pathname } = location;
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.location !== prevProps.location) {
|
||||
this.setState({ isOpen: false });
|
||||
}
|
||||
}
|
||||
useEffect(close, [ location ]);
|
||||
|
||||
render() {
|
||||
const { location } = this.props;
|
||||
const createServerPath = '/server/create';
|
||||
const toggleClass = classnames('main-header__toggle-icon', {
|
||||
'main-header__toggle-icon--opened': this.state.isOpen,
|
||||
});
|
||||
const settingsPath = '/settings';
|
||||
const toggleClass = classNames('main-header__toggle-icon', { 'main-header__toggle-icon--opened': isOpen });
|
||||
|
||||
return (
|
||||
<Navbar color="primary" dark fixed="top" className="main-header" expand="md">
|
||||
@@ -39,18 +30,19 @@ const MainHeader = (ServersDropdown) => class MainHeader extends React.Component
|
||||
<img src={shlinkLogo} alt="Shlink" className="main-header__brand-logo" /> Shlink
|
||||
</NavbarBrand>
|
||||
|
||||
<NavbarToggler onClick={this.handleToggle}>
|
||||
<NavbarToggler onClick={toggleOpen}>
|
||||
<FontAwesomeIcon icon={arrowIcon} className={toggleClass} />
|
||||
</NavbarToggler>
|
||||
|
||||
<Collapse navbar isOpen={this.state.isOpen}>
|
||||
<Collapse navbar isOpen={isOpen}>
|
||||
<Nav navbar className="ml-auto">
|
||||
<NavItem>
|
||||
<NavLink
|
||||
tag={Link}
|
||||
to={createServerPath}
|
||||
active={location.pathname === createServerPath}
|
||||
>
|
||||
<NavLink tag={Link} to={settingsPath} active={pathname === settingsPath}>
|
||||
<FontAwesomeIcon icon={cogsIcon} /> Settings
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
<NavItem>
|
||||
<NavLink tag={Link} to={createServerPath} active={pathname === createServerPath}>
|
||||
<FontAwesomeIcon icon={plusIcon} /> Add server
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
@@ -59,7 +51,11 @@ const MainHeader = (ServersDropdown) => class MainHeader extends React.Component
|
||||
</Collapse>
|
||||
</Navbar>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
MainHeaderComp.propTypes = propTypes;
|
||||
|
||||
return MainHeaderComp;
|
||||
};
|
||||
|
||||
export default MainHeader;
|
||||
|
||||
@@ -8,6 +8,7 @@ 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 { versionMatch } from '../utils/helpers/version';
|
||||
import NotFound from './NotFound';
|
||||
import './MenuLayout.scss';
|
||||
|
||||
@@ -17,7 +18,16 @@ const propTypes = {
|
||||
selectedServer: serverType,
|
||||
};
|
||||
|
||||
const MenuLayout = (TagsList, ShortUrls, AsideMenu, CreateShortUrl, ShortUrlVisits, ShlinkVersions, ServerError) => {
|
||||
const MenuLayout = (
|
||||
TagsList,
|
||||
ShortUrls,
|
||||
AsideMenu,
|
||||
CreateShortUrl,
|
||||
ShortUrlVisits,
|
||||
TagVisits,
|
||||
ShlinkVersions,
|
||||
ServerError
|
||||
) => {
|
||||
const MenuLayoutComp = ({ match, location, selectedServer }) => {
|
||||
const [ sidebarVisible, toggleSidebar, showSidebar, hideSidebar ] = useToggle();
|
||||
const { params: { serverId } } = match;
|
||||
@@ -28,11 +38,16 @@ const MenuLayout = (TagsList, ShortUrls, AsideMenu, CreateShortUrl, ShortUrlVisi
|
||||
return <ServerError type="not-reachable" />;
|
||||
}
|
||||
|
||||
const addTagsVisitsRoute = versionMatch(selectedServer.version, { minVersion: '2.2.0' });
|
||||
const burgerClasses = classNames('menu-layout__burger-icon', {
|
||||
'menu-layout__burger-icon--active': sidebarVisible,
|
||||
});
|
||||
const swipeMenuIfNoModalExists = (callback) => () => {
|
||||
if (document.querySelector('.modal')) {
|
||||
const swipeMenuIfNoModalExists = (callback) => (e) => {
|
||||
const swippedOnVisitsTable = e.event.path.some(
|
||||
({ classList }) => classList && classList.contains('visits-table')
|
||||
);
|
||||
|
||||
if (swippedOnVisitsTable || document.querySelector('.modal')) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -57,6 +72,7 @@ const MenuLayout = (TagsList, ShortUrls, AsideMenu, CreateShortUrl, ShortUrlVisi
|
||||
<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} />
|
||||
{addTagsVisitsRoute && <Route exact path="/server/:serverId/tag/:tag/visits" component={TagVisits} />}
|
||||
<Route exact path="/server/:serverId/manage-tags" component={TagsList} />
|
||||
<Route
|
||||
render={() => <NotFound to={`/server/${serverId}/list-short-urls/1`}>List short URLs</NotFound>}
|
||||
|
||||
13
src/common/NoMenuLayout.js
Normal file
13
src/common/NoMenuLayout.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './NoMenuLayout.scss';
|
||||
|
||||
const propTypes = {
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
const NoMenuLayout = ({ children }) => <div className="no-menu-wrapper">{children}</div>;
|
||||
|
||||
NoMenuLayout.propTypes = propTypes;
|
||||
|
||||
export default NoMenuLayout;
|
||||
3
src/common/NoMenuLayout.scss
Normal file
3
src/common/NoMenuLayout.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
.no-menu-wrapper {
|
||||
padding: 40px 20px;
|
||||
}
|
||||
@@ -1,23 +1,23 @@
|
||||
import React from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const ScrollToTop = ({ scrollTo }) => class ScrollToTop extends React.Component {
|
||||
static propTypes = {
|
||||
location: PropTypes.object,
|
||||
children: PropTypes.node,
|
||||
const propTypes = {
|
||||
location: PropTypes.object,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
const ScrollToTop = () => {
|
||||
const ScrollToTopComp = ({ location, children }) => {
|
||||
useEffect(() => {
|
||||
scrollTo(0, 0);
|
||||
}, [ location ]);
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
componentDidUpdate({ location: prevLocation }) {
|
||||
const { location } = this.props;
|
||||
ScrollToTopComp.propTypes = propTypes;
|
||||
|
||||
if (location !== prevLocation) {
|
||||
scrollTo(0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return this.props.children;
|
||||
}
|
||||
return ScrollToTopComp;
|
||||
};
|
||||
|
||||
export default ScrollToTop;
|
||||
|
||||
@@ -27,6 +27,7 @@ const provideServices = (bottle, connect, withRouter) => {
|
||||
'AsideMenu',
|
||||
'CreateShortUrl',
|
||||
'ShortUrlVisits',
|
||||
'TagVisits',
|
||||
'ShlinkVersions',
|
||||
'ServerError'
|
||||
);
|
||||
|
||||
@@ -9,6 +9,8 @@ import provideServersServices from '../servers/services/provideServices';
|
||||
import provideVisitsServices from '../visits/services/provideServices';
|
||||
import provideTagsServices from '../tags/services/provideServices';
|
||||
import provideUtilsServices from '../utils/services/provideServices';
|
||||
import provideMercureServices from '../mercure/services/provideServices';
|
||||
import provideSettingsServices from '../settings/services/provideServices';
|
||||
|
||||
const bottle = new Bottle();
|
||||
const { container } = bottle;
|
||||
@@ -26,7 +28,8 @@ const connect = (propsFromState, actionServiceNames = []) =>
|
||||
actionServiceNames.reduce(mapActionService, {})
|
||||
);
|
||||
|
||||
bottle.serviceFactory('App', App, 'MainHeader', 'Home', 'MenuLayout', 'CreateServer', 'EditServer');
|
||||
bottle.serviceFactory('App', App, 'MainHeader', 'Home', 'MenuLayout', 'CreateServer', 'EditServer', 'Settings');
|
||||
bottle.decorator('App', connect([ 'servers' ], [ 'fetchServers' ]));
|
||||
|
||||
provideCommonServices(bottle, connect, withRouter);
|
||||
provideShortUrlsServices(bottle, connect);
|
||||
@@ -34,5 +37,7 @@ provideServersServices(bottle, connect, withRouter);
|
||||
provideTagsServices(bottle, connect);
|
||||
provideVisitsServices(bottle, connect);
|
||||
provideUtilsServices(bottle);
|
||||
provideMercureServices(bottle);
|
||||
provideSettingsServices(bottle, connect);
|
||||
|
||||
export default container;
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import ReduxThunk from 'redux-thunk';
|
||||
import { applyMiddleware, compose, createStore } from 'redux';
|
||||
import { save, load } from 'redux-localstorage-simple';
|
||||
import reducers from '../reducers';
|
||||
|
||||
const composeEnhancers = process.env.NODE_ENV !== 'production' && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
|
||||
? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
|
||||
: compose;
|
||||
|
||||
const store = createStore(reducers, composeEnhancers(
|
||||
applyMiddleware(ReduxThunk)
|
||||
const localStorageConfig = {
|
||||
states: [ 'settings', 'servers' ],
|
||||
namespace: 'shlink',
|
||||
namespaceSeparator: '.',
|
||||
debounce: 300,
|
||||
};
|
||||
|
||||
const store = createStore(reducers, load(localStorageConfig), composeEnhancers(
|
||||
applyMiddleware(save(localStorageConfig), ReduxThunk)
|
||||
));
|
||||
|
||||
export default store;
|
||||
|
||||
@@ -44,11 +44,13 @@ body,
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.paddingless {
|
||||
padding: 0;
|
||||
.indivisible {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.indivisible {
|
||||
.text-ellipsis {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -59,3 +61,7 @@ body,
|
||||
background-color: darken($mainColor, 12%);
|
||||
}
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
background-color: $mainColor;
|
||||
}
|
||||
|
||||
24
src/mercure/helpers/index.js
Normal file
24
src/mercure/helpers/index.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import { EventSourcePolyfill as EventSource } from 'event-source-polyfill';
|
||||
|
||||
export const bindToMercureTopic = (mercureInfo, realTimeUpdates, topic, onMessage, onTokenExpired) => () => {
|
||||
const { enabled } = realTimeUpdates;
|
||||
const { mercureHubUrl, token, loading, error } = mercureInfo;
|
||||
|
||||
if (!enabled || loading || error) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const hubUrl = new URL(mercureHubUrl);
|
||||
|
||||
hubUrl.searchParams.append('topic', topic);
|
||||
const es = new EventSource(hubUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
es.onmessage = ({ data }) => onMessage(JSON.parse(data));
|
||||
es.onerror = ({ status }) => status === 401 && onTokenExpired();
|
||||
|
||||
return () => es.close();
|
||||
};
|
||||
41
src/mercure/reducers/mercureInfo.js
Normal file
41
src/mercure/reducers/mercureInfo.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import { handleActions } from 'redux-actions';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
/* eslint-disable padding-line-between-statements */
|
||||
export const GET_MERCURE_INFO_START = 'shlink/mercure/GET_MERCURE_INFO_START';
|
||||
export const GET_MERCURE_INFO_ERROR = 'shlink/mercure/GET_MERCURE_INFO_ERROR';
|
||||
export const GET_MERCURE_INFO = 'shlink/mercure/GET_MERCURE_INFO';
|
||||
/* eslint-enable padding-line-between-statements */
|
||||
|
||||
export const MercureInfoType = PropTypes.shape({
|
||||
token: PropTypes.string,
|
||||
mercureHubUrl: PropTypes.string,
|
||||
loading: PropTypes.bool,
|
||||
error: PropTypes.bool,
|
||||
});
|
||||
|
||||
const initialState = {
|
||||
token: undefined,
|
||||
mercureHubUrl: undefined,
|
||||
loading: true,
|
||||
error: false,
|
||||
};
|
||||
|
||||
export default handleActions({
|
||||
[GET_MERCURE_INFO_START]: (state) => ({ ...state, loading: true, error: false }),
|
||||
[GET_MERCURE_INFO_ERROR]: (state) => ({ ...state, loading: false, error: true }),
|
||||
[GET_MERCURE_INFO]: (state, { token, mercureHubUrl }) => ({ token, mercureHubUrl, loading: false, error: false }),
|
||||
}, initialState);
|
||||
|
||||
export const loadMercureInfo = (buildShlinkApiClient) => () => async (dispatch, getState) => {
|
||||
dispatch({ type: GET_MERCURE_INFO_START });
|
||||
const { mercureInfo } = buildShlinkApiClient(getState);
|
||||
|
||||
try {
|
||||
const result = await mercureInfo();
|
||||
|
||||
dispatch({ type: GET_MERCURE_INFO, ...result });
|
||||
} catch (e) {
|
||||
dispatch({ type: GET_MERCURE_INFO_ERROR });
|
||||
}
|
||||
};
|
||||
8
src/mercure/services/provideServices.js
Normal file
8
src/mercure/services/provideServices.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import { loadMercureInfo } from '../reducers/mercureInfo';
|
||||
|
||||
const provideServices = (bottle) => {
|
||||
// Actions
|
||||
bottle.serviceFactory('loadMercureInfo', loadMercureInfo, 'buildShlinkApiClient');
|
||||
};
|
||||
|
||||
export default provideServices;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { combineReducers } from 'redux';
|
||||
import serversReducer from '../servers/reducers/server';
|
||||
import serversReducer from '../servers/reducers/servers';
|
||||
import selectedServerReducer from '../servers/reducers/selectedServer';
|
||||
import shortUrlsListReducer from '../short-urls/reducers/shortUrlsList';
|
||||
import shortUrlsListParamsReducer from '../short-urls/reducers/shortUrlsListParams';
|
||||
@@ -9,10 +9,13 @@ 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 tagVisitsReducer from '../visits/reducers/tagVisits';
|
||||
import shortUrlDetailReducer from '../visits/reducers/shortUrlDetail';
|
||||
import tagsListReducer from '../tags/reducers/tagsList';
|
||||
import tagDeleteReducer from '../tags/reducers/tagDelete';
|
||||
import tagEditReducer from '../tags/reducers/tagEdit';
|
||||
import mercureInfoReducer from '../mercure/reducers/mercureInfo';
|
||||
import settingsReducer from '../settings/reducers/settings';
|
||||
|
||||
export default combineReducers({
|
||||
servers: serversReducer,
|
||||
@@ -25,8 +28,11 @@ export default combineReducers({
|
||||
shortUrlMeta: shortUrlMetaReducer,
|
||||
shortUrlEdition: shortUrlEditionReducer,
|
||||
shortUrlVisits: shortUrlVisitsReducer,
|
||||
tagVisits: tagVisitsReducer,
|
||||
shortUrlDetail: shortUrlDetailReducer,
|
||||
tagsList: tagsListReducer,
|
||||
tagDelete: tagDeleteReducer,
|
||||
tagEdit: tagEditReducer,
|
||||
mercureInfo: mercureInfoReducer,
|
||||
settings: settingsReducer,
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import PropTypes from 'prop-types';
|
||||
import './CreateServer.scss';
|
||||
import NoMenuLayout from '../common/NoMenuLayout';
|
||||
import { ServerForm } from './helpers/ServerForm';
|
||||
import './CreateServer.scss';
|
||||
|
||||
const SHOW_IMPORT_MSG_TIME = 4000;
|
||||
const propTypes = {
|
||||
@@ -29,7 +30,7 @@ const CreateServer = (ImportServersBtn, useStateFlagTimeout) => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="create-server">
|
||||
<NoMenuLayout>
|
||||
<ServerForm onSubmit={handleSubmit}>
|
||||
<ImportServersBtn onImport={setServersImported} />
|
||||
<button className="btn btn-outline-primary">Create server</button>
|
||||
@@ -44,7 +45,7 @@ const CreateServer = (ImportServersBtn, useStateFlagTimeout) => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</NoMenuLayout>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
@import '../utils/base';
|
||||
|
||||
.create-server {
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.create-server__label {
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Button } from 'reactstrap';
|
||||
import NoMenuLayout from '../common/NoMenuLayout';
|
||||
import { ServerForm } from './helpers/ServerForm';
|
||||
import { withSelectedServer } from './helpers/withSelectedServer';
|
||||
import { serverType } from './prop-types';
|
||||
@@ -9,22 +11,24 @@ const propTypes = {
|
||||
selectedServer: serverType,
|
||||
history: PropTypes.shape({
|
||||
push: PropTypes.func,
|
||||
goBack: PropTypes.func,
|
||||
}),
|
||||
};
|
||||
|
||||
export const EditServer = (ServerError) => {
|
||||
const EditServerComp = ({ editServer, selectedServer, history: { push } }) => {
|
||||
const EditServerComp = ({ editServer, selectedServer, history: { push, goBack } }) => {
|
||||
const handleSubmit = (serverData) => {
|
||||
editServer(selectedServer.id, serverData);
|
||||
push(`/server/${selectedServer.id}/list-short-urls/1`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="create-server">
|
||||
<NoMenuLayout>
|
||||
<ServerForm initialValues={selectedServer} onSubmit={handleSubmit}>
|
||||
<button className="btn btn-outline-primary">Save</button>
|
||||
<Button outline className="mr-2" onClick={goBack}>Cancel</Button>
|
||||
<Button outline color="primary">Save</Button>
|
||||
</ServerForm>
|
||||
</div>
|
||||
</NoMenuLayout>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -2,55 +2,54 @@ import { isEmpty, values } from 'ramda';
|
||||
import React from 'react';
|
||||
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { serverType } from './prop-types';
|
||||
|
||||
const ServersDropdown = (serversExporter) => class ServersDropdown extends React.Component {
|
||||
static propTypes = {
|
||||
servers: PropTypes.object,
|
||||
selectedServer: serverType,
|
||||
listServers: PropTypes.func,
|
||||
history: PropTypes.shape({
|
||||
push: PropTypes.func,
|
||||
}),
|
||||
};
|
||||
const propTypes = {
|
||||
servers: PropTypes.object,
|
||||
selectedServer: serverType,
|
||||
};
|
||||
|
||||
renderServers = () => {
|
||||
const { servers: { list, loading }, selectedServer } = this.props;
|
||||
const servers = values(list);
|
||||
const { push } = this.props.history;
|
||||
const loadServer = (id) => push(`/server/${id}/list-short-urls/1`);
|
||||
const ServersDropdown = (serversExporter) => {
|
||||
const ServersDropdownComp = ({ servers, selectedServer }) => {
|
||||
const serversList = values(servers);
|
||||
|
||||
if (loading) {
|
||||
return <DropdownItem disabled><i>Trying to load servers...</i></DropdownItem>;
|
||||
}
|
||||
const renderServers = () => {
|
||||
if (isEmpty(serversList)) {
|
||||
return <DropdownItem disabled><i>Add a server first...</i></DropdownItem>;
|
||||
}
|
||||
|
||||
if (isEmpty(servers)) {
|
||||
return <DropdownItem disabled><i>Add a server first...</i></DropdownItem>;
|
||||
}
|
||||
return (
|
||||
<React.Fragment>
|
||||
{serversList.map(({ name, id }) => (
|
||||
<DropdownItem
|
||||
key={id}
|
||||
tag={Link}
|
||||
to={`/server/${id}/list-short-urls/1`}
|
||||
active={selectedServer && selectedServer.id === id}
|
||||
>
|
||||
{name}
|
||||
</DropdownItem>
|
||||
))}
|
||||
<DropdownItem divider />
|
||||
<DropdownItem className="servers-dropdown__export-item" onClick={() => serversExporter.exportServers()}>
|
||||
Export servers
|
||||
</DropdownItem>
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{servers.map(({ name, id }) => (
|
||||
<DropdownItem key={id} active={selectedServer && selectedServer.id === id} onClick={() => loadServer(id)}>
|
||||
{name}
|
||||
</DropdownItem>
|
||||
))}
|
||||
<DropdownItem divider />
|
||||
<DropdownItem className="servers-dropdown__export-item" onClick={() => serversExporter.exportServers()}>
|
||||
Export servers
|
||||
</DropdownItem>
|
||||
</React.Fragment>
|
||||
<UncontrolledDropdown nav inNavbar>
|
||||
<DropdownToggle nav caret>Servers</DropdownToggle>
|
||||
<DropdownMenu right>{renderServers()}</DropdownMenu>
|
||||
</UncontrolledDropdown>
|
||||
);
|
||||
};
|
||||
|
||||
componentDidMount = this.props.listServers;
|
||||
ServersDropdownComp.propTypes = propTypes;
|
||||
|
||||
render = () => (
|
||||
<UncontrolledDropdown nav inNavbar>
|
||||
<DropdownToggle nav caret>Servers</DropdownToggle>
|
||||
<DropdownMenu right>{this.renderServers()}</DropdownMenu>
|
||||
</UncontrolledDropdown>
|
||||
);
|
||||
return ServersDropdownComp;
|
||||
};
|
||||
|
||||
export default ServersDropdown;
|
||||
|
||||
@@ -1,25 +1,17 @@
|
||||
import React from 'react';
|
||||
import React, { useRef } from 'react';
|
||||
import { UncontrolledTooltip } from 'reactstrap';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const ImportServersBtn = (serversImporter) => class ImportServersBtn extends React.Component {
|
||||
static defaultProps = {
|
||||
onImport: () => ({}),
|
||||
};
|
||||
static propTypes = {
|
||||
onImport: PropTypes.func,
|
||||
createServers: PropTypes.func,
|
||||
fileRef: PropTypes.oneOfType([ PropTypes.object, PropTypes.node ]),
|
||||
};
|
||||
const propTypes = {
|
||||
onImport: PropTypes.func,
|
||||
createServers: PropTypes.func,
|
||||
fileRef: PropTypes.oneOfType([ PropTypes.object, PropTypes.node ]),
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.fileRef = props.fileRef || React.createRef();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { importServersFromFile } = serversImporter;
|
||||
const { onImport, createServers } = this.props;
|
||||
// FIXME Replace with typescript: (ServersImporter)
|
||||
const ImportServersBtn = ({ importServersFromFile }) => {
|
||||
const ImportServersBtnComp = ({ createServers, fileRef, onImport = () => {} }) => {
|
||||
const ref = fileRef || useRef();
|
||||
const onChange = ({ target }) =>
|
||||
importServersFromFile(target.files[0])
|
||||
.then(createServers)
|
||||
@@ -35,24 +27,22 @@ const ImportServersBtn = (serversImporter) => class ImportServersBtn extends Rea
|
||||
type="button"
|
||||
className="btn btn-outline-secondary mr-2"
|
||||
id="importBtn"
|
||||
onClick={() => this.fileRef.current.click()}
|
||||
onClick={() => ref.current.click()}
|
||||
>
|
||||
Import from file
|
||||
</button>
|
||||
<UncontrolledTooltip placement="top" target="importBtn">
|
||||
You can create servers by importing a CSV file with columns <b>name</b>, <b>apiKey</b> and <b>url</b>
|
||||
You can create servers by importing a CSV file with columns <b>name</b>, <b>apiKey</b> and <b>url</b>.
|
||||
</UncontrolledTooltip>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
accept="text/csv"
|
||||
className="create-server__csv-select"
|
||||
ref={this.fileRef}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<input type="file" accept="text/csv" className="create-server__csv-select" ref={ref} onChange={onChange} />
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
ImportServersBtnComp.propTypes = propTypes;
|
||||
|
||||
return ImportServersBtnComp;
|
||||
};
|
||||
|
||||
export default ImportServersBtn;
|
||||
|
||||
@@ -13,7 +13,7 @@ const propTypes = {
|
||||
};
|
||||
|
||||
export const ServerError = (DeleteServerButton) => {
|
||||
const ServerErrorComp = ({ type, servers: { list }, selectedServer }) => (
|
||||
const ServerErrorComp = ({ type, servers, selectedServer }) => (
|
||||
<div className="server-error__container flex-column">
|
||||
<div className="row w-100 mb-3 mb-md-5">
|
||||
<Message type="error">
|
||||
@@ -27,7 +27,7 @@ export const ServerError = (DeleteServerButton) => {
|
||||
</Message>
|
||||
</div>
|
||||
|
||||
<ServersListGroup servers={Object.values(list)}>
|
||||
<ServersListGroup servers={Object.values(servers)}>
|
||||
These are the Shlink servers currently configured. Choose one of
|
||||
them or <Link to="/server/create">add a new one</Link>.
|
||||
</ServersListGroup>
|
||||
|
||||
22
src/servers/reducers/remoteServers.js
Normal file
22
src/servers/reducers/remoteServers.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import { pipe, prop } from 'ramda';
|
||||
import { homepage } from '../../../package.json';
|
||||
import { createServers } from './servers';
|
||||
|
||||
const responseToServersList = pipe(
|
||||
prop('data'),
|
||||
(value) => {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error('Value is not an array');
|
||||
}
|
||||
|
||||
return value;
|
||||
},
|
||||
);
|
||||
|
||||
export const fetchServers = ({ get }) => () => async (dispatch) => {
|
||||
const remoteList = await get(`${homepage}/servers.json`)
|
||||
.then(responseToServersList)
|
||||
.catch(() => []);
|
||||
|
||||
dispatch(createServers(remoteList));
|
||||
};
|
||||
@@ -25,10 +25,15 @@ const getServerVersion = memoizeWith(identity, (serverId, health) => health().th
|
||||
|
||||
export const resetSelectedServer = createAction(RESET_SELECTED_SERVER);
|
||||
|
||||
export const selectServer = ({ findServerById }, buildShlinkApiClient) => (serverId) => async (dispatch) => {
|
||||
export const selectServer = (buildShlinkApiClient, loadMercureInfo) => (serverId) => async (
|
||||
dispatch,
|
||||
getState
|
||||
) => {
|
||||
dispatch(resetSelectedServer());
|
||||
dispatch(resetShortUrlParams());
|
||||
const selectedServer = findServerById(serverId);
|
||||
|
||||
const { servers } = getState();
|
||||
const selectedServer = servers[serverId];
|
||||
|
||||
if (!selectedServer) {
|
||||
dispatch({
|
||||
@@ -51,6 +56,7 @@ export const selectServer = ({ findServerById }, buildShlinkApiClient) => (serve
|
||||
printableVersion,
|
||||
},
|
||||
});
|
||||
dispatch(loadMercureInfo());
|
||||
} catch (e) {
|
||||
dispatch({
|
||||
type: SELECT_SERVER,
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { handleActions } from 'redux-actions';
|
||||
import { pipe, isEmpty, assoc, map, prop } from 'ramda';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { homepage } from '../../../package.json';
|
||||
|
||||
/* eslint-disable padding-line-between-statements */
|
||||
export const FETCH_SERVERS_START = 'shlink/servers/FETCH_SERVERS_START';
|
||||
export const FETCH_SERVERS = 'shlink/servers/FETCH_SERVERS';
|
||||
/* eslint-enable padding-line-between-statements */
|
||||
|
||||
const initialState = {
|
||||
list: {},
|
||||
loading: false,
|
||||
};
|
||||
|
||||
const assocId = (server) => assoc('id', server.id || uuid(), server);
|
||||
|
||||
export default handleActions({
|
||||
[FETCH_SERVERS_START]: (state) => ({ ...state, loading: true }),
|
||||
[FETCH_SERVERS]: (state, { list }) => ({ list, loading: false }),
|
||||
}, initialState);
|
||||
|
||||
export const listServers = ({ listServers, createServers }, { get }) => () => async (dispatch) => {
|
||||
dispatch({ type: FETCH_SERVERS_START });
|
||||
const localList = listServers();
|
||||
|
||||
if (!isEmpty(localList)) {
|
||||
dispatch({ type: FETCH_SERVERS, list: localList });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// If local list is empty, try to fetch it remotely (making sure it's an array) and calculate IDs for every server
|
||||
const getDataAsArrayWithIds = pipe(
|
||||
prop('data'),
|
||||
(value) => {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error('Value is not an array');
|
||||
}
|
||||
|
||||
return value;
|
||||
},
|
||||
map(assocId),
|
||||
);
|
||||
const remoteList = await get(`${homepage}/servers.json`)
|
||||
.then(getDataAsArrayWithIds)
|
||||
.catch(() => []);
|
||||
|
||||
createServers(remoteList);
|
||||
dispatch({ type: FETCH_SERVERS, list: remoteList.reduce((map, server) => ({ ...map, [server.id]: server }), {}) });
|
||||
};
|
||||
|
||||
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(
|
||||
map(assocId),
|
||||
createServers,
|
||||
listServersAction
|
||||
);
|
||||
35
src/servers/reducers/servers.js
Normal file
35
src/servers/reducers/servers.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import { handleActions } from 'redux-actions';
|
||||
import { pipe, assoc, map, reduce, dissoc } from 'ramda';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
/* eslint-disable padding-line-between-statements */
|
||||
export const EDIT_SERVER = 'shlink/servers/EDIT_SERVER';
|
||||
export const DELETE_SERVER = 'shlink/servers/DELETE_SERVER';
|
||||
export const CREATE_SERVERS = 'shlink/servers/CREATE_SERVERS';
|
||||
/* eslint-enable padding-line-between-statements */
|
||||
|
||||
const initialState = {};
|
||||
|
||||
const assocId = (server) => assoc('id', server.id || uuid(), server);
|
||||
|
||||
export default handleActions({
|
||||
[CREATE_SERVERS]: (state, { newServers }) => ({ ...state, ...newServers }),
|
||||
[DELETE_SERVER]: (state, { serverId }) => dissoc(serverId, state),
|
||||
[EDIT_SERVER]: (state, { serverId, serverData }) => !state[serverId]
|
||||
? state
|
||||
: assoc(serverId, { ...state[serverId], ...serverData }, state),
|
||||
}, initialState);
|
||||
|
||||
export const createServer = (server) => createServers([ server ]);
|
||||
|
||||
const serversListToMap = reduce((acc, server) => assoc(server.id, server, acc), {});
|
||||
|
||||
export const createServers = pipe(
|
||||
map(assocId),
|
||||
serversListToMap,
|
||||
(newServers) => ({ type: CREATE_SERVERS, newServers })
|
||||
);
|
||||
|
||||
export const editServer = (serverId, serverData) => ({ type: EDIT_SERVER, serverId, serverData });
|
||||
|
||||
export const deleteServer = ({ id }) => ({ type: DELETE_SERVER, serverId: id });
|
||||
@@ -25,14 +25,14 @@ const saveCsv = (window, csv) => {
|
||||
};
|
||||
|
||||
export default class ServersExporter {
|
||||
constructor(serversService, window, csvjson) {
|
||||
this.serversService = serversService;
|
||||
constructor(storage, window, csvjson) {
|
||||
this.storage = storage;
|
||||
this.window = window;
|
||||
this.csvjson = csvjson;
|
||||
}
|
||||
|
||||
exportServers = async () => {
|
||||
const servers = values(this.serversService.listServers()).map(dissoc('id'));
|
||||
const servers = values(this.storage.get('servers') || {}).map(dissoc('id'));
|
||||
|
||||
try {
|
||||
const csv = this.csvjson.toCSV(servers, {
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { assoc, dissoc, reduce } from 'ramda';
|
||||
|
||||
const SERVERS_STORAGE_KEY = 'servers';
|
||||
|
||||
export default class ServersService {
|
||||
constructor(storage) {
|
||||
this.storage = storage;
|
||||
}
|
||||
|
||||
listServers = () => this.storage.get(SERVERS_STORAGE_KEY) || {};
|
||||
|
||||
findServerById = (serverId) => this.listServers()[serverId];
|
||||
|
||||
createServer = (server) => this.createServers([ server ]);
|
||||
|
||||
createServers = (servers) => {
|
||||
const allServers = reduce(
|
||||
(serversObj, server) => assoc(server.id, server, serversObj),
|
||||
this.listServers(),
|
||||
servers
|
||||
);
|
||||
|
||||
this.storage.set(SERVERS_STORAGE_KEY, allServers);
|
||||
};
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,11 @@ import DeleteServerButton from '../DeleteServerButton';
|
||||
import { EditServer } from '../EditServer';
|
||||
import ImportServersBtn from '../helpers/ImportServersBtn';
|
||||
import { resetSelectedServer, selectServer } from '../reducers/selectedServer';
|
||||
import { createServer, createServers, deleteServer, editServer, listServers } from '../reducers/server';
|
||||
import { createServer, createServers, deleteServer, editServer } from '../reducers/servers';
|
||||
import { fetchServers } from '../reducers/remoteServers';
|
||||
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) => {
|
||||
@@ -22,8 +22,7 @@ const provideServices = (bottle, connect, withRouter) => {
|
||||
bottle.decorator('EditServer', connect([ 'selectedServer' ], [ 'editServer', 'selectServer' ]));
|
||||
|
||||
bottle.serviceFactory('ServersDropdown', ServersDropdown, 'ServersExporter');
|
||||
bottle.decorator('ServersDropdown', withRouter);
|
||||
bottle.decorator('ServersDropdown', connect([ 'servers', 'selectedServer' ], [ 'listServers' ]));
|
||||
bottle.decorator('ServersDropdown', connect([ 'servers', 'selectedServer' ]));
|
||||
|
||||
bottle.serviceFactory('DeleteServerModal', () => DeleteServerModal);
|
||||
bottle.decorator('DeleteServerModal', withRouter);
|
||||
@@ -43,16 +42,15 @@ const provideServices = (bottle, connect, withRouter) => {
|
||||
// Services
|
||||
bottle.constant('csvjson', csvjson);
|
||||
bottle.service('ServersImporter', ServersImporter, 'csvjson');
|
||||
bottle.service('ServersService', ServersService, 'Storage');
|
||||
bottle.service('ServersExporter', ServersExporter, 'ServersService', 'window', 'csvjson');
|
||||
bottle.service('ServersExporter', ServersExporter, 'Storage', 'window', 'csvjson');
|
||||
|
||||
// Actions
|
||||
bottle.serviceFactory('selectServer', selectServer, 'ServersService', 'buildShlinkApiClient');
|
||||
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('selectServer', selectServer, 'buildShlinkApiClient', 'loadMercureInfo');
|
||||
bottle.serviceFactory('createServer', () => createServer);
|
||||
bottle.serviceFactory('createServers', () => createServers);
|
||||
bottle.serviceFactory('deleteServer', () => deleteServer);
|
||||
bottle.serviceFactory('editServer', () => editServer);
|
||||
bottle.serviceFactory('fetchServers', fetchServers, 'axios');
|
||||
|
||||
bottle.serviceFactory('resetSelectedServer', () => resetSelectedServer);
|
||||
};
|
||||
|
||||
25
src/settings/RealTimeUpdates.js
Normal file
25
src/settings/RealTimeUpdates.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import { Card, CardBody, CardHeader } from 'reactstrap';
|
||||
import PropTypes from 'prop-types';
|
||||
import Checkbox from '../utils/Checkbox';
|
||||
import { SettingsType } from './reducers/settings';
|
||||
|
||||
const propTypes = {
|
||||
settings: SettingsType,
|
||||
setRealTimeUpdates: PropTypes.func,
|
||||
};
|
||||
|
||||
const RealTimeUpdates = ({ settings: { realTimeUpdates }, setRealTimeUpdates }) => (
|
||||
<Card>
|
||||
<CardHeader>Real-time updates</CardHeader>
|
||||
<CardBody>
|
||||
<Checkbox checked={realTimeUpdates.enabled} onChange={setRealTimeUpdates}>
|
||||
Enable or disable real-time updates, when using Shlink v2.2.0 or newer.
|
||||
</Checkbox>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
|
||||
RealTimeUpdates.propTypes = propTypes;
|
||||
|
||||
export default RealTimeUpdates;
|
||||
10
src/settings/Settings.js
Normal file
10
src/settings/Settings.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import NoMenuLayout from '../common/NoMenuLayout';
|
||||
|
||||
const Settings = (RealTimeUpdates) => () => (
|
||||
<NoMenuLayout>
|
||||
<RealTimeUpdates />
|
||||
</NoMenuLayout>
|
||||
);
|
||||
|
||||
export default Settings;
|
||||
25
src/settings/reducers/settings.js
Normal file
25
src/settings/reducers/settings.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { handleActions } from 'redux-actions';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export const SET_REAL_TIME_UPDATES = 'shlink/realTimeUpdates/SET_REAL_TIME_UPDATES';
|
||||
|
||||
export const SettingsType = PropTypes.shape({
|
||||
realTimeUpdates: PropTypes.shape({
|
||||
enabled: PropTypes.bool.isRequired,
|
||||
}),
|
||||
});
|
||||
|
||||
const initialState = {
|
||||
realTimeUpdates: {
|
||||
enabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default handleActions({
|
||||
[SET_REAL_TIME_UPDATES]: (state, { realTimeUpdates }) => ({ ...state, realTimeUpdates }),
|
||||
}, initialState);
|
||||
|
||||
export const setRealTimeUpdates = (enabled) => ({
|
||||
type: SET_REAL_TIME_UPDATES,
|
||||
realTimeUpdates: { enabled },
|
||||
});
|
||||
16
src/settings/services/provideServices.js
Normal file
16
src/settings/services/provideServices.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import RealTimeUpdates from '../RealTimeUpdates';
|
||||
import Settings from '../Settings';
|
||||
import { setRealTimeUpdates } from '../reducers/settings';
|
||||
|
||||
const provideServices = (bottle, connect) => {
|
||||
// Components
|
||||
bottle.serviceFactory('Settings', Settings, 'RealTimeUpdates');
|
||||
|
||||
bottle.serviceFactory('RealTimeUpdates', () => RealTimeUpdates);
|
||||
bottle.decorator('RealTimeUpdates', connect([ 'settings' ], [ 'setRealTimeUpdates' ]));
|
||||
|
||||
// Actions
|
||||
bottle.serviceFactory('setRealTimeUpdates', () => setRealTimeUpdates);
|
||||
};
|
||||
|
||||
export default provideServices;
|
||||
@@ -1,12 +1,15 @@
|
||||
import { faCaretDown as caretDownIcon, faCaretUp as caretUpIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { head, isEmpty, keys, values } from 'ramda';
|
||||
import React from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import qs from 'qs';
|
||||
import PropTypes from 'prop-types';
|
||||
import { serverType } from '../servers/prop-types';
|
||||
import SortingDropdown from '../utils/SortingDropdown';
|
||||
import { determineOrderDir } from '../utils/utils';
|
||||
import { MercureInfoType } from '../mercure/reducers/mercureInfo';
|
||||
import { bindToMercureTopic } from '../mercure/helpers';
|
||||
import { SettingsType } from '../settings/reducers/settings';
|
||||
import { shortUrlType } from './reducers/shortUrlsList';
|
||||
import { shortUrlsListParamsType } from './reducers/shortUrlsListParams';
|
||||
import './ShortUrlsList.scss';
|
||||
@@ -18,118 +21,117 @@ export const SORTABLE_FIELDS = {
|
||||
visits: 'Visits',
|
||||
};
|
||||
|
||||
const propTypes = {
|
||||
listShortUrls: PropTypes.func,
|
||||
resetShortUrlParams: PropTypes.func,
|
||||
shortUrlsListParams: shortUrlsListParamsType,
|
||||
match: PropTypes.object,
|
||||
location: PropTypes.object,
|
||||
loading: PropTypes.bool,
|
||||
error: PropTypes.bool,
|
||||
shortUrlsList: PropTypes.arrayOf(shortUrlType),
|
||||
selectedServer: serverType,
|
||||
createNewVisit: PropTypes.func,
|
||||
loadMercureInfo: PropTypes.func,
|
||||
mercureInfo: MercureInfoType,
|
||||
settings: SettingsType,
|
||||
};
|
||||
|
||||
// FIXME Replace with typescript: (ShortUrlsRow component)
|
||||
const ShortUrlsList = (ShortUrlsRow) => class ShortUrlsList extends React.Component {
|
||||
static propTypes = {
|
||||
listShortUrls: PropTypes.func,
|
||||
resetShortUrlParams: PropTypes.func,
|
||||
shortUrlsListParams: shortUrlsListParamsType,
|
||||
match: PropTypes.object,
|
||||
location: PropTypes.object,
|
||||
loading: PropTypes.bool,
|
||||
error: PropTypes.bool,
|
||||
shortUrlsList: PropTypes.arrayOf(shortUrlType),
|
||||
selectedServer: serverType,
|
||||
};
|
||||
|
||||
refreshList = (extraParams) => {
|
||||
const { listShortUrls, shortUrlsListParams } = this.props;
|
||||
|
||||
listShortUrls({
|
||||
...shortUrlsListParams,
|
||||
...extraParams,
|
||||
const ShortUrlsList = (ShortUrlsRow) => {
|
||||
const ShortUrlsListComp = ({
|
||||
listShortUrls,
|
||||
resetShortUrlParams,
|
||||
shortUrlsListParams,
|
||||
match,
|
||||
location,
|
||||
loading,
|
||||
error,
|
||||
shortUrlsList,
|
||||
selectedServer,
|
||||
createNewVisit,
|
||||
loadMercureInfo,
|
||||
mercureInfo,
|
||||
settings: { realTimeUpdates },
|
||||
}) => {
|
||||
const { orderBy } = shortUrlsListParams;
|
||||
const [ order, setOrder ] = useState({
|
||||
orderField: orderBy && head(keys(orderBy)),
|
||||
orderDir: orderBy && head(values(orderBy)),
|
||||
});
|
||||
};
|
||||
|
||||
handleOrderBy = (orderField, orderDir) => {
|
||||
this.setState({ orderField, orderDir });
|
||||
this.refreshList({ orderBy: { [orderField]: orderDir } });
|
||||
};
|
||||
|
||||
orderByColumn = (columnName) => () =>
|
||||
this.handleOrderBy(columnName, determineOrderDir(columnName, this.state.orderField, this.state.orderDir));
|
||||
|
||||
renderOrderIcon = (field) => {
|
||||
if (this.state.orderField !== field) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!this.state.orderDir) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FontAwesomeIcon
|
||||
icon={this.state.orderDir === 'ASC' ? caretUpIcon : caretDownIcon}
|
||||
className="short-urls-list__header-icon"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const { orderBy } = props.shortUrlsListParams;
|
||||
|
||||
this.state = {
|
||||
orderField: orderBy ? head(keys(orderBy)) : undefined,
|
||||
orderDir: orderBy ? head(values(orderBy)) : undefined,
|
||||
const refreshList = (extraParams) => listShortUrls({ ...shortUrlsListParams, ...extraParams });
|
||||
const handleOrderBy = (orderField, orderDir) => {
|
||||
setOrder({ orderField, orderDir });
|
||||
refreshList({ orderBy: { [orderField]: orderDir } });
|
||||
};
|
||||
}
|
||||
const orderByColumn = (columnName) => () =>
|
||||
handleOrderBy(columnName, determineOrderDir(columnName, order.orderField, order.orderDir));
|
||||
const renderOrderIcon = (field) => {
|
||||
if (order.orderField !== field) {
|
||||
return null;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { match: { params }, location, shortUrlsListParams } = this.props;
|
||||
const query = qs.parse(location.search, { ignoreQueryPrefix: true });
|
||||
const tags = query.tag ? [ query.tag ] : shortUrlsListParams.tags;
|
||||
if (!order.orderDir) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.refreshList({ page: params.page, tags });
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
const { resetShortUrlParams } = this.props;
|
||||
|
||||
resetShortUrlParams();
|
||||
}
|
||||
|
||||
renderShortUrls() {
|
||||
const { shortUrlsList, selectedServer, loading, error, shortUrlsListParams } = this.props;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan="6" className="text-center table-danger">Something went wrong while loading short URLs :(</td>
|
||||
</tr>
|
||||
<FontAwesomeIcon
|
||||
icon={order.orderDir === 'ASC' ? caretUpIcon : caretDownIcon}
|
||||
className="short-urls-list__header-icon"
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
const renderShortUrls = () => {
|
||||
if (error) {
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan="6" className="text-center table-danger">Something went wrong while loading short URLs :(</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <tr><td colSpan="6" className="text-center">Loading...</td></tr>;
|
||||
}
|
||||
if (loading) {
|
||||
return <tr><td colSpan="6" className="text-center">Loading...</td></tr>;
|
||||
}
|
||||
|
||||
if (!loading && isEmpty(shortUrlsList)) {
|
||||
return <tr><td colSpan="6" className="text-center">No results found</td></tr>;
|
||||
}
|
||||
if (!loading && isEmpty(shortUrlsList)) {
|
||||
return <tr><td colSpan="6" className="text-center">No results found</td></tr>;
|
||||
}
|
||||
|
||||
return shortUrlsList.map((shortUrl) => (
|
||||
<ShortUrlsRow
|
||||
key={shortUrl.shortUrl}
|
||||
shortUrl={shortUrl}
|
||||
selectedServer={selectedServer}
|
||||
refreshList={this.refreshList}
|
||||
shortUrlsListParams={shortUrlsListParams}
|
||||
/>
|
||||
));
|
||||
}
|
||||
return shortUrlsList.map((shortUrl) => (
|
||||
<ShortUrlsRow
|
||||
key={shortUrl.shortUrl}
|
||||
shortUrl={shortUrl}
|
||||
selectedServer={selectedServer}
|
||||
refreshList={refreshList}
|
||||
shortUrlsListParams={shortUrlsListParams}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const { params } = match;
|
||||
const query = qs.parse(location.search, { ignoreQueryPrefix: true });
|
||||
const tags = query.tag ? [ query.tag ] : shortUrlsListParams.tags;
|
||||
|
||||
refreshList({ page: params.page, tags });
|
||||
|
||||
return resetShortUrlParams;
|
||||
}, []);
|
||||
useEffect(
|
||||
bindToMercureTopic(mercureInfo, realTimeUpdates, 'https://shlink.io/new-visit', createNewVisit, loadMercureInfo),
|
||||
[ mercureInfo ]
|
||||
);
|
||||
|
||||
render() {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<div className="d-block d-md-none mb-3">
|
||||
<SortingDropdown
|
||||
items={SORTABLE_FIELDS}
|
||||
orderField={this.state.orderField}
|
||||
orderDir={this.state.orderDir}
|
||||
onChange={this.handleOrderBy}
|
||||
orderField={order.orderField}
|
||||
orderDir={order.orderDir}
|
||||
onChange={handleOrderBy}
|
||||
/>
|
||||
</div>
|
||||
<table className="table table-striped table-hover">
|
||||
@@ -137,42 +139,46 @@ const ShortUrlsList = (ShortUrlsRow) => class ShortUrlsList extends React.Compon
|
||||
<tr>
|
||||
<th
|
||||
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
|
||||
onClick={this.orderByColumn('dateCreated')}
|
||||
onClick={orderByColumn('dateCreated')}
|
||||
>
|
||||
{this.renderOrderIcon('dateCreated')}
|
||||
{renderOrderIcon('dateCreated')}
|
||||
Created at
|
||||
</th>
|
||||
<th
|
||||
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
|
||||
onClick={this.orderByColumn('shortCode')}
|
||||
onClick={orderByColumn('shortCode')}
|
||||
>
|
||||
{this.renderOrderIcon('shortCode')}
|
||||
{renderOrderIcon('shortCode')}
|
||||
Short URL
|
||||
</th>
|
||||
<th
|
||||
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
|
||||
onClick={this.orderByColumn('longUrl')}
|
||||
onClick={orderByColumn('longUrl')}
|
||||
>
|
||||
{this.renderOrderIcon('longUrl')}
|
||||
{renderOrderIcon('longUrl')}
|
||||
Long URL
|
||||
</th>
|
||||
<th className="short-urls-list__header-cell">Tags</th>
|
||||
<th
|
||||
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
|
||||
onClick={this.orderByColumn('visits')}
|
||||
onClick={orderByColumn('visits')}
|
||||
>
|
||||
<span className="indivisible">{this.renderOrderIcon('visits')} Visits</span>
|
||||
<span className="indivisible">{renderOrderIcon('visits')} Visits</span>
|
||||
</th>
|
||||
<th className="short-urls-list__header-cell"> </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{this.renderShortUrls()}
|
||||
{renderShortUrls()}
|
||||
</tbody>
|
||||
</table>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
ShortUrlsListComp.propTypes = propTypes;
|
||||
|
||||
return ShortUrlsListComp;
|
||||
};
|
||||
|
||||
export default ShortUrlsList;
|
||||
|
||||
@@ -1,28 +1,26 @@
|
||||
import { faCopy as copyIcon } from '@fortawesome/free-regular-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { isNil } from 'ramda';
|
||||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { CopyToClipboard } from 'react-copy-to-clipboard';
|
||||
import { Card, CardBody, Tooltip } from 'reactstrap';
|
||||
import PropTypes from 'prop-types';
|
||||
import { createShortUrlResultType } from '../reducers/shortUrlCreation';
|
||||
import './CreateShortUrlResult.scss';
|
||||
|
||||
const CreateShortUrlResult = (stateFlagTimeout) => class CreateShortUrlResult extends React.Component {
|
||||
static propTypes = {
|
||||
resetCreateShortUrl: PropTypes.func,
|
||||
error: PropTypes.bool,
|
||||
result: createShortUrlResultType,
|
||||
};
|
||||
const propTypes = {
|
||||
resetCreateShortUrl: PropTypes.func,
|
||||
error: PropTypes.bool,
|
||||
result: createShortUrlResultType,
|
||||
};
|
||||
|
||||
state = { showCopyTooltip: false };
|
||||
const CreateShortUrlResult = (useStateFlagTimeout) => {
|
||||
const CreateShortUrlResultComp = ({ error, result, resetCreateShortUrl }) => {
|
||||
const [ showCopyTooltip, setShowCopyTooltip ] = useStateFlagTimeout();
|
||||
|
||||
componentDidMount() {
|
||||
this.props.resetCreateShortUrl();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { error, result } = this.props;
|
||||
useEffect(() => {
|
||||
resetCreateShortUrl();
|
||||
}, []);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
@@ -31,19 +29,19 @@ const CreateShortUrlResult = (stateFlagTimeout) => class CreateShortUrlResult ex
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (isNil(result)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { shortUrl } = result;
|
||||
const onCopy = () => stateFlagTimeout(this.setState.bind(this), 'showCopyTooltip');
|
||||
|
||||
return (
|
||||
<Card inverse className="bg-main mt-3">
|
||||
<CardBody>
|
||||
<b>Great!</b> The short URL is <b>{shortUrl}</b>
|
||||
|
||||
<CopyToClipboard text={shortUrl} onCopy={onCopy}>
|
||||
<CopyToClipboard text={shortUrl} onCopy={setShowCopyTooltip}>
|
||||
<button
|
||||
className="btn btn-light btn-sm create-short-url-result__copy-btn"
|
||||
id="copyBtn"
|
||||
@@ -53,13 +51,17 @@ const CreateShortUrlResult = (stateFlagTimeout) => class CreateShortUrlResult ex
|
||||
</button>
|
||||
</CopyToClipboard>
|
||||
|
||||
<Tooltip placement="left" isOpen={this.state.showCopyTooltip} target="copyBtn">
|
||||
<Tooltip placement="left" isOpen={showCopyTooltip} target="copyBtn">
|
||||
Copied!
|
||||
</Tooltip>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
CreateShortUrlResultComp.propTypes = propTypes;
|
||||
|
||||
return CreateShortUrlResultComp;
|
||||
};
|
||||
|
||||
export default CreateShortUrlResult;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||
import PropTypes from 'prop-types';
|
||||
import { identity, pipe } from 'ramda';
|
||||
@@ -7,21 +7,28 @@ import { shortUrlDeletionType } from '../reducers/shortUrlDeletion';
|
||||
|
||||
const THRESHOLD_REACHED = 'INVALID_SHORTCODE_DELETION';
|
||||
|
||||
export default class DeleteShortUrlModal extends React.Component {
|
||||
static propTypes = {
|
||||
shortUrl: shortUrlType,
|
||||
toggle: PropTypes.func,
|
||||
isOpen: PropTypes.bool,
|
||||
shortUrlDeletion: shortUrlDeletionType,
|
||||
deleteShortUrl: PropTypes.func,
|
||||
resetDeleteShortUrl: PropTypes.func,
|
||||
};
|
||||
const propTypes = {
|
||||
shortUrl: shortUrlType,
|
||||
toggle: PropTypes.func,
|
||||
isOpen: PropTypes.bool,
|
||||
shortUrlDeletion: shortUrlDeletionType,
|
||||
deleteShortUrl: PropTypes.func,
|
||||
resetDeleteShortUrl: PropTypes.func,
|
||||
};
|
||||
|
||||
state = { inputValue: '' };
|
||||
handleDeleteUrl = (e) => {
|
||||
const DeleteShortUrlModal = ({ shortUrl, toggle, isOpen, shortUrlDeletion, resetDeleteShortUrl, deleteShortUrl }) => {
|
||||
const [ inputValue, setInputValue ] = useState('');
|
||||
|
||||
useEffect(() => resetDeleteShortUrl, []);
|
||||
|
||||
const { error, errorData } = shortUrlDeletion;
|
||||
const errorCode = error && (errorData.type || errorData.error);
|
||||
const hasThresholdError = errorCode === THRESHOLD_REACHED;
|
||||
const hasErrorOtherThanThreshold = error && errorCode !== THRESHOLD_REACHED;
|
||||
const close = pipe(resetDeleteShortUrl, toggle);
|
||||
const handleDeleteUrl = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const { deleteShortUrl, shortUrl, toggle } = this.props;
|
||||
const { shortCode, domain } = shortUrl;
|
||||
|
||||
deleteShortUrl(shortCode, domain)
|
||||
@@ -29,62 +36,51 @@ export default class DeleteShortUrlModal extends React.Component {
|
||||
.catch(identity);
|
||||
};
|
||||
|
||||
componentWillUnmount() {
|
||||
const { resetDeleteShortUrl } = this.props;
|
||||
return (
|
||||
<Modal isOpen={isOpen} toggle={close} centered>
|
||||
<form onSubmit={handleDeleteUrl}>
|
||||
<ModalHeader toggle={close}>
|
||||
<span className="text-danger">Delete short URL</span>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<p><b className="text-danger">Caution!</b> You are about to delete a short URL.</p>
|
||||
<p>This action cannot be undone. Once you have deleted it, all the visits stats will be lost.</p>
|
||||
|
||||
resetDeleteShortUrl();
|
||||
}
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="Insert the short code of the URL"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
/>
|
||||
|
||||
render() {
|
||||
const { shortUrl, toggle, isOpen, shortUrlDeletion, resetDeleteShortUrl } = this.props;
|
||||
const { error, errorData } = shortUrlDeletion;
|
||||
const errorCode = error && (errorData.type || errorData.error);
|
||||
const hasThresholdError = errorCode === THRESHOLD_REACHED;
|
||||
const hasErrorOtherThanThreshold = error && errorCode !== THRESHOLD_REACHED;
|
||||
const close = pipe(resetDeleteShortUrl, toggle);
|
||||
{hasThresholdError && (
|
||||
<div className="p-2 mt-2 bg-warning text-center">
|
||||
{errorData.threshold && `This short URL has received more than ${errorData.threshold} visits, and therefore, it cannot be deleted.`}
|
||||
{!errorData.threshold && 'This short URL has received too many visits, and therefore, it cannot be deleted.'}
|
||||
</div>
|
||||
)}
|
||||
{hasErrorOtherThanThreshold && (
|
||||
<div className="p-2 mt-2 bg-danger text-white text-center">
|
||||
Something went wrong while deleting the URL :(
|
||||
</div>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<button type="button" className="btn btn-link" onClick={close}>Cancel</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-danger"
|
||||
disabled={inputValue !== shortUrl.shortCode || shortUrlDeletion.loading}
|
||||
>
|
||||
{shortUrlDeletion.loading ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} toggle={close} centered>
|
||||
<form onSubmit={this.handleDeleteUrl}>
|
||||
<ModalHeader toggle={close}>
|
||||
<span className="text-danger">Delete short URL</span>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<p><b className="text-danger">Caution!</b> You are about to delete a short URL.</p>
|
||||
<p>This action cannot be undone. Once you have deleted it, all the visits stats will be lost.</p>
|
||||
DeleteShortUrlModal.propTypes = propTypes;
|
||||
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="Insert the short code of the URL"
|
||||
value={this.state.inputValue}
|
||||
onChange={(e) => this.setState({ inputValue: e.target.value })}
|
||||
/>
|
||||
|
||||
{hasThresholdError && (
|
||||
<div className="p-2 mt-2 bg-warning text-center">
|
||||
{errorData.threshold && `This short URL has received more than ${errorData.threshold} visits, and therefore, it cannot be deleted.`}
|
||||
{!errorData.threshold && 'This short URL has received too many visits, and therefore, it cannot be deleted.'}
|
||||
</div>
|
||||
)}
|
||||
{hasErrorOtherThanThreshold && (
|
||||
<div className="p-2 mt-2 bg-danger text-white text-center">
|
||||
Something went wrong while deleting the URL :(
|
||||
</div>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<button type="button" className="btn btn-link" onClick={close}>Cancel</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-danger"
|
||||
disabled={this.state.inputValue !== shortUrl.shortCode || shortUrlDeletion.loading}
|
||||
>
|
||||
{shortUrlDeletion.loading ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
export default DeleteShortUrlModal;
|
||||
|
||||
@@ -1,52 +1,37 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||
import PropTypes from 'prop-types';
|
||||
import { ExternalLink } from 'react-external-link';
|
||||
import { pipe } from 'ramda';
|
||||
import { shortUrlTagsType } from '../reducers/shortUrlTags';
|
||||
import { shortUrlType } from '../reducers/shortUrlsList';
|
||||
|
||||
const EditTagsModal = (TagsSelector) => class EditTagsModal extends React.Component {
|
||||
static propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
toggle: PropTypes.func.isRequired,
|
||||
shortUrl: shortUrlType.isRequired,
|
||||
shortUrlTags: shortUrlTagsType,
|
||||
editShortUrlTags: PropTypes.func,
|
||||
resetShortUrlsTags: PropTypes.func,
|
||||
};
|
||||
const propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
toggle: PropTypes.func.isRequired,
|
||||
shortUrl: shortUrlType.isRequired,
|
||||
shortUrlTags: shortUrlTagsType,
|
||||
editShortUrlTags: PropTypes.func,
|
||||
resetShortUrlsTags: PropTypes.func,
|
||||
};
|
||||
|
||||
saveTags = () => {
|
||||
const { editShortUrlTags, shortUrl, toggle } = this.props;
|
||||
const EditTagsModal = (TagsSelector) => {
|
||||
const EditTagsModalComp = ({ isOpen, toggle, shortUrl, shortUrlTags, editShortUrlTags, resetShortUrlsTags }) => {
|
||||
const [ selectedTags, setSelectedTags ] = useState(shortUrl.tags || []);
|
||||
|
||||
editShortUrlTags(shortUrl.shortCode, shortUrl.domain, this.state.tags)
|
||||
useEffect(() => resetShortUrlsTags, []);
|
||||
|
||||
const url = shortUrl && (shortUrl.shortUrl || '');
|
||||
const saveTags = () => editShortUrlTags(shortUrl.shortCode, shortUrl.domain, selectedTags)
|
||||
.then(toggle)
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
const { resetShortUrlsTags } = this.props;
|
||||
|
||||
resetShortUrlsTags();
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { tags: props.shortUrl.tags };
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isOpen, toggle, shortUrl, shortUrlTags, resetShortUrlsTags } = this.props;
|
||||
const url = shortUrl && (shortUrl.shortUrl || '');
|
||||
const close = pipe(resetShortUrlsTags, toggle);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} toggle={close} centered>
|
||||
<ModalHeader toggle={close}>
|
||||
<Modal isOpen={isOpen} toggle={toggle} centered>
|
||||
<ModalHeader toggle={toggle}>
|
||||
Edit tags for <ExternalLink href={url} />
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<TagsSelector tags={this.state.tags} onChange={(tags) => this.setState({ tags })} />
|
||||
<TagsSelector tags={selectedTags} onChange={(tags) => setSelectedTags(tags)} />
|
||||
{shortUrlTags.error && (
|
||||
<div className="p-2 mt-2 bg-danger text-white text-center">
|
||||
Something went wrong while saving the tags :(
|
||||
@@ -54,19 +39,18 @@ const EditTagsModal = (TagsSelector) => class EditTagsModal extends React.Compon
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<button className="btn btn-link" onClick={close}>Cancel</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
type="button"
|
||||
disabled={shortUrlTags.saving}
|
||||
onClick={() => this.saveTags()}
|
||||
>
|
||||
<button className="btn btn-link" onClick={toggle}>Cancel</button>
|
||||
<button className="btn btn-primary" type="button" disabled={shortUrlTags.saving} onClick={saveTags}>
|
||||
{shortUrlTags.saving ? 'Saving tags...' : 'Save tags'}
|
||||
</button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
EditTagsModalComp.propTypes = propTypes;
|
||||
|
||||
return EditTagsModalComp;
|
||||
};
|
||||
|
||||
export default EditTagsModal;
|
||||
|
||||
@@ -1,24 +1,31 @@
|
||||
import React from 'react';
|
||||
import React, { useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { UncontrolledTooltip } from 'reactstrap';
|
||||
import classNames from 'classnames';
|
||||
import { serverType } from '../../servers/prop-types';
|
||||
import { prettify } from '../../utils/helpers/numbers';
|
||||
import { shortUrlType } from '../reducers/shortUrlsList';
|
||||
import './ShortUrlVisitsCount.scss';
|
||||
import VisitStatsLink from './VisitStatsLink';
|
||||
import './ShortUrlVisitsCount.scss';
|
||||
|
||||
const propTypes = {
|
||||
visitsCount: PropTypes.number.isRequired,
|
||||
shortUrl: shortUrlType,
|
||||
selectedServer: serverType,
|
||||
active: PropTypes.bool,
|
||||
};
|
||||
|
||||
const ShortUrlVisitsCount = ({ visitsCount, shortUrl, selectedServer }) => {
|
||||
const ShortUrlVisitsCount = ({ visitsCount, shortUrl, selectedServer, active = false }) => {
|
||||
const maxVisits = shortUrl && shortUrl.meta && shortUrl.meta.maxVisits;
|
||||
const visitsLink = (
|
||||
<VisitStatsLink selectedServer={selectedServer} shortUrl={shortUrl}>
|
||||
<strong>{visitsCount}</strong>
|
||||
<strong
|
||||
className={classNames('short-url-visits-count__amount', { 'short-url-visits-count__amount--big': active })}
|
||||
>
|
||||
{prettify(visitsCount)}
|
||||
</strong>
|
||||
</VisitStatsLink>
|
||||
);
|
||||
|
||||
@@ -26,19 +33,27 @@ const ShortUrlVisitsCount = ({ visitsCount, shortUrl, selectedServer }) => {
|
||||
return visitsLink;
|
||||
}
|
||||
|
||||
const prettifiedMaxVisits = prettify(maxVisits);
|
||||
const tooltipRef = useRef();
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<span className="indivisible">
|
||||
{visitsLink}
|
||||
<small id="maxVisitsControl" className="short-urls-visits-count__max-visits-control">
|
||||
{' '}/ {maxVisits}{' '}
|
||||
<small
|
||||
className="short-urls-visits-count__max-visits-control"
|
||||
ref={(el) => {
|
||||
tooltipRef.current = el;
|
||||
}}
|
||||
>
|
||||
{' '}/ {prettifiedMaxVisits}{' '}
|
||||
<sup>
|
||||
<FontAwesomeIcon icon={infoIcon} />
|
||||
</sup>
|
||||
</small>
|
||||
</span>
|
||||
<UncontrolledTooltip target="maxVisitsControl" placement="bottom">
|
||||
This short URL will not accept more than <b>{maxVisits}</b> visits.
|
||||
<UncontrolledTooltip target={() => tooltipRef.current} placement="bottom">
|
||||
This short URL will not accept more than <b>{prettifiedMaxVisits}</b> visits.
|
||||
</UncontrolledTooltip>
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
.short-urls-visits-count__max-visits-control {
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.short-url-visits-count__amount {
|
||||
transition: transform .3s ease;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.short-url-visits-count__amount--big {
|
||||
transform: scale(1.5);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { isEmpty } from 'ramda';
|
||||
import React from 'react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import Moment from 'react-moment';
|
||||
import PropTypes from 'prop-types';
|
||||
import { ExternalLink } from 'react-external-link';
|
||||
@@ -26,7 +26,10 @@ const ShortUrlsRow = (
|
||||
useStateFlagTimeout
|
||||
) => {
|
||||
const ShortUrlsRowComp = ({ shortUrl, selectedServer, refreshList, shortUrlsListParams }) => {
|
||||
const [ copiedToClipboard, setCopiedToClipboard ] = useStateFlagTimeout(false);
|
||||
const [ copiedToClipboard, setCopiedToClipboard ] = useStateFlagTimeout();
|
||||
const [ active, setActive ] = useStateFlagTimeout(false, 500);
|
||||
const isFirstRun = useRef(true);
|
||||
|
||||
const renderTags = (tags) => {
|
||||
if (isEmpty(tags)) {
|
||||
return <i className="indivisible"><small>No tags</small></i>;
|
||||
@@ -44,6 +47,14 @@ const ShortUrlsRow = (
|
||||
));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isFirstRun.current) {
|
||||
isFirstRun.current = false;
|
||||
} else {
|
||||
setActive(true);
|
||||
}
|
||||
}, [ shortUrl.visitsCount ]);
|
||||
|
||||
return (
|
||||
<tr className="short-urls-row">
|
||||
<td className="indivisible short-urls-row__cell" data-th="Created at: ">
|
||||
@@ -69,6 +80,7 @@ const ShortUrlsRow = (
|
||||
visitsCount={shortUrl.visitsCount}
|
||||
shortUrl={shortUrl}
|
||||
selectedServer={selectedServer}
|
||||
active={active}
|
||||
/>
|
||||
</td>
|
||||
<td className="short-urls-row__cell">
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.short-urls-row__cell--break {
|
||||
word-break: break-all;
|
||||
}
|
||||
@@ -43,6 +44,10 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.short-urls-row__cell--big {
|
||||
transform: scale(1.5);
|
||||
}
|
||||
|
||||
.short-urls-row__copy-btn {
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
|
||||
9
src/short-urls/helpers/index.js
Normal file
9
src/short-urls/helpers/index.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { isNil } from 'ramda';
|
||||
|
||||
export const shortUrlMatches = (shortUrl, shortCode, domain) => {
|
||||
if (isNil(domain)) {
|
||||
return shortUrl.shortCode === shortCode && !shortUrl.domain;
|
||||
}
|
||||
|
||||
return shortUrl.shortCode === shortCode && shortUrl.domain === domain;
|
||||
};
|
||||
@@ -1,6 +1,8 @@
|
||||
import { handleActions } from 'redux-actions';
|
||||
import { assoc, assocPath, isNil, reject } from 'ramda';
|
||||
import { assoc, assocPath, reject } from 'ramda';
|
||||
import PropTypes from 'prop-types';
|
||||
import { shortUrlMatches } from '../helpers';
|
||||
import { CREATE_VISIT } from '../../visits/reducers/visitCreation';
|
||||
import { SHORT_URL_TAGS_EDITED } from './shortUrlTags';
|
||||
import { SHORT_URL_DELETED } from './shortUrlDeletion';
|
||||
import { SHORT_URL_META_EDITED, shortUrlMetaType } from './shortUrlMeta';
|
||||
@@ -28,14 +30,6 @@ const initialState = {
|
||||
error: false,
|
||||
};
|
||||
|
||||
const shortUrlMatches = (shortUrl, shortCode, domain) => {
|
||||
if (isNil(domain)) {
|
||||
return shortUrl.shortCode === shortCode && !shortUrl.domain;
|
||||
}
|
||||
|
||||
return shortUrl.shortCode === shortCode && shortUrl.domain === domain;
|
||||
};
|
||||
|
||||
const setPropFromActionOnMatchingShortUrl = (prop) => (state, { shortCode, domain, [prop]: propValue }) => assocPath(
|
||||
[ 'shortUrls', 'data' ],
|
||||
state.shortUrls.data.map(
|
||||
@@ -56,6 +50,15 @@ export default handleActions({
|
||||
[SHORT_URL_TAGS_EDITED]: setPropFromActionOnMatchingShortUrl('tags'),
|
||||
[SHORT_URL_META_EDITED]: setPropFromActionOnMatchingShortUrl('meta'),
|
||||
[SHORT_URL_EDITED]: setPropFromActionOnMatchingShortUrl('longUrl'),
|
||||
[CREATE_VISIT]: (state, { shortUrl: { shortCode, domain, visitsCount } }) => assocPath(
|
||||
[ 'shortUrls', 'data' ],
|
||||
state.shortUrls && state.shortUrls.data && state.shortUrls.data.map(
|
||||
(shortUrl) => shortUrlMatches(shortUrl, shortCode, domain)
|
||||
? assoc('visitsCount', visitsCount, shortUrl)
|
||||
: shortUrl
|
||||
),
|
||||
state
|
||||
),
|
||||
}, initialState);
|
||||
|
||||
export const listShortUrls = (buildShlinkApiClient) => (params = {}) => async (dispatch, getState) => {
|
||||
|
||||
@@ -31,8 +31,8 @@ const provideServices = (bottle, connect) => {
|
||||
|
||||
bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsRow');
|
||||
bottle.decorator('ShortUrlsList', connect(
|
||||
[ 'selectedServer', 'shortUrlsListParams' ],
|
||||
[ 'listShortUrls', 'resetShortUrlParams' ]
|
||||
[ 'selectedServer', 'shortUrlsListParams', 'mercureInfo', 'settings' ],
|
||||
[ 'listShortUrls', 'resetShortUrlParams', 'createNewVisit', 'loadMercureInfo' ]
|
||||
));
|
||||
|
||||
bottle.serviceFactory('ShortUrlsRow', ShortUrlsRow, 'ShortUrlsRowMenu', 'ColorGenerator', 'useStateFlagTimeout');
|
||||
@@ -46,7 +46,7 @@ const provideServices = (bottle, connect) => {
|
||||
'EditShortUrlModal',
|
||||
'ForServerVersion'
|
||||
);
|
||||
bottle.serviceFactory('CreateShortUrlResult', CreateShortUrlResult, 'stateFlagTimeout');
|
||||
bottle.serviceFactory('CreateShortUrlResult', CreateShortUrlResult, 'useStateFlagTimeout');
|
||||
|
||||
bottle.serviceFactory('CreateShortUrl', CreateShortUrl, 'TagsSelector', 'CreateShortUrlResult', 'ForServerVersion');
|
||||
bottle.decorator(
|
||||
|
||||
@@ -1,47 +1,84 @@
|
||||
import { Card, CardBody } from 'reactstrap';
|
||||
import { Card, CardHeader, CardBody, Button, Collapse } from 'reactstrap';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faTrash as deleteIcon, faPencilAlt as editIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faTrash as deleteIcon, faPencilAlt as editIcon, faLink, faEye } from '@fortawesome/free-solid-svg-icons';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { serverType } from '../servers/prop-types';
|
||||
import { prettify } from '../utils/helpers/numbers';
|
||||
import { useToggle } from '../utils/helpers/hooks';
|
||||
import TagBullet from './helpers/TagBullet';
|
||||
import './TagCard.scss';
|
||||
|
||||
const TagCard = (DeleteTagConfirmModal, EditTagModal, colorGenerator) => class TagCard extends React.Component {
|
||||
static propTypes = {
|
||||
tag: PropTypes.string,
|
||||
currentServerId: PropTypes.string,
|
||||
};
|
||||
const propTypes = {
|
||||
tag: PropTypes.string,
|
||||
tagStats: PropTypes.shape({
|
||||
shortUrlsCount: PropTypes.number,
|
||||
visitsCount: PropTypes.number,
|
||||
}),
|
||||
selectedServer: serverType,
|
||||
displayed: PropTypes.bool,
|
||||
toggle: PropTypes.func,
|
||||
};
|
||||
|
||||
state = { isDeleteModalOpen: false, isEditModalOpen: false };
|
||||
const TagCard = (DeleteTagConfirmModal, EditTagModal, ForServerVersion, colorGenerator) => {
|
||||
const TagCardComp = ({ tag, tagStats, selectedServer, displayed, toggle }) => {
|
||||
const [ isDeleteModalOpen, toggleDelete ] = useToggle();
|
||||
const [ isEditModalOpen, toggleEdit ] = useToggle();
|
||||
|
||||
render() {
|
||||
const { tag, currentServerId } = this.props;
|
||||
const toggleDelete = () =>
|
||||
this.setState(({ isDeleteModalOpen }) => ({ isDeleteModalOpen: !isDeleteModalOpen }));
|
||||
const toggleEdit = () =>
|
||||
this.setState(({ isEditModalOpen }) => ({ isEditModalOpen: !isEditModalOpen }));
|
||||
const { id } = selectedServer;
|
||||
const shortUrlsLink = `/server/${id}/list-short-urls/1?tag=${tag}`;
|
||||
|
||||
return (
|
||||
<Card className="tag-card">
|
||||
<CardBody className="tag-card__body">
|
||||
<button className="btn btn-light btn-sm tag-card__btn tag-card__btn--last" onClick={toggleDelete}>
|
||||
<CardHeader className="tag-card__header">
|
||||
<Button color="light" size="sm" className="tag-card__btn tag-card__btn--last" onClick={toggleDelete}>
|
||||
<FontAwesomeIcon icon={deleteIcon} />
|
||||
</button>
|
||||
<button className="btn btn-light btn-sm tag-card__btn" onClick={toggleEdit}>
|
||||
</Button>
|
||||
<Button color="light" size="sm" className="tag-card__btn" onClick={toggleEdit}>
|
||||
<FontAwesomeIcon icon={editIcon} />
|
||||
</button>
|
||||
<h5 className="tag-card__tag-title">
|
||||
</Button>
|
||||
<h5 className="tag-card__tag-title text-ellipsis">
|
||||
<TagBullet tag={tag} colorGenerator={colorGenerator} />
|
||||
<Link to={`/server/${currentServerId}/list-short-urls/1?tag=${tag}`}>{tag}</Link>
|
||||
<ForServerVersion minVersion="2.2.0">
|
||||
<span className="tag-card__tag-name" onClick={toggle}>{tag}</span>
|
||||
</ForServerVersion>
|
||||
<ForServerVersion maxVersion="2.1.*">
|
||||
<Link to={shortUrlsLink}>{tag}</Link>
|
||||
</ForServerVersion>
|
||||
</h5>
|
||||
</CardBody>
|
||||
</CardHeader>
|
||||
|
||||
<DeleteTagConfirmModal tag={tag} toggle={toggleDelete} isOpen={this.state.isDeleteModalOpen} />
|
||||
<EditTagModal tag={tag} toggle={toggleEdit} isOpen={this.state.isEditModalOpen} />
|
||||
{tagStats && (
|
||||
<Collapse isOpen={displayed}>
|
||||
<CardBody className="tag-card__body">
|
||||
<Link
|
||||
to={shortUrlsLink}
|
||||
className="btn btn-light btn-block d-flex justify-content-between align-items-center mb-1"
|
||||
>
|
||||
<span className="text-ellipsis"><FontAwesomeIcon icon={faLink} className="mr-2" />Short URLs</span>
|
||||
<b>{prettify(tagStats.shortUrlsCount)}</b>
|
||||
</Link>
|
||||
<Link
|
||||
to={`/server/${id}/tag/${tag}/visits`}
|
||||
className="btn btn-light btn-block d-flex justify-content-between align-items-center"
|
||||
>
|
||||
<span className="text-ellipsis"><FontAwesomeIcon icon={faEye} className="mr-2" />Visits</span>
|
||||
<b>{prettify(tagStats.visitsCount)}</b>
|
||||
</Link>
|
||||
</CardBody>
|
||||
</Collapse>
|
||||
)}
|
||||
|
||||
<DeleteTagConfirmModal tag={tag} toggle={toggleDelete} isOpen={isDeleteModalOpen} />
|
||||
<EditTagModal tag={tag} toggle={toggleEdit} isOpen={isEditModalOpen} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
TagCardComp.propTypes = propTypes;
|
||||
|
||||
return TagCardComp;
|
||||
};
|
||||
|
||||
export default TagCard;
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
.tag-card.tag-card {
|
||||
background-color: #eee;
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
|
||||
.tag-card__header.tag-card__header {
|
||||
background-color: #eee;
|
||||
}
|
||||
|
||||
.tag-card__header.tag-card__header,
|
||||
.tag-card__body.tag-card__body {
|
||||
padding: .75rem;
|
||||
}
|
||||
@@ -10,9 +14,6 @@
|
||||
.tag-card__tag-title {
|
||||
margin: 0;
|
||||
line-height: 31px;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
padding-right: 5px;
|
||||
}
|
||||
|
||||
@@ -23,3 +24,17 @@
|
||||
.tag-card__btn--last {
|
||||
margin-left: 3px;
|
||||
}
|
||||
|
||||
.tag-card__table-cell.tag-card__table-cell {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.tag-card__tag-name {
|
||||
color: #007bff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tag-card__tag-name:hover {
|
||||
color: #0056b3;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@@ -1,84 +1,97 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { splitEvery } from 'ramda';
|
||||
import PropTypes from 'prop-types';
|
||||
import Message from '../utils/Message';
|
||||
import SearchField from '../utils/SearchField';
|
||||
import { serverType } from '../servers/prop-types';
|
||||
import { MercureInfoType } from '../mercure/reducers/mercureInfo';
|
||||
import { SettingsType } from '../settings/reducers/settings';
|
||||
import { bindToMercureTopic } from '../mercure/helpers';
|
||||
import { TagsListType } from './reducers/tagsList';
|
||||
|
||||
const { ceil } = Math;
|
||||
const TAGS_GROUPS_AMOUNT = 4;
|
||||
|
||||
const TagsList = (TagCard) => class TagsList extends React.Component {
|
||||
static propTypes = {
|
||||
filterTags: PropTypes.func,
|
||||
forceListTags: PropTypes.func,
|
||||
tagsList: PropTypes.shape({
|
||||
loading: PropTypes.bool,
|
||||
error: PropTypes.bool,
|
||||
filteredTags: PropTypes.arrayOf(PropTypes.string),
|
||||
}),
|
||||
match: PropTypes.object,
|
||||
const propTypes = {
|
||||
filterTags: PropTypes.func,
|
||||
forceListTags: PropTypes.func,
|
||||
tagsList: TagsListType,
|
||||
selectedServer: serverType,
|
||||
createNewVisit: PropTypes.func,
|
||||
loadMercureInfo: PropTypes.func,
|
||||
mercureInfo: MercureInfoType,
|
||||
settings: SettingsType,
|
||||
};
|
||||
|
||||
const TagsList = (TagCard) => {
|
||||
const TagListComp = (
|
||||
{ filterTags, forceListTags, tagsList, selectedServer, createNewVisit, loadMercureInfo, mercureInfo, settings }
|
||||
) => {
|
||||
const { realTimeUpdates } = settings;
|
||||
const [ displayedTag, setDisplayedTag ] = useState();
|
||||
|
||||
useEffect(() => {
|
||||
forceListTags();
|
||||
}, []);
|
||||
useEffect(
|
||||
bindToMercureTopic(mercureInfo, realTimeUpdates, 'https://shlink.io/new-visit', createNewVisit, loadMercureInfo),
|
||||
[ mercureInfo ]
|
||||
);
|
||||
|
||||
const renderContent = () => {
|
||||
if (tagsList.loading) {
|
||||
return <Message noMargin loading />;
|
||||
}
|
||||
|
||||
if (tagsList.error) {
|
||||
return (
|
||||
<div className="col-12">
|
||||
<div className="bg-danger p-2 text-white text-center">Error loading tags :(</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tagsCount = tagsList.filteredTags.length;
|
||||
|
||||
if (tagsCount < 1) {
|
||||
return <Message>No tags found</Message>;
|
||||
}
|
||||
|
||||
const tagsGroups = splitEvery(ceil(tagsCount / TAGS_GROUPS_AMOUNT), tagsList.filteredTags);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{tagsGroups.map((group, index) => (
|
||||
<div key={index} className="col-md-6 col-xl-3">
|
||||
{group.map((tag) => (
|
||||
<TagCard
|
||||
key={tag}
|
||||
tag={tag}
|
||||
tagStats={tagsList.stats[tag]}
|
||||
selectedServer={selectedServer}
|
||||
displayed={displayedTag === tag}
|
||||
toggle={() => setDisplayedTag(displayedTag !== tag ? tag : undefined)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{!tagsList.loading && <SearchField className="mb-3" placeholder="Search tags..." onChange={filterTags} />}
|
||||
<div className="row">
|
||||
{renderContent()}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
const { forceListTags } = this.props;
|
||||
TagListComp.propTypes = propTypes;
|
||||
|
||||
forceListTags();
|
||||
}
|
||||
|
||||
renderContent() {
|
||||
const { tagsList, match } = this.props;
|
||||
|
||||
if (tagsList.loading) {
|
||||
return <Message noMargin loading />;
|
||||
}
|
||||
|
||||
if (tagsList.error) {
|
||||
return (
|
||||
<div className="col-12">
|
||||
<div className="bg-danger p-2 text-white text-center">Error loading tags :(</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tagsCount = tagsList.filteredTags.length;
|
||||
|
||||
if (tagsCount < 1) {
|
||||
return <Message>No tags found</Message>;
|
||||
}
|
||||
|
||||
const tagsGroups = splitEvery(ceil(tagsCount / TAGS_GROUPS_AMOUNT), tagsList.filteredTags);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{tagsGroups.map((group, index) => (
|
||||
<div key={index} className="col-md-6 col-xl-3">
|
||||
{group.map((tag) => (
|
||||
<TagCard
|
||||
key={tag}
|
||||
tag={tag}
|
||||
currentServerId={match.params.serverId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { filterTags } = this.props;
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{!this.props.tagsList.loading &&
|
||||
<SearchField className="mb-3" placeholder="Search tags..." onChange={filterTags} />
|
||||
}
|
||||
<div className="row">
|
||||
{this.renderContent()}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
return TagListComp;
|
||||
};
|
||||
|
||||
export default TagsList;
|
||||
|
||||
@@ -3,64 +3,45 @@ import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||
import PropTypes from 'prop-types';
|
||||
import { tagDeleteType } from '../reducers/tagDelete';
|
||||
|
||||
export default class DeleteTagConfirmModal extends React.Component {
|
||||
static propTypes = {
|
||||
tag: PropTypes.string.isRequired,
|
||||
toggle: PropTypes.func.isRequired,
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
deleteTag: PropTypes.func,
|
||||
tagDelete: tagDeleteType,
|
||||
tagDeleted: PropTypes.func,
|
||||
};
|
||||
|
||||
doDelete = async () => {
|
||||
const { tag, toggle, deleteTag } = this.props;
|
||||
const propTypes = {
|
||||
tag: PropTypes.string.isRequired,
|
||||
toggle: PropTypes.func.isRequired,
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
deleteTag: PropTypes.func,
|
||||
tagDelete: tagDeleteType,
|
||||
tagDeleted: PropTypes.func,
|
||||
};
|
||||
|
||||
const DeleteTagConfirmModal = ({ tag, toggle, isOpen, deleteTag, tagDelete, tagDeleted }) => {
|
||||
const doDelete = async () => {
|
||||
await deleteTag(tag);
|
||||
this.tagWasDeleted = true;
|
||||
tagDeleted(tag);
|
||||
toggle();
|
||||
};
|
||||
handleOnClosed = () => {
|
||||
if (!this.tagWasDeleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { tagDeleted, tag } = this.props;
|
||||
return (
|
||||
<Modal toggle={toggle} isOpen={isOpen} centered>
|
||||
<ModalHeader toggle={toggle}>
|
||||
<span className="text-danger">Delete tag</span>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
Are you sure you want to delete tag <b>{tag}</b>?
|
||||
{tagDelete.error && (
|
||||
<div className="p-2 mt-2 bg-danger text-white text-center">
|
||||
Something went wrong while deleting the tag :(
|
||||
</div>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<button className="btn btn-link" onClick={toggle}>Cancel</button>
|
||||
<button className="btn btn-danger" disabled={tagDelete.deleting} onClick={doDelete}>
|
||||
{tagDelete.deleting ? 'Deleting tag...' : 'Delete tag'}
|
||||
</button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
tagDeleted(tag);
|
||||
};
|
||||
DeleteTagConfirmModal.propTypes = propTypes;
|
||||
|
||||
componentDidMount() {
|
||||
this.tagWasDeleted = false;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { tag, toggle, isOpen, tagDelete } = this.props;
|
||||
|
||||
return (
|
||||
<Modal toggle={toggle} isOpen={isOpen} centered onClosed={this.handleOnClosed}>
|
||||
<ModalHeader toggle={toggle}>
|
||||
<span className="text-danger">Delete tag</span>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
Are you sure you want to delete tag <b>{tag}</b>?
|
||||
{tagDelete.error && (
|
||||
<div className="p-2 mt-2 bg-danger text-white text-center">
|
||||
Something went wrong while deleting the tag :(
|
||||
</div>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<button className="btn btn-link" onClick={toggle}>Cancel</button>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
disabled={tagDelete.deleting}
|
||||
onClick={() => this.doDelete()}
|
||||
>
|
||||
{tagDelete.deleting ? 'Deleting tag...' : 'Delete tag'}
|
||||
</button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
export default DeleteTagConfirmModal;
|
||||
|
||||
@@ -1,109 +1,62 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { Modal, ModalBody, ModalFooter, ModalHeader, Popover } from 'reactstrap';
|
||||
import { ChromePicker } from 'react-color';
|
||||
import { faPalette as colorIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import PropTypes from 'prop-types';
|
||||
import './EditTagModal.scss';
|
||||
import { useToggle } from '../../utils/helpers/hooks';
|
||||
|
||||
const EditTagModal = ({ getColorForKey }) => class EditTagModal extends React.Component {
|
||||
static propTypes = {
|
||||
tag: PropTypes.string,
|
||||
editTag: PropTypes.func,
|
||||
toggle: PropTypes.func,
|
||||
tagEdited: PropTypes.func,
|
||||
isOpen: PropTypes.bool,
|
||||
tagEdit: PropTypes.shape({
|
||||
error: PropTypes.bool,
|
||||
editing: PropTypes.bool,
|
||||
}),
|
||||
};
|
||||
const propTypes = {
|
||||
tag: PropTypes.string,
|
||||
editTag: PropTypes.func,
|
||||
toggle: PropTypes.func,
|
||||
tagEdited: PropTypes.func,
|
||||
isOpen: PropTypes.bool,
|
||||
tagEdit: PropTypes.shape({
|
||||
error: PropTypes.bool,
|
||||
editing: PropTypes.bool,
|
||||
}),
|
||||
};
|
||||
|
||||
saveTag = (e) => {
|
||||
e.preventDefault();
|
||||
const { tag: oldName, editTag, toggle } = this.props;
|
||||
const { tag: newName, color } = this.state;
|
||||
const EditTagModal = ({ getColorForKey }) => {
|
||||
const EditTagModalComp = ({ tag, editTag, toggle, tagEdited, isOpen, tagEdit }) => {
|
||||
const [ newTagName, setNewTagName ] = useState(tag);
|
||||
const [ color, setColor ] = useState(getColorForKey(tag));
|
||||
const [ showColorPicker, toggleColorPicker ] = useToggle();
|
||||
const saveTag = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
editTag(oldName, newName, color)
|
||||
.then(() => {
|
||||
this.tagWasEdited = true;
|
||||
toggle();
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
handleOnClosed = () => {
|
||||
if (!this.tagWasEdited) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { tag: oldName, tagEdited } = this.props;
|
||||
const { tag: newName, color } = this.state;
|
||||
|
||||
tagEdited(oldName, newName, color);
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const { tag } = props;
|
||||
|
||||
this.state = {
|
||||
showColorPicker: false,
|
||||
tag,
|
||||
color: getColorForKey(tag),
|
||||
editTag(tag, newTagName, color)
|
||||
.then(() => tagEdited(tag, newTagName, color))
|
||||
.then(toggle)
|
||||
.catch(() => {});
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.tagWasEdited = false;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isOpen, toggle, tagEdit } = this.props;
|
||||
const { tag, color } = this.state;
|
||||
const toggleColorPicker = () =>
|
||||
this.setState(({ showColorPicker }) => ({ showColorPicker: !showColorPicker }));
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} toggle={toggle} centered onClosed={this.handleOnClosed}>
|
||||
<form onSubmit={(e) => this.saveTag(e)}>
|
||||
<Modal isOpen={isOpen} toggle={toggle} centered>
|
||||
<form onSubmit={saveTag}>
|
||||
<ModalHeader toggle={toggle}>Edit tag</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className="input-group">
|
||||
<div
|
||||
className="input-group-prepend"
|
||||
id="colorPickerBtn"
|
||||
onClick={toggleColorPicker}
|
||||
>
|
||||
<div className="input-group-prepend" id="colorPickerBtn" onClick={toggleColorPicker}>
|
||||
<div
|
||||
className="input-group-text edit-tag-modal__color-picker-toggle"
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
borderColor: color,
|
||||
}}
|
||||
style={{ backgroundColor: color, borderColor: color }}
|
||||
>
|
||||
<FontAwesomeIcon icon={colorIcon} className="edit-tag-modal__color-icon" />
|
||||
</div>
|
||||
</div>
|
||||
<Popover
|
||||
isOpen={this.state.showColorPicker}
|
||||
toggle={toggleColorPicker}
|
||||
target="colorPickerBtn"
|
||||
placement="right"
|
||||
>
|
||||
<ChromePicker
|
||||
color={color}
|
||||
disableAlpha
|
||||
onChange={(color) => this.setState({ color: color.hex })}
|
||||
/>
|
||||
<Popover isOpen={showColorPicker} toggle={toggleColorPicker} target="colorPickerBtn" placement="right">
|
||||
<ChromePicker color={color} disableAlpha onChange={({ hex }) => setColor(hex)} />
|
||||
</Popover>
|
||||
<input
|
||||
type="text"
|
||||
value={tag}
|
||||
value={newTagName}
|
||||
placeholder="Tag"
|
||||
required
|
||||
className="form-control"
|
||||
onChange={(e) => this.setState({ tag: e.target.value })}
|
||||
onChange={(e) => setNewTagName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -122,7 +75,11 @@ const EditTagModal = ({ getColorForKey }) => class EditTagModal extends React.Co
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
EditTagModalComp.propTypes = propTypes;
|
||||
|
||||
return EditTagModalComp;
|
||||
};
|
||||
|
||||
export default EditTagModal;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './Tag.scss';
|
||||
import { colorGeneratorType } from '../../utils/services/ColorGenerator';
|
||||
import './Tag.scss';
|
||||
|
||||
const propTypes = {
|
||||
text: PropTypes.string,
|
||||
@@ -17,12 +17,12 @@ const Tag = ({
|
||||
children,
|
||||
clearable,
|
||||
colorGenerator,
|
||||
onClick = () => {},
|
||||
onClose = () => {},
|
||||
onClick,
|
||||
onClose,
|
||||
}) => (
|
||||
<span
|
||||
className="badge tag"
|
||||
style={{ backgroundColor: colorGenerator.getColorForKey(text), cursor: clearable ? 'auto' : 'pointer' }}
|
||||
style={{ backgroundColor: colorGenerator.getColorForKey(text), cursor: clearable || !onClick ? 'auto' : 'pointer' }}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children || text}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
.tag {
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tag:not(:last-child) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import TagsInput from 'react-tagsinput';
|
||||
import PropTypes from 'prop-types';
|
||||
import Autosuggest from 'react-autosuggest';
|
||||
@@ -6,28 +6,23 @@ import { identity } from 'ramda';
|
||||
import TagBullet from './TagBullet';
|
||||
import './TagsSelector.scss';
|
||||
|
||||
const TagsSelector = (colorGenerator) => class TagsSelector extends React.Component {
|
||||
static propTypes = {
|
||||
tags: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
listTags: PropTypes.func,
|
||||
placeholder: PropTypes.string,
|
||||
tagsList: PropTypes.shape({
|
||||
tags: PropTypes.arrayOf(PropTypes.string),
|
||||
}),
|
||||
};
|
||||
static defaultProps = {
|
||||
placeholder: 'Add tags to the URL',
|
||||
};
|
||||
const propTypes = {
|
||||
tags: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
listTags: PropTypes.func,
|
||||
placeholder: PropTypes.string,
|
||||
tagsList: PropTypes.shape({
|
||||
tags: PropTypes.arrayOf(PropTypes.string),
|
||||
}),
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
const { listTags } = this.props;
|
||||
const TagsSelector = (colorGenerator) => {
|
||||
const TagsSelectorComp = ({ tags, onChange, listTags, tagsList, placeholder = 'Add tags to the URL' }) => {
|
||||
useEffect(() => {
|
||||
listTags();
|
||||
}, []);
|
||||
|
||||
listTags();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { tags, onChange, placeholder, tagsList } = this.props;
|
||||
// eslint-disable-next-line
|
||||
const renderTag = ({ tag, key, disabled, onRemove, classNameRemove, getTagDisplayValue, ...other }) => (
|
||||
<span key={key} style={{ backgroundColor: colorGenerator.getColorForKey(tag) }} {...other}>
|
||||
{getTagDisplayValue(tag)}
|
||||
@@ -40,7 +35,6 @@ const TagsSelector = (colorGenerator) => class TagsSelector extends React.Compon
|
||||
method === 'enter' ? e.preventDefault() : otherProps.onChange(e);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-extra-parens
|
||||
const inputValue = (otherProps.value && otherProps.value.trim().toLowerCase()) || '';
|
||||
const inputLength = inputValue.length;
|
||||
const suggestions = tagsList.tags.filter((state) => state.toLowerCase().slice(0, inputLength) === inputValue);
|
||||
@@ -75,13 +69,16 @@ const TagsSelector = (colorGenerator) => class TagsSelector extends React.Compon
|
||||
onlyUnique
|
||||
renderTag={renderTag}
|
||||
renderInput={renderAutocompleteInput}
|
||||
|
||||
// FIXME Workaround to be able to add tags on Android
|
||||
addOnBlur
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
TagsSelectorComp.propTypes = propTypes;
|
||||
|
||||
return TagsSelectorComp;
|
||||
};
|
||||
|
||||
export default TagsSelector;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { handleActions } from 'redux-actions';
|
||||
import { isEmpty, reject } from 'ramda';
|
||||
import PropTypes from 'prop-types';
|
||||
import { CREATE_VISIT } from '../../visits/reducers/visitCreation';
|
||||
import { TAG_DELETED } from './tagDelete';
|
||||
import { TAG_EDITED } from './tagEdit';
|
||||
|
||||
@@ -10,20 +12,46 @@ export const LIST_TAGS = 'shlink/tagsList/LIST_TAGS';
|
||||
export const FILTER_TAGS = 'shlink/tagsList/FILTER_TAGS';
|
||||
/* eslint-enable padding-line-between-statements */
|
||||
|
||||
const TagStatsType = PropTypes.shape({
|
||||
shortUrlsCount: PropTypes.number,
|
||||
visitsCount: PropTypes.number,
|
||||
});
|
||||
|
||||
export const TagsListType = PropTypes.shape({
|
||||
tags: PropTypes.arrayOf(PropTypes.string),
|
||||
filteredTags: PropTypes.arrayOf(PropTypes.string),
|
||||
stats: PropTypes.objectOf(TagStatsType), // Record
|
||||
loading: PropTypes.bool,
|
||||
error: PropTypes.bool,
|
||||
});
|
||||
|
||||
const initialState = {
|
||||
tags: [],
|
||||
filteredTags: [],
|
||||
stats: {},
|
||||
loading: false,
|
||||
error: false,
|
||||
};
|
||||
|
||||
const renameTag = (oldName, newName) => (tag) => tag === oldName ? newName : tag;
|
||||
const rejectTag = (tags, tagToReject) => reject((tag) => tag === tagToReject, tags);
|
||||
const increaseVisitsForTags = (tags, stats) => tags.reduce((stats, tag) => {
|
||||
if (!stats[tag]) {
|
||||
return stats;
|
||||
}
|
||||
|
||||
const tagStats = stats[tag];
|
||||
|
||||
tagStats.visitsCount = tagStats.visitsCount + 1;
|
||||
stats[tag] = tagStats;
|
||||
|
||||
return stats;
|
||||
}, { ...stats });
|
||||
|
||||
export default handleActions({
|
||||
[LIST_TAGS_START]: (state) => ({ ...state, loading: true, error: false }),
|
||||
[LIST_TAGS_ERROR]: (state) => ({ ...state, loading: false, error: true }),
|
||||
[LIST_TAGS]: (state, { tags }) => ({ tags, filteredTags: tags, loading: false, error: false }),
|
||||
[LIST_TAGS_START]: () => ({ ...initialState, loading: true }),
|
||||
[LIST_TAGS_ERROR]: () => ({ ...initialState, error: true }),
|
||||
[LIST_TAGS]: (state, { tags, stats }) => ({ ...initialState, stats, tags, filteredTags: tags }),
|
||||
[TAG_DELETED]: (state, { tag }) => ({
|
||||
...state,
|
||||
tags: rejectTag(state.tags, tag),
|
||||
@@ -38,6 +66,10 @@ export default handleActions({
|
||||
...state,
|
||||
filteredTags: state.tags.filter((tag) => tag.toLowerCase().match(searchTerm)),
|
||||
}),
|
||||
[CREATE_VISIT]: (state, { shortUrl }) => ({
|
||||
...state,
|
||||
stats: increaseVisitsForTags(shortUrl.tags, state.stats),
|
||||
}),
|
||||
}, initialState);
|
||||
|
||||
export const listTags = (buildShlinkApiClient, force = true) => () => async (dispatch, getState) => {
|
||||
@@ -51,15 +83,17 @@ export const listTags = (buildShlinkApiClient, force = true) => () => async (dis
|
||||
|
||||
try {
|
||||
const { listTags } = buildShlinkApiClient(getState);
|
||||
const tags = await listTags();
|
||||
const { tags, stats = [] } = await listTags();
|
||||
const processedStats = stats.reduce((acc, { tag, shortUrlsCount, visitsCount }) => {
|
||||
acc[tag] = { shortUrlsCount, visitsCount };
|
||||
|
||||
dispatch({ tags, type: LIST_TAGS });
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
dispatch({ tags, stats: processedStats, type: LIST_TAGS });
|
||||
} catch (e) {
|
||||
dispatch({ type: LIST_TAGS_ERROR });
|
||||
}
|
||||
};
|
||||
|
||||
export const filterTags = (searchTerm) => ({
|
||||
type: FILTER_TAGS,
|
||||
searchTerm,
|
||||
});
|
||||
export const filterTags = (searchTerm) => ({ type: FILTER_TAGS, searchTerm });
|
||||
|
||||
@@ -12,7 +12,14 @@ const provideServices = (bottle, connect) => {
|
||||
bottle.serviceFactory('TagsSelector', TagsSelector, 'ColorGenerator');
|
||||
bottle.decorator('TagsSelector', connect([ 'tagsList' ], [ 'listTags' ]));
|
||||
|
||||
bottle.serviceFactory('TagCard', TagCard, 'DeleteTagConfirmModal', 'EditTagModal', 'ColorGenerator');
|
||||
bottle.serviceFactory(
|
||||
'TagCard',
|
||||
TagCard,
|
||||
'DeleteTagConfirmModal',
|
||||
'EditTagModal',
|
||||
'ForServerVersion',
|
||||
'ColorGenerator'
|
||||
);
|
||||
|
||||
bottle.serviceFactory('DeleteTagConfirmModal', () => DeleteTagConfirmModal);
|
||||
bottle.decorator('DeleteTagConfirmModal', connect([ 'tagDelete' ], [ 'deleteTag', 'tagDeleted' ]));
|
||||
@@ -21,7 +28,10 @@ const provideServices = (bottle, connect) => {
|
||||
bottle.decorator('EditTagModal', connect([ 'tagEdit' ], [ 'editTag', 'tagEdited' ]));
|
||||
|
||||
bottle.serviceFactory('TagsList', TagsList, 'TagCard');
|
||||
bottle.decorator('TagsList', connect([ 'tagsList' ], [ 'forceListTags', 'filterTags' ]));
|
||||
bottle.decorator('TagsList', connect(
|
||||
[ 'tagsList', 'selectedServer', 'mercureInfo', 'settings' ],
|
||||
[ 'forceListTags', 'filterTags', 'createNewVisit', 'loadMercureInfo' ]
|
||||
));
|
||||
|
||||
// Actions
|
||||
const listTagsActionFactory = (force) => ({ buildShlinkApiClient }) => listTags(buildShlinkApiClient, force);
|
||||
|
||||
@@ -33,7 +33,7 @@ const SortingDropdown = ({ items, orderField, orderDir, onChange, isButton, righ
|
||||
<DropdownToggle
|
||||
caret
|
||||
color={isButton ? 'secondary' : 'link'}
|
||||
className={classNames({ 'btn-block': isButton, 'btn-sm paddingless': !isButton })}
|
||||
className={classNames({ 'btn-block': isButton, 'btn-sm p-0': !isButton })}
|
||||
>
|
||||
Order by
|
||||
</DropdownToggle>
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useRef } from 'react';
|
||||
|
||||
const DEFAULT_TIMEOUT_DELAY = 2000;
|
||||
const DEFAULT_DELAY = 2000;
|
||||
|
||||
export const useStateFlagTimeout = (setTimeout) => (initialValue = true, delay = DEFAULT_TIMEOUT_DELAY) => {
|
||||
export const useStateFlagTimeout = (setTimeout, clearTimeout) => (initialValue = false, delay = DEFAULT_DELAY) => {
|
||||
const [ flag, setFlag ] = useState(initialValue);
|
||||
const timeout = useRef(undefined);
|
||||
const callback = () => {
|
||||
setFlag(!initialValue);
|
||||
setTimeout(() => setFlag(initialValue), delay);
|
||||
|
||||
if (timeout.current) {
|
||||
clearTimeout(timeout.current);
|
||||
}
|
||||
|
||||
timeout.current = setTimeout(() => setFlag(initialValue), delay);
|
||||
};
|
||||
|
||||
return [ flag, callback ];
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import bowser from 'bowser';
|
||||
import { zipObj } from 'ramda';
|
||||
import { hasValue } from '../utils';
|
||||
|
||||
const DEFAULT = 'Others';
|
||||
@@ -35,3 +36,5 @@ export const extractDomain = (url) => {
|
||||
|
||||
return domain.split(':')[0];
|
||||
};
|
||||
|
||||
export const fillTheGaps = (stats, labels) => Object.values({ ...zipObj(labels, labels.map(() => 0)), ...stats });
|
||||
|
||||
@@ -36,6 +36,10 @@ export default class ShlinkApiClient {
|
||||
this._performRequest(`/short-urls/${shortCode}/visits`, 'GET', query)
|
||||
.then((resp) => resp.data.visits);
|
||||
|
||||
getTagVisits = (tag, query) =>
|
||||
this._performRequest(`/tags/${tag}/visits`, 'GET', query)
|
||||
.then((resp) => resp.data.visits);
|
||||
|
||||
getShortUrl = (shortCode, domain) =>
|
||||
this._performRequest(`/short-urls/${shortCode}`, 'GET', { domain })
|
||||
.then((resp) => resp.data);
|
||||
@@ -53,8 +57,9 @@ export default class ShlinkApiClient {
|
||||
.then(() => meta);
|
||||
|
||||
listTags = () =>
|
||||
this._performRequest('/tags', 'GET')
|
||||
.then((resp) => resp.data.tags.data);
|
||||
this._performRequest('/tags', 'GET', { withStats: 'true' })
|
||||
.then((resp) => resp.data.tags)
|
||||
.then(({ data, stats }) => ({ tags: data, stats }));
|
||||
|
||||
deleteTags = (tags) =>
|
||||
this._performRequest('/tags', 'DELETE', { tags })
|
||||
@@ -66,6 +71,8 @@ export default class ShlinkApiClient {
|
||||
|
||||
health = () => this._performRequest('/health', 'GET').then((resp) => resp.data);
|
||||
|
||||
mercureInfo = () => this._performRequest('/mercure-info', 'GET').then((resp) => resp.data);
|
||||
|
||||
_performRequest = async (url, method = 'GET', query = {}, body = {}) => {
|
||||
try {
|
||||
return await this.axios({
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import axios from 'axios';
|
||||
import { stateFlagTimeout } from '../utils';
|
||||
import { useStateFlagTimeout } from '../helpers/hooks';
|
||||
import Storage from './Storage';
|
||||
import ColorGenerator from './ColorGenerator';
|
||||
@@ -14,8 +13,8 @@ const provideServices = (bottle) => {
|
||||
bottle.serviceFactory('buildShlinkApiClient', buildShlinkApiClient, 'axios');
|
||||
|
||||
bottle.constant('setTimeout', global.setTimeout);
|
||||
bottle.serviceFactory('stateFlagTimeout', stateFlagTimeout, 'setTimeout');
|
||||
bottle.serviceFactory('useStateFlagTimeout', useStateFlagTimeout, 'setTimeout');
|
||||
bottle.constant('clearTimeout', global.clearTimeout);
|
||||
bottle.serviceFactory('useStateFlagTimeout', useStateFlagTimeout, 'setTimeout', 'clearTimeout');
|
||||
};
|
||||
|
||||
export default provideServices;
|
||||
|
||||
@@ -4,18 +4,6 @@ import marker from 'leaflet/dist/images/marker-icon.png';
|
||||
import markerShadow from 'leaflet/dist/images/marker-shadow.png';
|
||||
import { isEmpty, isNil, range } from 'ramda';
|
||||
|
||||
const DEFAULT_TIMEOUT_DELAY = 2000;
|
||||
|
||||
export const stateFlagTimeout = (setTimeout) => (
|
||||
setState,
|
||||
flagName,
|
||||
initialValue = true,
|
||||
delay = DEFAULT_TIMEOUT_DELAY
|
||||
) => {
|
||||
setState({ [flagName]: initialValue });
|
||||
setTimeout(() => setState({ [flagName]: !initialValue }), delay);
|
||||
};
|
||||
|
||||
export const determineOrderDir = (clickedField, currentOrderField, currentOrderDir) => {
|
||||
if (currentOrderField !== clickedField) {
|
||||
return 'ASC';
|
||||
|
||||
@@ -2,7 +2,8 @@ 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, zipObj } from 'ramda';
|
||||
import { keys, values } from 'ramda';
|
||||
import { fillTheGaps } from '../utils/helpers/visits';
|
||||
import './GraphCard.scss';
|
||||
|
||||
const propTypes = {
|
||||
@@ -12,15 +13,16 @@ const propTypes = {
|
||||
stats: PropTypes.object,
|
||||
max: PropTypes.number,
|
||||
highlightedStats: PropTypes.object,
|
||||
highlightedLabel: PropTypes.string,
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
|
||||
const generateGraphData = (title, isBarChart, labels, data, highlightedData) => ({
|
||||
const generateGraphData = (title, isBarChart, labels, data, highlightedData, highlightedLabel) => ({
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
title,
|
||||
label: highlightedData && 'Non-selected',
|
||||
label: highlightedData ? 'Non-selected' : 'Visits',
|
||||
data,
|
||||
backgroundColor: isBarChart ? 'rgba(70, 150, 229, 0.4)' : [
|
||||
'#97BBCD',
|
||||
@@ -40,7 +42,7 @@ const generateGraphData = (title, isBarChart, labels, data, highlightedData) =>
|
||||
},
|
||||
highlightedData && {
|
||||
title,
|
||||
label: 'Selected',
|
||||
label: highlightedLabel || 'Selected',
|
||||
data: highlightedData,
|
||||
backgroundColor: 'rgba(247, 127, 40, 0.4)',
|
||||
borderColor: '#F77F28',
|
||||
@@ -59,7 +61,7 @@ const determineHeight = (isBarChart, labels) => {
|
||||
return isBarChart && labels.length > 20 ? labels.length * 8 : null;
|
||||
};
|
||||
|
||||
const renderGraph = (title, isBarChart, stats, max, highlightedStats, onClick) => {
|
||||
const renderGraph = (title, isBarChart, stats, max, highlightedStats, highlightedLabel, onClick) => {
|
||||
const hasHighlightedStats = highlightedStats && Object.keys(highlightedStats).length > 0;
|
||||
const Component = isBarChart ? HorizontalBar : Doughnut;
|
||||
const labels = keys(stats).map(dropLabelIfHidden);
|
||||
@@ -70,16 +72,14 @@ const renderGraph = (title, isBarChart, stats, max, highlightedStats, onClick) =
|
||||
|
||||
return acc;
|
||||
}, { ...stats }));
|
||||
const highlightedData = hasHighlightedStats && values(
|
||||
{ ...zipObj(labels, labels.map(() => 0)), ...highlightedStats }
|
||||
);
|
||||
const highlightedData = hasHighlightedStats && fillTheGaps(highlightedStats, labels);
|
||||
|
||||
const options = {
|
||||
legend: isBarChart ? { display: false } : { position: 'right' },
|
||||
scales: isBarChart && {
|
||||
xAxes: [
|
||||
{
|
||||
ticks: { beginAtZero: true, max },
|
||||
ticks: { beginAtZero: true, precision: 0, max },
|
||||
stacked: true,
|
||||
},
|
||||
],
|
||||
@@ -95,7 +95,7 @@ const renderGraph = (title, isBarChart, stats, max, highlightedStats, onClick) =
|
||||
target.style.cursor = chartElement[0] ? 'pointer' : 'default';
|
||||
}),
|
||||
};
|
||||
const graphData = generateGraphData(title, isBarChart, labels, data, highlightedData);
|
||||
const graphData = generateGraphData(title, isBarChart, labels, data, highlightedData, highlightedLabel);
|
||||
const height = determineHeight(isBarChart, labels);
|
||||
|
||||
// Provide a key based on the height, so that every time the dataset changes, a new graph is rendered
|
||||
@@ -119,10 +119,10 @@ const renderGraph = (title, isBarChart, stats, max, highlightedStats, onClick) =
|
||||
);
|
||||
};
|
||||
|
||||
const GraphCard = ({ title, footer, isBarChart, stats, max, highlightedStats, onClick }) => (
|
||||
<Card className="mt-4">
|
||||
const GraphCard = ({ title, footer, isBarChart, stats, max, highlightedStats, highlightedLabel, onClick }) => (
|
||||
<Card>
|
||||
<CardHeader className="graph-card__header">{typeof title === 'function' ? title() : title}</CardHeader>
|
||||
<CardBody>{renderGraph(title, isBarChart, stats, max, highlightedStats, onClick)}</CardBody>
|
||||
<CardBody>{renderGraph(title, isBarChart, stats, max, highlightedStats, highlightedLabel, onClick)}</CardBody>
|
||||
{footer && <CardFooter className="graph-card__footer--sticky">{footer}</CardFooter>}
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,23 +1,17 @@
|
||||
import { isEmpty, propEq, values } from 'ramda';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Button, Card, Collapse } from 'reactstrap';
|
||||
import React, { useEffect } from 'react';
|
||||
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 Message from '../utils/Message';
|
||||
import { formatDate } from '../utils/helpers/date';
|
||||
import { useToggle } from '../utils/helpers/hooks';
|
||||
import SortableBarGraph from './SortableBarGraph';
|
||||
import { MercureInfoType } from '../mercure/reducers/mercureInfo';
|
||||
import { bindToMercureTopic } from '../mercure/helpers';
|
||||
import { SettingsType } from '../settings/reducers/settings';
|
||||
import { shortUrlVisitsType } from './reducers/shortUrlVisits';
|
||||
import VisitsHeader from './VisitsHeader';
|
||||
import GraphCard from './GraphCard';
|
||||
import ShortUrlVisitsHeader from './ShortUrlVisitsHeader';
|
||||
import { shortUrlDetailType } from './reducers/shortUrlDetail';
|
||||
import VisitsTable from './VisitsTable';
|
||||
|
||||
const propTypes = {
|
||||
history: PropTypes.shape({
|
||||
goBack: PropTypes.func,
|
||||
}),
|
||||
match: PropTypes.shape({
|
||||
params: PropTypes.object,
|
||||
}),
|
||||
@@ -29,23 +23,15 @@ const propTypes = {
|
||||
getShortUrlDetail: PropTypes.func,
|
||||
shortUrlDetail: shortUrlDetailType,
|
||||
cancelGetShortUrlVisits: PropTypes.func,
|
||||
matchMedia: PropTypes.func,
|
||||
createNewVisit: PropTypes.func,
|
||||
loadMercureInfo: PropTypes.func,
|
||||
mercureInfo: MercureInfoType,
|
||||
settings: SettingsType,
|
||||
};
|
||||
|
||||
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 ShortUrlVisits = (VisitsStats) => {
|
||||
const ShortUrlVisitsComp = ({
|
||||
history,
|
||||
match,
|
||||
location,
|
||||
shortUrlVisits,
|
||||
@@ -53,194 +39,36 @@ const ShortUrlVisits = ({ processStatsFromVisits, normalizeVisits }, OpenMapModa
|
||||
getShortUrlVisits,
|
||||
getShortUrlDetail,
|
||||
cancelGetShortUrlVisits,
|
||||
matchMedia = window.matchMedia,
|
||||
createNewVisit,
|
||||
loadMercureInfo,
|
||||
mercureInfo,
|
||||
settings: { realTimeUpdates },
|
||||
}) => {
|
||||
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 { search } = location;
|
||||
const { domain } = qs.parse(search, { ignoreQueryPrefix: true });
|
||||
|
||||
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);
|
||||
|
||||
const loadVisits = () =>
|
||||
getShortUrlVisits(shortCode, { startDate: format(startDate), endDate: format(endDate), domain });
|
||||
const loadVisits = (dates) => getShortUrlVisits(shortCode, { ...dates, domain });
|
||||
|
||||
useEffect(() => {
|
||||
getShortUrlDetail(shortCode, domain);
|
||||
determineIsMobileDevice();
|
||||
window.addEventListener('resize', determineIsMobileDevice);
|
||||
|
||||
return () => {
|
||||
cancelGetShortUrlVisits();
|
||||
window.removeEventListener('resize', determineIsMobileDevice);
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
loadVisits();
|
||||
}, [ startDate, endDate ]);
|
||||
|
||||
const renderVisitsContent = () => {
|
||||
if (loading) {
|
||||
const message = loadingLarge ? 'This is going to take a while... :S' : 'Loading...';
|
||||
|
||||
return <Message loading>{message}</Message>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className="mt-4" body inverse color="danger">
|
||||
An error occurred while loading visits :(
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (isEmpty(visits)) {
|
||||
return <Message>There are no visits matching current filter :(</Message>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-xl-4 col-lg-6">
|
||||
<GraphCard title="Operating systems" stats={os} />
|
||||
</div>
|
||||
<div className="col-xl-4 col-lg-6">
|
||||
<GraphCard title="Browsers" stats={browsers} />
|
||||
</div>
|
||||
<div className="col-xl-4">
|
||||
<SortableBarGraph
|
||||
title="Referrers"
|
||||
stats={referrers}
|
||||
withPagination={false}
|
||||
highlightedStats={highlightedVisitsToStats(highlightedVisits, 'referer')}
|
||||
sortingItems={{
|
||||
name: 'Referrer name',
|
||||
amount: 'Visits amount',
|
||||
}}
|
||||
onClick={highlightVisitsForProp('referer')}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-lg-6">
|
||||
<SortableBarGraph
|
||||
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
|
||||
title="Cities"
|
||||
stats={cities}
|
||||
highlightedStats={highlightedVisitsToStats(highlightedVisits, 'city')}
|
||||
extraHeaderContent={(activeCities) =>
|
||||
mapLocations.length > 0 &&
|
||||
<OpenMapModalBtn modalTitle="Cities" locations={mapLocations} activeCities={activeCities} />
|
||||
}
|
||||
sortingItems={{
|
||||
name: 'City name',
|
||||
amount: 'Visits amount',
|
||||
}}
|
||||
onClick={highlightVisitsForProp('city')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
useEffect(
|
||||
bindToMercureTopic(
|
||||
mercureInfo,
|
||||
realTimeUpdates,
|
||||
`https://shlink.io/new-visit/${shortCode}`,
|
||||
createNewVisit,
|
||||
loadMercureInfo
|
||||
),
|
||||
[ mercureInfo ],
|
||||
);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<VisitsHeader shortUrlDetail={shortUrlDetail} shortUrlVisits={shortUrlVisits} />
|
||||
|
||||
<section className="mt-4">
|
||||
<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>
|
||||
</React.Fragment>
|
||||
<VisitsStats getVisits={loadVisits} cancelGetVisits={cancelGetShortUrlVisits} visitsInfo={shortUrlVisits}>
|
||||
<ShortUrlVisitsHeader shortUrlDetail={shortUrlDetail} shortUrlVisits={shortUrlVisits} goBack={history.goBack} />
|
||||
</VisitsStats>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
54
src/visits/ShortUrlVisitsHeader.js
Normal file
54
src/visits/ShortUrlVisitsHeader.js
Normal file
@@ -0,0 +1,54 @@
|
||||
import { UncontrolledTooltip } from 'reactstrap';
|
||||
import Moment from 'react-moment';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { ExternalLink } from 'react-external-link';
|
||||
import { shortUrlDetailType } from './reducers/shortUrlDetail';
|
||||
import { shortUrlVisitsType } from './reducers/shortUrlVisits';
|
||||
import VisitsHeader from './VisitsHeader';
|
||||
import './ShortUrlVisitsHeader.scss';
|
||||
|
||||
const propTypes = {
|
||||
shortUrlDetail: shortUrlDetailType.isRequired,
|
||||
shortUrlVisits: shortUrlVisitsType.isRequired,
|
||||
goBack: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
const ShortUrlVisitsHeader = ({ shortUrlDetail, shortUrlVisits, goBack }) => {
|
||||
const { shortUrl, loading } = shortUrlDetail;
|
||||
const { visits } = shortUrlVisits;
|
||||
const shortLink = shortUrl && shortUrl.shortUrl ? shortUrl.shortUrl : '';
|
||||
const longLink = shortUrl && shortUrl.longUrl ? shortUrl.longUrl : '';
|
||||
|
||||
const renderDate = () => (
|
||||
<span>
|
||||
<b id="created" className="short-url-visits-header__created-at">
|
||||
<Moment fromNow>{shortUrl.dateCreated}</Moment>
|
||||
</b>
|
||||
<UncontrolledTooltip placement="bottom" target="created">
|
||||
<Moment format="YYYY-MM-DD HH:mm">{shortUrl.dateCreated}</Moment>
|
||||
</UncontrolledTooltip>
|
||||
</span>
|
||||
);
|
||||
const visitsStatsTitle = (
|
||||
<React.Fragment>
|
||||
Visits for <ExternalLink href={shortLink} />
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
return (
|
||||
<VisitsHeader title={visitsStatsTitle} goBack={goBack} visits={visits} shortUrl={shortUrl}>
|
||||
<hr />
|
||||
<div>Created: {renderDate()}</div>
|
||||
<div>
|
||||
Long URL:{' '}
|
||||
{loading && <small>Loading...</small>}
|
||||
{!loading && <ExternalLink href={longLink} />}
|
||||
</div>
|
||||
</VisitsHeader>
|
||||
);
|
||||
};
|
||||
|
||||
ShortUrlVisitsHeader.propTypes = propTypes;
|
||||
|
||||
export default ShortUrlVisitsHeader;
|
||||
3
src/visits/ShortUrlVisitsHeader.scss
Normal file
3
src/visits/ShortUrlVisitsHeader.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
.short-url-visits-header__created-at {
|
||||
cursor: default;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { fromPairs, head, keys, pipe, prop, reverse, sortBy, splitEvery, toLower, toPairs, type, zipObj } from 'ramda';
|
||||
import SortingDropdown from '../utils/SortingDropdown';
|
||||
@@ -8,72 +8,78 @@ import { roundTen } from '../utils/helpers/numbers';
|
||||
import SimplePaginator from '../common/SimplePaginator';
|
||||
import GraphCard from './GraphCard';
|
||||
|
||||
const { max } = Math;
|
||||
const propTypes = {
|
||||
stats: PropTypes.object.isRequired,
|
||||
highlightedStats: PropTypes.object,
|
||||
highlightedLabel: PropTypes.string,
|
||||
title: PropTypes.string.isRequired,
|
||||
sortingItems: PropTypes.object.isRequired,
|
||||
extraHeaderContent: PropTypes.func,
|
||||
withPagination: PropTypes.bool,
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
|
||||
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 = {
|
||||
const SortableBarGraph = ({
|
||||
stats,
|
||||
highlightedStats,
|
||||
title,
|
||||
sortingItems,
|
||||
extraHeaderContent,
|
||||
withPagination = true,
|
||||
...rest
|
||||
}) => {
|
||||
const [ order, setOrder ] = useState({
|
||||
orderField: undefined,
|
||||
orderDir: undefined,
|
||||
currentPage: 1,
|
||||
itemsPerPage: Infinity,
|
||||
};
|
||||
});
|
||||
const [ currentPage, setCurrentPage ] = useState(1);
|
||||
const [ itemsPerPage, setItemsPerPage ] = useState(50);
|
||||
|
||||
getSortedPairsForStats(stats, sortingItems) {
|
||||
const getSortedPairsForStats = (stats, sortingItems) => {
|
||||
const pairs = toPairs(stats);
|
||||
const sortedPairs = !this.state.orderField ? pairs : sortBy(
|
||||
const sortedPairs = !order.orderField ? pairs : sortBy(
|
||||
pipe(
|
||||
prop(this.state.orderField === head(keys(sortingItems)) ? 0 : 1),
|
||||
prop(order.orderField === head(keys(sortingItems)) ? 0 : 1),
|
||||
toLowerIfString
|
||||
),
|
||||
pairs
|
||||
);
|
||||
|
||||
return !this.state.orderDir || this.state.orderDir === 'ASC' ? sortedPairs : reverse(sortedPairs);
|
||||
}
|
||||
|
||||
determineStats(stats, highlightedStats, sortingItems) {
|
||||
const sortedPairs = this.getSortedPairsForStats(stats, sortingItems);
|
||||
return !order.orderDir || order.orderDir === 'ASC' ? sortedPairs : reverse(sortedPairs);
|
||||
};
|
||||
const determineStats = (stats, highlightedStats, sortingItems) => {
|
||||
const sortedPairs = 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) {
|
||||
if (sortedPairs.length <= itemsPerPage) {
|
||||
return {
|
||||
currentPageStats: fromPairs(sortedPairs),
|
||||
currentPageHighlightedStats: sortedHighlightedPairs && fromPairs(sortedHighlightedPairs),
|
||||
};
|
||||
}
|
||||
|
||||
const pages = splitEvery(this.state.itemsPerPage, sortedPairs);
|
||||
const highlightedPages = sortedHighlightedPairs && splitEvery(this.state.itemsPerPage, sortedHighlightedPairs);
|
||||
const pages = splitEvery(itemsPerPage, sortedPairs);
|
||||
const highlightedPages = sortedHighlightedPairs && splitEvery(itemsPerPage, sortedHighlightedPairs);
|
||||
|
||||
return {
|
||||
currentPageStats: fromPairs(this.determineCurrentPagePairs(pages)),
|
||||
currentPageHighlightedStats: highlightedPages && fromPairs(this.determineCurrentPagePairs(highlightedPages)),
|
||||
pagination: this.renderPagination(pages.length),
|
||||
max: roundTen(max(...sortedPairs.map(pickValueFromPair))),
|
||||
currentPageStats: fromPairs(determineCurrentPagePairs(pages)),
|
||||
currentPageHighlightedStats: highlightedPages && fromPairs(determineCurrentPagePairs(highlightedPages)),
|
||||
pagination: renderPagination(pages.length),
|
||||
max: roundTen(Math.max(...sortedPairs.map(pickValueFromPair))),
|
||||
};
|
||||
}
|
||||
};
|
||||
const determineCurrentPagePairs = (pages) => {
|
||||
const page = pages[currentPage - 1];
|
||||
|
||||
determineCurrentPagePairs(pages) {
|
||||
const page = pages[this.state.currentPage - 1];
|
||||
|
||||
if (this.state.currentPage < pages.length) {
|
||||
if (currentPage < pages.length) {
|
||||
return page;
|
||||
}
|
||||
|
||||
@@ -81,72 +87,60 @@ export default class SortableBarGraph extends React.Component {
|
||||
|
||||
// Using the "hidden" key, the chart will just replace the label by an empty string
|
||||
return [ ...page, ...rangeOf(firstPageLength - page.length, (i) => [ `hidden_${i}`, 0 ]) ];
|
||||
}
|
||||
};
|
||||
const renderPagination = (pagesCount) =>
|
||||
<SimplePaginator currentPage={currentPage} pagesCount={pagesCount} setCurrentPage={setCurrentPage} />;
|
||||
|
||||
renderPagination(pagesCount) {
|
||||
const { currentPage } = this.state;
|
||||
const setCurrentPage = (currentPage) => this.setState({ currentPage });
|
||||
|
||||
return <SimplePaginator currentPage={currentPage} pagesCount={pagesCount} setCurrentPage={setCurrentPage} />;
|
||||
}
|
||||
|
||||
render() {
|
||||
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>
|
||||
{title}
|
||||
const { currentPageStats, currentPageHighlightedStats, pagination, max } = determineStats(
|
||||
stats,
|
||||
highlightedStats && keys(highlightedStats).length > 0 ? highlightedStats : undefined,
|
||||
sortingItems
|
||||
);
|
||||
const activeCities = keys(currentPageStats);
|
||||
const computeTitle = () => (
|
||||
<React.Fragment>
|
||||
{title}
|
||||
<div className="float-right">
|
||||
<SortingDropdown
|
||||
isButton={false}
|
||||
right
|
||||
items={sortingItems}
|
||||
orderField={order.orderField}
|
||||
orderDir={order.orderDir}
|
||||
onChange={(orderField, orderDir) => setOrder({ orderField, orderDir }) || setCurrentPage(1)}
|
||||
/>
|
||||
</div>
|
||||
{withPagination && keys(stats).length > 50 && (
|
||||
<div className="float-right">
|
||||
<SortingDropdown
|
||||
isButton={false}
|
||||
right
|
||||
items={sortingItems}
|
||||
orderField={this.state.orderField}
|
||||
orderDir={this.state.orderDir}
|
||||
onChange={(orderField, orderDir) => this.setState({ orderField, orderDir, currentPage: 1 })}
|
||||
<PaginationDropdown
|
||||
toggleClassName="btn-sm p-0 mr-3"
|
||||
ranges={[ 50, 100, 200, 500 ]}
|
||||
value={itemsPerPage}
|
||||
setValue={(itemsPerPage) => setItemsPerPage(itemsPerPage) || setCurrentPage(1)}
|
||||
/>
|
||||
</div>
|
||||
{withPagination && keys(stats).length > 50 && (
|
||||
<div className="float-right">
|
||||
<PaginationDropdown
|
||||
toggleClassName="btn-sm paddingless mr-3"
|
||||
ranges={[ 50, 100, 200, 500 ]}
|
||||
value={this.state.itemsPerPage}
|
||||
setValue={(itemsPerPage) => this.setState({ itemsPerPage, currentPage: 1 })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{extraHeaderContent && (
|
||||
<div className="float-right">
|
||||
{extraHeaderContent(pagination ? activeCities : undefined)}
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
)}
|
||||
{extraHeaderContent && (
|
||||
<div className="float-right">
|
||||
{extraHeaderContent(pagination ? activeCities : undefined)}
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
return (
|
||||
<GraphCard
|
||||
isBarChart
|
||||
title={computeTitle}
|
||||
stats={currentPageStats}
|
||||
highlightedStats={currentPageHighlightedStats}
|
||||
footer={pagination}
|
||||
max={max}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<GraphCard
|
||||
isBarChart
|
||||
title={computeTitle}
|
||||
stats={currentPageStats}
|
||||
highlightedStats={currentPageHighlightedStats}
|
||||
footer={pagination}
|
||||
max={max}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
SortableBarGraph.propTypes = propTypes;
|
||||
|
||||
export default SortableBarGraph;
|
||||
|
||||
64
src/visits/TagVisits.js
Normal file
64
src/visits/TagVisits.js
Normal file
@@ -0,0 +1,64 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { MercureInfoType } from '../mercure/reducers/mercureInfo';
|
||||
import { SettingsType } from '../settings/reducers/settings';
|
||||
import { bindToMercureTopic } from '../mercure/helpers';
|
||||
import { TagVisitsType } from './reducers/tagVisits';
|
||||
import TagVisitsHeader from './TagVisitsHeader';
|
||||
|
||||
const propTypes = {
|
||||
history: PropTypes.shape({
|
||||
goBack: PropTypes.func,
|
||||
}),
|
||||
match: PropTypes.shape({
|
||||
params: PropTypes.object,
|
||||
}),
|
||||
getTagVisits: PropTypes.func,
|
||||
tagVisits: TagVisitsType,
|
||||
cancelGetTagVisits: PropTypes.func,
|
||||
createNewVisit: PropTypes.func,
|
||||
loadMercureInfo: PropTypes.func,
|
||||
mercureInfo: MercureInfoType,
|
||||
settings: SettingsType,
|
||||
};
|
||||
|
||||
const TagVisits = (VisitsStats, colorGenerator) => {
|
||||
const TagVisitsComp = ({
|
||||
history,
|
||||
match,
|
||||
getTagVisits,
|
||||
tagVisits,
|
||||
cancelGetTagVisits,
|
||||
createNewVisit,
|
||||
loadMercureInfo,
|
||||
mercureInfo,
|
||||
settings: { realTimeUpdates },
|
||||
}) => {
|
||||
const { params } = match;
|
||||
const { tag } = params;
|
||||
const loadVisits = (dates) => getTagVisits(tag, dates);
|
||||
|
||||
useEffect(
|
||||
bindToMercureTopic(
|
||||
mercureInfo,
|
||||
realTimeUpdates,
|
||||
'https://shlink.io/new-visit',
|
||||
createNewVisit,
|
||||
loadMercureInfo
|
||||
),
|
||||
[ mercureInfo ],
|
||||
);
|
||||
|
||||
return (
|
||||
<VisitsStats getVisits={loadVisits} cancelGetVisits={cancelGetTagVisits} visitsInfo={tagVisits}>
|
||||
<TagVisitsHeader tagVisits={tagVisits} goBack={history.goBack} colorGenerator={colorGenerator} />
|
||||
</VisitsStats>
|
||||
);
|
||||
};
|
||||
|
||||
TagVisitsComp.propTypes = propTypes;
|
||||
|
||||
return TagVisitsComp;
|
||||
};
|
||||
|
||||
export default TagVisits;
|
||||
30
src/visits/TagVisitsHeader.js
Normal file
30
src/visits/TagVisitsHeader.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Tag from '../tags/helpers/Tag';
|
||||
import { colorGeneratorType } from '../utils/services/ColorGenerator';
|
||||
import VisitsHeader from './VisitsHeader';
|
||||
import { TagVisitsType } from './reducers/tagVisits';
|
||||
import './ShortUrlVisitsHeader.scss';
|
||||
|
||||
const propTypes = {
|
||||
tagVisits: TagVisitsType.isRequired,
|
||||
goBack: PropTypes.func.isRequired,
|
||||
colorGenerator: colorGeneratorType,
|
||||
};
|
||||
|
||||
const TagVisitsHeader = ({ tagVisits, goBack, colorGenerator }) => {
|
||||
const { visits, tag } = tagVisits;
|
||||
|
||||
const visitsStatsTitle = (
|
||||
<span className="d-flex align-items-center justify-content-center">
|
||||
<span className="mr-2">Visits for</span>
|
||||
<Tag text={tag} colorGenerator={colorGenerator} />
|
||||
</span>
|
||||
);
|
||||
|
||||
return <VisitsHeader title={visitsStatsTitle} goBack={goBack} visits={visits} />;
|
||||
};
|
||||
|
||||
TagVisitsHeader.propTypes = propTypes;
|
||||
|
||||
export default TagVisitsHeader;
|
||||
@@ -1,52 +1,44 @@
|
||||
import { Card, UncontrolledTooltip } from 'reactstrap';
|
||||
import Moment from 'react-moment';
|
||||
import { Button, Card } from 'reactstrap';
|
||||
import React from 'react';
|
||||
import { ExternalLink } from 'react-external-link';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faArrowLeft } from '@fortawesome/free-solid-svg-icons';
|
||||
import ShortUrlVisitsCount from '../short-urls/helpers/ShortUrlVisitsCount';
|
||||
import { shortUrlDetailType } from './reducers/shortUrlDetail';
|
||||
import { shortUrlVisitsType } from './reducers/shortUrlVisits';
|
||||
import './VisitsHeader.scss';
|
||||
import { shortUrlType } from '../short-urls/reducers/shortUrlsList';
|
||||
import { VisitType } from './types';
|
||||
|
||||
const propTypes = {
|
||||
shortUrlDetail: shortUrlDetailType.isRequired,
|
||||
shortUrlVisits: shortUrlVisitsType.isRequired,
|
||||
visits: PropTypes.arrayOf(VisitType).isRequired,
|
||||
goBack: PropTypes.func.isRequired,
|
||||
title: PropTypes.node.isRequired,
|
||||
children: PropTypes.node,
|
||||
shortUrl: shortUrlType,
|
||||
};
|
||||
|
||||
export default function VisitsHeader({ shortUrlDetail, shortUrlVisits }) {
|
||||
const { shortUrl, loading } = shortUrlDetail;
|
||||
const { visits } = shortUrlVisits;
|
||||
const shortLink = shortUrl && shortUrl.shortUrl ? shortUrl.shortUrl : '';
|
||||
const longLink = shortUrl && shortUrl.longUrl ? shortUrl.longUrl : '';
|
||||
const VisitsHeader = ({ visits, goBack, shortUrl, children, title }) => (
|
||||
<header>
|
||||
<Card className="bg-light" body>
|
||||
<h2 className="d-flex justify-content-between align-items-center mb-0">
|
||||
<Button color="link" size="lg" className="p-0 mr-3" onClick={goBack}>
|
||||
<FontAwesomeIcon icon={faArrowLeft} />
|
||||
</Button>
|
||||
<span className="text-center d-none d-sm-block">
|
||||
<small>{title}</small>
|
||||
</span>
|
||||
<span className="badge badge-main ml-3">
|
||||
Visits:{' '}
|
||||
<ShortUrlVisitsCount visitsCount={visits.length} shortUrl={shortUrl} />
|
||||
</span>
|
||||
</h2>
|
||||
<h3 className="text-center d-block d-sm-none mb-0 mt-3">
|
||||
<small>{title}</small>
|
||||
</h3>
|
||||
|
||||
const renderDate = () => (
|
||||
<span>
|
||||
<b id="created" className="visits-header__created-at"><Moment fromNow>{shortUrl.dateCreated}</Moment></b>
|
||||
<UncontrolledTooltip placement="bottom" target="created">
|
||||
<Moment format="YYYY-MM-DD HH:mm">{shortUrl.dateCreated}</Moment>
|
||||
</UncontrolledTooltip>
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<header>
|
||||
<Card className="bg-light" body>
|
||||
<h2>
|
||||
<span className="badge badge-main float-right">
|
||||
Visits:{' '}
|
||||
<ShortUrlVisitsCount visitsCount={visits.length} shortUrl={shortUrl} />
|
||||
</span>
|
||||
Visit stats for <ExternalLink href={shortLink} />
|
||||
</h2>
|
||||
<hr />
|
||||
<div>Created: {renderDate()}</div>
|
||||
<div>
|
||||
Long URL:{' '}
|
||||
{loading && <small>Loading...</small>}
|
||||
{!loading && <ExternalLink href={longLink} />}
|
||||
</div>
|
||||
</Card>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
{children && <div className="mt-md-2">{children}</div>}
|
||||
</Card>
|
||||
</header>
|
||||
);
|
||||
|
||||
VisitsHeader.propTypes = propTypes;
|
||||
|
||||
export default VisitsHeader;
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
.visits-header__created-at {
|
||||
cursor: default;
|
||||
}
|
||||
246
src/visits/VisitsStats.js
Normal file
246
src/visits/VisitsStats.js
Normal file
@@ -0,0 +1,246 @@
|
||||
import { isEmpty, propEq, values } from 'ramda';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Button, Card, Collapse, Progress } from 'reactstrap';
|
||||
import PropTypes from 'prop-types';
|
||||
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 Message from '../utils/Message';
|
||||
import { formatDate } from '../utils/helpers/date';
|
||||
import { useToggle } from '../utils/helpers/hooks';
|
||||
import SortableBarGraph from './SortableBarGraph';
|
||||
import GraphCard from './GraphCard';
|
||||
import LineChartCard from './helpers/LineChartCard';
|
||||
import VisitsTable from './VisitsTable';
|
||||
import { VisitsInfoType } from './types';
|
||||
|
||||
const propTypes = {
|
||||
children: PropTypes.node,
|
||||
getVisits: PropTypes.func,
|
||||
visitsInfo: VisitsInfoType,
|
||||
cancelGetVisits: PropTypes.func,
|
||||
matchMedia: PropTypes.func,
|
||||
};
|
||||
|
||||
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 VisitsStats = ({ processStatsFromVisits, normalizeVisits }, OpenMapModalBtn) => {
|
||||
const VisitsStatsComp = ({ children, visitsInfo, getVisits, cancelGetVisits, 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 [ highlightedLabel, setHighlightedLabel ] = 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([]);
|
||||
setHighlightedLabel(undefined);
|
||||
selectedBar = undefined;
|
||||
} else {
|
||||
setHighlightedVisits(normalizedVisits.filter(propEq(prop, value)));
|
||||
setHighlightedLabel(value);
|
||||
selectedBar = newSelectedBar;
|
||||
}
|
||||
};
|
||||
|
||||
const { visits, loading, loadingLarge, error, progress } = visitsInfo;
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
determineIsMobileDevice();
|
||||
window.addEventListener('resize', determineIsMobileDevice);
|
||||
|
||||
return () => {
|
||||
cancelGetVisits();
|
||||
window.removeEventListener('resize', determineIsMobileDevice);
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
getVisits({ startDate: format(startDate), endDate: format(endDate) });
|
||||
}, [ startDate, endDate ]);
|
||||
|
||||
const renderVisitsContent = () => {
|
||||
if (loadingLarge) {
|
||||
return (
|
||||
<Message loading>
|
||||
This is going to take a while... :S
|
||||
<Progress value={progress} striped={progress === 100} className="mt-3" />
|
||||
</Message>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <Message loading />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className="mt-4" body inverse color="danger">
|
||||
An error occurred while loading visits :(
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (isEmpty(visits)) {
|
||||
return <Message>There are no visits matching current filter :(</Message>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-12 mt-4">
|
||||
<LineChartCard
|
||||
title="Visits during time"
|
||||
visits={visits}
|
||||
highlightedVisits={highlightedVisits}
|
||||
highlightedLabel={highlightedLabel}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-xl-4 col-lg-6 mt-4">
|
||||
<GraphCard title="Operating systems" stats={os} />
|
||||
</div>
|
||||
<div className="col-xl-4 col-lg-6 mt-4">
|
||||
<GraphCard title="Browsers" stats={browsers} />
|
||||
</div>
|
||||
<div className="col-xl-4 mt-4">
|
||||
<SortableBarGraph
|
||||
title="Referrers"
|
||||
stats={referrers}
|
||||
withPagination={false}
|
||||
highlightedStats={highlightedVisitsToStats(highlightedVisits, 'referer')}
|
||||
highlightedLabel={highlightedLabel}
|
||||
sortingItems={{
|
||||
name: 'Referrer name',
|
||||
amount: 'Visits amount',
|
||||
}}
|
||||
onClick={highlightVisitsForProp('referer')}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-lg-6 mt-4">
|
||||
<SortableBarGraph
|
||||
title="Countries"
|
||||
stats={countries}
|
||||
highlightedStats={highlightedVisitsToStats(highlightedVisits, 'country')}
|
||||
highlightedLabel={highlightedLabel}
|
||||
sortingItems={{
|
||||
name: 'Country name',
|
||||
amount: 'Visits amount',
|
||||
}}
|
||||
onClick={highlightVisitsForProp('country')}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-lg-6 mt-4">
|
||||
<SortableBarGraph
|
||||
title="Cities"
|
||||
stats={cities}
|
||||
highlightedStats={highlightedVisitsToStats(highlightedVisits, 'city')}
|
||||
highlightedLabel={highlightedLabel}
|
||||
extraHeaderContent={(activeCities) =>
|
||||
mapLocations.length > 0 &&
|
||||
<OpenMapModalBtn modalTitle="Cities" locations={mapLocations} activeCities={activeCities} />
|
||||
}
|
||||
sortingItems={{
|
||||
name: 'City name',
|
||||
amount: 'Visits amount',
|
||||
}}
|
||||
onClick={highlightVisitsForProp('city')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{children}
|
||||
|
||||
<section className="mt-4">
|
||||
<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>
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
VisitsStatsComp.propTypes = propTypes;
|
||||
|
||||
return VisitsStatsComp;
|
||||
};
|
||||
|
||||
export default VisitsStats;
|
||||
@@ -88,7 +88,7 @@ const VisitsTable = ({
|
||||
}, [ searchTerm ]);
|
||||
|
||||
return (
|
||||
<table className="table table-striped table-bordered table-hover table-sm visits-table">
|
||||
<table className="table table-striped table-bordered table-hover table-sm table-responsive-sm visits-table">
|
||||
<thead className="visits-table__header">
|
||||
<tr>
|
||||
<th
|
||||
|
||||
@@ -12,8 +12,10 @@
|
||||
|
||||
@include sticky-cell();
|
||||
|
||||
&.visits-table__sticky {
|
||||
top: $headerHeight - 2px;
|
||||
@media (min-width: $mdMin) {
|
||||
&.visits-table__sticky {
|
||||
top: $headerHeight - 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
170
src/visits/helpers/LineChartCard.js
Normal file
170
src/visits/helpers/LineChartCard.js
Normal file
@@ -0,0 +1,170 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
UncontrolledDropdown,
|
||||
DropdownToggle,
|
||||
DropdownMenu,
|
||||
DropdownItem,
|
||||
} from 'reactstrap';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import { reverse } from 'ramda';
|
||||
import moment from 'moment';
|
||||
import { VisitType } from '../types';
|
||||
import { fillTheGaps } from '../../utils/helpers/visits';
|
||||
import './LineChartCard.scss';
|
||||
import { useToggle } from '../../utils/helpers/hooks';
|
||||
import { rangeOf } from '../../utils/utils';
|
||||
import Checkbox from '../../utils/Checkbox';
|
||||
|
||||
const propTypes = {
|
||||
title: PropTypes.string,
|
||||
highlightedLabel: PropTypes.string,
|
||||
visits: PropTypes.arrayOf(VisitType),
|
||||
highlightedVisits: PropTypes.arrayOf(VisitType),
|
||||
};
|
||||
|
||||
const STEPS_MAP = {
|
||||
monthly: 'Month',
|
||||
weekly: 'Week',
|
||||
daily: 'Day',
|
||||
hourly: 'Hour',
|
||||
};
|
||||
|
||||
const STEP_TO_DATE_UNIT_MAP = {
|
||||
hourly: 'hour',
|
||||
daily: 'day',
|
||||
weekly: 'week',
|
||||
monthly: 'month',
|
||||
};
|
||||
|
||||
const STEP_TO_DATE_FORMAT = {
|
||||
hourly: (date) => moment(date).format('YYYY-MM-DD HH:00'),
|
||||
daily: (date) => moment(date).format('YYYY-MM-DD'),
|
||||
weekly(date) {
|
||||
const firstWeekDay = moment(date).isoWeekday(1).format('YYYY-MM-DD');
|
||||
const lastWeekDay = moment(date).isoWeekday(7).format('YYYY-MM-DD');
|
||||
|
||||
return `${firstWeekDay} - ${lastWeekDay}`;
|
||||
},
|
||||
monthly: (date) => moment(date).format('YYYY-MM'),
|
||||
};
|
||||
|
||||
const groupVisitsByStep = (step, visits) => visits.reduce((acc, visit) => {
|
||||
const key = STEP_TO_DATE_FORMAT[step](visit.date);
|
||||
|
||||
acc[key] = acc[key] ? acc[key] + 1 : 1;
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const generateLabels = (step, visits) => {
|
||||
const unit = STEP_TO_DATE_UNIT_MAP[step];
|
||||
const formatter = STEP_TO_DATE_FORMAT[step];
|
||||
const newerDate = moment(visits[0].date);
|
||||
const oldestDate = moment(visits[visits.length - 1].date);
|
||||
const size = newerDate.diff(oldestDate, unit);
|
||||
|
||||
return [
|
||||
formatter(oldestDate),
|
||||
...rangeOf(size, () => formatter(oldestDate.add(1, unit))),
|
||||
];
|
||||
};
|
||||
|
||||
const generateLabelsAndGroupedVisits = (visits, groupedVisitsWithGaps, step, skipNoElements) => {
|
||||
if (skipNoElements) {
|
||||
return [ Object.keys(groupedVisitsWithGaps), groupedVisitsWithGaps ];
|
||||
}
|
||||
|
||||
const labels = generateLabels(step, visits);
|
||||
|
||||
return [ labels, fillTheGaps(groupedVisitsWithGaps, labels) ];
|
||||
};
|
||||
|
||||
const generateDataset = (stats, label, color) => ({
|
||||
label,
|
||||
data: Object.values(stats),
|
||||
fill: false,
|
||||
lineTension: 0.2,
|
||||
borderColor: color,
|
||||
backgroundColor: color,
|
||||
});
|
||||
|
||||
const LineChartCard = ({ title, visits, highlightedVisits, highlightedLabel = 'Selected' }) => {
|
||||
const [ step, setStep ] = useState('monthly');
|
||||
const [ skipNoVisits, toggleSkipNoVisits ] = useToggle(true);
|
||||
|
||||
const groupedVisitsWithGaps = useMemo(() => groupVisitsByStep(step, reverse(visits)), [ step, visits ]);
|
||||
const [ labels, groupedVisits ] = useMemo(
|
||||
() => generateLabelsAndGroupedVisits(visits, groupedVisitsWithGaps, step, skipNoVisits),
|
||||
[ visits, step, skipNoVisits ]
|
||||
);
|
||||
const groupedHighlighted = useMemo(
|
||||
() => fillTheGaps(groupVisitsByStep(step, reverse(highlightedVisits)), labels),
|
||||
[ highlightedVisits, step, labels ]
|
||||
);
|
||||
|
||||
const data = {
|
||||
labels,
|
||||
datasets: [
|
||||
generateDataset(groupedVisits, 'Visits', '#4696e5'),
|
||||
highlightedVisits.length > 0 && generateDataset(groupedHighlighted, highlightedLabel, '#F77F28'),
|
||||
].filter(Boolean),
|
||||
};
|
||||
const options = {
|
||||
maintainAspectRatio: false,
|
||||
legend: { display: false },
|
||||
scales: {
|
||||
yAxes: [
|
||||
{
|
||||
ticks: { beginAtZero: true, precision: 0 },
|
||||
},
|
||||
],
|
||||
xAxes: [
|
||||
{
|
||||
scaleLabel: { display: true, labelString: STEPS_MAP[step] },
|
||||
},
|
||||
],
|
||||
},
|
||||
tooltips: {
|
||||
intersect: false,
|
||||
axis: 'x',
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
{title}
|
||||
<div className="float-right">
|
||||
<UncontrolledDropdown>
|
||||
<DropdownToggle caret color="link" className="btn-sm p-0">
|
||||
Group by
|
||||
</DropdownToggle>
|
||||
<DropdownMenu right>
|
||||
{Object.entries(STEPS_MAP).map(([ value, menuText ]) => (
|
||||
<DropdownItem key={value} active={step === value} onClick={() => setStep(value)}>
|
||||
{menuText}
|
||||
</DropdownItem>
|
||||
))}
|
||||
</DropdownMenu>
|
||||
</UncontrolledDropdown>
|
||||
</div>
|
||||
<div className="float-right mr-2">
|
||||
<Checkbox checked={skipNoVisits} onChange={toggleSkipNoVisits}>
|
||||
<small>Skip dates with no visits</small>
|
||||
</Checkbox>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody className="line-chart-card__body">
|
||||
<Line data={data} options={options} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
LineChartCard.propTypes = propTypes;
|
||||
|
||||
export default LineChartCard;
|
||||
9
src/visits/helpers/LineChartCard.scss
Normal file
9
src/visits/helpers/LineChartCard.scss
Normal file
@@ -0,0 +1,9 @@
|
||||
@import '../../utils/base';
|
||||
|
||||
.line-chart-card__body canvas {
|
||||
height: 300px !important;
|
||||
|
||||
@media (min-width: $mdMin) {
|
||||
height: 350px !important;
|
||||
}
|
||||
}
|
||||
60
src/visits/reducers/common.js
Normal file
60
src/visits/reducers/common.js
Normal file
@@ -0,0 +1,60 @@
|
||||
import { flatten, prop, range, splitEvery } from 'ramda';
|
||||
|
||||
const ITEMS_PER_PAGE = 5000;
|
||||
const PARALLEL_REQUESTS_COUNT = 4;
|
||||
const PARALLEL_STARTING_PAGE = 2;
|
||||
|
||||
const isLastPage = ({ currentPage, pagesCount }) => currentPage >= pagesCount;
|
||||
const calcProgress = (total, current) => current * 100 / total;
|
||||
|
||||
export const getVisitsWithLoader = async (visitsLoader, extraFinishActionData, actionMap, dispatch, getState) => {
|
||||
dispatch({ type: actionMap.start });
|
||||
|
||||
const loadVisits = async (page = 1) => {
|
||||
const { pagination, data } = await visitsLoader(page, ITEMS_PER_PAGE);
|
||||
|
||||
// If pagination was not returned, then this is an old shlink version. Just return data
|
||||
if (!pagination || isLastPage(pagination)) {
|
||||
return data;
|
||||
}
|
||||
|
||||
// If there are more pages, make requests in blocks of 4
|
||||
const pagesRange = range(PARALLEL_STARTING_PAGE, pagination.pagesCount + 1);
|
||||
const pagesBlocks = splitEvery(PARALLEL_REQUESTS_COUNT, pagesRange);
|
||||
|
||||
if (pagination.pagesCount - 1 > PARALLEL_REQUESTS_COUNT) {
|
||||
dispatch({ type: actionMap.large });
|
||||
}
|
||||
|
||||
return data.concat(await loadPagesBlocks(pagesBlocks));
|
||||
};
|
||||
|
||||
const loadPagesBlocks = async (pagesBlocks, index = 0) => {
|
||||
const { shortUrlVisits: { cancelLoad } } = getState();
|
||||
|
||||
if (cancelLoad) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = await loadVisitsInParallel(pagesBlocks[index]);
|
||||
|
||||
dispatch({ type: actionMap.progress, progress: calcProgress(pagesBlocks.length, index + PARALLEL_STARTING_PAGE) });
|
||||
|
||||
if (index < pagesBlocks.length - 1) {
|
||||
return data.concat(await loadPagesBlocks(pagesBlocks, index + 1));
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const loadVisitsInParallel = (pages) =>
|
||||
Promise.all(pages.map((page) => visitsLoader(page, ITEMS_PER_PAGE).then(prop('data')))).then(flatten);
|
||||
|
||||
try {
|
||||
const visits = await loadVisits();
|
||||
|
||||
dispatch({ ...extraFinishActionData, visits, type: actionMap.finish });
|
||||
} catch (e) {
|
||||
dispatch({ type: actionMap.error });
|
||||
}
|
||||
};
|
||||
@@ -21,9 +21,9 @@ const initialState = {
|
||||
};
|
||||
|
||||
export default handleActions({
|
||||
[GET_SHORT_URL_DETAIL_START]: (state) => ({ ...state, loading: true }),
|
||||
[GET_SHORT_URL_DETAIL_ERROR]: (state) => ({ ...state, loading: false, error: true }),
|
||||
[GET_SHORT_URL_DETAIL]: (state, { shortUrl }) => ({ shortUrl, loading: false, error: false }),
|
||||
[GET_SHORT_URL_DETAIL_START]: () => ({ ...initialState, loading: true }),
|
||||
[GET_SHORT_URL_DETAIL_ERROR]: () => ({ ...initialState, loading: false, error: true }),
|
||||
[GET_SHORT_URL_DETAIL]: (state, { shortUrl }) => ({ ...initialState, shortUrl }),
|
||||
}, initialState);
|
||||
|
||||
export const getShortUrlDetail = (buildShlinkApiClient) => (shortCode, domain) => async (dispatch, getState) => {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { createAction, handleActions } from 'redux-actions';
|
||||
import PropTypes from 'prop-types';
|
||||
import { flatten, prop, range, splitEvery } from 'ramda';
|
||||
import { shortUrlMatches } from '../../short-urls/helpers';
|
||||
import { VisitType } from '../types';
|
||||
import { getVisitsWithLoader } from './common';
|
||||
import { CREATE_VISIT } from './visitCreation';
|
||||
|
||||
/* eslint-disable padding-line-between-statements */
|
||||
export const GET_SHORT_URL_VISITS_START = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_START';
|
||||
@@ -8,120 +11,66 @@ export const GET_SHORT_URL_VISITS_ERROR = 'shlink/shortUrlVisits/GET_SHORT_URL_V
|
||||
export const GET_SHORT_URL_VISITS = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS';
|
||||
export const GET_SHORT_URL_VISITS_LARGE = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_LARGE';
|
||||
export const GET_SHORT_URL_VISITS_CANCEL = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_CANCEL';
|
||||
export const GET_SHORT_URL_VISITS_PROGRESS_CHANGED = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_PROGRESS_CHANGED';
|
||||
/* 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.arrayOf(visitType),
|
||||
export const shortUrlVisitsType = PropTypes.shape({ // TODO Should extend from VisitInfoType
|
||||
visits: PropTypes.arrayOf(VisitType),
|
||||
shortCode: PropTypes.string,
|
||||
domain: PropTypes.string,
|
||||
loading: PropTypes.bool,
|
||||
loadingLarge: PropTypes.bool,
|
||||
error: PropTypes.bool,
|
||||
progress: PropTypes.number,
|
||||
});
|
||||
|
||||
const initialState = {
|
||||
visits: [],
|
||||
shortCode: '',
|
||||
domain: undefined,
|
||||
loading: false,
|
||||
loadingLarge: false,
|
||||
error: false,
|
||||
cancelLoad: false,
|
||||
progress: 0,
|
||||
};
|
||||
|
||||
export default handleActions({
|
||||
[GET_SHORT_URL_VISITS_START]: (state) => ({
|
||||
...state,
|
||||
loading: true,
|
||||
loadingLarge: false,
|
||||
cancelLoad: false,
|
||||
}),
|
||||
[GET_SHORT_URL_VISITS_ERROR]: (state) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
loadingLarge: false,
|
||||
error: true,
|
||||
cancelLoad: false,
|
||||
}),
|
||||
[GET_SHORT_URL_VISITS]: (state, { visits }) => ({
|
||||
[GET_SHORT_URL_VISITS_START]: () => ({ ...initialState, loading: true }),
|
||||
[GET_SHORT_URL_VISITS_ERROR]: () => ({ ...initialState, error: true }),
|
||||
[GET_SHORT_URL_VISITS]: (state, { visits, shortCode, domain }) => ({
|
||||
...initialState,
|
||||
visits,
|
||||
loading: false,
|
||||
loadingLarge: false,
|
||||
error: false,
|
||||
cancelLoad: false,
|
||||
shortCode,
|
||||
domain,
|
||||
}),
|
||||
[GET_SHORT_URL_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }),
|
||||
[GET_SHORT_URL_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }),
|
||||
[GET_SHORT_URL_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }),
|
||||
[CREATE_VISIT]: (state, { shortUrl, visit }) => { // eslint-disable-line object-shorthand
|
||||
const { shortCode, domain, visits } = state;
|
||||
|
||||
if (!shortUrlMatches(shortUrl, shortCode, domain)) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return { ...state, visits: [ ...visits, visit ] };
|
||||
},
|
||||
}, initialState);
|
||||
|
||||
export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, query) => async (dispatch, getState) => {
|
||||
dispatch({ type: GET_SHORT_URL_VISITS_START });
|
||||
export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, query = {}) => (dispatch, getState) => {
|
||||
const { getShortUrlVisits } = buildShlinkApiClient(getState);
|
||||
const itemsPerPage = 5000;
|
||||
const isLastPage = ({ currentPage, pagesCount }) => currentPage >= pagesCount;
|
||||
|
||||
const loadVisits = async (page = 1) => {
|
||||
const { pagination, data } = await getShortUrlVisits(shortCode, { ...query, page, itemsPerPage });
|
||||
|
||||
// If pagination was not returned, then this is an older shlink version. Just return data
|
||||
if (!pagination || isLastPage(pagination)) {
|
||||
return data;
|
||||
}
|
||||
|
||||
// If there are more pages, make requests in blocks of 4
|
||||
const parallelRequestsCount = 4;
|
||||
const parallelStartingPage = 2;
|
||||
const pagesRange = range(parallelStartingPage, pagination.pagesCount + 1);
|
||||
const pagesBlocks = splitEvery(parallelRequestsCount, pagesRange);
|
||||
|
||||
if (pagination.pagesCount - 1 > parallelRequestsCount) {
|
||||
dispatch({ type: GET_SHORT_URL_VISITS_LARGE });
|
||||
}
|
||||
|
||||
return data.concat(await loadPagesBlocks(pagesBlocks));
|
||||
const visitsLoader = (page, itemsPerPage) => getShortUrlVisits(shortCode, { ...query, page, itemsPerPage });
|
||||
const extraFinishActionData = { shortCode, domain: query.domain };
|
||||
const actionMap = {
|
||||
start: GET_SHORT_URL_VISITS_START,
|
||||
large: GET_SHORT_URL_VISITS_LARGE,
|
||||
finish: GET_SHORT_URL_VISITS,
|
||||
error: GET_SHORT_URL_VISITS_ERROR,
|
||||
progress: GET_SHORT_URL_VISITS_PROGRESS_CHANGED,
|
||||
};
|
||||
|
||||
const loadPagesBlocks = async (pagesBlocks, index = 0) => {
|
||||
const { shortUrlVisits: { cancelLoad } } = getState();
|
||||
|
||||
if (cancelLoad) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = await loadVisitsInParallel(pagesBlocks[index]);
|
||||
|
||||
if (index < pagesBlocks.length - 1) {
|
||||
return data.concat(await loadPagesBlocks(pagesBlocks, index + 1));
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const loadVisitsInParallel = (pages) =>
|
||||
Promise.all(pages.map(
|
||||
(page) =>
|
||||
getShortUrlVisits(shortCode, { ...query, page, itemsPerPage })
|
||||
.then(prop('data'))
|
||||
)).then(flatten);
|
||||
|
||||
try {
|
||||
const visits = await loadVisits();
|
||||
|
||||
dispatch({ visits, type: GET_SHORT_URL_VISITS });
|
||||
} catch (e) {
|
||||
dispatch({ type: GET_SHORT_URL_VISITS_ERROR });
|
||||
}
|
||||
return getVisitsWithLoader(visitsLoader, extraFinishActionData, actionMap, dispatch, getState);
|
||||
};
|
||||
|
||||
export const cancelGetShortUrlVisits = createAction(GET_SHORT_URL_VISITS_CANCEL);
|
||||
|
||||
68
src/visits/reducers/tagVisits.js
Normal file
68
src/visits/reducers/tagVisits.js
Normal file
@@ -0,0 +1,68 @@
|
||||
import { createAction, handleActions } from 'redux-actions';
|
||||
import PropTypes from 'prop-types';
|
||||
import { VisitType } from '../types';
|
||||
import { getVisitsWithLoader } from './common';
|
||||
import { CREATE_VISIT } from './visitCreation';
|
||||
|
||||
/* eslint-disable padding-line-between-statements */
|
||||
export const GET_TAG_VISITS_START = 'shlink/tagVisits/GET_TAG_VISITS_START';
|
||||
export const GET_TAG_VISITS_ERROR = 'shlink/tagVisits/GET_TAG_VISITS_ERROR';
|
||||
export const GET_TAG_VISITS = 'shlink/tagVisits/GET_TAG_VISITS';
|
||||
export const GET_TAG_VISITS_LARGE = 'shlink/tagVisits/GET_TAG_VISITS_LARGE';
|
||||
export const GET_TAG_VISITS_CANCEL = 'shlink/tagVisits/GET_TAG_VISITS_CANCEL';
|
||||
export const GET_TAG_VISITS_PROGRESS_CHANGED = 'shlink/tagVisits/GET_TAG_VISITS_PROGRESS_CHANGED';
|
||||
/* eslint-enable padding-line-between-statements */
|
||||
|
||||
export const TagVisitsType = PropTypes.shape({ // TODO Should extend from VisitInfoType
|
||||
visits: PropTypes.arrayOf(VisitType),
|
||||
tag: PropTypes.string,
|
||||
loading: PropTypes.bool,
|
||||
loadingLarge: PropTypes.bool,
|
||||
error: PropTypes.bool,
|
||||
progress: PropTypes.number,
|
||||
});
|
||||
|
||||
const initialState = {
|
||||
visits: [],
|
||||
tag: '',
|
||||
loading: false,
|
||||
loadingLarge: false,
|
||||
error: false,
|
||||
cancelLoad: false,
|
||||
progress: 0,
|
||||
};
|
||||
|
||||
export default handleActions({
|
||||
[GET_TAG_VISITS_START]: () => ({ ...initialState, loading: true }),
|
||||
[GET_TAG_VISITS_ERROR]: () => ({ ...initialState, error: true }),
|
||||
[GET_TAG_VISITS]: (state, { visits, tag }) => ({ ...initialState, visits, tag }),
|
||||
[GET_TAG_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }),
|
||||
[GET_TAG_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }),
|
||||
[GET_TAG_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }),
|
||||
[CREATE_VISIT]: (state, { shortUrl, visit }) => { // eslint-disable-line object-shorthand
|
||||
const { tag, visits } = state;
|
||||
|
||||
if (!shortUrl.tags.includes(tag)) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return { ...state, visits: [ ...visits, visit ] };
|
||||
},
|
||||
}, initialState);
|
||||
|
||||
export const getTagVisits = (buildShlinkApiClient) => (tag, query = {}) => (dispatch, getState) => {
|
||||
const { getTagVisits } = buildShlinkApiClient(getState);
|
||||
const visitsLoader = (page, itemsPerPage) => getTagVisits(tag, { ...query, page, itemsPerPage });
|
||||
const extraFinishActionData = { tag };
|
||||
const actionMap = {
|
||||
start: GET_TAG_VISITS_START,
|
||||
large: GET_TAG_VISITS_LARGE,
|
||||
finish: GET_TAG_VISITS,
|
||||
error: GET_TAG_VISITS_ERROR,
|
||||
progress: GET_TAG_VISITS_PROGRESS_CHANGED,
|
||||
};
|
||||
|
||||
return getVisitsWithLoader(visitsLoader, extraFinishActionData, actionMap, dispatch, getState);
|
||||
};
|
||||
|
||||
export const cancelGetTagVisits = createAction(GET_TAG_VISITS_CANCEL);
|
||||
3
src/visits/reducers/visitCreation.js
Normal file
3
src/visits/reducers/visitCreation.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export const CREATE_VISIT = 'shlink/visitCreation/CREATE_VISIT';
|
||||
|
||||
export const createNewVisit = ({ shortUrl, visit }) => ({ shortUrl, visit, type: CREATE_VISIT });
|
||||
@@ -3,16 +3,26 @@ import { cancelGetShortUrlVisits, getShortUrlVisits } from '../reducers/shortUrl
|
||||
import { getShortUrlDetail } from '../reducers/shortUrlDetail';
|
||||
import OpenMapModalBtn from '../helpers/OpenMapModalBtn';
|
||||
import MapModal from '../helpers/MapModal';
|
||||
import VisitsStats from '../VisitsStats';
|
||||
import { createNewVisit } from '../reducers/visitCreation';
|
||||
import { cancelGetTagVisits, getTagVisits } from '../reducers/tagVisits';
|
||||
import TagVisits from '../TagVisits';
|
||||
import * as visitsParser from './VisitsParser';
|
||||
|
||||
const provideServices = (bottle, connect) => {
|
||||
// Components
|
||||
bottle.serviceFactory('OpenMapModalBtn', OpenMapModalBtn, 'MapModal');
|
||||
bottle.serviceFactory('MapModal', () => MapModal);
|
||||
bottle.serviceFactory('ShortUrlVisits', ShortUrlVisits, 'VisitsParser', 'OpenMapModalBtn');
|
||||
bottle.serviceFactory('VisitsStats', VisitsStats, 'VisitsParser', 'OpenMapModalBtn');
|
||||
bottle.serviceFactory('ShortUrlVisits', ShortUrlVisits, 'VisitsStats');
|
||||
bottle.decorator('ShortUrlVisits', connect(
|
||||
[ 'shortUrlVisits', 'shortUrlDetail' ],
|
||||
[ 'getShortUrlVisits', 'getShortUrlDetail', 'cancelGetShortUrlVisits' ]
|
||||
[ 'shortUrlVisits', 'shortUrlDetail', 'mercureInfo', 'settings' ],
|
||||
[ 'getShortUrlVisits', 'getShortUrlDetail', 'cancelGetShortUrlVisits', 'createNewVisit', 'loadMercureInfo' ]
|
||||
));
|
||||
bottle.serviceFactory('TagVisits', TagVisits, 'VisitsStats', 'ColorGenerator');
|
||||
bottle.decorator('TagVisits', connect(
|
||||
[ 'tagVisits', 'mercureInfo', 'settings' ],
|
||||
[ 'getTagVisits', 'cancelGetTagVisits', 'createNewVisit', 'loadMercureInfo' ]
|
||||
));
|
||||
|
||||
// Services
|
||||
@@ -22,6 +32,11 @@ const provideServices = (bottle, connect) => {
|
||||
bottle.serviceFactory('getShortUrlVisits', getShortUrlVisits, 'buildShlinkApiClient');
|
||||
bottle.serviceFactory('getShortUrlDetail', getShortUrlDetail, 'buildShlinkApiClient');
|
||||
bottle.serviceFactory('cancelGetShortUrlVisits', () => cancelGetShortUrlVisits);
|
||||
|
||||
bottle.serviceFactory('getTagVisits', getTagVisits, 'buildShlinkApiClient');
|
||||
bottle.serviceFactory('cancelGetTagVisits', () => cancelGetTagVisits);
|
||||
|
||||
bottle.serviceFactory('createNewVisit', () => createNewVisit);
|
||||
};
|
||||
|
||||
export default provideServices;
|
||||
|
||||
25
src/visits/types/index.js
Normal file
25
src/visits/types/index.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
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 VisitsInfoType = PropTypes.shape({
|
||||
visits: PropTypes.arrayOf(VisitType),
|
||||
loading: PropTypes.bool,
|
||||
loadingLarge: PropTypes.bool,
|
||||
error: PropTypes.bool,
|
||||
progress: PropTypes.number,
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
const jestConfig = require(`${__dirname}/jest.config.js`);
|
||||
|
||||
module.exports = (config) => config.set({
|
||||
module.exports = {
|
||||
mutate: jestConfig.collectCoverageFrom,
|
||||
mutator: 'javascript',
|
||||
testRunner: 'jest',
|
||||
@@ -19,4 +19,4 @@ module.exports = (config) => config.set({
|
||||
clearTextReporter: {
|
||||
logTests: false,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -21,6 +21,7 @@ describe('<App />', () => {
|
||||
const routes = wrapper.find(Route);
|
||||
const expectedPaths = [
|
||||
'/',
|
||||
'/settings',
|
||||
'/server/create',
|
||||
'/server/:serverId/edit',
|
||||
'/server/:serverId',
|
||||
|
||||
@@ -6,7 +6,7 @@ describe('<Home />', () => {
|
||||
let wrapped;
|
||||
const defaultProps = {
|
||||
resetSelectedServer: jest.fn(),
|
||||
servers: { loading: false, list: {} },
|
||||
servers: {},
|
||||
};
|
||||
const createComponent = (props) => {
|
||||
const actualProps = { ...defaultProps, ...props };
|
||||
@@ -24,20 +24,12 @@ describe('<Home />', () => {
|
||||
expect(wrapped.find('Link')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('shows message when loading servers', () => {
|
||||
const wrapped = createComponent({ servers: { loading: true } });
|
||||
const span = wrapped.find('span');
|
||||
|
||||
expect(span).toHaveLength(1);
|
||||
expect(span.text()).toContain('Trying to load 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 } });
|
||||
it('asks to select a server when servers exist', () => {
|
||||
const servers = {
|
||||
1: { name: 'foo', id: '1' },
|
||||
2: { name: 'bar', id: '2' },
|
||||
};
|
||||
const wrapped = createComponent({ servers });
|
||||
const span = wrapped.find('span');
|
||||
|
||||
expect(span).toHaveLength(1);
|
||||
|
||||
@@ -20,9 +20,4 @@ describe('<ScrollToTop />', () => {
|
||||
});
|
||||
|
||||
it('just renders children', () => expect(wrapper.text()).toEqual('Foobar'));
|
||||
|
||||
it('scrolls to top when location changes', () => {
|
||||
wrapper.instance().componentDidUpdate({ location: { href: 'bar' } });
|
||||
expect(window.scrollTo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
58
test/mercure/helpers/index.test.js
Normal file
58
test/mercure/helpers/index.test.js
Normal file
@@ -0,0 +1,58 @@
|
||||
import { EventSourcePolyfill as EventSource } from 'event-source-polyfill';
|
||||
import { bindToMercureTopic } from '../../../src/mercure/helpers';
|
||||
|
||||
jest.mock('event-source-polyfill');
|
||||
|
||||
describe('helpers', () => {
|
||||
afterEach(jest.resetAllMocks);
|
||||
|
||||
describe('bindToMercureTopic', () => {
|
||||
const onMessage = jest.fn();
|
||||
const onTokenExpired = jest.fn();
|
||||
|
||||
it.each([
|
||||
[{ loading: true, error: false }, { enabled: true }],
|
||||
[{ loading: false, error: true }, { enabled: true }],
|
||||
[{ loading: true, error: true }, { enabled: true }],
|
||||
[{ loading: false, error: false }, { enabled: false }],
|
||||
])('does not bind an EventSource when disabled, loading or error', (mercureInfo, realTimeUpdates) => {
|
||||
bindToMercureTopic(mercureInfo, realTimeUpdates)();
|
||||
|
||||
expect(EventSource).not.toHaveBeenCalled();
|
||||
expect(onMessage).not.toHaveBeenCalled();
|
||||
expect(onTokenExpired).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('binds an EventSource when mercure info is properly loaded', () => {
|
||||
const token = 'abc.123.efg';
|
||||
const mercureHubUrl = 'https://example.com/.well-known/mercure';
|
||||
const topic = 'foo';
|
||||
const hubUrl = new URL(mercureHubUrl);
|
||||
|
||||
hubUrl.searchParams.append('topic', topic);
|
||||
|
||||
const callback = bindToMercureTopic({
|
||||
loading: false,
|
||||
error: false,
|
||||
mercureHubUrl,
|
||||
token,
|
||||
}, { enabled: true }, topic, onMessage, onTokenExpired)();
|
||||
|
||||
expect(EventSource).toHaveBeenCalledWith(hubUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
const [ es ] = EventSource.mock.instances;
|
||||
|
||||
es.onmessage({ data: '{"foo": "bar"}' });
|
||||
es.onerror({ status: 401 });
|
||||
expect(onMessage).toHaveBeenCalledWith({ foo: 'bar' });
|
||||
expect(onTokenExpired).toHaveBeenCalled();
|
||||
|
||||
callback();
|
||||
expect(es.close).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
70
test/mercure/reducers/mercureInfo.test.js
Normal file
70
test/mercure/reducers/mercureInfo.test.js
Normal file
@@ -0,0 +1,70 @@
|
||||
import reducer, {
|
||||
GET_MERCURE_INFO_START,
|
||||
GET_MERCURE_INFO_ERROR,
|
||||
GET_MERCURE_INFO,
|
||||
loadMercureInfo,
|
||||
} from '../../../src/mercure/reducers/mercureInfo.js';
|
||||
|
||||
describe('mercureInfoReducer', () => {
|
||||
const mercureInfo = {
|
||||
mercureHubUrl: 'http://example.com/.well-known/mercure',
|
||||
token: 'abc.123.def',
|
||||
};
|
||||
|
||||
describe('reducer', () => {
|
||||
it('returns loading on GET_MERCURE_INFO_START', () => {
|
||||
expect(reducer({}, { type: GET_MERCURE_INFO_START })).toEqual({
|
||||
loading: true,
|
||||
error: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error on GET_MERCURE_INFO_ERROR', () => {
|
||||
expect(reducer({}, { type: GET_MERCURE_INFO_ERROR })).toEqual({
|
||||
loading: false,
|
||||
error: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns mercure info on GET_MERCURE_INFO', () => {
|
||||
expect(reducer({}, { type: GET_MERCURE_INFO, ...mercureInfo })).toEqual({
|
||||
...mercureInfo,
|
||||
loading: false,
|
||||
error: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadMercureInfo', () => {
|
||||
const createApiClientMock = (result) => ({
|
||||
mercureInfo: jest.fn(() => result),
|
||||
});
|
||||
const dispatch = jest.fn();
|
||||
const getState = () => ({});
|
||||
|
||||
afterEach(jest.resetAllMocks);
|
||||
|
||||
it('calls API on success', async () => {
|
||||
const apiClientMock = createApiClientMock(Promise.resolve(mercureInfo));
|
||||
|
||||
await loadMercureInfo(() => apiClientMock)()(dispatch, getState());
|
||||
|
||||
expect(apiClientMock.mercureInfo).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).toHaveBeenCalledTimes(2);
|
||||
expect(dispatch).toHaveBeenNthCalledWith(1, { type: GET_MERCURE_INFO_START });
|
||||
expect(dispatch).toHaveBeenNthCalledWith(2, { type: GET_MERCURE_INFO, ...mercureInfo });
|
||||
});
|
||||
|
||||
it('throws error on failure', async () => {
|
||||
const error = 'Error';
|
||||
const apiClientMock = createApiClientMock(Promise.reject(error));
|
||||
|
||||
await loadMercureInfo(() => apiClientMock)()(dispatch, getState());
|
||||
|
||||
expect(apiClientMock.mercureInfo).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).toHaveBeenCalledTimes(2);
|
||||
expect(dispatch).toHaveBeenNthCalledWith(1, { type: GET_MERCURE_INFO_START });
|
||||
expect(dispatch).toHaveBeenNthCalledWith(2, { type: GET_MERCURE_INFO_ERROR });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,12 +8,9 @@ describe('<ServersDropdown />', () => {
|
||||
let wrapped;
|
||||
let ServersDropdown;
|
||||
const servers = {
|
||||
list: {
|
||||
'1a': { name: 'foo', id: 1 },
|
||||
'2b': { name: 'bar', id: 2 },
|
||||
'3c': { name: 'baz', id: 3 },
|
||||
},
|
||||
loading: false,
|
||||
'1a': { name: 'foo', id: 1 },
|
||||
'2b': { name: 'bar', id: 2 },
|
||||
'3c': { name: 'baz', id: 3 },
|
||||
};
|
||||
const history = {
|
||||
push: jest.fn(),
|
||||
@@ -26,7 +23,7 @@ describe('<ServersDropdown />', () => {
|
||||
afterEach(() => wrapped.unmount());
|
||||
|
||||
it('contains the list of servers, the divider and the export button', () =>
|
||||
expect(wrapped.find(DropdownItem)).toHaveLength(values(servers.list).length + 2));
|
||||
expect(wrapped.find(DropdownItem)).toHaveLength(values(servers).length + 2));
|
||||
|
||||
it('contains a toggle with proper title', () =>
|
||||
expect(wrapped.find(DropdownToggle)).toHaveLength(1));
|
||||
@@ -40,7 +37,7 @@ describe('<ServersDropdown />', () => {
|
||||
|
||||
it('shows a message when no servers exist yet', () => {
|
||||
wrapped = shallow(
|
||||
<ServersDropdown servers={{ loading: false, list: {} }} listServers={identity} history={history} />
|
||||
<ServersDropdown servers={{}} listServers={identity} history={history} />
|
||||
);
|
||||
const item = wrapped.find(DropdownItem);
|
||||
|
||||
@@ -48,15 +45,4 @@ describe('<ServersDropdown />', () => {
|
||||
expect(item.prop('disabled')).toEqual(true);
|
||||
expect(item.find('i').text()).toEqual('Add a server first...');
|
||||
});
|
||||
|
||||
it('shows a message when loading', () => {
|
||||
wrapped = shallow(
|
||||
<ServersDropdown servers={{ loading: true, list: {} }} listServers={identity} history={history} />
|
||||
);
|
||||
const item = wrapped.find(DropdownItem);
|
||||
|
||||
expect(item).toHaveLength(1);
|
||||
expect(item.prop('disabled')).toEqual(true);
|
||||
expect(item.find('i').text()).toEqual('Trying to load servers...');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,7 +32,7 @@ describe('<ServerError />', () => {
|
||||
])('renders expected information for type "%s"', (type, textsToFind) => {
|
||||
wrapper = shallow(
|
||||
<BrowserRouter>
|
||||
<ServerError type={type} servers={{ list: [] }} selectedServer={selectedServer} />
|
||||
<ServerError type={type} servers={{}} selectedServer={selectedServer} />
|
||||
</BrowserRouter>
|
||||
);
|
||||
const wrapperText = wrapper.html();
|
||||
|
||||
55
test/servers/reducers/remoteServers.test.js
Normal file
55
test/servers/reducers/remoteServers.test.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import { fetchServers } from '../../../src/servers/reducers/remoteServers';
|
||||
import { CREATE_SERVERS } from '../../../src/servers/reducers/servers';
|
||||
|
||||
describe('remoteServersReducer', () => {
|
||||
afterEach(jest.resetAllMocks);
|
||||
|
||||
describe('fetchServers', () => {
|
||||
const axios = { get: jest.fn() };
|
||||
const dispatch = jest.fn();
|
||||
|
||||
it.each([
|
||||
[
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
id: '111',
|
||||
name: 'acel.me from servers.json',
|
||||
url: 'https://acel.me',
|
||||
apiKey: '07fb8a96-8059-4094-a24c-80a7d5e7e9b0',
|
||||
},
|
||||
{
|
||||
id: '222',
|
||||
name: 'Local from servers.json',
|
||||
url: 'http://localhost:8000',
|
||||
apiKey: '7a531c75-134e-4d5c-86e0-a71b7167b57a',
|
||||
},
|
||||
],
|
||||
}),
|
||||
{
|
||||
111: {
|
||||
id: '111',
|
||||
name: 'acel.me from servers.json',
|
||||
url: 'https://acel.me',
|
||||
apiKey: '07fb8a96-8059-4094-a24c-80a7d5e7e9b0',
|
||||
},
|
||||
222: {
|
||||
id: '222',
|
||||
name: 'Local from servers.json',
|
||||
url: 'http://localhost:8000',
|
||||
apiKey: '7a531c75-134e-4d5c-86e0-a71b7167b57a',
|
||||
},
|
||||
},
|
||||
],
|
||||
[ Promise.resolve('<html></html>'), {}],
|
||||
[ Promise.reject({}), {}],
|
||||
])('tries to fetch servers from remote', async (mockedValue, expectedList) => {
|
||||
axios.get.mockReturnValue(mockedValue);
|
||||
|
||||
await fetchServers(axios)()(dispatch);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({ type: CREATE_SERVERS, newServers: expectedList });
|
||||
expect(axios.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -32,14 +32,13 @@ describe('selectedServerReducer', () => {
|
||||
id: 'abc123',
|
||||
};
|
||||
const version = '1.19.0';
|
||||
const ServersServiceMock = {
|
||||
findServerById: jest.fn(() => selectedServer),
|
||||
};
|
||||
const createGetStateMock = (id) => jest.fn().mockReturnValue({ servers: { [id]: selectedServer } });
|
||||
const apiClientMock = {
|
||||
health: jest.fn(),
|
||||
};
|
||||
const buildApiClient = jest.fn().mockReturnValue(apiClientMock);
|
||||
const dispatch = jest.fn();
|
||||
const loadMercureInfo = jest.fn();
|
||||
|
||||
afterEach(jest.clearAllMocks);
|
||||
|
||||
@@ -48,6 +47,8 @@ describe('selectedServerReducer', () => {
|
||||
[ 'latest', MAX_FALLBACK_VERSION, 'latest' ],
|
||||
[ '%invalid_semver%', MIN_FALLBACK_VERSION, '%invalid_semver%' ],
|
||||
])('dispatches proper actions', async (serverVersion, expectedVersion, expectedPrintableVersion) => {
|
||||
const id = uuid();
|
||||
const getState = createGetStateMock(id);
|
||||
const expectedSelectedServer = {
|
||||
...selectedServer,
|
||||
version: expectedVersion,
|
||||
@@ -56,42 +57,50 @@ describe('selectedServerReducer', () => {
|
||||
|
||||
apiClientMock.health.mockResolvedValue({ version: serverVersion });
|
||||
|
||||
await selectServer(ServersServiceMock, buildApiClient)(uuid())(dispatch);
|
||||
await selectServer(buildApiClient, loadMercureInfo)(id)(dispatch, getState);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledTimes(3);
|
||||
expect(dispatch).toHaveBeenCalledTimes(4);
|
||||
expect(dispatch).toHaveBeenNthCalledWith(1, { type: RESET_SELECTED_SERVER });
|
||||
expect(dispatch).toHaveBeenNthCalledWith(2, { type: RESET_SHORT_URL_PARAMS });
|
||||
expect(dispatch).toHaveBeenNthCalledWith(3, { type: SELECT_SERVER, selectedServer: expectedSelectedServer });
|
||||
expect(loadMercureInfo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('invokes dependencies', async () => {
|
||||
await selectServer(ServersServiceMock, buildApiClient)(uuid())(() => {});
|
||||
const id = uuid();
|
||||
const getState = createGetStateMock(id);
|
||||
|
||||
expect(ServersServiceMock.findServerById).toHaveBeenCalledTimes(1);
|
||||
await selectServer(buildApiClient, loadMercureInfo)(id)(() => {}, getState);
|
||||
|
||||
expect(getState).toHaveBeenCalledTimes(1);
|
||||
expect(buildApiClient).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('dispatches error when health endpoint fails', async () => {
|
||||
const id = uuid();
|
||||
const getState = createGetStateMock(id);
|
||||
const expectedSelectedServer = { ...selectedServer, serverNotReachable: true };
|
||||
|
||||
apiClientMock.health.mockRejectedValue({});
|
||||
|
||||
await selectServer(ServersServiceMock, buildApiClient)(uuid())(dispatch);
|
||||
await selectServer(buildApiClient, loadMercureInfo)(id)(dispatch, getState);
|
||||
|
||||
expect(apiClientMock.health).toHaveBeenCalled();
|
||||
expect(dispatch).toHaveBeenNthCalledWith(3, { type: SELECT_SERVER, selectedServer: expectedSelectedServer });
|
||||
expect(loadMercureInfo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('dispatches error when server is not found', async () => {
|
||||
const id = uuid();
|
||||
const getState = jest.fn(() => ({ servers: {} }));
|
||||
const expectedSelectedServer = { serverNotFound: true };
|
||||
|
||||
ServersServiceMock.findServerById.mockReturnValue(undefined);
|
||||
await selectServer(buildApiClient, loadMercureInfo)(id)(dispatch, getState);
|
||||
|
||||
await selectServer(ServersServiceMock, buildApiClient)(uuid())(dispatch);
|
||||
|
||||
expect(ServersServiceMock.findServerById).toHaveBeenCalled();
|
||||
expect(getState).toHaveBeenCalled();
|
||||
expect(apiClientMock.health).not.toHaveBeenCalled();
|
||||
expect(dispatch).toHaveBeenNthCalledWith(3, { type: SELECT_SERVER, selectedServer: expectedSelectedServer });
|
||||
expect(loadMercureInfo).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
import { values } from 'ramda';
|
||||
import reducer, {
|
||||
createServer,
|
||||
deleteServer,
|
||||
listServers,
|
||||
createServers,
|
||||
FETCH_SERVERS, FETCH_SERVERS_START, editServer,
|
||||
} from '../../../src/servers/reducers/server';
|
||||
|
||||
describe('serverReducer', () => {
|
||||
const list = {
|
||||
abc123: { id: 'abc123' },
|
||||
def456: { id: 'def456' },
|
||||
};
|
||||
const expectedFetchServersResult = { type: FETCH_SERVERS, list };
|
||||
const ServersServiceMock = {
|
||||
listServers: jest.fn(() => list),
|
||||
createServer: jest.fn(),
|
||||
editServer: jest.fn(),
|
||||
deleteServer: jest.fn(),
|
||||
createServers: jest.fn(),
|
||||
};
|
||||
|
||||
afterEach(jest.clearAllMocks);
|
||||
|
||||
describe('reducer', () => {
|
||||
it('returns servers when action is FETCH_SERVERS', () =>
|
||||
expect(reducer({}, { type: FETCH_SERVERS, list })).toEqual({ loading: false, list }));
|
||||
});
|
||||
|
||||
describe('action creators', () => {
|
||||
describe('listServers', () => {
|
||||
const axios = { get: jest.fn() };
|
||||
const dispatch = jest.fn();
|
||||
const NoListServersServiceMock = { ...ServersServiceMock, listServers: jest.fn(() => ({})) };
|
||||
|
||||
it('fetches servers from local storage when found', async () => {
|
||||
await listServers(ServersServiceMock, axios)()(dispatch);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledTimes(2);
|
||||
expect(dispatch).toHaveBeenNthCalledWith(1, { type: FETCH_SERVERS_START });
|
||||
expect(dispatch).toHaveBeenNthCalledWith(2, expectedFetchServersResult);
|
||||
expect(ServersServiceMock.listServers).toHaveBeenCalledTimes(1);
|
||||
expect(ServersServiceMock.createServer).not.toHaveBeenCalled();
|
||||
expect(ServersServiceMock.editServer).not.toHaveBeenCalled();
|
||||
expect(ServersServiceMock.deleteServer).not.toHaveBeenCalled();
|
||||
expect(ServersServiceMock.createServers).not.toHaveBeenCalled();
|
||||
expect(axios.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
id: '111',
|
||||
name: 'acel.me from servers.json',
|
||||
url: 'https://acel.me',
|
||||
apiKey: '07fb8a96-8059-4094-a24c-80a7d5e7e9b0',
|
||||
},
|
||||
{
|
||||
id: '222',
|
||||
name: 'Local from servers.json',
|
||||
url: 'http://localhost:8000',
|
||||
apiKey: '7a531c75-134e-4d5c-86e0-a71b7167b57a',
|
||||
},
|
||||
],
|
||||
}),
|
||||
{
|
||||
111: {
|
||||
id: '111',
|
||||
name: 'acel.me from servers.json',
|
||||
url: 'https://acel.me',
|
||||
apiKey: '07fb8a96-8059-4094-a24c-80a7d5e7e9b0',
|
||||
},
|
||||
222: {
|
||||
id: '222',
|
||||
name: 'Local from servers.json',
|
||||
url: 'http://localhost:8000',
|
||||
apiKey: '7a531c75-134e-4d5c-86e0-a71b7167b57a',
|
||||
},
|
||||
},
|
||||
],
|
||||
[ Promise.resolve('<html></html>'), {}],
|
||||
[ Promise.reject({}), {}],
|
||||
])('tries to fetch servers from remote when not found locally', async (mockedValue, expectedList) => {
|
||||
axios.get.mockReturnValue(mockedValue);
|
||||
|
||||
await listServers(NoListServersServiceMock, axios)()(dispatch);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledTimes(2);
|
||||
expect(dispatch).toHaveBeenNthCalledWith(1, { type: FETCH_SERVERS_START });
|
||||
expect(dispatch).toHaveBeenNthCalledWith(2, { type: FETCH_SERVERS, list: expectedList });
|
||||
expect(NoListServersServiceMock.listServers).toHaveBeenCalledTimes(1);
|
||||
expect(NoListServersServiceMock.createServer).not.toHaveBeenCalled();
|
||||
expect(NoListServersServiceMock.editServer).not.toHaveBeenCalled();
|
||||
expect(NoListServersServiceMock.deleteServer).not.toHaveBeenCalled();
|
||||
expect(NoListServersServiceMock.createServers).toHaveBeenCalledTimes(1);
|
||||
expect(axios.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createServer', () => {
|
||||
it('adds new server and then fetches servers again', () => {
|
||||
const serverToCreate = { id: 'abc123' };
|
||||
const result = createServer(ServersServiceMock, () => expectedFetchServersResult)(serverToCreate);
|
||||
|
||||
expect(result).toEqual(expectedFetchServersResult);
|
||||
expect(ServersServiceMock.listServers).not.toHaveBeenCalled();
|
||||
expect(ServersServiceMock.createServer).toHaveBeenCalledTimes(1);
|
||||
expect(ServersServiceMock.createServer).toHaveBeenCalledWith(serverToCreate);
|
||||
expect(ServersServiceMock.editServer).not.toHaveBeenCalled();
|
||||
expect(ServersServiceMock.deleteServer).not.toHaveBeenCalled();
|
||||
expect(ServersServiceMock.createServers).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('editServer', () => {
|
||||
it('edits existing server and then fetches servers again', () => {
|
||||
const serverToEdit = { name: 'edited' };
|
||||
const result = editServer(ServersServiceMock, () => expectedFetchServersResult)('123', serverToEdit);
|
||||
|
||||
expect(result).toEqual(expectedFetchServersResult);
|
||||
expect(ServersServiceMock.listServers).not.toHaveBeenCalled();
|
||||
expect(ServersServiceMock.createServer).not.toHaveBeenCalled();
|
||||
expect(ServersServiceMock.editServer).toHaveBeenCalledTimes(1);
|
||||
expect(ServersServiceMock.editServer).toHaveBeenCalledWith('123', serverToEdit);
|
||||
expect(ServersServiceMock.deleteServer).not.toHaveBeenCalled();
|
||||
expect(ServersServiceMock.createServers).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteServer', () => {
|
||||
it('deletes a server and then fetches servers again', () => {
|
||||
const serverToDelete = { id: 'abc123' };
|
||||
const result = deleteServer(ServersServiceMock, () => expectedFetchServersResult)(serverToDelete);
|
||||
|
||||
expect(result).toEqual(expectedFetchServersResult);
|
||||
expect(ServersServiceMock.listServers).not.toHaveBeenCalled();
|
||||
expect(ServersServiceMock.createServer).not.toHaveBeenCalled();
|
||||
expect(ServersServiceMock.createServers).not.toHaveBeenCalled();
|
||||
expect(ServersServiceMock.editServer).not.toHaveBeenCalled();
|
||||
expect(ServersServiceMock.deleteServer).toHaveBeenCalledTimes(1);
|
||||
expect(ServersServiceMock.deleteServer).toHaveBeenCalledWith(serverToDelete);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createServers', () => {
|
||||
it('creates multiple servers and then fetches servers again', () => {
|
||||
const serversToCreate = values(list);
|
||||
const result = createServers(ServersServiceMock, () => expectedFetchServersResult)(serversToCreate);
|
||||
|
||||
expect(result).toEqual(expectedFetchServersResult);
|
||||
expect(ServersServiceMock.listServers).not.toHaveBeenCalled();
|
||||
expect(ServersServiceMock.createServer).not.toHaveBeenCalled();
|
||||
expect(ServersServiceMock.editServer).not.toHaveBeenCalled();
|
||||
expect(ServersServiceMock.deleteServer).not.toHaveBeenCalled();
|
||||
expect(ServersServiceMock.createServers).toHaveBeenCalledTimes(1);
|
||||
expect(ServersServiceMock.createServers).toHaveBeenCalledWith(serversToCreate);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user