mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2026-03-01 13:16:42 +00:00
Compare commits
106 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bfd38d861 | ||
|
|
27b6676edc | ||
|
|
66c91722fc | ||
|
|
178f15b7d3 | ||
|
|
0e47f9b502 | ||
|
|
d2ad1cd54b | ||
|
|
91e003153b | ||
|
|
c6cca9c91f | ||
|
|
7330fd85ff | ||
|
|
b61d863356 | ||
|
|
fa64c950ca | ||
|
|
0e4667e59c | ||
|
|
56d9dcf562 | ||
|
|
d5e8f81076 | ||
|
|
69905c4b38 | ||
|
|
08694d7693 | ||
|
|
8045fa8886 | ||
|
|
0789494a40 | ||
|
|
34837f2917 | ||
|
|
9e8c743d53 | ||
|
|
239cc4ab84 | ||
|
|
b3e79f4219 | ||
|
|
7c11a6d1ab | ||
|
|
635ee6c5eb | ||
|
|
f79bd39de7 | ||
|
|
5c6979122d | ||
|
|
402efac12e | ||
|
|
770ba624c2 | ||
|
|
d4236b914d | ||
|
|
2cc92b5b41 | ||
|
|
f0598ba47f | ||
|
|
66c5c7ebf1 | ||
|
|
741bc21a55 | ||
|
|
fb1ced5e3f | ||
|
|
3999d14bab | ||
|
|
99c77622cd | ||
|
|
bc5c25deb0 | ||
|
|
0275908f69 | ||
|
|
4be1a295d8 | ||
|
|
ee65c0c050 | ||
|
|
d718329b52 | ||
|
|
55716a8f7f | ||
|
|
5ef719c592 | ||
|
|
3a57416525 | ||
|
|
5bd57e71fd | ||
|
|
c4ed838510 | ||
|
|
affe2309b0 | ||
|
|
638ce89780 | ||
|
|
a0ab9533cb | ||
|
|
7b80948eea | ||
|
|
1cf96c7212 | ||
|
|
151175dc70 | ||
|
|
a30376344e | ||
|
|
db0c43dcdd | ||
|
|
a3550f8e52 | ||
|
|
3a3babadeb | ||
|
|
e22ad2c822 | ||
|
|
342dda3ec9 | ||
|
|
b7af07c043 | ||
|
|
6b338275d3 | ||
|
|
a72d3b2720 | ||
|
|
18042dba6e | ||
|
|
6e09d1372f | ||
|
|
ce02d29ca3 | ||
|
|
e193c700d6 | ||
|
|
bfeb282aa9 | ||
|
|
5caa648112 | ||
|
|
4546b74b6f | ||
|
|
2fb5507803 | ||
|
|
93329c5a12 | ||
|
|
5a91b668dc | ||
|
|
66aac4771c | ||
|
|
ce04b8eb58 | ||
|
|
e0c20c704e | ||
|
|
d5fadc56af | ||
|
|
bbc3342c00 | ||
|
|
76ebbd318a | ||
|
|
24801b068b | ||
|
|
4c21ad0a89 | ||
|
|
f626f9b046 | ||
|
|
ccffa0fe12 | ||
|
|
d5530b4614 | ||
|
|
7c327099bb | ||
|
|
577d7e79da | ||
|
|
31736fad1e | ||
|
|
6319a81ddb | ||
|
|
0ca6ff6906 | ||
|
|
eb69165781 | ||
|
|
4e3d311bef | ||
|
|
54b7aeed20 | ||
|
|
2ba8db1fd3 | ||
|
|
f74270a767 | ||
|
|
9a245fbf13 | ||
|
|
f16e9565e2 | ||
|
|
e65f9a7b89 | ||
|
|
0141a1e0ed | ||
|
|
937876ce67 | ||
|
|
b52120e0d3 | ||
|
|
62b65334b5 | ||
|
|
76dae535d9 | ||
|
|
23ba140ff4 | ||
|
|
76ff7d81b9 | ||
|
|
66deba29f5 | ||
|
|
e44527e9c9 | ||
|
|
aec629b95c | ||
|
|
fa4664e583 |
@@ -13,5 +13,6 @@
|
|||||||
"globals": {
|
"globals": {
|
||||||
"process": true,
|
"process": true,
|
||||||
"setImmediate": true
|
"setImmediate": true
|
||||||
}
|
},
|
||||||
|
"ignorePatterns": ["src/service*.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
41
.github/workflows/deploy-preview.yml
vendored
Normal file
41
.github/workflows/deploy-preview.yml
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
name: Deploy preview
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target: null
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-20.04
|
||||||
|
continue-on-error: true
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
with:
|
||||||
|
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||||
|
ref: ${{ github.event.pull_request.head.ref }}
|
||||||
|
- name: Use node.js 14.15
|
||||||
|
uses: actions/setup-node@v1
|
||||||
|
with:
|
||||||
|
node-version: 14.15
|
||||||
|
- name: Generate slug
|
||||||
|
id: generate_slug
|
||||||
|
run: echo "##[set-output name=slug;]$(echo ${GITHUB_HEAD_REF#refs/heads/} | sed -r 's/[~\^]+//g' | sed -r 's/[^a-zA-Z0-9]+/-/g' | sed -r 's/^-+\|-+$//g' | tr A-Z a-z)"
|
||||||
|
- name: Build
|
||||||
|
run: |
|
||||||
|
npm ci && \
|
||||||
|
node ./scripts/set-homepage.js /shlink-web-client/${{ steps.generate_slug.outputs.slug }} && \
|
||||||
|
rm src/service-worker.ts && \
|
||||||
|
npm run build
|
||||||
|
- name: Deploy
|
||||||
|
uses: JamesIves/github-pages-deploy-action@4.1.1
|
||||||
|
with:
|
||||||
|
branch: preview-env
|
||||||
|
folder: build
|
||||||
|
target-folder: ${{ steps.generate_slug.outputs.slug }}
|
||||||
|
- name: Publish env
|
||||||
|
uses: marocchino/sticky-pull-request-comment@v2
|
||||||
|
with:
|
||||||
|
header: Preview environment
|
||||||
|
message: |
|
||||||
|
## Preview environment
|
||||||
|
https://shlinkio.github.io/shlink-web-client/${{ steps.generate_slug.outputs.slug }}/
|
||||||
2
.github/workflows/docker-image-build.yml
vendored
2
.github/workflows/docker-image-build.yml
vendored
@@ -3,7 +3,7 @@ name: Build docker image
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- develop
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
|
|
||||||
|
|||||||
1
.github/workflows/publish-release.yml
vendored
1
.github/workflows/publish-release.yml
vendored
@@ -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: |
|
||||||
|
|||||||
86
CHANGELOG.md
86
CHANGELOG.md
@@ -4,6 +4,92 @@ 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.1] - 2021-09-12
|
||||||
|
### Added
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
* [#478](https://github.com/shlinkio/shlink-web-client/pull/478) Fixed tags including special chars not being properly URL encoded before using them as query params.
|
||||||
|
* [#480](https://github.com/shlinkio/shlink-web-client/pull/480) Fixed servers import on Chromium-based browsers when using windows.
|
||||||
|
* [#482](https://github.com/shlinkio/shlink-web-client/pull/480) Fixed end date not being set to the end of the day when filtering visits using a "smart filter" (last 7 days, last 30 days, etc).
|
||||||
|
|
||||||
|
|
||||||
|
## [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
|
||||||
|
### Added
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
* [#428](https://github.com/shlinkio/shlink-web-client/issues/428) Updated to StrykerJS 5.
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
* [#371](https://github.com/shlinkio/shlink-web-client/issues/371) Recovered PWA functionality.
|
||||||
|
|
||||||
|
|
||||||
|
## [3.1.1] - 2021-05-08
|
||||||
|
### Added
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
* [#413](https://github.com/shlinkio/shlink-web-client/issues/413) Fixed edit short URL form reflecting outdated info after navigating back from other section.
|
||||||
|
* [#412](https://github.com/shlinkio/shlink-web-client/issues/412) Ensured new visits coming from mercure hub are prepended and not appended, to keep proper sorting.
|
||||||
|
* [#417](https://github.com/shlinkio/shlink-web-client/issues/417) Fixed link spanning out of QR code modal.
|
||||||
|
* [#411](https://github.com/shlinkio/shlink-web-client/issues/411) Added missing feedback when editing a short URL to know if everything went right.
|
||||||
|
|
||||||
|
|
||||||
## [3.1.0] - 2021-03-29
|
## [3.1.0] - 2021-03-29
|
||||||
### Added
|
### Added
|
||||||
* [#379](https://github.com/shlinkio/shlink-web-client/issues/379) and [#384](https://github.com/shlinkio/shlink-web-client/issues/384) Improved QR code modal, including controls to customize size, format and margin, as well as a button to copy the link to the clipboard.
|
* [#379](https://github.com/shlinkio/shlink-web-client/issues/379) and [#384](https://github.com/shlinkio/shlink-web-client/issues/384) Improved QR code modal, including controls to customize size, format and margin, as well as a button to copy the link to the clipboard.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
19
README.md
19
README.md
@@ -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.**
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ module.exports = {
|
|||||||
appNodeModules: resolveApp('node_modules'),
|
appNodeModules: resolveApp('node_modules'),
|
||||||
publicUrl: getPublicUrl(resolveApp('package.json')),
|
publicUrl: getPublicUrl(resolveApp('package.json')),
|
||||||
servedPath: getServedPath(resolveApp('package.json')),
|
servedPath: getServedPath(resolveApp('package.json')),
|
||||||
|
swSrc: resolveModule(resolveApp, 'src/service-worker'),
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports.moduleFileExtensions = moduleFileExtensions;
|
module.exports.moduleFileExtensions = moduleFileExtensions;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
|
|||||||
const safePostCssParser = require('postcss-safe-parser');
|
const safePostCssParser = require('postcss-safe-parser');
|
||||||
const ManifestPlugin = require('webpack-manifest-plugin');
|
const ManifestPlugin = require('webpack-manifest-plugin');
|
||||||
const InterpolateHtmlPlugin = require('react-dev-utils/InterpolateHtmlPlugin');
|
const InterpolateHtmlPlugin = require('react-dev-utils/InterpolateHtmlPlugin');
|
||||||
|
const WorkboxWebpackPlugin = require('workbox-webpack-plugin');
|
||||||
const WatchMissingNodeModulesPlugin = require('react-dev-utils/WatchMissingNodeModulesPlugin');
|
const WatchMissingNodeModulesPlugin = require('react-dev-utils/WatchMissingNodeModulesPlugin');
|
||||||
const ModuleScopePlugin = require('react-dev-utils/ModuleScopePlugin');
|
const ModuleScopePlugin = require('react-dev-utils/ModuleScopePlugin');
|
||||||
const getCSSModuleLocalIdent = require('react-dev-utils/getCSSModuleLocalIdent');
|
const getCSSModuleLocalIdent = require('react-dev-utils/getCSSModuleLocalIdent');
|
||||||
@@ -32,6 +33,9 @@ const shouldInlineRuntimeChunk = process.env.INLINE_RUNTIME_CHUNK !== 'false';
|
|||||||
// Check if TypeScript is setup
|
// Check if TypeScript is setup
|
||||||
const useTypeScript = fs.existsSync(paths.appTsConfig);
|
const useTypeScript = fs.existsSync(paths.appTsConfig);
|
||||||
|
|
||||||
|
// Get the path to the uncompiled service worker (if it exists).
|
||||||
|
const swSrc = paths.swSrc;
|
||||||
|
|
||||||
// style files regexes
|
// style files regexes
|
||||||
const cssRegex = /\.css$/;
|
const cssRegex = /\.css$/;
|
||||||
const cssModuleRegex = /\.module\.css$/;
|
const cssModuleRegex = /\.module\.css$/;
|
||||||
@@ -610,6 +614,18 @@ module.exports = (webpackEnv) => {
|
|||||||
// You can remove this if you don't use Moment.js:
|
// You can remove this if you don't use Moment.js:
|
||||||
new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
|
new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
|
||||||
|
|
||||||
|
// Generate a service worker script that will precache, and keep up to date,
|
||||||
|
// the HTML & assets that are part of the webpack build.
|
||||||
|
isEnvProduction && fs.existsSync(swSrc) && new WorkboxWebpackPlugin.InjectManifest({
|
||||||
|
swSrc,
|
||||||
|
dontCacheBustURLsMatching: /\.[0-9a-f]{8}\./,
|
||||||
|
exclude: [ /\.map$/, /asset-manifest\.json$/, /LICENSE/ ],
|
||||||
|
// Bump up the default maximum size (2mb) that's precached,
|
||||||
|
// to make lazy-loading failure scenarios less likely.
|
||||||
|
// See https://github.com/cra-template/pwa/issues/13#issuecomment-722667270
|
||||||
|
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
|
||||||
|
}),
|
||||||
|
|
||||||
// TypeScript type checking
|
// TypeScript type checking
|
||||||
useTypeScript &&
|
useTypeScript &&
|
||||||
new ForkTsCheckerWebpackPlugin({
|
new ForkTsCheckerWebpackPlugin({
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
1549
package-lock.json
generated
1549
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
28
package.json
28
package.json
@@ -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,36 +47,38 @@
|
|||||||
"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",
|
||||||
"redux-thunk": "^2.3.0",
|
"redux-thunk": "^2.3.0",
|
||||||
"uuid": "^8.3.2"
|
"uuid": "^8.3.2",
|
||||||
|
"workbox-core": "^6.1.5",
|
||||||
|
"workbox-expiration": "^6.1.5",
|
||||||
|
"workbox-precaching": "^6.1.5",
|
||||||
|
"workbox-routing": "^6.1.5",
|
||||||
|
"workbox-strategies": "^6.1.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.13.8",
|
"@babel/core": "^7.13.8",
|
||||||
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.13.8",
|
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.13.8",
|
||||||
"@babel/plugin-proposal-optional-chaining": "^7.13.8",
|
"@babel/plugin-proposal-optional-chaining": "^7.13.8",
|
||||||
"@shlinkio/eslint-config-js-coding-standard": "~1.2.2",
|
"@shlinkio/eslint-config-js-coding-standard": "~1.2.2",
|
||||||
"@stryker-mutator/core": "^4.4.1",
|
"@stryker-mutator/core": "^5.0.0",
|
||||||
"@stryker-mutator/jest-runner": "^4.4.1",
|
"@stryker-mutator/jest-runner": "^5.0.0",
|
||||||
"@stryker-mutator/typescript-checker": "^4.4.1",
|
"@stryker-mutator/typescript-checker": "^5.0.0",
|
||||||
"@svgr/webpack": "^5.5.0",
|
"@svgr/webpack": "^5.5.0",
|
||||||
"@types/chart.js": "^2.9.31",
|
"@types/chart.js": "^2.9.31",
|
||||||
"@types/classnames": "^2.2.11",
|
"@types/classnames": "^2.2.11",
|
||||||
"@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",
|
||||||
@@ -85,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",
|
||||||
@@ -129,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",
|
||||||
@@ -146,7 +147,8 @@
|
|||||||
"webpack": "^4.44.2",
|
"webpack": "^4.44.2",
|
||||||
"webpack-dev-server": "^3.11.0",
|
"webpack-dev-server": "^3.11.0",
|
||||||
"webpack-manifest-plugin": "^2.2.0",
|
"webpack-manifest-plugin": "^2.2.0",
|
||||||
"whatwg-fetch": "^3.5.0"
|
"whatwg-fetch": "^3.5.0",
|
||||||
|
"workbox-webpack-plugin": "^6.1.5"
|
||||||
},
|
},
|
||||||
"babel": {
|
"babel": {
|
||||||
"presets": [
|
"presets": [
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
16
scripts/docker/servers_from_env.sh
Executable file
16
scripts/docker/servers_from_env.sh
Executable 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
|
||||||
13
scripts/set-homepage.js
Normal file
13
scripts/set-homepage.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
const argv = process.argv.slice(2);
|
||||||
|
const [ homepage ] = argv;
|
||||||
|
|
||||||
|
if (!homepage) {
|
||||||
|
throw new Error('Homepage has to be provided as the first arg for this script');
|
||||||
|
}
|
||||||
|
|
||||||
|
const packageJsonPath = `${__dirname}/../package.json`;
|
||||||
|
const packageJson = require(packageJsonPath);
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
packageJson.homepage = homepage;
|
||||||
|
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2));
|
||||||
10
src/App.tsx
10
src/App.tsx
@@ -4,12 +4,16 @@ 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 { AppUpdateBanner } from './common/AppUpdateBanner';
|
||||||
|
import { forceUpdate } from './utils/helpers/sw';
|
||||||
import './App.scss';
|
import './App.scss';
|
||||||
|
|
||||||
interface AppProps {
|
interface AppProps {
|
||||||
fetchServers: Function;
|
fetchServers: () => void;
|
||||||
servers: ServersMap;
|
servers: ServersMap;
|
||||||
settings: Settings;
|
settings: Settings;
|
||||||
|
resetAppUpdate: () => void;
|
||||||
|
appUpdated: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const App = (
|
const App = (
|
||||||
@@ -20,7 +24,7 @@ const App = (
|
|||||||
EditServer: FC,
|
EditServer: FC,
|
||||||
Settings: FC,
|
Settings: FC,
|
||||||
ShlinkVersionsContainer: FC,
|
ShlinkVersionsContainer: FC,
|
||||||
) => ({ fetchServers, servers, settings }: AppProps) => {
|
) => ({ fetchServers, servers, settings, appUpdated, resetAppUpdate }: AppProps) => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// On first load, try to fetch the remote servers if the list is empty
|
// On first load, try to fetch the remote servers if the list is empty
|
||||||
if (Object.keys(servers).length === 0) {
|
if (Object.keys(servers).length === 0) {
|
||||||
@@ -50,6 +54,8 @@ const App = (
|
|||||||
<ShlinkVersionsContainer />
|
<ShlinkVersionsContainer />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AppUpdateBanner isOpen={appUpdated} toggle={resetAppUpdate} forceUpdate={forceUpdate} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
18
src/app/reducers/appUpdates.ts
Normal file
18
src/app/reducers/appUpdates.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Action } from 'redux';
|
||||||
|
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
||||||
|
|
||||||
|
/* eslint-disable padding-line-between-statements */
|
||||||
|
export const APP_UPDATE_AVAILABLE = 'shlink/appUpdates/APP_UPDATE_AVAILABLE';
|
||||||
|
export const RESET_APP_UPDATE = 'shlink/appUpdates/RESET_APP_UPDATE';
|
||||||
|
/* eslint-enable padding-line-between-statements */
|
||||||
|
|
||||||
|
const initialState = false;
|
||||||
|
|
||||||
|
export default buildReducer<boolean, Action<string>>({
|
||||||
|
[APP_UPDATE_AVAILABLE]: () => true,
|
||||||
|
[RESET_APP_UPDATE]: () => false,
|
||||||
|
}, initialState);
|
||||||
|
|
||||||
|
export const appUpdateAvailable = buildActionCreator(APP_UPDATE_AVAILABLE);
|
||||||
|
|
||||||
|
export const resetAppUpdate = buildActionCreator(RESET_APP_UPDATE);
|
||||||
26
src/app/services/provideServices.ts
Normal file
26
src/app/services/provideServices.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import Bottle from 'bottlejs';
|
||||||
|
import { appUpdateAvailable, resetAppUpdate } from '../reducers/appUpdates';
|
||||||
|
import App from '../../App';
|
||||||
|
import { ConnectDecorator } from '../../container/types';
|
||||||
|
|
||||||
|
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
|
// Components
|
||||||
|
bottle.serviceFactory(
|
||||||
|
'App',
|
||||||
|
App,
|
||||||
|
'MainHeader',
|
||||||
|
'Home',
|
||||||
|
'MenuLayout',
|
||||||
|
'CreateServer',
|
||||||
|
'EditServer',
|
||||||
|
'Settings',
|
||||||
|
'ShlinkVersionsContainer',
|
||||||
|
);
|
||||||
|
bottle.decorator('App', connect([ 'servers', 'settings', 'appUpdated' ], [ 'fetchServers', 'resetAppUpdate' ]));
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
bottle.serviceFactory('appUpdateAvailable', () => appUpdateAvailable);
|
||||||
|
bottle.serviceFactory('resetAppUpdate', () => resetAppUpdate);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default provideServices;
|
||||||
17
src/common/AppUpdateBanner.scss
Normal file
17
src/common/AppUpdateBanner.scss
Normal 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);
|
||||||
|
}
|
||||||
34
src/common/AppUpdateBanner.tsx
Normal file
34
src/common/AppUpdateBanner.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -2,10 +2,12 @@ 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 './Home.scss';
|
|
||||||
import { ServersMap } from '../servers/data';
|
import { ServersMap } from '../servers/data';
|
||||||
import { ShlinkLogo } from './img/ShlinkLogo';
|
import { ShlinkLogo } from './img/ShlinkLogo';
|
||||||
|
import './Home.scss';
|
||||||
|
|
||||||
export interface HomeProps {
|
export interface HomeProps {
|
||||||
servers: ServersMap;
|
servers: ServersMap;
|
||||||
@@ -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‘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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
145
src/common/react-tag-autocomplete.scss
Normal file
145
src/common/react-tag-autocomplete.scss
Normal 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;
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ import Bottle, { IContainer } from 'bottlejs';
|
|||||||
import { withRouter } from 'react-router-dom';
|
import { withRouter } from 'react-router-dom';
|
||||||
import { connect as reduxConnect } from 'react-redux';
|
import { connect as reduxConnect } from 'react-redux';
|
||||||
import { pick } from 'ramda';
|
import { pick } from 'ramda';
|
||||||
import App from '../App';
|
|
||||||
import provideApiServices from '../api/services/provideServices';
|
import provideApiServices from '../api/services/provideServices';
|
||||||
import provideCommonServices from '../common/services/provideServices';
|
import provideCommonServices from '../common/services/provideServices';
|
||||||
import provideShortUrlsServices from '../short-urls/services/provideServices';
|
import provideShortUrlsServices from '../short-urls/services/provideServices';
|
||||||
@@ -13,6 +12,7 @@ import provideUtilsServices from '../utils/services/provideServices';
|
|||||||
import provideMercureServices from '../mercure/services/provideServices';
|
import provideMercureServices from '../mercure/services/provideServices';
|
||||||
import provideSettingsServices from '../settings/services/provideServices';
|
import provideSettingsServices from '../settings/services/provideServices';
|
||||||
import provideDomainsServices from '../domains/services/provideServices';
|
import provideDomainsServices from '../domains/services/provideServices';
|
||||||
|
import provideAppServices from '../app/services/provideServices';
|
||||||
import { ConnectDecorator } from './types';
|
import { ConnectDecorator } from './types';
|
||||||
|
|
||||||
type LazyActionMap = Record<string, Function>;
|
type LazyActionMap = Record<string, Function>;
|
||||||
@@ -33,19 +33,7 @@ const connect: ConnectDecorator = (propsFromState: string[] | null, actionServic
|
|||||||
actionServiceNames.reduce(mapActionService, {}),
|
actionServiceNames.reduce(mapActionService, {}),
|
||||||
);
|
);
|
||||||
|
|
||||||
bottle.serviceFactory(
|
provideAppServices(bottle, connect);
|
||||||
'App',
|
|
||||||
App,
|
|
||||||
'MainHeader',
|
|
||||||
'Home',
|
|
||||||
'MenuLayout',
|
|
||||||
'CreateServer',
|
|
||||||
'EditServer',
|
|
||||||
'Settings',
|
|
||||||
'ShlinkVersionsContainer',
|
|
||||||
);
|
|
||||||
bottle.decorator('App', connect([ 'servers', 'settings' ], [ 'fetchServers' ]));
|
|
||||||
|
|
||||||
provideCommonServices(bottle, connect, withRouter);
|
provideCommonServices(bottle, connect, withRouter);
|
||||||
provideApiServices(bottle);
|
provideApiServices(bottle);
|
||||||
provideShortUrlsServices(bottle, connect);
|
provideShortUrlsServices(bottle, connect);
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export interface ShlinkState {
|
|||||||
settings: Settings;
|
settings: Settings;
|
||||||
domainsList: DomainsList;
|
domainsList: DomainsList;
|
||||||
visitsOverview: VisitsOverview;
|
visitsOverview: VisitsOverview;
|
||||||
|
appUpdated: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ConnectDecorator = (props: string[] | null, actions?: string[]) => any;
|
export type ConnectDecorator = (props: string[] | null, actions?: string[]) => any;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
* {
|
* {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { homepage } from '../package.json';
|
|||||||
import container from './container';
|
import container from './container';
|
||||||
import store from './container/store';
|
import store from './container/store';
|
||||||
import { fixLeafletIcons } from './utils/helpers/leaflet';
|
import { fixLeafletIcons } from './utils/helpers/leaflet';
|
||||||
|
import { register as registerServiceWorker } from './serviceWorkerRegistration';
|
||||||
import 'react-datepicker/dist/react-datepicker.css';
|
import 'react-datepicker/dist/react-datepicker.css';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import './index.scss';
|
import './index.scss';
|
||||||
@@ -12,7 +13,7 @@ import './index.scss';
|
|||||||
// This overwrites icons used for leaflet maps, fixing some issues caused by webpack while processing the CSS
|
// This overwrites icons used for leaflet maps, fixing some issues caused by webpack while processing the CSS
|
||||||
fixLeafletIcons();
|
fixLeafletIcons();
|
||||||
|
|
||||||
const { App, ScrollToTop, ErrorHandler } = container;
|
const { App, ScrollToTop, ErrorHandler, appUpdateAvailable } = container;
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
@@ -26,3 +27,12 @@ render(
|
|||||||
</Provider>,
|
</Provider>,
|
||||||
document.getElementById('root'),
|
document.getElementById('root'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// If you want your app to work offline and load faster, you can change
|
||||||
|
// unregister() to register() below. Note this comes with some pitfalls.
|
||||||
|
// Learn more about service workers: https://cra.link/PWA
|
||||||
|
registerServiceWorker({
|
||||||
|
onUpdate() {
|
||||||
|
store.dispatch(appUpdateAvailable()); // eslint-disable-line @typescript-eslint/no-unsafe-call
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import mercureInfoReducer from '../mercure/reducers/mercureInfo';
|
|||||||
import settingsReducer from '../settings/reducers/settings';
|
import settingsReducer from '../settings/reducers/settings';
|
||||||
import domainsListReducer from '../domains/reducers/domainsList';
|
import domainsListReducer from '../domains/reducers/domainsList';
|
||||||
import visitsOverviewReducer from '../visits/reducers/visitsOverview';
|
import visitsOverviewReducer from '../visits/reducers/visitsOverview';
|
||||||
|
import appUpdatesReducer from '../app/reducers/appUpdates';
|
||||||
import { ShlinkState } from '../container/types';
|
import { ShlinkState } from '../container/types';
|
||||||
|
|
||||||
export default combineReducers<ShlinkState>({
|
export default combineReducers<ShlinkState>({
|
||||||
@@ -38,4 +39,5 @@ export default combineReducers<ShlinkState>({
|
|||||||
settings: settingsReducer,
|
settings: settingsReducer,
|
||||||
domainsList: domainsListReducer,
|
domainsList: domainsListReducer,
|
||||||
visitsOverview: visitsOverviewReducer,
|
visitsOverview: visitsOverviewReducer,
|
||||||
|
appUpdated: appUpdatesReducer,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ type Ref<T> = RefObject<T> | MutableRefObject<T>;
|
|||||||
|
|
||||||
export interface ImportServersBtnProps {
|
export interface ImportServersBtnProps {
|
||||||
onImport?: () => void;
|
onImport?: () => void;
|
||||||
onImportError?: () => void;
|
onImportError?: (error: Error) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportServersBtnConnectProps extends ImportServersBtnProps {
|
interface ImportServersBtnConnectProps extends ImportServersBtnProps {
|
||||||
|
|||||||
@@ -1,29 +1,37 @@
|
|||||||
import { CsvJson } from 'csvjson';
|
import { CsvJson } from 'csvjson';
|
||||||
import { ServerData } from '../data';
|
import { ServerData } from '../data';
|
||||||
|
|
||||||
interface CsvFile extends File {
|
const validateServer = (server: any): server is ServerData =>
|
||||||
type: 'text/csv' | 'text/comma-separated-values' | 'application/csv';
|
typeof server.url === 'string' && typeof server.apiKey === 'string' && typeof server.name === 'string';
|
||||||
}
|
|
||||||
|
|
||||||
const CSV_MIME_TYPES = [ 'text/csv', 'text/comma-separated-values', 'application/csv' ];
|
const validateServers = (servers: any): servers is ServerData[] =>
|
||||||
const isCsv = (file?: File | null): file is CsvFile => !!file && CSV_MIME_TYPES.includes(file.type);
|
Array.isArray(servers) && servers.every(validateServer);
|
||||||
|
|
||||||
export default class ServersImporter {
|
export default class ServersImporter {
|
||||||
public constructor(private readonly csvjson: CsvJson, private readonly fileReaderFactory: () => FileReader) {}
|
public constructor(private readonly csvJson: CsvJson, private readonly fileReaderFactory: () => FileReader) {}
|
||||||
|
|
||||||
public readonly importServersFromFile = async (file?: File | null): Promise<ServerData[]> => {
|
public readonly importServersFromFile = async (file?: File | null): Promise<ServerData[]> => {
|
||||||
if (!isCsv(file)) {
|
if (!file) {
|
||||||
throw new Error('No file provided or file is not a CSV');
|
throw new Error('No file provided');
|
||||||
}
|
}
|
||||||
|
|
||||||
const reader = this.fileReaderFactory();
|
const reader = this.fileReaderFactory();
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve, reject) => {
|
||||||
reader.addEventListener('loadend', (e: ProgressEvent<FileReader>) => {
|
reader.addEventListener('loadend', (e: ProgressEvent<FileReader>) => {
|
||||||
const content = e.target?.result?.toString() ?? '';
|
try {
|
||||||
const servers = this.csvjson.toObject<ServerData>(content);
|
// TODO Read as stream, otherwise, if the file is too big, this will block the browser tab
|
||||||
|
const content = e.target?.result?.toString() ?? '';
|
||||||
|
const servers = this.csvJson.toObject(content);
|
||||||
|
|
||||||
resolve(servers);
|
if (!validateServers(servers)) {
|
||||||
|
throw new Error('Provided file does not have the right format.');
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(servers);
|
||||||
|
} catch (e) {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
reader.readAsText(file);
|
reader.readAsText(file);
|
||||||
});
|
});
|
||||||
|
|||||||
80
src/service-worker.ts
Normal file
80
src/service-worker.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
/// <reference lib="webworker" />
|
||||||
|
/* eslint-disable no-restricted-globals */
|
||||||
|
|
||||||
|
// This service worker can be customized!
|
||||||
|
// See https://developers.google.com/web/tools/workbox/modules
|
||||||
|
// for the list of available Workbox modules, or add any other
|
||||||
|
// code you'd like.
|
||||||
|
// You can also remove this file if you'd prefer not to use a
|
||||||
|
// service worker, and the Workbox build step will be skipped.
|
||||||
|
|
||||||
|
import { clientsClaim } from 'workbox-core';
|
||||||
|
import { ExpirationPlugin } from 'workbox-expiration';
|
||||||
|
import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching';
|
||||||
|
import { registerRoute } from 'workbox-routing';
|
||||||
|
import { StaleWhileRevalidate } from 'workbox-strategies';
|
||||||
|
|
||||||
|
declare const self: ServiceWorkerGlobalScope;
|
||||||
|
|
||||||
|
clientsClaim();
|
||||||
|
|
||||||
|
// Precache all of the assets generated by your build process.
|
||||||
|
// Their URLs are injected into the manifest variable below.
|
||||||
|
// This variable must be present somewhere in your service worker file,
|
||||||
|
// even if you decide not to use precaching. See https://cra.link/PWA
|
||||||
|
precacheAndRoute(self.__WB_MANIFEST);
|
||||||
|
|
||||||
|
// Set up App Shell-style routing, so that all navigation requests
|
||||||
|
// are fulfilled with your index.html shell. Learn more at
|
||||||
|
// https://developers.google.com/web/fundamentals/architecture/app-shell
|
||||||
|
const fileExtensionRegexp = new RegExp('/[^/?]+\\.[^/]+$');
|
||||||
|
registerRoute(
|
||||||
|
// Return false to exempt requests from being fulfilled by index.html.
|
||||||
|
({ request, url }: { request: Request; url: URL }) => {
|
||||||
|
// If this isn't a navigation, skip.
|
||||||
|
if (request.mode !== 'navigate') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this is a URL that starts with /_, skip.
|
||||||
|
if (url.pathname.startsWith('/_')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this looks like a URL for a resource, because it contains
|
||||||
|
// a file extension, skip.
|
||||||
|
if (url.pathname.match(fileExtensionRegexp)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return true to signal that we want to use the handler.
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
createHandlerBoundToURL(process.env.PUBLIC_URL + '/index.html')
|
||||||
|
);
|
||||||
|
|
||||||
|
// An example runtime caching route for requests that aren't handled by the
|
||||||
|
// precache, in this case same-origin .png requests like those from in public/
|
||||||
|
registerRoute(
|
||||||
|
// Add in any other file extensions or routing criteria as needed.
|
||||||
|
({ url }) => url.origin === self.location.origin && url.pathname.endsWith('.png'),
|
||||||
|
// Customize this strategy as needed, e.g., by changing to CacheFirst.
|
||||||
|
new StaleWhileRevalidate({
|
||||||
|
cacheName: 'images',
|
||||||
|
plugins: [
|
||||||
|
// Ensure that once this runtime cache reaches a maximum size the
|
||||||
|
// least-recently used images are removed.
|
||||||
|
new ExpirationPlugin({ maxEntries: 50 }),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// This allows the web app to trigger skipWaiting via
|
||||||
|
// registration.waiting.postMessage({type: 'SKIP_WAITING'})
|
||||||
|
self.addEventListener('message', (event) => {
|
||||||
|
if (event.data && event.data.type === 'SKIP_WAITING') {
|
||||||
|
self.skipWaiting();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Any other custom service worker logic can go here.
|
||||||
142
src/serviceWorkerRegistration.ts
Normal file
142
src/serviceWorkerRegistration.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
// This optional code is used to register a service worker.
|
||||||
|
// register() is not called by default.
|
||||||
|
|
||||||
|
// This lets the app load faster on subsequent visits in production, and gives
|
||||||
|
// it offline capabilities. However, it also means that developers (and users)
|
||||||
|
// will only see deployed updates on subsequent visits to a page, after all the
|
||||||
|
// existing tabs open on the page have been closed, since previously cached
|
||||||
|
// resources are updated in the background.
|
||||||
|
|
||||||
|
// To learn more about the benefits of this model and instructions on how to
|
||||||
|
// opt-in, read https://cra.link/PWA
|
||||||
|
|
||||||
|
const isLocalhost = Boolean(
|
||||||
|
window.location.hostname === 'localhost' ||
|
||||||
|
// [::1] is the IPv6 localhost address.
|
||||||
|
window.location.hostname === '[::1]' ||
|
||||||
|
// 127.0.0.0/8 are considered localhost for IPv4.
|
||||||
|
window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/)
|
||||||
|
);
|
||||||
|
|
||||||
|
type Config = {
|
||||||
|
onSuccess?: (registration: ServiceWorkerRegistration) => void;
|
||||||
|
onUpdate?: (registration: ServiceWorkerRegistration) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function register(config?: Config) {
|
||||||
|
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
|
||||||
|
// The URL constructor is available in all browsers that support SW.
|
||||||
|
const publicUrl = new URL(process.env.PUBLIC_URL ?? '', window.location.href);
|
||||||
|
if (publicUrl.origin !== window.location.origin) {
|
||||||
|
// Our service worker won't work if PUBLIC_URL is on a different origin
|
||||||
|
// from what our page is served on. This might happen if a CDN is used to
|
||||||
|
// serve assets; see https://github.com/facebook/create-react-app/issues/2374
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
|
||||||
|
|
||||||
|
if (isLocalhost) {
|
||||||
|
// This is running on localhost. Let's check if a service worker still exists or not.
|
||||||
|
checkValidServiceWorker(swUrl, config);
|
||||||
|
|
||||||
|
// Add some additional logging to localhost, pointing developers to the
|
||||||
|
// service worker/PWA documentation.
|
||||||
|
navigator.serviceWorker.ready.then(() => {
|
||||||
|
console.log(
|
||||||
|
'This web app is being served cache-first by a service ' +
|
||||||
|
'worker. To learn more, visit https://cra.link/PWA'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Is not localhost. Just register service worker
|
||||||
|
registerValidSW(swUrl, config);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerValidSW(swUrl: string, config?: Config) {
|
||||||
|
navigator.serviceWorker
|
||||||
|
.register(swUrl)
|
||||||
|
.then((registration) => {
|
||||||
|
registration.onupdatefound = () => {
|
||||||
|
const installingWorker = registration.installing;
|
||||||
|
if (installingWorker == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
installingWorker.onstatechange = () => {
|
||||||
|
if (installingWorker.state === 'installed') {
|
||||||
|
if (navigator.serviceWorker.controller) {
|
||||||
|
// At this point, the updated precached content has been fetched,
|
||||||
|
// but the previous service worker will still serve the older
|
||||||
|
// content until all client tabs are closed.
|
||||||
|
console.log(
|
||||||
|
'New content is available and will be used when all ' +
|
||||||
|
'tabs for this page are closed. See https://cra.link/PWA.'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Execute callback
|
||||||
|
if (config && config.onUpdate) {
|
||||||
|
config.onUpdate(registration);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// At this point, everything has been precached.
|
||||||
|
// It's the perfect time to display a
|
||||||
|
// "Content is cached for offline use." message.
|
||||||
|
console.log('Content is cached for offline use.');
|
||||||
|
|
||||||
|
// Execute callback
|
||||||
|
if (config && config.onSuccess) {
|
||||||
|
config.onSuccess(registration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Error during service worker registration:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkValidServiceWorker(swUrl: string, config?: Config) {
|
||||||
|
// Check if the service worker can be found. If it can't reload the page.
|
||||||
|
fetch(swUrl, {
|
||||||
|
headers: { 'Service-Worker': 'script' },
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
// Ensure service worker exists, and that we really are getting a JS file.
|
||||||
|
const contentType = response.headers.get('content-type');
|
||||||
|
if (
|
||||||
|
response.status === 404 ||
|
||||||
|
(contentType != null && contentType.indexOf('javascript') === -1)
|
||||||
|
) {
|
||||||
|
// No service worker found. Probably a different app. Reload the page.
|
||||||
|
navigator.serviceWorker.ready.then((registration) => {
|
||||||
|
registration.unregister().then(() => {
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Service worker found. Proceed as normal.
|
||||||
|
registerValidSW(swUrl, config);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
console.log('No internet connection found. App is running in offline mode.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unregister() {
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.ready
|
||||||
|
.then((registration) => {
|
||||||
|
registration.unregister();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,7 +49,11 @@ const CreateShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>, CreateShortUrlResul
|
|||||||
saving={shortUrlCreationResult.saving}
|
saving={shortUrlCreationResult.saving}
|
||||||
selectedServer={selectedServer}
|
selectedServer={selectedServer}
|
||||||
mode={basicMode ? 'create-basic' : 'create'}
|
mode={basicMode ? 'create-basic' : 'create'}
|
||||||
onSave={createShortUrl}
|
onSave={async (data: ShortUrlData) => {
|
||||||
|
resetCreateShortUrl();
|
||||||
|
|
||||||
|
return createShortUrl(data);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<CreateShortUrlResult
|
<CreateShortUrlResult
|
||||||
{...shortUrlCreationResult}
|
{...shortUrlCreationResult}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { parseQuery } from '../utils/helpers/query';
|
|||||||
import Message from '../utils/Message';
|
import Message from '../utils/Message';
|
||||||
import { Result } from '../utils/Result';
|
import { Result } from '../utils/Result';
|
||||||
import { ShlinkApiError } from '../api/ShlinkApiError';
|
import { ShlinkApiError } from '../api/ShlinkApiError';
|
||||||
|
import { useToggle } from '../utils/helpers/hooks';
|
||||||
import { ShortUrlFormProps } from './ShortUrlForm';
|
import { ShortUrlFormProps } from './ShortUrlForm';
|
||||||
import { ShortUrlDetail } from './reducers/shortUrlDetail';
|
import { ShortUrlDetail } from './reducers/shortUrlDetail';
|
||||||
import { EditShortUrlData, ShortUrl, ShortUrlData } from './data';
|
import { EditShortUrlData, ShortUrl, ShortUrlData } from './data';
|
||||||
@@ -40,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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -62,6 +64,7 @@ export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
|
|||||||
() => getInitialState(shortUrl, shortUrlCreationSettings),
|
() => getInitialState(shortUrl, shortUrlCreationSettings),
|
||||||
[ shortUrl, shortUrlCreationSettings ],
|
[ shortUrl, shortUrlCreationSettings ],
|
||||||
);
|
);
|
||||||
|
const [ savingSucceeded,, isSuccessful, isNotSuccessful ] = useToggle();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getShortUrlDetail(params.shortCode, domain);
|
getShortUrlDetail(params.shortCode, domain);
|
||||||
@@ -79,8 +82,6 @@ export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = <small>Edit <ExternalLink href={shortUrl?.shortUrl ?? ''} /></small>;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<header className="mb-3">
|
<header className="mb-3">
|
||||||
@@ -89,7 +90,9 @@ export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
|
|||||||
<Button color="link" size="lg" className="p-0 mr-3" onClick={goBack}>
|
<Button color="link" size="lg" className="p-0 mr-3" onClick={goBack}>
|
||||||
<FontAwesomeIcon icon={faArrowLeft} />
|
<FontAwesomeIcon icon={faArrowLeft} />
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-center">{title}</span>
|
<span className="text-center">
|
||||||
|
<small>Edit <ExternalLink href={shortUrl?.shortUrl ?? ''} /></small>
|
||||||
|
</span>
|
||||||
<span />
|
<span />
|
||||||
</h2>
|
</h2>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -99,13 +102,23 @@ export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
|
|||||||
saving={saving}
|
saving={saving}
|
||||||
selectedServer={selectedServer}
|
selectedServer={selectedServer}
|
||||||
mode="edit"
|
mode="edit"
|
||||||
onSave={async (shortUrlData) => shortUrl && editShortUrl(shortUrl.shortCode, shortUrl.domain, shortUrlData)}
|
onSave={async (shortUrlData) => {
|
||||||
|
if (!shortUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isNotSuccessful();
|
||||||
|
editShortUrl(shortUrl.shortCode, shortUrl.domain, shortUrlData)
|
||||||
|
.then(isSuccessful)
|
||||||
|
.catch(isNotSuccessful);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{savingError && (
|
{savingError && (
|
||||||
<Result type="error" className="mt-3">
|
<Result type="error" className="mt-3">
|
||||||
<ShlinkApiError errorData={savingErrorData} fallbackMessage="An error occurred while updating short URL :(" />
|
<ShlinkApiError errorData={savingErrorData} fallbackMessage="An error occurred while updating short URL :(" />
|
||||||
</Result>
|
</Result>
|
||||||
)}
|
)}
|
||||||
|
{savingSucceeded && <Result type="success" className="mt-3">Short URL properly edited.</Result>}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 ?? [];
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ const ShortUrlsList = (ShortUrlsTable: FC<ShortUrlsTableProps>) => boundToMercur
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const { tag } = parseQuery<{ tag?: string }>(location.search);
|
const { tag } = parseQuery<{ tag?: string }>(location.search);
|
||||||
const tags = tag ? [ tag ] : shortUrlsListParams.tags;
|
const tags = tag ? [ decodeURIComponent(tag) ] : shortUrlsListParams.tags;
|
||||||
|
|
||||||
refreshList({ page: match.params.page, tags, itemsPerPage: undefined });
|
refreshList({ page: match.params.page, tags, itemsPerPage: undefined });
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ const QrCodeModal = ({ shortUrl: { shortUrl }, toggle, isOpen, selectedServer }:
|
|||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<div>QR code URL:</div>
|
<div>QR code URL:</div>
|
||||||
<ExternalLink className="indivisible" href={qrCodeUrl} />
|
<ExternalLink href={qrCodeUrl} />
|
||||||
<CopyToClipboardIcon text={qrCodeUrl} />
|
<CopyToClipboardIcon text={qrCodeUrl} />
|
||||||
</div>
|
</div>
|
||||||
<img src={qrCodeUrl} className="qr-code-modal__img" alt="QR code" />
|
<img src={qrCodeUrl} className="qr-code-modal__img" alt="QR code" />
|
||||||
|
|||||||
39
src/short-urls/helpers/ShortUrlFormCheckboxGroup.tsx
Normal file
39
src/short-urls/helpers/ShortUrlFormCheckboxGroup.tsx
Normal 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>
|
||||||
|
);
|
||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { ShlinkShortUrlsResponse } from '../../api/types';
|
|||||||
import { DeleteShortUrlAction, SHORT_URL_DELETED } from './shortUrlDeletion';
|
import { DeleteShortUrlAction, SHORT_URL_DELETED } from './shortUrlDeletion';
|
||||||
import { ShortUrlsListParams } from './shortUrlsListParams';
|
import { ShortUrlsListParams } from './shortUrlsListParams';
|
||||||
import { CREATE_SHORT_URL, CreateShortUrlAction } from './shortUrlCreation';
|
import { CREATE_SHORT_URL, CreateShortUrlAction } from './shortUrlCreation';
|
||||||
|
import { SHORT_URL_EDITED, ShortUrlEditedAction } from './shortUrlEdition';
|
||||||
|
|
||||||
/* eslint-disable padding-line-between-statements */
|
/* eslint-disable padding-line-between-statements */
|
||||||
export const LIST_SHORT_URLS_START = 'shlink/shortUrlsList/LIST_SHORT_URLS_START';
|
export const LIST_SHORT_URLS_START = 'shlink/shortUrlsList/LIST_SHORT_URLS_START';
|
||||||
@@ -32,6 +33,7 @@ export type ListShortUrlsCombinedAction = (
|
|||||||
& CreateVisitsAction
|
& CreateVisitsAction
|
||||||
& CreateShortUrlAction
|
& CreateShortUrlAction
|
||||||
& DeleteShortUrlAction
|
& DeleteShortUrlAction
|
||||||
|
& ShortUrlEditedAction
|
||||||
);
|
);
|
||||||
|
|
||||||
const initialState: ShortUrlsList = {
|
const initialState: ShortUrlsList = {
|
||||||
@@ -87,6 +89,15 @@ export default buildReducer<ShortUrlsList, ListShortUrlsCombinedAction>({
|
|||||||
state,
|
state,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
[SHORT_URL_EDITED]: (state, { shortUrl: editedShortUrl }) => !state.shortUrls ? state : assocPath(
|
||||||
|
[ 'shortUrls', 'data' ],
|
||||||
|
state.shortUrls.data.map((shortUrl) => {
|
||||||
|
const { shortCode, domain } = editedShortUrl;
|
||||||
|
|
||||||
|
return shortUrlMatches(shortUrl, shortCode, domain) ? editedShortUrl : shortUrl;
|
||||||
|
}),
|
||||||
|
state,
|
||||||
|
),
|
||||||
}, initialState);
|
}, initialState);
|
||||||
|
|
||||||
export const listShortUrls = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
|
export const listShortUrls = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const TagCard = (
|
|||||||
const [ isEditModalOpen, toggleEdit ] = useToggle();
|
const [ isEditModalOpen, toggleEdit ] = useToggle();
|
||||||
|
|
||||||
const serverId = isServerWithId(selectedServer) ? selectedServer.id : '';
|
const serverId = isServerWithId(selectedServer) ? selectedServer.id : '';
|
||||||
const shortUrlsLink = `/server/${serverId}/list-short-urls/1?tag=${tag}`;
|
const shortUrlsLink = `/server/${serverId}/list-short-urls/1?tag=${encodeURIComponent(tag)}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="tag-card">
|
<Card className="tag-card">
|
||||||
|
|||||||
@@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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() ])}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
18
src/utils/Time.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,9 @@ 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));
|
||||||
|
const endingToday = (startDate: Date): DateRange => ({ startDate, endDate: endOfDay(new Date()) });
|
||||||
|
|
||||||
export const intervalToDateRange = (dateInterval?: DateInterval): DateRange => {
|
export const intervalToDateRange = (dateInterval?: DateInterval): DateRange => {
|
||||||
if (!dateInterval) {
|
if (!dateInterval) {
|
||||||
return {};
|
return {};
|
||||||
@@ -61,21 +64,19 @@ export const intervalToDateRange = (dateInterval?: DateInterval): DateRange => {
|
|||||||
|
|
||||||
switch (dateInterval) {
|
switch (dateInterval) {
|
||||||
case 'today':
|
case 'today':
|
||||||
return { startDate: moment().startOf('day'), endDate: moment() };
|
return endingToday(startOfDay(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 endingToday(startOfDaysAgo(7));
|
||||||
case 'last30Days':
|
case 'last30Days':
|
||||||
return { startDate: moment().subtract(30, 'days').startOf('day'), endDate: moment() };
|
return endingToday(startOfDaysAgo(30));
|
||||||
case 'last90Days':
|
case 'last90Days':
|
||||||
return { startDate: moment().subtract(90, 'days').startOf('day'), endDate: moment() };
|
return endingToday(startOfDaysAgo(90));
|
||||||
case 'last180days':
|
case 'last180days':
|
||||||
return { startDate: moment().subtract(180, 'days').startOf('day'), endDate: moment() };
|
return endingToday(startOfDaysAgo(180));
|
||||||
case 'last365Days':
|
case 'last365Days':
|
||||||
return { startDate: moment().subtract(365, 'days').startOf('day'), endDate: moment() };
|
return endingToday(startOfDaysAgo(365));
|
||||||
}
|
}
|
||||||
|
|
||||||
return {};
|
return {};
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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
16
src/utils/helpers/sw.ts
Normal 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' });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)))),
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
52
src/visits/helpers/VisitsFilterDropdown.tsx
Normal file
52
src/visits/helpers/VisitsFilterDropdown.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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';
|
||||||
|
|
||||||
@@ -44,16 +53,24 @@ export default buildReducer<VisitsInfo, OrphanVisitsCombinedAction>({
|
|||||||
const { visits } = state;
|
const { visits } = state;
|
||||||
const newVisits = createdVisits.map(({ visit }) => visit);
|
const newVisits = createdVisits.map(({ visit }) => visit);
|
||||||
|
|
||||||
return { ...state, visits: [ ...visits, ...newVisits ] };
|
return { ...state, visits: [ ...newVisits, ...visits ] };
|
||||||
},
|
},
|
||||||
}, 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,
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
@@ -58,13 +58,13 @@ export default buildReducer<ShortUrlVisits, ShortUrlVisitsCombinedAction>({
|
|||||||
.filter(({ shortUrl }) => shortUrl && shortUrlMatches(shortUrl, shortCode, domain))
|
.filter(({ shortUrl }) => shortUrl && shortUrlMatches(shortUrl, shortCode, domain))
|
||||||
.map(({ visit }) => visit);
|
.map(({ visit }) => visit);
|
||||||
|
|
||||||
return { ...state, visits: [ ...visits, ...newVisits ] };
|
return { ...state, visits: [ ...newVisits, ...visits ] };
|
||||||
},
|
},
|
||||||
}, initialState);
|
}, initialState);
|
||||||
|
|
||||||
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(
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
@@ -52,14 +53,14 @@ export default buildReducer<TagVisits, TagsVisitsCombinedAction>({
|
|||||||
.filter(({ shortUrl }) => shortUrl?.tags.includes(tag))
|
.filter(({ shortUrl }) => shortUrl?.tags.includes(tag))
|
||||||
.map(({ visit }) => visit);
|
.map(({ visit }) => visit);
|
||||||
|
|
||||||
return { ...state, visits: [ ...visits, ...newVisits ] };
|
return { ...state, visits: [ ...newVisits, ...visits ] };
|
||||||
},
|
},
|
||||||
}, 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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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' ],
|
||||||
));
|
));
|
||||||
|
|
||||||
|
|||||||
7
src/visits/types/CommonVisitsProps.ts
Normal file
7
src/visits/types/CommonVisitsProps.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { SelectedServer } from '../../servers/data';
|
||||||
|
import { Settings } from '../../settings/reducers/settings';
|
||||||
|
|
||||||
|
export interface CommonVisitsProps {
|
||||||
|
selectedServer: SelectedServer;
|
||||||
|
settings: Settings;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,16 @@ module.exports = {
|
|||||||
tsconfigFile: 'tsconfig.json',
|
tsconfigFile: 'tsconfig.json',
|
||||||
testRunner: 'jest',
|
testRunner: 'jest',
|
||||||
reporters: [ 'progress', 'clear-text' ],
|
reporters: [ 'progress', 'clear-text' ],
|
||||||
coverageAnalysis: 'perTest',
|
ignorePatterns: [
|
||||||
|
'coverage',
|
||||||
|
'reports',
|
||||||
|
'build',
|
||||||
|
'dist',
|
||||||
|
'home',
|
||||||
|
'scripts',
|
||||||
|
'docker-compose.*',
|
||||||
|
'public/servers.json*',
|
||||||
|
],
|
||||||
jest: {
|
jest: {
|
||||||
projectType: 'custom',
|
projectType: 'custom',
|
||||||
config: jestConfig,
|
config: jestConfig,
|
||||||
|
|||||||
@@ -1,23 +1,36 @@
|
|||||||
import { shallow, ShallowWrapper } from 'enzyme';
|
import { shallow, ShallowWrapper } from 'enzyme';
|
||||||
import { Route } from 'react-router-dom';
|
import { Route } from 'react-router-dom';
|
||||||
import { identity } from 'ramda';
|
|
||||||
import { Mock } from 'ts-mockery';
|
import { Mock } from 'ts-mockery';
|
||||||
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;
|
||||||
const MainHeader = () => null;
|
const MainHeader = () => null;
|
||||||
|
const ShlinkVersions = () => null;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const App = appFactory(MainHeader, () => null, () => null, () => null, () => null, () => null, () => null);
|
const App = appFactory(MainHeader, () => null, () => null, () => null, () => null, () => null, ShlinkVersions);
|
||||||
|
|
||||||
wrapper = shallow(<App fetchServers={identity} servers={{}} settings={Mock.all<Settings>()} />);
|
wrapper = shallow(
|
||||||
|
<App
|
||||||
|
fetchServers={() => {}}
|
||||||
|
servers={{}}
|
||||||
|
settings={Mock.all<Settings>()}
|
||||||
|
appUpdated={false}
|
||||||
|
resetAppUpdate={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
afterEach(() => wrapper.unmount());
|
afterEach(() => wrapper.unmount());
|
||||||
|
|
||||||
it('renders a header', () => expect(wrapper.find(MainHeader)).toHaveLength(1));
|
it('renders a header', () => expect(wrapper.find(MainHeader)).toHaveLength(1));
|
||||||
|
|
||||||
|
it('renders versions', () => expect(wrapper.find(ShlinkVersions)).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);
|
||||||
const expectedPaths = [
|
const expectedPaths = [
|
||||||
|
|||||||
30
test/app/reducers/appUpdates.test.ts
Normal file
30
test/app/reducers/appUpdates.test.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import reducer, {
|
||||||
|
APP_UPDATE_AVAILABLE,
|
||||||
|
RESET_APP_UPDATE,
|
||||||
|
appUpdateAvailable,
|
||||||
|
resetAppUpdate,
|
||||||
|
} from '../../../src/app/reducers/appUpdates';
|
||||||
|
|
||||||
|
describe('appUpdatesReducer', () => {
|
||||||
|
describe('reducer', () => {
|
||||||
|
it('returns true on APP_UPDATE_AVAILABLE', () => {
|
||||||
|
expect(reducer(undefined, { type: APP_UPDATE_AVAILABLE })).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false on RESET_APP_UPDATE', () => {
|
||||||
|
expect(reducer(undefined, { type: RESET_APP_UPDATE })).toEqual(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('appUpdateAvailable', () => {
|
||||||
|
test('creates expected action', () => {
|
||||||
|
expect(appUpdateAvailable()).toEqual({ type: APP_UPDATE_AVAILABLE });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resetAppUpdate', () => {
|
||||||
|
test('creates expected action', () => {
|
||||||
|
expect(resetAppUpdate()).toEqual({ type: RESET_APP_UPDATE });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
43
test/common/AppUpdateBanner.test.tsx
Normal file
43
test/common/AppUpdateBanner.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,23 +21,70 @@ describe('ServersImporter', () => {
|
|||||||
describe('importServersFromFile', () => {
|
describe('importServersFromFile', () => {
|
||||||
it('rejects with error if no file was provided', async () => {
|
it('rejects with error if no file was provided', async () => {
|
||||||
await expect(importer.importServersFromFile()).rejects.toEqual(
|
await expect(importer.importServersFromFile()).rejects.toEqual(
|
||||||
new Error('No file provided or file is not a CSV'),
|
new Error('No file provided'),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects with error if provided file is not a CSV', async () => {
|
it('rejects with error if parsing the file fails', async () => {
|
||||||
await expect(importer.importServersFromFile(Mock.of<File>({ type: 'text/html' }))).rejects.toEqual(
|
const expectedError = new Error('Error parsing file');
|
||||||
new Error('No file provided or file is not a CSV'),
|
|
||||||
);
|
toObject.mockImplementation(() => {
|
||||||
|
throw expectedError;
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(importer.importServersFromFile(Mock.of<File>({ type: 'text/html' }))).rejects.toEqual(expectedError);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
[ 'text/csv' ],
|
[{}],
|
||||||
[ 'text/comma-separated-values' ],
|
[ undefined ],
|
||||||
[ 'application/csv' ],
|
[[{ foo: 'bar' }]],
|
||||||
])('reads file when a CSV is provided', async (type) => {
|
[
|
||||||
await importer.importServersFromFile(Mock.of<File>({ type }));
|
[
|
||||||
|
{
|
||||||
|
url: 1,
|
||||||
|
apiKey: 1,
|
||||||
|
name: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[
|
||||||
|
{
|
||||||
|
url: 'foo',
|
||||||
|
apiKey: 'foo',
|
||||||
|
name: 'foo',
|
||||||
|
},
|
||||||
|
{ bar: 'foo' },
|
||||||
|
],
|
||||||
|
],
|
||||||
|
])('rejects with error if provided file does not parse to valid list of servers', async (parsedObject) => {
|
||||||
|
toObject.mockReturnValue(parsedObject);
|
||||||
|
|
||||||
|
await expect(importer.importServersFromFile(Mock.of<File>({ type: 'text/html' }))).rejects.toEqual(
|
||||||
|
new Error('Provided file does not have the right format.'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads file when a CSV containing valid servers is provided', async () => {
|
||||||
|
const expectedServers = [
|
||||||
|
{
|
||||||
|
url: 'foo',
|
||||||
|
apiKey: 'foo',
|
||||||
|
name: 'foo',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: 'bar',
|
||||||
|
apiKey: 'bar',
|
||||||
|
name: 'bar',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
toObject.mockReturnValue(expectedServers);
|
||||||
|
|
||||||
|
const result = await importer.importServersFromFile(Mock.all<File>());
|
||||||
|
|
||||||
|
expect(result).toEqual(expectedServers);
|
||||||
expect(readAsText).toHaveBeenCalledTimes(1);
|
expect(readAsText).toHaveBeenCalledTimes(1);
|
||||||
expect(toObject).toHaveBeenCalledTimes(1);
|
expect(toObject).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
16
test/short-urls/helpers/ShortUrlFormCheckboxGroup.test.tsx
Normal file
16
test/short-urls/helpers/ShortUrlFormCheckboxGroup.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { ShortUrl } from '../../../src/short-urls/data';
|
|||||||
import ShlinkApiClient from '../../../src/api/services/ShlinkApiClient';
|
import ShlinkApiClient from '../../../src/api/services/ShlinkApiClient';
|
||||||
import { ShlinkPaginator, ShlinkShortUrlsResponse } from '../../../src/api/types';
|
import { ShlinkPaginator, ShlinkShortUrlsResponse } from '../../../src/api/types';
|
||||||
import { CREATE_SHORT_URL } from '../../../src/short-urls/reducers/shortUrlCreation';
|
import { CREATE_SHORT_URL } from '../../../src/short-urls/reducers/shortUrlCreation';
|
||||||
|
import { SHORT_URL_EDITED } from '../../../src/short-urls/reducers/shortUrlEdition';
|
||||||
|
|
||||||
describe('shortUrlsListReducer', () => {
|
describe('shortUrlsListReducer', () => {
|
||||||
describe('reducer', () => {
|
describe('reducer', () => {
|
||||||
@@ -124,6 +125,40 @@ describe('shortUrlsListReducer', () => {
|
|||||||
error: false,
|
error: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
((): [ShortUrl, ShortUrl[], ShortUrl[]] => {
|
||||||
|
const editedShortUrl = Mock.of<ShortUrl>({ shortCode: 'notMatching' });
|
||||||
|
const list = [ Mock.of<ShortUrl>({ shortCode: 'foo' }), Mock.of<ShortUrl>({ shortCode: 'bar' }) ];
|
||||||
|
|
||||||
|
return [ editedShortUrl, list, list ];
|
||||||
|
})(),
|
||||||
|
((): [ShortUrl, ShortUrl[], ShortUrl[]] => {
|
||||||
|
const editedShortUrl = Mock.of<ShortUrl>({ shortCode: 'matching', longUrl: 'new_one' });
|
||||||
|
const list = [
|
||||||
|
Mock.of<ShortUrl>({ shortCode: 'matching', longUrl: 'old_one' }),
|
||||||
|
Mock.of<ShortUrl>({ shortCode: 'bar' }),
|
||||||
|
];
|
||||||
|
const expectedList = [ editedShortUrl, list[1] ];
|
||||||
|
|
||||||
|
return [ editedShortUrl, list, expectedList ];
|
||||||
|
})(),
|
||||||
|
])('updates matching short URL on SHORT_URL_EDITED', (editedShortUrl, initialList, expectedList) => {
|
||||||
|
const state = {
|
||||||
|
shortUrls: Mock.of<ShlinkShortUrlsResponse>({
|
||||||
|
data: initialList,
|
||||||
|
pagination: Mock.of<ShlinkPaginator>({
|
||||||
|
totalItems: 15,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
loading: false,
|
||||||
|
error: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = reducer(state, { type: SHORT_URL_EDITED, shortUrl: editedShortUrl } as any);
|
||||||
|
|
||||||
|
expect(result.shortUrls?.data).toEqual(expectedList);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('listShortUrls', () => {
|
describe('listShortUrls', () => {
|
||||||
|
|||||||
@@ -14,30 +14,36 @@ describe('<TagCard />', () => {
|
|||||||
};
|
};
|
||||||
const DeleteTagConfirmModal = jest.fn();
|
const DeleteTagConfirmModal = jest.fn();
|
||||||
const EditTagModal = jest.fn();
|
const EditTagModal = jest.fn();
|
||||||
|
const TagCard = createTagCard(DeleteTagConfirmModal, EditTagModal, () => null, Mock.all<ColorGenerator>());
|
||||||
beforeEach(() => {
|
const createWrapper = (tag = 'ssr') => {
|
||||||
const TagCard = createTagCard(DeleteTagConfirmModal, EditTagModal, () => null, Mock.all<ColorGenerator>());
|
|
||||||
|
|
||||||
wrapper = shallow(
|
wrapper = shallow(
|
||||||
<TagCard
|
<TagCard
|
||||||
tag="ssr"
|
tag={tag}
|
||||||
selectedServer={Mock.of<ReachableServer>({ id: '1' })}
|
selectedServer={Mock.of<ReachableServer>({ id: '1' })}
|
||||||
tagStats={tagStats}
|
tagStats={tagStats}
|
||||||
displayed={true}
|
displayed={true}
|
||||||
toggle={() => {}}
|
toggle={() => {}}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
});
|
|
||||||
|
return wrapper;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => createWrapper());
|
||||||
|
|
||||||
afterEach(() => wrapper.unmount());
|
afterEach(() => wrapper.unmount());
|
||||||
afterEach(jest.resetAllMocks);
|
afterEach(jest.resetAllMocks);
|
||||||
|
|
||||||
it('shows a TagBullet and a link to the list filtering by the tag', () => {
|
it.each([
|
||||||
|
[ 'ssr', '/server/1/list-short-urls/1?tag=ssr' ],
|
||||||
|
[ 'ssr-&-foo', '/server/1/list-short-urls/1?tag=ssr-%26-foo' ],
|
||||||
|
])('shows a TagBullet and a link to the list filtering by the tag', (tag, expectedLink) => {
|
||||||
|
const wrapper = createWrapper(tag);
|
||||||
const links = wrapper.find(Link);
|
const links = wrapper.find(Link);
|
||||||
const bullet = wrapper.find(TagBullet);
|
const bullet = wrapper.find(TagBullet);
|
||||||
|
|
||||||
expect(links.at(0).prop('to')).toEqual('/server/1/list-short-urls/1?tag=ssr');
|
expect(links.at(0).prop('to')).toEqual(expectedLink);
|
||||||
expect(bullet.prop('tag')).toEqual('ssr');
|
expect(bullet.prop('tag')).toEqual(tag);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('displays delete modal when delete btn is clicked', () => {
|
it('displays delete modal when delete btn is clicked', () => {
|
||||||
|
|||||||
66
test/tags/helpers/TagsSelector.test.tsx
Normal file
66
test/tags/helpers/TagsSelector.test.tsx
Normal 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 ]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
30
test/utils/Time.test.tsx
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 ],
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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([
|
||||||
|
|||||||
@@ -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>()}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
89
test/visits/helpers/VisitsFilterDropdown.test.tsx
Normal file
89
test/visits/helpers/VisitsFilterDropdown.test.tsx
Normal 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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user