Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
72
CHANGELOG.md
@@ -4,6 +4,78 @@ 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.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.11.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
|
||||
|
||||
@@ -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|jpg|jpeg|gif|bmp|ico|json|csv|otf|eot|svg|svgz|ttf|woff|woff2|ijmap|pdf|tif|map) {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
|
||||
|
||||
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,
|
||||
|
||||
@@ -59,3 +59,7 @@ body,
|
||||
.paddingless {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.indivisible {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -2,7 +2,7 @@ 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';
|
||||
@@ -40,9 +40,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 +49,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">
|
||||
|
||||
@@ -1,31 +1,56 @@
|
||||
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 { compareVersions, formatDate } from '../utils/utils';
|
||||
import { serverType } from '../servers/prop-types';
|
||||
import { shortUrlsListParamsType } from './reducers/shortUrlsListParams';
|
||||
import './SearchBar.scss';
|
||||
|
||||
const propTypes = {
|
||||
listShortUrls: PropTypes.func,
|
||||
shortUrlsListParams: shortUrlsListParamsType,
|
||||
selectedServer: serverType,
|
||||
};
|
||||
|
||||
const dateOrUndefined = (date) => date ? moment(date) : undefined;
|
||||
|
||||
const SearchBar = (colorGenerator) => {
|
||||
const SearchBar = ({ listShortUrls, shortUrlsListParams }) => {
|
||||
const SearchBar = ({ listShortUrls, shortUrlsListParams, selectedServer }) => {
|
||||
const currentServerVersion = selectedServer ? selectedServer.version : '';
|
||||
const enableDateFiltering = !isEmpty(currentServerVersion) && compareVersions(currentServerVersion, '>=', '1.21.0');
|
||||
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 })
|
||||
}
|
||||
/>
|
||||
|
||||
{enableDateFiltering && (
|
||||
<div className="mt-3">
|
||||
<DateRangeRow
|
||||
startDate={dateOrUndefined(shortUrlsListParams.startDate)}
|
||||
endDate={dateOrUndefined(shortUrlsListParams.endDate)}
|
||||
onStartDateChange={setDate('startDate')}
|
||||
onEndDateChange={setDate('endDate')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!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() {
|
||||
|
||||
@@ -5,6 +5,8 @@ import { identity } 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,
|
||||
@@ -39,9 +41,10 @@ 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 { error, errorData } = shortUrlDeletion;
|
||||
const errorCode = error && (errorData.type || errorData.error);
|
||||
const hasThresholdError = errorCode === THRESHOLD_REACHED;
|
||||
const hasErrorOtherThanThreshold = error && errorCode !== THRESHOLD_REACHED;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} toggle={toggle} centered>
|
||||
@@ -63,7 +66,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 && (
|
||||
|
||||
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 { 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, {
|
||||
maxVisits: maxVisits && parseInt(maxVisits),
|
||||
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,7 @@
|
||||
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 { shortUrlTagsType } from '../reducers/shortUrlTags';
|
||||
import { shortUrlType } from '../reducers/shortUrlsList';
|
||||
|
||||
@@ -9,7 +9,6 @@ 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,
|
||||
@@ -51,12 +50,13 @@ const EditTagsModal = (TagsSelector) => class EditTagsModal extends React.Compon
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isOpen, toggle, url, shortUrlTags } = this.props;
|
||||
const { isOpen, toggle, shortUrl, shortUrlTags } = this.props;
|
||||
const url = shortUrl && (shortUrl.shortUrl || '');
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} toggle={toggle} centered onClosed={() => this.refreshShortUrls()}>
|
||||
<ModalHeader toggle={toggle}>
|
||||
Edit tags for <ExternalLink href={url}>{url}</ExternalLink>
|
||||
Edit tags for <ExternalLink href={url} />
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<TagsSelector tags={this.state.tags} onChange={(tags) => this.setState({ tags })} />
|
||||
|
||||
@@ -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,
|
||||
|
||||
41
src/short-urls/helpers/ShortUrlVisitsCount.js
Normal file
@@ -0,0 +1,41 @@
|
||||
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 { shortUrlMetaType } from '../reducers/shortUrlMeta';
|
||||
import './ShortUrlVisitsCount.scss';
|
||||
|
||||
const propTypes = {
|
||||
visitsCount: PropTypes.number.isRequired,
|
||||
meta: shortUrlMetaType,
|
||||
};
|
||||
|
||||
const ShortUrlVisitsCount = ({ visitsCount, meta }) => {
|
||||
const maxVisits = meta && meta.maxVisits;
|
||||
|
||||
if (!maxVisits) {
|
||||
return <span>{visitsCount}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<span className="indivisible">
|
||||
{visitsCount}
|
||||
<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,9 @@ 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} meta={shortUrl.meta} />
|
||||
</td>
|
||||
<td className="short-urls-row__cell short-urls-row__cell--relative">
|
||||
<small
|
||||
className="badge badge-warning short-urls-row__copy-hint"
|
||||
|
||||
@@ -5,6 +5,7 @@ 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';
|
||||
@@ -12,13 +13,19 @@ 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 { isEmpty } from 'ramda';
|
||||
import { serverType } from '../../servers/prop-types';
|
||||
import { compareVersions } from '../../utils/utils';
|
||||
import { shortUrlType } from '../reducers/shortUrlsList';
|
||||
import PreviewModal from './PreviewModal';
|
||||
import QrCodeModal from './QrCodeModal';
|
||||
import './ShortUrlsRowMenu.scss';
|
||||
|
||||
const ShortUrlsRowMenu = (DeleteShortUrlModal, EditTagsModal) => class ShortUrlsRowMenu extends React.Component {
|
||||
const ShortUrlsRowMenu = (
|
||||
DeleteShortUrlModal,
|
||||
EditTagsModal,
|
||||
EditMetaModal
|
||||
) => class ShortUrlsRowMenu extends React.Component {
|
||||
static propTypes = {
|
||||
onCopyToClipboard: PropTypes.func,
|
||||
selectedServer: serverType,
|
||||
@@ -30,6 +37,7 @@ const ShortUrlsRowMenu = (DeleteShortUrlModal, EditTagsModal) => class ShortUrls
|
||||
isQrModalOpen: false,
|
||||
isPreviewModalOpen: false,
|
||||
isTagsModalOpen: false,
|
||||
isMetaModalOpen: false,
|
||||
isDeleteModalOpen: false,
|
||||
};
|
||||
toggle = () => this.setState(({ isOpen }) => ({ isOpen: !isOpen }));
|
||||
@@ -37,10 +45,14 @@ const ShortUrlsRowMenu = (DeleteShortUrlModal, EditTagsModal) => class ShortUrls
|
||||
render() {
|
||||
const { onCopyToClipboard, shortUrl, selectedServer } = this.props;
|
||||
const completeShortUrl = shortUrl && shortUrl.shortUrl ? shortUrl.shortUrl : '';
|
||||
const currentServerVersion = selectedServer ? selectedServer.version : '';
|
||||
const showEditMetaBtn = !isEmpty(currentServerVersion) && compareVersions(currentServerVersion, '>=', '1.18.0');
|
||||
const showPreviewBtn = !isEmpty(currentServerVersion) && compareVersions(currentServerVersion, '<', '2.0.0');
|
||||
const toggleModal = (prop) => () => this.setState((prevState) => ({ [prop]: !prevState[prop] }));
|
||||
const toggleQrCode = toggleModal('isQrModalOpen');
|
||||
const togglePreview = toggleModal('isPreviewModalOpen');
|
||||
const toggleTags = toggleModal('isTagsModalOpen');
|
||||
const toggleMeta = toggleModal('isMetaModalOpen');
|
||||
const toggleDelete = toggleModal('isDeleteModalOpen');
|
||||
|
||||
return (
|
||||
@@ -50,41 +62,49 @@ const ShortUrlsRowMenu = (DeleteShortUrlModal, EditTagsModal) => class ShortUrls
|
||||
</DropdownToggle>
|
||||
<DropdownMenu right>
|
||||
<DropdownItem tag={Link} to={`/server/${selectedServer ? selectedServer.id : ''}/short-code/${shortUrl.shortCode}/visits`}>
|
||||
<FontAwesomeIcon icon={pieChartIcon} /> Visit stats
|
||||
<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} />
|
||||
|
||||
{showEditMetaBtn && (
|
||||
<React.Fragment>
|
||||
<DropdownItem onClick={toggleMeta}>
|
||||
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit metadata
|
||||
</DropdownItem>
|
||||
<EditMetaModal shortUrl={shortUrl} isOpen={this.state.isMetaModalOpen} toggle={toggleMeta} />
|
||||
</React.Fragment>
|
||||
)}
|
||||
|
||||
<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} />
|
||||
{showPreviewBtn && (
|
||||
<React.Fragment>
|
||||
<DropdownItem onClick={togglePreview}>
|
||||
<FontAwesomeIcon icon={pictureIcon} fixedWidth /> Preview
|
||||
</DropdownItem>
|
||||
<PreviewModal url={completeShortUrl} isOpen={this.state.isPreviewModalOpen} toggle={togglePreview} />
|
||||
</React.Fragment>
|
||||
)}
|
||||
|
||||
<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 />
|
||||
{showPreviewBtn && <DropdownItem divider />}
|
||||
|
||||
<CopyToClipboard text={completeShortUrl} onCopy={onCopyToClipboard}>
|
||||
<DropdownItem>
|
||||
<FontAwesomeIcon icon={copyIcon} /> Copy to clipboard
|
||||
<FontAwesomeIcon icon={copyIcon} fixedWidth /> Copy to clipboard
|
||||
</DropdownItem>
|
||||
</CopyToClipboard>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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';
|
||||
@@ -13,10 +14,7 @@ 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 = {
|
||||
|
||||
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, meta) => async (dispatch, getState) => {
|
||||
dispatch({ type: EDIT_SHORT_URL_META_START });
|
||||
const { updateShortUrlMeta } = await buildShlinkApiClient(getState);
|
||||
|
||||
try {
|
||||
await updateShortUrlMeta(shortCode, meta);
|
||||
dispatch({ shortCode, meta, 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);
|
||||
@@ -3,6 +3,7 @@ import { assoc, assocPath, propEq, 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,6 +15,8 @@ export const shortUrlType = PropTypes.shape({
|
||||
shortCode: PropTypes.string,
|
||||
shortUrl: PropTypes.string,
|
||||
longUrl: PropTypes.string,
|
||||
visitsCount: PropTypes.number,
|
||||
meta: shortUrlMetaType,
|
||||
tags: PropTypes.arrayOf(PropTypes.string),
|
||||
});
|
||||
|
||||
@@ -23,23 +26,25 @@ const initialState = {
|
||||
error: false,
|
||||
};
|
||||
|
||||
const setPropFromActionOnMatchingShortUrl = (prop) => (state, { shortCode, [prop]: propValue }) => assocPath(
|
||||
[ 'shortUrls', 'data' ],
|
||||
state.shortUrls.data.map(
|
||||
(shortUrl) => shortUrl.shortCode === shortCode ? 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 }) => assocPath(
|
||||
[ 'shortUrls', 'data' ],
|
||||
reject(propEq('shortCode', action.shortCode), state.shortUrls.data),
|
||||
reject(propEq('shortCode', shortCode), 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 { editShortUrlMeta, resetShortUrlMeta } from '../reducers/shortUrlMeta';
|
||||
import { resetShortUrlParams } from '../reducers/shortUrlsListParams';
|
||||
|
||||
const provideServices = (bottle, connect) => {
|
||||
@@ -23,7 +25,7 @@ const provideServices = (bottle, connect) => {
|
||||
));
|
||||
|
||||
bottle.serviceFactory('SearchBar', SearchBar, 'ColorGenerator');
|
||||
bottle.decorator('SearchBar', connect([ 'shortUrlsListParams' ], [ 'listShortUrls' ]));
|
||||
bottle.decorator('SearchBar', connect([ 'shortUrlsListParams', 'selectedServer' ], [ 'listShortUrls' ]));
|
||||
|
||||
bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsRow');
|
||||
bottle.decorator('ShortUrlsList', connect(
|
||||
@@ -33,7 +35,7 @@ const provideServices = (bottle, connect) => {
|
||||
|
||||
bottle.serviceFactory('ShortUrlsRow', ShortUrlsRow, 'ShortUrlsRowMenu', 'ColorGenerator', 'stateFlagTimeout');
|
||||
|
||||
bottle.serviceFactory('ShortUrlsRowMenu', ShortUrlsRowMenu, 'DeleteShortUrlModal', 'EditTagsModal');
|
||||
bottle.serviceFactory('ShortUrlsRowMenu', ShortUrlsRowMenu, 'DeleteShortUrlModal', 'EditTagsModal', 'EditMetaModal');
|
||||
bottle.serviceFactory('CreateShortUrlResult', CreateShortUrlResult, 'stateFlagTimeout');
|
||||
|
||||
bottle.serviceFactory('CreateShortUrl', CreateShortUrl, 'TagsSelector', 'CreateShortUrlResult');
|
||||
@@ -54,6 +56,9 @@ const provideServices = (bottle, connect) => {
|
||||
[ 'editShortUrlTags', 'resetShortUrlsTags', 'shortUrlTagsEdited' ]
|
||||
));
|
||||
|
||||
bottle.serviceFactory('EditMetaModal', () => EditMetaModal);
|
||||
bottle.decorator('EditMetaModal', connect([ 'shortUrlMeta' ], [ 'editShortUrlMeta', 'resetShortUrlMeta' ]));
|
||||
|
||||
// Actions
|
||||
bottle.serviceFactory('editShortUrlTags', editShortUrlTags, 'buildShlinkApiClient');
|
||||
bottle.serviceFactory('resetShortUrlsTags', () => resetShortUrlsTags);
|
||||
@@ -68,6 +73,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;
|
||||
@@ -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,30 @@
|
||||
import qs from 'qs';
|
||||
import { isEmpty, isNil, reject } from 'ramda';
|
||||
import { isEmpty, isNil, pipe, 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}` : '';
|
||||
|
||||
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);
|
||||
listShortUrls = pipe(
|
||||
(options = {}) => reject(isNil, options),
|
||||
(options) => this._performRequest('/short-urls', 'GET', options).then((resp) => resp.data.shortUrls)
|
||||
);
|
||||
|
||||
createShortUrl = (options) => {
|
||||
const filteredOptions = reject((value) => isEmpty(value) || isNil(value), options);
|
||||
@@ -39,6 +49,10 @@ export default class ShlinkApiClient {
|
||||
this._performRequest(`/short-urls/${shortCode}/tags`, 'PUT', {}, { tags })
|
||||
.then((resp) => resp.data.tags);
|
||||
|
||||
updateShortUrlMeta = (shortCode, meta) =>
|
||||
this._performRequest(`/short-urls/${shortCode}`, 'PATCH', {}, meta)
|
||||
.then(() => meta);
|
||||
|
||||
listTags = () =>
|
||||
this._performRequest('/tags', 'GET')
|
||||
.then((resp) => resp.data.tags.data);
|
||||
@@ -53,13 +67,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: 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,14 @@ 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 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 },
|
||||
@@ -32,10 +32,7 @@ const ShortUrlVisits = (
|
||||
loadVisits = () => {
|
||||
const { match: { params }, getShortUrlVisits } = this.props;
|
||||
const { shortCode } = params;
|
||||
const dates = mapObjIndexed(
|
||||
(value) => value && value.format ? value.format('YYYY-MM-DD') : value,
|
||||
this.state
|
||||
);
|
||||
const dates = mapObjIndexed(formatDate(), this.state);
|
||||
const { startDate, endDate } = dates;
|
||||
|
||||
// While the "page" is loaded, use the timestamp + filtering dates as memoization IDs for stats calcs
|
||||
@@ -131,33 +128,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} meta={shortUrl.meta} />
|
||||
</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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
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;
|
||||
@@ -20,6 +22,17 @@ describe('<SearchBar />', () => {
|
||||
expect(wrapper.find(SearchField)).toHaveLength(1);
|
||||
});
|
||||
|
||||
each([
|
||||
[ '2.0.0', 1 ],
|
||||
[ '1.21.2', 1 ],
|
||||
[ '1.21.0', 1 ],
|
||||
[ '1.20.0', 0 ],
|
||||
]).it('renders a DateRangeRow when proper version is run', (version, expectedLength) => {
|
||||
wrapper = shallow(<SearchBar shortUrlsListParams={{}} selectedServer={{ version }} />);
|
||||
|
||||
expect(wrapper.find(DateRangeRow)).toHaveLength(expectedLength);
|
||||
});
|
||||
|
||||
it('renders no tags when the list of tags is empty', () => {
|
||||
wrapper = shallow(<SearchBar shortUrlsListParams={{}} />);
|
||||
|
||||
@@ -53,4 +66,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} selectedServer={{ version: '2.0.0' }} />
|
||||
);
|
||||
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 />', () => {
|
||||
@@ -32,17 +33,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();
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,6 @@ describe('<EditTagsModal />', () => {
|
||||
wrapper = shallow(
|
||||
<EditTagsModal
|
||||
isOpen={true}
|
||||
url={''}
|
||||
shortUrl={{
|
||||
tags: [],
|
||||
shortCode,
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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, meta) => {
|
||||
wrapper = shallow(<ShortUrlVisitsCount visitsCount={visitsCount} meta={meta} />);
|
||||
|
||||
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>${visitsCount}</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', () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { ButtonDropdown, DropdownItem } from 'reactstrap';
|
||||
import each from 'jest-each';
|
||||
import createShortUrlsRowMenu from '../../../src/short-urls/helpers/ShortUrlsRowMenu';
|
||||
import PreviewModal from '../../../src/short-urls/helpers/PreviewModal';
|
||||
import QrCodeModal from '../../../src/short-urls/helpers/QrCodeModal';
|
||||
@@ -9,24 +10,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 = (serverVersion = '1.21.1') => {
|
||||
const ShortUrlsRowMenu = createShortUrlsRowMenu(DeleteShortUrlModal, EditTagsModal, EditMetaModal);
|
||||
|
||||
wrapper = shallow(
|
||||
<ShortUrlsRowMenu selectedServer={selectedServer} shortUrl={shortUrl} onCopyToClipboard={onCopyToClipboard} />
|
||||
<ShortUrlsRowMenu
|
||||
selectedServer={{ ...selectedServer, version: serverVersion }}
|
||||
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);
|
||||
@@ -38,10 +46,21 @@ describe('<ShortUrlsRowMenu />', () => {
|
||||
expect(qrCodeModal).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('renders correct amount of menu items', () => {
|
||||
each([
|
||||
[ '1.17.0', 6, 2 ],
|
||||
[ '1.17.2', 6, 2 ],
|
||||
[ '1.18.0', 7, 2 ],
|
||||
[ '1.18.1', 7, 2 ],
|
||||
[ '1.19.0', 7, 2 ],
|
||||
[ '1.20.3', 7, 2 ],
|
||||
[ '1.21.0', 7, 2 ],
|
||||
[ '1.21.1', 7, 2 ],
|
||||
[ '2.0.0', 6, 1 ],
|
||||
[ '2.0.1', 6, 1 ],
|
||||
[ '2.1.0', 6, 1 ],
|
||||
]).it('renders correct amount of menu items depending on the version', (version, expectedNonDividerItems, expectedDividerItems) => {
|
||||
const wrapper = createWrapper(version);
|
||||
const items = wrapper.find(DropdownItem);
|
||||
const expectedNonDividerItems = 6;
|
||||
const expectedDividerItems = 2;
|
||||
|
||||
expect(items).toHaveLength(expectedNonDividerItems + expectedDividerItems);
|
||||
expect(items.find('[divider]')).toHaveLength(expectedDividerItems);
|
||||
@@ -49,6 +68,7 @@ describe('<ShortUrlsRowMenu />', () => {
|
||||
|
||||
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 +86,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);
|
||||
|
||||
93
test/short-urls/reducers/shortUrlMeta.test.js
Normal file
@@ -0,0 +1,93 @@
|
||||
import moment from 'moment';
|
||||
import reducer, {
|
||||
EDIT_SHORT_URL_META_START,
|
||||
EDIT_SHORT_URL_META_ERROR,
|
||||
SHORT_URL_META_EDITED,
|
||||
RESET_EDIT_SHORT_URL_META,
|
||||
editShortUrlMeta,
|
||||
resetShortUrlMeta,
|
||||
} from '../../../src/short-urls/reducers/shortUrlMeta';
|
||||
|
||||
describe('shortUrlMetaReducer', () => {
|
||||
const meta = {
|
||||
maxVisits: 50,
|
||||
startDate: moment('2020-01-01').format(),
|
||||
};
|
||||
const shortCode = 'abc123';
|
||||
|
||||
describe('reducer', () => {
|
||||
it('returns loading on EDIT_SHORT_URL_META_START', () => {
|
||||
expect(reducer({}, { type: EDIT_SHORT_URL_META_START })).toEqual({
|
||||
saving: true,
|
||||
error: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error on EDIT_SHORT_URL_META_ERROR', () => {
|
||||
expect(reducer({}, { type: EDIT_SHORT_URL_META_ERROR })).toEqual({
|
||||
saving: false,
|
||||
error: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns provided tags and shortCode on SHORT_URL_META_EDITED', () => {
|
||||
expect(reducer({}, { type: SHORT_URL_META_EDITED, meta, shortCode })).toEqual({
|
||||
meta,
|
||||
shortCode,
|
||||
saving: false,
|
||||
error: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('goes back to initial state on RESET_EDIT_SHORT_URL_META', () => {
|
||||
expect(reducer({}, { type: RESET_EDIT_SHORT_URL_META })).toEqual({
|
||||
meta: {},
|
||||
shortCode: null,
|
||||
saving: false,
|
||||
error: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('editShortUrlMeta', () => {
|
||||
const updateShortUrlMeta = jest.fn().mockResolvedValue({});
|
||||
const buildShlinkApiClient = jest.fn().mockResolvedValue({ updateShortUrlMeta });
|
||||
const dispatch = jest.fn();
|
||||
|
||||
afterEach(jest.clearAllMocks);
|
||||
|
||||
it('dispatches metadata on success', async () => {
|
||||
await editShortUrlMeta(buildShlinkApiClient)(shortCode, meta)(dispatch);
|
||||
|
||||
expect(buildShlinkApiClient).toHaveBeenCalledTimes(1);
|
||||
expect(updateShortUrlMeta).toHaveBeenCalledTimes(1);
|
||||
expect(updateShortUrlMeta).toHaveBeenCalledWith(shortCode, meta);
|
||||
expect(dispatch).toHaveBeenCalledTimes(2);
|
||||
expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_SHORT_URL_META_START });
|
||||
expect(dispatch).toHaveBeenNthCalledWith(2, { type: SHORT_URL_META_EDITED, meta, shortCode });
|
||||
});
|
||||
|
||||
it('dispatches error on failure', async () => {
|
||||
const error = new Error();
|
||||
|
||||
updateShortUrlMeta.mockRejectedValue(error);
|
||||
|
||||
try {
|
||||
await editShortUrlMeta(buildShlinkApiClient)(shortCode, meta)(dispatch);
|
||||
} catch (e) {
|
||||
expect(e).toBe(error);
|
||||
}
|
||||
|
||||
expect(buildShlinkApiClient).toHaveBeenCalledTimes(1);
|
||||
expect(updateShortUrlMeta).toHaveBeenCalledTimes(1);
|
||||
expect(updateShortUrlMeta).toHaveBeenCalledWith(shortCode, meta);
|
||||
expect(dispatch).toHaveBeenCalledTimes(2);
|
||||
expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_SHORT_URL_META_START });
|
||||
expect(dispatch).toHaveBeenNthCalledWith(2, { type: EDIT_SHORT_URL_META_ERROR });
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetShortUrlMeta', () => {
|
||||
it('creates expected action', () => expect(resetShortUrlMeta()).toEqual({ type: RESET_EDIT_SHORT_URL_META }));
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,11 @@
|
||||
import reducer, {
|
||||
EDIT_SHORT_URL_TAGS,
|
||||
EDIT_SHORT_URL_TAGS_ERROR,
|
||||
EDIT_SHORT_URL_TAGS_START, editShortUrlTags,
|
||||
EDIT_SHORT_URL_TAGS_START,
|
||||
RESET_EDIT_SHORT_URL_TAGS,
|
||||
resetShortUrlsTags,
|
||||
SHORT_URL_TAGS_EDITED,
|
||||
editShortUrlTags,
|
||||
shortUrlTagsEdited,
|
||||
} from '../../../src/short-urls/reducers/shortUrlTags';
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import reducer, {
|
||||
} from '../../../src/short-urls/reducers/shortUrlsList';
|
||||
import { SHORT_URL_TAGS_EDITED } from '../../../src/short-urls/reducers/shortUrlTags';
|
||||
import { SHORT_URL_DELETED } from '../../../src/short-urls/reducers/shortUrlDeletion';
|
||||
import { SHORT_URL_META_EDITED } from '../../../src/short-urls/reducers/shortUrlMeta';
|
||||
|
||||
describe('shortUrlsListReducer', () => {
|
||||
describe('reducer', () => {
|
||||
@@ -52,6 +53,31 @@ describe('shortUrlsListReducer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('Updates meta on matching URL on SHORT_URL_META_EDITED', () => {
|
||||
const shortCode = 'abc123';
|
||||
const meta = {
|
||||
maxVisits: 5,
|
||||
validSince: '2020-05-05',
|
||||
};
|
||||
const state = {
|
||||
shortUrls: {
|
||||
data: [
|
||||
{ shortCode, meta: { maxVisits: 10 } },
|
||||
{ shortCode: 'foo', meta: null },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
expect(reducer(state, { type: SHORT_URL_META_EDITED, shortCode, meta })).toEqual({
|
||||
shortUrls: {
|
||||
data: [
|
||||
{ shortCode, meta },
|
||||
{ shortCode: 'foo', meta: null },
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('Removes matching URL on SHORT_URL_DELETED', () => {
|
||||
const shortCode = 'abc123';
|
||||
const state = {
|
||||
|
||||
40
test/utils/DateRangeRow.test.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import DateRangeRow from '../../src/utils/DateRangeRow';
|
||||
import DateInput from '../../src/utils/DateInput';
|
||||
|
||||
describe('<DateRangeRow />', () => {
|
||||
let wrapper;
|
||||
const onEndDateChange = jest.fn();
|
||||
const onStartDateChange = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = shallow(<DateRangeRow onEndDateChange={onEndDateChange} onStartDateChange={onStartDateChange} />);
|
||||
});
|
||||
afterEach(() => {
|
||||
wrapper.unmount();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders two date inputs', () => {
|
||||
const dateInput = wrapper.find(DateInput);
|
||||
|
||||
expect(dateInput).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('invokes start date callback when change event is triggered on first input', () => {
|
||||
const dateInput = wrapper.find(DateInput).first();
|
||||
|
||||
expect(onStartDateChange).not.toHaveBeenCalled();
|
||||
dateInput.simulate('change');
|
||||
expect(onStartDateChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('invokes end date callback when change event is triggered on second input', () => {
|
||||
const dateInput = wrapper.find(DateInput).last();
|
||||
|
||||
expect(onEndDateChange).not.toHaveBeenCalled();
|
||||
dateInput.simulate('change');
|
||||
expect(onEndDateChange).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -102,6 +102,25 @@ describe('ShlinkApiClient', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateShortUrlMeta', () => {
|
||||
it('properly updates short URL meta', async () => {
|
||||
const expectedMeta = {
|
||||
maxVisits: 50,
|
||||
validSince: '2025-01-01T10:00:00+01:00',
|
||||
};
|
||||
const axiosSpy = jest.fn(createAxiosMock());
|
||||
const { updateShortUrlMeta } = new ShlinkApiClient(axiosSpy);
|
||||
|
||||
const result = await updateShortUrlMeta('abc123', expectedMeta);
|
||||
|
||||
expect(expectedMeta).toEqual(result);
|
||||
expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({
|
||||
url: '/short-urls/abc123',
|
||||
method: 'PATCH',
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe('listTags', () => {
|
||||
it('properly returns list of tags', async () => {
|
||||
const expectedTags = [ 'foo', 'bar' ];
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import buildShlinkApiClient from '../../../src/utils/services/ShlinkApiClientBuilder';
|
||||
import { buildShlinkBaseUrl } from '../../../src/utils/services/ShlinkApiClient';
|
||||
|
||||
describe('ShlinkApiClientBuilder', () => {
|
||||
const createBuilder = () => {
|
||||
@@ -40,7 +39,7 @@ describe('ShlinkApiClientBuilder', () => {
|
||||
const apiKey = 'apiKey';
|
||||
const apiClient = await buildShlinkApiClient({})({ url, apiKey });
|
||||
|
||||
expect(apiClient._baseUrl).toEqual(buildShlinkBaseUrl(url));
|
||||
expect(apiClient._baseUrl).toEqual(url);
|
||||
expect(apiClient._apiKey).toEqual(apiKey);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,8 +5,8 @@ import { Card } from 'reactstrap';
|
||||
import createShortUrlVisits from '../../src/visits/ShortUrlVisits';
|
||||
import MutedMessage from '../../src/utils/MuttedMessage';
|
||||
import GraphCard from '../../src/visits/GraphCard';
|
||||
import DateInput from '../../src/utils/DateInput';
|
||||
import SortableBarGraph from '../../src/visits/SortableBarGraph';
|
||||
import DateRangeRow from '../../src/utils/DateRangeRow';
|
||||
|
||||
describe('<ShortUrlVisits />', () => {
|
||||
let wrapper;
|
||||
@@ -82,14 +82,15 @@ describe('<ShortUrlVisits />', () => {
|
||||
|
||||
it('reloads visits when selected dates change', () => {
|
||||
const wrapper = createComponent({ loading: false, error: false, visits: [{}, {}, {}] });
|
||||
const dateInput = wrapper.find(DateInput).first();
|
||||
const dateRange = wrapper.find(DateRangeRow);
|
||||
|
||||
dateInput.simulate('change', '2016-01-01T00:00:00+01:00');
|
||||
dateInput.simulate('change', '2016-01-02T00:00:00+01:00');
|
||||
dateInput.simulate('change', '2016-01-03T00:00:00+01:00');
|
||||
dateRange.simulate('startDateChange', '2016-01-01T00:00:00+01:00');
|
||||
dateRange.simulate('endDateChange', '2016-01-02T00:00:00+01:00');
|
||||
dateRange.simulate('endDateChange', '2016-01-03T00:00:00+01:00');
|
||||
|
||||
expect(getShortUrlVisitsMock).toHaveBeenCalledTimes(4);
|
||||
expect(wrapper.state('startDate')).toEqual('2016-01-03T00:00:00+01:00');
|
||||
expect(wrapper.state('startDate')).toEqual('2016-01-01T00:00:00+01:00');
|
||||
expect(wrapper.state('endDate')).toEqual('2016-01-03T00:00:00+01:00');
|
||||
});
|
||||
|
||||
it('holds the map button content generator on cities graph extraHeaderContent', () => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import Moment from 'react-moment';
|
||||
import { ExternalLink } from 'react-external-link';
|
||||
import VisitsHeader from '../../src/visits/VisitsHeader';
|
||||
import ExternalLink from '../../src/utils/ExternalLink';
|
||||
|
||||
describe('<VisitsHeader />', () => {
|
||||
let wrapper;
|
||||
@@ -19,16 +19,14 @@ describe('<VisitsHeader />', () => {
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = shallow(
|
||||
<VisitsHeader shortUrlDetail={shortUrlDetail} shortUrlVisits={shortUrlVisits} shortLink="foo" />
|
||||
);
|
||||
wrapper = shallow(<VisitsHeader shortUrlDetail={shortUrlDetail} shortUrlVisits={shortUrlVisits} />);
|
||||
});
|
||||
afterEach(() => wrapper.unmount());
|
||||
|
||||
it('shows the amount of visits', () => {
|
||||
const visitsBadge = wrapper.find('.badge');
|
||||
|
||||
expect(visitsBadge.text()).toEqual(`Visits: ${shortUrlVisits.visits.length}`);
|
||||
expect(visitsBadge.html()).toContain(`Visits: <span>${shortUrlVisits.visits.length}</span>`);
|
||||
});
|
||||
|
||||
it('shows when the URL was created', () => {
|
||||
|
||||