Merge pull request #453 from shlinkio/develop

Release 3.2.0
This commit is contained in:
Alejandro Celaya 2021-07-12 16:44:20 +02:00 committed by GitHub
commit fa64c950ca
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
79 changed files with 1172 additions and 656 deletions

View file

@ -3,7 +3,7 @@ name: Build docker image
on: on:
push: push:
branches: branches:
- main - develop
tags: tags:
- 'v*' - 'v*'

View file

@ -21,7 +21,6 @@ jobs:
uses: docker://antonyurchenko/git-release:latest uses: docker://antonyurchenko/git-release:latest
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ALLOW_TAG_PREFIX: "true"
ALLOW_EMPTY_CHANGELOG: "true" ALLOW_EMPTY_CHANGELOG: "true"
with: with:
args: | args: |

View file

@ -4,6 +4,36 @@ 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). 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).
## [3.2.0] - 2021-07-12
### Added
* [#433](https://github.com/shlinkio/shlink-web-client/pull/433) Added support to provide a default server to connect to via env vars:
* `SHLINK_SERVER_URL`: The URL of the Shlink server to configure by default.
* `SHLINK_SERVER_API_KEY`: The API key of the Shlink server.
* `SHLINK_SERVER_NAME`: A name you want to give to this server. Defaults to *Shlink* if not provided.
* [#432](https://github.com/shlinkio/shlink-web-client/pull/432) Added support to provide the `servers.json` file inside a `conf.d` folder.
* [#440](https://github.com/shlinkio/shlink-web-client/pull/440) Added hint of what visits come potentially from a bot, in the visits table, when consuming Shlink >=2.7.
* [#431](https://github.com/shlinkio/shlink-web-client/pull/431) Added support to filter out visits from potential bots in visits sections, when consuming Shlink >=2.7.
* [#430](https://github.com/shlinkio/shlink-web-client/pull/430) Added support to set new and existing short URLs as crawlable, when consuming Shlink >=2.7.
* [#450](https://github.com/shlinkio/shlink-web-client/pull/450) Improved landing page design.
* [#449](https://github.com/shlinkio/shlink-web-client/pull/449) Improved PWA update banner, allowing to restart the app directly from it without having to close the tab.
### Changed
* [#442](https://github.com/shlinkio/shlink-web-client/pull/442) Visits filtering now goes through the corresponding reducer.
* [#337](https://github.com/shlinkio/shlink-web-client/pull/337) Replaced moment.js with date-fns.
* [#360](https://github.com/shlinkio/shlink-web-client/pull/360) Changed component used to generate a tags selector, switching from `react-tagsinput`, which is no longer maintained, to `react-tag-autocomplete`.
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#438](https://github.com/shlinkio/shlink-web-client/pull/438) Fixed horizontal scrolling in short URLs list on mobile devices when the long URL didn't have words to break.
## [3.1.2] - 2021-06-06 ## [3.1.2] - 2021-06-06
### Added ### Added
* *Nothing* * *Nothing*

View file

@ -1,12 +1,13 @@
FROM node:14.15-alpine as node FROM node:14.17-alpine as node
COPY . /shlink-web-client COPY . /shlink-web-client
ARG VERSION="latest" ARG VERSION="latest"
ENV VERSION ${VERSION} ENV VERSION ${VERSION}
RUN cd /shlink-web-client && \ RUN cd /shlink-web-client && \
npm install && npm run build -- ${VERSION} --no-dist npm install && npm run build -- ${VERSION} --no-dist
FROM nginx:1.19.6-alpine FROM nginx:1.21-alpine
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>" LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
RUN rm -r /usr/share/nginx/html && rm /etc/nginx/conf.d/default.conf 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 COPY config/docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY scripts/docker/servers_from_env.sh /docker-entrypoint.d/30-shlink-servers-json.sh
COPY --from=node /shlink-web-client/build /usr/share/nginx/html COPY --from=node /shlink-web-client/build /usr/share/nginx/html

View file

@ -68,6 +68,25 @@ Those servers can be exported and imported in other browsers, but if for some re
If you are using the shlink-web-client docker image, you can mount the `servers.json` file in a volume inside `/usr/share/nginx/html`, which is the app's document root inside the container. If you are using the shlink-web-client docker image, you can mount the `servers.json` file in a volume inside `/usr/share/nginx/html`, which is the app's document root inside the container.
docker run --name shlink-web-client -p 8000:80 -v ${PWD}/servers.json:/usr/share/nginx/html/servers.json shlinkio/shlink-web-client docker run --name shlink-web-client -p 8000:80 -v ${PWD}/servers.json:/usr/share/nginx/html/servers.json shlinkio/shlink-web-client
Alternatively, you can mount a `conf.d` directory, which in turn contains the `servers.json` file, in a volume inside `/usr/share/nginx/html`. *(since shlink-web-client 3.2.0)*.
docker run --name shlink-web-client -p 8000:80 -v ${PWD}/my-config/:/usr/share/nginx/html/conf.d/ shlinkio/shlink-web-client
If you want to pre-configure a single server, you can provide its config via env vars. When the container starts up, it will build the `servers.json` file dynamically based on them. *(since shlink-web-client 3.2.0)*.
* `SHLINK_SERVER_URL`: The fully qualified URL for the Shlink server.
* `SHLINK_SERVER_API_KEY`: The API key.
* `SHLINK_SERVER_NAME`: The name to be displayed. Defaults to **Shlink** if not provided.
```shell
docker run \
--name shlink-web-client \
-p 8000:80 \
-e SHLINK_SERVER_URL=https://doma.in \
-e SHLINK_SERVER_API_KEY=6aeb82c6-e275-4538-a747-31f9abfba63c \
shlinkio/shlink-web-client
```
> **Be extremely careful when using this feature.** > **Be extremely careful when using this feature.**
> >

View file

@ -20,6 +20,11 @@ server {
add_header Cache-Control "public"; add_header Cache-Control "public";
} }
# servers.json may be on the root, or in conf.d directory
location = /servers.json {
try_files /servers.json /conf.d/servers.json;
}
# When requesting static paths with extension, try them, and return a 404 if not found # When requesting static paths with extension, try them, and return a 404 if not found
location ~* .+\.(css|js|html|png|jpe?g|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; try_files $uri $uri/ =404;

View file

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

169
package-lock.json generated
View file

@ -6517,15 +6517,6 @@
"integrity": "sha512-fZQQafSREFyuZcdWFAExYjBiCL7AUCdgsk80iO0q4yihYYdcIiH28CcuPTGFgLOCC8RlW49GSQxdHwZP+I7CNg==", "integrity": "sha512-fZQQafSREFyuZcdWFAExYjBiCL7AUCdgsk80iO0q4yihYYdcIiH28CcuPTGFgLOCC8RlW49GSQxdHwZP+I7CNg==",
"dev": true "dev": true
}, },
"@types/moment": {
"version": "2.13.0",
"resolved": "https://registry.npmjs.org/@types/moment/-/moment-2.13.0.tgz",
"integrity": "sha1-YE69GJvDvDShVIaJQE5hoqSqyJY=",
"dev": true,
"requires": {
"moment": "*"
}
},
"@types/node": { "@types/node": {
"version": "12.7.11", "version": "12.7.11",
"resolved": "https://registry.npmjs.org/@types/node/-/node-12.7.11.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-12.7.11.tgz",
@ -6587,15 +6578,6 @@
"csstype": "^3.0.2" "csstype": "^3.0.2"
} }
}, },
"@types/react-autosuggest": {
"version": "10.1.2",
"resolved": "https://registry.npmjs.org/@types/react-autosuggest/-/react-autosuggest-10.1.2.tgz",
"integrity": "sha512-K23lmXhC3Bbd8y/jm5+wYrw/NAeN4U/wlHTgAEBIwLOyQKFCFYA3ONKte9P21L+RGIXRP8UlzHOSRtmIZw5Nqw==",
"dev": true,
"requires": {
"@types/react": "*"
}
},
"@types/react-color": { "@types/react-color": {
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/react-color/-/react-color-3.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/react-color/-/react-color-3.0.4.tgz",
@ -6678,10 +6660,10 @@
"@types/react-router": "*" "@types/react-router": "*"
} }
}, },
"@types/react-tagsinput": { "@types/react-tag-autocomplete": {
"version": "3.19.7", "version": "6.1.0",
"resolved": "https://registry.npmjs.org/@types/react-tagsinput/-/react-tagsinput-3.19.7.tgz", "resolved": "https://registry.npmjs.org/@types/react-tag-autocomplete/-/react-tag-autocomplete-6.1.0.tgz",
"integrity": "sha512-yj/3iFBLoan/0vzXMxC9zGhO1uJ89qjQldekf0o3fX4mYdaAPW/VbP921fsyYt6PdHmJ9UMo+kERSMzUAml1xQ==", "integrity": "sha512-6qJQS81ZMaqV/ZSADwiU91TXnR6ZJINPqoV3z2SMMSlUcO6CV8Vc5QnqcqcVTj2CHnU3UQ2Q5QfSj3NyXomcDg==",
"dev": true, "dev": true,
"requires": { "requires": {
"@types/react": "*" "@types/react": "*"
@ -7627,9 +7609,9 @@
"dev": true "dev": true
}, },
"arch": { "arch": {
"version": "2.1.2", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/arch/-/arch-2.1.2.tgz", "resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz",
"integrity": "sha512-NTBIIbAfkJeIletyABbVtdPgeKfDafR+1mZV/AyyfC1UkVkp9iUjV+wwmqtUgphHYajbI86jejBJp5e+jkGTiQ==", "integrity": "sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==",
"dev": true "dev": true
}, },
"arg": { "arg": {
@ -10810,40 +10792,48 @@
"dev": true "dev": true
}, },
"clipboardy": { "clipboardy": {
"version": "1.2.3", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-1.2.3.tgz", "resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-2.3.0.tgz",
"integrity": "sha512-2WNImOvCRe6r63Gk9pShfkwXsVtKCroMAevIbiae021mS850UkWPbevxsBz3tnvjZIEGvlwaqCPsw+4ulzNgJA==", "integrity": "sha512-mKhiIL2DrQIsuXMgBgnfEHOZOryC7kY7YO//TN6c63wlEm3NG5tz+YgY5rVi29KCmq/QQjKYvM7a19+MDOTHOQ==",
"dev": true, "dev": true,
"requires": { "requires": {
"arch": "^2.1.0", "arch": "^2.1.1",
"execa": "^0.8.0" "execa": "^1.0.0",
"is-wsl": "^2.1.1"
}, },
"dependencies": { "dependencies": {
"cross-spawn": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz",
"integrity": "sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk=",
"dev": true,
"requires": {
"lru-cache": "^4.0.1",
"shebang-command": "^1.2.0",
"which": "^1.2.9"
}
},
"execa": { "execa": {
"version": "0.8.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/execa/-/execa-0.8.0.tgz", "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz",
"integrity": "sha1-2NdrvBtVIX7RkP1t1J08d07PyNo=", "integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==",
"dev": true, "dev": true,
"requires": { "requires": {
"cross-spawn": "^5.0.1", "cross-spawn": "^6.0.0",
"get-stream": "^3.0.0", "get-stream": "^4.0.0",
"is-stream": "^1.1.0", "is-stream": "^1.1.0",
"npm-run-path": "^2.0.0", "npm-run-path": "^2.0.0",
"p-finally": "^1.0.0", "p-finally": "^1.0.0",
"signal-exit": "^3.0.0", "signal-exit": "^3.0.0",
"strip-eof": "^1.0.0" "strip-eof": "^1.0.0"
} }
},
"get-stream": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz",
"integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==",
"dev": true,
"requires": {
"pump": "^3.0.0"
}
},
"is-wsl": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
"integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
"dev": true,
"requires": {
"is-docker": "^2.0.0"
}
} }
} }
}, },
@ -12082,9 +12072,9 @@
} }
}, },
"date-fns": { "date-fns": {
"version": "2.16.1", "version": "2.22.1",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.16.1.tgz", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.22.1.tgz",
"integrity": "sha512-sAJVKx/FqrLYHAQeN7VpJrPhagZc9R4ImZIWYRFZaaohR3KzmuK88touwsSwSVT8Qcbd4zoDsnGfX4GFB4imyQ==" "integrity": "sha512-yUFPQjrxEmIsMqlHhAhmxkuH769baF21Kk+nZwZGyrMoyLA+LugaQtC0+Tqf9CBUUULWwUJt6Q5ySI3LJDDCGg=="
}, },
"date-format": { "date-format": {
"version": "3.0.0", "version": "3.0.0",
@ -12928,7 +12918,8 @@
"es6-promise": { "es6-promise": {
"version": "4.2.8", "version": "4.2.8",
"resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz",
"integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==" "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==",
"dev": true
}, },
"escalade": { "escalade": {
"version": "3.1.1", "version": "3.1.1",
@ -14269,12 +14260,6 @@
"integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=", "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=",
"dev": true "dev": true
}, },
"fast-deep-equal": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz",
"integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=",
"dev": true
},
"fast-glob": { "fast-glob": {
"version": "3.2.4", "version": "3.2.4",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.4.tgz", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.4.tgz",
@ -24584,18 +24569,6 @@
} }
} }
}, },
"react-autosuggest": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/react-autosuggest/-/react-autosuggest-10.1.0.tgz",
"integrity": "sha512-/azBHmc6z/31s/lBf6irxPf/7eejQdR0IqnZUzjdSibtlS8+Rw/R79pgDAo6Ft5QqCUTyEQ+f0FhL+1olDQ8OA==",
"requires": {
"es6-promise": "^4.2.8",
"prop-types": "^15.7.2",
"react-themeable": "^1.1.0",
"section-iterator": "^2.0.0",
"shallow-equal": "^1.2.1"
}
},
"react-chartjs-2": { "react-chartjs-2": {
"version": "2.11.1", "version": "2.11.1",
"resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-2.11.1.tgz", "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-2.11.1.tgz",
@ -24903,11 +24876,6 @@
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==" "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="
}, },
"react-moment": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/react-moment/-/react-moment-1.0.0.tgz",
"integrity": "sha512-J4iIiwUT4oZcL7cp2U7naQKbQtqvmzGXXBMg/DLj+Pi7n9EW0VhBRx/1aJ1Tp2poCqTCAPoadLEoUIkReGnNNg=="
},
"react-onclickoutside": { "react-onclickoutside": {
"version": "6.10.0", "version": "6.10.0",
"resolved": "https://registry.npmjs.org/react-onclickoutside/-/react-onclickoutside-6.10.0.tgz", "resolved": "https://registry.npmjs.org/react-onclickoutside/-/react-onclickoutside-6.10.0.tgz",
@ -25012,25 +24980,10 @@
"resolved": "https://registry.npmjs.org/react-swipeable/-/react-swipeable-6.0.1.tgz", "resolved": "https://registry.npmjs.org/react-swipeable/-/react-swipeable-6.0.1.tgz",
"integrity": "sha512-69nonicgjT4ofeHxZSpjuz37BoIiWMEbUYkX0mdTCY2mX1U53XDzDUIOVKRg6vVBNGL+pxYjbRzmylXWORh1xQ==" "integrity": "sha512-69nonicgjT4ofeHxZSpjuz37BoIiWMEbUYkX0mdTCY2mX1U53XDzDUIOVKRg6vVBNGL+pxYjbRzmylXWORh1xQ=="
}, },
"react-tagsinput": { "react-tag-autocomplete": {
"version": "3.19.0", "version": "6.1.0",
"resolved": "https://registry.yarnpkg.com/react-tagsinput/-/react-tagsinput-3.19.0.tgz", "resolved": "https://registry.npmjs.org/react-tag-autocomplete/-/react-tag-autocomplete-6.1.0.tgz",
"integrity": "sha1-bjtFWV8tKV1GV78ZRJGYj5SMqr8=" "integrity": "sha512-AMhVqxEEIrOfzH0A9XrpsTaLZCVYgjjxp3DSTuSvx91LBSFI6uYcKe38ltR/H/TQw4aytofVghQ1hR9sKpXRQA=="
},
"react-themeable": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/react-themeable/-/react-themeable-1.1.0.tgz",
"integrity": "sha1-fURm3ZsrX6dQWHJ4JenxUro3mg4=",
"requires": {
"object-assign": "^3.0.0"
},
"dependencies": {
"object-assign": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-3.0.0.tgz",
"integrity": "sha1-m+3VygiXlJvKR+f/QIBi1Un1h/I="
}
}
}, },
"react-transition-group": { "react-transition-group": {
"version": "2.9.0", "version": "2.9.0",
@ -26135,11 +26088,6 @@
} }
} }
}, },
"section-iterator": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/section-iterator/-/section-iterator-2.0.0.tgz",
"integrity": "sha1-v0RNev7rlK1Dw5rS+yYVFifMuio="
},
"select-hose": { "select-hose": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz",
@ -26215,34 +26163,22 @@
} }
}, },
"serve": { "serve": {
"version": "11.3.2", "version": "12.0.0",
"resolved": "https://registry.npmjs.org/serve/-/serve-11.3.2.tgz", "resolved": "https://registry.npmjs.org/serve/-/serve-12.0.0.tgz",
"integrity": "sha512-yKWQfI3xbj/f7X1lTBg91fXBP0FqjJ4TEi+ilES5yzH0iKJpN5LjNb1YzIfQg9Rqn4ECUS2SOf2+Kmepogoa5w==", "integrity": "sha512-BkTsETQYynAZ7rXX414kg4X6EvuZQS3UVs1NY0VQYdRHSTYWPYcH38nnDh48D0x6ONuislgjag8uKlU2gTBImA==",
"dev": true, "dev": true,
"requires": { "requires": {
"@zeit/schemas": "2.6.0", "@zeit/schemas": "2.6.0",
"ajv": "6.5.3", "ajv": "6.12.6",
"arg": "2.0.0", "arg": "2.0.0",
"boxen": "1.3.0", "boxen": "1.3.0",
"chalk": "2.4.1", "chalk": "2.4.1",
"clipboardy": "1.2.3", "clipboardy": "2.3.0",
"compression": "1.7.3", "compression": "1.7.3",
"serve-handler": "6.1.3", "serve-handler": "6.1.3",
"update-check": "1.5.2" "update-check": "1.5.2"
}, },
"dependencies": { "dependencies": {
"ajv": {
"version": "6.5.3",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.5.3.tgz",
"integrity": "sha512-LqZ9wY+fx3UMiiPd741yB2pj3hhil+hQc8taf4o2QGRFpWgZ2V5C8HA165DY9sS3fJwsk7uT7ZlFEyC3Ig3lLg==",
"dev": true,
"requires": {
"fast-deep-equal": "^2.0.1",
"fast-json-stable-stringify": "^2.0.0",
"json-schema-traverse": "^0.4.1",
"uri-js": "^4.2.2"
}
},
"chalk": { "chalk": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.1.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.1.tgz",
@ -26388,11 +26324,6 @@
"safe-buffer": "^5.0.1" "safe-buffer": "^5.0.1"
} }
}, },
"shallow-equal": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/shallow-equal/-/shallow-equal-1.2.1.tgz",
"integrity": "sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA=="
},
"shebang-command": { "shebang-command": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz",

View file

@ -33,14 +33,13 @@
"classnames": "^2.2.6", "classnames": "^2.2.6",
"compare-versions": "^3.6.0", "compare-versions": "^3.6.0",
"csvjson": "^5.1.0", "csvjson": "^5.1.0",
"date-fns": "^2.22.1",
"event-source-polyfill": "^1.0.22", "event-source-polyfill": "^1.0.22",
"leaflet": "^1.7.1", "leaflet": "^1.7.1",
"moment": "^2.29.1",
"promise": "^8.1.0", "promise": "^8.1.0",
"qs": "^6.9.6", "qs": "^6.9.6",
"ramda": "^0.27.1", "ramda": "^0.27.1",
"react": "^17.0.1", "react": "^17.0.1",
"react-autosuggest": "^10.1.0",
"react-chartjs-2": "^2.11.1", "react-chartjs-2": "^2.11.1",
"react-color": "^2.19.3", "react-color": "^2.19.3",
"react-copy-to-clipboard": "^5.0.2", "react-copy-to-clipboard": "^5.0.2",
@ -48,11 +47,10 @@
"react-dom": "^17.0.1", "react-dom": "^17.0.1",
"react-external-link": "^1.2.0", "react-external-link": "^1.2.0",
"react-leaflet": "^3.1.0", "react-leaflet": "^3.1.0",
"react-moment": "^1.0.0",
"react-redux": "^7.2.2", "react-redux": "^7.2.2",
"react-router-dom": "^5.2.0", "react-router-dom": "^5.2.0",
"react-swipeable": "^6.0.1", "react-swipeable": "^6.0.1",
"react-tagsinput": "^3.19.0", "react-tag-autocomplete": "^6.1.0",
"reactstrap": "^8.9.0", "reactstrap": "^8.9.0",
"redux": "^4.0.5", "redux": "^4.0.5",
"redux-localstorage-simple": "^2.4.0", "redux-localstorage-simple": "^2.4.0",
@ -78,11 +76,9 @@
"@types/enzyme": "^3.10.8", "@types/enzyme": "^3.10.8",
"@types/jest": "^26.0.20", "@types/jest": "^26.0.20",
"@types/leaflet": "^1.5.23", "@types/leaflet": "^1.5.23",
"@types/moment": "^2.13.0",
"@types/qs": "^6.9.5", "@types/qs": "^6.9.5",
"@types/ramda": "^0.27.38", "@types/ramda": "^0.27.38",
"@types/react": "^17.0.2", "@types/react": "^17.0.2",
"@types/react-autosuggest": "^10.1.2",
"@types/react-color": "^3.0.4", "@types/react-color": "^3.0.4",
"@types/react-copy-to-clipboard": "^5.0.0", "@types/react-copy-to-clipboard": "^5.0.0",
"@types/react-datepicker": "^3.1.5", "@types/react-datepicker": "^3.1.5",
@ -90,7 +86,7 @@
"@types/react-leaflet": "^2.5.2", "@types/react-leaflet": "^2.5.2",
"@types/react-redux": "^7.1.16", "@types/react-redux": "^7.1.16",
"@types/react-router-dom": "^5.1.7", "@types/react-router-dom": "^5.1.7",
"@types/react-tagsinput": "^3.19.7", "@types/react-tag-autocomplete": "^6.1.0",
"@types/uuid": "^8.3.0", "@types/uuid": "^8.3.0",
"@wojtekmaj/enzyme-adapter-react-17": "^0.3.1", "@wojtekmaj/enzyme-adapter-react-17": "^0.3.1",
"adm-zip": "^0.4.16", "adm-zip": "^0.4.16",
@ -134,7 +130,7 @@
"resolve": "^1.19.0", "resolve": "^1.19.0",
"sass": "^1.29.0", "sass": "^1.29.0",
"sass-loader": "^10.1.0", "sass-loader": "^10.1.0",
"serve": "^11.3.2", "serve": "^12.0.0",
"stryker-cli": "^1.0.0", "stryker-cli": "^1.0.0",
"style-loader": "^2.0.0", "style-loader": "^2.0.0",
"stylelint": "^13.7.2", "stylelint": "^13.7.2",

View file

@ -5,12 +5,12 @@ set -ex
PLATFORMS="linux/arm/v7,linux/arm64/v8,linux/amd64" PLATFORMS="linux/arm/v7,linux/arm64/v8,linux/amd64"
DOCKER_IMAGE="shlinkio/shlink-web-client" DOCKER_IMAGE="shlinkio/shlink-web-client"
if [[ "$GITHUB_REF" == *"main"* ]]; then if [[ "$GITHUB_REF" == *"develop"* ]]; then
docker buildx build --push \ docker buildx build --push \
--platform ${PLATFORMS} \ --platform ${PLATFORMS} \
-t ${DOCKER_IMAGE}:latest . -t ${DOCKER_IMAGE}:latest .
# If ref is not main, then this is a tag. Build that docker tag and also "stable" # If ref is not develop, then this is a tag. Build that docker tag and also "stable"
else else
VERSION=${GITHUB_REF#refs/tags/v} VERSION=${GITHUB_REF#refs/tags/v}
TAGS="-t ${DOCKER_IMAGE}:${VERSION}" TAGS="-t ${DOCKER_IMAGE}:${VERSION}"

View file

@ -0,0 +1,16 @@
#!/bin/sh
set -e
ME=$(basename $0)
setup_single_shlink_server() {
[ -n "$SHLINK_SERVER_URL" ] || return 0
[ -n "$SHLINK_SERVER_API_KEY" ] || return 0
local name="${SHLINK_SERVER_NAME:-Shlink}"
echo "[{\"name\":\"${name}\",\"url\":\"${SHLINK_SERVER_URL}\",\"apiKey\":\"${SHLINK_SERVER_API_KEY}\"}]" > /usr/share/nginx/html/servers.json
}
setup_single_shlink_server
exit 0

View file

@ -1,5 +1,4 @@
@import './utils/base'; @import './utils/base';
@import './utils/mixins/horizontal-align';
.app-container { .app-container {
height: 100%; height: 100%;
@ -25,18 +24,3 @@
padding: 0 15px; padding: 0 15px;
} }
} }
.app__update-banner.app__update-banner {
@include horizontal-align();
position: fixed;
top: $headerHeight - 25px;
padding: 0 4rem 0 0;
z-index: 1040;
margin: 0;
color: var(--text-color);
text-align: center;
width: 700px;
max-width: calc(100% - 30px);
box-shadow: 0 0 1rem var(--brand-color);
}

View file

@ -1,11 +1,11 @@
import { useEffect, FC } from 'react'; import { useEffect, FC } from 'react';
import { Route, Switch } from 'react-router-dom'; import { Route, Switch } from 'react-router-dom';
import { Alert } from 'reactstrap';
import NotFound from './common/NotFound'; import NotFound from './common/NotFound';
import { ServersMap } from './servers/data'; import { ServersMap } from './servers/data';
import { Settings } from './settings/reducers/settings'; import { Settings } from './settings/reducers/settings';
import { changeThemeInMarkup } from './utils/theme'; import { changeThemeInMarkup } from './utils/theme';
import { SimpleCard } from './utils/SimpleCard'; import { AppUpdateBanner } from './common/AppUpdateBanner';
import { forceUpdate } from './utils/helpers/sw';
import './App.scss'; import './App.scss';
interface AppProps { interface AppProps {
@ -55,16 +55,7 @@ const App = (
</div> </div>
</div> </div>
<Alert <AppUpdateBanner isOpen={appUpdated} toggle={resetAppUpdate} forceUpdate={forceUpdate} />
className="app__update-banner"
tag={SimpleCard}
color="secondary"
isOpen={appUpdated}
toggle={resetAppUpdate}
>
<h4 className="mb-4">This app has just been updated!</h4>
<p className="mb-0">Restart it to enjoy the new features.</p>
</Alert>
</div> </div>
); );
}; };

View file

@ -55,6 +55,7 @@ export interface ShlinkVisitsParams {
itemsPerPage?: number; itemsPerPage?: number;
startDate?: string; startDate?: string;
endDate?: string; endDate?: string;
excludeBots?: boolean;
} }
export interface ShlinkShortUrlData extends ShortUrlMeta { export interface ShlinkShortUrlData extends ShortUrlMeta {

View file

@ -0,0 +1,17 @@
@import '../utils/base';
@import '../utils/mixins/horizontal-align';
.app-update-banner.app-update-banner {
@include horizontal-align();
position: fixed;
top: $headerHeight - 25px;
padding: 0 4rem 0 0;
z-index: 1040;
margin: 0;
color: var(--text-color);
text-align: center;
width: 700px;
max-width: calc(100% - 30px);
box-shadow: 0 0 1rem var(--brand-color);
}

View file

@ -0,0 +1,34 @@
import { FC, MouseEventHandler } from 'react';
import { Alert, Button } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSyncAlt as reloadIcon } from '@fortawesome/free-solid-svg-icons';
import { SimpleCard } from '../utils/SimpleCard';
import { useToggle } from '../utils/helpers/hooks';
import './AppUpdateBanner.scss';
interface AppUpdateBannerProps {
isOpen: boolean;
toggle: MouseEventHandler<any>;
forceUpdate: Function;
}
export const AppUpdateBanner: FC<AppUpdateBannerProps> = ({ isOpen, toggle, forceUpdate }) => {
const [ isUpdating,, setUpdating ] = useToggle();
const update = () => {
setUpdating();
forceUpdate();
};
return (
<Alert className="app-update-banner" isOpen={isOpen} toggle={toggle} tag={SimpleCard} color="secondary">
<h4 className="mb-4">This app has just been updated!</h4>
<p className="mb-0">
Restart it to enjoy the new features.
<Button disabled={isUpdating} className="ml-2" color="secondary" size="sm" onClick={update}>
{!isUpdating && <>Restart now <FontAwesomeIcon icon={reloadIcon} className="ml-1" /></>}
{isUpdating && <>Restarting...</>}
</Button>
</p>
</Alert>
);
};

View file

@ -2,6 +2,8 @@ import { isEmpty, values } from 'ramda';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Card, Row } from 'reactstrap'; import { Card, Row } from 'reactstrap';
import { ExternalLink } from 'react-external-link'; import { ExternalLink } from 'react-external-link';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faExternalLinkAlt, faPlus } from '@fortawesome/free-solid-svg-icons';
import ServersListGroup from '../servers/ServersListGroup'; import ServersListGroup from '../servers/ServersListGroup';
import { ServersMap } from '../servers/data'; import { ServersMap } from '../servers/data';
import { ShlinkLogo } from './img/ShlinkLogo'; import { ShlinkLogo } from './img/ShlinkLogo';
@ -30,12 +32,19 @@ const Home = ({ servers }: HomeProps) => {
</div> </div>
<ServersListGroup embedded servers={serversList}> <ServersListGroup embedded servers={serversList}>
{!hasServers && ( {!hasServers && (
<div className="p-4"> <div className="p-4 text-center">
<p>This application will help you to manage your Shlink servers.</p> <p className="mb-5">This application will help you manage your Shlink servers.</p>
<p>To start, please, <Link to="/server/create">add your first server</Link>.</p> <p>
<p className="m-0"> <Link to="/server/create" className="btn btn-outline-primary btn-lg mr-2">
You still don&lsquo;t have a Shlink server? <FontAwesomeIcon icon={faPlus} /> <span className="ml-1">Add a server</span>
Learn how to <ExternalLink href="https://shlink.io/documentation">get started</ExternalLink>. </Link>
</p>
<p className="mb-0 mt-5">
<ExternalLink href="https://shlink.io/documentation">
<small>
<span className="mr-1">Learn more about Shlink</span> <FontAwesomeIcon icon={faExternalLinkAlt} />
</small>
</ExternalLink>
</p> </p>
</div> </div>
)} )}

View file

@ -0,0 +1,145 @@
@import '../utils/base';
.react-tags {
position: relative;
padding: 5px 0 0 6px;
border-radius: .3rem;
background-color: var(--input-color);
border: 1px solid var(--input-border-color);
transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
/* shared font styles */
font-size: 1em;
line-height: 1.2;
/* clicking anywhere will focus the input */
cursor: text;
}
.react-tags.is-focused {
box-shadow: 0 0 0 .2rem rgb(70 150 229 / 25%);
}
.react-tags__tag {
font-size: 100%;
}
.react-tags__selected {
display: inline;
vertical-align: 2px;
}
.react-tags__selected-tag {
display: inline-block;
box-sizing: border-box;
margin: 0 6px 6px 0;
padding: 6px 8px;
border: 1px solid var(--input-border-color);
border-radius: .25rem;
background: #f1f1f1;
/* match the font styles */
font-size: inherit;
line-height: inherit;
}
.react-tags__selected-tag:after {
content: '\2715';
color: #aaaaaa;
margin-left: 8px;
}
.react-tags__selected-tag:hover,
.react-tags__selected-tag:focus {
border-color: var(--input-border-color);
}
.react-tags__search {
display: inline-block;
/* match tag layout */
padding: 6px 2px;
margin-bottom: 5px;
/* prevent autoresize overflowing the container */
max-width: 100%;
}
@media screen and (min-width: $smMin) {
.react-tags__search {
/* this will become the offsetParent for suggestions */
position: relative;
}
}
.react-tags__search-input {
font-size: 1.25rem;
line-height: inherit;
color: var(--input-text-color);
background-color: var(--input-color);
/* prevent autoresize overflowing the container */
max-width: 100%;
/* remove styles and layout from this element */
margin: 0 0 0 7px;
padding: 0;
border: 0;
outline: none;
}
.react-tags__search-input::-ms-clear {
display: none;
}
.react-tags__suggestions {
position: absolute;
top: 100%;
left: 0;
width: 100%;
z-index: 10;
}
@media screen and (min-width: $smMin) {
.react-tags__suggestions {
width: 240px;
}
}
.react-tags__suggestions ul {
margin: 4px -1px;
padding: 0;
list-style: none;
background: var(--primary-color);
border: 1px solid var(--border-color);
border-radius: .25rem;
box-shadow: 0 2px 6px rgba(0, 0, 0, .2);
}
.react-tags__suggestions li {
padding: 8px 10px;
}
.react-tags__suggestions li:not(:last-child) {
border-bottom: 1px solid var(--border-color);
}
.react-tags__suggestions li mark {
text-decoration: underline;
background: none;
font-weight: 600;
}
.react-tags__suggestions li:hover {
cursor: pointer;
background-color: var(--active-color);
}
.react-tags__suggestions li.is-active {
background-color: var(--active-color);
}
.react-tags__suggestions li.is-disabled {
opacity: .5;
cursor: auto;
}

View file

@ -1,58 +0,0 @@
@import '../utils/base';
.react-tagsinput {
background-color: var(--input-color);
border: 1px solid var(--input-border-color);
border-radius: .25rem;
overflow: hidden;
min-height: 2.6rem;
padding: .5rem 0 0 1rem;
transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
}
.react-tagsinput--focused {
border-color: #80bdff;
box-shadow: 0 0 0 .2rem rgb(70 150 229 / 25%);
}
.react-tagsinput-tag {
font-size: 1rem;
background-color: #f1f1f1;
border-radius: 4px;
display: inline-block;
font-weight: 400;
margin: 0 5px 6px 0;
padding: 6px 8px;
line-height: 1;
color: #ffffff;
}
.react-tagsinput-remove {
cursor: pointer;
font-weight: 700;
margin-left: 8px;
}
.react-tagsinput-tag span:before {
content: '\2715';
color: #ffffff;
}
.react-tagsinput-input {
background: transparent;
border: 0;
outline: none;
padding: 1px 0;
width: 100%;
margin-bottom: 6px;
font-size: 1.25rem;
color: var(--input-text-color);
}
.react-tagsinput-input::placeholder {
color: $textPlaceholder;
}
.react-autosuggest__suggestion--highlighted {
background-color: var(--active-color);
}

View file

@ -2,7 +2,7 @@
@import './utils/base'; @import './utils/base';
@import 'node_modules/bootstrap/scss/bootstrap.scss'; @import 'node_modules/bootstrap/scss/bootstrap.scss';
@import './common/react-tagsinput.scss'; @import './common/react-tag-autocomplete.scss';
@import './theme/theme'; @import './theme/theme';
* { * {

View file

@ -15,7 +15,7 @@ const ServersDropdown = (serversExporter: ServersExporter) => ({ servers, select
const serversList = values(servers); const serversList = values(servers);
const createServerItem = ( const createServerItem = (
<DropdownItem tag={Link} to="/server/create"> <DropdownItem tag={Link} to="/server/create">
<FontAwesomeIcon icon={plusIcon} /> <span className="ml-1">Add server</span> <FontAwesomeIcon icon={plusIcon} /> <span className="ml-1">Add a server</span>
</DropdownItem> </DropdownItem>
); );

View file

@ -41,6 +41,7 @@ const getInitialState = (shortUrl?: ShortUrl, settings?: ShortUrlCreationSetting
validSince: shortUrl.meta.validSince ?? undefined, validSince: shortUrl.meta.validSince ?? undefined,
validUntil: shortUrl.meta.validUntil ?? undefined, validUntil: shortUrl.meta.validUntil ?? undefined,
maxVisits: shortUrl.meta.maxVisits ?? undefined, maxVisits: shortUrl.meta.maxVisits ?? undefined,
crawlable: shortUrl.crawlable,
validateUrl, validateUrl,
}; };
}; };

View file

@ -1,7 +1,7 @@
import { faTags as tagsIcon } from '@fortawesome/free-solid-svg-icons'; import { faTags as tagsIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { isEmpty, pipe } from 'ramda'; import { isEmpty, pipe } from 'ramda';
import moment from 'moment'; import { parseISO } from 'date-fns';
import SearchField from '../utils/SearchField'; import SearchField from '../utils/SearchField';
import Tag from '../tags/helpers/Tag'; import Tag from '../tags/helpers/Tag';
import { DateRangeSelector } from '../utils/dates/DateRangeSelector'; import { DateRangeSelector } from '../utils/dates/DateRangeSelector';
@ -16,7 +16,7 @@ interface SearchBarProps {
shortUrlsListParams: ShortUrlsListParams; shortUrlsListParams: ShortUrlsListParams;
} }
const dateOrNull = (date?: string) => date ? moment(date) : null; const dateOrNull = (date?: string) => date ? parseISO(date) : null;
const SearchBar = (colorGenerator: ColorGenerator) => ({ listShortUrls, shortUrlsListParams }: SearchBarProps) => { const SearchBar = (colorGenerator: ColorGenerator) => ({ listShortUrls, shortUrlsListParams }: SearchBarProps) => {
const selectedTags = shortUrlsListParams.tags ?? []; const selectedTags = shortUrlsListParams.tags ?? [];

View file

@ -2,10 +2,11 @@ import { FC, useEffect, useState } from 'react';
import { InputType } from 'reactstrap/lib/Input'; import { InputType } from 'reactstrap/lib/Input';
import { Button, FormGroup, Input, Row } from 'reactstrap'; import { Button, FormGroup, Input, Row } from 'reactstrap';
import { isEmpty, pipe, replace, trim } from 'ramda'; import { isEmpty, pipe, replace, trim } from 'ramda';
import m from 'moment';
import classNames from 'classnames'; import classNames from 'classnames';
import { parseISO } from 'date-fns';
import DateInput, { DateInputProps } from '../utils/DateInput'; import DateInput, { DateInputProps } from '../utils/DateInput';
import { import {
supportsCrawlableVisits,
supportsListingDomains, supportsListingDomains,
supportsSettingShortCodeLength, supportsSettingShortCodeLength,
supportsShortUrlTitle, supportsShortUrlTitle,
@ -20,6 +21,7 @@ import { DomainSelectorProps } from '../domains/DomainSelector';
import { formatIsoDate } from '../utils/helpers/date'; import { formatIsoDate } from '../utils/helpers/date';
import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon'; import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon';
import { ShortUrlData } from './data'; import { ShortUrlData } from './data';
import { ShortUrlFormCheckboxGroup } from './helpers/ShortUrlFormCheckboxGroup';
import './ShortUrlForm.scss'; import './ShortUrlForm.scss';
export type Mode = 'create' | 'create-basic' | 'edit'; export type Mode = 'create' | 'create-basic' | 'edit';
@ -36,6 +38,7 @@ export interface ShortUrlFormProps {
} }
const normalizeTag = pipe(trim, replace(/ /g, '-')); const normalizeTag = pipe(trim, replace(/ /g, '-'));
const toDate = (date?: string | Date): Date | undefined => typeof date === 'string' ? parseISO(date) : date;
export const ShortUrlForm = ( export const ShortUrlForm = (
TagsSelector: FC<TagsSelectorProps>, TagsSelector: FC<TagsSelectorProps>,
@ -72,7 +75,7 @@ export const ShortUrlForm = (
const renderDateInput = (id: DateFields, placeholder: string, props: Partial<DateInputProps> = {}) => ( const renderDateInput = (id: DateFields, placeholder: string, props: Partial<DateInputProps> = {}) => (
<div className="form-group"> <div className="form-group">
<DateInput <DateInput
selected={shortUrlData[id] ? m(shortUrlData[id]) : null} selected={shortUrlData[id] ? toDate(shortUrlData[id] as string | Date) : null}
placeholderText={placeholder} placeholderText={placeholder}
isClearable isClearable
onChange={(date) => setShortUrlData({ ...shortUrlData, [id]: date })} onChange={(date) => setShortUrlData({ ...shortUrlData, [id]: date })}
@ -94,7 +97,7 @@ export const ShortUrlForm = (
</FormGroup> </FormGroup>
<FormGroup> <FormGroup>
<TagsSelector tags={shortUrlData.tags ?? []} onChange={changeTags} /> <TagsSelector selectedTags={shortUrlData.tags ?? []} onChange={changeTags} />
</FormGroup> </FormGroup>
</> </>
); );
@ -108,7 +111,8 @@ export const ShortUrlForm = (
'col-sm-12': !showCustomizeCard, 'col-sm-12': !showCustomizeCard,
}); });
const showValidateUrl = supportsValidateUrl(selectedServer); const showValidateUrl = supportsValidateUrl(selectedServer);
const showExtraValidationsCard = showValidateUrl || !isEdit; const showCrawlableControl = supportsCrawlableVisits(selectedServer);
const showExtraValidationsCard = showValidateUrl || showCrawlableControl || !isEdit;
return ( return (
<form className="short-url-form" onSubmit={submit}> <form className="short-url-form" onSubmit={submit}>
@ -160,30 +164,31 @@ export const ShortUrlForm = (
<div className={limitAccessCardClasses}> <div className={limitAccessCardClasses}>
<SimpleCard title="Limit access to the short URL"> <SimpleCard title="Limit access to the short URL">
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })} {renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
{renderDateInput('validSince', 'Enabled since...', { maxDate: shortUrlData.validUntil ? m(shortUrlData.validUntil) : undefined })} {renderDateInput('validSince', 'Enabled since...', { maxDate: shortUrlData.validUntil ? toDate(shortUrlData.validUntil) : undefined })}
{renderDateInput('validUntil', 'Enabled until...', { minDate: shortUrlData.validSince ? m(shortUrlData.validSince) : undefined })} {renderDateInput('validUntil', 'Enabled until...', { minDate: shortUrlData.validSince ? toDate(shortUrlData.validSince) : undefined })}
</SimpleCard> </SimpleCard>
</div> </div>
</Row> </Row>
{showExtraValidationsCard && ( {showExtraValidationsCard && (
<SimpleCard title="Extra validations" className="mb-3"> <SimpleCard title="Extra checks" className="mb-3">
{!isEdit && (
<p>
Make sure the long URL is valid, or ensure an existing short URL is returned if it matches all
provided data.
</p>
)}
{showValidateUrl && ( {showValidateUrl && (
<p> <ShortUrlFormCheckboxGroup
<Checkbox infoTooltip="If checked, Shlink will try to reach the long URL, failing in case it's not publicly accessible."
inline checked={shortUrlData.validateUrl}
checked={shortUrlData.validateUrl} onChange={(validateUrl) => setShortUrlData({ ...shortUrlData, validateUrl })}
onChange={(validateUrl) => setShortUrlData({ ...shortUrlData, validateUrl })} >
> Validate URL
Validate URL </ShortUrlFormCheckboxGroup>
</Checkbox> )}
</p> {showCrawlableControl && (
<ShortUrlFormCheckboxGroup
infoTooltip="This short URL will be included in the robots.txt for your Shlink instance, allowing web crawlers (like Google) to index it."
checked={shortUrlData.crawlable}
onChange={(crawlable) => setShortUrlData({ ...shortUrlData, crawlable })}
>
Make it crawlable
</ShortUrlFormCheckboxGroup>
)} )}
{!isEdit && ( {!isEdit && (
<p> <p>

View file

@ -1,14 +1,14 @@
import * as m from 'moment';
import { Nullable, OptionalString } from '../../utils/utils'; import { Nullable, OptionalString } from '../../utils/utils';
export interface EditShortUrlData { export interface EditShortUrlData {
longUrl?: string; longUrl?: string;
tags?: string[]; tags?: string[];
title?: string; title?: string;
validSince?: m.Moment | string | null; validSince?: Date | string | null;
validUntil?: m.Moment | string | null; validUntil?: Date | string | null;
maxVisits?: number | null; maxVisits?: number | null;
validateUrl?: boolean; validateUrl?: boolean;
crawlable?: boolean;
} }
export interface ShortUrlData extends EditShortUrlData { export interface ShortUrlData extends EditShortUrlData {
@ -29,6 +29,7 @@ export interface ShortUrl {
tags: string[]; tags: string[];
domain: string | null; domain: string | null;
title?: string | null; title?: string | null;
crawlable?: boolean;
} }
export interface ShortUrlMeta { export interface ShortUrlMeta {

View file

@ -0,0 +1,39 @@
import { ChangeEvent, FC, useRef } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
import { UncontrolledTooltip } from 'reactstrap';
import Checkbox from '../../utils/Checkbox';
interface ShortUrlFormCheckboxGroupProps {
checked?: boolean;
onChange?: (checked: boolean, e: ChangeEvent<HTMLInputElement>) => void;
infoTooltip?: string;
}
const InfoTooltip: FC<{ tooltip: string }> = ({ tooltip }) => {
const ref = useRef<HTMLElement | null>();
return (
<>
<span
ref={(el) => {
ref.current = el;
}}
>
<FontAwesomeIcon icon={infoIcon} />
</span>
<UncontrolledTooltip target={(() => ref.current) as any} placement="right">{tooltip}</UncontrolledTooltip>
</>
);
};
export const ShortUrlFormCheckboxGroup: FC<ShortUrlFormCheckboxGroupProps> = (
{ children, infoTooltip, checked, onChange },
) => (
<p>
<Checkbox inline checked={checked} className={infoTooltip ? 'mr-2' : ''} onChange={onChange}>
{children}
</Checkbox>
{infoTooltip && <InfoTooltip tooltip={infoTooltip} />}
</p>
);

View file

@ -1,6 +1,5 @@
import { isEmpty } from 'ramda';
import { FC, useEffect, useRef } from 'react'; import { FC, useEffect, useRef } from 'react';
import Moment from 'react-moment'; import { isEmpty } from 'ramda';
import { ExternalLink } from 'react-external-link'; import { ExternalLink } from 'react-external-link';
import ColorGenerator from '../../utils/services/ColorGenerator'; import ColorGenerator from '../../utils/services/ColorGenerator';
import { StateFlagTimeout } from '../../utils/helpers/hooks'; import { StateFlagTimeout } from '../../utils/helpers/hooks';
@ -8,6 +7,7 @@ import Tag from '../../tags/helpers/Tag';
import { SelectedServer } from '../../servers/data'; import { SelectedServer } from '../../servers/data';
import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon'; import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon';
import { ShortUrl } from '../data'; import { ShortUrl } from '../data';
import { Time } from '../../utils/Time';
import ShortUrlVisitsCount from './ShortUrlVisitsCount'; import ShortUrlVisitsCount from './ShortUrlVisitsCount';
import { ShortUrlsRowMenuProps } from './ShortUrlsRowMenu'; import { ShortUrlsRowMenuProps } from './ShortUrlsRowMenu';
import './ShortUrlsRow.scss'; import './ShortUrlsRow.scss';
@ -53,7 +53,7 @@ const ShortUrlsRow = (
return ( return (
<tr className="short-urls-row"> <tr className="short-urls-row">
<td className="indivisible short-urls-row__cell" data-th="Created at: "> <td className="indivisible short-urls-row__cell" data-th="Created at: ">
<Moment format="YYYY-MM-DD HH:mm">{shortUrl.dateCreated}</Moment> <Time date={shortUrl.dateCreated} />
</td> </td>
<td className="short-urls-row__cell" data-th="Short URL: "> <td className="short-urls-row__cell" data-th="Short URL: ">
<span className="indivisible short-urls-row__cell--relative"> <span className="indivisible short-urls-row__cell--relative">
@ -68,7 +68,7 @@ const ShortUrlsRow = (
<ExternalLink href={shortUrl.longUrl}>{shortUrl.title ?? shortUrl.longUrl}</ExternalLink> <ExternalLink href={shortUrl.longUrl}>{shortUrl.title ?? shortUrl.longUrl}</ExternalLink>
</td> </td>
{shortUrl.title && ( {shortUrl.title && (
<td className="short-urls-row__cell d-lg-none" data-th="Long URL: "> <td className="short-urls-row__cell short-urls-row__cell--break d-lg-none" data-th="Long URL: ">
<ExternalLink href={shortUrl.longUrl} /> <ExternalLink href={shortUrl.longUrl} />
</td> </td>
)} )}

View file

@ -1,18 +1,19 @@
import { FC } from 'react'; import { FC, MouseEventHandler } from 'react';
import ColorGenerator from '../../utils/services/ColorGenerator'; import ColorGenerator from '../../utils/services/ColorGenerator';
import './Tag.scss'; import './Tag.scss';
interface TagProps { interface TagProps {
colorGenerator: ColorGenerator; colorGenerator: ColorGenerator;
text: string; text: string;
className?: string;
clearable?: boolean; clearable?: boolean;
onClick?: () => void; onClick?: MouseEventHandler;
onClose?: () => void; onClose?: MouseEventHandler;
} }
const Tag: FC<TagProps> = ({ text, children, clearable, colorGenerator, onClick, onClose }) => ( const Tag: FC<TagProps> = ({ text, children, clearable, className = '', colorGenerator, onClick, onClose }) => (
<span <span
className="badge tag" className={`badge tag ${className}`}
style={{ backgroundColor: colorGenerator.getColorForKey(text), cursor: clearable || !onClick ? 'auto' : 'pointer' }} style={{ backgroundColor: colorGenerator.getColorForKey(text), cursor: clearable || !onClick ? 'auto' : 'pointer' }}
onClick={onClick} onClick={onClick}
> >

View file

@ -1,16 +0,0 @@
@import '../../utils/base';
.react-autosuggest__suggestions-list {
list-style-type: none;
padding: 0;
margin-bottom: 6px;
}
.react-autosuggest__suggestion {
margin-left: -6px;
padding: 5px 8px;
}
.react-autosuggest__suggestion--highlighted {
background-color: $lightGrey;
}

View file

@ -1,13 +1,12 @@
import { ChangeEvent, useEffect } from 'react'; import { useEffect } from 'react';
import TagsInput, { RenderInputProps, RenderTagProps } from 'react-tagsinput'; import ReactTags, { SuggestionComponentProps, TagComponentProps } from 'react-tag-autocomplete';
import Autosuggest, { ChangeEvent as AutoChangeEvent, SuggestionSelectedEventData } from 'react-autosuggest';
import ColorGenerator from '../../utils/services/ColorGenerator'; import ColorGenerator from '../../utils/services/ColorGenerator';
import { TagsList } from '../reducers/tagsList'; import { TagsList } from '../reducers/tagsList';
import TagBullet from './TagBullet'; import TagBullet from './TagBullet';
import './TagsSelector.scss'; import Tag from './Tag';
export interface TagsSelectorProps { export interface TagsSelectorProps {
tags: string[]; selectedTags: string[];
onChange: (tags: string[]) => void; onChange: (tags: string[]) => void;
placeholder?: string; placeholder?: string;
} }
@ -17,65 +16,41 @@ interface TagsSelectorConnectProps extends TagsSelectorProps {
tagsList: TagsList; tagsList: TagsList;
} }
const noop = () => {}; const toComponentTag = (tag: string) => ({ id: tag, name: tag });
const TagsSelector = (colorGenerator: ColorGenerator) => ( const TagsSelector = (colorGenerator: ColorGenerator) => (
{ tags, onChange, listTags, tagsList, placeholder = 'Add tags to the URL' }: TagsSelectorConnectProps, { selectedTags, onChange, listTags, tagsList, placeholder = 'Add tags to the URL' }: TagsSelectorConnectProps,
) => { ) => {
useEffect(() => { useEffect(() => {
listTags(); listTags();
}, []); }, []);
const renderTag = ( const ReactTagsTag = ({ tag, onDelete }: TagComponentProps) =>
{ tag, key, disabled, onRemove, classNameRemove, getTagDisplayValue, ...other }: RenderTagProps<string>, <Tag colorGenerator={colorGenerator} text={tag.name} clearable className="react-tags__tag" onClose={onDelete} />;
) => ( const ReactTagsSuggestion = ({ item }: SuggestionComponentProps) => (
<span key={key} style={{ backgroundColor: colorGenerator.getColorForKey(tag) }} {...other}> <>
{getTagDisplayValue(tag)} <TagBullet tag={`${item.name}`} colorGenerator={colorGenerator} />
{!disabled && <span className={classNameRemove} onClick={() => onRemove(key)} />} {item.name}
</span> </>
); );
const renderAutocompleteInput = (data: RenderInputProps<string>) => {
const { addTag, ...otherProps } = data;
const handleOnChange = (e: ChangeEvent<HTMLInputElement>, { method }: AutoChangeEvent) => {
method === 'enter' ? e.preventDefault() : otherProps.onChange(e);
};
const inputValue = otherProps.value?.trim().toLowerCase() ?? '';
const suggestions = tagsList.tags.filter((tag) => tag.startsWith(inputValue));
return (
<Autosuggest
ref={otherProps.ref}
suggestions={suggestions}
inputProps={{ ...otherProps, onChange: handleOnChange }}
highlightFirstSuggestion
shouldRenderSuggestions={(value: string) => value.trim().length > 0}
getSuggestionValue={(suggestion) => suggestion}
renderSuggestion={(suggestion) => (
<>
<TagBullet tag={suggestion} colorGenerator={colorGenerator} />
{suggestion}
</>
)}
onSuggestionsFetchRequested={noop}
onSuggestionsClearRequested={noop}
onSuggestionSelected={(_, { suggestion }: SuggestionSelectedEventData<string>) => {
addTag(suggestion);
}}
/>
);
};
return ( return (
<TagsInput <ReactTags
value={tags} tags={selectedTags.map(toComponentTag)}
inputProps={{ placeholder }} tagComponent={ReactTagsTag}
onlyUnique suggestions={tagsList.tags.filter((tag) => !selectedTags.includes(tag)).map(toComponentTag)}
renderTag={renderTag} suggestionComponent={ReactTagsSuggestion}
renderInput={renderAutocompleteInput} allowNew
// FIXME Workaround to be able to add tags on Android
addOnBlur addOnBlur
onChange={onChange} placeholderText={placeholder}
minQueryLength={1}
onDelete={(removedTagIndex) => {
const tagsCopy = [ ...selectedTags ];
tagsCopy.splice(removedTagIndex, 1);
onChange(tagsCopy);
}}
onAddition={({ name: newTag }) => onChange([ ...selectedTags, newTag.toLowerCase() ])}
/> />
); );
}; };

View file

@ -1,33 +1,12 @@
import { useRef } from 'react'; import { useRef } from 'react';
import { isNil, dissoc } from 'ramda'; import { isNil } from 'ramda';
import DatePicker, { ReactDatePickerProps } from 'react-datepicker'; import DatePicker, { ReactDatePickerProps } from 'react-datepicker';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCalendarAlt as calendarIcon } from '@fortawesome/free-regular-svg-icons'; import { faCalendarAlt as calendarIcon } from '@fortawesome/free-regular-svg-icons';
import classNames from 'classnames'; import classNames from 'classnames';
import moment from 'moment';
import './DateInput.scss'; import './DateInput.scss';
interface DatePropsInterface { export type DateInputProps = ReactDatePickerProps;
endDate?: moment.Moment | null;
maxDate?: moment.Moment | null;
minDate?: moment.Moment | null;
selected?: moment.Moment | null;
startDate?: moment.Moment | null;
onChange?: (date: moment.Moment | null) => void;
}
export type DateInputProps = DatePropsInterface & Omit<ReactDatePickerProps, keyof DatePropsInterface>;
const transformProps = (props: DateInputProps): ReactDatePickerProps => ({
// @ts-expect-error The DatePicker type definition is wrong. It has a ref prop
...dissoc('ref', props),
endDate: props.endDate?.toDate(),
maxDate: props.maxDate?.toDate(),
minDate: props.minDate?.toDate(),
selected: props.selected?.toDate(),
startDate: props.startDate?.toDate(),
onChange: (date: Date | null) => props.onChange?.(date && moment(date)),
});
const DateInput = (props: DateInputProps) => { const DateInput = (props: DateInputProps) => {
const { className, isClearable, selected } = props; const { className, isClearable, selected } = props;
@ -37,7 +16,7 @@ const DateInput = (props: DateInputProps) => {
return ( return (
<div className="date-input-container"> <div className="date-input-container">
<DatePicker <DatePicker
{...transformProps(props)} {...props}
dateFormat="yyyy-MM-dd" dateFormat="yyyy-MM-dd"
className={classNames('date-input-container__input form-control', className)} className={classNames('date-input-container__input form-control', className)}
// @ts-expect-error The DatePicker type definition is wrong. It has a ref prop // @ts-expect-error The DatePicker type definition is wrong. It has a ref prop

View file

@ -9,18 +9,20 @@ export interface DropdownBtnProps {
className?: string; className?: string;
dropdownClassName?: string; dropdownClassName?: string;
right?: boolean; right?: boolean;
minWidth?: number;
} }
export const DropdownBtn: FC<DropdownBtnProps> = ( export const DropdownBtn: FC<DropdownBtnProps> = (
{ text, disabled = false, className = '', children, dropdownClassName, right = false }, { text, disabled = false, className = '', children, dropdownClassName, right = false, minWidth },
) => { ) => {
const [ isOpen, toggle ] = useToggle(); const [ isOpen, toggle ] = useToggle();
const toggleClasses = `dropdown-btn__toggle btn-block ${className}`; const toggleClasses = `dropdown-btn__toggle btn-block ${className}`;
const style = { minWidth: minWidth && `${minWidth}px` };
return ( return (
<Dropdown isOpen={isOpen} toggle={toggle} disabled={disabled} className={dropdownClassName}> <Dropdown isOpen={isOpen} toggle={toggle} disabled={disabled} className={dropdownClassName}>
<DropdownToggle caret className={toggleClasses} color="primary">{text}</DropdownToggle> <DropdownToggle caret className={toggleClasses} color="primary">{text}</DropdownToggle>
<DropdownMenu className="w-100" right={right}>{children}</DropdownMenu> <DropdownMenu className="w-100" right={right} style={style}>{children}</DropdownMenu>
</Dropdown> </Dropdown>
); );
}; };

18
src/utils/Time.tsx Normal file
View file

@ -0,0 +1,18 @@
import { parseISO, format as formatDate, getUnixTime, formatDistance } from 'date-fns';
import { isDateObject } from './helpers/date';
export interface DateProps {
date: Date | string;
format?: string;
relative?: boolean;
}
export const Time = ({ date, format = 'yyyy-MM-dd HH:mm', relative = false }: DateProps) => {
const dateObject = isDateObject(date) ? date : parseISO(date);
return (
<time dateTime={`${getUnixTime(dateObject)}000`}>
{relative ? `${formatDistance(new Date(), dateObject)} ago` : formatDate(dateObject, format)}
</time>
);
};

View file

@ -1,10 +1,9 @@
import moment from 'moment';
import DateInput from '../DateInput'; import DateInput from '../DateInput';
import { DateRange } from './types'; import { DateRange } from './types';
interface DateRangeRowProps extends DateRange { interface DateRangeRowProps extends DateRange {
onStartDateChange: (date: moment.Moment | null) => void; onStartDateChange: (date: Date | null) => void;
onEndDateChange: (date: moment.Moment | null) => void; onEndDateChange: (date: Date | null) => void;
disabled?: boolean; disabled?: boolean;
} }

View file

@ -1,10 +1,10 @@
import moment from 'moment'; import { subDays, startOfDay, endOfDay } from 'date-fns';
import { filter, isEmpty } from 'ramda'; import { filter, isEmpty } from 'ramda';
import { formatInternational } from '../../helpers/date'; import { formatInternational } from '../../helpers/date';
export interface DateRange { export interface DateRange {
startDate?: moment.Moment | null; startDate?: Date | null;
endDate?: moment.Moment | null; endDate?: Date | null;
} }
export type DateInterval = 'today' | 'yesterday' | 'last7Days' | 'last30Days' | 'last90Days' | 'last180days' | 'last365Days'; export type DateInterval = 'today' | 'yesterday' | 'last7Days' | 'last30Days' | 'last90Days' | 'last180days' | 'last365Days';
@ -54,6 +54,8 @@ export const rangeOrIntervalToString = (range?: DateRange | DateInterval): strin
return INTERVAL_TO_STRING_MAP[range]; return INTERVAL_TO_STRING_MAP[range];
}; };
const startOfDaysAgo = (daysAgo: number) => startOfDay(subDays(new Date(), daysAgo));
export const intervalToDateRange = (dateInterval?: DateInterval): DateRange => { export const intervalToDateRange = (dateInterval?: DateInterval): DateRange => {
if (!dateInterval) { if (!dateInterval) {
return {}; return {};
@ -61,21 +63,19 @@ export const intervalToDateRange = (dateInterval?: DateInterval): DateRange => {
switch (dateInterval) { switch (dateInterval) {
case 'today': case 'today':
return { startDate: moment().startOf('day'), endDate: moment() }; return { startDate: startOfDay(new Date()), endDate: new Date() };
case 'yesterday': case 'yesterday':
const yesterday = moment().subtract(1, 'day'); // eslint-disable-line no-case-declarations return { startDate: startOfDaysAgo(1), endDate: endOfDay(subDays(new Date(), 1)) };
return { startDate: yesterday.startOf('day'), endDate: yesterday.endOf('day') };
case 'last7Days': case 'last7Days':
return { startDate: moment().subtract(7, 'days').startOf('day'), endDate: moment() }; return { startDate: startOfDaysAgo(7), endDate: new Date() };
case 'last30Days': case 'last30Days':
return { startDate: moment().subtract(30, 'days').startOf('day'), endDate: moment() }; return { startDate: startOfDaysAgo(30), endDate: new Date() };
case 'last90Days': case 'last90Days':
return { startDate: moment().subtract(90, 'days').startOf('day'), endDate: moment() }; return { startDate: startOfDaysAgo(90), endDate: new Date() };
case 'last180days': case 'last180days':
return { startDate: moment().subtract(180, 'days').startOf('day'), endDate: moment() }; return { startDate: startOfDaysAgo(180), endDate: new Date() };
case 'last365Days': case 'last365Days':
return { startDate: moment().subtract(365, 'days').startOf('day'), endDate: moment() }; return { startDate: startOfDaysAgo(365), endDate: new Date() };
} }
return {}; return {};

View file

@ -1,16 +1,23 @@
import * as moment from 'moment'; import { format, formatISO, parse } from 'date-fns';
import { OptionalString } from '../utils'; import { OptionalString } from '../utils';
type MomentOrString = moment.Moment | string; type DateOrString = Date | string;
type NullableDate = MomentOrString | null; type NullableDate = DateOrString | null;
const isMomentObject = (date: MomentOrString): date is moment.Moment => typeof (date as moment.Moment).format === 'function'; export const isDateObject = (date: DateOrString): date is Date => typeof date !== 'string';
const formatDateFromFormat = (date?: NullableDate, format?: string): OptionalString => const formatDateFromFormat = (date?: NullableDate, theFormat?: string): OptionalString => {
!date || !isMomentObject(date) ? date : date.format(format); if (!date || !isDateObject(date)) {
return date;
}
export const formatDate = (format = 'YYYY-MM-DD') => (date?: NullableDate) => formatDateFromFormat(date, format); return theFormat ? format(date, theFormat) : formatISO(date);
};
export const formatDate = (format = 'yyyy-MM-dd') => (date?: NullableDate) => formatDateFromFormat(date, format);
export const formatIsoDate = (date?: NullableDate) => formatDateFromFormat(date, undefined); export const formatIsoDate = (date?: NullableDate) => formatDateFromFormat(date, undefined);
export const formatInternational = formatDate(); export const formatInternational = formatDate();
export const parseDate = (date: string, format: string) => parse(date, format, new Date());

View file

@ -23,3 +23,7 @@ export const supportsOrphanVisits = supportsShortUrlTitle;
export const supportsQrCodeMargin = supportsShortUrlTitle; export const supportsQrCodeMargin = supportsShortUrlTitle;
export const supportsTagsInPatch = supportsShortUrlTitle; export const supportsTagsInPatch = supportsShortUrlTitle;
export const supportsBotVisits = serverMatchesVersions({ minVersion: '2.7.0' });
export const supportsCrawlableVisits = supportsBotVisits;

16
src/utils/helpers/sw.ts Normal file
View file

@ -0,0 +1,16 @@
export const forceUpdate = async () => {
const registrations = await navigator.serviceWorker?.getRegistrations() ?? [];
for (const registration of registrations) {
const { waiting } = registration;
waiting?.addEventListener('statechange', (event) => {
if ((event.target as any)?.state === 'activated') {
window.location.reload();
}
});
// The logic that makes skipWaiting to be called when this message is posted is in service-worker.ts
waiting?.postMessage({ type: 'SKIP_WAITING' });
}
};

View file

@ -2,17 +2,17 @@ import { RouteComponentProps } from 'react-router';
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
import { ShlinkVisitsParams } from '../api/types'; import { ShlinkVisitsParams } from '../api/types';
import { Topics } from '../mercure/helpers/Topics'; import { Topics } from '../mercure/helpers/Topics';
import { Settings } from '../settings/reducers/settings';
import VisitsStats from './VisitsStats'; import VisitsStats from './VisitsStats';
import { OrphanVisitsHeader } from './OrphanVisitsHeader'; import { OrphanVisitsHeader } from './OrphanVisitsHeader';
import { NormalizedVisit, VisitsInfo } from './types'; import { NormalizedVisit, OrphanVisitType, VisitsInfo, VisitsParams } from './types';
import { VisitsExporter } from './services/VisitsExporter'; import { VisitsExporter } from './services/VisitsExporter';
import { CommonVisitsProps } from './types/CommonVisitsProps';
import { toApiParams } from './types/helpers';
export interface OrphanVisitsProps extends RouteComponentProps { export interface OrphanVisitsProps extends CommonVisitsProps, RouteComponentProps {
getOrphanVisits: (params: ShlinkVisitsParams) => void; getOrphanVisits: (params?: ShlinkVisitsParams, orphanVisitsType?: OrphanVisitType) => void;
orphanVisits: VisitsInfo; orphanVisits: VisitsInfo;
cancelGetOrphanVisits: () => void; cancelGetOrphanVisits: () => void;
settings: Settings;
} }
export const OrphanVisits = ({ exportVisits }: VisitsExporter) => boundToMercureHub(({ export const OrphanVisits = ({ exportVisits }: VisitsExporter) => boundToMercureHub(({
@ -22,17 +22,20 @@ export const OrphanVisits = ({ exportVisits }: VisitsExporter) => boundToMercure
orphanVisits, orphanVisits,
cancelGetOrphanVisits, cancelGetOrphanVisits,
settings, settings,
selectedServer,
}: OrphanVisitsProps) => { }: OrphanVisitsProps) => {
const exportCsv = (visits: NormalizedVisit[]) => exportVisits('orphan_visits.csv', visits); const exportCsv = (visits: NormalizedVisit[]) => exportVisits('orphan_visits.csv', visits);
const loadVisits = (params: VisitsParams) => getOrphanVisits(toApiParams(params), params.filter?.orphanVisitsType);
return ( return (
<VisitsStats <VisitsStats
getVisits={getOrphanVisits} getVisits={loadVisits}
cancelGetVisits={cancelGetOrphanVisits} cancelGetVisits={cancelGetOrphanVisits}
visitsInfo={orphanVisits} visitsInfo={orphanVisits}
baseUrl={url} baseUrl={url}
settings={settings} settings={settings}
exportCsv={exportCsv} exportCsv={exportCsv}
selectedServer={selectedServer}
isOrphanVisits isOrphanVisits
> >
<OrphanVisitsHeader orphanVisits={orphanVisits} goBack={goBack} /> <OrphanVisitsHeader orphanVisits={orphanVisits} goBack={goBack} />

View file

@ -5,20 +5,20 @@ import { ShlinkVisitsParams } from '../api/types';
import { parseQuery } from '../utils/helpers/query'; import { parseQuery } from '../utils/helpers/query';
import { Topics } from '../mercure/helpers/Topics'; import { Topics } from '../mercure/helpers/Topics';
import { ShortUrlDetail } from '../short-urls/reducers/shortUrlDetail'; import { ShortUrlDetail } from '../short-urls/reducers/shortUrlDetail';
import { Settings } from '../settings/reducers/settings';
import { ShortUrlVisits as ShortUrlVisitsState } from './reducers/shortUrlVisits'; import { ShortUrlVisits as ShortUrlVisitsState } from './reducers/shortUrlVisits';
import ShortUrlVisitsHeader from './ShortUrlVisitsHeader'; import ShortUrlVisitsHeader from './ShortUrlVisitsHeader';
import VisitsStats from './VisitsStats'; import VisitsStats from './VisitsStats';
import { VisitsExporter } from './services/VisitsExporter'; import { VisitsExporter } from './services/VisitsExporter';
import { NormalizedVisit } from './types'; import { NormalizedVisit, VisitsParams } from './types';
import { CommonVisitsProps } from './types/CommonVisitsProps';
import { toApiParams } from './types/helpers';
export interface ShortUrlVisitsProps extends RouteComponentProps<{ shortCode: string }> { export interface ShortUrlVisitsProps extends CommonVisitsProps, RouteComponentProps<{ shortCode: string }> {
getShortUrlVisits: (shortCode: string, query?: ShlinkVisitsParams) => void; getShortUrlVisits: (shortCode: string, query?: ShlinkVisitsParams) => void;
shortUrlVisits: ShortUrlVisitsState; shortUrlVisits: ShortUrlVisitsState;
getShortUrlDetail: Function; getShortUrlDetail: Function;
shortUrlDetail: ShortUrlDetail; shortUrlDetail: ShortUrlDetail;
cancelGetShortUrlVisits: () => void; cancelGetShortUrlVisits: () => void;
settings: Settings;
} }
const ShortUrlVisits = ({ exportVisits }: VisitsExporter) => boundToMercureHub(({ const ShortUrlVisits = ({ exportVisits }: VisitsExporter) => boundToMercureHub(({
@ -31,10 +31,11 @@ const ShortUrlVisits = ({ exportVisits }: VisitsExporter) => boundToMercureHub((
getShortUrlDetail, getShortUrlDetail,
cancelGetShortUrlVisits, cancelGetShortUrlVisits,
settings, settings,
selectedServer,
}: ShortUrlVisitsProps) => { }: ShortUrlVisitsProps) => {
const { shortCode } = params; const { shortCode } = params;
const { domain } = parseQuery<{ domain?: string }>(search); const { domain } = parseQuery<{ domain?: string }>(search);
const loadVisits = (params: Partial<ShlinkVisitsParams>) => getShortUrlVisits(shortCode, { ...params, domain }); const loadVisits = (params: VisitsParams) => getShortUrlVisits(shortCode, { ...toApiParams(params), domain });
const exportCsv = (visits: NormalizedVisit[]) => exportVisits( const exportCsv = (visits: NormalizedVisit[]) => exportVisits(
`short-url_${shortUrlDetail.shortUrl?.shortUrl.replace(/https?:\/\//g, '')}_visits.csv`, `short-url_${shortUrlDetail.shortUrl?.shortUrl.replace(/https?:\/\//g, '')}_visits.csv`,
visits, visits,
@ -53,6 +54,7 @@ const ShortUrlVisits = ({ exportVisits }: VisitsExporter) => boundToMercureHub((
domain={domain} domain={domain}
settings={settings} settings={settings}
exportCsv={exportCsv} exportCsv={exportCsv}
selectedServer={selectedServer}
> >
<ShortUrlVisitsHeader shortUrlDetail={shortUrlDetail} shortUrlVisits={shortUrlVisits} goBack={goBack} /> <ShortUrlVisitsHeader shortUrlDetail={shortUrlDetail} shortUrlVisits={shortUrlVisits} goBack={goBack} />
</VisitsStats> </VisitsStats>

View file

@ -1,7 +1,7 @@
import { UncontrolledTooltip } from 'reactstrap'; import { UncontrolledTooltip } from 'reactstrap';
import Moment from 'react-moment';
import { ExternalLink } from 'react-external-link'; import { ExternalLink } from 'react-external-link';
import { ShortUrlDetail } from '../short-urls/reducers/shortUrlDetail'; import { ShortUrlDetail } from '../short-urls/reducers/shortUrlDetail';
import { Time } from '../utils/Time';
import { ShortUrlVisits } from './reducers/shortUrlVisits'; import { ShortUrlVisits } from './reducers/shortUrlVisits';
import VisitsHeader from './VisitsHeader'; import VisitsHeader from './VisitsHeader';
import './ShortUrlVisitsHeader.scss'; import './ShortUrlVisitsHeader.scss';
@ -22,18 +22,14 @@ const ShortUrlVisitsHeader = ({ shortUrlDetail, shortUrlVisits, goBack }: ShortU
const renderDate = () => !shortUrl ? <small>Loading...</small> : ( const renderDate = () => !shortUrl ? <small>Loading...</small> : (
<span> <span>
<b id="created" className="short-url-visits-header__created-at"> <b id="created" className="short-url-visits-header__created-at">
<Moment fromNow>{shortUrl.dateCreated}</Moment> <Time date={shortUrl.dateCreated} relative />
</b> </b>
<UncontrolledTooltip placement="bottom" target="created"> <UncontrolledTooltip placement="bottom" target="created">
<Moment format="YYYY-MM-DD HH:mm">{shortUrl.dateCreated}</Moment> <Time date={shortUrl.dateCreated} />
</UncontrolledTooltip> </UncontrolledTooltip>
</span> </span>
); );
const visitsStatsTitle = ( const visitsStatsTitle = <>Visits for <ExternalLink href={shortLink} /></>;
<>
Visits for <ExternalLink href={shortLink} />
</>
);
return ( return (
<VisitsHeader title={visitsStatsTitle} goBack={goBack} visits={visits} shortUrl={shortUrl}> <VisitsHeader title={visitsStatsTitle} goBack={goBack} visits={visits} shortUrl={shortUrl}>

View file

@ -3,18 +3,18 @@ import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
import ColorGenerator from '../utils/services/ColorGenerator'; import ColorGenerator from '../utils/services/ColorGenerator';
import { ShlinkVisitsParams } from '../api/types'; import { ShlinkVisitsParams } from '../api/types';
import { Topics } from '../mercure/helpers/Topics'; import { Topics } from '../mercure/helpers/Topics';
import { Settings } from '../settings/reducers/settings';
import { TagVisits as TagVisitsState } from './reducers/tagVisits'; import { TagVisits as TagVisitsState } from './reducers/tagVisits';
import TagVisitsHeader from './TagVisitsHeader'; import TagVisitsHeader from './TagVisitsHeader';
import VisitsStats from './VisitsStats'; import VisitsStats from './VisitsStats';
import { VisitsExporter } from './services/VisitsExporter'; import { VisitsExporter } from './services/VisitsExporter';
import { NormalizedVisit } from './types'; import { NormalizedVisit } from './types';
import { CommonVisitsProps } from './types/CommonVisitsProps';
import { toApiParams } from './types/helpers';
export interface TagVisitsProps extends RouteComponentProps<{ tag: string }> { export interface TagVisitsProps extends CommonVisitsProps, RouteComponentProps<{ tag: string }> {
getTagVisits: (tag: string, query: any) => void; getTagVisits: (tag: string, query?: ShlinkVisitsParams) => void;
tagVisits: TagVisitsState; tagVisits: TagVisitsState;
cancelGetTagVisits: () => void; cancelGetTagVisits: () => void;
settings: Settings;
} }
const TagVisits = (colorGenerator: ColorGenerator, { exportVisits }: VisitsExporter) => boundToMercureHub(({ const TagVisits = (colorGenerator: ColorGenerator, { exportVisits }: VisitsExporter) => boundToMercureHub(({
@ -24,9 +24,10 @@ const TagVisits = (colorGenerator: ColorGenerator, { exportVisits }: VisitsExpor
tagVisits, tagVisits,
cancelGetTagVisits, cancelGetTagVisits,
settings, settings,
selectedServer,
}: TagVisitsProps) => { }: TagVisitsProps) => {
const { tag } = params; const { tag } = params;
const loadVisits = (params: ShlinkVisitsParams) => getTagVisits(tag, params); const loadVisits = (params: ShlinkVisitsParams) => getTagVisits(tag, toApiParams(params));
const exportCsv = (visits: NormalizedVisit[]) => exportVisits(`tag_${tag}_visits.csv`, visits); const exportCsv = (visits: NormalizedVisit[]) => exportVisits(`tag_${tag}_visits.csv`, visits);
return ( return (
@ -37,6 +38,7 @@ const TagVisits = (colorGenerator: ColorGenerator, { exportVisits }: VisitsExpor
baseUrl={url} baseUrl={url}
settings={settings} settings={settings}
exportCsv={exportCsv} exportCsv={exportCsv}
selectedServer={selectedServer}
> >
<TagVisitsHeader tagVisits={tagVisits} goBack={goBack} colorGenerator={colorGenerator} /> <TagVisitsHeader tagVisits={tagVisits} goBack={goBack} colorGenerator={colorGenerator} />
</VisitsStats> </VisitsStats>

View file

@ -9,27 +9,28 @@ import { Location } from 'history';
import classNames from 'classnames'; import classNames from 'classnames';
import { DateRangeSelector } from '../utils/dates/DateRangeSelector'; import { DateRangeSelector } from '../utils/dates/DateRangeSelector';
import Message from '../utils/Message'; import Message from '../utils/Message';
import { formatIsoDate } from '../utils/helpers/date';
import { ShlinkVisitsParams } from '../api/types';
import { DateInterval, DateRange, intervalToDateRange } from '../utils/dates/types'; import { DateInterval, DateRange, intervalToDateRange } from '../utils/dates/types';
import { Result } from '../utils/Result'; import { Result } from '../utils/Result';
import { ShlinkApiError } from '../api/ShlinkApiError'; import { ShlinkApiError } from '../api/ShlinkApiError';
import { Settings } from '../settings/reducers/settings'; import { Settings } from '../settings/reducers/settings';
import { SelectedServer } from '../servers/data';
import { supportsBotVisits } from '../utils/helpers/features';
import SortableBarGraph from './helpers/SortableBarGraph'; import SortableBarGraph from './helpers/SortableBarGraph';
import GraphCard from './helpers/GraphCard'; import GraphCard from './helpers/GraphCard';
import LineChartCard from './helpers/LineChartCard'; import LineChartCard from './helpers/LineChartCard';
import VisitsTable from './VisitsTable'; import VisitsTable from './VisitsTable';
import { NormalizedOrphanVisit, NormalizedVisit, OrphanVisitType, VisitsInfo } from './types'; import { NormalizedOrphanVisit, NormalizedVisit, VisitsFilter, VisitsInfo, VisitsParams } from './types';
import OpenMapModalBtn from './helpers/OpenMapModalBtn'; import OpenMapModalBtn from './helpers/OpenMapModalBtn';
import { processStatsFromVisits } from './services/VisitsParser'; import { normalizeVisits, processStatsFromVisits } from './services/VisitsParser';
import { OrphanVisitTypeDropdown } from './helpers/OrphanVisitTypeDropdown'; import { VisitsFilterDropdown } from './helpers/VisitsFilterDropdown';
import { HighlightableProps, highlightedVisitsToStats } from './types/helpers';
import './VisitsStats.scss'; import './VisitsStats.scss';
import { HighlightableProps, highlightedVisitsToStats, normalizeAndFilterVisits } from './types/helpers';
export interface VisitsStatsProps { export interface VisitsStatsProps {
getVisits: (params: Partial<ShlinkVisitsParams>) => void; getVisits: (params: VisitsParams) => void;
visitsInfo: VisitsInfo; visitsInfo: VisitsInfo;
settings: Settings; settings: Settings;
selectedServer: SelectedServer;
cancelGetVisits: () => void; cancelGetVisits: () => void;
baseUrl: string; baseUrl: string;
domain?: string; domain?: string;
@ -67,14 +68,24 @@ const VisitsNavLink: FC<VisitsNavLinkProps & { to: string }> = ({ subPath, title
</NavLink> </NavLink>
); );
const VisitsStats: FC<VisitsStatsProps> = ( const VisitsStats: FC<VisitsStatsProps> = ({
{ children, visitsInfo, getVisits, cancelGetVisits, baseUrl, domain, settings, exportCsv, isOrphanVisits = false }, children,
) => { visitsInfo,
getVisits,
cancelGetVisits,
baseUrl,
domain,
settings,
exportCsv,
selectedServer,
isOrphanVisits = false,
}) => {
const initialInterval: DateInterval = settings.visits?.defaultInterval ?? 'last30Days'; const initialInterval: DateInterval = settings.visits?.defaultInterval ?? 'last30Days';
const [ dateRange, setDateRange ] = useState<DateRange>(intervalToDateRange(initialInterval)); const [ dateRange, setDateRange ] = useState<DateRange>(intervalToDateRange(initialInterval));
const [ highlightedVisits, setHighlightedVisits ] = useState<NormalizedVisit[]>([]); const [ highlightedVisits, setHighlightedVisits ] = useState<NormalizedVisit[]>([]);
const [ highlightedLabel, setHighlightedLabel ] = useState<string | undefined>(); const [ highlightedLabel, setHighlightedLabel ] = useState<string | undefined>();
const [ orphanVisitType, setOrphanVisitType ] = useState<OrphanVisitType | undefined>(); const [ visitsFilter, setVisitsFilter ] = useState<VisitsFilter>({});
const botsSupported = supportsBotVisits(selectedServer);
const buildSectionUrl = (subPath?: string) => { const buildSectionUrl = (subPath?: string) => {
const query = domain ? `?domain=${domain}` : ''; const query = domain ? `?domain=${domain}` : '';
@ -82,10 +93,7 @@ const VisitsStats: FC<VisitsStatsProps> = (
return !subPath ? `${baseUrl}${query}` : `${baseUrl}${subPath}${query}`; return !subPath ? `${baseUrl}${query}` : `${baseUrl}${subPath}${query}`;
}; };
const { visits, loading, loadingLarge, error, errorData, progress } = visitsInfo; const { visits, loading, loadingLarge, error, errorData, progress } = visitsInfo;
const normalizedVisits = useMemo( const normalizedVisits = useMemo(() => normalizeVisits(visits), [ visits ]);
() => normalizeAndFilterVisits(visits, orphanVisitType),
[ visits, orphanVisitType ],
);
const { os, browsers, referrers, countries, cities, citiesForMap, visitedUrls } = useMemo( const { os, browsers, referrers, countries, cities, citiesForMap, visitedUrls } = useMemo(
() => processStatsFromVisits(normalizedVisits), () => processStatsFromVisits(normalizedVisits),
[ normalizedVisits ], [ normalizedVisits ],
@ -112,10 +120,8 @@ const VisitsStats: FC<VisitsStatsProps> = (
useEffect(() => cancelGetVisits, []); useEffect(() => cancelGetVisits, []);
useEffect(() => { useEffect(() => {
const { startDate, endDate } = dateRange; getVisits({ dateRange, filter: visitsFilter });
}, [ dateRange, visitsFilter ]);
getVisits({ startDate: formatIsoDate(startDate) ?? undefined, endDate: formatIsoDate(endDate) ?? undefined });
}, [ dateRange ]);
const renderVisitsContent = () => { const renderVisitsContent = () => {
if (loadingLarge) { if (loadingLarge) {
@ -243,6 +249,7 @@ const VisitsStats: FC<VisitsStatsProps> = (
selectedVisits={highlightedVisits} selectedVisits={highlightedVisits}
setSelectedVisits={setSelectedVisits} setSelectedVisits={setSelectedVisits}
isOrphanVisits={isOrphanVisits} isOrphanVisits={isOrphanVisits}
selectedServer={selectedServer}
/> />
</div> </div>
</Route> </Route>
@ -270,14 +277,13 @@ const VisitsStats: FC<VisitsStatsProps> = (
onDatesChange={setDateRange} onDatesChange={setDateRange}
/> />
</div> </div>
{isOrphanVisits && ( <VisitsFilterDropdown
<OrphanVisitTypeDropdown className="ml-0 ml-md-2 mt-3 mt-md-0"
text="Filter by type" isOrphanVisits={isOrphanVisits}
className="ml-0 ml-md-2 mt-3 mt-md-0" botsSupported={botsSupported}
selected={orphanVisitType} selected={visitsFilter}
onChange={setOrphanVisitType} onChange={setVisitsFilter}
/> />
)}
</div> </div>
</div> </div>
{visits.length > 0 && ( {visits.length > 0 && (

View file

@ -1,29 +1,34 @@
import { useEffect, useMemo, useState, useRef } from 'react'; import { useEffect, useMemo, useState, useRef } from 'react';
import Moment from 'react-moment';
import classNames from 'classnames'; import classNames from 'classnames';
import { min, splitEvery } from 'ramda'; import { min, splitEvery } from 'ramda';
import { import {
faCaretDown as caretDownIcon, faCaretDown as caretDownIcon,
faCaretUp as caretUpIcon, faCaretUp as caretUpIcon,
faCheck as checkIcon, faCheck as checkIcon,
faRobot as botIcon,
} from '@fortawesome/free-solid-svg-icons'; } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { UncontrolledTooltip } from 'reactstrap';
import SimplePaginator from '../common/SimplePaginator'; import SimplePaginator from '../common/SimplePaginator';
import SearchField from '../utils/SearchField'; import SearchField from '../utils/SearchField';
import { determineOrderDir, OrderDir } from '../utils/utils'; import { determineOrderDir, OrderDir } from '../utils/utils';
import { prettify } from '../utils/helpers/numbers'; import { prettify } from '../utils/helpers/numbers';
import { supportsBotVisits } from '../utils/helpers/features';
import { SelectedServer } from '../servers/data';
import { Time } from '../utils/Time';
import { NormalizedOrphanVisit, NormalizedVisit } from './types'; import { NormalizedOrphanVisit, NormalizedVisit } from './types';
import './VisitsTable.scss'; import './VisitsTable.scss';
interface VisitsTableProps { export interface VisitsTableProps {
visits: NormalizedVisit[]; visits: NormalizedVisit[];
selectedVisits?: NormalizedVisit[]; selectedVisits?: NormalizedVisit[];
setSelectedVisits: (visits: NormalizedVisit[]) => void; setSelectedVisits: (visits: NormalizedVisit[]) => void;
matchMedia?: (query: string) => MediaQueryList; matchMedia?: (query: string) => MediaQueryList;
isOrphanVisits?: boolean; isOrphanVisits?: boolean;
selectedServer: SelectedServer;
} }
type OrderableFields = 'date' | 'country' | 'city' | 'browser' | 'os' | 'referer' | 'visitedUrl'; type OrderableFields = 'date' | 'country' | 'city' | 'browser' | 'os' | 'referer' | 'visitedUrl' | 'potentialBot';
interface Order { interface Order {
field?: OrderableFields; field?: OrderableFields;
@ -58,6 +63,7 @@ const VisitsTable = ({
visits, visits,
selectedVisits = [], selectedVisits = [],
setSelectedVisits, setSelectedVisits,
selectedServer,
matchMedia = window.matchMedia, matchMedia = window.matchMedia,
isOrphanVisits = false, isOrphanVisits = false,
}: VisitsTableProps) => { }: VisitsTableProps) => {
@ -69,10 +75,11 @@ const VisitsTable = ({
const [ order, setOrder ] = useState<Order>({ field: undefined, dir: undefined }); const [ order, setOrder ] = useState<Order>({ field: undefined, dir: undefined });
const resultSet = useMemo(() => calculateVisits(visits, searchTerm, order), [ searchTerm, order ]); const resultSet = useMemo(() => calculateVisits(visits, searchTerm, order), [ searchTerm, order ]);
const isFirstLoad = useRef(true); const isFirstLoad = useRef(true);
const [ page, setPage ] = useState(1); const [ page, setPage ] = useState(1);
const end = page * PAGE_SIZE; const end = page * PAGE_SIZE;
const start = end - PAGE_SIZE; const start = end - PAGE_SIZE;
const supportsBots = supportsBotVisits(selectedServer);
const fullSizeColSpan = 7 + Number(supportsBots) + Number(isOrphanVisits);
const orderByColumn = (field: OrderableFields) => const orderByColumn = (field: OrderableFields) =>
() => setOrder({ field, dir: determineOrderDir(field, order.field, order.dir) }); () => setOrder({ field, dir: determineOrderDir(field, order.field, order.dir) });
@ -102,13 +109,19 @@ const VisitsTable = ({
<thead className="visits-table__header"> <thead className="visits-table__header">
<tr> <tr>
<th <th
className="visits-table__header-cell visits-table__sticky text-center" className={`${headerCellsClass} text-center`}
onClick={() => setSelectedVisits( onClick={() => setSelectedVisits(
selectedVisits.length < resultSet.total ? resultSet.visitsGroups.flat() : [], selectedVisits.length < resultSet.total ? resultSet.visitsGroups.flat() : [],
)} )}
> >
<FontAwesomeIcon icon={checkIcon} className={classNames({ 'text-primary': selectedVisits.length > 0 })} /> <FontAwesomeIcon icon={checkIcon} className={classNames({ 'text-primary': selectedVisits.length > 0 })} />
</th> </th>
{supportsBots && (
<th className={`${headerCellsClass} text-center`} onClick={orderByColumn('potentialBot')}>
<FontAwesomeIcon icon={botIcon} />
{renderOrderIcon('potentialBot')}
</th>
)}
<th className={headerCellsClass} onClick={orderByColumn('date')}> <th className={headerCellsClass} onClick={orderByColumn('date')}>
Date Date
{renderOrderIcon('date')} {renderOrderIcon('date')}
@ -141,7 +154,7 @@ const VisitsTable = ({
)} )}
</tr> </tr>
<tr> <tr>
<td colSpan={isOrphanVisits ? 8 : 7} className="p-0"> <td colSpan={fullSizeColSpan} className="p-0">
<SearchField noBorder large={false} onChange={setSearchTerm} /> <SearchField noBorder large={false} onChange={setSearchTerm} />
</td> </td>
</tr> </tr>
@ -149,7 +162,7 @@ const VisitsTable = ({
<tbody> <tbody>
{!resultSet.visitsGroups[page - 1]?.length && ( {!resultSet.visitsGroups[page - 1]?.length && (
<tr> <tr>
<td colSpan={isOrphanVisits ? 8 : 7} className="text-center"> <td colSpan={fullSizeColSpan} className="text-center">
No visits found with current filtering No visits found with current filtering
</td> </td>
</tr> </tr>
@ -169,9 +182,19 @@ const VisitsTable = ({
<td className="text-center"> <td className="text-center">
{isSelected && <FontAwesomeIcon icon={checkIcon} className="text-primary" />} {isSelected && <FontAwesomeIcon icon={checkIcon} className="text-primary" />}
</td> </td>
<td> {supportsBots && (
<Moment format="YYYY-MM-DD HH:mm">{visit.date}</Moment> <td className="text-center">
</td> {visit.potentialBot && (
<>
<FontAwesomeIcon icon={botIcon} id={`botIcon${index}`} />
<UncontrolledTooltip placement="right" target={`botIcon${index}`}>
Potentially a visit from a bot or crawler
</UncontrolledTooltip>
</>
)}
</td>
)}
<td><Time date={visit.date} /></td>
<td>{visit.country}</td> <td>{visit.country}</td>
<td>{visit.city}</td> <td>{visit.city}</td>
<td>{visit.browser}</td> <td>{visit.browser}</td>
@ -185,7 +208,7 @@ const VisitsTable = ({
{resultSet.total > PAGE_SIZE && ( {resultSet.total > PAGE_SIZE && (
<tfoot> <tfoot>
<tr> <tr>
<td colSpan={isOrphanVisits ? 8 : 7} className="visits-table__footer-cell visits-table__sticky"> <td colSpan={fullSizeColSpan} className="visits-table__footer-cell visits-table__sticky">
<div className="row"> <div className="row">
<div className="col-md-6"> <div className="col-md-6">
<SimplePaginator <SimplePaginator

View file

@ -10,7 +10,17 @@ import {
} from 'reactstrap'; } from 'reactstrap';
import { Line } from 'react-chartjs-2'; import { Line } from 'react-chartjs-2';
import { always, cond, countBy, reverse } from 'ramda'; import { always, cond, countBy, reverse } from 'ramda';
import moment from 'moment'; import {
add,
differenceInDays,
differenceInHours,
differenceInMonths,
differenceInWeeks,
parseISO,
format,
startOfISOWeek,
endOfISOWeek,
} from 'date-fns';
import Chart, { ChartData, ChartDataSets, ChartOptions } from 'chart.js'; import Chart, { ChartData, ChartDataSets, ChartOptions } from 'chart.js';
import { NormalizedVisit, Stats } from '../types'; import { NormalizedVisit, Stats } from '../types';
import { fillTheGaps } from '../../utils/helpers/visits'; import { fillTheGaps } from '../../utils/helpers/visits';
@ -39,46 +49,53 @@ const STEPS_MAP: Record<Step, string> = {
hourly: 'Hour', hourly: 'Hour',
}; };
const STEP_TO_DATE_UNIT_MAP: Record<Step, moment.unitOfTime.Diff> = { const STEP_TO_DURATION_MAP: Record<Step, (amount: number) => Duration> = {
hourly: 'hour', hourly: (hours: number) => ({ hours }),
daily: 'day', daily: (days: number) => ({ days }),
weekly: 'week', weekly: (weeks: number) => ({ weeks }),
monthly: 'month', monthly: (months: number) => ({ months }),
}; };
const STEP_TO_DATE_FORMAT: Record<Step, (date: moment.Moment | string) => string> = { const STEP_TO_DIFF_FUNC_MAP: Record<Step, (dateLeft: Date, dateRight: Date) => number> = {
hourly: (date) => moment(date).format('YYYY-MM-DD HH:00'), hourly: differenceInHours,
daily: (date) => moment(date).format('YYYY-MM-DD'), daily: differenceInDays,
weekly: differenceInWeeks,
monthly: differenceInMonths,
};
const STEP_TO_DATE_FORMAT: Record<Step, (date: Date) => string> = {
hourly: (date) => format(date, 'yyyy-MM-dd HH:00'),
daily: (date) => format(date, 'yyyy-MM-dd'),
weekly(date) { weekly(date) {
const firstWeekDay = moment(date).isoWeekday(1).format('YYYY-MM-DD'); const firstWeekDay = format(startOfISOWeek(date), 'yyyy-MM-dd');
const lastWeekDay = moment(date).isoWeekday(7).format('YYYY-MM-DD'); const lastWeekDay = format(endOfISOWeek(date), 'yyyy-MM-dd');
return `${firstWeekDay} - ${lastWeekDay}`; return `${firstWeekDay} - ${lastWeekDay}`;
}, },
monthly: (date) => moment(date).format('YYYY-MM'), monthly: (date) => format(date, 'yyyy-MM'),
}; };
const determineInitialStep = (oldestVisitDate: string): Step => { const determineInitialStep = (oldestVisitDate: string): Step => {
const now = moment(); const now = new Date();
const oldestDate = moment(oldestVisitDate); const oldestDate = parseISO(oldestVisitDate);
const matcher = cond<never, Step | undefined>([ const matcher = cond<never, Step | undefined>([
[ () => now.diff(oldestDate, 'day') <= 2, always<Step>('hourly') ], // Less than 2 days [ () => differenceInDays(now, oldestDate) <= 2, always<Step>('hourly') ], // Less than 2 days
[ () => now.diff(oldestDate, 'month') <= 1, always<Step>('daily') ], // Between 2 days and 1 month [ () => differenceInMonths(now, oldestDate) <= 1, always<Step>('daily') ], // Between 2 days and 1 month
[ () => now.diff(oldestDate, 'month') <= 6, always<Step>('weekly') ], // Between 1 and 6 months [ () => differenceInMonths(now, oldestDate) <= 6, always<Step>('weekly') ], // Between 1 and 6 months
]); ]);
return matcher() ?? 'monthly'; return matcher() ?? 'monthly';
}; };
const groupVisitsByStep = (step: Step, visits: NormalizedVisit[]): Stats => countBy( const groupVisitsByStep = (step: Step, visits: NormalizedVisit[]): Stats => countBy(
(visit) => STEP_TO_DATE_FORMAT[step](visit.date), (visit) => STEP_TO_DATE_FORMAT[step](parseISO(visit.date)),
visits, visits,
); );
const visitsToDatasetGroups = (step: Step, visits: NormalizedVisit[]) => const visitsToDatasetGroups = (step: Step, visits: NormalizedVisit[]) =>
visits.reduce<Record<string, NormalizedVisit[]>>( visits.reduce<Record<string, NormalizedVisit[]>>(
(acc, visit) => { (acc, visit) => {
const key = STEP_TO_DATE_FORMAT[step](visit.date); const key = STEP_TO_DATE_FORMAT[step](parseISO(visit.date));
acc[key] = acc[key] ?? []; acc[key] = acc[key] ?? [];
acc[key].push(visit); acc[key].push(visit);
@ -89,15 +106,16 @@ const visitsToDatasetGroups = (step: Step, visits: NormalizedVisit[]) =>
); );
const generateLabels = (step: Step, visits: NormalizedVisit[]): string[] => { const generateLabels = (step: Step, visits: NormalizedVisit[]): string[] => {
const unit = STEP_TO_DATE_UNIT_MAP[step]; const diffFunc = STEP_TO_DIFF_FUNC_MAP[step];
const formatter = STEP_TO_DATE_FORMAT[step]; const formatter = STEP_TO_DATE_FORMAT[step];
const newerDate = moment(visits[0].date); const newerDate = parseISO(visits[0].date);
const oldestDate = moment(visits[visits.length - 1].date); const oldestDate = parseISO(visits[visits.length - 1].date);
const size = newerDate.diff(oldestDate, unit); const size = diffFunc(newerDate, oldestDate);
const duration = STEP_TO_DURATION_MAP[step];
return [ return [
formatter(oldestDate), formatter(oldestDate),
...rangeOf(size, () => formatter(oldestDate.add(1, unit))), ...rangeOf(size, (num) => formatter(add(oldestDate, duration(num)))),
]; ];
}; };

View file

@ -1,26 +0,0 @@
import { DropdownItem } from 'reactstrap';
import { OrphanVisitType } from '../types';
import { DropdownBtn } from '../../utils/DropdownBtn';
interface OrphanVisitTypeDropdownProps {
onChange: (type: OrphanVisitType | undefined) => void;
selected?: OrphanVisitType | undefined;
className?: string;
text: string;
}
export const OrphanVisitTypeDropdown = ({ onChange, selected, text, className }: OrphanVisitTypeDropdownProps) => (
<DropdownBtn text={text} dropdownClassName={className} className="mr-3" right>
<DropdownItem active={selected === 'base_url'} onClick={() => onChange('base_url')}>
Base URL
</DropdownItem>
<DropdownItem active={selected === 'invalid_short_url'} onClick={() => onChange('invalid_short_url')}>
Invalid short URL
</DropdownItem>
<DropdownItem active={selected === 'regular_404'} onClick={() => onChange('regular_404')}>
Regular 404
</DropdownItem>
<DropdownItem divider />
<DropdownItem onClick={() => onChange(undefined)}><i>Clear selection</i></DropdownItem>
</DropdownBtn>
);

View file

@ -0,0 +1,52 @@
import { DropdownItem, DropdownItemProps } from 'reactstrap'; // eslint-disable-line import/named
import { OrphanVisitType, VisitsFilter } from '../types';
import { DropdownBtn } from '../../utils/DropdownBtn';
import { hasValue } from '../../utils/utils';
interface VisitsFilterDropdownProps {
onChange: (filters: VisitsFilter) => void;
selected?: VisitsFilter;
className?: string;
isOrphanVisits: boolean;
botsSupported: boolean;
}
export const VisitsFilterDropdown = (
{ onChange, selected = {}, className, isOrphanVisits, botsSupported }: VisitsFilterDropdownProps,
) => {
if (!botsSupported && !isOrphanVisits) {
return null;
}
const { orphanVisitsType, excludeBots = false } = selected;
const propsForOrphanVisitsTypeItem = (type: OrphanVisitType): DropdownItemProps => ({
active: orphanVisitsType === type,
onClick: () => onChange({ ...selected, orphanVisitsType: type === selected?.orphanVisitsType ? undefined : type }),
});
const onBotsClick = () => onChange({ ...selected, excludeBots: !selected?.excludeBots });
return (
<DropdownBtn text="Filters" dropdownClassName={className} className="mr-3" right minWidth={250}>
{botsSupported && (
<>
<DropdownItem header>Bots:</DropdownItem>
<DropdownItem active={excludeBots} onClick={onBotsClick}>Exclude potential bots</DropdownItem>
</>
)}
{botsSupported && isOrphanVisits && <DropdownItem divider />}
{isOrphanVisits && (
<>
<DropdownItem header>Orphan visits type:</DropdownItem>
<DropdownItem {...propsForOrphanVisitsTypeItem('base_url')}>Base URL</DropdownItem>
<DropdownItem {...propsForOrphanVisitsTypeItem('invalid_short_url')}>Invalid short URL</DropdownItem>
<DropdownItem {...propsForOrphanVisitsTypeItem('regular_404')}>Regular 404</DropdownItem>
</>
)}
<DropdownItem divider />
<DropdownItem disabled={!hasValue(selected)} onClick={() => onChange({})}><i>Clear filters</i></DropdownItem>
</DropdownBtn>
);
};

View file

@ -1,8 +1,17 @@
import { Action, Dispatch } from 'redux'; import { Action, Dispatch } from 'redux';
import { Visit, VisitsInfo, VisitsLoadFailedAction, VisitsLoadProgressChangedAction } from '../types'; import {
OrphanVisit,
OrphanVisitType,
Visit,
VisitsInfo,
VisitsLoadFailedAction,
VisitsLoadProgressChangedAction,
} from '../types';
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux'; import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
import { GetState } from '../../container/types'; import { GetState } from '../../container/types';
import { ShlinkVisitsParams } from '../../api/types';
import { isOrphanVisit } from '../types/helpers';
import { getVisitsWithLoader } from './common'; import { getVisitsWithLoader } from './common';
import { CREATE_VISITS, CreateVisitsAction } from './visitCreation'; import { CREATE_VISITS, CreateVisitsAction } from './visitCreation';
@ -48,12 +57,20 @@ export default buildReducer<VisitsInfo, OrphanVisitsCombinedAction>({
}, },
}, initialState); }, initialState);
export const getOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => (query = {}) => async ( const matchesType = (visit: OrphanVisit, orphanVisitsType?: OrphanVisitType) =>
dispatch: Dispatch, !orphanVisitsType || orphanVisitsType === visit.type;
getState: GetState,
) => { export const getOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
query: ShlinkVisitsParams = {},
orphanVisitsType?: OrphanVisitType,
) => async (dispatch: Dispatch, getState: GetState) => {
const { getOrphanVisits } = buildShlinkApiClient(getState); const { getOrphanVisits } = buildShlinkApiClient(getState);
const visitsLoader = async (page: number, itemsPerPage: number) => getOrphanVisits({ ...query, page, itemsPerPage }); const visitsLoader = async (page: number, itemsPerPage: number) => getOrphanVisits({ ...query, page, itemsPerPage })
.then((result) => {
const visits = result.data.filter((visit) => isOrphanVisit(visit) && matchesType(visit, orphanVisitsType));
return { ...result, data: visits };
});
const shouldCancel = () => getState().orphanVisits.cancelLoad; const shouldCancel = () => getState().orphanVisits.cancelLoad;
const actionMap = { const actionMap = {
start: GET_ORPHAN_VISITS_START, start: GET_ORPHAN_VISITS_START,

View file

@ -5,7 +5,7 @@ import { ShortUrlIdentifier } from '../../short-urls/data';
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux'; import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
import { GetState } from '../../container/types'; import { GetState } from '../../container/types';
import { OptionalString } from '../../utils/utils'; import { ShlinkVisitsParams } from '../../api/types';
import { getVisitsWithLoader } from './common'; import { getVisitsWithLoader } from './common';
import { CREATE_VISITS, CreateVisitsAction } from './visitCreation'; import { CREATE_VISITS, CreateVisitsAction } from './visitCreation';
@ -64,7 +64,7 @@ export default buildReducer<ShortUrlVisits, ShortUrlVisitsCombinedAction>({
export const getShortUrlVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( export const getShortUrlVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
shortCode: string, shortCode: string,
query: { domain?: OptionalString } = {}, query: ShlinkVisitsParams = {},
) => async (dispatch: Dispatch, getState: GetState) => { ) => async (dispatch: Dispatch, getState: GetState) => {
const { getShortUrlVisits } = buildShlinkApiClient(getState); const { getShortUrlVisits } = buildShlinkApiClient(getState);
const visitsLoader = async (page: number, itemsPerPage: number) => getShortUrlVisits( const visitsLoader = async (page: number, itemsPerPage: number) => getShortUrlVisits(

View file

@ -3,6 +3,7 @@ import { Visit, VisitsInfo, VisitsLoadFailedAction, VisitsLoadProgressChangedAct
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux'; import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
import { GetState } from '../../container/types'; import { GetState } from '../../container/types';
import { ShlinkVisitsParams } from '../../api/types';
import { getVisitsWithLoader } from './common'; import { getVisitsWithLoader } from './common';
import { CREATE_VISITS, CreateVisitsAction } from './visitCreation'; import { CREATE_VISITS, CreateVisitsAction } from './visitCreation';
@ -56,10 +57,10 @@ export default buildReducer<TagVisits, TagsVisitsCombinedAction>({
}, },
}, initialState); }, initialState);
export const getTagVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => (tag: string, query = {}) => async ( export const getTagVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
dispatch: Dispatch, tag: string,
getState: GetState, query: ShlinkVisitsParams = {},
) => { ) => async (dispatch: Dispatch, getState: GetState) => {
const { getTagVisits } = buildShlinkApiClient(getState); const { getTagVisits } = buildShlinkApiClient(getState);
const visitsLoader = async (page: number, itemsPerPage: number) => getTagVisits( const visitsLoader = async (page: number, itemsPerPage: number) => getTagVisits(
tag, tag,

View file

@ -81,9 +81,10 @@ export const processStatsFromVisits = (visits: NormalizedVisit[]) => visits.redu
); );
export const normalizeVisits = map((visit: Visit): NormalizedVisit => { export const normalizeVisits = map((visit: Visit): NormalizedVisit => {
const { userAgent, date, referer, visitLocation } = visit; const { userAgent, date, referer, visitLocation, potentialBot = false } = visit;
const common = { const common = {
date, date,
potentialBot,
...parseUserAgent(userAgent), ...parseUserAgent(userAgent),
referer: extractDomain(referer), referer: extractDomain(referer),
country: visitLocation?.countryName || 'Unknown', // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing country: visitLocation?.countryName || 'Unknown', // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing

View file

@ -18,19 +18,19 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('ShortUrlVisits', ShortUrlVisits, 'VisitsExporter'); bottle.serviceFactory('ShortUrlVisits', ShortUrlVisits, 'VisitsExporter');
bottle.decorator('ShortUrlVisits', connect( bottle.decorator('ShortUrlVisits', connect(
[ 'shortUrlVisits', 'shortUrlDetail', 'mercureInfo', 'settings' ], [ 'shortUrlVisits', 'shortUrlDetail', 'mercureInfo', 'settings', 'selectedServer' ],
[ 'getShortUrlVisits', 'getShortUrlDetail', 'cancelGetShortUrlVisits', 'createNewVisits', 'loadMercureInfo' ], [ 'getShortUrlVisits', 'getShortUrlDetail', 'cancelGetShortUrlVisits', 'createNewVisits', 'loadMercureInfo' ],
)); ));
bottle.serviceFactory('TagVisits', TagVisits, 'ColorGenerator', 'VisitsExporter'); bottle.serviceFactory('TagVisits', TagVisits, 'ColorGenerator', 'VisitsExporter');
bottle.decorator('TagVisits', connect( bottle.decorator('TagVisits', connect(
[ 'tagVisits', 'mercureInfo', 'settings' ], [ 'tagVisits', 'mercureInfo', 'settings', 'selectedServer' ],
[ 'getTagVisits', 'cancelGetTagVisits', 'createNewVisits', 'loadMercureInfo' ], [ 'getTagVisits', 'cancelGetTagVisits', 'createNewVisits', 'loadMercureInfo' ],
)); ));
bottle.serviceFactory('OrphanVisits', OrphanVisits, 'VisitsExporter'); bottle.serviceFactory('OrphanVisits', OrphanVisits, 'VisitsExporter');
bottle.decorator('OrphanVisits', connect( bottle.decorator('OrphanVisits', connect(
[ 'orphanVisits', 'mercureInfo', 'settings' ], [ 'orphanVisits', 'mercureInfo', 'settings', 'selectedServer' ],
[ 'getOrphanVisits', 'cancelGetOrphanVisits', 'createNewVisits', 'loadMercureInfo' ], [ 'getOrphanVisits', 'cancelGetOrphanVisits', 'createNewVisits', 'loadMercureInfo' ],
)); ));

View file

@ -0,0 +1,7 @@
import { SelectedServer } from '../../servers/data';
import { Settings } from '../../settings/reducers/settings';
export interface CommonVisitsProps {
selectedServer: SelectedServer;
settings: Settings;
}

View file

@ -1,14 +1,7 @@
import { countBy, filter, groupBy, pipe, prop } from 'ramda'; import { countBy, groupBy, pipe, prop } from 'ramda';
import { normalizeVisits } from '../services/VisitsParser'; import { formatIsoDate } from '../../utils/helpers/date';
import { import { ShlinkVisitsParams } from '../../api/types';
Visit, import { CreateVisit, NormalizedOrphanVisit, NormalizedVisit, OrphanVisit, Stats, Visit, VisitsParams } from './index';
OrphanVisit,
CreateVisit,
NormalizedVisit,
NormalizedOrphanVisit,
Stats,
OrphanVisitType,
} from './index';
export const isOrphanVisit = (visit: Visit): visit is OrphanVisit => visit.hasOwnProperty('visitedUrl'); export const isOrphanVisit = (visit: Visit): visit is OrphanVisit => visit.hasOwnProperty('visitedUrl');
@ -35,7 +28,10 @@ export const highlightedVisitsToStats = <T extends NormalizedVisit>(
property: HighlightableProps<T>, property: HighlightableProps<T>,
): Stats => countBy(prop(property) as any, highlightedVisits); ): Stats => countBy(prop(property) as any, highlightedVisits);
export const normalizeAndFilterVisits = (visits: Visit[], type: OrphanVisitType | undefined) => pipe( export const toApiParams = ({ page, itemsPerPage, filter, dateRange }: VisitsParams): ShlinkVisitsParams => {
normalizeVisits, const startDate = (dateRange?.startDate && formatIsoDate(dateRange?.startDate)) ?? undefined;
filter((normalizedVisit) => type === undefined || (normalizedVisit as NormalizedOrphanVisit).type === type), const endDate = (dateRange?.endDate && formatIsoDate(dateRange?.endDate)) ?? undefined;
)(visits); const excludeBots = filter?.excludeBots || undefined; // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing
return { page, itemsPerPage, startDate, endDate, excludeBots };
};

View file

@ -1,6 +1,7 @@
import { Action } from 'redux'; import { Action } from 'redux';
import { ShortUrl } from '../../short-urls/data'; import { ShortUrl } from '../../short-urls/data';
import { ProblemDetailsError } from '../../api/types'; import { ProblemDetailsError } from '../../api/types';
import { DateRange } from '../../utils/dates/types';
export interface VisitsInfo { export interface VisitsInfo {
visits: Visit[]; visits: Visit[];
@ -38,6 +39,7 @@ export interface RegularVisit {
date: string; date: string;
userAgent: string; userAgent: string;
visitLocation: VisitLocation | null; visitLocation: VisitLocation | null;
potentialBot?: boolean; // Optional only when using Shlink older than v2.7
} }
export interface OrphanVisit extends RegularVisit { export interface OrphanVisit extends RegularVisit {
@ -59,6 +61,7 @@ export interface NormalizedRegularVisit extends UserAgent {
city: string; city: string;
latitude?: number | null; latitude?: number | null;
longitude?: number | null; longitude?: number | null;
potentialBot: boolean;
} }
export interface NormalizedOrphanVisit extends NormalizedRegularVisit { export interface NormalizedOrphanVisit extends NormalizedRegularVisit {
@ -92,3 +95,15 @@ export interface VisitsStats {
citiesForMap: Record<string, CityStats>; citiesForMap: Record<string, CityStats>;
visitedUrls: Stats; visitedUrls: Stats;
} }
export interface VisitsFilter {
orphanVisitsType?: OrphanVisitType | undefined;
excludeBots?: boolean;
}
export interface VisitsParams {
page?: number;
itemsPerPage?: number;
dateRange?: DateRange;
filter?: VisitsFilter;
}

View file

@ -1,9 +1,9 @@
import { shallow, ShallowWrapper } from 'enzyme'; import { shallow, ShallowWrapper } from 'enzyme';
import { Route } from 'react-router-dom'; import { Route } from 'react-router-dom';
import { Mock } from 'ts-mockery'; import { Mock } from 'ts-mockery';
import { Alert } from 'reactstrap';
import { Settings } from '../src/settings/reducers/settings'; import { Settings } from '../src/settings/reducers/settings';
import appFactory from '../src/App'; import appFactory from '../src/App';
import { AppUpdateBanner } from '../src/common/AppUpdateBanner';
describe('<App />', () => { describe('<App />', () => {
let wrapper: ShallowWrapper; let wrapper: ShallowWrapper;
@ -29,7 +29,7 @@ describe('<App />', () => {
it('renders versions', () => expect(wrapper.find(ShlinkVersions)).toHaveLength(1)); it('renders versions', () => expect(wrapper.find(ShlinkVersions)).toHaveLength(1));
it('renders an Alert', () => expect(wrapper.find(Alert)).toHaveLength(1)); it('renders an update banner', () => expect(wrapper.find(AppUpdateBanner)).toHaveLength(1));
it('renders app main routes', () => { it('renders app main routes', () => {
const routes = wrapper.find(Route); const routes = wrapper.find(Route);

View file

@ -0,0 +1,43 @@
import { shallow, ShallowWrapper } from 'enzyme';
import { Button } from 'reactstrap';
import { AppUpdateBanner } from '../../src/common/AppUpdateBanner';
import { SimpleCard } from '../../src/utils/SimpleCard';
describe('<AppUpdateBanner />', () => {
const toggle = jest.fn();
const forceUpdate = jest.fn();
let wrapper: ShallowWrapper;
beforeEach(() => {
wrapper = shallow(<AppUpdateBanner isOpen={true} toggle={toggle} forceUpdate={forceUpdate} />);
});
afterEach(jest.clearAllMocks);
afterEach(() => wrapper?.unmount());
it('renders an alert with expected props', () => {
expect(wrapper.prop('className')).toEqual('app-update-banner');
expect(wrapper.prop('isOpen')).toEqual(true);
expect(wrapper.prop('toggle')).toEqual(toggle);
expect(wrapper.prop('tag')).toEqual(SimpleCard);
expect(wrapper.prop('color')).toEqual('secondary');
});
it('invokes toggle when alert is toggled', () => {
(wrapper.prop('toggle') as Function)(); // eslint-disable-line @typescript-eslint/no-unnecessary-type-assertion
expect(toggle).toHaveBeenCalled();
});
it('triggers the update when clicking the button', () => {
expect(wrapper.find(Button).html()).toContain('Restart now');
expect(wrapper.find(Button).prop('disabled')).toEqual(false);
expect(forceUpdate).not.toHaveBeenCalled();
wrapper.find(Button).simulate('click');
expect(wrapper.find(Button).html()).toContain('Restarting...');
expect(wrapper.find(Button).prop('disabled')).toEqual(true);
expect(forceUpdate).toHaveBeenCalled();
});
});

View file

@ -43,6 +43,6 @@ describe('<ServersDropdown />', () => {
expect(item).toHaveLength(1); expect(item).toHaveLength(1);
expect(item.prop('to')).toEqual('/server/create'); expect(item.prop('to')).toEqual('/server/create');
expect(item.find('span').text()).toContain('Add server'); expect(item.find('span').text()).toContain('Add a server');
}); });
}); });

View file

@ -14,7 +14,7 @@ describe('<EditShortUrl />', () => {
const ShortUrlForm = () => null; const ShortUrlForm = () => null;
const goBack = jest.fn(); const goBack = jest.fn();
const getShortUrlDetail = jest.fn(); const getShortUrlDetail = jest.fn();
const editShortUrl = jest.fn(); const editShortUrl = jest.fn(async () => Promise.resolve());
const shortUrlCreation = { validateUrls: true }; const shortUrlCreation = { validateUrls: true };
const createWrapper = (detail: Partial<ShortUrlDetail> = {}, edition: Partial<ShortUrlEdition> = {}) => { const createWrapper = (detail: Partial<ShortUrlDetail> = {}, edition: Partial<ShortUrlEdition> = {}) => {
const EditSHortUrl = createEditShortUrl(ShortUrlForm); const EditSHortUrl = createEditShortUrl(ShortUrlForm);

View file

@ -1,5 +1,5 @@
import { shallow, ShallowWrapper } from 'enzyme'; import { shallow, ShallowWrapper } from 'enzyme';
import moment from 'moment'; import { formatISO } from 'date-fns';
import { identity } from 'ramda'; import { identity } from 'ramda';
import { Mock } from 'ts-mockery'; import { Mock } from 'ts-mockery';
import { Input } from 'reactstrap'; import { Input } from 'reactstrap';
@ -8,11 +8,12 @@ import DateInput from '../../src/utils/DateInput';
import { ShortUrlData } from '../../src/short-urls/data'; import { ShortUrlData } from '../../src/short-urls/data';
import { ReachableServer, SelectedServer } from '../../src/servers/data'; import { ReachableServer, SelectedServer } from '../../src/servers/data';
import { SimpleCard } from '../../src/utils/SimpleCard'; import { SimpleCard } from '../../src/utils/SimpleCard';
import { parseDate } from '../../src/utils/helpers/date';
describe('<ShortUrlForm />', () => { describe('<ShortUrlForm />', () => {
let wrapper: ShallowWrapper; let wrapper: ShallowWrapper;
const TagsSelector = () => null; const TagsSelector = () => null;
const createShortUrl = jest.fn(); const createShortUrl = jest.fn(async () => Promise.resolve());
const createWrapper = (selectedServer: SelectedServer = null, mode: Mode = 'create') => { const createWrapper = (selectedServer: SelectedServer = null, mode: Mode = 'create') => {
const ShortUrlForm = createShortUrlForm(TagsSelector, () => null); const ShortUrlForm = createShortUrlForm(TagsSelector, () => null);
@ -34,8 +35,8 @@ describe('<ShortUrlForm />', () => {
it('saves short URL with data set in form controls', () => { it('saves short URL with data set in form controls', () => {
const wrapper = createWrapper(); const wrapper = createWrapper();
const validSince = moment('2017-01-01'); const validSince = parseDate('2017-01-01', 'yyyy-MM-dd');
const validUntil = moment('2017-01-06'); const validUntil = parseDate('2017-01-06', 'yyyy-MM-dd');
wrapper.find(Input).first().simulate('change', { target: { value: 'https://long-domain.com/foo/bar' } }); wrapper.find(Input).first().simulate('change', { target: { value: 'https://long-domain.com/foo/bar' } });
wrapper.find('TagsSelector').simulate('change', [ 'tag_foo', 'tag_bar' ]); wrapper.find('TagsSelector').simulate('change', [ 'tag_foo', 'tag_bar' ]);
@ -53,8 +54,8 @@ describe('<ShortUrlForm />', () => {
tags: [ 'tag_foo', 'tag_bar' ], tags: [ 'tag_foo', 'tag_bar' ],
customSlug: 'my-slug', customSlug: 'my-slug',
domain: 'example.com', domain: 'example.com',
validSince: validSince.format(), validSince: formatISO(validSince),
validUntil: validUntil.format(), validUntil: formatISO(validUntil),
maxVisits: 20, maxVisits: 20,
findIfExists: false, findIfExists: false,
shortCodeLength: 15, shortCodeLength: 15,

View file

@ -0,0 +1,16 @@
import { shallow } from 'enzyme';
import { ShortUrlFormCheckboxGroup } from '../../../src/short-urls/helpers/ShortUrlFormCheckboxGroup';
import Checkbox from '../../../src/utils/Checkbox';
describe('<ShortUrlFormCheckboxGroup />', () => {
test.each([
[ undefined, '', 0 ],
[ 'This is the tooltip', 'mr-2', 1 ],
])('renders tooltip only when provided', (infoTooltip, expectedClassName, expectedAmountOfTooltips) => {
const wrapper = shallow(<ShortUrlFormCheckboxGroup infoTooltip={infoTooltip} />);
const checkbox = wrapper.find(Checkbox);
expect(checkbox.prop('className')).toEqual(expectedClassName);
expect(wrapper.find('InfoTooltip')).toHaveLength(expectedAmountOfTooltips);
});
});

View file

@ -1,9 +1,8 @@
import { shallow, ShallowWrapper } from 'enzyme'; import { shallow, ShallowWrapper } from 'enzyme';
import moment from 'moment';
import Moment from 'react-moment';
import { assoc, toString } from 'ramda'; import { assoc, toString } from 'ramda';
import { Mock } from 'ts-mockery'; import { Mock } from 'ts-mockery';
import { ExternalLink } from 'react-external-link'; import { ExternalLink } from 'react-external-link';
import { formatISO } from 'date-fns';
import createShortUrlsRow from '../../../src/short-urls/helpers/ShortUrlsRow'; import createShortUrlsRow from '../../../src/short-urls/helpers/ShortUrlsRow';
import Tag from '../../../src/tags/helpers/Tag'; import Tag from '../../../src/tags/helpers/Tag';
import ColorGenerator from '../../../src/utils/services/ColorGenerator'; import ColorGenerator from '../../../src/utils/services/ColorGenerator';
@ -11,6 +10,8 @@ import { StateFlagTimeout } from '../../../src/utils/helpers/hooks';
import { ShortUrl } from '../../../src/short-urls/data'; import { ShortUrl } from '../../../src/short-urls/data';
import { ReachableServer } from '../../../src/servers/data'; import { ReachableServer } from '../../../src/servers/data';
import { CopyToClipboardIcon } from '../../../src/utils/CopyToClipboardIcon'; import { CopyToClipboardIcon } from '../../../src/utils/CopyToClipboardIcon';
import { Time } from '../../../src/utils/Time';
import { parseDate } from '../../../src/utils/helpers/date';
describe('<ShortUrlsRow />', () => { describe('<ShortUrlsRow />', () => {
let wrapper: ShallowWrapper; let wrapper: ShallowWrapper;
@ -27,7 +28,7 @@ describe('<ShortUrlsRow />', () => {
shortCode: 'abc123', shortCode: 'abc123',
shortUrl: 'http://doma.in/abc123', shortUrl: 'http://doma.in/abc123',
longUrl: 'http://foo.com/bar', longUrl: 'http://foo.com/bar',
dateCreated: moment('2018-05-23 18:30:41').format(), dateCreated: formatISO(parseDate('2018-05-23 18:30:41', 'yyyy-MM-dd HH:mm:ss')),
tags: [ 'nodejs', 'reactjs' ], tags: [ 'nodejs', 'reactjs' ],
visitsCount: 45, visitsCount: 45,
domain: null, domain: null,
@ -62,9 +63,9 @@ describe('<ShortUrlsRow />', () => {
it('renders date in first column', () => { it('renders date in first column', () => {
const col = wrapper.find('td').first(); const col = wrapper.find('td').first();
const moment = col.find(Moment); const date = col.find(Time);
expect(moment.html()).toContain('>2018-05-23 18:30</time>'); expect(date.html()).toContain('>2018-05-23 18:30</time>');
}); });
it('renders short URL in second row', () => { it('renders short URL in second row', () => {

View file

@ -0,0 +1,66 @@
import { shallow, ShallowWrapper } from 'enzyme';
import { Mock } from 'ts-mockery';
import createTagsSelector from '../../../src/tags/helpers/TagsSelector';
import ColorGenerator from '../../../src/utils/services/ColorGenerator';
import { TagsList } from '../../../src/tags/reducers/tagsList';
describe('<TagsSelector />', () => {
const onChange = jest.fn();
const TagsSelector = createTagsSelector(Mock.all<ColorGenerator>());
const tags = [ 'foo', 'bar' ];
const tagsList = Mock.of<TagsList>({ tags: [ ...tags, 'baz' ] });
let wrapper: ShallowWrapper;
beforeEach(jest.clearAllMocks);
beforeEach(() => {
wrapper = shallow(
<TagsSelector selectedTags={tags} tagsList={tagsList} listTags={jest.fn()} onChange={onChange} />,
);
});
afterEach(() => wrapper?.unmount());
it('has expected props', () => {
expect(wrapper.prop('placeholderText')).toEqual('Add tags to the URL');
expect(wrapper.prop('allowNew')).toEqual(true);
expect(wrapper.prop('addOnBlur')).toEqual(true);
expect(wrapper.prop('minQueryLength')).toEqual(1);
});
it('contains expected tags', () => {
expect(wrapper.prop('tags')).toEqual([
{
id: 'foo',
name: 'foo',
},
{
id: 'bar',
name: 'bar',
},
]);
});
it('contains expected suggestions', () => {
expect(wrapper.prop('suggestions')).toEqual([
{
id: 'baz',
name: 'baz',
},
]);
});
it('invokes onChange when new tags are added', () => {
wrapper.simulate('addition', { name: 'The-New-Tag' });
expect(onChange).toHaveBeenCalledWith([ ...tags, 'the-new-tag' ]);
});
it.each([
[ 0, 'bar' ],
[ 1, 'foo' ],
])('invokes onChange when tags are deleted', (index, expected) => {
wrapper.simulate('delete', index);
expect(onChange).toHaveBeenCalledWith([ expected ]);
});
});

View file

@ -1,6 +1,5 @@
import { shallow, ShallowWrapper } from 'enzyme'; import { shallow, ShallowWrapper } from 'enzyme';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import moment from 'moment';
import { Mock } from 'ts-mockery'; import { Mock } from 'ts-mockery';
import DateInput, { DateInputProps } from '../../src/utils/DateInput'; import DateInput, { DateInputProps } from '../../src/utils/DateInput';
@ -30,7 +29,7 @@ describe('<DateInput />', () => {
}); });
it('does not show calendar icon when input is clearable', () => { it('does not show calendar icon when input is clearable', () => {
wrapped = createComponent({ isClearable: true, selected: moment() }); wrapped = createComponent({ isClearable: true, selected: new Date() });
expect(wrapped.find(FontAwesomeIcon)).toHaveLength(0); expect(wrapped.find(FontAwesomeIcon)).toHaveLength(0);
}); });
}); });

View file

@ -38,4 +38,15 @@ describe('<DropdownBtn />', () => {
expect(toggle.prop('className')?.trim()).toEqual(expectedClasses); expect(toggle.prop('className')?.trim()).toEqual(expectedClasses);
}); });
it.each([
[ 100, { minWidth: '100px' }],
[ 250, { minWidth: '250px' }],
[ undefined, {}],
])('renders proper styles when minWidth is provided', (minWidth, expectedStyle) => {
const wrapper = createWrapper({ text: '', minWidth });
const style = wrapper.find(DropdownMenu).prop('style');
expect(style).toEqual(expectedStyle);
});
}); });

30
test/utils/Time.test.tsx Normal file
View file

@ -0,0 +1,30 @@
import { shallow, ShallowWrapper } from 'enzyme';
import { DateProps, Time } from '../../src/utils/Time';
import { parseDate } from '../../src/utils/helpers/date';
describe('<Time />', () => {
let wrapper: ShallowWrapper;
const createWrapper = (props: DateProps) => {
wrapper = shallow(<Time {...props} />);
return wrapper;
};
afterEach(() => wrapper?.unmount());
it.each([
[{ date: parseDate('2020-05-05', 'yyyy-MM-dd') }, '1588636800000', '2020-05-05 00:00' ],
[{ date: parseDate('2021-03-20', 'yyyy-MM-dd'), format: 'dd/MM/yyyy' }, '1616198400000', '20/03/2021' ],
])('includes expected dateTime and format', (props, expectedDateTime, expectedFormatted) => {
const wrapper = createWrapper(props);
expect(wrapper.prop('dateTime')).toEqual(expectedDateTime);
expect(wrapper.prop('children')).toEqual(expectedFormatted);
});
it('renders relative times when requested', () => {
const wrapper = createWrapper({ date: new Date(), relative: true });
expect(wrapper.prop('children')).toContain(' ago');
});
});

View file

@ -1,6 +1,5 @@
import { shallow, ShallowWrapper } from 'enzyme'; import { shallow, ShallowWrapper } from 'enzyme';
import { DropdownItem } from 'reactstrap'; import { DropdownItem } from 'reactstrap';
import moment from 'moment';
import { Mock } from 'ts-mockery'; import { Mock } from 'ts-mockery';
import { DateRangeSelector, DateRangeSelectorProps } from '../../../src/utils/dates/DateRangeSelector'; import { DateRangeSelector, DateRangeSelectorProps } from '../../../src/utils/dates/DateRangeSelector';
import { DateInterval } from '../../../src/utils/dates/types'; import { DateInterval } from '../../../src/utils/dates/types';
@ -40,7 +39,7 @@ describe('<DateRangeSelector />', () => {
[ 'last90Days' as DateInterval, 0, 1 ], [ 'last90Days' as DateInterval, 0, 1 ],
[ 'last180days' as DateInterval, 0, 1 ], [ 'last180days' as DateInterval, 0, 1 ],
[ 'last365Days' as DateInterval, 0, 1 ], [ 'last365Days' as DateInterval, 0, 1 ],
[{ startDate: moment() }, 0, 0 ], [{ startDate: new Date() }, 0, 0 ],
])('sets proper element as active based on provided date range', ( ])('sets proper element as active based on provided date range', (
initialDateRange, initialDateRange,
expectedActiveItems, expectedActiveItems,

View file

@ -1,4 +1,4 @@
import moment from 'moment'; import { format, subDays } from 'date-fns';
import { import {
DateInterval, DateInterval,
dateRangeIsEmpty, dateRangeIsEmpty,
@ -6,6 +6,7 @@ import {
rangeIsInterval, rangeIsInterval,
rangeOrIntervalToString, rangeOrIntervalToString,
} from '../../../../src/utils/dates/types'; } from '../../../../src/utils/dates/types';
import { parseDate } from '../../../../src/utils/helpers/date';
describe('date-types', () => { describe('date-types', () => {
describe('dateRangeIsEmpty', () => { describe('dateRangeIsEmpty', () => {
@ -20,9 +21,9 @@ describe('date-types', () => {
[{ startDate: undefined, endDate: undefined }, true ], [{ startDate: undefined, endDate: undefined }, true ],
[{ startDate: undefined, endDate: null }, true ], [{ startDate: undefined, endDate: null }, true ],
[{ startDate: null, endDate: undefined }, true ], [{ startDate: null, endDate: undefined }, true ],
[{ startDate: moment() }, false ], [{ startDate: new Date() }, false ],
[{ endDate: moment() }, false ], [{ endDate: new Date() }, false ],
[{ startDate: moment(), endDate: moment() }, false ], [{ startDate: new Date(), endDate: new Date() }, false ],
])('proper result is returned', (dateRange, expectedResult) => { ])('proper result is returned', (dateRange, expectedResult) => {
expect(dateRangeIsEmpty(dateRange)).toEqual(expectedResult); expect(dateRangeIsEmpty(dateRange)).toEqual(expectedResult);
}); });
@ -58,31 +59,36 @@ describe('date-types', () => {
[{ startDate: undefined, endDate: undefined }, undefined ], [{ startDate: undefined, endDate: undefined }, undefined ],
[{ startDate: undefined, endDate: null }, undefined ], [{ startDate: undefined, endDate: null }, undefined ],
[{ startDate: null, endDate: undefined }, undefined ], [{ startDate: null, endDate: undefined }, undefined ],
[{ startDate: moment('2020-01-01') }, 'Since 2020-01-01' ], [{ startDate: parseDate('2020-01-01', 'yyyy-MM-dd') }, 'Since 2020-01-01' ],
[{ endDate: moment('2020-01-01') }, 'Until 2020-01-01' ], [{ endDate: parseDate('2020-01-01', 'yyyy-MM-dd') }, 'Until 2020-01-01' ],
[{ startDate: moment('2020-01-01'), endDate: moment('2021-02-02') }, '2020-01-01 - 2021-02-02' ], [
{ startDate: parseDate('2020-01-01', 'yyyy-MM-dd'), endDate: parseDate('2021-02-02', 'yyyy-MM-dd') },
'2020-01-01 - 2021-02-02',
],
])('proper result is returned', (range, expectedValue) => { ])('proper result is returned', (range, expectedValue) => {
expect(rangeOrIntervalToString(range)).toEqual(expectedValue); expect(rangeOrIntervalToString(range)).toEqual(expectedValue);
}); });
}); });
describe('intervalToDateRange', () => { describe('intervalToDateRange', () => {
const now = () => moment(); const now = () => new Date();
const daysBack = (days: number) => subDays(new Date(), days);
const formatted = (date?: Date | null): string | undefined => !date ? undefined : format(date, 'yyyy-MM-dd');
test.each([ test.each([
[ undefined, undefined, undefined ], [ undefined, undefined, undefined ],
[ 'today' as DateInterval, now(), now() ], [ 'today' as DateInterval, now(), now() ],
[ 'yesterday' as DateInterval, now().subtract(1, 'day'), now().subtract(1, 'day') ], [ 'yesterday' as DateInterval, daysBack(1), daysBack(1) ],
[ 'last7Days' as DateInterval, now().subtract(7, 'day'), now() ], [ 'last7Days' as DateInterval, daysBack(7), now() ],
[ 'last30Days' as DateInterval, now().subtract(30, 'day'), now() ], [ 'last30Days' as DateInterval, daysBack(30), now() ],
[ 'last90Days' as DateInterval, now().subtract(90, 'day'), now() ], [ 'last90Days' as DateInterval, daysBack(90), now() ],
[ 'last180days' as DateInterval, now().subtract(180, 'day'), now() ], [ 'last180days' as DateInterval, daysBack(180), now() ],
[ 'last365Days' as DateInterval, now().subtract(365, 'day'), now() ], [ 'last365Days' as DateInterval, daysBack(365), now() ],
])('proper result is returned', (interval, expectedStartDate, expectedEndDate) => { ])('proper result is returned', (interval, expectedStartDate, expectedEndDate) => {
const { startDate, endDate } = intervalToDateRange(interval); const { startDate, endDate } = intervalToDateRange(interval);
expect(expectedStartDate?.format('YYYY-MM-DD')).toEqual(startDate?.format('YYYY-MM-DD')); expect(formatted(expectedStartDate)).toEqual(formatted(startDate));
expect(expectedEndDate?.format('YYYY-MM-DD')).toEqual(endDate?.format('YYYY-MM-DD')); expect(formatted(expectedEndDate)).toEqual(formatted(endDate));
}); });
}); });
}); });

View file

@ -1,13 +1,13 @@
import moment from 'moment'; import { formatISO } from 'date-fns';
import { formatDate, formatIsoDate } from '../../../src/utils/helpers/date'; import { formatDate, formatIsoDate, parseDate } from '../../../src/utils/helpers/date';
describe('date', () => { describe('date', () => {
describe('formatDate', () => { describe('formatDate', () => {
it.each([ it.each([
[ moment('2020-03-05 10:00:10'), 'DD/MM/YYYY', '05/03/2020' ], [ parseDate('2020-03-05 10:00:10', 'yyyy-MM-dd HH:mm:ss'), 'dd/MM/yyyy', '05/03/2020' ],
[ moment('2020-03-05 10:00:10'), 'YYYY-MM', '2020-03' ], [ parseDate('2020-03-05 10:00:10', 'yyyy-MM-dd HH:mm:ss'), 'yyyy-MM', '2020-03' ],
[ moment('2020-03-05 10:00:10'), undefined, '2020-03-05' ], [ parseDate('2020-03-05 10:00:10', 'yyyy-MM-dd HH:mm:ss'), undefined, '2020-03-05' ],
[ '2020-03-05 10:00:10', 'DD-MM-YYYY', '2020-03-05 10:00:10' ], [ '2020-03-05 10:00:10', 'dd-MM-yyyy', '2020-03-05 10:00:10' ],
[ '2020-03-05 10:00:10', undefined, '2020-03-05 10:00:10' ], [ '2020-03-05 10:00:10', undefined, '2020-03-05 10:00:10' ],
[ undefined, undefined, undefined ], [ undefined, undefined, undefined ],
[ null, undefined, null ], [ null, undefined, null ],
@ -18,7 +18,10 @@ describe('date', () => {
describe('formatIsoDate', () => { describe('formatIsoDate', () => {
it.each([ it.each([
[ moment('2020-03-05 10:00:10'), moment('2020-03-05 10:00:10').format() ], [
parseDate('2020-03-05 10:00:10', 'yyyy-MM-dd HH:mm:ss'),
formatISO(parseDate('2020-03-05 10:00:10', 'yyyy-MM-dd HH:mm:ss')),
],
[ '2020-03-05 10:00:10', '2020-03-05 10:00:10' ], [ '2020-03-05 10:00:10', '2020-03-05 10:00:10' ],
[ 'foo', 'foo' ], [ 'foo', 'foo' ],
[ undefined, undefined ], [ undefined, undefined ],

View file

@ -9,6 +9,7 @@ import VisitsStats from '../../src/visits/VisitsStats';
import { OrphanVisitsHeader } from '../../src/visits/OrphanVisitsHeader'; import { OrphanVisitsHeader } from '../../src/visits/OrphanVisitsHeader';
import { Settings } from '../../src/settings/reducers/settings'; import { Settings } from '../../src/settings/reducers/settings';
import { VisitsExporter } from '../../src/visits/services/VisitsExporter'; import { VisitsExporter } from '../../src/visits/services/VisitsExporter';
import { SelectedServer } from '../../src/servers/data';
describe('<OrphanVisits />', () => { describe('<OrphanVisits />', () => {
it('wraps visits stats and header', () => { it('wraps visits stats and header', () => {
@ -28,6 +29,7 @@ describe('<OrphanVisits />', () => {
location={Mock.all<Location>()} location={Mock.all<Location>()}
match={Mock.of<match>({ url: 'the_base_url' })} match={Mock.of<match>({ url: 'the_base_url' })}
settings={Mock.all<Settings>()} settings={Mock.all<Settings>()}
selectedServer={Mock.all<SelectedServer>()}
/>, />,
).dive(); ).dive();
const stats = wrapper.find(VisitsStats); const stats = wrapper.find(VisitsStats);
@ -35,7 +37,6 @@ describe('<OrphanVisits />', () => {
expect(stats).toHaveLength(1); expect(stats).toHaveLength(1);
expect(header).toHaveLength(1); expect(header).toHaveLength(1);
expect(stats.prop('getVisits')).toEqual(getOrphanVisits);
expect(stats.prop('cancelGetVisits')).toEqual(cancelGetOrphanVisits); expect(stats.prop('cancelGetVisits')).toEqual(cancelGetOrphanVisits);
expect(stats.prop('visitsInfo')).toEqual(orphanVisits); expect(stats.prop('visitsInfo')).toEqual(orphanVisits);
expect(stats.prop('baseUrl')).toEqual('the_base_url'); expect(stats.prop('baseUrl')).toEqual('the_base_url');

View file

@ -1,10 +1,10 @@
import { shallow, ShallowWrapper } from 'enzyme'; import { shallow, ShallowWrapper } from 'enzyme';
import Moment from 'react-moment';
import { ExternalLink } from 'react-external-link'; import { ExternalLink } from 'react-external-link';
import { Mock } from 'ts-mockery'; import { Mock } from 'ts-mockery';
import ShortUrlVisitsHeader from '../../src/visits/ShortUrlVisitsHeader'; import ShortUrlVisitsHeader from '../../src/visits/ShortUrlVisitsHeader';
import { ShortUrlDetail } from '../../src/short-urls/reducers/shortUrlDetail'; import { ShortUrlDetail } from '../../src/short-urls/reducers/shortUrlDetail';
import { ShortUrlVisits } from '../../src/visits/reducers/shortUrlVisits'; import { ShortUrlVisits } from '../../src/visits/reducers/shortUrlVisits';
import { Time } from '../../src/utils/Time';
describe('<ShortUrlVisitsHeader />', () => { describe('<ShortUrlVisitsHeader />', () => {
let wrapper: ShallowWrapper; let wrapper: ShallowWrapper;
@ -36,9 +36,9 @@ describe('<ShortUrlVisitsHeader />', () => {
afterEach(() => wrapper.unmount()); afterEach(() => wrapper.unmount());
it('shows when the URL was created', () => { it('shows when the URL was created', () => {
const moment = wrapper.find(Moment).first(); const time = wrapper.find(Time).first();
expect(moment.prop('children')).toEqual(dateCreated); expect(time.prop('date')).toEqual(dateCreated);
}); });
it.each([ it.each([

View file

@ -10,6 +10,7 @@ import LineChartCard from '../../src/visits/helpers/LineChartCard';
import VisitsTable from '../../src/visits/VisitsTable'; import VisitsTable from '../../src/visits/VisitsTable';
import { Result } from '../../src/utils/Result'; import { Result } from '../../src/utils/Result';
import { Settings } from '../../src/settings/reducers/settings'; import { Settings } from '../../src/settings/reducers/settings';
import { SelectedServer } from '../../src/servers/data';
describe('<VisitStats />', () => { describe('<VisitStats />', () => {
const visits = [ Mock.all<Visit>(), Mock.all<Visit>(), Mock.all<Visit>() ]; const visits = [ Mock.all<Visit>(), Mock.all<Visit>(), Mock.all<Visit>() ];
@ -27,6 +28,7 @@ describe('<VisitStats />', () => {
baseUrl={''} baseUrl={''}
settings={Mock.all<Settings>()} settings={Mock.all<Settings>()}
exportCsv={exportCsv} exportCsv={exportCsv}
selectedServer={Mock.all<SelectedServer>()}
/>, />,
); );

View file

@ -1,43 +1,62 @@
import { shallow, ShallowWrapper } from 'enzyme'; import { shallow, ShallowWrapper } from 'enzyme';
import { Mock } from 'ts-mockery'; import { Mock } from 'ts-mockery';
import VisitsTable from '../../src/visits/VisitsTable'; import VisitsTable, { VisitsTableProps } from '../../src/visits/VisitsTable';
import { rangeOf } from '../../src/utils/utils'; import { rangeOf } from '../../src/utils/utils';
import SimplePaginator from '../../src/common/SimplePaginator'; import SimplePaginator from '../../src/common/SimplePaginator';
import SearchField from '../../src/utils/SearchField'; import SearchField from '../../src/utils/SearchField';
import { NormalizedVisit } from '../../src/visits/types'; import { NormalizedVisit } from '../../src/visits/types';
import { ReachableServer, SelectedServer } from '../../src/servers/data';
import { SemVer } from '../../src/utils/helpers/version';
describe('<VisitsTable />', () => { describe('<VisitsTable />', () => {
const matchMedia = () => Mock.of<MediaQueryList>({ matches: false }); const matchMedia = () => Mock.of<MediaQueryList>({ matches: false });
const setSelectedVisits = jest.fn(); const setSelectedVisits = jest.fn();
let wrapper: ShallowWrapper; let wrapper: ShallowWrapper;
const createWrapper = (visits: NormalizedVisit[], selectedVisits: NormalizedVisit[] = [], isOrphanVisits = false) => { const wrapperFactory = (props: Partial<VisitsTableProps> = {}) => {
wrapper = shallow( wrapper = shallow(
<VisitsTable <VisitsTable
visits={visits} visits={[]}
selectedVisits={selectedVisits} selectedServer={Mock.all<SelectedServer>()}
setSelectedVisits={setSelectedVisits} {...props}
matchMedia={matchMedia} matchMedia={matchMedia}
isOrphanVisits={isOrphanVisits} setSelectedVisits={setSelectedVisits}
/>, />,
); );
return wrapper; return wrapper;
}; };
const createWrapper = (visits: NormalizedVisit[], selectedVisits: NormalizedVisit[] = []) => wrapperFactory(
{ visits, selectedVisits },
);
const createOrphanVisitsWrapper = (isOrphanVisits: boolean, version: SemVer) => wrapperFactory({
isOrphanVisits,
selectedServer: Mock.of<ReachableServer>({ printableVersion: version, version }),
});
const createServerVersionWrapper = (version: SemVer) => wrapperFactory({
selectedServer: Mock.of<ReachableServer>({ printableVersion: version, version }),
});
const createWrapperWithBots = () => wrapperFactory({
selectedServer: Mock.of<ReachableServer>({ printableVersion: '2.7.0', version: '2.7.0' }),
visits: [
Mock.of<NormalizedVisit>({ potentialBot: false }),
Mock.of<NormalizedVisit>({ potentialBot: true }),
],
});
afterEach(jest.resetAllMocks); afterEach(jest.resetAllMocks);
afterEach(() => wrapper?.unmount()); afterEach(() => wrapper?.unmount());
it('renders columns as expected', () => { it.each([
const wrapper = createWrapper([]); [ '2.6.0' as SemVer, [ 'Date', 'Country', 'City', 'Browser', 'OS', 'Referrer' ]],
[ '2.7.0' as SemVer, [ 'fa-robot', 'Date', 'Country', 'City', 'Browser', 'OS', 'Referrer' ]],
])('renders columns as expected', (version, expectedColumns) => {
const wrapper = createServerVersionWrapper(version);
const th = wrapper.find('thead').find('th'); const th = wrapper.find('thead').find('th');
expect(th).toHaveLength(7); expect(th).toHaveLength(expectedColumns.length + 1);
expect(th.at(1).text()).toContain('Date'); expectedColumns.forEach((column, index) => {
expect(th.at(2).text()).toContain('Country'); expect(th.at(index + 1).html()).toContain(column);
expect(th.at(3).text()).toContain('City'); });
expect(th.at(4).text()).toContain('Browser');
expect(th.at(5).text()).toContain('OS');
expect(th.at(6).text()).toContain('Referrer');
}); });
it('shows warning when no visits are found', () => { it('shows warning when no visits are found', () => {
@ -137,10 +156,12 @@ describe('<VisitsTable />', () => {
}); });
it.each([ it.each([
[ true, 8 ], [ true, '2.6.0' as SemVer, 8 ],
[ false, 7 ], [ false, '2.6.0' as SemVer, 7 ],
])('displays proper amount of columns for orphan and non-orphan visits', (isOrphanVisits, expectedCols) => { [ true, '2.7.0' as SemVer, 9 ],
const wrapper = createWrapper([], [], isOrphanVisits); [ false, '2.7.0' as SemVer, 8 ],
])('displays proper amount of columns for orphan and non-orphan visits', (isOrphanVisits, version, expectedCols) => {
const wrapper = createOrphanVisitsWrapper(isOrphanVisits, version);
const rowsWithColspan = wrapper.find('[colSpan]'); const rowsWithColspan = wrapper.find('[colSpan]');
const cols = wrapper.find('th'); const cols = wrapper.find('th');
@ -148,4 +169,12 @@ describe('<VisitsTable />', () => {
expect(rowsWithColspan).toHaveLength(2); expect(rowsWithColspan).toHaveLength(2);
rowsWithColspan.forEach((row) => expect(row.prop('colSpan')).toEqual(expectedCols)); rowsWithColspan.forEach((row) => expect(row.prop('colSpan')).toEqual(expectedCols));
}); });
it('displays bots icon when a visit is a potential bot', () => {
const wrapper = createWrapperWithBots();
const rows = wrapper.find('tbody').find('tr');
expect(rows.at(0).find('td').at(1).text()).not.toContain('FontAwesomeIcon');
expect(rows.at(1).find('td').at(1).text()).toContain('FontAwesomeIcon');
});
}); });

View file

@ -1,7 +1,7 @@
import { shallow, ShallowWrapper } from 'enzyme'; import { shallow, ShallowWrapper } from 'enzyme';
import { CardHeader, DropdownItem } from 'reactstrap'; import { CardHeader, DropdownItem } from 'reactstrap';
import { Line } from 'react-chartjs-2'; import { Line } from 'react-chartjs-2';
import moment from 'moment'; import { formatISO, subDays, subMonths, subYears } from 'date-fns';
import { Mock } from 'ts-mockery'; import { Mock } from 'ts-mockery';
import LineChartCard from '../../../src/visits/helpers/LineChartCard'; import LineChartCard from '../../../src/visits/helpers/LineChartCard';
import ToggleSwitch from '../../../src/utils/ToggleSwitch'; import ToggleSwitch from '../../../src/utils/ToggleSwitch';
@ -27,12 +27,12 @@ describe('<LineChartCard />', () => {
it.each([ it.each([
[[], 'monthly' ], [[], 'monthly' ],
[[{ date: moment().subtract(1, 'day').format() }], 'hourly' ], [[{ date: formatISO(subDays(new Date(), 1)) }], 'hourly' ],
[[{ date: moment().subtract(3, 'day').format() }], 'daily' ], [[{ date: formatISO(subDays(new Date(), 3)) }], 'daily' ],
[[{ date: moment().subtract(2, 'month').format() }], 'weekly' ], [[{ date: formatISO(subMonths(new Date(), 2)) }], 'weekly' ],
[[{ date: moment().subtract(6, 'month').format() }], 'weekly' ], [[{ date: formatISO(subMonths(new Date(), 6)) }], 'weekly' ],
[[{ date: moment().subtract(7, 'month').format() }], 'monthly' ], [[{ date: formatISO(subMonths(new Date(), 7)) }], 'monthly' ],
[[{ date: moment().subtract(1, 'year').format() }], 'monthly' ], [[{ date: formatISO(subYears(new Date(), 1)) }], 'monthly' ],
])('renders group menu and selects proper grouping item based on visits dates', (visits, expectedActiveItem) => { ])('renders group menu and selects proper grouping item based on visits dates', (visits, expectedActiveItem) => {
const wrapper = createWrapper(visits.map((visit) => Mock.of<NormalizedVisit>(visit))); const wrapper = createWrapper(visits.map((visit) => Mock.of<NormalizedVisit>(visit)));
const items = wrapper.find(DropdownItem); const items = wrapper.find(DropdownItem);
@ -75,8 +75,8 @@ describe('<LineChartCard />', () => {
}); });
it.each([ it.each([
[[ Mock.of<NormalizedVisit>({}) ], [], 1 ], [[ Mock.of<NormalizedVisit>({ date: '2016-04-01' }) ], [], 1 ],
[[ Mock.of<NormalizedVisit>({}) ], [ Mock.of<NormalizedVisit>({}) ], 2 ], [[ Mock.of<NormalizedVisit>({ date: '2016-04-01' }) ], [ Mock.of<NormalizedVisit>({ date: '2016-04-01' }) ], 2 ],
])('renders chart with expected data', (visits, highlightedVisits, expectedLines) => { ])('renders chart with expected data', (visits, highlightedVisits, expectedLines) => {
const wrapper = createWrapper(visits, highlightedVisits); const wrapper = createWrapper(visits, highlightedVisits);
const chart = wrapper.find(Line); const chart = wrapper.find(Line);

View file

@ -1,56 +0,0 @@
import { shallow, ShallowWrapper } from 'enzyme';
import { DropdownItem } from 'reactstrap';
import { OrphanVisitType } from '../../../src/visits/types';
import { OrphanVisitTypeDropdown } from '../../../src/visits/helpers/OrphanVisitTypeDropdown';
describe('<OrphanVisitTypeDropdown />', () => {
let wrapper: ShallowWrapper;
const onChange = jest.fn();
const createWrapper = (selected?: OrphanVisitType) => {
wrapper = shallow(<OrphanVisitTypeDropdown text="The text" selected={selected} onChange={onChange} />);
return wrapper;
};
beforeEach(jest.clearAllMocks);
afterEach(() => wrapper?.unmount());
it('has provided text', () => {
const wrapper = createWrapper();
expect(wrapper.prop('text')).toEqual('The text');
});
it.each([
[ 'base_url' as OrphanVisitType, 0, 1 ],
[ 'invalid_short_url' as OrphanVisitType, 1, 1 ],
[ 'regular_404' as OrphanVisitType, 2, 1 ],
[ undefined, -1, 0 ],
])('sets expected item as active', (selected, expectedSelectedIndex, expectedActiveItems) => {
const wrapper = createWrapper(selected);
const items = wrapper.find(DropdownItem);
const activeItem = items.filterWhere((item) => !!item.prop('active'));
expect.assertions(expectedActiveItems + 1);
expect(activeItem).toHaveLength(expectedActiveItems);
items.forEach((item, index) => {
if (item.prop('active')) {
expect(index).toEqual(expectedSelectedIndex);
}
});
});
it.each([
[ 0, 'base_url' ],
[ 1, 'invalid_short_url' ],
[ 2, 'regular_404' ],
[ 4, undefined ],
])('invokes onChange with proper type when an item is clicked', (index, expectedType) => {
const wrapper = createWrapper();
const itemToClick = wrapper.find(DropdownItem).at(index);
itemToClick.simulate('click');
expect(onChange).toHaveBeenCalledWith(expectedType);
});
});

View file

@ -0,0 +1,89 @@
import { shallow, ShallowWrapper } from 'enzyme';
import { DropdownItem } from 'reactstrap';
import { OrphanVisitType, VisitsFilter } from '../../../src/visits/types';
import { VisitsFilterDropdown } from '../../../src/visits/helpers/VisitsFilterDropdown';
describe('<VisitsFilterDropdown />', () => {
let wrapper: ShallowWrapper;
const onChange = jest.fn();
const createWrapper = (selected: VisitsFilter = {}, isOrphanVisits = true) => {
wrapper = shallow(
<VisitsFilterDropdown
isOrphanVisits={isOrphanVisits}
botsSupported={true}
selected={selected}
onChange={onChange}
/>,
);
return wrapper;
};
beforeEach(jest.clearAllMocks);
afterEach(() => wrapper?.unmount());
it('has expected text', () => {
const wrapper = createWrapper();
expect(wrapper.prop('text')).toEqual('Filters');
});
it.each([
[ false, 4, 1 ],
[ true, 9, 2 ],
])('renders expected amount of items', (isOrphanVisits, expectedItemsAmount, expectedHeadersAmount) => {
const wrapper = createWrapper({}, isOrphanVisits);
const items = wrapper.find(DropdownItem);
const headers = items.filterWhere((item) => !!item.prop('header'));
expect(items).toHaveLength(expectedItemsAmount);
expect(headers).toHaveLength(expectedHeadersAmount);
});
it.each([
[ 'base_url' as OrphanVisitType, 4, 1 ],
[ 'invalid_short_url' as OrphanVisitType, 5, 1 ],
[ 'regular_404' as OrphanVisitType, 6, 1 ],
[ undefined, -1, 0 ],
])('sets expected item as active', (orphanVisitsType, expectedSelectedIndex, expectedActiveItems) => {
const wrapper = createWrapper({ orphanVisitsType });
const items = wrapper.find(DropdownItem);
const activeItem = items.filterWhere((item) => !!item.prop('active'));
expect.assertions(expectedActiveItems + 1);
expect(activeItem).toHaveLength(expectedActiveItems);
items.forEach((item, index) => {
if (item.prop('active')) {
expect(index).toEqual(expectedSelectedIndex);
}
});
});
it.each([
[ 1, { excludeBots: true }],
[ 4, { orphanVisitsType: 'base_url' }],
[ 5, { orphanVisitsType: 'invalid_short_url' }],
[ 6, { orphanVisitsType: 'regular_404' }],
[ 8, {}],
])('invokes onChange with proper selection when an item is clicked', (index, expectedSelection) => {
const wrapper = createWrapper();
const itemToClick = wrapper.find(DropdownItem).at(index);
itemToClick.simulate('click');
expect(onChange).toHaveBeenCalledWith(expectedSelection);
});
it('does not render the component when neither orphan visits or bots filtering will be displayed', () => {
const wrapper = shallow(
<VisitsFilterDropdown
isOrphanVisits={false}
botsSupported={false}
selected={{}}
onChange={onChange}
/>,
);
expect(wrapper.text()).toEqual('');
});
});

View file

@ -110,9 +110,9 @@ describe('orphanVisitsReducer', () => {
[ undefined ], [ undefined ],
[{}], [{}],
])('dispatches start and success when promise is resolved', async (query) => { ])('dispatches start and success when promise is resolved', async (query) => {
const visits = visitsMocks; const visits = visitsMocks.map((visit) => ({ ...visit, visitedUrl: '' }));
const ShlinkApiClient = buildApiClientMock(Promise.resolve({ const ShlinkApiClient = buildApiClientMock(Promise.resolve({
data: visitsMocks, data: visits,
pagination: { pagination: {
currentPage: 1, currentPage: 1,
pagesCount: 1, pagesCount: 1,

View file

@ -39,6 +39,7 @@ describe('VisitsExporter', () => {
longitude: 0, longitude: 0,
os: 'os', os: 'os',
referer: 'referer', referer: 'referer',
potentialBot: false,
}, },
]; ];

View file

@ -43,6 +43,7 @@ describe('VisitsParser', () => {
}), }),
Mock.of<Visit>({ Mock.of<Visit>({
userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.106 Safari/537.36 OPR/38.0.2220.41', userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.106 Safari/537.36 OPR/38.0.2220.41',
potentialBot: true,
}), }),
]; ];
const orphanVisits: OrphanVisit[] = [ const orphanVisits: OrphanVisit[] = [
@ -61,6 +62,7 @@ describe('VisitsParser', () => {
Mock.of<OrphanVisit>({ Mock.of<OrphanVisit>({
type: 'regular_404', type: 'regular_404',
visitedUrl: 'bar', visitedUrl: 'bar',
potentialBot: true,
}), }),
Mock.of<OrphanVisit>({ Mock.of<OrphanVisit>({
type: 'invalid_short_url', type: 'invalid_short_url',
@ -73,6 +75,7 @@ describe('VisitsParser', () => {
latitude: 123.45, latitude: 123.45,
longitude: -543.21, longitude: -543.21,
}, },
potentialBot: false,
}), }),
]; ];
@ -176,6 +179,7 @@ describe('VisitsParser', () => {
date: undefined, date: undefined,
latitude: 123.45, latitude: 123.45,
longitude: -543.21, longitude: -543.21,
potentialBot: false,
}, },
{ {
browser: 'Firefox', browser: 'Firefox',
@ -186,6 +190,7 @@ describe('VisitsParser', () => {
date: undefined, date: undefined,
latitude: 1029, latitude: 1029,
longitude: 6758, longitude: 6758,
potentialBot: false,
}, },
{ {
browser: 'Chrome', browser: 'Chrome',
@ -196,6 +201,7 @@ describe('VisitsParser', () => {
date: undefined, date: undefined,
latitude: undefined, latitude: undefined,
longitude: undefined, longitude: undefined,
potentialBot: false,
}, },
{ {
browser: 'Chrome', browser: 'Chrome',
@ -206,6 +212,7 @@ describe('VisitsParser', () => {
date: undefined, date: undefined,
latitude: 123.45, latitude: 123.45,
longitude: -543.21, longitude: -543.21,
potentialBot: false,
}, },
{ {
browser: 'Opera', browser: 'Opera',
@ -216,6 +223,7 @@ describe('VisitsParser', () => {
date: undefined, date: undefined,
latitude: undefined, latitude: undefined,
longitude: undefined, longitude: undefined,
potentialBot: true,
}, },
]); ]);
}); });
@ -233,6 +241,7 @@ describe('VisitsParser', () => {
longitude: 6758, longitude: 6758,
type: 'base_url', type: 'base_url',
visitedUrl: 'foo', visitedUrl: 'foo',
potentialBot: false,
}, },
{ {
type: 'regular_404', type: 'regular_404',
@ -245,6 +254,7 @@ describe('VisitsParser', () => {
longitude: undefined, longitude: undefined,
os: 'Others', os: 'Others',
referer: 'Direct', referer: 'Direct',
potentialBot: true,
}, },
{ {
browser: 'Chrome', browser: 'Chrome',
@ -257,6 +267,7 @@ describe('VisitsParser', () => {
longitude: -543.21, longitude: -543.21,
type: 'invalid_short_url', type: 'invalid_short_url',
visitedUrl: 'bar', visitedUrl: 'bar',
potentialBot: false,
}, },
]); ]);
}); });