Compare commits

...

93 Commits

Author SHA1 Message Date
Alejandro Celaya
da54a72b3e Merge pull request #206 from acelaya-forks/feature/match-domain
Feature/match domain
2020-02-08 11:04:58 +01:00
Alejandro Celaya
86c155d8d1 Updated changelog 2020-02-08 10:47:44 +01:00
Alejandro Celaya
666d2d3065 Ensured domain is dispatched when modifying a short URL somehow 2020-02-08 10:46:11 +01:00
Alejandro Celaya
01e69fb6ca Merge pull request #204 from acelaya-forks/feature/multi-domain-fixes
Feature/multi domain fixes
2020-02-08 10:24:49 +01:00
Alejandro Celaya
30e5253acd Simplified instructions removing redundant vars 2020-02-08 10:07:34 +01:00
Alejandro Celaya
c67ce3918b Removed redundant function call 2020-02-08 10:03:24 +01:00
Alejandro Celaya
58077f2d86 Updated changelog 2020-02-08 09:59:13 +01:00
Alejandro Celaya
098c94bccf Ensured domain is passed when deleting a short URL on a specific domain 2020-02-08 09:57:18 +01:00
Alejandro Celaya
861a3c068f Ensured domain is passed when editing meta for a short URL on a specific domain 2020-02-08 09:52:30 +01:00
Alejandro Celaya
3b95e8ebc0 Ensured domain is passed when editing tags for a short URL on a specific domain 2020-02-08 09:48:35 +01:00
Alejandro Celaya
170e427530 Ensured domain is passed when loading detail for a short URL on a specific domain 2020-02-08 09:38:19 +01:00
Alejandro Celaya
707c9f4ce6 Created VisitStatsLink test 2020-02-08 09:22:17 +01:00
Alejandro Celaya
dc672bf0f0 Ensured domain is passed when loading visits for a short URL on a specific domain 2020-02-08 09:07:55 +01:00
Alejandro Celaya
c682737505 Standardized date-picker selected day color 2020-02-02 09:30:41 +01:00
Alejandro Celaya
46fa3d4345 Merge pull request #201 from acelaya-forks/feature/document-routing-fallback
Updated documentation
2020-01-31 20:51:17 +01:00
Alejandro Celaya
9b7bc4b495 Updated documentation 2020-01-31 20:37:50 +01:00
Alejandro Celaya
4385061499 Merge pull request #200 from acelaya-forks/feature/refactor-edit-tags-modal
Feature/refactor edit tags modal
2020-01-31 20:22:04 +01:00
Alejandro Celaya
e17498e68b Updated changelog 2020-01-31 20:13:18 +01:00
Alejandro Celaya
3e298f010b Simplified DeleteShortUrlModal component and shortUrlDeletion reducer 2020-01-31 20:12:22 +01:00
Alejandro Celaya
30117bd121 Simplified EditTagsModal component and shortUrlTags reducer 2020-01-31 20:06:28 +01:00
Alejandro Celaya
93f33b6218 Fixed some tests after not injecting a component 2020-01-31 20:04:03 +01:00
Alejandro Celaya
535d08a607 Merge pull request #197 from MartinH0/master
Add htaccess to redirect if not found to index
2020-01-31 16:21:30 +01:00
MartinH0
6ac3a49db2 Updated nginx.conf (optimization for future)
1. changed location from "~" (case sensitive!) to "~*" (case insensitive!) to also match uppercase static assets. (http://nginx.org/en/docs/http/ngx_http_core_module.html#location)
2. added regex "jpe?g" to match "jpg" and "jpeg" in one command.
2020-01-31 01:36:17 +01:00
MartinH0
c16f760d79 Update .htaccess
1. removed $ (dollar sign from line 14
2. changed line 8 from ".*" to "(.*)"
2020-01-31 01:29:41 +01:00
MartinH0
965c2b243f Update .htaccess
1. added more comments.
2. added NC Tag for making all the static assets case insensetive ("jpg" now matches "jpg" and "JPG" and so on)
3. transformed "jpe|jpeg" into "jpe?g" as its regex for the same, but shorter
4. changed line 8 from "+" to "*" to match everything, also zero times the wildcard
2020-01-31 01:27:13 +01:00
MartinH0
703addddb9 updated htaccess
deleted second json (just needed once)
2020-01-30 21:05:20 +01:00
MartinH0
ab6dff5c31 Updates htaccess
return 404 error if static assets does not exist
2020-01-30 20:51:23 +01:00
MartinH0
2ef330c62b Updated htaccess 2020-01-30 20:49:46 +01:00
MartinH0
72e71aff40 Updated htaccess to meet required functions.
If a file gets called it will be redirected to index.html

But not if it the requested File does contain a dot (and with this does have a file extension.

If you call:
links.domain.de/notexistingfile.jpg 
It will trigger 404

If you call:
links.domain.de/server/[CODE-CODE-CODE]/list-short-urls/1
It will redirect the call to index.html
2020-01-30 19:06:50 +01:00
MartinH0
cefd6ec752 Add htaccess to redirect if not found to index
If (file not found or directory not found)
then > redirect to index.html
2020-01-30 18:51:38 +01:00
MartinH0
aec3de18aa Deleted .htaccess at wrong directory
Sorry fucked it up, will correct it.
2020-01-30 18:51:32 +01:00
MartinH0
97620cb583 Add htaccess to redirect if not found to index
If file not fount or directory not found redirect to index.html
2020-01-30 18:36:02 +01:00
Alejandro Celaya
cf4e8190a4 Merge pull request #195 from acelaya-forks/feature/server-version-wrapper
Feature/server version wrapper
2020-01-28 19:55:24 +01:00
Alejandro Celaya
8af7436f13 Updated changelog 2020-01-28 19:47:41 +01:00
Alejandro Celaya
c53520ae56 Moved logic to dynamically render components based on server version to a separated component 2020-01-28 19:46:36 +01:00
Alejandro Celaya
3adcaef455 Merge pull request #194 from acelaya-forks/feature/fix-set-empty-max-visits
Fixed maxVisits being set to 0 when trying to reset it
2020-01-28 18:51:18 +01:00
Alejandro Celaya
43cd9722a9 Updated project to node 12.14.1 2020-01-28 18:40:33 +01:00
Alejandro Celaya
f3154e770e Fixed maxVisits being set to 0 when trying to reset it 2020-01-28 18:36:23 +01:00
Alejandro Celaya
44aca4aeda Merge pull request #192 from acelaya-forks/feature/version-constraints
Feature/version constraints
2020-01-19 21:37:56 +01:00
Alejandro Celaya
5762342d6c Ensured edit meta menu item is only displayed when shlink v1.18 or greater is run 2020-01-19 21:30:01 +01:00
Alejandro Celaya
2236ed467e Ensured date range filtering is only displayed if Shlink v1.21 ow higer is run 2020-01-19 21:25:45 +01:00
Alejandro Celaya
d244b830ac Updated date on license file 2020-01-19 21:20:32 +01:00
Alejandro Celaya
e89b68fe1e Merge pull request #190 from acelaya-forks/feature/edit-meta
Feature/edit meta
2020-01-19 21:15:25 +01:00
Alejandro Celaya
1f588c5b13 Updated changelog with v2.3.0 2020-01-19 21:00:31 +01:00
Alejandro Celaya
38cad143a0 Created EditMetaModal test 2020-01-19 20:59:01 +01:00
Alejandro Celaya
f52bcc5389 Ensured state is reset on edit meta modal after closing it 2020-01-19 20:37:12 +01:00
Alejandro Celaya
caa6f7bcd8 Created shortUrlMetaReducer test 2020-01-19 20:21:59 +01:00
Alejandro Celaya
207a8cef20 Updated tests from modified code 2020-01-19 13:20:46 +01:00
Alejandro Celaya
d44a4b260e Finished component to allow metadata to be edited on existing short URLs 2020-01-19 13:07:33 +01:00
Alejandro Celaya
80a8e0b55c Created component to edit short URLs meta 2020-01-17 21:07:59 +01:00
Alejandro Celaya
2d60f830f7 Improved icons on short URL menu 2020-01-15 20:25:58 +01:00
Alejandro Celaya
90751a09f7 Merge pull request #188 from acelaya-forks/feature/visits-amount
Feature/visits amount
2020-01-15 18:42:09 +01:00
Alejandro Celaya
301da4bb2a Recovered behavior to show amount of visits in selected date range on visits detail page 2020-01-15 18:31:28 +01:00
Alejandro Celaya
c90cd46095 Removed old ExternalLink component in favor of external one 2020-01-15 18:16:12 +01:00
Alejandro Celaya
7826000384 Merge pull request #187 from acelaya-forks/feature/date-filter
Feature/date filter
2020-01-14 20:59:01 +01:00
Alejandro Celaya
b48dcdd5e1 Fixed wrong files being picked for mutations 2020-01-14 20:48:01 +01:00
Alejandro Celaya
4f6326b139 Updated changelog 2020-01-14 20:21:14 +01:00
Alejandro Celaya
cff96eeccc Created DateRangeRow test 2020-01-14 20:20:27 +01:00
Alejandro Celaya
5eb4a3adec Fixed tests and typos 2020-01-14 20:12:30 +01:00
Alejandro Celaya
b60908a5e9 Added filtering by date to short URLs list 2020-01-14 19:59:25 +01:00
Alejandro Celaya
124441238b Moved style to the proper scope 2020-01-12 12:08:26 +01:00
Alejandro Celaya
4ec0287a74 Merge pull request #185 from Starbix/patch-2
Update nginx base image
2020-01-12 11:01:38 +01:00
Cédric Laubacher
05c67a5c99 Update nginx base image 2020-01-12 10:50:40 +01:00
Alejandro Celaya
f507a3628c Merge pull request #184 from acelaya-forks/feature/show-max-visits
Feature/show max visits
2020-01-11 20:23:02 +01:00
Alejandro Celaya
89e9d2b2d1 Fixed accidentally refactored string 2020-01-11 20:11:41 +01:00
Alejandro Celaya
595858ac4b Used visits count component in short URL visits view 2020-01-11 20:10:12 +01:00
Alejandro Celaya
3f2162fe62 Extracted visits count component to reuse it in other places 2020-01-11 19:58:04 +01:00
Alejandro Celaya
f2cb30409a Updated changelog 2020-01-11 19:41:41 +01:00
Alejandro Celaya
5c4fec5a2f Displayed amount of max visits on those URLs which have it 2020-01-11 19:40:16 +01:00
Alejandro Celaya
e96c119432 Merge pull request #183 from acelaya-forks/feature/support-shlink-2
Feature/support shlink 2
2020-01-11 14:25:25 +01:00
Alejandro Celaya
0920962d72 Used already unpacked property 2020-01-11 14:16:23 +01:00
Alejandro Celaya
aaeb0fff78 Updated changelog 2020-01-11 14:13:58 +01:00
Alejandro Celaya
de41f50945 Ensured preview menu item is hidden when consuming Shlink 2 2020-01-11 14:12:58 +01:00
Alejandro Celaya
0f51bf95e3 Updated ShlinkApiClient so that it retries API version when v2 is not supported 2020-01-11 13:55:37 +01:00
Alejandro Celaya
ba8cade6fc When handling API errors, use the type prop and fallback to error if not found 2020-01-11 12:24:45 +01:00
Alejandro Celaya
dbefae5a01 Merge pull request #173 from Starbix/patch-1
Update baseimages
2019-11-21 11:35:49 +01:00
Cédric Laubacher
727b219742 Update nginx image to latest version 2019-11-21 07:55:40 +01:00
Alejandro Celaya
fb25e44b58 Used strict version number for nginx base image 2019-11-21 07:52:50 +01:00
Cédric Laubacher
fe2d394831 Update baseimages
Nginx can be set to the latest patch version, as its API is really stable.
2019-11-16 10:24:34 +01:00
Alejandro Celaya
efd08ff1d6 Updated changelog 2019-11-10 13:04:15 +01:00
Alejandro Celaya
4b861a5376 Removed profanity 2019-11-10 08:47:13 +01:00
Alejandro Celaya
2076e7d5e8 Merge pull request #171 from MartinH0/master
Updated FavIcons
2019-11-10 08:44:59 +01:00
MartinH0
37f6f1f90c rename to favicon 2019-11-09 22:34:41 +01:00
MartinH0
81f76e0bd6 SVG FavIcon Fix
Added sizes="any" to the svg FavIcon
2019-11-09 22:32:13 +01:00
MartinH0
69b305cd8a Added SVG FavIcon
Added SVG FavIcon as File in Rootdirectory
2019-11-09 22:29:37 +01:00
MartinH0
45742a066e Added SVG FavIcon 2019-11-09 22:28:44 +01:00
MartinH0
86fb8b3f7c Added FavIcons
Added .gif and .png FavIcon
2019-11-09 19:38:50 +01:00
MartinH0
9c0fc8e1d2 Adjusted to FavIcons
Added MS Icons, Added all Apple Icons, Added all normal Icons and standard FavIcons
2019-11-09 19:37:31 +01:00
MartinH0
10d6302180 Added all FavIcons to Manifest
Added all missing FavIcons to the manifest
2019-11-09 19:22:55 +01:00
MartinH0
da7ed6992f FavIcon
Overwrite FavIcon with 128x128 new optimized FavIcon
2019-11-09 19:17:45 +01:00
MartinH0
32c9375ac8 FavIcons edits
Added new FavIcons and overwrite old ones
2019-11-09 19:16:12 +01:00
MartinH0
7ed1334a51 Updated FavIcons
Added all Versions of all available FavIcons.
Also Added normal FavIcons additionally to the apple ones.
Also added the "msapplication-TileImage" meta for Microsoft.
2019-11-07 21:47:57 +01:00
Alejandro Celaya
d9097896f6 Added github funding 2019-10-22 19:33:59 +02:00
104 changed files with 1402 additions and 402 deletions

View File

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

@@ -1 +1,2 @@
github: ['acelaya']
custom: ['https://acel.me/donate']

View File

@@ -1,6 +1,6 @@
build:
environment:
node: v12.11.0
node: v12.14.1
tools:
external_code_coverage:
timeout: 1200

View File

@@ -1,7 +1,7 @@
language: node_js
node_js:
- "12.11.0"
- "12.14.1"
cache:
directories:
@@ -15,7 +15,7 @@ install:
before_script:
- echo "Building commit range ${TRAVIS_COMMIT_RANGE}"
- export MUTATION_FILES=$(git diff ${TRAVIS_COMMIT_RANGE:-origin/master} --name-only | grep -E 'src\/(.*).(js|ts|jsx|tsx)' | paste -sd ",")
- export MUTATION_FILES=$(git diff ${TRAVIS_COMMIT_RANGE:-origin/master} --name-only | grep -E 'src\/(.*).(js|ts|jsx|tsx)$' | paste -sd ",")
script:
- npm run lint

View File

@@ -4,6 +4,59 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
## 2.3.1 - 2020-02-08
#### Added
* *Nothing*
#### Changed
* [#191](https://github.com/shlinkio/shlink-web-client/issues/191) Created `ForServerVersion` helper component which dynamically renders children if current server conditions are met.
* [#189](https://github.com/shlinkio/shlink-web-client/issues/189) Simplified short url tags and short url deletion components and reducers, by removing redundant actions.
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#193](https://github.com/shlinkio/shlink-web-client/issues/193) Fixed `maxVisits` being set to 0 when trying to reset it from having a value to `null`.
* [#196](https://github.com/shlinkio/shlink-web-client/issues/196) Included apache `.htaccess` file which takes care of falling back to index.html when reloading the page on a client-side handled route.
* [#179](https://github.com/shlinkio/shlink-web-client/issues/179) Ensured domain is provided to Shlink server when editing, deleting or fetching short URLs which do not belong to default domain.
* [#202](https://github.com/shlinkio/shlink-web-client/issues/202) Fixed domain not passed when dispatching actions that affect a single short URL (edit tags, edit meta and delete), which cased the list not to be properly updated.
## 2.3.0 - 2020-01-19
#### Added
* [#174](https://github.com/shlinkio/shlink-web-client/issues/174) Added complete support for Shlink v2.x together with currently supported Shlink versions.
* [#164](https://github.com/shlinkio/shlink-web-client/issues/164) Added max visits control on those URLs which have `maxVisits`.
* [#178](https://github.com/shlinkio/shlink-web-client/issues/178) Short URLs list can now be filtered by date range.
* [#46](https://github.com/shlinkio/shlink-web-client/issues/46) Allowed short URL's metadata to be edited (`maxVisits`, `validSince` and `validUntil`).
#### Changed
* *Nothing*
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#170](https://github.com/shlinkio/shlink-web-client/issues/170) Fixed apple icon referencing to incorrect file names.
## 2.2.2 - 2019-10-21
#### Added

View File

@@ -1,8 +1,8 @@
FROM node:12.11.0-alpine as node
FROM node:12.14.1-alpine as node
COPY . /shlink-web-client
RUN cd /shlink-web-client && npm install && npm run build
FROM nginx:1.17.4-alpine
FROM nginx:1.17.7-alpine
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
RUN rm -r /usr/share/nginx/html && rm /etc/nginx/conf.d/default.conf
COPY config/docker/nginx.conf /etc/nginx/conf.d/default.conf

View File

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

View File

@@ -14,23 +14,30 @@ A ReactJS-based progressive web application for [Shlink](https://shlink.io).
There are three ways in which you can use this application.
* The easiest way to use shlink-web-client is by just going to <https://app.shlink.io>.
### From app.shlink.io
The application runs 100% in the browser, so you can safely access any shlink instance from there.
The easiest way to use shlink-web-client is by just going to <https://app.shlink.io>.
* Self hosting the application yourself.
The application runs 100% in the browser, so you can safely access any shlink instance from there.
Get the [latest release](https://github.com/shlinkio/shlink-web-client/releases/latest) and download the distributable zip file attached to it (`shlink-web-client_X.X.X_dist.zip`).
### Docker image
The package contains static files only, so just put it in a folder and serve it with the web server of your choice.
If you want to deploy shlink-web-client in a container-based cluster (kubernetes, docker swarm, etc), just pick the [shlinkio/shlink-web-client](https://hub.docker.com/r/shlinkio/shlink-web-client/) image and do it.
Provided dist files are configured to be served from the root of your domain. If you need to serve shlink-web-client from a subpath, you will have to build it yourself following [these steps](#serve-shlink-in-subpath).
It's a lightweight [nginx:alpine](https://hub.docker.com/r/library/nginx/) image serving the static app on port 80.
* Using the official [docker image](https://hub.docker.com/r/shlinkio/shlink-web-client/)
### Self-hosted
If you want to deploy shlink-web-client in a container-based cluster (kubernetes, docker swarm, etc), just pick the `shlinkio/shlink-web-client` image and do it.
If you want to self-host it yourself, get the [latest release](https://github.com/shlinkio/shlink-web-client/releases/latest) and download the distributable zip file attached to it (`shlink-web-client_X.X.X_dist.zip`).
It's a lightweight [nginx:alpine](https://hub.docker.com/r/library/nginx/) image serving the static app on port 80.
The package contains static files only, so just put it in a folder and serve it with the web server of your choice.
**Considerations**:
* Provided dist files are configured to be served from the root of your domain. If you need to serve shlink-web-client from a subpath, you will have to build it yourself following [these steps](#serve-shlink-in-subpath).
* The app has a client-side router that handles dynamic paths. Because of that, you need to configure your web server to fall-back to the `index.html` file when requested files do not exist.
* If you use Apache, you are covered, since the project includes an `.htaccess` file which already does this.
* If you use nginx, you can [see how it's done](config/docker/nginx.conf) for the docker image and do the same.
## Pre-configuring servers

View File

@@ -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|json|csv|otf|eot|svg|svgz|ttf|woff|woff2|ijmap|pdf|tif|map) {
location ~* .+\.(css|js|html|png|jpe?g|gif|bmp|ico|json|csv|otf|eot|svg|svgz|ttf|woff|woff2|ijmap|pdf|tif|map) {
try_files $uri $uri/ =404;
}

View File

@@ -3,7 +3,7 @@ version: '3'
services:
shlink_web_client_node:
container_name: shlink_web_client_node
image: node:12.11.0-alpine
image: node:12.14.1-alpine
command: /bin/sh -c "cd /home/shlink/www && npm install && npm run start"
volumes:
- ./:/home/shlink/www

16
public/.htaccess Normal file
View File

@@ -0,0 +1,16 @@
RewriteEngine on
RewriteBase /
# do not do anything for already existing files
RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -l [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule (.*) - [L]
# if request is no valid file NOR directory
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
# if static asset do not do anything
RewriteRule (.*)(css|js|html|png|jpe?g|gif|bmp|ico|json|csv|otf|eot|svg|svgz|ttf|woff|woff2|ijmap|pdf|tif|map) - [NC,L,R=404]
# everything else should be redirected to /index.html so it can be routed by it
RewriteRule (.*) /index.html [L]

BIN
public/favicon.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 642 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

1
public/favicon.svg Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
public/icons/icon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
public/icons/icon-24x24.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

BIN
public/icons/icon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 437 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

BIN
public/icons/icon-40x40.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 466 B

BIN
public/icons/icon-48x48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 551 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

BIN
public/icons/icon-60x60.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 638 B

BIN
public/icons/icon-64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 684 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 750 B

BIN
public/icons/icon-76x76.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 783 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 984 B

View File

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

View File

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

View File

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

View File

@@ -20,7 +20,7 @@ const mapActionService = (map, actionName) => ({
// Wrap actual action service in a function so that it is lazily created the first time it is called
[actionName]: lazyService(container, actionName),
});
const connect = (propsFromState, actionServiceNames) =>
const connect = (propsFromState, actionServiceNames = []) =>
reduxConnect(
propsFromState ? pick(propsFromState) : null,
actionServiceNames.reduce(mapActionService, {})

View File

@@ -59,3 +59,15 @@ body,
.paddingless {
padding: 0;
}
.indivisible {
white-space: nowrap;
}
.react-datepicker__day--keyboard-selected {
background-color: $mainColor;
&:hover {
background-color: darken($mainColor, 12%);
}
}

View File

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

View File

@@ -0,0 +1,31 @@
import React from 'react';
import PropTypes from 'prop-types';
import { compareVersions } from '../../utils/utils';
import { serverType } from '../prop-types';
const propTypes = {
minVersion: PropTypes.string,
maxVersion: PropTypes.string,
selectedServer: serverType,
children: PropTypes.node.isRequired,
};
const ForServerVersion = ({ minVersion, maxVersion, selectedServer, children }) => {
if (!selectedServer) {
return null;
}
const { version } = selectedServer;
const matchesMinVersion = !minVersion || compareVersions(version, '>=', minVersion);
const matchesMaxVersion = !maxVersion || compareVersions(version, '<=', maxVersion);
if (!matchesMinVersion || !matchesMaxVersion) {
return null;
}
return <React.Fragment>{children}</React.Fragment>;
};
ForServerVersion.propTypes = propTypes;
export default ForServerVersion;

View File

@@ -5,4 +5,5 @@ export const serverType = PropTypes.shape({
name: PropTypes.string,
url: PropTypes.string,
apiKey: PropTypes.string,
version: PropTypes.string,
});

View File

@@ -6,6 +6,7 @@ import DeleteServerButton from '../DeleteServerButton';
import ImportServersBtn from '../helpers/ImportServersBtn';
import { resetSelectedServer, selectServer } from '../reducers/selectedServer';
import { createServer, createServers, deleteServer, listServers } from '../reducers/server';
import ForServerVersion from '../helpers/ForServerVersion';
import ServersImporter from './ServersImporter';
import ServersService from './ServersService';
import ServersExporter from './ServersExporter';
@@ -28,6 +29,9 @@ const provideServices = (bottle, connect, withRouter) => {
bottle.serviceFactory('ImportServersBtn', ImportServersBtn, 'ServersImporter');
bottle.decorator('ImportServersBtn', connect(null, [ 'createServers' ]));
bottle.serviceFactory('ForServerVersion', () => ForServerVersion);
bottle.decorator('ForServerVersion', connect([ 'selectedServer' ]));
// Services
bottle.constant('csvjson', csvjson);
bottle.service('ServersImporter', ServersImporter, 'csvjson');

View File

@@ -2,11 +2,10 @@ import { faAngleDoubleDown as downIcon, faAngleDoubleUp as upIcon } from '@forta
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { assoc, dissoc, isEmpty, isNil, pipe, replace, trim } from 'ramda';
import React from 'react';
import { Collapse } from 'reactstrap';
import { Collapse, FormGroup, Input } from 'reactstrap';
import * as PropTypes from 'prop-types';
import DateInput from '../utils/DateInput';
import Checkbox from '../utils/Checkbox';
import ForVersion from '../utils/ForVersion';
import { serverType } from '../servers/prop-types';
import { compareVersions } from '../utils/utils';
import { createShortUrlResultType } from './reducers/shortUrlCreation';
@@ -15,7 +14,11 @@ import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon';
const normalizeTag = pipe(trim, replace(/ /g, '-'));
const formatDate = (date) => isNil(date) ? date : date.format();
const CreateShortUrl = (TagsSelector, CreateShortUrlResult) => class CreateShortUrl extends React.Component {
const CreateShortUrl = (
TagsSelector,
CreateShortUrlResult,
ForServerVersion
) => class CreateShortUrl extends React.Component {
static propTypes = {
createShortUrl: PropTypes.func,
shortUrlCreationResult: createShortUrlResultType,
@@ -40,9 +43,8 @@ const CreateShortUrl = (TagsSelector, CreateShortUrlResult) => class CreateShort
const changeTags = (tags) => this.setState({ tags: tags.map(normalizeTag) });
const renderOptionalInput = (id, placeholder, type = 'text', props = {}) => (
<div className="form-group">
<input
className="form-control"
<FormGroup>
<Input
id={id}
type={type}
placeholder={placeholder}
@@ -50,7 +52,7 @@ const CreateShortUrl = (TagsSelector, CreateShortUrlResult) => class CreateShort
onChange={(e) => this.setState({ [id]: e.target.value })}
{...props}
/>
</div>
</FormGroup>
);
const renderDateInput = (id, placeholder, props = {}) => (
<div className="form-group">
@@ -117,7 +119,7 @@ const CreateShortUrl = (TagsSelector, CreateShortUrlResult) => class CreateShort
</div>
</div>
<ForVersion minVersion="1.16.0" currentServerVersion={currentServerVersion}>
<ForServerVersion minVersion="1.16.0">
<div className="mb-4 text-right">
<Checkbox
className="mr-2"
@@ -128,7 +130,7 @@ const CreateShortUrl = (TagsSelector, CreateShortUrlResult) => class CreateShort
</Checkbox>
<UseExistingIfFoundInfoIcon />
</div>
</ForVersion>
</ForServerVersion>
</Collapse>
<div>

View File

@@ -1,10 +1,13 @@
import { faTags as tagsIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React from 'react';
import { isEmpty } from 'ramda';
import { isEmpty, pipe } from 'ramda';
import PropTypes from 'prop-types';
import moment from 'moment';
import SearchField from '../utils/SearchField';
import Tag from '../tags/helpers/Tag';
import DateRangeRow from '../utils/DateRangeRow';
import { formatDate } from '../utils/utils';
import { shortUrlsListParamsType } from './reducers/shortUrlsListParams';
import './SearchBar.scss';
@@ -13,19 +16,37 @@ const propTypes = {
shortUrlsListParams: shortUrlsListParamsType,
};
const SearchBar = (colorGenerator) => {
const dateOrUndefined = (date) => date ? moment(date) : undefined;
const SearchBar = (colorGenerator, ForServerVersion) => {
const SearchBar = ({ listShortUrls, shortUrlsListParams }) => {
const selectedTags = shortUrlsListParams.tags || [];
const setDate = (dateName) => pipe(
formatDate(),
(date) => listShortUrls({ ...shortUrlsListParams, [dateName]: date })
);
return (
<div className="serach-bar-container">
<SearchField onChange={
(searchTerm) => listShortUrls({ ...shortUrlsListParams, searchTerm })
}
<div className="search-bar-container">
<SearchField
onChange={
(searchTerm) => listShortUrls({ ...shortUrlsListParams, searchTerm })
}
/>
<ForServerVersion minVersion="1.21.0">
<div className="mt-3">
<DateRangeRow
startDate={dateOrUndefined(shortUrlsListParams.startDate)}
endDate={dateOrUndefined(shortUrlsListParams.endDate)}
onStartDateChange={setDate('startDate')}
onEndDateChange={setDate('endDate')}
/>
</div>
</ForServerVersion>
{!isEmpty(selectedTags) && (
<h4 className="search-bar__selected-tag mt-2">
<h4 className="search-bar__selected-tag mt-3">
<FontAwesomeIcon icon={tagsIcon} className="search-bar__tags-icon" />
&nbsp;
{selectedTags.map((tag) => (

View File

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

View File

@@ -1,10 +1,12 @@
import React from 'react';
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import PropTypes from 'prop-types';
import { identity } from 'ramda';
import { identity, pipe } from 'ramda';
import { shortUrlType } from '../reducers/shortUrlsList';
import { shortUrlDeletionType } from '../reducers/shortUrlDeletion';
const THRESHOLD_REACHED = 'INVALID_SHORTCODE_DELETION';
export default class DeleteShortUrlModal extends React.Component {
static propTypes = {
shortUrl: shortUrlType,
@@ -13,21 +15,17 @@ export default class DeleteShortUrlModal extends React.Component {
shortUrlDeletion: shortUrlDeletionType,
deleteShortUrl: PropTypes.func,
resetDeleteShortUrl: PropTypes.func,
shortUrlDeleted: PropTypes.func,
};
state = { inputValue: '' };
handleDeleteUrl = (e) => {
e.preventDefault();
const { deleteShortUrl, shortUrl, toggle, shortUrlDeleted } = this.props;
const { shortCode } = shortUrl;
const { deleteShortUrl, shortUrl, toggle } = this.props;
const { shortCode, domain } = shortUrl;
deleteShortUrl(shortCode)
.then(() => {
shortUrlDeleted(shortCode);
toggle();
})
deleteShortUrl(shortCode, domain)
.then(toggle)
.catch(identity);
};
@@ -38,15 +36,17 @@ export default class DeleteShortUrlModal extends React.Component {
}
render() {
const { shortUrl, toggle, isOpen, shortUrlDeletion } = this.props;
const THRESHOLD_REACHED = 'INVALID_SHORTCODE_DELETION';
const hasThresholdError = shortUrlDeletion.error && shortUrlDeletion.errorData.error === THRESHOLD_REACHED;
const hasErrorOtherThanThreshold = shortUrlDeletion.error && shortUrlDeletion.errorData.error !== THRESHOLD_REACHED;
const { shortUrl, toggle, isOpen, shortUrlDeletion, resetDeleteShortUrl } = this.props;
const { error, errorData } = shortUrlDeletion;
const errorCode = error && (errorData.type || errorData.error);
const hasThresholdError = errorCode === THRESHOLD_REACHED;
const hasErrorOtherThanThreshold = error && errorCode !== THRESHOLD_REACHED;
const close = pipe(resetDeleteShortUrl, toggle);
return (
<Modal isOpen={isOpen} toggle={toggle} centered>
<Modal isOpen={isOpen} toggle={close} centered>
<form onSubmit={this.handleDeleteUrl}>
<ModalHeader toggle={toggle}>
<ModalHeader toggle={close}>
<span className="text-danger">Delete short URL</span>
</ModalHeader>
<ModalBody>
@@ -63,7 +63,8 @@ export default class DeleteShortUrlModal extends React.Component {
{hasThresholdError && (
<div className="p-2 mt-2 bg-warning text-center">
This short URL has received too many visits and therefore, it cannot be deleted
{errorData.threshold && `This short URL has received more than ${errorData.threshold} visits, and therefore, it cannot be deleted.`}
{!errorData.threshold && 'This short URL has received too many visits, and therefore, it cannot be deleted.'}
</div>
)}
{hasErrorOtherThanThreshold && (
@@ -73,7 +74,7 @@ export default class DeleteShortUrlModal extends React.Component {
)}
</ModalBody>
<ModalFooter>
<button type="button" className="btn btn-link" onClick={toggle}>Cancel</button>
<button type="button" className="btn btn-link" onClick={close}>Cancel</button>
<button
type="submit"
className="btn btn-danger"

View File

@@ -0,0 +1,100 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { Modal, ModalBody, ModalFooter, ModalHeader, FormGroup, Input, UncontrolledTooltip } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
import { ExternalLink } from 'react-external-link';
import moment from 'moment';
import { isEmpty, pipe } from 'ramda';
import { shortUrlType } from '../reducers/shortUrlsList';
import { shortUrlEditMetaType } from '../reducers/shortUrlMeta';
import DateInput from '../../utils/DateInput';
import { formatIsoDate } from '../../utils/utils';
const propTypes = {
isOpen: PropTypes.bool.isRequired,
toggle: PropTypes.func.isRequired,
shortUrl: shortUrlType.isRequired,
shortUrlMeta: shortUrlEditMetaType,
editShortUrlMeta: PropTypes.func,
resetShortUrlMeta: PropTypes.func,
};
const dateOrUndefined = (shortUrl, dateName) => {
const date = shortUrl && shortUrl.meta && shortUrl.meta[dateName];
return date && moment(date);
};
const EditMetaModal = (
{ isOpen, toggle, shortUrl, shortUrlMeta, editShortUrlMeta, resetShortUrlMeta }
) => {
const { saving, error } = shortUrlMeta;
const url = shortUrl && (shortUrl.shortUrl || '');
const [ validSince, setValidSince ] = useState(dateOrUndefined(shortUrl, 'validSince'));
const [ validUntil, setValidUntil ] = useState(dateOrUndefined(shortUrl, 'validUntil'));
const [ maxVisits, setMaxVisits ] = useState(shortUrl && shortUrl.meta && shortUrl.meta.maxVisits);
const close = pipe(resetShortUrlMeta, toggle);
const doEdit = () => editShortUrlMeta(shortUrl.shortCode, shortUrl.domain, {
maxVisits: maxVisits && !isEmpty(maxVisits) ? parseInt(maxVisits) : null,
validSince: validSince && formatIsoDate(validSince),
validUntil: validUntil && formatIsoDate(validUntil),
}).then(close);
return (
<Modal isOpen={isOpen} toggle={close} centered>
<ModalHeader toggle={close}>
<FontAwesomeIcon icon={infoIcon} id="metaTitleInfo" /> Edit metadata for <ExternalLink href={url} />
<UncontrolledTooltip target="metaTitleInfo" placement="bottom">
<p>Using these metadata properties, you can limit when and how many times your short URL can be visited.</p>
<p>If any of the params is not met, the URL will behave as if it was an invalid short URL.</p>
</UncontrolledTooltip>
</ModalHeader>
<form onSubmit={(e) => e.preventDefault() || doEdit()}>
<ModalBody>
<FormGroup>
<DateInput
placeholderText="Enabled since..."
selected={validSince}
maxDate={validUntil}
isClearable
onChange={setValidSince}
/>
</FormGroup>
<FormGroup>
<DateInput
placeholderText="Enabled until..."
selected={validUntil}
minDate={validSince}
isClearable
onChange={setValidUntil}
/>
</FormGroup>
<FormGroup className="mb-0">
<Input
type="number"
placeholder="Maximum number of visits allowed"
min={1}
value={maxVisits || ''}
onChange={(e) => setMaxVisits(e.target.value)}
/>
</FormGroup>
{error && (
<div className="p-2 mt-2 bg-danger text-white text-center">
Something went wrong while saving the metadata :(
</div>
)}
</ModalBody>
<ModalFooter>
<button className="btn btn-link" type="button" onClick={close}>Cancel</button>
<button className="btn btn-primary" type="submit" disabled={saving}>{saving ? 'Saving...' : 'Save'}</button>
</ModalFooter>
</form>
</Modal>
);
};
EditMetaModal.propTypes = propTypes;
export default EditMetaModal;

View File

@@ -1,7 +1,8 @@
import React from 'react';
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import PropTypes from 'prop-types';
import ExternalLink from '../../utils/ExternalLink';
import { ExternalLink } from 'react-external-link';
import { pipe } from 'ramda';
import { shortUrlTagsType } from '../reducers/shortUrlTags';
import { shortUrlType } from '../reducers/shortUrlsList';
@@ -9,40 +10,24 @@ const EditTagsModal = (TagsSelector) => class EditTagsModal extends React.Compon
static propTypes = {
isOpen: PropTypes.bool.isRequired,
toggle: PropTypes.func.isRequired,
url: PropTypes.string.isRequired,
shortUrl: shortUrlType.isRequired,
shortUrlTags: shortUrlTagsType,
editShortUrlTags: PropTypes.func,
shortUrlTagsEdited: PropTypes.func,
resetShortUrlsTags: PropTypes.func,
};
saveTags = () => {
const { editShortUrlTags, shortUrl, toggle } = this.props;
editShortUrlTags(shortUrl.shortCode, this.state.tags)
.then(() => {
this.tagsSaved = true;
toggle();
})
editShortUrlTags(shortUrl.shortCode, shortUrl.domain, this.state.tags)
.then(toggle)
.catch(() => {});
};
refreshShortUrls = () => {
if (!this.tagsSaved) {
return;
}
const { shortUrlTagsEdited, shortUrl, shortUrlTags } = this.props;
const { tags } = shortUrlTags;
shortUrlTagsEdited(shortUrl.shortCode, tags);
};
componentDidMount() {
const { resetShortUrlsTags } = this.props;
resetShortUrlsTags();
this.tagsSaved = false;
}
constructor(props) {
@@ -51,12 +36,14 @@ const EditTagsModal = (TagsSelector) => class EditTagsModal extends React.Compon
}
render() {
const { isOpen, toggle, url, shortUrlTags } = this.props;
const { isOpen, toggle, shortUrl, shortUrlTags, resetShortUrlsTags } = this.props;
const url = shortUrl && (shortUrl.shortUrl || '');
const close = pipe(resetShortUrlsTags, toggle);
return (
<Modal isOpen={isOpen} toggle={toggle} centered onClosed={() => this.refreshShortUrls()}>
<ModalHeader toggle={toggle}>
Edit tags for <ExternalLink href={url}>{url}</ExternalLink>
<Modal isOpen={isOpen} toggle={close} centered>
<ModalHeader toggle={close}>
Edit tags for <ExternalLink href={url} />
</ModalHeader>
<ModalBody>
<TagsSelector tags={this.state.tags} onChange={(tags) => this.setState({ tags })} />
@@ -67,7 +54,7 @@ const EditTagsModal = (TagsSelector) => class EditTagsModal extends React.Compon
)}
</ModalBody>
<ModalFooter>
<button className="btn btn-link" onClick={toggle}>Cancel</button>
<button className="btn btn-link" onClick={close}>Cancel</button>
<button
className="btn btn-primary"
type="button"

View File

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

View File

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

View File

@@ -0,0 +1,49 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
import { UncontrolledTooltip } from 'reactstrap';
import { serverType } from '../../servers/prop-types';
import { shortUrlType } from '../reducers/shortUrlsList';
import './ShortUrlVisitsCount.scss';
import VisitStatsLink from './VisitStatsLink';
const propTypes = {
visitsCount: PropTypes.number.isRequired,
shortUrl: shortUrlType,
selectedServer: serverType,
};
const ShortUrlVisitsCount = ({ visitsCount, shortUrl, selectedServer }) => {
const maxVisits = shortUrl && shortUrl.meta && shortUrl.meta.maxVisits;
const visitsLink = (
<VisitStatsLink selectedServer={selectedServer} shortUrl={shortUrl}>
<strong>{visitsCount}</strong>
</VisitStatsLink>
);
if (!maxVisits) {
return visitsLink;
}
return (
<React.Fragment>
<span className="indivisible">
{visitsLink}
<small id="maxVisitsControl" className="short-urls-visits-count__max-visits-control">
{' '}/ {maxVisits}{' '}
<sup>
<FontAwesomeIcon icon={infoIcon} />
</sup>
</small>
</span>
<UncontrolledTooltip target="maxVisitsControl" placement="bottom">
This short URL will not accept more than <b>{maxVisits}</b> visits.
</UncontrolledTooltip>
</React.Fragment>
);
};
ShortUrlVisitsCount.propTypes = propTypes;
export default ShortUrlVisitsCount;

View File

@@ -0,0 +1,3 @@
.short-urls-visits-count__max-visits-control {
cursor: help;
}

View File

@@ -2,11 +2,12 @@ import { isEmpty } from 'ramda';
import React from 'react';
import Moment from 'react-moment';
import PropTypes from 'prop-types';
import { ExternalLink } from 'react-external-link';
import { shortUrlsListParamsType } from '../reducers/shortUrlsListParams';
import { serverType } from '../../servers/prop-types';
import ExternalLink from '../../utils/ExternalLink';
import { shortUrlType } from '../reducers/shortUrlsList';
import Tag from '../../tags/helpers/Tag';
import ShortUrlVisitsCount from './ShortUrlVisitsCount';
import './ShortUrlsRow.scss';
const ShortUrlsRow = (
@@ -56,7 +57,13 @@ const ShortUrlsRow = (
<ExternalLink href={shortUrl.longUrl} />
</td>
<td className="short-urls-row__cell" data-th="Tags: ">{this.renderTags(shortUrl.tags)}</td>
<td className="short-urls-row__cell text-md-right" data-th="Visits: ">{shortUrl.visitsCount}</td>
<td className="short-urls-row__cell text-md-right" data-th="Visits: ">
<ShortUrlVisitsCount
visitsCount={shortUrl.visitsCount}
shortUrl={shortUrl}
selectedServer={selectedServer}
/>
</td>
<td className="short-urls-row__cell short-urls-row__cell--relative">
<small
className="badge badge-warning short-urls-row__copy-hint"

View File

@@ -5,20 +5,26 @@ import {
faEllipsisV as menuIcon,
faQrcode as qrIcon,
faMinusCircle as deleteIcon,
faEdit as editIcon,
} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React from 'react';
import { CopyToClipboard } from 'react-copy-to-clipboard';
import { Link } from 'react-router-dom';
import { ButtonDropdown, DropdownItem, DropdownMenu, DropdownToggle } from 'reactstrap';
import PropTypes from 'prop-types';
import { serverType } from '../../servers/prop-types';
import { shortUrlType } from '../reducers/shortUrlsList';
import PreviewModal from './PreviewModal';
import QrCodeModal from './QrCodeModal';
import VisitStatsLink from './VisitStatsLink';
import './ShortUrlsRowMenu.scss';
const ShortUrlsRowMenu = (DeleteShortUrlModal, EditTagsModal) => class ShortUrlsRowMenu extends React.Component {
const ShortUrlsRowMenu = (
DeleteShortUrlModal,
EditTagsModal,
EditMetaModal,
ForServerVersion
) => class ShortUrlsRowMenu extends React.Component {
static propTypes = {
onCopyToClipboard: PropTypes.func,
selectedServer: serverType,
@@ -30,6 +36,7 @@ const ShortUrlsRowMenu = (DeleteShortUrlModal, EditTagsModal) => class ShortUrls
isQrModalOpen: false,
isPreviewModalOpen: false,
isTagsModalOpen: false,
isMetaModalOpen: false,
isDeleteModalOpen: false,
};
toggle = () => this.setState(({ isOpen }) => ({ isOpen: !isOpen }));
@@ -41,6 +48,7 @@ const ShortUrlsRowMenu = (DeleteShortUrlModal, EditTagsModal) => class ShortUrls
const toggleQrCode = toggleModal('isQrModalOpen');
const togglePreview = toggleModal('isPreviewModalOpen');
const toggleTags = toggleModal('isTagsModalOpen');
const toggleMeta = toggleModal('isMetaModalOpen');
const toggleDelete = toggleModal('isDeleteModalOpen');
return (
@@ -49,42 +57,48 @@ const ShortUrlsRowMenu = (DeleteShortUrlModal, EditTagsModal) => class ShortUrls
&nbsp;<FontAwesomeIcon icon={menuIcon} />&nbsp;
</DropdownToggle>
<DropdownMenu right>
<DropdownItem tag={Link} to={`/server/${selectedServer ? selectedServer.id : ''}/short-code/${shortUrl.shortCode}/visits`}>
<FontAwesomeIcon icon={pieChartIcon} /> &nbsp;Visit stats
<DropdownItem tag={VisitStatsLink} selectedServer={selectedServer} shortUrl={shortUrl}>
<FontAwesomeIcon icon={pieChartIcon} fixedWidth /> Visit stats
</DropdownItem>
<DropdownItem onClick={toggleTags}>
<FontAwesomeIcon icon={tagsIcon} /> &nbsp;Edit tags
<FontAwesomeIcon icon={tagsIcon} fixedWidth /> Edit tags
</DropdownItem>
<EditTagsModal
url={completeShortUrl}
shortUrl={shortUrl}
isOpen={this.state.isTagsModalOpen}
toggle={toggleTags}
/>
<EditTagsModal shortUrl={shortUrl} isOpen={this.state.isTagsModalOpen} toggle={toggleTags} />
<ForServerVersion minVersion="1.18.0">
<DropdownItem onClick={toggleMeta}>
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit metadata
</DropdownItem>
<EditMetaModal shortUrl={shortUrl} isOpen={this.state.isMetaModalOpen} toggle={toggleMeta} />
</ForServerVersion>
<DropdownItem className="short-urls-row-menu__dropdown-item--danger" onClick={toggleDelete}>
<FontAwesomeIcon icon={deleteIcon} /> &nbsp;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} /> &nbsp;Preview
</DropdownItem>
<PreviewModal url={completeShortUrl} isOpen={this.state.isPreviewModalOpen} toggle={togglePreview} />
<ForServerVersion maxVersion="1.x">
<DropdownItem onClick={togglePreview}>
<FontAwesomeIcon icon={pictureIcon} fixedWidth /> Preview
</DropdownItem>
<PreviewModal url={completeShortUrl} isOpen={this.state.isPreviewModalOpen} toggle={togglePreview} />
</ForServerVersion>
<DropdownItem onClick={toggleQrCode}>
<FontAwesomeIcon icon={qrIcon} /> &nbsp;QR code
<FontAwesomeIcon icon={qrIcon} fixedWidth /> QR code
</DropdownItem>
<QrCodeModal url={completeShortUrl} isOpen={this.state.isQrModalOpen} toggle={toggleQrCode} />
<DropdownItem divider />
<ForServerVersion maxVersion="1.x">
<DropdownItem divider />
</ForServerVersion>
<CopyToClipboard text={completeShortUrl} onCopy={onCopyToClipboard}>
<DropdownItem>
<FontAwesomeIcon icon={copyIcon} /> &nbsp;Copy to clipboard
<FontAwesomeIcon icon={copyIcon} fixedWidth /> Copy to clipboard
</DropdownItem>
</CopyToClipboard>
</DropdownMenu>

View File

@@ -0,0 +1,29 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
import { serverType } from '../../servers/prop-types';
import { shortUrlType } from '../reducers/shortUrlsList';
const propTypes = {
shortUrl: shortUrlType,
selectedServer: serverType,
children: PropTypes.node.isRequired,
};
const buildVisitsUrl = ({ id }, { shortCode, domain }) => {
const query = domain ? `?domain=${domain}` : '';
return `/server/${id}/short-code/${shortCode}/visits${query}`;
};
const VisitStatsLink = ({ selectedServer, shortUrl, children, ...rest }) => {
if (!selectedServer || !shortUrl) {
return <span {...rest}>{children}</span>;
}
return <Link to={buildVisitsUrl(selectedServer, shortUrl)} {...rest}>{children}</Link>;
};
VisitStatsLink.propTypes = propTypes;
export default VisitStatsLink;

View File

@@ -1,22 +1,19 @@
import { createAction, handleActions } from 'redux-actions';
import PropTypes from 'prop-types';
import { apiErrorType } from '../../utils/services/ShlinkApiClient';
/* eslint-disable padding-line-between-statements */
export const DELETE_SHORT_URL_START = 'shlink/deleteShortUrl/DELETE_SHORT_URL_START';
export const DELETE_SHORT_URL_ERROR = 'shlink/deleteShortUrl/DELETE_SHORT_URL_ERROR';
export const DELETE_SHORT_URL = 'shlink/deleteShortUrl/DELETE_SHORT_URL';
export const RESET_DELETE_SHORT_URL = 'shlink/deleteShortUrl/RESET_DELETE_SHORT_URL';
export const SHORT_URL_DELETED = 'shlink/deleteShortUrl/SHORT_URL_DELETED';
export const RESET_DELETE_SHORT_URL = 'shlink/deleteShortUrl/RESET_DELETE_SHORT_URL';
/* eslint-enable padding-line-between-statements */
export const shortUrlDeletionType = PropTypes.shape({
shortCode: PropTypes.string.isRequired,
loading: PropTypes.bool.isRequired,
error: PropTypes.bool.isRequired,
errorData: PropTypes.shape({
error: PropTypes.string,
message: PropTypes.string,
}).isRequired,
errorData: apiErrorType.isRequired,
});
const initialState = {
@@ -29,18 +26,18 @@ const initialState = {
export default handleActions({
[DELETE_SHORT_URL_START]: (state) => ({ ...state, loading: true, error: false }),
[DELETE_SHORT_URL_ERROR]: (state, { errorData }) => ({ ...state, errorData, loading: false, error: true }),
[DELETE_SHORT_URL]: (state, { shortCode }) => ({ ...state, shortCode, loading: false, error: false }),
[SHORT_URL_DELETED]: (state, { shortCode }) => ({ ...state, shortCode, loading: false, error: false }),
[RESET_DELETE_SHORT_URL]: () => initialState,
}, initialState);
export const deleteShortUrl = (buildShlinkApiClient) => (shortCode) => async (dispatch, getState) => {
export const deleteShortUrl = (buildShlinkApiClient) => (shortCode, domain) => async (dispatch, getState) => {
dispatch({ type: DELETE_SHORT_URL_START });
const { deleteShortUrl } = await buildShlinkApiClient(getState);
try {
await deleteShortUrl(shortCode);
dispatch({ type: DELETE_SHORT_URL, shortCode });
await deleteShortUrl(shortCode, domain);
dispatch({ type: SHORT_URL_DELETED, shortCode, domain });
} catch (e) {
dispatch({ type: DELETE_SHORT_URL_ERROR, errorData: e.response.data });
@@ -49,5 +46,3 @@ export const deleteShortUrl = (buildShlinkApiClient) => (shortCode) => async (di
};
export const resetDeleteShortUrl = createAction(RESET_DELETE_SHORT_URL);
export const shortUrlDeleted = (shortCode) => ({ type: SHORT_URL_DELETED, shortCode });

View File

@@ -0,0 +1,52 @@
import { createAction, handleActions } from 'redux-actions';
import PropTypes from 'prop-types';
/* eslint-disable padding-line-between-statements */
export const EDIT_SHORT_URL_META_START = 'shlink/shortUrlMeta/EDIT_SHORT_URL_META_START';
export const EDIT_SHORT_URL_META_ERROR = 'shlink/shortUrlMeta/EDIT_SHORT_URL_META_ERROR';
export const SHORT_URL_META_EDITED = 'shlink/shortUrlMeta/SHORT_URL_META_EDITED';
export const RESET_EDIT_SHORT_URL_META = 'shlink/shortUrlMeta/RESET_EDIT_SHORT_URL_META';
/* eslint-enable padding-line-between-statements */
export const shortUrlMetaType = PropTypes.shape({
validSince: PropTypes.string,
validUntil: PropTypes.string,
maxVisits: PropTypes.number,
});
export const shortUrlEditMetaType = PropTypes.shape({
shortCode: PropTypes.string,
meta: shortUrlMetaType.isRequired,
saving: PropTypes.bool.isRequired,
error: PropTypes.bool.isRequired,
});
const initialState = {
shortCode: null,
meta: {},
saving: false,
error: false,
};
export default handleActions({
[EDIT_SHORT_URL_META_START]: (state) => ({ ...state, saving: true, error: false }),
[EDIT_SHORT_URL_META_ERROR]: (state) => ({ ...state, saving: false, error: true }),
[SHORT_URL_META_EDITED]: (state, { shortCode, meta }) => ({ shortCode, meta, saving: false, error: false }),
[RESET_EDIT_SHORT_URL_META]: () => initialState,
}, initialState);
export const editShortUrlMeta = (buildShlinkApiClient) => (shortCode, domain, meta) => async (dispatch, getState) => {
dispatch({ type: EDIT_SHORT_URL_META_START });
const { updateShortUrlMeta } = await buildShlinkApiClient(getState);
try {
await updateShortUrlMeta(shortCode, domain, meta);
dispatch({ shortCode, meta, domain, type: SHORT_URL_META_EDITED });
} catch (e) {
dispatch({ type: EDIT_SHORT_URL_META_ERROR });
throw e;
}
};
export const resetShortUrlMeta = createAction(RESET_EDIT_SHORT_URL_META);

View File

@@ -4,9 +4,8 @@ import PropTypes from 'prop-types';
/* eslint-disable padding-line-between-statements */
export const EDIT_SHORT_URL_TAGS_START = 'shlink/shortUrlTags/EDIT_SHORT_URL_TAGS_START';
export const EDIT_SHORT_URL_TAGS_ERROR = 'shlink/shortUrlTags/EDIT_SHORT_URL_TAGS_ERROR';
export const EDIT_SHORT_URL_TAGS = 'shlink/shortUrlTags/EDIT_SHORT_URL_TAGS';
export const RESET_EDIT_SHORT_URL_TAGS = 'shlink/shortUrlTags/RESET_EDIT_SHORT_URL_TAGS';
export const SHORT_URL_TAGS_EDITED = 'shlink/shortUrlTags/SHORT_URL_TAGS_EDITED';
export const RESET_EDIT_SHORT_URL_TAGS = 'shlink/shortUrlTags/RESET_EDIT_SHORT_URL_TAGS';
/* eslint-enable padding-line-between-statements */
export const shortUrlTagsType = PropTypes.shape({
@@ -26,18 +25,18 @@ const initialState = {
export default handleActions({
[EDIT_SHORT_URL_TAGS_START]: (state) => ({ ...state, saving: true, error: false }),
[EDIT_SHORT_URL_TAGS_ERROR]: (state) => ({ ...state, saving: false, error: true }),
[EDIT_SHORT_URL_TAGS]: (state, { shortCode, tags }) => ({ shortCode, tags, saving: false, error: false }),
[SHORT_URL_TAGS_EDITED]: (state, { shortCode, tags }) => ({ shortCode, tags, saving: false, error: false }),
[RESET_EDIT_SHORT_URL_TAGS]: () => initialState,
}, initialState);
export const editShortUrlTags = (buildShlinkApiClient) => (shortCode, tags) => async (dispatch, getState) => {
export const editShortUrlTags = (buildShlinkApiClient) => (shortCode, domain, tags) => async (dispatch, getState) => {
dispatch({ type: EDIT_SHORT_URL_TAGS_START });
const { updateShortUrlTags } = await buildShlinkApiClient(getState);
try {
const normalizedTags = await updateShortUrlTags(shortCode, tags);
const normalizedTags = await updateShortUrlTags(shortCode, domain, tags);
dispatch({ tags: normalizedTags, shortCode, type: EDIT_SHORT_URL_TAGS });
dispatch({ tags: normalizedTags, shortCode, domain, type: SHORT_URL_TAGS_EDITED });
} catch (e) {
dispatch({ type: EDIT_SHORT_URL_TAGS_ERROR });
@@ -46,9 +45,3 @@ export const editShortUrlTags = (buildShlinkApiClient) => (shortCode, tags) => a
};
export const resetShortUrlsTags = createAction(RESET_EDIT_SHORT_URL_TAGS);
export const shortUrlTagsEdited = (shortCode, tags) => ({
tags,
shortCode,
type: SHORT_URL_TAGS_EDITED,
});

View File

@@ -1,8 +1,9 @@
import { handleActions } from 'redux-actions';
import { assoc, assocPath, propEq, reject } from 'ramda';
import { assoc, assocPath, isNil, reject } from 'ramda';
import PropTypes from 'prop-types';
import { SHORT_URL_TAGS_EDITED } from './shortUrlTags';
import { SHORT_URL_DELETED } from './shortUrlDeletion';
import { SHORT_URL_META_EDITED, shortUrlMetaType } from './shortUrlMeta';
/* eslint-disable padding-line-between-statements */
export const LIST_SHORT_URLS_START = 'shlink/shortUrlsList/LIST_SHORT_URLS_START';
@@ -14,7 +15,10 @@ export const shortUrlType = PropTypes.shape({
shortCode: PropTypes.string,
shortUrl: PropTypes.string,
longUrl: PropTypes.string,
visitsCount: PropTypes.number,
meta: shortUrlMetaType,
tags: PropTypes.arrayOf(PropTypes.string),
domain: PropTypes.string,
});
const initialState = {
@@ -23,23 +27,33 @@ const initialState = {
error: false,
};
const shortUrlMatches = (shortUrl, shortCode, domain) => {
if (isNil(domain)) {
return shortUrl.shortCode === shortCode && !shortUrl.domain;
}
return shortUrl.shortCode === shortCode && shortUrl.domain === domain;
};
const setPropFromActionOnMatchingShortUrl = (prop) => (state, { shortCode, domain, [prop]: propValue }) => assocPath(
[ 'shortUrls', 'data' ],
state.shortUrls.data.map(
(shortUrl) => shortUrlMatches(shortUrl, shortCode, domain) ? assoc(prop, propValue, shortUrl) : shortUrl
),
state
);
export default handleActions({
[LIST_SHORT_URLS_START]: (state) => ({ ...state, loading: true, error: false }),
[LIST_SHORT_URLS]: (state, { shortUrls }) => ({ loading: false, error: false, shortUrls }),
[LIST_SHORT_URLS_ERROR]: () => ({ loading: false, error: true, shortUrls: {} }),
[SHORT_URL_TAGS_EDITED]: (state, action) => { // eslint-disable-line object-shorthand
const { data } = state.shortUrls;
return assocPath([ 'shortUrls', 'data' ], data.map((shortUrl) =>
shortUrl.shortCode === action.shortCode
? assoc('tags', action.tags, shortUrl)
: shortUrl), state);
},
[SHORT_URL_DELETED]: (state, action) => assocPath(
[SHORT_URL_DELETED]: (state, { shortCode, domain }) => assocPath(
[ 'shortUrls', 'data' ],
reject(propEq('shortCode', action.shortCode), state.shortUrls.data),
reject((shortUrl) => shortUrlMatches(shortUrl, shortCode, domain), state.shortUrls.data),
state,
),
[SHORT_URL_TAGS_EDITED]: setPropFromActionOnMatchingShortUrl('tags'),
[SHORT_URL_META_EDITED]: setPropFromActionOnMatchingShortUrl('meta'),
}, initialState);
export const listShortUrls = (buildShlinkApiClient) => (params = {}) => async (dispatch, getState) => {

View File

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

View File

@@ -8,11 +8,13 @@ import ShortUrlsRowMenu from '../helpers/ShortUrlsRowMenu';
import CreateShortUrl from '../CreateShortUrl';
import DeleteShortUrlModal from '../helpers/DeleteShortUrlModal';
import EditTagsModal from '../helpers/EditTagsModal';
import EditMetaModal from '../helpers/EditMetaModal';
import CreateShortUrlResult from '../helpers/CreateShortUrlResult';
import { listShortUrls } from '../reducers/shortUrlsList';
import { createShortUrl, resetCreateShortUrl } from '../reducers/shortUrlCreation';
import { deleteShortUrl, resetDeleteShortUrl, shortUrlDeleted } from '../reducers/shortUrlDeletion';
import { editShortUrlTags, resetShortUrlsTags, shortUrlTagsEdited } from '../reducers/shortUrlTags';
import { deleteShortUrl, resetDeleteShortUrl } from '../reducers/shortUrlDeletion';
import { editShortUrlTags, resetShortUrlsTags } from '../reducers/shortUrlTags';
import { editShortUrlMeta, resetShortUrlMeta } from '../reducers/shortUrlMeta';
import { resetShortUrlParams } from '../reducers/shortUrlsListParams';
const provideServices = (bottle, connect) => {
@@ -22,7 +24,7 @@ const provideServices = (bottle, connect) => {
(state) => assoc('shortUrlsList', state.shortUrlsList.shortUrls, state.shortUrlsList)
));
bottle.serviceFactory('SearchBar', SearchBar, 'ColorGenerator');
bottle.serviceFactory('SearchBar', SearchBar, 'ColorGenerator', 'ForServerVersion');
bottle.decorator('SearchBar', connect([ 'shortUrlsListParams' ], [ 'listShortUrls' ]));
bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsRow');
@@ -33,31 +35,34 @@ const provideServices = (bottle, connect) => {
bottle.serviceFactory('ShortUrlsRow', ShortUrlsRow, 'ShortUrlsRowMenu', 'ColorGenerator', 'stateFlagTimeout');
bottle.serviceFactory('ShortUrlsRowMenu', ShortUrlsRowMenu, 'DeleteShortUrlModal', 'EditTagsModal');
bottle.serviceFactory(
'ShortUrlsRowMenu',
ShortUrlsRowMenu,
'DeleteShortUrlModal',
'EditTagsModal',
'EditMetaModal',
'ForServerVersion'
);
bottle.serviceFactory('CreateShortUrlResult', CreateShortUrlResult, 'stateFlagTimeout');
bottle.serviceFactory('CreateShortUrl', CreateShortUrl, 'TagsSelector', 'CreateShortUrlResult');
bottle.serviceFactory('CreateShortUrl', CreateShortUrl, 'TagsSelector', 'CreateShortUrlResult', 'ForServerVersion');
bottle.decorator(
'CreateShortUrl',
connect([ 'shortUrlCreationResult', 'selectedServer' ], [ 'createShortUrl', 'resetCreateShortUrl' ])
);
bottle.serviceFactory('DeleteShortUrlModal', () => DeleteShortUrlModal);
bottle.decorator('DeleteShortUrlModal', connect(
[ 'shortUrlDeletion' ],
[ 'deleteShortUrl', 'resetDeleteShortUrl', 'shortUrlDeleted' ]
));
bottle.decorator('DeleteShortUrlModal', connect([ 'shortUrlDeletion' ], [ 'deleteShortUrl', 'resetDeleteShortUrl' ]));
bottle.serviceFactory('EditTagsModal', EditTagsModal, 'TagsSelector');
bottle.decorator('EditTagsModal', connect(
[ 'shortUrlTags' ],
[ 'editShortUrlTags', 'resetShortUrlsTags', 'shortUrlTagsEdited' ]
));
bottle.decorator('EditTagsModal', connect([ 'shortUrlTags' ], [ 'editShortUrlTags', 'resetShortUrlsTags' ]));
bottle.serviceFactory('EditMetaModal', () => EditMetaModal);
bottle.decorator('EditMetaModal', connect([ 'shortUrlMeta' ], [ 'editShortUrlMeta', 'resetShortUrlMeta' ]));
// Actions
bottle.serviceFactory('editShortUrlTags', editShortUrlTags, 'buildShlinkApiClient');
bottle.serviceFactory('resetShortUrlsTags', () => resetShortUrlsTags);
bottle.serviceFactory('shortUrlTagsEdited', () => shortUrlTagsEdited);
bottle.serviceFactory('listShortUrls', listShortUrls, 'buildShlinkApiClient');
bottle.serviceFactory('resetShortUrlParams', () => resetShortUrlParams);
@@ -67,7 +72,9 @@ const provideServices = (bottle, connect) => {
bottle.serviceFactory('deleteShortUrl', deleteShortUrl, 'buildShlinkApiClient');
bottle.serviceFactory('resetDeleteShortUrl', () => resetDeleteShortUrl);
bottle.serviceFactory('shortUrlDeleted', () => shortUrlDeleted);
bottle.serviceFactory('editShortUrlMeta', editShortUrlMeta, 'buildShlinkApiClient');
bottle.serviceFactory('resetShortUrlMeta', () => resetShortUrlMeta);
};
export default provideServices;

View File

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

View File

@@ -1,6 +1,6 @@
@import '../utils/base';
.short-url-visits__date-input {
.date-range-row__date-input {
@media (max-width: $smMax) {
margin-top: .5rem;
}

View File

@@ -1,3 +0,0 @@
import { ExternalLink } from 'react-external-link';
export default ExternalLink;

View File

@@ -1,19 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { isEmpty } from 'ramda';
import { compareVersions } from './utils';
const propTypes = {
minVersion: PropTypes.string.isRequired,
currentServerVersion: PropTypes.string.isRequired,
children: PropTypes.node.isRequired,
};
const ForVersion = ({ minVersion, currentServerVersion, children }) =>
isEmpty(currentServerVersion) || compareVersions(currentServerVersion, '<', minVersion)
? null
: <React.Fragment>{children}</React.Fragment>;
ForVersion.propTypes = propTypes;
export default ForVersion;

View File

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

View File

@@ -1,20 +1,29 @@
import qs from 'qs';
import { isEmpty, isNil, reject } from 'ramda';
import PropTypes from 'prop-types';
const API_VERSION = '1';
export const apiErrorType = PropTypes.shape({
type: PropTypes.string,
detail: PropTypes.string,
title: PropTypes.string,
status: PropTypes.number,
error: PropTypes.string, // Deprecated
message: PropTypes.string, // Deprecated
});
export const buildShlinkBaseUrl = (url) => url ? `${url}/rest/v${API_VERSION}` : '';
const buildShlinkBaseUrl = (url, apiVersion) => url ? `${url}/rest/v${apiVersion}` : '';
const rejectNilProps = reject(isNil);
export default class ShlinkApiClient {
constructor(axios, baseUrl, apiKey) {
this.axios = axios;
this._baseUrl = buildShlinkBaseUrl(baseUrl);
this._apiVersion = 2;
this._baseUrl = baseUrl;
this._apiKey = apiKey || '';
}
listShortUrls = (options = {}) =>
this._performRequest('/short-urls', 'GET', options)
.then((resp) => resp.data.shortUrls);
this._performRequest('/short-urls', 'GET', options).then((resp) => resp.data.shortUrls);
createShortUrl = (options) => {
const filteredOptions = reject((value) => isEmpty(value) || isNil(value), options);
@@ -27,18 +36,22 @@ export default class ShlinkApiClient {
this._performRequest(`/short-urls/${shortCode}/visits`, 'GET', query)
.then((resp) => resp.data.visits);
getShortUrl = (shortCode) =>
this._performRequest(`/short-urls/${shortCode}`, 'GET')
getShortUrl = (shortCode, domain) =>
this._performRequest(`/short-urls/${shortCode}`, 'GET', { domain })
.then((resp) => resp.data);
deleteShortUrl = (shortCode) =>
this._performRequest(`/short-urls/${shortCode}`, 'DELETE')
deleteShortUrl = (shortCode, domain) =>
this._performRequest(`/short-urls/${shortCode}`, 'DELETE', { domain })
.then(() => ({}));
updateShortUrlTags = (shortCode, tags) =>
this._performRequest(`/short-urls/${shortCode}/tags`, 'PUT', {}, { tags })
updateShortUrlTags = (shortCode, domain, tags) =>
this._performRequest(`/short-urls/${shortCode}/tags`, 'PUT', { domain }, { tags })
.then((resp) => resp.data.tags);
updateShortUrlMeta = (shortCode, domain, meta) =>
this._performRequest(`/short-urls/${shortCode}`, 'PATCH', { domain }, meta)
.then(() => meta);
listTags = () =>
this._performRequest('/tags', 'GET')
.then((resp) => resp.data.tags.data);
@@ -53,13 +66,35 @@ export default class ShlinkApiClient {
health = () => this._performRequest('/health', 'GET').then((resp) => resp.data);
_performRequest = async (url, method = 'GET', query = {}, body = {}) =>
await this.axios({
method,
url: `${this._baseUrl}${url}`,
headers: { 'X-Api-Key': this._apiKey },
params: query,
data: body,
paramsSerializer: (params) => qs.stringify(params, { arrayFormat: 'brackets' }),
});
_performRequest = async (url, method = 'GET', query = {}, body = {}) => {
try {
return await this.axios({
method,
url: `${buildShlinkBaseUrl(this._baseUrl, this._apiVersion)}${url}`,
headers: { 'X-Api-Key': this._apiKey },
params: rejectNilProps(query),
data: body,
paramsSerializer: (params) => qs.stringify(params, { arrayFormat: 'brackets' }),
});
} catch (e) {
const { response } = e;
// Due to a bug on all previous Shlink versions, requests to non-matching URLs will always result on a CORS error
// when performed from the browser (due to the preflight request not returning a 2xx status.
// See https://github.com/shlinkio/shlink/issues/614), which will make the "response" prop not to be set here.
// The bug will be fixed on upcoming Shlink patches, but for other versions, we can consider this situation as
// if a request has been performed to a not supported API version.
const apiVersionIsNotSupported = !response;
// When the request is not invalid or we have already tried both API versions, throw the error and let the
// caller handle it
if (!apiVersionIsNotSupported || this._apiVersion === 1) {
throw e;
}
this._apiVersion = 1;
return await this._performRequest(url, method, query, body);
}
}
}

View File

@@ -68,3 +68,7 @@ export const versionIsValidSemVer = (version) => {
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;

View File

@@ -4,14 +4,15 @@ import { isEmpty, mapObjIndexed, values } from 'ramda';
import React from 'react';
import { Card } from 'reactstrap';
import PropTypes from 'prop-types';
import DateInput from '../utils/DateInput';
import qs from 'qs';
import DateRangeRow from '../utils/DateRangeRow';
import MutedMessage from '../utils/MuttedMessage';
import { formatDate } from '../utils/utils';
import SortableBarGraph from './SortableBarGraph';
import { shortUrlVisitsType } from './reducers/shortUrlVisits';
import VisitsHeader from './VisitsHeader';
import GraphCard from './GraphCard';
import { shortUrlDetailType } from './reducers/shortUrlDetail';
import './ShortUrlVisits.scss';
const ShortUrlVisits = (
{ processStatsFromVisits },
@@ -21,6 +22,9 @@ const ShortUrlVisits = (
match: PropTypes.shape({
params: PropTypes.object,
}),
location: PropTypes.shape({
search: PropTypes.string,
}),
getShortUrlVisits: PropTypes.func,
shortUrlVisits: shortUrlVisitsType,
getShortUrlDetail: PropTypes.func,
@@ -29,27 +33,24 @@ const ShortUrlVisits = (
};
state = { startDate: undefined, endDate: undefined };
loadVisits = () => {
const { match: { params }, getShortUrlVisits } = this.props;
loadVisits = (loadDetail = false) => {
const { match: { params }, location: { search }, getShortUrlVisits, getShortUrlDetail } = this.props;
const { shortCode } = params;
const dates = mapObjIndexed(
(value) => value && value.format ? value.format('YYYY-MM-DD') : value,
this.state
);
const { startDate, endDate } = dates;
const { startDate, endDate } = mapObjIndexed(formatDate(), this.state);
const { domain } = qs.parse(search, { ignoreQueryPrefix: true });
// While the "page" is loaded, use the timestamp + filtering dates as memoization IDs for stats calcs
// While the "page" is loaded, use the timestamp + filtering dates as memoization IDs for stats calculations
this.memoizationId = `${this.timeWhenMounted}_${shortCode}_${startDate}_${endDate}`;
getShortUrlVisits(shortCode, dates);
getShortUrlVisits(shortCode, { startDate, endDate, domain });
if (loadDetail) {
getShortUrlDetail(shortCode, domain);
}
};
componentDidMount() {
const { match: { params }, getShortUrlDetail } = this.props;
const { shortCode } = params;
this.timeWhenMounted = new Date().getTime();
this.loadVisits();
getShortUrlDetail(shortCode);
this.loadVisits(true);
}
componentWillUnmount() {
@@ -131,33 +132,19 @@ const ShortUrlVisits = (
</div>
);
};
const setDate = (dateField) => (date) => this.setState({ [dateField]: date }, this.loadVisits);
return (
<div className="shlink-container">
<VisitsHeader shortUrlDetail={shortUrlDetail} shortUrlVisits={shortUrlVisits} />
<section className="mt-4">
<div className="row">
<div className="col-xl-3 col-lg-4 col-md-6 offset-xl-6 offset-lg-4">
<DateInput
selected={this.state.startDate}
placeholderText="Since"
isClearable
maxDate={this.state.endDate}
onChange={(date) => this.setState({ startDate: date }, this.loadVisits)}
/>
</div>
<div className="col-xl-3 col-lg-4 col-md-6">
<DateInput
className="short-url-visits__date-input"
selected={this.state.endDate}
placeholderText="Until"
isClearable
minDate={this.state.startDate}
onChange={(date) => this.setState({ endDate: date }, this.loadVisits)}
/>
</div>
</div>
<DateRangeRow
startDate={this.state.startDate}
endDate={this.state.endDate}
onStartDateChange={setDate('startDate')}
onEndDateChange={setDate('endDate')}
/>
</section>
<section>

View File

@@ -1,10 +1,11 @@
import { Card, UncontrolledTooltip } from 'reactstrap';
import Moment from 'react-moment';
import React from 'react';
import ExternalLink from '../utils/ExternalLink';
import './VisitsHeader.scss';
import { ExternalLink } from 'react-external-link';
import ShortUrlVisitsCount from '../short-urls/helpers/ShortUrlVisitsCount';
import { shortUrlDetailType } from './reducers/shortUrlDetail';
import { shortUrlVisitsType } from './reducers/shortUrlVisits';
import './VisitsHeader.scss';
const propTypes = {
shortUrlDetail: shortUrlDetailType.isRequired,
@@ -30,14 +31,16 @@ export default function VisitsHeader({ shortUrlDetail, shortUrlVisits }) {
<header>
<Card className="bg-light" body>
<h2>
<span className="badge badge-main float-right">Visits: {visits.length}</span>
<span className="badge badge-main float-right">
Visits:{' '}
<ShortUrlVisitsCount visitsCount={visits.length} shortUrl={shortUrl} />
</span>
Visit stats for <ExternalLink href={shortLink} />
</h2>
<hr />
<div>Created: {renderDate()}</div>
<div>
Long URL:
&nbsp;
Long URL:{' '}
{loading && <small>Loading...</small>}
{!loading && <ExternalLink href={longLink} />}
</div>

View File

@@ -26,13 +26,13 @@ export default handleActions({
[GET_SHORT_URL_DETAIL]: (state, { shortUrl }) => ({ shortUrl, loading: false, error: false }),
}, initialState);
export const getShortUrlDetail = (buildShlinkApiClient) => (shortCode) => async (dispatch, getState) => {
export const getShortUrlDetail = (buildShlinkApiClient) => (shortCode, domain) => async (dispatch, getState) => {
dispatch({ type: GET_SHORT_URL_DETAIL_START });
const { getShortUrl } = await buildShlinkApiClient(getState);
try {
const shortUrl = await getShortUrl(shortCode);
const shortUrl = await getShortUrl(shortCode, domain);
dispatch({ shortUrl, type: GET_SHORT_URL_DETAIL });
} catch (e) {

View File

@@ -49,7 +49,7 @@ export default handleActions({
[GET_SHORT_URL_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }),
}, initialState);
export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, dates) => async (dispatch, getState) => {
export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, query) => async (dispatch, getState) => {
dispatch({ type: GET_SHORT_URL_VISITS_START });
const { getShortUrlVisits } = await buildShlinkApiClient(getState);
@@ -57,7 +57,7 @@ export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, dates) =>
const isLastPage = ({ currentPage, pagesCount }) => currentPage >= pagesCount;
const loadVisits = async (page = 1) => {
const { pagination, data } = await getShortUrlVisits(shortCode, { ...dates, page, itemsPerPage });
const { pagination, data } = await getShortUrlVisits(shortCode, { ...query, page, itemsPerPage });
// If pagination was not returned, then this is an older shlink version. Just return data
if (!pagination || isLastPage(pagination)) {
@@ -96,7 +96,7 @@ export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, dates) =>
const loadVisitsInParallel = (pages) =>
Promise.all(pages.map(
(page) =>
getShortUrlVisits(shortCode, { ...dates, page, itemsPerPage })
getShortUrlVisits(shortCode, { ...query, page, itemsPerPage })
.then(prop('data'))
)).then(flatten);

View File

@@ -0,0 +1,48 @@
import React from 'react';
import { mount } from 'enzyme';
import each from 'jest-each';
import ForServerVersion from '../../../src/servers/helpers/ForServerVersion';
describe('<ForServerVersion />', () => {
let wrapped;
const renderComponent = (minVersion, maxVersion, selectedServer) => {
wrapped = mount(
<ForServerVersion minVersion={minVersion} maxVersion={maxVersion} selectedServer={selectedServer}>
<span>Hello</span>
</ForServerVersion>
);
return wrapped;
};
afterEach(() => wrapped && wrapped.unmount());
it('does not render children when current server is empty', () => {
const wrapped = renderComponent('1');
expect(wrapped.html()).toBeNull();
});
each([
[ '2.0.0', undefined, '1.8.3' ],
[ undefined, '1.8.0', '1.8.3' ],
[ '1.7.0', '1.8.0', '1.8.3' ],
]).it('does not render children when current version does not match requirements', (min, max, version) => {
const wrapped = renderComponent(min, max, { version });
expect(wrapped.html()).toBeNull();
});
each([
[ '2.0.0', undefined, '2.8.3' ],
[ '2.0.0', undefined, '2.0.0' ],
[ undefined, '1.8.0', '1.8.0' ],
[ undefined, '1.8.0', '1.7.1' ],
[ '1.7.0', '1.8.0', '1.7.3' ],
]).it('renders children when current version matches requirements', (min, max, version) => {
const wrapped = renderComponent(min, max, { version });
expect(wrapped.html()).toContain('<span>Hello</span>');
});
});

View File

@@ -14,7 +14,7 @@ describe('<CreateShortUrl />', () => {
const createShortUrl = jest.fn();
beforeEach(() => {
const CreateShortUrl = createShortUrlsCreator(TagsSelector, () => '');
const CreateShortUrl = createShortUrlsCreator(TagsSelector, () => '', () => '');
wrapper = shallow(
<CreateShortUrl shortUrlCreationResult={shortUrlCreationResult} createShortUrl={createShortUrl} />

View File

@@ -1,13 +1,15 @@
import React from 'react';
import { shallow } from 'enzyme';
import each from 'jest-each';
import searchBarCreator from '../../src/short-urls/SearchBar';
import SearchField from '../../src/utils/SearchField';
import Tag from '../../src/tags/helpers/Tag';
import DateRangeRow from '../../src/utils/DateRangeRow';
describe('<SearchBar />', () => {
let wrapper;
const listShortUrlsMock = jest.fn();
const SearchBar = searchBarCreator({});
const SearchBar = searchBarCreator({}, () => '');
afterEach(() => {
listShortUrlsMock.mockReset();
@@ -20,6 +22,12 @@ describe('<SearchBar />', () => {
expect(wrapper.find(SearchField)).toHaveLength(1);
});
it('renders a DateRangeRow', () => {
wrapper = shallow(<SearchBar shortUrlsListParams={{}} />);
expect(wrapper.find(DateRangeRow)).toHaveLength(1);
});
it('renders no tags when the list of tags is empty', () => {
wrapper = shallow(<SearchBar shortUrlsListParams={{}} />);
@@ -53,4 +61,15 @@ describe('<SearchBar />', () => {
tag.simulate('close');
expect(listShortUrlsMock).toHaveBeenCalledTimes(1);
});
each([ 'startDateChange', 'endDateChange' ]).it('updates short URLs list when date range changes', (event) => {
wrapper = shallow(
<SearchBar shortUrlsListParams={{}} listShortUrls={listShortUrlsMock} />
);
const dateRange = wrapper.find(DateRangeRow);
expect(listShortUrlsMock).not.toHaveBeenCalled();
dateRange.simulate(event);
expect(listShortUrlsMock).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { shallow } from 'enzyme';
import { identity } from 'ramda';
import each from 'jest-each';
import DeleteShortUrlModal from '../../../src/short-urls/helpers/DeleteShortUrlModal';
describe('<DeleteShortUrlModal />', () => {
@@ -20,7 +21,6 @@ describe('<DeleteShortUrlModal />', () => {
toggle={identity}
deleteShortUrl={deleteShortUrl}
resetDeleteShortUrl={identity}
shortUrlDeleted={identity}
/>
);
@@ -32,17 +32,34 @@ describe('<DeleteShortUrlModal />', () => {
deleteShortUrl.mockClear();
});
it('shows threshold error message when threshold error occurs', () => {
each([
[
{ error: 'INVALID_SHORTCODE_DELETION' },
'This short URL has received too many visits, and therefore, it cannot be deleted.',
],
[
{ type: 'INVALID_SHORTCODE_DELETION' },
'This short URL has received too many visits, and therefore, it cannot be deleted.',
],
[
{ error: 'INVALID_SHORTCODE_DELETION', threshold: 35 },
'This short URL has received more than 35 visits, and therefore, it cannot be deleted.',
],
[
{ type: 'INVALID_SHORTCODE_DELETION', threshold: 8 },
'This short URL has received more than 8 visits, and therefore, it cannot be deleted.',
],
]).it('shows threshold error message when threshold error occurs', (errorData, expectedMessage) => {
const wrapper = createWrapper({
loading: false,
error: true,
shortCode: 'abc123',
errorData: { error: 'INVALID_SHORTCODE_DELETION' },
errorData,
});
const warning = wrapper.find('.bg-warning');
expect(warning).toHaveLength(1);
expect(warning.html()).toContain('This short URL has received too many visits and therefore, it cannot be deleted');
expect(warning.html()).toContain(expectedMessage);
});
it('shows generic error when non-threshold error occurs', () => {

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

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { shallow } from 'enzyme';
import { Modal } from 'reactstrap';
import each from 'jest-each';
import createEditTagsModal from '../../../src/short-urls/helpers/EditTagsModal';
describe('<EditTagsModal />', () => {
@@ -8,25 +9,23 @@ describe('<EditTagsModal />', () => {
const shortCode = 'abc123';
const TagsSelector = () => '';
const editShortUrlTags = jest.fn(() => Promise.resolve());
const shortUrlTagsEdited = jest.fn();
const resetShortUrlsTags = jest.fn();
const toggle = jest.fn();
const createWrapper = (shortUrlTags) => {
const createWrapper = (shortUrlTags, domain) => {
const EditTagsModal = createEditTagsModal(TagsSelector);
wrapper = shallow(
<EditTagsModal
isOpen={true}
url={''}
shortUrl={{
tags: [],
shortCode,
domain,
originalUrl: 'https://long-domain.com/foo/bar',
}}
shortUrlTags={shortUrlTags}
toggle={toggle}
editShortUrlTags={editShortUrlTags}
shortUrlTagsEdited={shortUrlTagsEdited}
resetShortUrlsTags={resetShortUrlsTags}
/>
);
@@ -36,10 +35,7 @@ describe('<EditTagsModal />', () => {
afterEach(() => {
wrapper && wrapper.unmount();
editShortUrlTags.mockClear();
shortUrlTagsEdited.mockReset();
resetShortUrlsTags.mockReset();
toggle.mockReset();
jest.clearAllMocks();
});
it('resets tags when component is mounted', () => {
@@ -80,19 +76,19 @@ describe('<EditTagsModal />', () => {
expect(saveBtn.text()).toEqual('Saving tags...');
});
it('saves tags when save button is clicked', (done) => {
each([[ undefined ], [ null ], [ 'example.com' ]]).it('saves tags when save button is clicked', (domain, done) => {
const wrapper = createWrapper({
shortCode,
tags: [],
saving: true,
error: false,
});
}, domain);
const saveBtn = wrapper.find('.btn-primary');
saveBtn.simulate('click');
expect(editShortUrlTags).toHaveBeenCalledTimes(1);
expect(editShortUrlTags).toHaveBeenCalledWith(shortCode, []);
expect(editShortUrlTags).toHaveBeenCalledWith(shortCode, domain, []);
// Wrap this expect in a setImmediate since it is called as a result of an inner promise
setImmediate(() => {
@@ -111,28 +107,7 @@ describe('<EditTagsModal />', () => {
const modal = wrapper.find(Modal);
modal.simulate('closed');
expect(shortUrlTagsEdited).not.toHaveBeenCalled();
});
it('notifies tags have been edited when window is closed after saving', (done) => {
const wrapper = createWrapper({
shortCode,
tags: [],
saving: true,
error: false,
});
const saveBtn = wrapper.find('.btn-primary');
const modal = wrapper.find(Modal);
saveBtn.simulate('click');
// Wrap this expect in a setImmediate since it is called as a result of an inner promise
setImmediate(() => {
modal.simulate('closed');
expect(shortUrlTagsEdited).toHaveBeenCalledTimes(1);
expect(shortUrlTagsEdited).toHaveBeenCalledWith(shortCode, []);
done();
});
expect(editShortUrlTags).not.toHaveBeenCalled();
});
it('toggles modal when cancel button is clicked', () => {

View File

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

View File

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

View File

@@ -0,0 +1,41 @@
import React from 'react';
import { shallow } from 'enzyme';
import { UncontrolledTooltip } from 'reactstrap';
import each from 'jest-each';
import ShortUrlVisitsCount from '../../../src/short-urls/helpers/ShortUrlVisitsCount';
describe('<ShortUrlVisitsCount />', () => {
let wrapper;
const createWrapper = (visitsCount, shortUrl) => {
wrapper = shallow(<ShortUrlVisitsCount visitsCount={visitsCount} shortUrl={shortUrl} />);
return wrapper;
};
afterEach(() => wrapper && wrapper.unmount());
each([ undefined, {}]).it('just returns visits when no maxVisits is provided', (meta) => {
const visitsCount = 45;
const wrapper = createWrapper(visitsCount, { meta });
const maxVisitsHelper = wrapper.find('.short-urls-visits-count__max-visits-control');
const maxVisitsTooltip = wrapper.find(UncontrolledTooltip);
expect(wrapper.html()).toEqual(`<span><strong>${visitsCount}</strong></span>`);
expect(maxVisitsHelper).toHaveLength(0);
expect(maxVisitsTooltip).toHaveLength(0);
});
it('displays the maximum amount of visits when present', () => {
const visitsCount = 45;
const maxVisits = 500;
const meta = { maxVisits };
const wrapper = createWrapper(visitsCount, { meta });
const maxVisitsHelper = wrapper.find('.short-urls-visits-count__max-visits-control');
const maxVisitsTooltip = wrapper.find(UncontrolledTooltip);
expect(wrapper.html()).toContain(`/ ${maxVisits}`);
expect(maxVisitsHelper).toHaveLength(1);
expect(maxVisitsTooltip).toHaveLength(1);
});
});

View File

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

View File

@@ -9,24 +9,31 @@ describe('<ShortUrlsRowMenu />', () => {
let wrapper;
const DeleteShortUrlModal = () => '';
const EditTagsModal = () => '';
const EditMetaModal = () => '';
const onCopyToClipboard = jest.fn();
const selectedServer = { id: 'abc123' };
const shortUrl = {
shortCode: 'abc123',
shortUrl: 'https://doma.in/abc123',
};
beforeEach(() => {
const ShortUrlsRowMenu = createShortUrlsRowMenu(DeleteShortUrlModal, EditTagsModal);
const createWrapper = () => {
const ShortUrlsRowMenu = createShortUrlsRowMenu(DeleteShortUrlModal, EditTagsModal, EditMetaModal, () => '');
wrapper = shallow(
<ShortUrlsRowMenu selectedServer={selectedServer} shortUrl={shortUrl} onCopyToClipboard={onCopyToClipboard} />
<ShortUrlsRowMenu
selectedServer={selectedServer}
shortUrl={shortUrl}
onCopyToClipboard={onCopyToClipboard}
/>
);
});
afterEach(() => wrapper.unmount());
return wrapper;
};
afterEach(() => wrapper && wrapper.unmount());
it('renders modal windows', () => {
const wrapper = createWrapper();
const deleteShortUrlModal = wrapper.find(DeleteShortUrlModal);
const editTagsModal = wrapper.find(EditTagsModal);
const previewModal = wrapper.find(PreviewModal);
@@ -39,16 +46,16 @@ describe('<ShortUrlsRowMenu />', () => {
});
it('renders correct amount of menu items', () => {
const wrapper = createWrapper();
const items = wrapper.find(DropdownItem);
const expectedNonDividerItems = 6;
const expectedDividerItems = 2;
expect(items).toHaveLength(expectedNonDividerItems + expectedDividerItems);
expect(items.find('[divider]')).toHaveLength(expectedDividerItems);
expect(items).toHaveLength(9);
expect(items.find('[divider]')).toHaveLength(2);
});
describe('toggles state when toggling modal windows', () => {
const assert = (modalComponent, stateProp, done) => {
const wrapper = createWrapper();
const modal = wrapper.find(modalComponent);
expect(wrapper.state(stateProp)).toEqual(false);
@@ -66,6 +73,7 @@ describe('<ShortUrlsRowMenu />', () => {
});
it('toggles dropdown state when toggling dropdown', (done) => {
const wrapper = createWrapper();
const dropdown = wrapper.find(ButtonDropdown);
expect(wrapper.state('isOpen')).toEqual(false);

View File

@@ -0,0 +1,42 @@
import React from 'react';
import { shallow } from 'enzyme';
import each from 'jest-each';
import { Link } from 'react-router-dom';
import VisitStatsLink from '../../../src/short-urls/helpers/VisitStatsLink';
describe('<VisitStatsLink />', () => {
let wrapper;
afterEach(() => wrapper && wrapper.unmount());
each([
[ undefined, undefined ],
[ null, null ],
[{}, null ],
[{}, undefined ],
[ null, {}],
[ undefined, {}],
]).it('only renders a plan span when either server or short URL are not set', (selectedServer, shortUrl) => {
wrapper = shallow(<VisitStatsLink selectedServer={selectedServer} shortUrl={shortUrl}>Something</VisitStatsLink>);
const link = wrapper.find(Link);
expect(link).toHaveLength(0);
expect(wrapper.html()).toEqual('<span>Something</span>');
});
each([
[{ id: '1' }, { shortCode: 'abc123' }, '/server/1/short-code/abc123/visits' ],
[
{ id: '3' },
{ shortCode: 'def456', domain: 'example.com' },
'/server/3/short-code/def456/visits?domain=example.com',
],
]).it('renders link with expected query when', (selectedServer, shortUrl, expectedLink) => {
wrapper = shallow(<VisitStatsLink selectedServer={selectedServer} shortUrl={shortUrl}>Something</VisitStatsLink>);
const link = wrapper.find(Link);
const to = link.prop('to');
expect(link).toHaveLength(1);
expect(to).toEqual(expectedLink);
});
});

View File

@@ -1,10 +1,10 @@
import each from 'jest-each';
import reducer, {
DELETE_SHORT_URL, DELETE_SHORT_URL_ERROR,
DELETE_SHORT_URL_ERROR,
DELETE_SHORT_URL_START,
RESET_DELETE_SHORT_URL,
SHORT_URL_DELETED,
resetDeleteShortUrl,
shortUrlDeleted,
deleteShortUrl,
} from '../../../src/short-urls/reducers/shortUrlDeletion';
@@ -26,8 +26,8 @@ describe('shortUrlDeletionReducer', () => {
errorData: {},
}));
it('returns shortCode on DELETE_SHORT_URL', () =>
expect(reducer(undefined, { type: DELETE_SHORT_URL, shortCode: 'foo' })).toEqual({
it('returns shortCode on SHORT_URL_DELETED', () =>
expect(reducer(undefined, { type: SHORT_URL_DELETED, shortCode: 'foo' })).toEqual({
shortCode: 'foo',
loading: false,
error: false,
@@ -51,11 +51,6 @@ describe('shortUrlDeletionReducer', () => {
expect(resetDeleteShortUrl()).toEqual({ type: RESET_DELETE_SHORT_URL }));
});
describe('shortUrlDeleted', () => {
it('returns expected action', () =>
expect(shortUrlDeleted('abc123')).toEqual({ type: SHORT_URL_DELETED, shortCode: 'abc123' }));
});
describe('deleteShortUrl', () => {
const dispatch = jest.fn();
const getState = jest.fn().mockReturnValue({ selectedServer: {} });
@@ -65,20 +60,22 @@ describe('shortUrlDeletionReducer', () => {
getState.mockClear();
});
it('dispatches proper actions if API client request succeeds', async () => {
each(
[[ undefined ], [ null ], [ 'example.com' ]]
).it('dispatches proper actions if API client request succeeds', async (domain) => {
const apiClientMock = {
deleteShortUrl: jest.fn(() => ''),
};
const shortCode = 'abc123';
await deleteShortUrl(() => apiClientMock)(shortCode)(dispatch, getState);
await deleteShortUrl(() => apiClientMock)(shortCode, domain)(dispatch, getState);
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenNthCalledWith(1, { type: DELETE_SHORT_URL_START });
expect(dispatch).toHaveBeenNthCalledWith(2, { type: DELETE_SHORT_URL, shortCode });
expect(dispatch).toHaveBeenNthCalledWith(2, { type: SHORT_URL_DELETED, shortCode, domain });
expect(apiClientMock.deleteShortUrl).toHaveBeenCalledTimes(1);
expect(apiClientMock.deleteShortUrl).toHaveBeenCalledWith(shortCode);
expect(apiClientMock.deleteShortUrl).toHaveBeenCalledWith(shortCode, domain);
});
it('dispatches proper actions if API client request fails', async () => {
@@ -100,7 +97,7 @@ describe('shortUrlDeletionReducer', () => {
expect(dispatch).toHaveBeenNthCalledWith(2, { type: DELETE_SHORT_URL_ERROR, errorData: data });
expect(apiClientMock.deleteShortUrl).toHaveBeenCalledTimes(1);
expect(apiClientMock.deleteShortUrl).toHaveBeenCalledWith(shortCode);
expect(apiClientMock.deleteShortUrl).toHaveBeenCalledWith(shortCode, undefined);
});
});
});

View File

@@ -0,0 +1,94 @@
import moment from 'moment';
import each from 'jest-each';
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);
each([[ undefined ], [ null ], [ 'example.com' ]]).it('dispatches metadata on success', async (domain) => {
await editShortUrlMeta(buildShlinkApiClient)(shortCode, domain, meta)(dispatch);
expect(buildShlinkApiClient).toHaveBeenCalledTimes(1);
expect(updateShortUrlMeta).toHaveBeenCalledTimes(1);
expect(updateShortUrlMeta).toHaveBeenCalledWith(shortCode, domain, 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, domain });
});
it('dispatches error on failure', async () => {
const error = new Error();
updateShortUrlMeta.mockRejectedValue(error);
try {
await editShortUrlMeta(buildShlinkApiClient)(shortCode, undefined, meta)(dispatch);
} catch (e) {
expect(e).toBe(error);
}
expect(buildShlinkApiClient).toHaveBeenCalledTimes(1);
expect(updateShortUrlMeta).toHaveBeenCalledTimes(1);
expect(updateShortUrlMeta).toHaveBeenCalledWith(shortCode, undefined, 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 }));
});
});

View File

@@ -1,11 +1,11 @@
import each from 'jest-each';
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,
shortUrlTagsEdited,
editShortUrlTags,
} from '../../../src/short-urls/reducers/shortUrlTags';
describe('shortUrlTagsReducer', () => {
@@ -27,8 +27,8 @@ describe('shortUrlTagsReducer', () => {
});
});
it('returns provided tags and shortCode on EDIT_SHORT_URL_TAGS', () => {
expect(reducer({}, { type: EDIT_SHORT_URL_TAGS, tags, shortCode })).toEqual({
it('returns provided tags and shortCode on SHORT_URL_TAGS_EDITED', () => {
expect(reducer({}, { type: SHORT_URL_TAGS_EDITED, tags, shortCode })).toEqual({
tags,
shortCode,
saving: false,
@@ -50,14 +50,6 @@ describe('shortUrlTagsReducer', () => {
it('creates expected action', () => expect(resetShortUrlsTags()).toEqual({ type: RESET_EDIT_SHORT_URL_TAGS }));
});
describe('shortUrlTagsEdited', () => {
it('creates expected action', () => expect(shortUrlTagsEdited(shortCode, tags)).toEqual({
tags,
shortCode,
type: SHORT_URL_TAGS_EDITED,
}));
});
describe('editShortUrlTags', () => {
const updateShortUrlTags = jest.fn();
const buildShlinkApiClient = jest.fn().mockResolvedValue({ updateShortUrlTags });
@@ -69,19 +61,22 @@ describe('shortUrlTagsReducer', () => {
dispatch.mockReset();
});
it('dispatches normalized tags on success', async () => {
each([[ undefined ], [ null ], [ 'example.com' ]]).it('dispatches normalized tags on success', async (domain) => {
const normalizedTags = [ 'bar', 'foo' ];
updateShortUrlTags.mockResolvedValue(normalizedTags);
await editShortUrlTags(buildShlinkApiClient)(shortCode, tags)(dispatch);
await editShortUrlTags(buildShlinkApiClient)(shortCode, domain, tags)(dispatch);
expect(buildShlinkApiClient).toHaveBeenCalledTimes(1);
expect(updateShortUrlTags).toHaveBeenCalledTimes(1);
expect(updateShortUrlTags).toHaveBeenCalledWith(shortCode, tags);
expect(updateShortUrlTags).toHaveBeenCalledWith(shortCode, domain, tags);
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_SHORT_URL_TAGS_START });
expect(dispatch).toHaveBeenNthCalledWith(2, { type: EDIT_SHORT_URL_TAGS, tags: normalizedTags, shortCode });
expect(dispatch).toHaveBeenNthCalledWith(
2,
{ type: SHORT_URL_TAGS_EDITED, tags: normalizedTags, shortCode, domain }
);
});
it('dispatches error on failure', async () => {
@@ -90,14 +85,14 @@ describe('shortUrlTagsReducer', () => {
updateShortUrlTags.mockRejectedValue(error);
try {
await editShortUrlTags(buildShlinkApiClient)(shortCode, tags)(dispatch);
await editShortUrlTags(buildShlinkApiClient)(shortCode, undefined, tags)(dispatch);
} catch (e) {
expect(e).toBe(error);
}
expect(buildShlinkApiClient).toHaveBeenCalledTimes(1);
expect(updateShortUrlTags).toHaveBeenCalledTimes(1);
expect(updateShortUrlTags).toHaveBeenCalledWith(shortCode, tags);
expect(updateShortUrlTags).toHaveBeenCalledWith(shortCode, undefined, tags);
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_SHORT_URL_TAGS_START });
expect(dispatch).toHaveBeenNthCalledWith(2, { type: EDIT_SHORT_URL_TAGS_ERROR });

View File

@@ -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', () => {
@@ -37,27 +38,58 @@ describe('shortUrlsListReducer', () => {
shortUrls: {
data: [
{ shortCode, tags: [] },
{ shortCode, tags: [], domain: 'example.com' },
{ shortCode: 'foo', tags: [] },
],
},
};
expect(reducer(state, { type: SHORT_URL_TAGS_EDITED, shortCode, tags })).toEqual({
expect(reducer(state, { type: SHORT_URL_TAGS_EDITED, shortCode, tags, domain: null })).toEqual({
shortUrls: {
data: [
{ shortCode, tags },
{ shortCode, tags: [], domain: 'example.com' },
{ shortCode: 'foo', tags: [] },
],
},
});
});
it('Updates meta on matching URL on SHORT_URL_META_EDITED', () => {
const shortCode = 'abc123';
const domain = 'example.com';
const meta = {
maxVisits: 5,
validSince: '2020-05-05',
};
const state = {
shortUrls: {
data: [
{ shortCode, meta: { maxVisits: 10 }, domain },
{ shortCode, meta: { maxVisits: 50 } },
{ shortCode: 'foo', meta: null },
],
},
};
expect(reducer(state, { type: SHORT_URL_META_EDITED, shortCode, meta, domain })).toEqual({
shortUrls: {
data: [
{ shortCode, meta, domain: 'example.com' },
{ shortCode, meta: { maxVisits: 50 } },
{ shortCode: 'foo', meta: null },
],
},
});
});
it('Removes matching URL on SHORT_URL_DELETED', () => {
const shortCode = 'abc123';
const state = {
shortUrls: {
data: [
{ shortCode },
{ shortCode, domain: 'example.com' },
{ shortCode: 'foo' },
],
},
@@ -65,7 +97,7 @@ describe('shortUrlsListReducer', () => {
expect(reducer(state, { type: SHORT_URL_DELETED, shortCode })).toEqual({
shortUrls: {
data: [{ shortCode: 'foo' }],
data: [{ shortCode, domain: 'example.com' }, { shortCode: 'foo' }],
},
});
});

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

View File

@@ -1,43 +0,0 @@
import React from 'react';
import { mount } from 'enzyme';
import ForVersion from '../../src/utils/ForVersion';
describe('<ForVersion />', () => {
let wrapped;
const renderComponent = (minVersion, currentServerVersion) => {
wrapped = mount(
<ForVersion minVersion={minVersion} currentServerVersion={currentServerVersion}>
<span>Hello</span>
</ForVersion>
);
return wrapped;
};
afterEach(() => wrapped && wrapped.unmount());
it('does not render children when current version is empty', () => {
const wrapped = renderComponent('1', '');
expect(wrapped.html()).toBeNull();
});
it('does not render children when current version is lower than min version', () => {
const wrapped = renderComponent('2.0.0', '1.8.3');
expect(wrapped.html()).toBeNull();
});
it('renders children when current version is equal min version', () => {
const wrapped = renderComponent('2.0.0', '2.0.0');
expect(wrapped.html()).toContain('<span>Hello</span>');
});
it('renders children when current version is higher than min version', () => {
const wrapped = renderComponent('2.0.0', '2.1.0');
expect(wrapped.html()).toContain('<span>Hello</span>');
});
});

Some files were not shown because too many files have changed in this diff Show More