Compare commits

...

91 Commits

Author SHA1 Message Date
Alejandro Celaya
984c1ea716 Added v2.5.0 to changelog 2020-05-31 12:00:50 +02:00
Alejandro Celaya
df38cf6ca9 Merge pull request #275 from acelaya-forks/feature/remove-class-components
Feature/remove class components
2020-05-31 11:51:08 +02:00
Alejandro Celaya
1b60b0e2a8 Updated SortableBarGraph to be a functional component 2020-05-31 11:36:56 +02:00
Alejandro Celaya
11f9c7c507 Updated TagsSelector to be a functional component 2020-05-31 11:19:53 +02:00
Alejandro Celaya
ebe649aaac Updated EditTagModal to be a functional component 2020-05-31 11:06:23 +02:00
Alejandro Celaya
656b68d422 Updated DeleteTagConfirmModal to be a functional component 2020-05-31 10:45:18 +02:00
Alejandro Celaya
cd1f186e28 Updated EditTagsModal to be a functional component 2020-05-31 10:31:00 +02:00
Alejandro Celaya
d0b3edaa2f Updated DeleteShortUrlModal to be a functional component 2020-05-31 10:23:13 +02:00
Alejandro Celaya
2268b85ade Updated CreateShortUrlResult to be a functional component 2020-05-31 10:16:09 +02:00
Alejandro Celaya
d7e3b7b912 Updated ImportServersBtn to be a functional component 2020-05-31 09:58:27 +02:00
Alejandro Celaya
4bd83eecfb Updated ScrollToTop to be a functional component 2020-05-31 09:50:00 +02:00
Alejandro Celaya
b7fd2308ad Merge pull request #274 from acelaya-forks/feature/stryker3
Updated to stryker 3
2020-05-31 09:34:02 +02:00
Alejandro Celaya
a6958941ad Updated to stryker 3 2020-05-31 09:27:42 +02:00
Alejandro Celaya
c98b28ff0f Merge pull request #273 from acelaya-forks/feature/charts-improvements
Feature/charts improvements
2020-05-31 09:19:02 +02:00
Alejandro Celaya
6a372badfa Improved labels displayed in charts when visits are highlighted 2020-05-31 09:07:21 +02:00
Alejandro Celaya
b6ab9a1bdd Improved memoization of grouped visits for line chart 2020-05-31 08:55:52 +02:00
Alejandro Celaya
daf9e7cf64 Merge pull request #272 from acelaya-forks/feature/line-chart-improvements
Some improvements on LineChartCard
2020-05-30 17:52:55 +02:00
Alejandro Celaya
ef42dcd666 Simplified code and removed duplication 2020-05-30 17:43:13 +02:00
Alejandro Celaya
1b6028ae6d Some improvements on LineChartCard 2020-05-30 17:39:08 +02:00
Alejandro Celaya
9340512980 Merge pull request #271 from acelaya-forks/feature/line-chart
Feature/line chart
2020-05-30 10:54:27 +02:00
Alejandro Celaya
9d0b4cc065 Updated changelog 2020-05-30 10:43:18 +02:00
Alejandro Celaya
c5cb0dcb26 Added test for LineChartCard 2020-05-30 10:41:46 +02:00
Alejandro Celaya
a42f5ab13e Set fixed height for time-based line chart 2020-05-30 10:26:52 +02:00
Alejandro Celaya
68b0577526 Added dynamic grouping to time-based line chart 2020-05-30 09:57:21 +02:00
Alejandro Celaya
61867366e7 Created first version of the time-based visits chart 2020-05-30 09:25:15 +02:00
Alejandro Celaya
c670d86955 Merge pull request #270 from acelaya-forks/feature/parallel-docker-build
Feature/parallel docker build
2020-05-17 10:37:34 +02:00
Alejandro Celaya
4565a64cd8 Rolled back node version for scrutinizer 2020-05-17 10:28:49 +02:00
Alejandro Celaya
f36e42d9c1 Fixed travis config error 2020-05-17 10:16:53 +02:00
Alejandro Celaya
0a3a97242b Simplified docker image building, as it will now be run in a different job as the dist file release 2020-05-17 10:13:33 +02:00
Alejandro Celaya
68253c3bc4 Disabled multi-arch building until everything is compatible 2020-05-17 10:12:39 +02:00
Alejandro Celaya
544384d85e Updated runtimne versions 2020-05-17 10:04:05 +02:00
Alejandro Celaya
91daec852f Added parallel docker image multi-arch building 2020-05-17 10:02:36 +02:00
Alejandro Celaya
dcc5b9cc8c Merge pull request #268 from acelaya-forks/feature/tag-visits
Feature/tag visits
2020-05-13 20:41:39 +02:00
Alejandro Celaya
1d26cd93fb Added real time updates to tags list page 2020-05-13 18:32:27 +02:00
Alejandro Celaya
e47dfaf36f Changed paginable charts so that they use 50 items per page by default 2020-05-11 19:44:27 +02:00
Alejandro Celaya
09e2c69e46 Ensured visits by tag route does not work for old Shlink servers 2020-05-11 19:40:19 +02:00
Alejandro Celaya
07d3567244 Added progress bar to visits page when loading a lot of visits 2020-05-11 19:32:42 +02:00
Alejandro Celaya
9bdbe90716 Ensured state is properly reset when starting, finisihing or failing to load visits 2020-05-11 18:55:35 +02:00
Alejandro Celaya
02a4380f7c Updated changelog 2020-05-10 20:36:03 +02:00
Alejandro Celaya
4e483dc5d4 Created TagVisits test 2020-05-10 20:30:19 +02:00
Alejandro Celaya
52631e629e Created TagVisitsHeader test 2020-05-10 20:23:45 +02:00
Alejandro Celaya
3a53298417 Improved visits pages titles 2020-05-10 20:17:17 +02:00
Alejandro Celaya
fb0f14fc16 Created header for visits by tag section 2020-05-10 19:49:58 +02:00
Alejandro Celaya
7a94b1730d Created common component for visits header 2020-05-10 19:37:00 +02:00
Alejandro Celaya
f856bc218a Created tagVisits reducer test 2020-05-10 19:12:18 +02:00
Alejandro Celaya
bfbb21e1cc Created page for tag visit stats 2020-05-10 19:02:58 +02:00
Alejandro Celaya
18e18f533b Extracted visits charts elements into reusable component 2020-05-10 17:49:55 +02:00
Alejandro Celaya
6eead70511 Merge pull request #266 from acelaya-forks/feature/tags-list-improvements
Feature/tags list improvements
2020-05-10 11:34:48 +02:00
Alejandro Celaya
6fd30ed51a Improved how tags are exposed by the ApiClient when listing tags 2020-05-10 11:20:40 +02:00
Alejandro Celaya
67c674f073 Updated changelog 2020-05-10 11:15:24 +02:00
Alejandro Celaya
289d8784c0 Converted TagCard component into functional component 2020-05-10 11:12:22 +02:00
Alejandro Celaya
18e026e4ca Updated tags list to display visits and short URLs when remote shlink version allows it 2020-05-10 10:57:49 +02:00
Alejandro Celaya
8741f42fe8 Merge pull request #264 from acelaya-forks/feature/charts-precision
Feature/charts precision
2020-05-07 11:21:51 +02:00
Alejandro Celaya
665d6209d9 Updated changelog 2020-05-07 11:10:10 +02:00
Alejandro Celaya
59fda29894 Added precision 0 to charts, to avoid having decimals 2020-05-07 11:06:14 +02:00
Alejandro Celaya
61c027f9a1 Merge pull request #263 from acelaya-forks/feature/minor-improvements
Minor improvements
2020-05-03 20:23:24 +02:00
Alejandro Celaya
241c9b73b0 Minor improvements 2020-05-03 20:16:21 +02:00
Alejandro Celaya
85dc1d0825 Merge pull request #260 from acelaya-forks/feature/responsive-visits-table
Feature/responsive visits table
2020-04-28 19:08:20 +02:00
Alejandro Celaya
e38887aa26 Ensured side menu is not swippoed when horizontally scrolling visits table 2020-04-28 18:54:58 +02:00
Alejandro Celaya
54fec79945 First minor improvements to the visits table responsiveness 2020-04-27 18:04:24 +02:00
Alejandro Celaya
fad0bf1c9d Merge pull request #258 from acelaya-forks/feature/redux-localstorage
Feature/redux localstorage
2020-04-27 13:49:05 +02:00
Alejandro Celaya
be2f86050f Updated changelog 2020-04-27 13:37:54 +02:00
Alejandro Celaya
a7f941e8e4 Deleted no-longer-needed ServersService 2020-04-27 13:21:07 +02:00
Alejandro Celaya
b08c6748c7 Moved remote servers loading to separated action 2020-04-27 12:54:52 +02:00
Alejandro Celaya
bdd7932e07 Refactored ServersDropdown into functional component 2020-04-27 12:30:17 +02:00
Alejandro Celaya
bcf5dcf180 Converted server handling actions into regular actions 2020-04-27 11:30:51 +02:00
Alejandro Celaya
8b2cbf7aea Some minor refactorings 2020-04-27 10:52:19 +02:00
Alejandro Celaya
277b5e43f8 Flatten model holding list of servers 2020-04-27 10:49:55 +02:00
Alejandro Celaya
7dd6a31609 Deleted SettingsService, as the task is not transparently handled by a redux middleware 2020-04-26 19:07:47 +02:00
Alejandro Celaya
86bf1515d4 Added redux middleware to save parts of the store in the local storage transparently 2020-04-26 19:04:17 +02:00
Alejandro Celaya
bbc47b387e Created single reducer to handle settings 2020-04-26 13:00:27 +02:00
Alejandro Celaya
3953e98a77 Merge pull request #257 from acelaya-forks/feature/navigate-back
Feature/navigate back
2020-04-26 12:02:54 +02:00
Alejandro Celaya
09b8bd501d Converted TagsList component into functional component 2020-04-26 11:48:08 +02:00
Alejandro Celaya
6bddaaa055 Added cancel button to edit server page 2020-04-26 10:56:27 +02:00
Alejandro Celaya
dd728d4d13 Added back button to visits stats page 2020-04-26 10:43:00 +02:00
Alejandro Celaya
9ba8bc8f3d Merge pull request #256 from acelaya-forks/feature/settings-page
Feature/settings page
2020-04-25 11:19:19 +02:00
Alejandro Celaya
16dee3664b Ensured mercure updates are not set even if supported, when they have been disabled 2020-04-25 10:37:50 +02:00
Alejandro Celaya
6fcf588bfd Updated changelog 2020-04-25 10:23:51 +02:00
Alejandro Celaya
6a6c427b0e Added unit tests for settings business logic elements 2020-04-25 10:22:17 +02:00
Alejandro Celaya
41f885d8ec Created settings page and reducers to handle real-time updates config 2020-04-25 09:49:54 +02:00
Alejandro Celaya
7516ca8dd9 Created settings page and converted MainHeader into functional component 2020-04-18 20:58:35 +02:00
Alejandro Celaya
aa59a95f91 Merge pull request #251 from acelaya-forks/feature/real-time-updates
Feature/real time updates
2020-04-18 20:57:07 +02:00
Alejandro Celaya
8a5161c0e8 Updated changelog 2020-04-18 20:36:49 +02:00
Alejandro Celaya
d8ae69e861 Added test for mercure info helpers 2020-04-18 12:49:03 +02:00
Alejandro Celaya
a485d0b507 Added token expired handling to mercure binding 2020-04-18 12:26:00 +02:00
Alejandro Celaya
ed40b79c8d Added more tests covering new use cases 2020-04-18 12:09:51 +02:00
Alejandro Celaya
91488ae294 Fixed visits count not handling separated tooltiups 2020-04-18 11:03:49 +02:00
Alejandro Celaya
a22a1938c1 Added automatic refresh on mercure events 2020-04-18 10:50:01 +02:00
Alejandro Celaya
0f73cb9f8c Converted short URLs list in functional component 2020-04-17 17:39:30 +02:00
Alejandro Celaya
f3129399de Added EventSource connection to mercure hub possible 2020-04-17 17:11:52 +02:00
Alejandro Celaya
37e6c27461 Created mercure info reducer and loaded info when server is reachable 2020-04-17 15:51:18 +02:00
127 changed files with 3580 additions and 2122 deletions

View File

@@ -4,3 +4,4 @@
./node_modules
./test
./shlink-web-client.gif
./dist

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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
View File

@@ -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": {

View File

@@ -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",

View File

@@ -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
View 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

View File

@@ -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;

View File

@@ -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>
);

View File

@@ -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} />&nbsp; Settings
</NavLink>
</NavItem>
<NavItem>
<NavLink tag={Link} to={createServerPath} active={pathname === createServerPath}>
<FontAwesomeIcon icon={plusIcon} />&nbsp; 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;

View File

@@ -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>}

View 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;

View File

@@ -0,0 +1,3 @@
.no-menu-wrapper {
padding: 40px 20px;
}

View File

@@ -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;

View File

@@ -27,6 +27,7 @@ const provideServices = (bottle, connect, withRouter) => {
'AsideMenu',
'CreateShortUrl',
'ShortUrlVisits',
'TagVisits',
'ShlinkVersions',
'ServerError'
);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}

View 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();
};

View 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 });
}
};

View File

@@ -0,0 +1,8 @@
import { loadMercureInfo } from '../reducers/mercureInfo';
const provideServices = (bottle) => {
// Actions
bottle.serviceFactory('loadMercureInfo', loadMercureInfo, 'buildShlinkApiClient');
};
export default provideServices;

View File

@@ -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,
});

View File

@@ -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>
);
};

View File

@@ -1,9 +1,5 @@
@import '../utils/base';
.create-server {
padding: 40px 20px;
}
.create-server__label {
font-weight: 700;
cursor: pointer;

View File

@@ -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>
);
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>

View 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));
};

View File

@@ -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,

View File

@@ -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
);

View 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 });

View File

@@ -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, {

View File

@@ -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));
}
}

View File

@@ -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);
};

View 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
View File

@@ -0,0 +1,10 @@
import React from 'react';
import NoMenuLayout from '../common/NoMenuLayout';
const Settings = (RealTimeUpdates) => () => (
<NoMenuLayout>
<RealTimeUpdates />
</NoMenuLayout>
);
export default Settings;

View 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 },
});

View 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;

View File

@@ -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">&nbsp;</th>
</tr>
</thead>
<tbody>
{this.renderShortUrls()}
{renderShortUrls()}
</tbody>
</table>
</React.Fragment>
);
}
};
ShortUrlsListComp.propTypes = propTypes;
return ShortUrlsListComp;
};
export default ShortUrlsList;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>
);

View File

@@ -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);
}

View File

@@ -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">

View File

@@ -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;

View 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;
};

View File

@@ -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) => {

View File

@@ -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(

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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}

View File

@@ -1,6 +1,5 @@
.tag {
color: #fff;
cursor: pointer;
}
.tag:not(:last-child) {

View File

@@ -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;

View File

@@ -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 });

View File

@@ -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);

View File

@@ -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>

View File

@@ -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 ];

View File

@@ -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 });

View File

@@ -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({

View File

@@ -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;

View File

@@ -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';

View File

@@ -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>
);

View File

@@ -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>
);
};

View 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;

View File

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

View File

@@ -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
View 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;

View 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;

View File

@@ -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;

View File

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

246
src/visits/VisitsStats.js Normal file
View 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;

View File

@@ -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

View File

@@ -12,8 +12,10 @@
@include sticky-cell();
&.visits-table__sticky {
top: $headerHeight - 2px;
@media (min-width: $mdMin) {
&.visits-table__sticky {
top: $headerHeight - 2px;
}
}
}

View 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;

View File

@@ -0,0 +1,9 @@
@import '../../utils/base';
.line-chart-card__body canvas {
height: 300px !important;
@media (min-width: $mdMin) {
height: 350px !important;
}
}

View 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 });
}
};

View File

@@ -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) => {

View File

@@ -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);

View 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);

View File

@@ -0,0 +1,3 @@
export const CREATE_VISIT = 'shlink/visitCreation/CREATE_VISIT';
export const createNewVisit = ({ shortUrl, visit }) => ({ shortUrl, visit, type: CREATE_VISIT });

View File

@@ -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
View 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,
});

View File

@@ -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,
},
});
};

View File

@@ -21,6 +21,7 @@ describe('<App />', () => {
const routes = wrapper.find(Route);
const expectedPaths = [
'/',
'/settings',
'/server/create',
'/server/:serverId/edit',
'/server/:serverId',

View File

@@ -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);

View File

@@ -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);
});
});

View 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();
});
});
});

View 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 });
});
});
});

View File

@@ -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...');
});
});

View File

@@ -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();

View 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);
});
});
});

View File

@@ -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();
});
});
});

View File

@@ -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