Compare commits
101 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da54a72b3e | ||
|
|
86c155d8d1 | ||
|
|
666d2d3065 | ||
|
|
01e69fb6ca | ||
|
|
30e5253acd | ||
|
|
c67ce3918b | ||
|
|
58077f2d86 | ||
|
|
098c94bccf | ||
|
|
861a3c068f | ||
|
|
3b95e8ebc0 | ||
|
|
170e427530 | ||
|
|
707c9f4ce6 | ||
|
|
dc672bf0f0 | ||
|
|
c682737505 | ||
|
|
46fa3d4345 | ||
|
|
9b7bc4b495 | ||
|
|
4385061499 | ||
|
|
e17498e68b | ||
|
|
3e298f010b | ||
|
|
30117bd121 | ||
|
|
93f33b6218 | ||
|
|
535d08a607 | ||
|
|
6ac3a49db2 | ||
|
|
c16f760d79 | ||
|
|
965c2b243f | ||
|
|
703addddb9 | ||
|
|
ab6dff5c31 | ||
|
|
2ef330c62b | ||
|
|
72e71aff40 | ||
|
|
cefd6ec752 | ||
|
|
aec3de18aa | ||
|
|
97620cb583 | ||
|
|
cf4e8190a4 | ||
|
|
8af7436f13 | ||
|
|
c53520ae56 | ||
|
|
3adcaef455 | ||
|
|
43cd9722a9 | ||
|
|
f3154e770e | ||
|
|
44aca4aeda | ||
|
|
5762342d6c | ||
|
|
2236ed467e | ||
|
|
d244b830ac | ||
|
|
e89b68fe1e | ||
|
|
1f588c5b13 | ||
|
|
38cad143a0 | ||
|
|
f52bcc5389 | ||
|
|
caa6f7bcd8 | ||
|
|
207a8cef20 | ||
|
|
d44a4b260e | ||
|
|
80a8e0b55c | ||
|
|
2d60f830f7 | ||
|
|
90751a09f7 | ||
|
|
301da4bb2a | ||
|
|
c90cd46095 | ||
|
|
7826000384 | ||
|
|
b48dcdd5e1 | ||
|
|
4f6326b139 | ||
|
|
cff96eeccc | ||
|
|
5eb4a3adec | ||
|
|
b60908a5e9 | ||
|
|
124441238b | ||
|
|
4ec0287a74 | ||
|
|
05c67a5c99 | ||
|
|
f507a3628c | ||
|
|
89e9d2b2d1 | ||
|
|
595858ac4b | ||
|
|
3f2162fe62 | ||
|
|
f2cb30409a | ||
|
|
5c4fec5a2f | ||
|
|
e96c119432 | ||
|
|
0920962d72 | ||
|
|
aaeb0fff78 | ||
|
|
de41f50945 | ||
|
|
0f51bf95e3 | ||
|
|
ba8cade6fc | ||
|
|
dbefae5a01 | ||
|
|
727b219742 | ||
|
|
fb25e44b58 | ||
|
|
fe2d394831 | ||
|
|
efd08ff1d6 | ||
|
|
4b861a5376 | ||
|
|
2076e7d5e8 | ||
|
|
37f6f1f90c | ||
|
|
81f76e0bd6 | ||
|
|
69b305cd8a | ||
|
|
45742a066e | ||
|
|
86fb8b3f7c | ||
|
|
9c0fc8e1d2 | ||
|
|
10d6302180 | ||
|
|
da7ed6992f | ||
|
|
32c9375ac8 | ||
|
|
7ed1334a51 | ||
|
|
d9097896f6 | ||
|
|
359b16e700 | ||
|
|
0237af771d | ||
|
|
86cce5b205 | ||
|
|
fc7a2e0c6d | ||
|
|
f74d135922 | ||
|
|
66124370a6 | ||
|
|
e9fc2bb73a | ||
|
|
12f6b94ece |
@@ -28,6 +28,7 @@
|
||||
"no-warning-comments": "off",
|
||||
"no-magic-numbers": "off",
|
||||
"no-undefined": "off",
|
||||
"no-inline-comments": "off",
|
||||
"indent": ["error", 2, {
|
||||
"SwitchCase": 1
|
||||
}
|
||||
|
||||
1
.github/FUNDING.yml
vendored
@@ -1 +1,2 @@
|
||||
github: ['acelaya']
|
||||
custom: ['https://acel.me/donate']
|
||||
|
||||
1
.github/ISSUE_TEMPLATE/Bug.md
vendored
@@ -16,6 +16,7 @@ With that said, please fill in the information requested next. More information
|
||||
#### Shlink web client version
|
||||
|
||||
* Version: x.y.z
|
||||
* How do you use shlink-web-client: app.shlink.io|Docker image|self-hosted
|
||||
|
||||
#### Summary
|
||||
|
||||
|
||||
1
.github/ISSUE_TEMPLATE/Question_Support.md
vendored
@@ -16,6 +16,7 @@ With that said, please fill in the information requested next. More information
|
||||
#### Shlink web client version
|
||||
|
||||
* Version: x.y.z
|
||||
* How do you use shlink-web-client: app.shlink.io|Docker image|self-hosted
|
||||
|
||||
#### Summary
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
build:
|
||||
environment:
|
||||
node: v12.11.0
|
||||
node: v12.14.1
|
||||
tools:
|
||||
external_code_coverage:
|
||||
timeout: 1200
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
language: node_js
|
||||
|
||||
node_js:
|
||||
- "12.11.0"
|
||||
- "12.14.1"
|
||||
|
||||
cache:
|
||||
directories:
|
||||
@@ -15,7 +15,7 @@ install:
|
||||
|
||||
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 ",")
|
||||
- export MUTATION_FILES=$(git diff ${TRAVIS_COMMIT_RANGE:-origin/master} --name-only | grep -E 'src\/(.*).(js|ts|jsx|tsx)$' | paste -sd ",")
|
||||
|
||||
script:
|
||||
- npm run lint
|
||||
|
||||
99
CHANGELOG.md
@@ -4,6 +4,105 @@ 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.3.1 - 2020-02-08
|
||||
|
||||
#### Added
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Changed
|
||||
|
||||
* [#191](https://github.com/shlinkio/shlink-web-client/issues/191) Created `ForServerVersion` helper component which dynamically renders children if current server conditions are met.
|
||||
* [#189](https://github.com/shlinkio/shlink-web-client/issues/189) Simplified short url tags and short url deletion components and reducers, by removing redundant actions.
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* [#193](https://github.com/shlinkio/shlink-web-client/issues/193) Fixed `maxVisits` being set to 0 when trying to reset it from having a value to `null`.
|
||||
* [#196](https://github.com/shlinkio/shlink-web-client/issues/196) Included apache `.htaccess` file which takes care of falling back to index.html when reloading the page on a client-side handled route.
|
||||
* [#179](https://github.com/shlinkio/shlink-web-client/issues/179) Ensured domain is provided to Shlink server when editing, deleting or fetching short URLs which do not belong to default domain.
|
||||
* [#202](https://github.com/shlinkio/shlink-web-client/issues/202) Fixed domain not passed when dispatching actions that affect a single short URL (edit tags, edit meta and delete), which cased the list not to be properly updated.
|
||||
|
||||
|
||||
## 2.3.0 - 2020-01-19
|
||||
|
||||
#### Added
|
||||
|
||||
* [#174](https://github.com/shlinkio/shlink-web-client/issues/174) Added complete support for Shlink v2.x together with currently supported Shlink versions.
|
||||
* [#164](https://github.com/shlinkio/shlink-web-client/issues/164) Added max visits control on those URLs which have `maxVisits`.
|
||||
* [#178](https://github.com/shlinkio/shlink-web-client/issues/178) Short URLs list can now be filtered by date range.
|
||||
* [#46](https://github.com/shlinkio/shlink-web-client/issues/46) Allowed short URL's metadata to be edited (`maxVisits`, `validSince` and `validUntil`).
|
||||
|
||||
#### Changed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* [#170](https://github.com/shlinkio/shlink-web-client/issues/170) Fixed apple icon referencing to incorrect file names.
|
||||
|
||||
|
||||
## 2.2.2 - 2019-10-21
|
||||
|
||||
#### Added
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Changed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* [#167](https://github.com/shlinkio/shlink-web-client/issues/167) Fixed `/servers.json` path not being ignored when returning something other than an array.
|
||||
|
||||
|
||||
## 2.2.1 - 2019-10-18
|
||||
|
||||
#### Added
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Changed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* [#165](https://github.com/shlinkio/shlink-web-client/issues/165) Fixed error thrown when opening "create" page while using a Shlink version which does not return a valid SemVer version (like `latest` docker image, or any development instance).
|
||||
|
||||
|
||||
## 2.2.0 - 2019-10-05
|
||||
|
||||
#### Added
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
FROM node:12.11.0-alpine as node
|
||||
FROM node:12.14.1-alpine as node
|
||||
COPY . /shlink-web-client
|
||||
RUN cd /shlink-web-client && npm install && npm run build
|
||||
|
||||
FROM nginx:1.17.4-alpine
|
||||
FROM nginx:1.17.7-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
|
||||
|
||||
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2018-2019 shlinkio
|
||||
Copyright (c) 2018-2020 shlinkio
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
25
README.md
@@ -14,23 +14,30 @@ A ReactJS-based progressive web application for [Shlink](https://shlink.io).
|
||||
|
||||
There are three ways in which you can use this application.
|
||||
|
||||
* The easiest way to use shlink-web-client is by just going to <https://app.shlink.io>.
|
||||
### From app.shlink.io
|
||||
|
||||
The application runs 100% in the browser, so you can safely access any shlink instance from there.
|
||||
The easiest way to use shlink-web-client is by just going to <https://app.shlink.io>.
|
||||
|
||||
* Self hosting the application yourself.
|
||||
The application runs 100% in the browser, so you can safely access any shlink instance from there.
|
||||
|
||||
Get the [latest release](https://github.com/shlinkio/shlink-web-client/releases/latest) and download the distributable zip file attached to it (`shlink-web-client_X.X.X_dist.zip`).
|
||||
### Docker image
|
||||
|
||||
The package contains static files only, so just put it in a folder and serve it with the web server of your choice.
|
||||
If you want to deploy shlink-web-client in a container-based cluster (kubernetes, docker swarm, etc), just pick the [shlinkio/shlink-web-client](https://hub.docker.com/r/shlinkio/shlink-web-client/) image and do it.
|
||||
|
||||
Provided dist files are configured to be served from the root of your domain. If you need to serve shlink-web-client from a subpath, you will have to build it yourself following [these steps](#serve-shlink-in-subpath).
|
||||
It's a lightweight [nginx:alpine](https://hub.docker.com/r/library/nginx/) image serving the static app on port 80.
|
||||
|
||||
* Using the official [docker image](https://hub.docker.com/r/shlinkio/shlink-web-client/)
|
||||
### Self-hosted
|
||||
|
||||
If you want to deploy shlink-web-client in a container-based cluster (kubernetes, docker swarm, etc), just pick the `shlinkio/shlink-web-client` image and do it.
|
||||
If you want to self-host it yourself, get the [latest release](https://github.com/shlinkio/shlink-web-client/releases/latest) and download the distributable zip file attached to it (`shlink-web-client_X.X.X_dist.zip`).
|
||||
|
||||
It's a lightweight [nginx:alpine](https://hub.docker.com/r/library/nginx/) image serving the static app on port 80.
|
||||
The package contains static files only, so just put it in a folder and serve it with the web server of your choice.
|
||||
|
||||
**Considerations**:
|
||||
|
||||
* Provided dist files are configured to be served from the root of your domain. If you need to serve shlink-web-client from a subpath, you will have to build it yourself following [these steps](#serve-shlink-in-subpath).
|
||||
* The app has a client-side router that handles dynamic paths. Because of that, you need to configure your web server to fall-back to the `index.html` file when requested files do not exist.
|
||||
* If you use Apache, you are covered, since the project includes an `.htaccess` file which already does this.
|
||||
* If you use nginx, you can [see how it's done](config/docker/nginx.conf) for the docker image and do the same.
|
||||
|
||||
## Pre-configuring servers
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ server {
|
||||
index index.html;
|
||||
|
||||
# When requesting static paths with extension, try them, and return a 404 if not found
|
||||
location ~ .+\.(css|js|html|png|jpg|jpeg|gif|bmp|ico|csv|otf|eot|svg|svgz|ttf|woff|woff2|ijmap|pdf|tif|map) {
|
||||
location ~* .+\.(css|js|html|png|jpe?g|gif|bmp|ico|json|csv|otf|eot|svg|svgz|ttf|woff|woff2|ijmap|pdf|tif|map) {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ version: '3'
|
||||
services:
|
||||
shlink_web_client_node:
|
||||
container_name: shlink_web_client_node
|
||||
image: node:12.11.0-alpine
|
||||
image: node:12.14.1-alpine
|
||||
command: /bin/sh -c "cd /home/shlink/www && npm install && npm run start"
|
||||
volumes:
|
||||
- ./:/home/shlink/www
|
||||
|
||||
16
public/.htaccess
Normal file
@@ -0,0 +1,16 @@
|
||||
RewriteEngine on
|
||||
RewriteBase /
|
||||
|
||||
# do not do anything for already existing files
|
||||
RewriteCond %{REQUEST_FILENAME} -f [OR]
|
||||
RewriteCond %{REQUEST_FILENAME} -l [OR]
|
||||
RewriteCond %{REQUEST_FILENAME} -d
|
||||
RewriteRule (.*) - [L]
|
||||
|
||||
# if request is no valid file NOR directory
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
# if static asset do not do anything
|
||||
RewriteRule (.*)(css|js|html|png|jpe?g|gif|bmp|ico|json|csv|otf|eot|svg|svgz|ttf|woff|woff2|ijmap|pdf|tif|map) - [NC,L,R=404]
|
||||
# everything else should be redirected to /index.html so it can be routed by it
|
||||
RewriteRule (.*) /index.html [L]
|
||||
BIN
public/favicon.gif
Normal file
|
After Width: | Height: | Size: 642 B |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 1.1 KiB |
BIN
public/favicon.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
1
public/favicon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="512pt" height="512pt" viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg"><g fill="#4595e3"><path d=" M 23.71 85.08 C 17.22 49.81 49.44 14.86 85.08 18.12 C 118.83 19.21 145.72 53.33 139.45 86.37 C 155.64 102.30 171.32 118.83 187.87 134.36 C 198.32 111.73 208.84 89.12 219.57 66.62 C 226.05 53.84 243.47 48.74 255.73 56.27 C 263.76 62.10 270.34 69.69 277.25 76.75 C 286.28 86.61 285.72 102.89 276.31 112.31 C 223.38 165.37 170.38 218.37 117.35 271.34 C 107.72 280.99 91.01 281.25 81.11 271.86 C 74.39 264.94 66.82 258.69 61.24 250.77 C 53.72 238.52 58.85 221.07 71.64 214.62 C 94.11 203.87 116.72 193.38 139.33 182.91 C 123.81 166.36 107.30 150.68 91.37 134.49 C 60.20 140.28 27.37 116.78 23.71 85.08 Z" /><path d=" M 205.21 201.23 C 225.32 181.36 260.88 181.11 281.14 200.86 C 299.25 218.75 317.37 236.65 335.10 254.93 C 356.73 278.01 352.01 318.70 326.03 336.56 C 320.07 330.47 313.73 324.65 308.12 318.28 C 323.86 309.39 328.76 286.18 316.63 272.39 C 301.73 256.95 286.30 242.03 271.24 226.75 C 264.49 219.65 256.80 212.00 246.37 211.52 C 224.65 208.64 205.52 233.36 214.49 253.58 C 221.09 266.81 234.22 275.12 243.62 286.24 C 240.43 295.96 238.09 306.13 238.29 316.46 C 225.55 304.29 213.16 291.73 200.89 279.09 C 180.97 257.57 183.10 220.45 205.21 201.23 Z" /><path d=" M 273.90 352.07 C 252.28 328.99 256.98 288.31 282.96 270.46 C 288.93 276.54 295.26 282.36 300.88 288.72 C 285.14 297.62 280.23 320.82 292.38 334.61 C 307.27 350.05 322.70 364.96 337.75 380.25 C 344.51 387.35 352.20 395.00 362.64 395.48 C 384.35 398.37 403.49 373.64 394.51 353.42 C 387.92 340.18 374.78 331.88 365.38 320.76 C 368.56 311.04 370.91 300.86 370.71 290.54 C 383.45 302.70 395.84 315.27 408.11 327.91 C 428.03 349.43 425.90 386.55 403.78 405.77 C 383.68 425.64 348.13 425.89 327.86 406.14 C 309.75 388.25 291.60 370.37 273.90 352.07 Z" /><path d=" M 422.11 403.83 C 431.96 394.07 441.60 384.06 451.66 374.51 C 460.90 383.74 471.89 392.70 474.89 406.11 C 480.16 429.97 484.08 454.13 488.76 478.12 C 490.00 483.41 484.47 488.29 479.35 486.63 C 454.66 481.52 429.55 478.12 405.14 471.84 C 393.17 467.97 385.20 457.75 376.55 449.27 C 386.39 439.49 396.13 429.60 406.06 419.91 C 416.37 433.45 435.74 414.00 422.11 403.83 Z" /></g></svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
BIN
public/icons/icon-1024x1024.png
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
BIN
public/icons/icon-114x114.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
public/icons/icon-120x120.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 1.4 KiB |
BIN
public/icons/icon-150x150.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 1.3 KiB |
BIN
public/icons/icon-160x160.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
public/icons/icon-167x167.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
public/icons/icon-16x16.png
Normal file
|
After Width: | Height: | Size: 287 B |
BIN
public/icons/icon-180x180.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 1.7 KiB |
BIN
public/icons/icon-196x196.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
public/icons/icon-228x228.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
public/icons/icon-24x24.png
Normal file
|
After Width: | Height: | Size: 381 B |
BIN
public/icons/icon-256x256.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
public/icons/icon-310x310.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
public/icons/icon-32x32.png
Normal file
|
After Width: | Height: | Size: 437 B |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 3.2 KiB |
BIN
public/icons/icon-40x40.png
Normal file
|
After Width: | Height: | Size: 466 B |
BIN
public/icons/icon-48x48.png
Normal file
|
After Width: | Height: | Size: 551 B |
BIN
public/icons/icon-512x512.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
public/icons/icon-60x60.png
Normal file
|
After Width: | Height: | Size: 638 B |
BIN
public/icons/icon-64x64.png
Normal file
|
After Width: | Height: | Size: 684 B |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 750 B |
BIN
public/icons/icon-76x76.png
Normal file
|
After Width: | Height: | Size: 783 B |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 984 B |
@@ -11,12 +11,74 @@
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
|
||||
|
||||
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/icons/shlink-128.png">
|
||||
<link rel="apple-touch-icon" sizes="64x64" href="%PUBLIC_URL%/icons/shlink-64.png">
|
||||
<link rel="apple-touch-icon" sizes="32x32" href="%PUBLIC_URL%/icons/shlink-32.png">
|
||||
<link rel="apple-touch-icon" sizes="24x24" href="%PUBLIC_URL%/icons/shlink-24.png">
|
||||
<link rel="apple-touch-icon" sizes="16x16" href="%PUBLIC_URL%/icons/shlink-16.png">
|
||||
<!-- FavIcon itself -->
|
||||
<link rel="icon" type="image/x-icon" href="%PUBLIC_URL%/favicon.ico">
|
||||
<link rel="icon" type="image/svg+xml" href="%PUBLIC_URL%/favicon.svg" sizes="any">
|
||||
<link rel="icon" type="image/png" href="%PUBLIC_URL%/favicon.png">
|
||||
<link rel="icon" type="image/gif" href="%PUBLIC_URL%/favicon.gif">
|
||||
<!-- Apple Touch -->
|
||||
<link rel="apple-touch-icon" sizes="16x16" href="%PUBLIC_URL%/icons/icon-16x16.png">
|
||||
<link rel="apple-touch-icon" sizes="24x24" href="%PUBLIC_URL%/icons/icon-24x24.png">
|
||||
<link rel="apple-touch-icon" sizes="32x32" href="%PUBLIC_URL%/icons/icon-32x32.png">
|
||||
<link rel="apple-touch-icon" sizes="40x40" href="%PUBLIC_URL%/icons/icon-40x40.png">
|
||||
<link rel="apple-touch-icon" sizes="48x48" href="%PUBLIC_URL%/icons/icon-48x48.png">
|
||||
<link rel="apple-touch-icon" sizes="60x60" href="%PUBLIC_URL%/icons/icon-60x60.png">
|
||||
<link rel="apple-touch-icon" sizes="64x64" href="%PUBLIC_URL%/icons/icon-64x64.png">
|
||||
<link rel="apple-touch-icon" sizes="72x72" href="%PUBLIC_URL%/icons/icon-72x72.png">
|
||||
<link rel="apple-touch-icon" sizes="76x76" href="%PUBLIC_URL%/icons/icon-76x76.png">
|
||||
<link rel="apple-touch-icon" sizes="96x96" href="%PUBLIC_URL%/icons/icon-96x96.png">
|
||||
<link rel="apple-touch-icon" sizes="114x114" href="%PUBLIC_URL%/icons/icon-114x114.png">
|
||||
<link rel="apple-touch-icon" sizes="120x120" href="%PUBLIC_URL%/icons/icon-120x120.png">
|
||||
<link rel="apple-touch-icon" sizes="128x128" href="%PUBLIC_URL%/icons/icon-128x128.png">
|
||||
<link rel="apple-touch-icon" sizes="144x144" href="%PUBLIC_URL%/icons/icon-144x144.png">
|
||||
<link rel="apple-touch-icon" sizes="150x150" href="%PUBLIC_URL%/icons/icon-150x150.png">
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="%PUBLIC_URL%/icons/icon-152x152.png">
|
||||
<link rel="apple-touch-icon" sizes="160x160" href="%PUBLIC_URL%/icons/icon-160x160.png">
|
||||
<link rel="apple-touch-icon" sizes="167x167" href="%PUBLIC_URL%/icons/icon-167x167.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/icons/icon-180x180.png">
|
||||
<link rel="apple-touch-icon" sizes="192x192" href="%PUBLIC_URL%/icons/icon-192x192.png">
|
||||
<link rel="apple-touch-icon" sizes="196x196" href="%PUBLIC_URL%/icons/icon-196x196.png">
|
||||
<link rel="apple-touch-icon" sizes="228x228" href="%PUBLIC_URL%/icons/icon-228x228.png">
|
||||
<link rel="apple-touch-icon" sizes="256x256" href="%PUBLIC_URL%/icons/icon-256x256.png">
|
||||
<link rel="apple-touch-icon" sizes="310x310" href="%PUBLIC_URL%/icons/icon-310x310.png">
|
||||
<link rel="apple-touch-icon" sizes="384x384" href="%PUBLIC_URL%/icons/icon-384x384.png">
|
||||
<link rel="apple-touch-icon" sizes="512x512" href="%PUBLIC_URL%/icons/icon-512x512.png">
|
||||
<link rel="apple-touch-icon" sizes="1024x1024" href="%PUBLIC_URL%/icons/icon-1024x1024.png">
|
||||
<!-- Normal -->
|
||||
<link rel="icon" type="image/png" sizes="1024x1024" href="%PUBLIC_URL%/icons/icon-1024x1024.png">
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="%PUBLIC_URL%/icons/icon-512x512.png">
|
||||
<link rel="icon" type="image/png" sizes="384x384" href="%PUBLIC_URL%/icons/icon-384x384.png">
|
||||
<link rel="icon" type="image/png" sizes="310x310" href="%PUBLIC_URL%/icons/icon-310x310.png">
|
||||
<link rel="icon" type="image/png" sizes="256x256" href="%PUBLIC_URL%/icons/icon-256x256.png">
|
||||
<link rel="icon" type="image/png" sizes="228x228" href="%PUBLIC_URL%/icons/icon-228x228.png">
|
||||
<link rel="icon" type="image/png" sizes="196x196" href="%PUBLIC_URL%/icons/icon-196x196.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="%PUBLIC_URL%/icons/icon-192x192.png">
|
||||
<link rel="icon" type="image/png" sizes="180x180" href="%PUBLIC_URL%/icons/icon-180x180.png">
|
||||
<link rel="icon" type="image/png" sizes="167x167" href="%PUBLIC_URL%/icons/icon-167x167.png">
|
||||
<link rel="icon" type="image/png" sizes="160x160" href="%PUBLIC_URL%/icons/icon-160x160.png">
|
||||
<link rel="icon" type="image/png" sizes="152x152" href="%PUBLIC_URL%/icons/icon-152x152.png">
|
||||
<link rel="icon" type="image/png" sizes="150x150" href="%PUBLIC_URL%/icons/icon-150x150.png">
|
||||
<link rel="icon" type="image/png" sizes="144x144" href="%PUBLIC_URL%/icons/icon-144x144.png">
|
||||
<link rel="icon" type="image/png" sizes="128x128" href="%PUBLIC_URL%/icons/icon-128x128.png">
|
||||
<link rel="icon" type="image/png" sizes="120x120" href="%PUBLIC_URL%/icons/icon-120x120.png">
|
||||
<link rel="icon" type="image/png" sizes="114x114" href="%PUBLIC_URL%/icons/icon-114x114.png">
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="%PUBLIC_URL%/icons/icon-96x96.png">
|
||||
<link rel="icon" type="image/png" sizes="76x76" href="%PUBLIC_URL%/icons/icon-76x76.png">
|
||||
<link rel="icon" type="image/png" sizes="72x72" href="%PUBLIC_URL%/icons/icon-72x72.png">
|
||||
<link rel="icon" type="image/png" sizes="64x64" href="%PUBLIC_URL%/icons/icon-64x64.png">
|
||||
<link rel="icon" type="image/png" sizes="60x60" href="%PUBLIC_URL%/icons/icon-60x60.png">
|
||||
<link rel="icon" type="image/png" sizes="48x48" href="%PUBLIC_URL%/icons/icon-48x48.png">
|
||||
<link rel="icon" type="image/png" sizes="40x40" href="%PUBLIC_URL%/icons/icon-40x40.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="%PUBLIC_URL%/icons/icon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="24x24" href="%PUBLIC_URL%/icons/icon-24x24.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="%PUBLIC_URL%/icons/icon-16x16.png">
|
||||
<!-- MS -->
|
||||
<meta name="msapplication-TileImage" content="%PUBLIC_URL%/icons/icon-144x144.png">
|
||||
<meta name="msapplication-square70x70logo" content="%PUBLIC_URL%/icons/icon-70x70.png">
|
||||
<meta name="msapplication-square144x144logo" content="%PUBLIC_URL%/icons/icon-144x144.png">
|
||||
<meta name="msapplication-square150x150logo" content="%PUBLIC_URL%/icons/icon-150x150.png">
|
||||
<meta name="msapplication-square310x310logo" content="%PUBLIC_URL%/icons/icon-310x310.png">
|
||||
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
|
||||
@@ -6,16 +6,66 @@
|
||||
"theme_color": "#4696e5",
|
||||
"background_color": "#4696e5",
|
||||
"icons": [
|
||||
{
|
||||
"src": "./icons/icon-16x16.png",
|
||||
"type": "image/png",
|
||||
"sizes": "16x16"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-24x24.png",
|
||||
"type": "image/png",
|
||||
"sizes": "24x24"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-32x32.png",
|
||||
"type": "image/png",
|
||||
"sizes": "32x32"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-40x40.png",
|
||||
"type": "image/png",
|
||||
"sizes": "40x40"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-48x48.png",
|
||||
"type": "image/png",
|
||||
"sizes": "48x48"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-60x60.png",
|
||||
"type": "image/png",
|
||||
"sizes": "60x60"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-64x64.png",
|
||||
"type": "image/png",
|
||||
"sizes": "64x64"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-72x72.png",
|
||||
"type": "image/png",
|
||||
"sizes": "72x72"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-76x76.png",
|
||||
"type": "image/png",
|
||||
"sizes": "76x76"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-96x96.png",
|
||||
"type": "image/png",
|
||||
"sizes": "96x96"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-114x114.png",
|
||||
"type": "image/png",
|
||||
"sizes": "114x114"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-120x120.png",
|
||||
"type": "image/png",
|
||||
"sizes": "120x120"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-128x128.png",
|
||||
"type": "image/png",
|
||||
@@ -26,20 +76,70 @@
|
||||
"type": "image/png",
|
||||
"sizes": "144x144"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-150x150.png",
|
||||
"type": "image/png",
|
||||
"sizes": "150x150"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-152x152.png",
|
||||
"type": "image/png",
|
||||
"sizes": "152x152"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-160x160.png",
|
||||
"type": "image/png",
|
||||
"sizes": "160x160"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-167x167.png",
|
||||
"type": "image/png",
|
||||
"sizes": "167x167"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-180x180.png",
|
||||
"type": "image/png",
|
||||
"sizes": "180x180"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-192x192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-196x196.png",
|
||||
"type": "image/png",
|
||||
"sizes": "196x196"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-228x228.png",
|
||||
"type": "image/png",
|
||||
"sizes": "228x228"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-256x256.png",
|
||||
"type": "image/png",
|
||||
"sizes": "256x256"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-310x310.png",
|
||||
"type": "image/png",
|
||||
"sizes": "310x310"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-384x384.png",
|
||||
"type": "image/png",
|
||||
"sizes": "384x384"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-512x512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-1024x1024.png",
|
||||
"type": "image/png",
|
||||
"sizes": "1024x1024"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as PropTypes from 'prop-types';
|
||||
import './ErrorHandler.scss';
|
||||
import { Button } from 'reactstrap';
|
||||
|
||||
// FIXME Replace with typescript: (window, console)
|
||||
const ErrorHandler = ({ location }, { error }) => class ErrorHandler extends React.Component {
|
||||
static propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
|
||||
@@ -20,7 +20,7 @@ const mapActionService = (map, actionName) => ({
|
||||
// Wrap actual action service in a function so that it is lazily created the first time it is called
|
||||
[actionName]: lazyService(container, actionName),
|
||||
});
|
||||
const connect = (propsFromState, actionServiceNames) =>
|
||||
const connect = (propsFromState, actionServiceNames = []) =>
|
||||
reduxConnect(
|
||||
propsFromState ? pick(propsFromState) : null,
|
||||
actionServiceNames.reduce(mapActionService, {})
|
||||
|
||||
@@ -59,3 +59,15 @@ body,
|
||||
.paddingless {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.indivisible {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.react-datepicker__day--keyboard-selected {
|
||||
background-color: $mainColor;
|
||||
|
||||
&:hover {
|
||||
background-color: darken($mainColor, 12%);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import shortUrlsListParamsReducer from '../short-urls/reducers/shortUrlsListPara
|
||||
import shortUrlCreationReducer from '../short-urls/reducers/shortUrlCreation';
|
||||
import shortUrlDeletionReducer from '../short-urls/reducers/shortUrlDeletion';
|
||||
import shortUrlTagsReducer from '../short-urls/reducers/shortUrlTags';
|
||||
import shortUrlMetaReducer from '../short-urls/reducers/shortUrlMeta';
|
||||
import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits';
|
||||
import shortUrlDetailReducer from '../visits/reducers/shortUrlDetail';
|
||||
import tagsListReducer from '../tags/reducers/tagsList';
|
||||
@@ -20,6 +21,7 @@ export default combineReducers({
|
||||
shortUrlCreationResult: shortUrlCreationReducer,
|
||||
shortUrlDeletion: shortUrlDeletionReducer,
|
||||
shortUrlTags: shortUrlTagsReducer,
|
||||
shortUrlMeta: shortUrlMetaReducer,
|
||||
shortUrlVisits: shortUrlVisitsReducer,
|
||||
shortUrlDetail: shortUrlDetailReducer,
|
||||
tagsList: tagsListReducer,
|
||||
|
||||
31
src/servers/helpers/ForServerVersion.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { compareVersions } from '../../utils/utils';
|
||||
import { serverType } from '../prop-types';
|
||||
|
||||
const propTypes = {
|
||||
minVersion: PropTypes.string,
|
||||
maxVersion: PropTypes.string,
|
||||
selectedServer: serverType,
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
const ForServerVersion = ({ minVersion, maxVersion, selectedServer, children }) => {
|
||||
if (!selectedServer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { version } = selectedServer;
|
||||
const matchesMinVersion = !minVersion || compareVersions(version, '>=', minVersion);
|
||||
const matchesMaxVersion = !maxVersion || compareVersions(version, '<=', maxVersion);
|
||||
|
||||
if (!matchesMinVersion || !matchesMaxVersion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <React.Fragment>{children}</React.Fragment>;
|
||||
};
|
||||
|
||||
ForServerVersion.propTypes = propTypes;
|
||||
|
||||
export default ForServerVersion;
|
||||
@@ -5,4 +5,5 @@ export const serverType = PropTypes.shape({
|
||||
name: PropTypes.string,
|
||||
url: PropTypes.string,
|
||||
apiKey: PropTypes.string,
|
||||
version: PropTypes.string,
|
||||
});
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { createAction, handleActions } from 'redux-actions';
|
||||
import { resetShortUrlParams } from '../../short-urls/reducers/shortUrlsListParams';
|
||||
import { versionIsValidSemVer } from '../../utils/utils';
|
||||
|
||||
/* eslint-disable padding-line-between-statements */
|
||||
export const SELECT_SERVER = 'shlink/selectedServer/SELECT_SERVER';
|
||||
export const RESET_SELECTED_SERVER = 'shlink/selectedServer/RESET_SELECTED_SERVER';
|
||||
|
||||
export const MIN_FALLBACK_VERSION = '1.0.0';
|
||||
export const MAX_FALLBACK_VERSION = '999.999.999';
|
||||
export const LATEST_VERSION_CONSTRAINT = 'latest';
|
||||
/* eslint-enable padding-line-between-statements */
|
||||
|
||||
const initialState = null;
|
||||
@@ -15,7 +20,10 @@ export const selectServer = ({ findServerById }, buildShlinkApiClient) => (serve
|
||||
|
||||
const selectedServer = findServerById(serverId);
|
||||
const { health } = await buildShlinkApiClient(selectedServer);
|
||||
const { version } = await health().catch(() => ({ version: '1.0.0' }));
|
||||
const version = await health()
|
||||
.then(({ version }) => version === LATEST_VERSION_CONSTRAINT ? MAX_FALLBACK_VERSION : version)
|
||||
.then((version) => !versionIsValidSemVer(version) ? MIN_FALLBACK_VERSION : version)
|
||||
.catch(() => MIN_FALLBACK_VERSION);
|
||||
|
||||
dispatch({
|
||||
type: SELECT_SERVER,
|
||||
|
||||
@@ -30,10 +30,20 @@ export const listServers = ({ listServers, createServers }, { get }) => () => as
|
||||
return;
|
||||
}
|
||||
|
||||
// If local list is empty, try to fetch it remotely and calculate IDs for every server
|
||||
// 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(prop('data'))
|
||||
.then(map(assocId))
|
||||
.then(getDataAsArrayWithIds)
|
||||
.catch(() => []);
|
||||
|
||||
createServers(remoteList);
|
||||
|
||||
@@ -6,6 +6,7 @@ import DeleteServerButton from '../DeleteServerButton';
|
||||
import ImportServersBtn from '../helpers/ImportServersBtn';
|
||||
import { resetSelectedServer, selectServer } from '../reducers/selectedServer';
|
||||
import { createServer, createServers, deleteServer, listServers } from '../reducers/server';
|
||||
import ForServerVersion from '../helpers/ForServerVersion';
|
||||
import ServersImporter from './ServersImporter';
|
||||
import ServersService from './ServersService';
|
||||
import ServersExporter from './ServersExporter';
|
||||
@@ -28,6 +29,9 @@ const provideServices = (bottle, connect, withRouter) => {
|
||||
bottle.serviceFactory('ImportServersBtn', ImportServersBtn, 'ServersImporter');
|
||||
bottle.decorator('ImportServersBtn', connect(null, [ 'createServers' ]));
|
||||
|
||||
bottle.serviceFactory('ForServerVersion', () => ForServerVersion);
|
||||
bottle.decorator('ForServerVersion', connect([ 'selectedServer' ]));
|
||||
|
||||
// Services
|
||||
bottle.constant('csvjson', csvjson);
|
||||
bottle.service('ServersImporter', ServersImporter, 'csvjson');
|
||||
|
||||
@@ -2,11 +2,10 @@ import { faAngleDoubleDown as downIcon, faAngleDoubleUp as upIcon } from '@forta
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { assoc, dissoc, isEmpty, isNil, pipe, replace, trim } from 'ramda';
|
||||
import React from 'react';
|
||||
import { Collapse } from 'reactstrap';
|
||||
import { Collapse, FormGroup, Input } from 'reactstrap';
|
||||
import * as PropTypes from 'prop-types';
|
||||
import DateInput from '../utils/DateInput';
|
||||
import Checkbox from '../utils/Checkbox';
|
||||
import ForVersion from '../utils/ForVersion';
|
||||
import { serverType } from '../servers/prop-types';
|
||||
import { compareVersions } from '../utils/utils';
|
||||
import { createShortUrlResultType } from './reducers/shortUrlCreation';
|
||||
@@ -15,7 +14,11 @@ import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon';
|
||||
const normalizeTag = pipe(trim, replace(/ /g, '-'));
|
||||
const formatDate = (date) => isNil(date) ? date : date.format();
|
||||
|
||||
const CreateShortUrl = (TagsSelector, CreateShortUrlResult) => class CreateShortUrl extends React.Component {
|
||||
const CreateShortUrl = (
|
||||
TagsSelector,
|
||||
CreateShortUrlResult,
|
||||
ForServerVersion
|
||||
) => class CreateShortUrl extends React.Component {
|
||||
static propTypes = {
|
||||
createShortUrl: PropTypes.func,
|
||||
shortUrlCreationResult: createShortUrlResultType,
|
||||
@@ -40,9 +43,8 @@ const CreateShortUrl = (TagsSelector, CreateShortUrlResult) => class CreateShort
|
||||
|
||||
const changeTags = (tags) => this.setState({ tags: tags.map(normalizeTag) });
|
||||
const renderOptionalInput = (id, placeholder, type = 'text', props = {}) => (
|
||||
<div className="form-group">
|
||||
<input
|
||||
className="form-control"
|
||||
<FormGroup>
|
||||
<Input
|
||||
id={id}
|
||||
type={type}
|
||||
placeholder={placeholder}
|
||||
@@ -50,7 +52,7 @@ const CreateShortUrl = (TagsSelector, CreateShortUrlResult) => class CreateShort
|
||||
onChange={(e) => this.setState({ [id]: e.target.value })}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
</FormGroup>
|
||||
);
|
||||
const renderDateInput = (id, placeholder, props = {}) => (
|
||||
<div className="form-group">
|
||||
@@ -117,7 +119,7 @@ const CreateShortUrl = (TagsSelector, CreateShortUrlResult) => class CreateShort
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ForVersion minVersion="1.16.0" currentServerVersion={currentServerVersion}>
|
||||
<ForServerVersion minVersion="1.16.0">
|
||||
<div className="mb-4 text-right">
|
||||
<Checkbox
|
||||
className="mr-2"
|
||||
@@ -128,7 +130,7 @@ const CreateShortUrl = (TagsSelector, CreateShortUrlResult) => class CreateShort
|
||||
</Checkbox>
|
||||
<UseExistingIfFoundInfoIcon />
|
||||
</div>
|
||||
</ForVersion>
|
||||
</ForServerVersion>
|
||||
</Collapse>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { faTags as tagsIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import React from 'react';
|
||||
import { isEmpty } from 'ramda';
|
||||
import { isEmpty, pipe } from 'ramda';
|
||||
import PropTypes from 'prop-types';
|
||||
import moment from 'moment';
|
||||
import SearchField from '../utils/SearchField';
|
||||
import Tag from '../tags/helpers/Tag';
|
||||
import DateRangeRow from '../utils/DateRangeRow';
|
||||
import { formatDate } from '../utils/utils';
|
||||
import { shortUrlsListParamsType } from './reducers/shortUrlsListParams';
|
||||
import './SearchBar.scss';
|
||||
|
||||
@@ -13,19 +16,37 @@ const propTypes = {
|
||||
shortUrlsListParams: shortUrlsListParamsType,
|
||||
};
|
||||
|
||||
const SearchBar = (colorGenerator) => {
|
||||
const dateOrUndefined = (date) => date ? moment(date) : undefined;
|
||||
|
||||
const SearchBar = (colorGenerator, ForServerVersion) => {
|
||||
const SearchBar = ({ listShortUrls, shortUrlsListParams }) => {
|
||||
const selectedTags = shortUrlsListParams.tags || [];
|
||||
const setDate = (dateName) => pipe(
|
||||
formatDate(),
|
||||
(date) => listShortUrls({ ...shortUrlsListParams, [dateName]: date })
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="serach-bar-container">
|
||||
<SearchField onChange={
|
||||
(searchTerm) => listShortUrls({ ...shortUrlsListParams, searchTerm })
|
||||
}
|
||||
<div className="search-bar-container">
|
||||
<SearchField
|
||||
onChange={
|
||||
(searchTerm) => listShortUrls({ ...shortUrlsListParams, searchTerm })
|
||||
}
|
||||
/>
|
||||
|
||||
<ForServerVersion minVersion="1.21.0">
|
||||
<div className="mt-3">
|
||||
<DateRangeRow
|
||||
startDate={dateOrUndefined(shortUrlsListParams.startDate)}
|
||||
endDate={dateOrUndefined(shortUrlsListParams.endDate)}
|
||||
onStartDateChange={setDate('startDate')}
|
||||
onEndDateChange={setDate('endDate')}
|
||||
/>
|
||||
</div>
|
||||
</ForServerVersion>
|
||||
|
||||
{!isEmpty(selectedTags) && (
|
||||
<h4 className="search-bar__selected-tag mt-2">
|
||||
<h4 className="search-bar__selected-tag mt-3">
|
||||
<FontAwesomeIcon icon={tagsIcon} className="search-bar__tags-icon" />
|
||||
|
||||
{selectedTags.map((tag) => (
|
||||
|
||||
@@ -18,6 +18,7 @@ export const SORTABLE_FIELDS = {
|
||||
visits: 'Visits',
|
||||
};
|
||||
|
||||
// FIXME Replace with typescript: (ShortUrlsRow component)
|
||||
const ShortUrlsList = (ShortUrlsRow) => class ShortUrlsList extends React.Component {
|
||||
static propTypes = {
|
||||
listShortUrls: PropTypes.func,
|
||||
@@ -39,12 +40,15 @@ const ShortUrlsList = (ShortUrlsRow) => class ShortUrlsList extends React.Compon
|
||||
...extraParams,
|
||||
});
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -76,8 +80,9 @@ const ShortUrlsList = (ShortUrlsRow) => class ShortUrlsList extends React.Compon
|
||||
componentDidMount() {
|
||||
const { match: { params }, location, shortUrlsListParams } = this.props;
|
||||
const query = qs.parse(location.search, { ignoreQueryPrefix: true });
|
||||
const tags = query.tag ? [ query.tag ] : shortUrlsListParams.tags;
|
||||
|
||||
this.refreshList({ page: params.page, tags: query.tag ? [ query.tag ] : shortUrlsListParams.tags });
|
||||
this.refreshList({ page: params.page, tags });
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import React from 'react';
|
||||
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||
import PropTypes from 'prop-types';
|
||||
import { identity } from 'ramda';
|
||||
import { identity, pipe } from 'ramda';
|
||||
import { shortUrlType } from '../reducers/shortUrlsList';
|
||||
import { shortUrlDeletionType } from '../reducers/shortUrlDeletion';
|
||||
|
||||
const THRESHOLD_REACHED = 'INVALID_SHORTCODE_DELETION';
|
||||
|
||||
export default class DeleteShortUrlModal extends React.Component {
|
||||
static propTypes = {
|
||||
shortUrl: shortUrlType,
|
||||
@@ -13,21 +15,17 @@ export default class DeleteShortUrlModal extends React.Component {
|
||||
shortUrlDeletion: shortUrlDeletionType,
|
||||
deleteShortUrl: PropTypes.func,
|
||||
resetDeleteShortUrl: PropTypes.func,
|
||||
shortUrlDeleted: PropTypes.func,
|
||||
};
|
||||
|
||||
state = { inputValue: '' };
|
||||
handleDeleteUrl = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const { deleteShortUrl, shortUrl, toggle, shortUrlDeleted } = this.props;
|
||||
const { shortCode } = shortUrl;
|
||||
const { deleteShortUrl, shortUrl, toggle } = this.props;
|
||||
const { shortCode, domain } = shortUrl;
|
||||
|
||||
deleteShortUrl(shortCode)
|
||||
.then(() => {
|
||||
shortUrlDeleted(shortCode);
|
||||
toggle();
|
||||
})
|
||||
deleteShortUrl(shortCode, domain)
|
||||
.then(toggle)
|
||||
.catch(identity);
|
||||
};
|
||||
|
||||
@@ -38,15 +36,17 @@ export default class DeleteShortUrlModal extends React.Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { shortUrl, toggle, isOpen, shortUrlDeletion } = this.props;
|
||||
const THRESHOLD_REACHED = 'INVALID_SHORTCODE_DELETION';
|
||||
const hasThresholdError = shortUrlDeletion.error && shortUrlDeletion.errorData.error === THRESHOLD_REACHED;
|
||||
const hasErrorOtherThanThreshold = shortUrlDeletion.error && shortUrlDeletion.errorData.error !== THRESHOLD_REACHED;
|
||||
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);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} toggle={toggle} centered>
|
||||
<Modal isOpen={isOpen} toggle={close} centered>
|
||||
<form onSubmit={this.handleDeleteUrl}>
|
||||
<ModalHeader toggle={toggle}>
|
||||
<ModalHeader toggle={close}>
|
||||
<span className="text-danger">Delete short URL</span>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
@@ -63,7 +63,8 @@ export default class DeleteShortUrlModal extends React.Component {
|
||||
|
||||
{hasThresholdError && (
|
||||
<div className="p-2 mt-2 bg-warning text-center">
|
||||
This short URL has received too many visits and therefore, it cannot be deleted
|
||||
{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 && (
|
||||
@@ -73,7 +74,7 @@ export default class DeleteShortUrlModal extends React.Component {
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<button type="button" className="btn btn-link" onClick={toggle}>Cancel</button>
|
||||
<button type="button" className="btn btn-link" onClick={close}>Cancel</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-danger"
|
||||
|
||||
100
src/short-urls/helpers/EditMetaModal.js
Normal file
@@ -0,0 +1,100 @@
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Modal, ModalBody, ModalFooter, ModalHeader, FormGroup, Input, UncontrolledTooltip } from 'reactstrap';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { ExternalLink } from 'react-external-link';
|
||||
import moment from 'moment';
|
||||
import { isEmpty, pipe } from 'ramda';
|
||||
import { shortUrlType } from '../reducers/shortUrlsList';
|
||||
import { shortUrlEditMetaType } from '../reducers/shortUrlMeta';
|
||||
import DateInput from '../../utils/DateInput';
|
||||
import { formatIsoDate } from '../../utils/utils';
|
||||
|
||||
const propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
toggle: PropTypes.func.isRequired,
|
||||
shortUrl: shortUrlType.isRequired,
|
||||
shortUrlMeta: shortUrlEditMetaType,
|
||||
editShortUrlMeta: PropTypes.func,
|
||||
resetShortUrlMeta: PropTypes.func,
|
||||
};
|
||||
|
||||
const dateOrUndefined = (shortUrl, dateName) => {
|
||||
const date = shortUrl && shortUrl.meta && shortUrl.meta[dateName];
|
||||
|
||||
return date && moment(date);
|
||||
};
|
||||
|
||||
const EditMetaModal = (
|
||||
{ isOpen, toggle, shortUrl, shortUrlMeta, editShortUrlMeta, resetShortUrlMeta }
|
||||
) => {
|
||||
const { saving, error } = shortUrlMeta;
|
||||
const url = shortUrl && (shortUrl.shortUrl || '');
|
||||
const [ validSince, setValidSince ] = useState(dateOrUndefined(shortUrl, 'validSince'));
|
||||
const [ validUntil, setValidUntil ] = useState(dateOrUndefined(shortUrl, 'validUntil'));
|
||||
const [ maxVisits, setMaxVisits ] = useState(shortUrl && shortUrl.meta && shortUrl.meta.maxVisits);
|
||||
|
||||
const close = pipe(resetShortUrlMeta, toggle);
|
||||
const doEdit = () => editShortUrlMeta(shortUrl.shortCode, shortUrl.domain, {
|
||||
maxVisits: maxVisits && !isEmpty(maxVisits) ? parseInt(maxVisits) : null,
|
||||
validSince: validSince && formatIsoDate(validSince),
|
||||
validUntil: validUntil && formatIsoDate(validUntil),
|
||||
}).then(close);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} toggle={close} centered>
|
||||
<ModalHeader toggle={close}>
|
||||
<FontAwesomeIcon icon={infoIcon} id="metaTitleInfo" /> Edit metadata for <ExternalLink href={url} />
|
||||
<UncontrolledTooltip target="metaTitleInfo" placement="bottom">
|
||||
<p>Using these metadata properties, you can limit when and how many times your short URL can be visited.</p>
|
||||
<p>If any of the params is not met, the URL will behave as if it was an invalid short URL.</p>
|
||||
</UncontrolledTooltip>
|
||||
</ModalHeader>
|
||||
<form onSubmit={(e) => e.preventDefault() || doEdit()}>
|
||||
<ModalBody>
|
||||
<FormGroup>
|
||||
<DateInput
|
||||
placeholderText="Enabled since..."
|
||||
selected={validSince}
|
||||
maxDate={validUntil}
|
||||
isClearable
|
||||
onChange={setValidSince}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup>
|
||||
<DateInput
|
||||
placeholderText="Enabled until..."
|
||||
selected={validUntil}
|
||||
minDate={validSince}
|
||||
isClearable
|
||||
onChange={setValidUntil}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup className="mb-0">
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Maximum number of visits allowed"
|
||||
min={1}
|
||||
value={maxVisits || ''}
|
||||
onChange={(e) => setMaxVisits(e.target.value)}
|
||||
/>
|
||||
</FormGroup>
|
||||
{error && (
|
||||
<div className="p-2 mt-2 bg-danger text-white text-center">
|
||||
Something went wrong while saving the metadata :(
|
||||
</div>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<button className="btn btn-link" type="button" onClick={close}>Cancel</button>
|
||||
<button className="btn btn-primary" type="submit" disabled={saving}>{saving ? 'Saving...' : 'Save'}</button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
EditMetaModal.propTypes = propTypes;
|
||||
|
||||
export default EditMetaModal;
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from 'react';
|
||||
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||
import PropTypes from 'prop-types';
|
||||
import ExternalLink from '../../utils/ExternalLink';
|
||||
import { ExternalLink } from 'react-external-link';
|
||||
import { pipe } from 'ramda';
|
||||
import { shortUrlTagsType } from '../reducers/shortUrlTags';
|
||||
import { shortUrlType } from '../reducers/shortUrlsList';
|
||||
|
||||
@@ -9,40 +10,24 @@ const EditTagsModal = (TagsSelector) => class EditTagsModal extends React.Compon
|
||||
static propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
toggle: PropTypes.func.isRequired,
|
||||
url: PropTypes.string.isRequired,
|
||||
shortUrl: shortUrlType.isRequired,
|
||||
shortUrlTags: shortUrlTagsType,
|
||||
editShortUrlTags: PropTypes.func,
|
||||
shortUrlTagsEdited: PropTypes.func,
|
||||
resetShortUrlsTags: PropTypes.func,
|
||||
};
|
||||
|
||||
saveTags = () => {
|
||||
const { editShortUrlTags, shortUrl, toggle } = this.props;
|
||||
|
||||
editShortUrlTags(shortUrl.shortCode, this.state.tags)
|
||||
.then(() => {
|
||||
this.tagsSaved = true;
|
||||
toggle();
|
||||
})
|
||||
editShortUrlTags(shortUrl.shortCode, shortUrl.domain, this.state.tags)
|
||||
.then(toggle)
|
||||
.catch(() => {});
|
||||
};
|
||||
refreshShortUrls = () => {
|
||||
if (!this.tagsSaved) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { shortUrlTagsEdited, shortUrl, shortUrlTags } = this.props;
|
||||
const { tags } = shortUrlTags;
|
||||
|
||||
shortUrlTagsEdited(shortUrl.shortCode, tags);
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
const { resetShortUrlsTags } = this.props;
|
||||
|
||||
resetShortUrlsTags();
|
||||
this.tagsSaved = false;
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
@@ -51,12 +36,14 @@ const EditTagsModal = (TagsSelector) => class EditTagsModal extends React.Compon
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isOpen, toggle, url, shortUrlTags } = this.props;
|
||||
const { isOpen, toggle, shortUrl, shortUrlTags, resetShortUrlsTags } = this.props;
|
||||
const url = shortUrl && (shortUrl.shortUrl || '');
|
||||
const close = pipe(resetShortUrlsTags, toggle);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} toggle={toggle} centered onClosed={() => this.refreshShortUrls()}>
|
||||
<ModalHeader toggle={toggle}>
|
||||
Edit tags for <ExternalLink href={url}>{url}</ExternalLink>
|
||||
<Modal isOpen={isOpen} toggle={close} centered>
|
||||
<ModalHeader toggle={close}>
|
||||
Edit tags for <ExternalLink href={url} />
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<TagsSelector tags={this.state.tags} onChange={(tags) => this.setState({ tags })} />
|
||||
@@ -67,7 +54,7 @@ const EditTagsModal = (TagsSelector) => class EditTagsModal extends React.Compon
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<button className="btn btn-link" onClick={toggle}>Cancel</button>
|
||||
<button className="btn btn-link" onClick={close}>Cancel</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
type="button"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react';
|
||||
import { Modal, ModalBody, ModalHeader } from 'reactstrap';
|
||||
import PropTypes from 'prop-types';
|
||||
import { ExternalLink } from 'react-external-link';
|
||||
import './PreviewModal.scss';
|
||||
import ExternalLink from '../../utils/ExternalLink';
|
||||
|
||||
const propTypes = {
|
||||
url: PropTypes.string,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react';
|
||||
import { Modal, ModalBody, ModalHeader } from 'reactstrap';
|
||||
import PropTypes from 'prop-types';
|
||||
import { ExternalLink } from 'react-external-link';
|
||||
import './QrCodeModal.scss';
|
||||
import ExternalLink from '../../utils/ExternalLink';
|
||||
|
||||
const propTypes = {
|
||||
url: PropTypes.string,
|
||||
|
||||
49
src/short-urls/helpers/ShortUrlVisitsCount.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import React 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 { serverType } from '../../servers/prop-types';
|
||||
import { shortUrlType } from '../reducers/shortUrlsList';
|
||||
import './ShortUrlVisitsCount.scss';
|
||||
import VisitStatsLink from './VisitStatsLink';
|
||||
|
||||
const propTypes = {
|
||||
visitsCount: PropTypes.number.isRequired,
|
||||
shortUrl: shortUrlType,
|
||||
selectedServer: serverType,
|
||||
};
|
||||
|
||||
const ShortUrlVisitsCount = ({ visitsCount, shortUrl, selectedServer }) => {
|
||||
const maxVisits = shortUrl && shortUrl.meta && shortUrl.meta.maxVisits;
|
||||
const visitsLink = (
|
||||
<VisitStatsLink selectedServer={selectedServer} shortUrl={shortUrl}>
|
||||
<strong>{visitsCount}</strong>
|
||||
</VisitStatsLink>
|
||||
);
|
||||
|
||||
if (!maxVisits) {
|
||||
return visitsLink;
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<span className="indivisible">
|
||||
{visitsLink}
|
||||
<small id="maxVisitsControl" className="short-urls-visits-count__max-visits-control">
|
||||
{' '}/ {maxVisits}{' '}
|
||||
<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>
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
ShortUrlVisitsCount.propTypes = propTypes;
|
||||
|
||||
export default ShortUrlVisitsCount;
|
||||
3
src/short-urls/helpers/ShortUrlVisitsCount.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
.short-urls-visits-count__max-visits-control {
|
||||
cursor: help;
|
||||
}
|
||||
@@ -2,11 +2,12 @@ import { isEmpty } from 'ramda';
|
||||
import React from 'react';
|
||||
import Moment from 'react-moment';
|
||||
import PropTypes from 'prop-types';
|
||||
import { ExternalLink } from 'react-external-link';
|
||||
import { shortUrlsListParamsType } from '../reducers/shortUrlsListParams';
|
||||
import { serverType } from '../../servers/prop-types';
|
||||
import ExternalLink from '../../utils/ExternalLink';
|
||||
import { shortUrlType } from '../reducers/shortUrlsList';
|
||||
import Tag from '../../tags/helpers/Tag';
|
||||
import ShortUrlVisitsCount from './ShortUrlVisitsCount';
|
||||
import './ShortUrlsRow.scss';
|
||||
|
||||
const ShortUrlsRow = (
|
||||
@@ -56,7 +57,13 @@ const ShortUrlsRow = (
|
||||
<ExternalLink href={shortUrl.longUrl} />
|
||||
</td>
|
||||
<td className="short-urls-row__cell" data-th="Tags: ">{this.renderTags(shortUrl.tags)}</td>
|
||||
<td className="short-urls-row__cell text-md-right" data-th="Visits: ">{shortUrl.visitsCount}</td>
|
||||
<td className="short-urls-row__cell text-md-right" data-th="Visits: ">
|
||||
<ShortUrlVisitsCount
|
||||
visitsCount={shortUrl.visitsCount}
|
||||
shortUrl={shortUrl}
|
||||
selectedServer={selectedServer}
|
||||
/>
|
||||
</td>
|
||||
<td className="short-urls-row__cell short-urls-row__cell--relative">
|
||||
<small
|
||||
className="badge badge-warning short-urls-row__copy-hint"
|
||||
|
||||
@@ -5,20 +5,26 @@ import {
|
||||
faEllipsisV as menuIcon,
|
||||
faQrcode as qrIcon,
|
||||
faMinusCircle as deleteIcon,
|
||||
faEdit as editIcon,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import React from 'react';
|
||||
import { CopyToClipboard } from 'react-copy-to-clipboard';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ButtonDropdown, DropdownItem, DropdownMenu, DropdownToggle } from 'reactstrap';
|
||||
import PropTypes from 'prop-types';
|
||||
import { serverType } from '../../servers/prop-types';
|
||||
import { shortUrlType } from '../reducers/shortUrlsList';
|
||||
import PreviewModal from './PreviewModal';
|
||||
import QrCodeModal from './QrCodeModal';
|
||||
import VisitStatsLink from './VisitStatsLink';
|
||||
import './ShortUrlsRowMenu.scss';
|
||||
|
||||
const ShortUrlsRowMenu = (DeleteShortUrlModal, EditTagsModal) => class ShortUrlsRowMenu extends React.Component {
|
||||
const ShortUrlsRowMenu = (
|
||||
DeleteShortUrlModal,
|
||||
EditTagsModal,
|
||||
EditMetaModal,
|
||||
ForServerVersion
|
||||
) => class ShortUrlsRowMenu extends React.Component {
|
||||
static propTypes = {
|
||||
onCopyToClipboard: PropTypes.func,
|
||||
selectedServer: serverType,
|
||||
@@ -30,6 +36,7 @@ const ShortUrlsRowMenu = (DeleteShortUrlModal, EditTagsModal) => class ShortUrls
|
||||
isQrModalOpen: false,
|
||||
isPreviewModalOpen: false,
|
||||
isTagsModalOpen: false,
|
||||
isMetaModalOpen: false,
|
||||
isDeleteModalOpen: false,
|
||||
};
|
||||
toggle = () => this.setState(({ isOpen }) => ({ isOpen: !isOpen }));
|
||||
@@ -41,6 +48,7 @@ const ShortUrlsRowMenu = (DeleteShortUrlModal, EditTagsModal) => class ShortUrls
|
||||
const toggleQrCode = toggleModal('isQrModalOpen');
|
||||
const togglePreview = toggleModal('isPreviewModalOpen');
|
||||
const toggleTags = toggleModal('isTagsModalOpen');
|
||||
const toggleMeta = toggleModal('isMetaModalOpen');
|
||||
const toggleDelete = toggleModal('isDeleteModalOpen');
|
||||
|
||||
return (
|
||||
@@ -49,42 +57,48 @@ const ShortUrlsRowMenu = (DeleteShortUrlModal, EditTagsModal) => class ShortUrls
|
||||
<FontAwesomeIcon icon={menuIcon} />
|
||||
</DropdownToggle>
|
||||
<DropdownMenu right>
|
||||
<DropdownItem tag={Link} to={`/server/${selectedServer ? selectedServer.id : ''}/short-code/${shortUrl.shortCode}/visits`}>
|
||||
<FontAwesomeIcon icon={pieChartIcon} /> Visit stats
|
||||
<DropdownItem tag={VisitStatsLink} selectedServer={selectedServer} shortUrl={shortUrl}>
|
||||
<FontAwesomeIcon icon={pieChartIcon} fixedWidth /> Visit stats
|
||||
</DropdownItem>
|
||||
|
||||
<DropdownItem onClick={toggleTags}>
|
||||
<FontAwesomeIcon icon={tagsIcon} /> Edit tags
|
||||
<FontAwesomeIcon icon={tagsIcon} fixedWidth /> Edit tags
|
||||
</DropdownItem>
|
||||
<EditTagsModal
|
||||
url={completeShortUrl}
|
||||
shortUrl={shortUrl}
|
||||
isOpen={this.state.isTagsModalOpen}
|
||||
toggle={toggleTags}
|
||||
/>
|
||||
<EditTagsModal shortUrl={shortUrl} isOpen={this.state.isTagsModalOpen} toggle={toggleTags} />
|
||||
|
||||
<ForServerVersion minVersion="1.18.0">
|
||||
<DropdownItem onClick={toggleMeta}>
|
||||
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit metadata
|
||||
</DropdownItem>
|
||||
<EditMetaModal shortUrl={shortUrl} isOpen={this.state.isMetaModalOpen} toggle={toggleMeta} />
|
||||
</ForServerVersion>
|
||||
|
||||
<DropdownItem className="short-urls-row-menu__dropdown-item--danger" onClick={toggleDelete}>
|
||||
<FontAwesomeIcon icon={deleteIcon} /> Delete short URL
|
||||
<FontAwesomeIcon icon={deleteIcon} fixedWidth /> Delete short URL
|
||||
</DropdownItem>
|
||||
<DeleteShortUrlModal shortUrl={shortUrl} isOpen={this.state.isDeleteModalOpen} toggle={toggleDelete} />
|
||||
|
||||
<DropdownItem divider />
|
||||
|
||||
<DropdownItem onClick={togglePreview}>
|
||||
<FontAwesomeIcon icon={pictureIcon} /> Preview
|
||||
</DropdownItem>
|
||||
<PreviewModal url={completeShortUrl} isOpen={this.state.isPreviewModalOpen} toggle={togglePreview} />
|
||||
<ForServerVersion maxVersion="1.x">
|
||||
<DropdownItem onClick={togglePreview}>
|
||||
<FontAwesomeIcon icon={pictureIcon} fixedWidth /> Preview
|
||||
</DropdownItem>
|
||||
<PreviewModal url={completeShortUrl} isOpen={this.state.isPreviewModalOpen} toggle={togglePreview} />
|
||||
</ForServerVersion>
|
||||
|
||||
<DropdownItem onClick={toggleQrCode}>
|
||||
<FontAwesomeIcon icon={qrIcon} /> QR code
|
||||
<FontAwesomeIcon icon={qrIcon} fixedWidth /> QR code
|
||||
</DropdownItem>
|
||||
<QrCodeModal url={completeShortUrl} isOpen={this.state.isQrModalOpen} toggle={toggleQrCode} />
|
||||
|
||||
<DropdownItem divider />
|
||||
<ForServerVersion maxVersion="1.x">
|
||||
<DropdownItem divider />
|
||||
</ForServerVersion>
|
||||
|
||||
<CopyToClipboard text={completeShortUrl} onCopy={onCopyToClipboard}>
|
||||
<DropdownItem>
|
||||
<FontAwesomeIcon icon={copyIcon} /> Copy to clipboard
|
||||
<FontAwesomeIcon icon={copyIcon} fixedWidth /> Copy to clipboard
|
||||
</DropdownItem>
|
||||
</CopyToClipboard>
|
||||
</DropdownMenu>
|
||||
|
||||
29
src/short-urls/helpers/VisitStatsLink.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { serverType } from '../../servers/prop-types';
|
||||
import { shortUrlType } from '../reducers/shortUrlsList';
|
||||
|
||||
const propTypes = {
|
||||
shortUrl: shortUrlType,
|
||||
selectedServer: serverType,
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
const buildVisitsUrl = ({ id }, { shortCode, domain }) => {
|
||||
const query = domain ? `?domain=${domain}` : '';
|
||||
|
||||
return `/server/${id}/short-code/${shortCode}/visits${query}`;
|
||||
};
|
||||
|
||||
const VisitStatsLink = ({ selectedServer, shortUrl, children, ...rest }) => {
|
||||
if (!selectedServer || !shortUrl) {
|
||||
return <span {...rest}>{children}</span>;
|
||||
}
|
||||
|
||||
return <Link to={buildVisitsUrl(selectedServer, shortUrl)} {...rest}>{children}</Link>;
|
||||
};
|
||||
|
||||
VisitStatsLink.propTypes = propTypes;
|
||||
|
||||
export default VisitStatsLink;
|
||||
@@ -1,22 +1,19 @@
|
||||
import { createAction, handleActions } from 'redux-actions';
|
||||
import PropTypes from 'prop-types';
|
||||
import { apiErrorType } from '../../utils/services/ShlinkApiClient';
|
||||
|
||||
/* eslint-disable padding-line-between-statements */
|
||||
export const DELETE_SHORT_URL_START = 'shlink/deleteShortUrl/DELETE_SHORT_URL_START';
|
||||
export const DELETE_SHORT_URL_ERROR = 'shlink/deleteShortUrl/DELETE_SHORT_URL_ERROR';
|
||||
export const DELETE_SHORT_URL = 'shlink/deleteShortUrl/DELETE_SHORT_URL';
|
||||
export const RESET_DELETE_SHORT_URL = 'shlink/deleteShortUrl/RESET_DELETE_SHORT_URL';
|
||||
export const SHORT_URL_DELETED = 'shlink/deleteShortUrl/SHORT_URL_DELETED';
|
||||
export const RESET_DELETE_SHORT_URL = 'shlink/deleteShortUrl/RESET_DELETE_SHORT_URL';
|
||||
/* eslint-enable padding-line-between-statements */
|
||||
|
||||
export const shortUrlDeletionType = PropTypes.shape({
|
||||
shortCode: PropTypes.string.isRequired,
|
||||
loading: PropTypes.bool.isRequired,
|
||||
error: PropTypes.bool.isRequired,
|
||||
errorData: PropTypes.shape({
|
||||
error: PropTypes.string,
|
||||
message: PropTypes.string,
|
||||
}).isRequired,
|
||||
errorData: apiErrorType.isRequired,
|
||||
});
|
||||
|
||||
const initialState = {
|
||||
@@ -29,18 +26,18 @@ const initialState = {
|
||||
export default handleActions({
|
||||
[DELETE_SHORT_URL_START]: (state) => ({ ...state, loading: true, error: false }),
|
||||
[DELETE_SHORT_URL_ERROR]: (state, { errorData }) => ({ ...state, errorData, loading: false, error: true }),
|
||||
[DELETE_SHORT_URL]: (state, { shortCode }) => ({ ...state, shortCode, loading: false, error: false }),
|
||||
[SHORT_URL_DELETED]: (state, { shortCode }) => ({ ...state, shortCode, loading: false, error: false }),
|
||||
[RESET_DELETE_SHORT_URL]: () => initialState,
|
||||
}, initialState);
|
||||
|
||||
export const deleteShortUrl = (buildShlinkApiClient) => (shortCode) => async (dispatch, getState) => {
|
||||
export const deleteShortUrl = (buildShlinkApiClient) => (shortCode, domain) => async (dispatch, getState) => {
|
||||
dispatch({ type: DELETE_SHORT_URL_START });
|
||||
|
||||
const { deleteShortUrl } = await buildShlinkApiClient(getState);
|
||||
|
||||
try {
|
||||
await deleteShortUrl(shortCode);
|
||||
dispatch({ type: DELETE_SHORT_URL, shortCode });
|
||||
await deleteShortUrl(shortCode, domain);
|
||||
dispatch({ type: SHORT_URL_DELETED, shortCode, domain });
|
||||
} catch (e) {
|
||||
dispatch({ type: DELETE_SHORT_URL_ERROR, errorData: e.response.data });
|
||||
|
||||
@@ -49,5 +46,3 @@ export const deleteShortUrl = (buildShlinkApiClient) => (shortCode) => async (di
|
||||
};
|
||||
|
||||
export const resetDeleteShortUrl = createAction(RESET_DELETE_SHORT_URL);
|
||||
|
||||
export const shortUrlDeleted = (shortCode) => ({ type: SHORT_URL_DELETED, shortCode });
|
||||
|
||||
52
src/short-urls/reducers/shortUrlMeta.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import { createAction, handleActions } from 'redux-actions';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
/* eslint-disable padding-line-between-statements */
|
||||
export const EDIT_SHORT_URL_META_START = 'shlink/shortUrlMeta/EDIT_SHORT_URL_META_START';
|
||||
export const EDIT_SHORT_URL_META_ERROR = 'shlink/shortUrlMeta/EDIT_SHORT_URL_META_ERROR';
|
||||
export const SHORT_URL_META_EDITED = 'shlink/shortUrlMeta/SHORT_URL_META_EDITED';
|
||||
export const RESET_EDIT_SHORT_URL_META = 'shlink/shortUrlMeta/RESET_EDIT_SHORT_URL_META';
|
||||
/* eslint-enable padding-line-between-statements */
|
||||
|
||||
export const shortUrlMetaType = PropTypes.shape({
|
||||
validSince: PropTypes.string,
|
||||
validUntil: PropTypes.string,
|
||||
maxVisits: PropTypes.number,
|
||||
});
|
||||
|
||||
export const shortUrlEditMetaType = PropTypes.shape({
|
||||
shortCode: PropTypes.string,
|
||||
meta: shortUrlMetaType.isRequired,
|
||||
saving: PropTypes.bool.isRequired,
|
||||
error: PropTypes.bool.isRequired,
|
||||
});
|
||||
|
||||
const initialState = {
|
||||
shortCode: null,
|
||||
meta: {},
|
||||
saving: false,
|
||||
error: false,
|
||||
};
|
||||
|
||||
export default handleActions({
|
||||
[EDIT_SHORT_URL_META_START]: (state) => ({ ...state, saving: true, error: false }),
|
||||
[EDIT_SHORT_URL_META_ERROR]: (state) => ({ ...state, saving: false, error: true }),
|
||||
[SHORT_URL_META_EDITED]: (state, { shortCode, meta }) => ({ shortCode, meta, saving: false, error: false }),
|
||||
[RESET_EDIT_SHORT_URL_META]: () => initialState,
|
||||
}, initialState);
|
||||
|
||||
export const editShortUrlMeta = (buildShlinkApiClient) => (shortCode, domain, meta) => async (dispatch, getState) => {
|
||||
dispatch({ type: EDIT_SHORT_URL_META_START });
|
||||
const { updateShortUrlMeta } = await buildShlinkApiClient(getState);
|
||||
|
||||
try {
|
||||
await updateShortUrlMeta(shortCode, domain, meta);
|
||||
dispatch({ shortCode, meta, domain, type: SHORT_URL_META_EDITED });
|
||||
} catch (e) {
|
||||
dispatch({ type: EDIT_SHORT_URL_META_ERROR });
|
||||
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
export const resetShortUrlMeta = createAction(RESET_EDIT_SHORT_URL_META);
|
||||
@@ -4,9 +4,8 @@ import PropTypes from 'prop-types';
|
||||
/* eslint-disable padding-line-between-statements */
|
||||
export const EDIT_SHORT_URL_TAGS_START = 'shlink/shortUrlTags/EDIT_SHORT_URL_TAGS_START';
|
||||
export const EDIT_SHORT_URL_TAGS_ERROR = 'shlink/shortUrlTags/EDIT_SHORT_URL_TAGS_ERROR';
|
||||
export const EDIT_SHORT_URL_TAGS = 'shlink/shortUrlTags/EDIT_SHORT_URL_TAGS';
|
||||
export const RESET_EDIT_SHORT_URL_TAGS = 'shlink/shortUrlTags/RESET_EDIT_SHORT_URL_TAGS';
|
||||
export const SHORT_URL_TAGS_EDITED = 'shlink/shortUrlTags/SHORT_URL_TAGS_EDITED';
|
||||
export const RESET_EDIT_SHORT_URL_TAGS = 'shlink/shortUrlTags/RESET_EDIT_SHORT_URL_TAGS';
|
||||
/* eslint-enable padding-line-between-statements */
|
||||
|
||||
export const shortUrlTagsType = PropTypes.shape({
|
||||
@@ -26,18 +25,18 @@ const initialState = {
|
||||
export default handleActions({
|
||||
[EDIT_SHORT_URL_TAGS_START]: (state) => ({ ...state, saving: true, error: false }),
|
||||
[EDIT_SHORT_URL_TAGS_ERROR]: (state) => ({ ...state, saving: false, error: true }),
|
||||
[EDIT_SHORT_URL_TAGS]: (state, { shortCode, tags }) => ({ shortCode, tags, saving: false, error: false }),
|
||||
[SHORT_URL_TAGS_EDITED]: (state, { shortCode, tags }) => ({ shortCode, tags, saving: false, error: false }),
|
||||
[RESET_EDIT_SHORT_URL_TAGS]: () => initialState,
|
||||
}, initialState);
|
||||
|
||||
export const editShortUrlTags = (buildShlinkApiClient) => (shortCode, tags) => async (dispatch, getState) => {
|
||||
export const editShortUrlTags = (buildShlinkApiClient) => (shortCode, domain, tags) => async (dispatch, getState) => {
|
||||
dispatch({ type: EDIT_SHORT_URL_TAGS_START });
|
||||
const { updateShortUrlTags } = await buildShlinkApiClient(getState);
|
||||
|
||||
try {
|
||||
const normalizedTags = await updateShortUrlTags(shortCode, tags);
|
||||
const normalizedTags = await updateShortUrlTags(shortCode, domain, tags);
|
||||
|
||||
dispatch({ tags: normalizedTags, shortCode, type: EDIT_SHORT_URL_TAGS });
|
||||
dispatch({ tags: normalizedTags, shortCode, domain, type: SHORT_URL_TAGS_EDITED });
|
||||
} catch (e) {
|
||||
dispatch({ type: EDIT_SHORT_URL_TAGS_ERROR });
|
||||
|
||||
@@ -46,9 +45,3 @@ export const editShortUrlTags = (buildShlinkApiClient) => (shortCode, tags) => a
|
||||
};
|
||||
|
||||
export const resetShortUrlsTags = createAction(RESET_EDIT_SHORT_URL_TAGS);
|
||||
|
||||
export const shortUrlTagsEdited = (shortCode, tags) => ({
|
||||
tags,
|
||||
shortCode,
|
||||
type: SHORT_URL_TAGS_EDITED,
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { handleActions } from 'redux-actions';
|
||||
import { assoc, assocPath, propEq, reject } from 'ramda';
|
||||
import { assoc, assocPath, isNil, reject } from 'ramda';
|
||||
import PropTypes from 'prop-types';
|
||||
import { SHORT_URL_TAGS_EDITED } from './shortUrlTags';
|
||||
import { SHORT_URL_DELETED } from './shortUrlDeletion';
|
||||
import { SHORT_URL_META_EDITED, shortUrlMetaType } from './shortUrlMeta';
|
||||
|
||||
/* eslint-disable padding-line-between-statements */
|
||||
export const LIST_SHORT_URLS_START = 'shlink/shortUrlsList/LIST_SHORT_URLS_START';
|
||||
@@ -14,7 +15,10 @@ export const shortUrlType = PropTypes.shape({
|
||||
shortCode: PropTypes.string,
|
||||
shortUrl: PropTypes.string,
|
||||
longUrl: PropTypes.string,
|
||||
visitsCount: PropTypes.number,
|
||||
meta: shortUrlMetaType,
|
||||
tags: PropTypes.arrayOf(PropTypes.string),
|
||||
domain: PropTypes.string,
|
||||
});
|
||||
|
||||
const initialState = {
|
||||
@@ -23,23 +27,33 @@ 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(
|
||||
(shortUrl) => shortUrlMatches(shortUrl, shortCode, domain) ? assoc(prop, propValue, shortUrl) : shortUrl
|
||||
),
|
||||
state
|
||||
);
|
||||
|
||||
export default handleActions({
|
||||
[LIST_SHORT_URLS_START]: (state) => ({ ...state, loading: true, error: false }),
|
||||
[LIST_SHORT_URLS]: (state, { shortUrls }) => ({ loading: false, error: false, shortUrls }),
|
||||
[LIST_SHORT_URLS_ERROR]: () => ({ loading: false, error: true, shortUrls: {} }),
|
||||
[SHORT_URL_TAGS_EDITED]: (state, action) => { // eslint-disable-line object-shorthand
|
||||
const { data } = state.shortUrls;
|
||||
|
||||
return assocPath([ 'shortUrls', 'data' ], data.map((shortUrl) =>
|
||||
shortUrl.shortCode === action.shortCode
|
||||
? assoc('tags', action.tags, shortUrl)
|
||||
: shortUrl), state);
|
||||
},
|
||||
[SHORT_URL_DELETED]: (state, action) => assocPath(
|
||||
[SHORT_URL_DELETED]: (state, { shortCode, domain }) => assocPath(
|
||||
[ 'shortUrls', 'data' ],
|
||||
reject(propEq('shortCode', action.shortCode), state.shortUrls.data),
|
||||
reject((shortUrl) => shortUrlMatches(shortUrl, shortCode, domain), state.shortUrls.data),
|
||||
state,
|
||||
),
|
||||
[SHORT_URL_TAGS_EDITED]: setPropFromActionOnMatchingShortUrl('tags'),
|
||||
[SHORT_URL_META_EDITED]: setPropFromActionOnMatchingShortUrl('meta'),
|
||||
}, initialState);
|
||||
|
||||
export const listShortUrls = (buildShlinkApiClient) => (params = {}) => async (dispatch, getState) => {
|
||||
|
||||
@@ -8,6 +8,9 @@ export const shortUrlsListParamsType = PropTypes.shape({
|
||||
page: PropTypes.string,
|
||||
tags: PropTypes.arrayOf(PropTypes.string),
|
||||
searchTerm: PropTypes.string,
|
||||
startDate: PropTypes.string,
|
||||
endDate: PropTypes.string,
|
||||
orderBy: PropTypes.object,
|
||||
});
|
||||
|
||||
const initialState = { page: '1' };
|
||||
|
||||
@@ -8,11 +8,13 @@ import ShortUrlsRowMenu from '../helpers/ShortUrlsRowMenu';
|
||||
import CreateShortUrl from '../CreateShortUrl';
|
||||
import DeleteShortUrlModal from '../helpers/DeleteShortUrlModal';
|
||||
import EditTagsModal from '../helpers/EditTagsModal';
|
||||
import EditMetaModal from '../helpers/EditMetaModal';
|
||||
import CreateShortUrlResult from '../helpers/CreateShortUrlResult';
|
||||
import { listShortUrls } from '../reducers/shortUrlsList';
|
||||
import { createShortUrl, resetCreateShortUrl } from '../reducers/shortUrlCreation';
|
||||
import { deleteShortUrl, resetDeleteShortUrl, shortUrlDeleted } from '../reducers/shortUrlDeletion';
|
||||
import { editShortUrlTags, resetShortUrlsTags, shortUrlTagsEdited } from '../reducers/shortUrlTags';
|
||||
import { deleteShortUrl, resetDeleteShortUrl } from '../reducers/shortUrlDeletion';
|
||||
import { editShortUrlTags, resetShortUrlsTags } from '../reducers/shortUrlTags';
|
||||
import { editShortUrlMeta, resetShortUrlMeta } from '../reducers/shortUrlMeta';
|
||||
import { resetShortUrlParams } from '../reducers/shortUrlsListParams';
|
||||
|
||||
const provideServices = (bottle, connect) => {
|
||||
@@ -22,7 +24,7 @@ const provideServices = (bottle, connect) => {
|
||||
(state) => assoc('shortUrlsList', state.shortUrlsList.shortUrls, state.shortUrlsList)
|
||||
));
|
||||
|
||||
bottle.serviceFactory('SearchBar', SearchBar, 'ColorGenerator');
|
||||
bottle.serviceFactory('SearchBar', SearchBar, 'ColorGenerator', 'ForServerVersion');
|
||||
bottle.decorator('SearchBar', connect([ 'shortUrlsListParams' ], [ 'listShortUrls' ]));
|
||||
|
||||
bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsRow');
|
||||
@@ -33,31 +35,34 @@ const provideServices = (bottle, connect) => {
|
||||
|
||||
bottle.serviceFactory('ShortUrlsRow', ShortUrlsRow, 'ShortUrlsRowMenu', 'ColorGenerator', 'stateFlagTimeout');
|
||||
|
||||
bottle.serviceFactory('ShortUrlsRowMenu', ShortUrlsRowMenu, 'DeleteShortUrlModal', 'EditTagsModal');
|
||||
bottle.serviceFactory(
|
||||
'ShortUrlsRowMenu',
|
||||
ShortUrlsRowMenu,
|
||||
'DeleteShortUrlModal',
|
||||
'EditTagsModal',
|
||||
'EditMetaModal',
|
||||
'ForServerVersion'
|
||||
);
|
||||
bottle.serviceFactory('CreateShortUrlResult', CreateShortUrlResult, 'stateFlagTimeout');
|
||||
|
||||
bottle.serviceFactory('CreateShortUrl', CreateShortUrl, 'TagsSelector', 'CreateShortUrlResult');
|
||||
bottle.serviceFactory('CreateShortUrl', CreateShortUrl, 'TagsSelector', 'CreateShortUrlResult', 'ForServerVersion');
|
||||
bottle.decorator(
|
||||
'CreateShortUrl',
|
||||
connect([ 'shortUrlCreationResult', 'selectedServer' ], [ 'createShortUrl', 'resetCreateShortUrl' ])
|
||||
);
|
||||
|
||||
bottle.serviceFactory('DeleteShortUrlModal', () => DeleteShortUrlModal);
|
||||
bottle.decorator('DeleteShortUrlModal', connect(
|
||||
[ 'shortUrlDeletion' ],
|
||||
[ 'deleteShortUrl', 'resetDeleteShortUrl', 'shortUrlDeleted' ]
|
||||
));
|
||||
bottle.decorator('DeleteShortUrlModal', connect([ 'shortUrlDeletion' ], [ 'deleteShortUrl', 'resetDeleteShortUrl' ]));
|
||||
|
||||
bottle.serviceFactory('EditTagsModal', EditTagsModal, 'TagsSelector');
|
||||
bottle.decorator('EditTagsModal', connect(
|
||||
[ 'shortUrlTags' ],
|
||||
[ 'editShortUrlTags', 'resetShortUrlsTags', 'shortUrlTagsEdited' ]
|
||||
));
|
||||
bottle.decorator('EditTagsModal', connect([ 'shortUrlTags' ], [ 'editShortUrlTags', 'resetShortUrlsTags' ]));
|
||||
|
||||
bottle.serviceFactory('EditMetaModal', () => EditMetaModal);
|
||||
bottle.decorator('EditMetaModal', connect([ 'shortUrlMeta' ], [ 'editShortUrlMeta', 'resetShortUrlMeta' ]));
|
||||
|
||||
// Actions
|
||||
bottle.serviceFactory('editShortUrlTags', editShortUrlTags, 'buildShlinkApiClient');
|
||||
bottle.serviceFactory('resetShortUrlsTags', () => resetShortUrlsTags);
|
||||
bottle.serviceFactory('shortUrlTagsEdited', () => shortUrlTagsEdited);
|
||||
|
||||
bottle.serviceFactory('listShortUrls', listShortUrls, 'buildShlinkApiClient');
|
||||
bottle.serviceFactory('resetShortUrlParams', () => resetShortUrlParams);
|
||||
@@ -67,7 +72,9 @@ const provideServices = (bottle, connect) => {
|
||||
|
||||
bottle.serviceFactory('deleteShortUrl', deleteShortUrl, 'buildShlinkApiClient');
|
||||
bottle.serviceFactory('resetDeleteShortUrl', () => resetDeleteShortUrl);
|
||||
bottle.serviceFactory('shortUrlDeleted', () => shortUrlDeleted);
|
||||
|
||||
bottle.serviceFactory('editShortUrlMeta', editShortUrlMeta, 'buildShlinkApiClient');
|
||||
bottle.serviceFactory('resetShortUrlMeta', () => resetShortUrlMeta);
|
||||
};
|
||||
|
||||
export default provideServices;
|
||||
|
||||
@@ -4,6 +4,7 @@ import DatePicker from 'react-datepicker';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faCalendarAlt as calendarIcon } from '@fortawesome/free-regular-svg-icons';
|
||||
import * as PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import './DateInput.scss';
|
||||
|
||||
const propTypes = {
|
||||
@@ -21,7 +22,7 @@ const DateInput = (props) => {
|
||||
<div className="date-input-container">
|
||||
<DatePicker
|
||||
{...props}
|
||||
className={`date-input-container__input form-control ${className || ''}`}
|
||||
className={classNames('date-input-container__input form-control', className)}
|
||||
dateFormat="YYYY-MM-DD"
|
||||
readOnly
|
||||
ref={ref}
|
||||
|
||||
40
src/utils/DateRangeRow.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import DateInput from './DateInput';
|
||||
import './DateRangeRow.scss';
|
||||
|
||||
const dateType = PropTypes.oneOfType([ PropTypes.string, PropTypes.object ]);
|
||||
const propTypes = {
|
||||
startDate: dateType,
|
||||
endDate: dateType,
|
||||
onStartDateChange: PropTypes.func.isRequired,
|
||||
onEndDateChange: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
const DateRangeRow = ({ startDate, endDate, onStartDateChange, onEndDateChange }) => (
|
||||
<div className="row">
|
||||
<div className="col-xl-3 col-lg-4 col-md-6 offset-xl-6 offset-lg-4">
|
||||
<DateInput
|
||||
selected={startDate}
|
||||
placeholderText="Since"
|
||||
isClearable
|
||||
maxDate={endDate}
|
||||
onChange={onStartDateChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-xl-3 col-lg-4 col-md-6">
|
||||
<DateInput
|
||||
className="date-range-row__date-input"
|
||||
selected={endDate}
|
||||
placeholderText="Until"
|
||||
isClearable
|
||||
minDate={startDate}
|
||||
onChange={onEndDateChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
DateRangeRow.propTypes = propTypes;
|
||||
|
||||
export default DateRangeRow;
|
||||
@@ -1,6 +1,6 @@
|
||||
@import '../utils/base';
|
||||
|
||||
.short-url-visits__date-input {
|
||||
.date-range-row__date-input {
|
||||
@media (max-width: $smMax) {
|
||||
margin-top: .5rem;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import { ExternalLink } from 'react-external-link';
|
||||
|
||||
export default ExternalLink;
|
||||
@@ -1,19 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { isEmpty } from 'ramda';
|
||||
import { compareVersions } from './utils';
|
||||
|
||||
const propTypes = {
|
||||
minVersion: PropTypes.string.isRequired,
|
||||
currentServerVersion: PropTypes.string.isRequired,
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
const ForVersion = ({ minVersion, currentServerVersion, children }) =>
|
||||
isEmpty(currentServerVersion) || compareVersions(currentServerVersion, '<', minVersion)
|
||||
? null
|
||||
: <React.Fragment>{children}</React.Fragment>;
|
||||
|
||||
ForVersion.propTypes = propTypes;
|
||||
|
||||
export default ForVersion;
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faSearch as searchIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import PropTypes from 'prop-types';
|
||||
import classnames from 'classnames';
|
||||
import classNames from 'classnames';
|
||||
import './SearchField.scss';
|
||||
|
||||
const DEFAULT_SEARCH_INTERVAL = 500;
|
||||
@@ -44,7 +44,7 @@ export default class SearchField extends React.Component {
|
||||
const { className, placeholder } = this.props;
|
||||
|
||||
return (
|
||||
<div className={classnames('search-field', className)}>
|
||||
<div className={classNames('search-field', className)}>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control form-control-lg search-field__input"
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
import qs from 'qs';
|
||||
import { isEmpty, isNil, reject } from 'ramda';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const API_VERSION = '1';
|
||||
export const apiErrorType = PropTypes.shape({
|
||||
type: PropTypes.string,
|
||||
detail: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
status: PropTypes.number,
|
||||
error: PropTypes.string, // Deprecated
|
||||
message: PropTypes.string, // Deprecated
|
||||
});
|
||||
|
||||
export const buildShlinkBaseUrl = (url) => url ? `${url}/rest/v${API_VERSION}` : '';
|
||||
const buildShlinkBaseUrl = (url, apiVersion) => url ? `${url}/rest/v${apiVersion}` : '';
|
||||
const rejectNilProps = reject(isNil);
|
||||
|
||||
export default class ShlinkApiClient {
|
||||
constructor(axios, baseUrl, apiKey) {
|
||||
this.axios = axios;
|
||||
this._baseUrl = buildShlinkBaseUrl(baseUrl);
|
||||
this._apiVersion = 2;
|
||||
this._baseUrl = baseUrl;
|
||||
this._apiKey = apiKey || '';
|
||||
}
|
||||
|
||||
listShortUrls = (options = {}) =>
|
||||
this._performRequest('/short-urls', 'GET', options)
|
||||
.then((resp) => resp.data.shortUrls);
|
||||
this._performRequest('/short-urls', 'GET', options).then((resp) => resp.data.shortUrls);
|
||||
|
||||
createShortUrl = (options) => {
|
||||
const filteredOptions = reject((value) => isEmpty(value) || isNil(value), options);
|
||||
@@ -27,18 +36,22 @@ export default class ShlinkApiClient {
|
||||
this._performRequest(`/short-urls/${shortCode}/visits`, 'GET', query)
|
||||
.then((resp) => resp.data.visits);
|
||||
|
||||
getShortUrl = (shortCode) =>
|
||||
this._performRequest(`/short-urls/${shortCode}`, 'GET')
|
||||
getShortUrl = (shortCode, domain) =>
|
||||
this._performRequest(`/short-urls/${shortCode}`, 'GET', { domain })
|
||||
.then((resp) => resp.data);
|
||||
|
||||
deleteShortUrl = (shortCode) =>
|
||||
this._performRequest(`/short-urls/${shortCode}`, 'DELETE')
|
||||
deleteShortUrl = (shortCode, domain) =>
|
||||
this._performRequest(`/short-urls/${shortCode}`, 'DELETE', { domain })
|
||||
.then(() => ({}));
|
||||
|
||||
updateShortUrlTags = (shortCode, tags) =>
|
||||
this._performRequest(`/short-urls/${shortCode}/tags`, 'PUT', {}, { tags })
|
||||
updateShortUrlTags = (shortCode, domain, tags) =>
|
||||
this._performRequest(`/short-urls/${shortCode}/tags`, 'PUT', { domain }, { tags })
|
||||
.then((resp) => resp.data.tags);
|
||||
|
||||
updateShortUrlMeta = (shortCode, domain, meta) =>
|
||||
this._performRequest(`/short-urls/${shortCode}`, 'PATCH', { domain }, meta)
|
||||
.then(() => meta);
|
||||
|
||||
listTags = () =>
|
||||
this._performRequest('/tags', 'GET')
|
||||
.then((resp) => resp.data.tags.data);
|
||||
@@ -53,13 +66,35 @@ export default class ShlinkApiClient {
|
||||
|
||||
health = () => this._performRequest('/health', 'GET').then((resp) => resp.data);
|
||||
|
||||
_performRequest = async (url, method = 'GET', query = {}, body = {}) =>
|
||||
await this.axios({
|
||||
method,
|
||||
url: `${this._baseUrl}${url}`,
|
||||
headers: { 'X-Api-Key': this._apiKey },
|
||||
params: query,
|
||||
data: body,
|
||||
paramsSerializer: (params) => qs.stringify(params, { arrayFormat: 'brackets' }),
|
||||
});
|
||||
_performRequest = async (url, method = 'GET', query = {}, body = {}) => {
|
||||
try {
|
||||
return await this.axios({
|
||||
method,
|
||||
url: `${buildShlinkBaseUrl(this._baseUrl, this._apiVersion)}${url}`,
|
||||
headers: { 'X-Api-Key': this._apiKey },
|
||||
params: rejectNilProps(query),
|
||||
data: body,
|
||||
paramsSerializer: (params) => qs.stringify(params, { arrayFormat: 'brackets' }),
|
||||
});
|
||||
} catch (e) {
|
||||
const { response } = e;
|
||||
|
||||
// Due to a bug on all previous Shlink versions, requests to non-matching URLs will always result on a CORS error
|
||||
// when performed from the browser (due to the preflight request not returning a 2xx status.
|
||||
// See https://github.com/shlinkio/shlink/issues/614), which will make the "response" prop not to be set here.
|
||||
// The bug will be fixed on upcoming Shlink patches, but for other versions, we can consider this situation as
|
||||
// if a request has been performed to a not supported API version.
|
||||
const apiVersionIsNotSupported = !response;
|
||||
|
||||
// When the request is not invalid or we have already tried both API versions, throw the error and let the
|
||||
// caller handle it
|
||||
if (!apiVersionIsNotSupported || this._apiVersion === 1) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
this._apiVersion = 1;
|
||||
|
||||
return await this._performRequest(url, method, query, body);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,3 +60,15 @@ export const compareVersions = (firstVersion, operator, secondVersion) => compar
|
||||
secondVersion,
|
||||
operator
|
||||
);
|
||||
|
||||
export const versionIsValidSemVer = (version) => {
|
||||
try {
|
||||
return compareVersions(version, '=', version);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const formatDate = (format = 'YYYY-MM-DD') => (date) => date && date.format ? date.format(format) : date;
|
||||
|
||||
export const formatIsoDate = (date) => date && date.format ? date.format() : date;
|
||||
|
||||
@@ -4,14 +4,15 @@ import { isEmpty, mapObjIndexed, values } from 'ramda';
|
||||
import React from 'react';
|
||||
import { Card } from 'reactstrap';
|
||||
import PropTypes from 'prop-types';
|
||||
import DateInput from '../utils/DateInput';
|
||||
import qs from 'qs';
|
||||
import DateRangeRow from '../utils/DateRangeRow';
|
||||
import MutedMessage from '../utils/MuttedMessage';
|
||||
import { formatDate } from '../utils/utils';
|
||||
import SortableBarGraph from './SortableBarGraph';
|
||||
import { shortUrlVisitsType } from './reducers/shortUrlVisits';
|
||||
import VisitsHeader from './VisitsHeader';
|
||||
import GraphCard from './GraphCard';
|
||||
import { shortUrlDetailType } from './reducers/shortUrlDetail';
|
||||
import './ShortUrlVisits.scss';
|
||||
|
||||
const ShortUrlVisits = (
|
||||
{ processStatsFromVisits },
|
||||
@@ -21,6 +22,9 @@ const ShortUrlVisits = (
|
||||
match: PropTypes.shape({
|
||||
params: PropTypes.object,
|
||||
}),
|
||||
location: PropTypes.shape({
|
||||
search: PropTypes.string,
|
||||
}),
|
||||
getShortUrlVisits: PropTypes.func,
|
||||
shortUrlVisits: shortUrlVisitsType,
|
||||
getShortUrlDetail: PropTypes.func,
|
||||
@@ -29,27 +33,24 @@ const ShortUrlVisits = (
|
||||
};
|
||||
|
||||
state = { startDate: undefined, endDate: undefined };
|
||||
loadVisits = () => {
|
||||
const { match: { params }, getShortUrlVisits } = this.props;
|
||||
loadVisits = (loadDetail = false) => {
|
||||
const { match: { params }, location: { search }, getShortUrlVisits, getShortUrlDetail } = this.props;
|
||||
const { shortCode } = params;
|
||||
const dates = mapObjIndexed(
|
||||
(value) => value && value.format ? value.format('YYYY-MM-DD') : value,
|
||||
this.state
|
||||
);
|
||||
const { startDate, endDate } = dates;
|
||||
const { startDate, endDate } = mapObjIndexed(formatDate(), this.state);
|
||||
const { domain } = qs.parse(search, { ignoreQueryPrefix: true });
|
||||
|
||||
// While the "page" is loaded, use the timestamp + filtering dates as memoization IDs for stats calcs
|
||||
// While the "page" is loaded, use the timestamp + filtering dates as memoization IDs for stats calculations
|
||||
this.memoizationId = `${this.timeWhenMounted}_${shortCode}_${startDate}_${endDate}`;
|
||||
getShortUrlVisits(shortCode, dates);
|
||||
getShortUrlVisits(shortCode, { startDate, endDate, domain });
|
||||
|
||||
if (loadDetail) {
|
||||
getShortUrlDetail(shortCode, domain);
|
||||
}
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
const { match: { params }, getShortUrlDetail } = this.props;
|
||||
const { shortCode } = params;
|
||||
|
||||
this.timeWhenMounted = new Date().getTime();
|
||||
this.loadVisits();
|
||||
getShortUrlDetail(shortCode);
|
||||
this.loadVisits(true);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
@@ -131,33 +132,19 @@ const ShortUrlVisits = (
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const setDate = (dateField) => (date) => this.setState({ [dateField]: date }, this.loadVisits);
|
||||
|
||||
return (
|
||||
<div className="shlink-container">
|
||||
<VisitsHeader shortUrlDetail={shortUrlDetail} shortUrlVisits={shortUrlVisits} />
|
||||
|
||||
<section className="mt-4">
|
||||
<div className="row">
|
||||
<div className="col-xl-3 col-lg-4 col-md-6 offset-xl-6 offset-lg-4">
|
||||
<DateInput
|
||||
selected={this.state.startDate}
|
||||
placeholderText="Since"
|
||||
isClearable
|
||||
maxDate={this.state.endDate}
|
||||
onChange={(date) => this.setState({ startDate: date }, this.loadVisits)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-xl-3 col-lg-4 col-md-6">
|
||||
<DateInput
|
||||
className="short-url-visits__date-input"
|
||||
selected={this.state.endDate}
|
||||
placeholderText="Until"
|
||||
isClearable
|
||||
minDate={this.state.startDate}
|
||||
onChange={(date) => this.setState({ endDate: date }, this.loadVisits)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DateRangeRow
|
||||
startDate={this.state.startDate}
|
||||
endDate={this.state.endDate}
|
||||
onStartDateChange={setDate('startDate')}
|
||||
onEndDateChange={setDate('endDate')}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Card, UncontrolledTooltip } from 'reactstrap';
|
||||
import Moment from 'react-moment';
|
||||
import React from 'react';
|
||||
import ExternalLink from '../utils/ExternalLink';
|
||||
import './VisitsHeader.scss';
|
||||
import { ExternalLink } from 'react-external-link';
|
||||
import ShortUrlVisitsCount from '../short-urls/helpers/ShortUrlVisitsCount';
|
||||
import { shortUrlDetailType } from './reducers/shortUrlDetail';
|
||||
import { shortUrlVisitsType } from './reducers/shortUrlVisits';
|
||||
import './VisitsHeader.scss';
|
||||
|
||||
const propTypes = {
|
||||
shortUrlDetail: shortUrlDetailType.isRequired,
|
||||
@@ -30,14 +31,16 @@ export default function VisitsHeader({ shortUrlDetail, shortUrlVisits }) {
|
||||
<header>
|
||||
<Card className="bg-light" body>
|
||||
<h2>
|
||||
<span className="badge badge-main float-right">Visits: {visits.length}</span>
|
||||
<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:
|
||||
|
||||
Long URL:{' '}
|
||||
{loading && <small>Loading...</small>}
|
||||
{!loading && <ExternalLink href={longLink} />}
|
||||
</div>
|
||||
|
||||
@@ -26,13 +26,13 @@ export default handleActions({
|
||||
[GET_SHORT_URL_DETAIL]: (state, { shortUrl }) => ({ shortUrl, loading: false, error: false }),
|
||||
}, initialState);
|
||||
|
||||
export const getShortUrlDetail = (buildShlinkApiClient) => (shortCode) => async (dispatch, getState) => {
|
||||
export const getShortUrlDetail = (buildShlinkApiClient) => (shortCode, domain) => async (dispatch, getState) => {
|
||||
dispatch({ type: GET_SHORT_URL_DETAIL_START });
|
||||
|
||||
const { getShortUrl } = await buildShlinkApiClient(getState);
|
||||
|
||||
try {
|
||||
const shortUrl = await getShortUrl(shortCode);
|
||||
const shortUrl = await getShortUrl(shortCode, domain);
|
||||
|
||||
dispatch({ shortUrl, type: GET_SHORT_URL_DETAIL });
|
||||
} catch (e) {
|
||||
|
||||
@@ -49,7 +49,7 @@ export default handleActions({
|
||||
[GET_SHORT_URL_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }),
|
||||
}, initialState);
|
||||
|
||||
export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, dates) => async (dispatch, getState) => {
|
||||
export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, query) => async (dispatch, getState) => {
|
||||
dispatch({ type: GET_SHORT_URL_VISITS_START });
|
||||
|
||||
const { getShortUrlVisits } = await buildShlinkApiClient(getState);
|
||||
@@ -57,7 +57,7 @@ export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, dates) =>
|
||||
const isLastPage = ({ currentPage, pagesCount }) => currentPage >= pagesCount;
|
||||
|
||||
const loadVisits = async (page = 1) => {
|
||||
const { pagination, data } = await getShortUrlVisits(shortCode, { ...dates, page, itemsPerPage });
|
||||
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)) {
|
||||
@@ -96,7 +96,7 @@ export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, dates) =>
|
||||
const loadVisitsInParallel = (pages) =>
|
||||
Promise.all(pages.map(
|
||||
(page) =>
|
||||
getShortUrlVisits(shortCode, { ...dates, page, itemsPerPage })
|
||||
getShortUrlVisits(shortCode, { ...query, page, itemsPerPage })
|
||||
.then(prop('data'))
|
||||
)).then(flatten);
|
||||
|
||||
|
||||
48
test/servers/helpers/ForServerVersion.test.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
import each from 'jest-each';
|
||||
import ForServerVersion from '../../../src/servers/helpers/ForServerVersion';
|
||||
|
||||
describe('<ForServerVersion />', () => {
|
||||
let wrapped;
|
||||
|
||||
const renderComponent = (minVersion, maxVersion, selectedServer) => {
|
||||
wrapped = mount(
|
||||
<ForServerVersion minVersion={minVersion} maxVersion={maxVersion} selectedServer={selectedServer}>
|
||||
<span>Hello</span>
|
||||
</ForServerVersion>
|
||||
);
|
||||
|
||||
return wrapped;
|
||||
};
|
||||
|
||||
afterEach(() => wrapped && wrapped.unmount());
|
||||
|
||||
it('does not render children when current server is empty', () => {
|
||||
const wrapped = renderComponent('1');
|
||||
|
||||
expect(wrapped.html()).toBeNull();
|
||||
});
|
||||
|
||||
each([
|
||||
[ '2.0.0', undefined, '1.8.3' ],
|
||||
[ undefined, '1.8.0', '1.8.3' ],
|
||||
[ '1.7.0', '1.8.0', '1.8.3' ],
|
||||
]).it('does not render children when current version does not match requirements', (min, max, version) => {
|
||||
const wrapped = renderComponent(min, max, { version });
|
||||
|
||||
expect(wrapped.html()).toBeNull();
|
||||
});
|
||||
|
||||
each([
|
||||
[ '2.0.0', undefined, '2.8.3' ],
|
||||
[ '2.0.0', undefined, '2.0.0' ],
|
||||
[ undefined, '1.8.0', '1.8.0' ],
|
||||
[ undefined, '1.8.0', '1.7.1' ],
|
||||
[ '1.7.0', '1.8.0', '1.7.3' ],
|
||||
]).it('renders children when current version matches requirements', (min, max, version) => {
|
||||
const wrapped = renderComponent(min, max, { version });
|
||||
|
||||
expect(wrapped.html()).toContain('<span>Hello</span>');
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,11 @@
|
||||
import each from 'jest-each';
|
||||
import reducer, {
|
||||
selectServer,
|
||||
resetSelectedServer,
|
||||
RESET_SELECTED_SERVER,
|
||||
SELECT_SERVER,
|
||||
MAX_FALLBACK_VERSION,
|
||||
MIN_FALLBACK_VERSION,
|
||||
} from '../../../src/servers/reducers/selectedServer';
|
||||
import { RESET_SHORT_URL_PARAMS } from '../../../src/short-urls/reducers/shortUrlsListParams';
|
||||
|
||||
@@ -34,26 +37,25 @@ describe('selectedServerReducer', () => {
|
||||
findServerById: jest.fn(() => selectedServer),
|
||||
};
|
||||
const apiClientMock = {
|
||||
health: jest.fn().mockResolvedValue({ version }),
|
||||
health: jest.fn(),
|
||||
};
|
||||
const buildApiClient = jest.fn().mockResolvedValue(apiClientMock);
|
||||
const dispatch = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
apiClientMock.health.mockClear();
|
||||
buildApiClient.mockClear();
|
||||
});
|
||||
afterEach(jest.clearAllMocks);
|
||||
|
||||
afterEach(() => {
|
||||
ServersServiceMock.findServerById.mockClear();
|
||||
});
|
||||
|
||||
it('dispatches proper actions', async () => {
|
||||
const dispatch = jest.fn();
|
||||
each([
|
||||
[ version, version ],
|
||||
[ 'latest', MAX_FALLBACK_VERSION ],
|
||||
[ '%invalid_semver%', MIN_FALLBACK_VERSION ],
|
||||
]).it('dispatches proper actions', async (serverVersion, expectedVersion) => {
|
||||
const expectedSelectedServer = {
|
||||
...selectedServer,
|
||||
version,
|
||||
version: expectedVersion,
|
||||
};
|
||||
|
||||
apiClientMock.health.mockResolvedValue({ version: serverVersion });
|
||||
|
||||
await selectServer(ServersServiceMock, buildApiClient)(serverId)(dispatch);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledTimes(2);
|
||||
@@ -67,5 +69,18 @@ describe('selectedServerReducer', () => {
|
||||
expect(ServersServiceMock.findServerById).toHaveBeenCalledTimes(1);
|
||||
expect(buildApiClient).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('falls back to min version when health endpoint fails', async () => {
|
||||
const expectedSelectedServer = {
|
||||
...selectedServer,
|
||||
version: MIN_FALLBACK_VERSION,
|
||||
};
|
||||
|
||||
apiClientMock.health.mockRejectedValue({});
|
||||
|
||||
await selectServer(ServersServiceMock, buildApiClient)(serverId)(dispatch);
|
||||
|
||||
expect(dispatch).toHaveBeenNthCalledWith(2, { type: SELECT_SERVER, selectedServer: expectedSelectedServer });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { values } from 'ramda';
|
||||
import each from 'jest-each';
|
||||
import reducer, {
|
||||
createServer,
|
||||
deleteServer,
|
||||
@@ -20,27 +21,18 @@ describe('serverReducer', () => {
|
||||
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', () => {
|
||||
beforeEach(() => {
|
||||
ServersServiceMock.listServers.mockClear();
|
||||
ServersServiceMock.createServer.mockReset();
|
||||
ServersServiceMock.deleteServer.mockReset();
|
||||
ServersServiceMock.createServers.mockReset();
|
||||
});
|
||||
|
||||
describe('listServers', () => {
|
||||
const axios = { get: jest.fn().mockResolvedValue({ data: [] }) };
|
||||
const axios = { get: jest.fn() };
|
||||
const dispatch = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
axios.get.mockClear();
|
||||
dispatch.mockReset();
|
||||
});
|
||||
const NoListServersServiceMock = { ...ServersServiceMock, listServers: jest.fn(() => ({})) };
|
||||
|
||||
it('fetches servers from local storage when found', async () => {
|
||||
await listServers(ServersServiceMock, axios)()(dispatch);
|
||||
@@ -55,14 +47,49 @@ describe('serverReducer', () => {
|
||||
expect(axios.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('tries to fetch servers from remote when not found locally', async () => {
|
||||
const NoListServersServiceMock = { ...ServersServiceMock, listServers: jest.fn(() => ({})) };
|
||||
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({}), {}],
|
||||
]).it('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: {} });
|
||||
expect(dispatch).toHaveBeenNthCalledWith(2, { type: FETCH_SERVERS, list: expectedList });
|
||||
expect(NoListServersServiceMock.listServers).toHaveBeenCalledTimes(1);
|
||||
expect(NoListServersServiceMock.createServer).not.toHaveBeenCalled();
|
||||
expect(NoListServersServiceMock.deleteServer).not.toHaveBeenCalled();
|
||||
|
||||
@@ -14,7 +14,7 @@ describe('<CreateShortUrl />', () => {
|
||||
const createShortUrl = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
const CreateShortUrl = createShortUrlsCreator(TagsSelector, () => '');
|
||||
const CreateShortUrl = createShortUrlsCreator(TagsSelector, () => '', () => '');
|
||||
|
||||
wrapper = shallow(
|
||||
<CreateShortUrl shortUrlCreationResult={shortUrlCreationResult} createShortUrl={createShortUrl} />
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import each from 'jest-each';
|
||||
import searchBarCreator from '../../src/short-urls/SearchBar';
|
||||
import SearchField from '../../src/utils/SearchField';
|
||||
import Tag from '../../src/tags/helpers/Tag';
|
||||
import DateRangeRow from '../../src/utils/DateRangeRow';
|
||||
|
||||
describe('<SearchBar />', () => {
|
||||
let wrapper;
|
||||
const listShortUrlsMock = jest.fn();
|
||||
const SearchBar = searchBarCreator({});
|
||||
const SearchBar = searchBarCreator({}, () => '');
|
||||
|
||||
afterEach(() => {
|
||||
listShortUrlsMock.mockReset();
|
||||
@@ -20,6 +22,12 @@ describe('<SearchBar />', () => {
|
||||
expect(wrapper.find(SearchField)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('renders a DateRangeRow', () => {
|
||||
wrapper = shallow(<SearchBar shortUrlsListParams={{}} />);
|
||||
|
||||
expect(wrapper.find(DateRangeRow)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('renders no tags when the list of tags is empty', () => {
|
||||
wrapper = shallow(<SearchBar shortUrlsListParams={{}} />);
|
||||
|
||||
@@ -53,4 +61,15 @@ describe('<SearchBar />', () => {
|
||||
tag.simulate('close');
|
||||
expect(listShortUrlsMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
each([ 'startDateChange', 'endDateChange' ]).it('updates short URLs list when date range changes', (event) => {
|
||||
wrapper = shallow(
|
||||
<SearchBar shortUrlsListParams={{}} listShortUrls={listShortUrlsMock} />
|
||||
);
|
||||
const dateRange = wrapper.find(DateRangeRow);
|
||||
|
||||
expect(listShortUrlsMock).not.toHaveBeenCalled();
|
||||
dateRange.simulate(event);
|
||||
expect(listShortUrlsMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { identity } from 'ramda';
|
||||
import each from 'jest-each';
|
||||
import DeleteShortUrlModal from '../../../src/short-urls/helpers/DeleteShortUrlModal';
|
||||
|
||||
describe('<DeleteShortUrlModal />', () => {
|
||||
@@ -20,7 +21,6 @@ describe('<DeleteShortUrlModal />', () => {
|
||||
toggle={identity}
|
||||
deleteShortUrl={deleteShortUrl}
|
||||
resetDeleteShortUrl={identity}
|
||||
shortUrlDeleted={identity}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -32,17 +32,34 @@ describe('<DeleteShortUrlModal />', () => {
|
||||
deleteShortUrl.mockClear();
|
||||
});
|
||||
|
||||
it('shows threshold error message when threshold error occurs', () => {
|
||||
each([
|
||||
[
|
||||
{ error: 'INVALID_SHORTCODE_DELETION' },
|
||||
'This short URL has received too many visits, and therefore, it cannot be deleted.',
|
||||
],
|
||||
[
|
||||
{ type: 'INVALID_SHORTCODE_DELETION' },
|
||||
'This short URL has received too many visits, and therefore, it cannot be deleted.',
|
||||
],
|
||||
[
|
||||
{ error: 'INVALID_SHORTCODE_DELETION', threshold: 35 },
|
||||
'This short URL has received more than 35 visits, and therefore, it cannot be deleted.',
|
||||
],
|
||||
[
|
||||
{ type: 'INVALID_SHORTCODE_DELETION', threshold: 8 },
|
||||
'This short URL has received more than 8 visits, and therefore, it cannot be deleted.',
|
||||
],
|
||||
]).it('shows threshold error message when threshold error occurs', (errorData, expectedMessage) => {
|
||||
const wrapper = createWrapper({
|
||||
loading: false,
|
||||
error: true,
|
||||
shortCode: 'abc123',
|
||||
errorData: { error: 'INVALID_SHORTCODE_DELETION' },
|
||||
errorData,
|
||||
});
|
||||
const warning = wrapper.find('.bg-warning');
|
||||
|
||||
expect(warning).toHaveLength(1);
|
||||
expect(warning.html()).toContain('This short URL has received too many visits and therefore, it cannot be deleted');
|
||||
expect(warning.html()).toContain(expectedMessage);
|
||||
});
|
||||
|
||||
it('shows generic error when non-threshold error occurs', () => {
|
||||
|
||||
84
test/short-urls/helpers/EditMetaModal.test.js
Normal file
@@ -0,0 +1,84 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { FormGroup, Modal, ModalHeader } from 'reactstrap';
|
||||
import each from 'jest-each';
|
||||
import EditMetaModal from '../../../src/short-urls/helpers/EditMetaModal';
|
||||
|
||||
describe('<EditMetaModal />', () => {
|
||||
let wrapper;
|
||||
const editShortUrlMeta = jest.fn(() => Promise.resolve());
|
||||
const resetShortUrlMeta = jest.fn();
|
||||
const toggle = jest.fn();
|
||||
const createWrapper = (shortUrl, shortUrlMeta) => {
|
||||
wrapper = shallow(
|
||||
<EditMetaModal
|
||||
isOpen={true}
|
||||
shortUrl={shortUrl}
|
||||
shortUrlMeta={shortUrlMeta}
|
||||
toggle={toggle}
|
||||
editShortUrlMeta={editShortUrlMeta}
|
||||
resetShortUrlMeta={resetShortUrlMeta}
|
||||
/>
|
||||
);
|
||||
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
wrapper && wrapper.unmount();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('properly renders form with components', () => {
|
||||
const wrapper = createWrapper({}, { saving: false, error: false, meta: {} });
|
||||
const error = wrapper.find('.bg-danger');
|
||||
const form = wrapper.find('form');
|
||||
const formGroup = form.find(FormGroup);
|
||||
|
||||
expect(form).toHaveLength(1);
|
||||
expect(formGroup).toHaveLength(3);
|
||||
expect(error).toHaveLength(0);
|
||||
});
|
||||
|
||||
each([
|
||||
[ true, 'Saving...' ],
|
||||
[ false, 'Save' ],
|
||||
]).it('renders submit button on expected state', (saving, expectedText) => {
|
||||
const wrapper = createWrapper({}, { saving, error: false, meta: {} });
|
||||
const button = wrapper.find('[type="submit"]');
|
||||
|
||||
expect(button.prop('disabled')).toEqual(saving);
|
||||
expect(button.text()).toContain(expectedText);
|
||||
});
|
||||
|
||||
it('renders error message on error', () => {
|
||||
const wrapper = createWrapper({}, { saving: false, error: true, meta: {} });
|
||||
const error = wrapper.find('.bg-danger');
|
||||
|
||||
expect(error).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('saves meta when form is submit', () => {
|
||||
const preventDefault = jest.fn();
|
||||
const wrapper = createWrapper({}, { saving: false, error: false, meta: {} });
|
||||
const form = wrapper.find('form');
|
||||
|
||||
form.simulate('submit', { preventDefault });
|
||||
|
||||
expect(preventDefault).toHaveBeenCalled();
|
||||
expect(editShortUrlMeta).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
each([
|
||||
[ '.btn-link', 'onClick' ],
|
||||
[ Modal, 'toggle' ],
|
||||
[ ModalHeader, 'toggle' ],
|
||||
]).it('resets meta when modal is toggled in any way', (componentToFind, propToCall) => {
|
||||
const wrapper = createWrapper({}, { saving: false, error: false, meta: {} });
|
||||
const component = wrapper.find(componentToFind);
|
||||
|
||||
component.prop(propToCall)();
|
||||
|
||||
expect(resetShortUrlMeta).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { Modal } from 'reactstrap';
|
||||
import each from 'jest-each';
|
||||
import createEditTagsModal from '../../../src/short-urls/helpers/EditTagsModal';
|
||||
|
||||
describe('<EditTagsModal />', () => {
|
||||
@@ -8,25 +9,23 @@ describe('<EditTagsModal />', () => {
|
||||
const shortCode = 'abc123';
|
||||
const TagsSelector = () => '';
|
||||
const editShortUrlTags = jest.fn(() => Promise.resolve());
|
||||
const shortUrlTagsEdited = jest.fn();
|
||||
const resetShortUrlsTags = jest.fn();
|
||||
const toggle = jest.fn();
|
||||
const createWrapper = (shortUrlTags) => {
|
||||
const createWrapper = (shortUrlTags, domain) => {
|
||||
const EditTagsModal = createEditTagsModal(TagsSelector);
|
||||
|
||||
wrapper = shallow(
|
||||
<EditTagsModal
|
||||
isOpen={true}
|
||||
url={''}
|
||||
shortUrl={{
|
||||
tags: [],
|
||||
shortCode,
|
||||
domain,
|
||||
originalUrl: 'https://long-domain.com/foo/bar',
|
||||
}}
|
||||
shortUrlTags={shortUrlTags}
|
||||
toggle={toggle}
|
||||
editShortUrlTags={editShortUrlTags}
|
||||
shortUrlTagsEdited={shortUrlTagsEdited}
|
||||
resetShortUrlsTags={resetShortUrlsTags}
|
||||
/>
|
||||
);
|
||||
@@ -36,10 +35,7 @@ describe('<EditTagsModal />', () => {
|
||||
|
||||
afterEach(() => {
|
||||
wrapper && wrapper.unmount();
|
||||
editShortUrlTags.mockClear();
|
||||
shortUrlTagsEdited.mockReset();
|
||||
resetShortUrlsTags.mockReset();
|
||||
toggle.mockReset();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('resets tags when component is mounted', () => {
|
||||
@@ -80,19 +76,19 @@ describe('<EditTagsModal />', () => {
|
||||
expect(saveBtn.text()).toEqual('Saving tags...');
|
||||
});
|
||||
|
||||
it('saves tags when save button is clicked', (done) => {
|
||||
each([[ undefined ], [ null ], [ 'example.com' ]]).it('saves tags when save button is clicked', (domain, done) => {
|
||||
const wrapper = createWrapper({
|
||||
shortCode,
|
||||
tags: [],
|
||||
saving: true,
|
||||
error: false,
|
||||
});
|
||||
}, domain);
|
||||
const saveBtn = wrapper.find('.btn-primary');
|
||||
|
||||
saveBtn.simulate('click');
|
||||
|
||||
expect(editShortUrlTags).toHaveBeenCalledTimes(1);
|
||||
expect(editShortUrlTags).toHaveBeenCalledWith(shortCode, []);
|
||||
expect(editShortUrlTags).toHaveBeenCalledWith(shortCode, domain, []);
|
||||
|
||||
// Wrap this expect in a setImmediate since it is called as a result of an inner promise
|
||||
setImmediate(() => {
|
||||
@@ -111,28 +107,7 @@ describe('<EditTagsModal />', () => {
|
||||
const modal = wrapper.find(Modal);
|
||||
|
||||
modal.simulate('closed');
|
||||
expect(shortUrlTagsEdited).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('notifies tags have been edited when window is closed after saving', (done) => {
|
||||
const wrapper = createWrapper({
|
||||
shortCode,
|
||||
tags: [],
|
||||
saving: true,
|
||||
error: false,
|
||||
});
|
||||
const saveBtn = wrapper.find('.btn-primary');
|
||||
const modal = wrapper.find(Modal);
|
||||
|
||||
saveBtn.simulate('click');
|
||||
|
||||
// Wrap this expect in a setImmediate since it is called as a result of an inner promise
|
||||
setImmediate(() => {
|
||||
modal.simulate('closed');
|
||||
expect(shortUrlTagsEdited).toHaveBeenCalledTimes(1);
|
||||
expect(shortUrlTagsEdited).toHaveBeenCalledWith(shortCode, []);
|
||||
done();
|
||||
});
|
||||
expect(editShortUrlTags).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('toggles modal when cancel button is clicked', () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { ExternalLink } from 'react-external-link';
|
||||
import PreviewModal from '../../../src/short-urls/helpers/PreviewModal';
|
||||
import ExternalLink from '../../../src/utils/ExternalLink';
|
||||
|
||||
describe('<PreviewModal />', () => {
|
||||
let wrapper;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { ExternalLink } from 'react-external-link';
|
||||
import QrCodeModal from '../../../src/short-urls/helpers/QrCodeModal';
|
||||
import ExternalLink from '../../../src/utils/ExternalLink';
|
||||
|
||||
describe('<QrCodeModal />', () => {
|
||||
let wrapper;
|
||||
|
||||
41
test/short-urls/helpers/ShortUrlVisitsCount.test.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { UncontrolledTooltip } from 'reactstrap';
|
||||
import each from 'jest-each';
|
||||
import ShortUrlVisitsCount from '../../../src/short-urls/helpers/ShortUrlVisitsCount';
|
||||
|
||||
describe('<ShortUrlVisitsCount />', () => {
|
||||
let wrapper;
|
||||
|
||||
const createWrapper = (visitsCount, shortUrl) => {
|
||||
wrapper = shallow(<ShortUrlVisitsCount visitsCount={visitsCount} shortUrl={shortUrl} />);
|
||||
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
afterEach(() => wrapper && wrapper.unmount());
|
||||
|
||||
each([ undefined, {}]).it('just returns visits when no maxVisits is provided', (meta) => {
|
||||
const visitsCount = 45;
|
||||
const wrapper = createWrapper(visitsCount, { meta });
|
||||
const maxVisitsHelper = wrapper.find('.short-urls-visits-count__max-visits-control');
|
||||
const maxVisitsTooltip = wrapper.find(UncontrolledTooltip);
|
||||
|
||||
expect(wrapper.html()).toEqual(`<span><strong>${visitsCount}</strong></span>`);
|
||||
expect(maxVisitsHelper).toHaveLength(0);
|
||||
expect(maxVisitsTooltip).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('displays the maximum amount of visits when present', () => {
|
||||
const visitsCount = 45;
|
||||
const maxVisits = 500;
|
||||
const meta = { maxVisits };
|
||||
const wrapper = createWrapper(visitsCount, { meta });
|
||||
const maxVisitsHelper = wrapper.find('.short-urls-visits-count__max-visits-control');
|
||||
const maxVisitsTooltip = wrapper.find(UncontrolledTooltip);
|
||||
|
||||
expect(wrapper.html()).toContain(`/ ${maxVisits}`);
|
||||
expect(maxVisitsHelper).toHaveLength(1);
|
||||
expect(maxVisitsTooltip).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -3,8 +3,8 @@ import { shallow } from 'enzyme';
|
||||
import moment from 'moment';
|
||||
import Moment from 'react-moment';
|
||||
import { assoc, toString } from 'ramda';
|
||||
import { ExternalLink } from 'react-external-link';
|
||||
import createShortUrlsRow from '../../../src/short-urls/helpers/ShortUrlsRow';
|
||||
import ExternalLink from '../../../src/utils/ExternalLink';
|
||||
import Tag from '../../../src/tags/helpers/Tag';
|
||||
|
||||
describe('<ShortUrlsRow />', () => {
|
||||
@@ -83,7 +83,7 @@ describe('<ShortUrlsRow />', () => {
|
||||
it('renders visits count in fifth row', () => {
|
||||
const col = wrapper.find('td').at(4);
|
||||
|
||||
expect(col.text()).toEqual(toString(shortUrl.visitsCount));
|
||||
expect(col.html()).toContain(toString(shortUrl.visitsCount));
|
||||
});
|
||||
|
||||
it('updates state when copied to clipboard', () => {
|
||||
|
||||
@@ -9,24 +9,31 @@ describe('<ShortUrlsRowMenu />', () => {
|
||||
let wrapper;
|
||||
const DeleteShortUrlModal = () => '';
|
||||
const EditTagsModal = () => '';
|
||||
const EditMetaModal = () => '';
|
||||
const onCopyToClipboard = jest.fn();
|
||||
const selectedServer = { id: 'abc123' };
|
||||
const shortUrl = {
|
||||
shortCode: 'abc123',
|
||||
shortUrl: 'https://doma.in/abc123',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
const ShortUrlsRowMenu = createShortUrlsRowMenu(DeleteShortUrlModal, EditTagsModal);
|
||||
const createWrapper = () => {
|
||||
const ShortUrlsRowMenu = createShortUrlsRowMenu(DeleteShortUrlModal, EditTagsModal, EditMetaModal, () => '');
|
||||
|
||||
wrapper = shallow(
|
||||
<ShortUrlsRowMenu selectedServer={selectedServer} shortUrl={shortUrl} onCopyToClipboard={onCopyToClipboard} />
|
||||
<ShortUrlsRowMenu
|
||||
selectedServer={selectedServer}
|
||||
shortUrl={shortUrl}
|
||||
onCopyToClipboard={onCopyToClipboard}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => wrapper.unmount());
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
afterEach(() => wrapper && wrapper.unmount());
|
||||
|
||||
it('renders modal windows', () => {
|
||||
const wrapper = createWrapper();
|
||||
const deleteShortUrlModal = wrapper.find(DeleteShortUrlModal);
|
||||
const editTagsModal = wrapper.find(EditTagsModal);
|
||||
const previewModal = wrapper.find(PreviewModal);
|
||||
@@ -39,16 +46,16 @@ describe('<ShortUrlsRowMenu />', () => {
|
||||
});
|
||||
|
||||
it('renders correct amount of menu items', () => {
|
||||
const wrapper = createWrapper();
|
||||
const items = wrapper.find(DropdownItem);
|
||||
const expectedNonDividerItems = 6;
|
||||
const expectedDividerItems = 2;
|
||||
|
||||
expect(items).toHaveLength(expectedNonDividerItems + expectedDividerItems);
|
||||
expect(items.find('[divider]')).toHaveLength(expectedDividerItems);
|
||||
expect(items).toHaveLength(9);
|
||||
expect(items.find('[divider]')).toHaveLength(2);
|
||||
});
|
||||
|
||||
describe('toggles state when toggling modal windows', () => {
|
||||
const assert = (modalComponent, stateProp, done) => {
|
||||
const wrapper = createWrapper();
|
||||
const modal = wrapper.find(modalComponent);
|
||||
|
||||
expect(wrapper.state(stateProp)).toEqual(false);
|
||||
@@ -66,6 +73,7 @@ describe('<ShortUrlsRowMenu />', () => {
|
||||
});
|
||||
|
||||
it('toggles dropdown state when toggling dropdown', (done) => {
|
||||
const wrapper = createWrapper();
|
||||
const dropdown = wrapper.find(ButtonDropdown);
|
||||
|
||||
expect(wrapper.state('isOpen')).toEqual(false);
|
||||
|
||||
42
test/short-urls/helpers/VisitStatsLink.test.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import each from 'jest-each';
|
||||
import { Link } from 'react-router-dom';
|
||||
import VisitStatsLink from '../../../src/short-urls/helpers/VisitStatsLink';
|
||||
|
||||
describe('<VisitStatsLink />', () => {
|
||||
let wrapper;
|
||||
|
||||
afterEach(() => wrapper && wrapper.unmount());
|
||||
|
||||
each([
|
||||
[ undefined, undefined ],
|
||||
[ null, null ],
|
||||
[{}, null ],
|
||||
[{}, undefined ],
|
||||
[ null, {}],
|
||||
[ undefined, {}],
|
||||
]).it('only renders a plan span when either server or short URL are not set', (selectedServer, shortUrl) => {
|
||||
wrapper = shallow(<VisitStatsLink selectedServer={selectedServer} shortUrl={shortUrl}>Something</VisitStatsLink>);
|
||||
const link = wrapper.find(Link);
|
||||
|
||||
expect(link).toHaveLength(0);
|
||||
expect(wrapper.html()).toEqual('<span>Something</span>');
|
||||
});
|
||||
|
||||
each([
|
||||
[{ id: '1' }, { shortCode: 'abc123' }, '/server/1/short-code/abc123/visits' ],
|
||||
[
|
||||
{ id: '3' },
|
||||
{ shortCode: 'def456', domain: 'example.com' },
|
||||
'/server/3/short-code/def456/visits?domain=example.com',
|
||||
],
|
||||
]).it('renders link with expected query when', (selectedServer, shortUrl, expectedLink) => {
|
||||
wrapper = shallow(<VisitStatsLink selectedServer={selectedServer} shortUrl={shortUrl}>Something</VisitStatsLink>);
|
||||
const link = wrapper.find(Link);
|
||||
const to = link.prop('to');
|
||||
|
||||
expect(link).toHaveLength(1);
|
||||
expect(to).toEqual(expectedLink);
|
||||
});
|
||||
});
|
||||