From ccffa0fe12e39e890147d0be12c3a85000a52b88 Mon Sep 17 00:00:00 2001 From: Matias Garcia Isaia Date: Fri, 28 May 2021 22:01:39 -0300 Subject: [PATCH 01/49] Allow Docker image to generate servers.json from environment In the Docker image, generate the servers.json with a single server by reading environment variables. --- Dockerfile | 1 + config/docker/servers.json_from_env.sh | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) create mode 100755 config/docker/servers.json_from_env.sh diff --git a/Dockerfile b/Dockerfile index 3b704baf..682bbbd5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,4 +9,5 @@ FROM nginx:1.19.6-alpine LABEL maintainer="Alejandro Celaya " 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/servers.json_from_env.sh /docker-entrypoint.d/30-shlink-servers-json.sh COPY --from=node /shlink-web-client/build /usr/share/nginx/html diff --git a/config/docker/servers.json_from_env.sh b/config/docker/servers.json_from_env.sh new file mode 100755 index 00000000..170e33f6 --- /dev/null +++ b/config/docker/servers.json_from_env.sh @@ -0,0 +1,16 @@ +#!/bin/sh + +set -e + +ME=$(basename $0) + +setup_single_shlink_server() { + [ -n "$SHLINK_CLIENT_SERVER_URL" ] || return 0 + [ -n "$SHLINK_CLIENT_API_KEY" ] || return 0 + local name="${SHLINK_CLIENT_SERVER_NAME:-Shlink}" + echo "[{\"name\":\"${name}\",\"url\":\"${SHLINK_CLIENT_SERVER_URL}\",\"apiKey\":\"${SHLINK_CLIENT_API_KEY}\"}]" > /usr/share/nginx/html/servers.json +} + +setup_single_shlink_server + +exit 0 From f626f9b046e028d7e910db3793fe28563ffde87c Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 29 May 2021 11:30:35 +0200 Subject: [PATCH 02/49] Renamed env vars --- Dockerfile | 2 +- config/docker/servers.json_from_env.sh | 16 ---------------- scripts/docker/servers.json_from_env.sh | 16 ++++++++++++++++ 3 files changed, 17 insertions(+), 17 deletions(-) delete mode 100755 config/docker/servers.json_from_env.sh create mode 100755 scripts/docker/servers.json_from_env.sh diff --git a/Dockerfile b/Dockerfile index 682bbbd5..dfe37fd6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,5 +9,5 @@ FROM nginx:1.19.6-alpine LABEL maintainer="Alejandro Celaya " 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/servers.json_from_env.sh /docker-entrypoint.d/30-shlink-servers-json.sh +COPY scripts/docker/servers.json_from_env.sh /docker-entrypoint.d/30-shlink-servers-json.sh COPY --from=node /shlink-web-client/build /usr/share/nginx/html diff --git a/config/docker/servers.json_from_env.sh b/config/docker/servers.json_from_env.sh deleted file mode 100755 index 170e33f6..00000000 --- a/config/docker/servers.json_from_env.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/sh - -set -e - -ME=$(basename $0) - -setup_single_shlink_server() { - [ -n "$SHLINK_CLIENT_SERVER_URL" ] || return 0 - [ -n "$SHLINK_CLIENT_API_KEY" ] || return 0 - local name="${SHLINK_CLIENT_SERVER_NAME:-Shlink}" - echo "[{\"name\":\"${name}\",\"url\":\"${SHLINK_CLIENT_SERVER_URL}\",\"apiKey\":\"${SHLINK_CLIENT_API_KEY}\"}]" > /usr/share/nginx/html/servers.json -} - -setup_single_shlink_server - -exit 0 diff --git a/scripts/docker/servers.json_from_env.sh b/scripts/docker/servers.json_from_env.sh new file mode 100755 index 00000000..4275f591 --- /dev/null +++ b/scripts/docker/servers.json_from_env.sh @@ -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 From 24801b068b2c61fd9c28c2d078198e67280a0585 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 29 May 2021 11:40:14 +0200 Subject: [PATCH 03/49] Updated changelog --- CHANGELOG.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6532f9fc..7bd57b24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] ### Added -* *Nothing* +* [#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. ### Changed * [#428](https://github.com/shlinkio/shlink-web-client/issues/428) Updated to StrykerJS 5. From 76ebbd318a96b5acc44bbec1b7bba1fd8008f321 Mon Sep 17 00:00:00 2001 From: Matias Garcia Isaia Date: Fri, 28 May 2021 21:30:51 -0300 Subject: [PATCH 04/49] Support servers.json in a conf.d directory In Cattle (and maybe other Docker environments) you can't mount specific files, but have to mount a whole volume as a directory. We now allow the servers.json to be looked for inside a specific folder to support that use case. --- config/docker/nginx.conf | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/config/docker/nginx.conf b/config/docker/nginx.conf index 1cb4901f..b76b7175 100644 --- a/config/docker/nginx.conf +++ b/config/docker/nginx.conf @@ -24,6 +24,12 @@ server { 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; } + + # servers.json may be on the root, or in it's own directory (ie, mounting a volume in Cattle) + location = /servers.json { + try_files /servers.json /conf.d/servers.json; + } + # When requesting a path without extension, try it, and return the index if not found # This allows HTML5 history paths to be handled by the client application location / { From bbc3342c0006f9d9c9e39fab97eb6f57f0798914 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 29 May 2021 11:53:06 +0200 Subject: [PATCH 05/49] Moved servers.json config on nginx above another less restrictive but conflicting rule --- config/docker/nginx.conf | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/config/docker/nginx.conf b/config/docker/nginx.conf index b76b7175..21276555 100644 --- a/config/docker/nginx.conf +++ b/config/docker/nginx.conf @@ -20,16 +20,16 @@ server { 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 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; } - # servers.json may be on the root, or in it's own directory (ie, mounting a volume in Cattle) - location = /servers.json { - try_files /servers.json /conf.d/servers.json; - } - # When requesting a path without extension, try it, and return the index if not found # This allows HTML5 history paths to be handled by the client application location / { From d5fadc56af42c850c77a475f7722a90ac48182a3 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 29 May 2021 11:54:08 +0200 Subject: [PATCH 06/49] Removed new empty line added by mistake --- config/docker/nginx.conf | 1 - 1 file changed, 1 deletion(-) diff --git a/config/docker/nginx.conf b/config/docker/nginx.conf index 21276555..cee606bb 100644 --- a/config/docker/nginx.conf +++ b/config/docker/nginx.conf @@ -29,7 +29,6 @@ server { 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; } - # When requesting a path without extension, try it, and return the index if not found # This allows HTML5 history paths to be handled by the client application location / { From ce04b8eb58d89c4d73e77d0e0f1465cd78ccc93d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Garc=C3=ADa=20Isa=C3=ADa?= Date: Sat, 29 May 2021 11:11:24 -0300 Subject: [PATCH 07/49] Update server.json alternative Docker configs in README.md See #432 & #433 --- README.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/README.md b/README.md index d58c59a8..b0e7a324 100644 --- a/README.md +++ b/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. 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.** > From 5a91b668dc9849c9e741d23821c25e18eabaa944 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 30 May 2021 17:50:54 +0200 Subject: [PATCH 08/49] Updated changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bd57b24..e69c52c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), * `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 a `conf.d` folder. + ### Changed * [#428](https://github.com/shlinkio/shlink-web-client/issues/428) Updated to StrykerJS 5. From a72d3b27204e730aea1395e9e00b4c3b20832d55 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 6 Jun 2021 19:14:18 +0200 Subject: [PATCH 09/49] Updated changelog --- CHANGELOG.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e795e03d..17868f61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ 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). -## [3.1.2] - 2021-06-06 +## [Unreleased] ### Added * [#433](https://github.com/shlinkio/shlink-web-client/pull/433) Added support to provide a default server to connect to via env vars: @@ -14,6 +14,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), * [#432](https://github.com/shlinkio/shlink-web-client/pull/432) Added support to provide the `servers.json` file inside a a `conf.d` folder. +### Changed +* *Nothing* + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* *Nothing* + + +## [3.1.2] - 2021-06-06 +### Added +* *Nothing* + ### Changed * [#428](https://github.com/shlinkio/shlink-web-client/issues/428) Updated to StrykerJS 5. From 6b338275d3a0e45ce71db8e4f8a431aea949a290 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 6 Jun 2021 19:24:57 +0200 Subject: [PATCH 10/49] Updated branch where the docker image builds unstable versions --- .github/workflows/docker-image-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-image-build.yml b/.github/workflows/docker-image-build.yml index 36ff3809..f02beff1 100644 --- a/.github/workflows/docker-image-build.yml +++ b/.github/workflows/docker-image-build.yml @@ -3,7 +3,7 @@ name: Build docker image on: push: branches: - - main + - develop tags: - 'v*' From b7af07c043eeb670a48d4473b6ffb89f32d49407 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 6 Jun 2021 19:27:43 +0200 Subject: [PATCH 11/49] Fixed docker build script so that it can work with develop branch --- scripts/docker/build | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/docker/build b/scripts/docker/build index e7b0c8a4..1a39c9cb 100755 --- a/scripts/docker/build +++ b/scripts/docker/build @@ -5,12 +5,12 @@ set -ex PLATFORMS="linux/arm/v7,linux/arm64/v8,linux/amd64" DOCKER_IMAGE="shlinkio/shlink-web-client" -if [[ "$GITHUB_REF" == *"main"* ]]; then +if [[ "$GITHUB_REF" == *"develop"* ]]; then docker buildx build --push \ --platform ${PLATFORMS} \ -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 VERSION=${GITHUB_REF#refs/tags/v} TAGS="-t ${DOCKER_IMAGE}:${VERSION}" From 342dda3ec976c0e2bc5b4ff65a2008e50f0288fd Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 13 Jun 2021 07:52:53 +0200 Subject: [PATCH 12/49] Fixed horizontal scroll --- CHANGELOG.md | 2 +- src/short-urls/helpers/ShortUrlsRow.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17868f61..26b7ea02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), * *Nothing* ### Fixed -* *Nothing* +* [#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 diff --git a/src/short-urls/helpers/ShortUrlsRow.tsx b/src/short-urls/helpers/ShortUrlsRow.tsx index db40e670..2b57399b 100644 --- a/src/short-urls/helpers/ShortUrlsRow.tsx +++ b/src/short-urls/helpers/ShortUrlsRow.tsx @@ -68,7 +68,7 @@ const ShortUrlsRow = ( {shortUrl.title ?? shortUrl.longUrl} {shortUrl.title && ( - + )} From 3a3babadeb56b068cf5d81764e9b8d5272987571 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 13 Jun 2021 09:51:10 +0200 Subject: [PATCH 13/49] Renamed script --- Dockerfile | 2 +- .../docker/{servers.json_from_env.sh => servers_from_env.sh} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename scripts/docker/{servers.json_from_env.sh => servers_from_env.sh} (100%) diff --git a/Dockerfile b/Dockerfile index dfe37fd6..78ba67ca 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,5 +9,5 @@ FROM nginx:1.19.6-alpine LABEL maintainer="Alejandro Celaya " 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 scripts/docker/servers.json_from_env.sh /docker-entrypoint.d/30-shlink-servers-json.sh +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 diff --git a/scripts/docker/servers.json_from_env.sh b/scripts/docker/servers_from_env.sh similarity index 100% rename from scripts/docker/servers.json_from_env.sh rename to scripts/docker/servers_from_env.sh From a3550f8e52b2d4c58937a843ee6606dae3dd278d Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 13 Jun 2021 09:55:07 +0200 Subject: [PATCH 14/49] Updated docker images --- Dockerfile | 4 ++-- docker-compose.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 78ba67ca..d495a4eb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,11 @@ -FROM node:14.15-alpine as node +FROM node:14.17-alpine as node COPY . /shlink-web-client ARG VERSION="latest" ENV VERSION ${VERSION} RUN cd /shlink-web-client && \ npm install && npm run build -- ${VERSION} --no-dist -FROM nginx:1.19.6-alpine +FROM nginx:1.21-alpine LABEL maintainer="Alejandro Celaya " 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 diff --git a/docker-compose.yml b/docker-compose.yml index 945494d6..e1e70d5c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,7 @@ version: '3' services: 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" volumes: - ./:/home/shlink/www From db0c43dcdd610ac80ac6b0c69fcd1241c9c83966 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 13 Jun 2021 11:07:32 +0200 Subject: [PATCH 15/49] Added column to display if a visit is a potential bot in the visits table --- src/utils/helpers/features.ts | 2 ++ src/visits/OrphanVisits.tsx | 7 +++-- src/visits/ShortUrlVisits.tsx | 7 +++-- src/visits/TagVisits.tsx | 7 +++-- src/visits/VisitsStats.tsx | 20 ++++++++++--- src/visits/VisitsTable.tsx | 36 +++++++++++++++++++---- src/visits/services/VisitsParser.ts | 3 +- src/visits/services/provideServices.ts | 6 ++-- src/visits/types/CommonVisitsProps.ts | 7 +++++ src/visits/types/index.ts | 2 ++ test/visits/VisitsTable.test.tsx | 2 ++ test/visits/services/VisitsParser.test.ts | 11 +++++++ 12 files changed, 88 insertions(+), 22 deletions(-) create mode 100644 src/visits/types/CommonVisitsProps.ts diff --git a/src/utils/helpers/features.ts b/src/utils/helpers/features.ts index 2caecc75..4d7633df 100644 --- a/src/utils/helpers/features.ts +++ b/src/utils/helpers/features.ts @@ -23,3 +23,5 @@ export const supportsOrphanVisits = supportsShortUrlTitle; export const supportsQrCodeMargin = supportsShortUrlTitle; export const supportsTagsInPatch = supportsShortUrlTitle; + +export const supportsBotVisits = serverMatchesVersions({ minVersion: '2.7.0' }); diff --git a/src/visits/OrphanVisits.tsx b/src/visits/OrphanVisits.tsx index b79cfad8..8a252e95 100644 --- a/src/visits/OrphanVisits.tsx +++ b/src/visits/OrphanVisits.tsx @@ -2,17 +2,16 @@ import { RouteComponentProps } from 'react-router'; import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; import { ShlinkVisitsParams } from '../api/types'; import { Topics } from '../mercure/helpers/Topics'; -import { Settings } from '../settings/reducers/settings'; import VisitsStats from './VisitsStats'; import { OrphanVisitsHeader } from './OrphanVisitsHeader'; import { NormalizedVisit, VisitsInfo } from './types'; import { VisitsExporter } from './services/VisitsExporter'; +import { CommonVisitsProps } from './types/CommonVisitsProps'; -export interface OrphanVisitsProps extends RouteComponentProps { +export interface OrphanVisitsProps extends CommonVisitsProps, RouteComponentProps { getOrphanVisits: (params: ShlinkVisitsParams) => void; orphanVisits: VisitsInfo; cancelGetOrphanVisits: () => void; - settings: Settings; } export const OrphanVisits = ({ exportVisits }: VisitsExporter) => boundToMercureHub(({ @@ -22,6 +21,7 @@ export const OrphanVisits = ({ exportVisits }: VisitsExporter) => boundToMercure orphanVisits, cancelGetOrphanVisits, settings, + selectedServer, }: OrphanVisitsProps) => { const exportCsv = (visits: NormalizedVisit[]) => exportVisits('orphan_visits.csv', visits); @@ -33,6 +33,7 @@ export const OrphanVisits = ({ exportVisits }: VisitsExporter) => boundToMercure baseUrl={url} settings={settings} exportCsv={exportCsv} + selectedServer={selectedServer} isOrphanVisits > diff --git a/src/visits/ShortUrlVisits.tsx b/src/visits/ShortUrlVisits.tsx index eac8b953..538b4eea 100644 --- a/src/visits/ShortUrlVisits.tsx +++ b/src/visits/ShortUrlVisits.tsx @@ -5,20 +5,19 @@ import { ShlinkVisitsParams } from '../api/types'; import { parseQuery } from '../utils/helpers/query'; import { Topics } from '../mercure/helpers/Topics'; import { ShortUrlDetail } from '../short-urls/reducers/shortUrlDetail'; -import { Settings } from '../settings/reducers/settings'; import { ShortUrlVisits as ShortUrlVisitsState } from './reducers/shortUrlVisits'; import ShortUrlVisitsHeader from './ShortUrlVisitsHeader'; import VisitsStats from './VisitsStats'; import { VisitsExporter } from './services/VisitsExporter'; import { NormalizedVisit } from './types'; +import { CommonVisitsProps } from './types/CommonVisitsProps'; -export interface ShortUrlVisitsProps extends RouteComponentProps<{ shortCode: string }> { +export interface ShortUrlVisitsProps extends CommonVisitsProps, RouteComponentProps<{ shortCode: string }> { getShortUrlVisits: (shortCode: string, query?: ShlinkVisitsParams) => void; shortUrlVisits: ShortUrlVisitsState; getShortUrlDetail: Function; shortUrlDetail: ShortUrlDetail; cancelGetShortUrlVisits: () => void; - settings: Settings; } const ShortUrlVisits = ({ exportVisits }: VisitsExporter) => boundToMercureHub(({ @@ -31,6 +30,7 @@ const ShortUrlVisits = ({ exportVisits }: VisitsExporter) => boundToMercureHub(( getShortUrlDetail, cancelGetShortUrlVisits, settings, + selectedServer, }: ShortUrlVisitsProps) => { const { shortCode } = params; const { domain } = parseQuery<{ domain?: string }>(search); @@ -53,6 +53,7 @@ const ShortUrlVisits = ({ exportVisits }: VisitsExporter) => boundToMercureHub(( domain={domain} settings={settings} exportCsv={exportCsv} + selectedServer={selectedServer} > diff --git a/src/visits/TagVisits.tsx b/src/visits/TagVisits.tsx index b7dd573b..d4d47eaa 100644 --- a/src/visits/TagVisits.tsx +++ b/src/visits/TagVisits.tsx @@ -3,18 +3,17 @@ import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; import ColorGenerator from '../utils/services/ColorGenerator'; import { ShlinkVisitsParams } from '../api/types'; import { Topics } from '../mercure/helpers/Topics'; -import { Settings } from '../settings/reducers/settings'; import { TagVisits as TagVisitsState } from './reducers/tagVisits'; import TagVisitsHeader from './TagVisitsHeader'; import VisitsStats from './VisitsStats'; import { VisitsExporter } from './services/VisitsExporter'; import { NormalizedVisit } from './types'; +import { CommonVisitsProps } from './types/CommonVisitsProps'; -export interface TagVisitsProps extends RouteComponentProps<{ tag: string }> { +export interface TagVisitsProps extends CommonVisitsProps, RouteComponentProps<{ tag: string }> { getTagVisits: (tag: string, query: any) => void; tagVisits: TagVisitsState; cancelGetTagVisits: () => void; - settings: Settings; } const TagVisits = (colorGenerator: ColorGenerator, { exportVisits }: VisitsExporter) => boundToMercureHub(({ @@ -24,6 +23,7 @@ const TagVisits = (colorGenerator: ColorGenerator, { exportVisits }: VisitsExpor tagVisits, cancelGetTagVisits, settings, + selectedServer, }: TagVisitsProps) => { const { tag } = params; const loadVisits = (params: ShlinkVisitsParams) => getTagVisits(tag, params); @@ -37,6 +37,7 @@ const TagVisits = (colorGenerator: ColorGenerator, { exportVisits }: VisitsExpor baseUrl={url} settings={settings} exportCsv={exportCsv} + selectedServer={selectedServer} > diff --git a/src/visits/VisitsStats.tsx b/src/visits/VisitsStats.tsx index ad2e13a1..628964ca 100644 --- a/src/visits/VisitsStats.tsx +++ b/src/visits/VisitsStats.tsx @@ -15,6 +15,7 @@ import { DateInterval, DateRange, intervalToDateRange } from '../utils/dates/typ import { Result } from '../utils/Result'; import { ShlinkApiError } from '../api/ShlinkApiError'; import { Settings } from '../settings/reducers/settings'; +import { SelectedServer } from '../servers/data'; import SortableBarGraph from './helpers/SortableBarGraph'; import GraphCard from './helpers/GraphCard'; import LineChartCard from './helpers/LineChartCard'; @@ -23,13 +24,14 @@ import { NormalizedOrphanVisit, NormalizedVisit, OrphanVisitType, VisitsInfo } f import OpenMapModalBtn from './helpers/OpenMapModalBtn'; import { processStatsFromVisits } from './services/VisitsParser'; import { OrphanVisitTypeDropdown } from './helpers/OrphanVisitTypeDropdown'; -import './VisitsStats.scss'; import { HighlightableProps, highlightedVisitsToStats, normalizeAndFilterVisits } from './types/helpers'; +import './VisitsStats.scss'; export interface VisitsStatsProps { getVisits: (params: Partial) => void; visitsInfo: VisitsInfo; settings: Settings; + selectedServer: SelectedServer; cancelGetVisits: () => void; baseUrl: string; domain?: string; @@ -67,9 +69,18 @@ const VisitsNavLink: FC = ({ subPath, title ); -const VisitsStats: FC = ( - { children, visitsInfo, getVisits, cancelGetVisits, baseUrl, domain, settings, exportCsv, isOrphanVisits = false }, -) => { +const VisitsStats: FC = ({ + children, + visitsInfo, + getVisits, + cancelGetVisits, + baseUrl, + domain, + settings, + exportCsv, + selectedServer, + isOrphanVisits = false, +}) => { const initialInterval: DateInterval = settings.visits?.defaultInterval ?? 'last30Days'; const [ dateRange, setDateRange ] = useState(intervalToDateRange(initialInterval)); const [ highlightedVisits, setHighlightedVisits ] = useState([]); @@ -243,6 +254,7 @@ const VisitsStats: FC = ( selectedVisits={highlightedVisits} setSelectedVisits={setSelectedVisits} isOrphanVisits={isOrphanVisits} + selectedServer={selectedServer} /> diff --git a/src/visits/VisitsTable.tsx b/src/visits/VisitsTable.tsx index 9eb0eef2..445bf017 100644 --- a/src/visits/VisitsTable.tsx +++ b/src/visits/VisitsTable.tsx @@ -6,12 +6,16 @@ import { faCaretDown as caretDownIcon, faCaretUp as caretUpIcon, faCheck as checkIcon, + faRobot as botIcon, } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { UncontrolledTooltip } from 'reactstrap'; import SimplePaginator from '../common/SimplePaginator'; import SearchField from '../utils/SearchField'; import { determineOrderDir, OrderDir } from '../utils/utils'; import { prettify } from '../utils/helpers/numbers'; +import { supportsBotVisits } from '../utils/helpers/features'; +import { SelectedServer } from '../servers/data'; import { NormalizedOrphanVisit, NormalizedVisit } from './types'; import './VisitsTable.scss'; @@ -21,9 +25,10 @@ interface VisitsTableProps { setSelectedVisits: (visits: NormalizedVisit[]) => void; matchMedia?: (query: string) => MediaQueryList; 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 { field?: OrderableFields; @@ -58,6 +63,7 @@ const VisitsTable = ({ visits, selectedVisits = [], setSelectedVisits, + selectedServer, matchMedia = window.matchMedia, isOrphanVisits = false, }: VisitsTableProps) => { @@ -69,10 +75,12 @@ const VisitsTable = ({ const [ order, setOrder ] = useState({ field: undefined, dir: undefined }); const resultSet = useMemo(() => calculateVisits(visits, searchTerm, order), [ searchTerm, order ]); const isFirstLoad = useRef(true); + const supportsBots = supportsBotVisits(selectedServer); const [ page, setPage ] = useState(1); const end = page * PAGE_SIZE; const start = end - PAGE_SIZE; + const fullSizeColSpan = 7 + Number(supportsBots) + Number(isOrphanVisits); const orderByColumn = (field: OrderableFields) => () => setOrder({ field, dir: determineOrderDir(field, order.field, order.dir) }); @@ -102,13 +110,19 @@ const VisitsTable = ({ setSelectedVisits( selectedVisits.length < resultSet.total ? resultSet.visitsGroups.flat() : [], )} > 0 })} /> + {supportsBots && ( + + + {renderOrderIcon('potentialBot')} + + )} Date {renderOrderIcon('date')} @@ -141,7 +155,7 @@ const VisitsTable = ({ )} - + @@ -149,7 +163,7 @@ const VisitsTable = ({ {!resultSet.visitsGroups[page - 1]?.length && ( - + No visits found with current filtering @@ -169,6 +183,18 @@ const VisitsTable = ({ {isSelected && } + {supportsBots && ( + + {visit.potentialBot && ( + <> + + + Potentially a visit from a bot or crawler + + + )} + + )} {visit.date} @@ -185,7 +211,7 @@ const VisitsTable = ({ {resultSet.total > PAGE_SIZE && ( - +
visits.redu ); export const normalizeVisits = map((visit: Visit): NormalizedVisit => { - const { userAgent, date, referer, visitLocation } = visit; + const { userAgent, date, referer, visitLocation, potentialBot = false } = visit; const common = { date, + potentialBot, ...parseUserAgent(userAgent), referer: extractDomain(referer), country: visitLocation?.countryName || 'Unknown', // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing diff --git a/src/visits/services/provideServices.ts b/src/visits/services/provideServices.ts index 733cac95..9eb3f5eb 100644 --- a/src/visits/services/provideServices.ts +++ b/src/visits/services/provideServices.ts @@ -18,19 +18,19 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('ShortUrlVisits', ShortUrlVisits, 'VisitsExporter'); bottle.decorator('ShortUrlVisits', connect( - [ 'shortUrlVisits', 'shortUrlDetail', 'mercureInfo', 'settings' ], + [ 'shortUrlVisits', 'shortUrlDetail', 'mercureInfo', 'settings', 'selectedServer' ], [ 'getShortUrlVisits', 'getShortUrlDetail', 'cancelGetShortUrlVisits', 'createNewVisits', 'loadMercureInfo' ], )); bottle.serviceFactory('TagVisits', TagVisits, 'ColorGenerator', 'VisitsExporter'); bottle.decorator('TagVisits', connect( - [ 'tagVisits', 'mercureInfo', 'settings' ], + [ 'tagVisits', 'mercureInfo', 'settings', 'selectedServer' ], [ 'getTagVisits', 'cancelGetTagVisits', 'createNewVisits', 'loadMercureInfo' ], )); bottle.serviceFactory('OrphanVisits', OrphanVisits, 'VisitsExporter'); bottle.decorator('OrphanVisits', connect( - [ 'orphanVisits', 'mercureInfo', 'settings' ], + [ 'orphanVisits', 'mercureInfo', 'settings', 'selectedServer' ], [ 'getOrphanVisits', 'cancelGetOrphanVisits', 'createNewVisits', 'loadMercureInfo' ], )); diff --git a/src/visits/types/CommonVisitsProps.ts b/src/visits/types/CommonVisitsProps.ts new file mode 100644 index 00000000..7e5a21b4 --- /dev/null +++ b/src/visits/types/CommonVisitsProps.ts @@ -0,0 +1,7 @@ +import { SelectedServer } from '../../servers/data'; +import { Settings } from '../../settings/reducers/settings'; + +export interface CommonVisitsProps { + selectedServer: SelectedServer; + settings: Settings; +} diff --git a/src/visits/types/index.ts b/src/visits/types/index.ts index 0e2879d5..ffaccb47 100644 --- a/src/visits/types/index.ts +++ b/src/visits/types/index.ts @@ -38,6 +38,7 @@ export interface RegularVisit { date: string; userAgent: string; visitLocation: VisitLocation | null; + potentialBot?: boolean; // Optional only when using Shlink older than v2.7 } export interface OrphanVisit extends RegularVisit { @@ -59,6 +60,7 @@ export interface NormalizedRegularVisit extends UserAgent { city: string; latitude?: number | null; longitude?: number | null; + potentialBot: boolean; } export interface NormalizedOrphanVisit extends NormalizedRegularVisit { diff --git a/test/visits/VisitsTable.test.tsx b/test/visits/VisitsTable.test.tsx index 393ab64d..65c71992 100644 --- a/test/visits/VisitsTable.test.tsx +++ b/test/visits/VisitsTable.test.tsx @@ -5,6 +5,7 @@ import { rangeOf } from '../../src/utils/utils'; import SimplePaginator from '../../src/common/SimplePaginator'; import SearchField from '../../src/utils/SearchField'; import { NormalizedVisit } from '../../src/visits/types'; +import { SelectedServer } from '../../src/servers/data'; describe('', () => { const matchMedia = () => Mock.of({ matches: false }); @@ -18,6 +19,7 @@ describe('', () => { setSelectedVisits={setSelectedVisits} matchMedia={matchMedia} isOrphanVisits={isOrphanVisits} + selectedServer={Mock.all()} />, ); diff --git a/test/visits/services/VisitsParser.test.ts b/test/visits/services/VisitsParser.test.ts index 7edafab8..d629985e 100644 --- a/test/visits/services/VisitsParser.test.ts +++ b/test/visits/services/VisitsParser.test.ts @@ -43,6 +43,7 @@ describe('VisitsParser', () => { }), Mock.of({ userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.106 Safari/537.36 OPR/38.0.2220.41', + potentialBot: true, }), ]; const orphanVisits: OrphanVisit[] = [ @@ -61,6 +62,7 @@ describe('VisitsParser', () => { Mock.of({ type: 'regular_404', visitedUrl: 'bar', + potentialBot: true, }), Mock.of({ type: 'invalid_short_url', @@ -73,6 +75,7 @@ describe('VisitsParser', () => { latitude: 123.45, longitude: -543.21, }, + potentialBot: false, }), ]; @@ -176,6 +179,7 @@ describe('VisitsParser', () => { date: undefined, latitude: 123.45, longitude: -543.21, + potentialBot: false, }, { browser: 'Firefox', @@ -186,6 +190,7 @@ describe('VisitsParser', () => { date: undefined, latitude: 1029, longitude: 6758, + potentialBot: false, }, { browser: 'Chrome', @@ -196,6 +201,7 @@ describe('VisitsParser', () => { date: undefined, latitude: undefined, longitude: undefined, + potentialBot: false, }, { browser: 'Chrome', @@ -206,6 +212,7 @@ describe('VisitsParser', () => { date: undefined, latitude: 123.45, longitude: -543.21, + potentialBot: false, }, { browser: 'Opera', @@ -216,6 +223,7 @@ describe('VisitsParser', () => { date: undefined, latitude: undefined, longitude: undefined, + potentialBot: true, }, ]); }); @@ -233,6 +241,7 @@ describe('VisitsParser', () => { longitude: 6758, type: 'base_url', visitedUrl: 'foo', + potentialBot: false, }, { type: 'regular_404', @@ -245,6 +254,7 @@ describe('VisitsParser', () => { longitude: undefined, os: 'Others', referer: 'Direct', + potentialBot: true, }, { browser: 'Chrome', @@ -257,6 +267,7 @@ describe('VisitsParser', () => { longitude: -543.21, type: 'invalid_short_url', visitedUrl: 'bar', + potentialBot: false, }, ]); }); From a30376344e7ee179d16525286b9daf9e7af63839 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 13 Jun 2021 11:38:13 +0200 Subject: [PATCH 16/49] Added tests covering visits table with potential bots --- src/visits/VisitsTable.tsx | 5 ++-- test/visits/VisitsTable.test.tsx | 43 +++++++++++++++++++------------- 2 files changed, 27 insertions(+), 21 deletions(-) diff --git a/src/visits/VisitsTable.tsx b/src/visits/VisitsTable.tsx index 445bf017..3c569ddc 100644 --- a/src/visits/VisitsTable.tsx +++ b/src/visits/VisitsTable.tsx @@ -19,7 +19,7 @@ import { SelectedServer } from '../servers/data'; import { NormalizedOrphanVisit, NormalizedVisit } from './types'; import './VisitsTable.scss'; -interface VisitsTableProps { +export interface VisitsTableProps { visits: NormalizedVisit[]; selectedVisits?: NormalizedVisit[]; setSelectedVisits: (visits: NormalizedVisit[]) => void; @@ -75,11 +75,10 @@ const VisitsTable = ({ const [ order, setOrder ] = useState({ field: undefined, dir: undefined }); const resultSet = useMemo(() => calculateVisits(visits, searchTerm, order), [ searchTerm, order ]); const isFirstLoad = useRef(true); - const supportsBots = supportsBotVisits(selectedServer); - const [ page, setPage ] = useState(1); const end = page * PAGE_SIZE; const start = end - PAGE_SIZE; + const supportsBots = supportsBotVisits(selectedServer); const fullSizeColSpan = 7 + Number(supportsBots) + Number(isOrphanVisits); const orderByColumn = (field: OrderableFields) => diff --git a/test/visits/VisitsTable.test.tsx b/test/visits/VisitsTable.test.tsx index 65c71992..6972cf35 100644 --- a/test/visits/VisitsTable.test.tsx +++ b/test/visits/VisitsTable.test.tsx @@ -1,45 +1,52 @@ import { shallow, ShallowWrapper } from 'enzyme'; 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 SimplePaginator from '../../src/common/SimplePaginator'; import SearchField from '../../src/utils/SearchField'; import { NormalizedVisit } from '../../src/visits/types'; -import { SelectedServer } from '../../src/servers/data'; +import { ReachableServer, SelectedServer } from '../../src/servers/data'; +import { SemVer } from '../../src/utils/helpers/version'; describe('', () => { const matchMedia = () => Mock.of({ matches: false }); const setSelectedVisits = jest.fn(); let wrapper: ShallowWrapper; - const createWrapper = (visits: NormalizedVisit[], selectedVisits: NormalizedVisit[] = [], isOrphanVisits = false) => { + const wrapperFactory = (props: Partial = {}) => { wrapper = shallow( ()} + {...props} + matchMedia={matchMedia} + setSelectedVisits={setSelectedVisits} />, ); return wrapper; }; + const createWrapper = (visits: NormalizedVisit[], selectedVisits: NormalizedVisit[] = []) => wrapperFactory( + { visits, selectedVisits }, + ); + const createOrphanVisitsWrapper = (isOrphanVisits: boolean) => wrapperFactory({ isOrphanVisits }); + const createServerVersionWrapper = (version: SemVer) => wrapperFactory({ + selectedServer: Mock.of({ printableVersion: version, version }), + }); afterEach(jest.resetAllMocks); afterEach(() => wrapper?.unmount()); - it('renders columns as expected', () => { - const wrapper = createWrapper([]); + it.each([ + [ '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'); - expect(th).toHaveLength(7); - expect(th.at(1).text()).toContain('Date'); - expect(th.at(2).text()).toContain('Country'); - 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'); + expect(th).toHaveLength(expectedColumns.length + 1); + expectedColumns.forEach((column, index) => { + expect(th.at(index + 1).html()).toContain(column); + }); }); it('shows warning when no visits are found', () => { @@ -142,7 +149,7 @@ describe('', () => { [ true, 8 ], [ false, 7 ], ])('displays proper amount of columns for orphan and non-orphan visits', (isOrphanVisits, expectedCols) => { - const wrapper = createWrapper([], [], isOrphanVisits); + const wrapper = createOrphanVisitsWrapper(isOrphanVisits); const rowsWithColspan = wrapper.find('[colSpan]'); const cols = wrapper.find('th'); From 151175dc708d7efdadb2860b32adc0db208eff1b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 13 Jun 2021 11:41:41 +0200 Subject: [PATCH 17/49] Updated changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26b7ea02..a096c278 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), * `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 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. ### Changed * *Nothing* From 1cf96c72124f4da99ab6616c973dc39eaa71d6d4 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 13 Jun 2021 11:49:53 +0200 Subject: [PATCH 18/49] Improved VisitsTable test --- test/visits/VisitsTable.test.tsx | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/test/visits/VisitsTable.test.tsx b/test/visits/VisitsTable.test.tsx index 6972cf35..7abc95a0 100644 --- a/test/visits/VisitsTable.test.tsx +++ b/test/visits/VisitsTable.test.tsx @@ -28,10 +28,20 @@ describe('', () => { const createWrapper = (visits: NormalizedVisit[], selectedVisits: NormalizedVisit[] = []) => wrapperFactory( { visits, selectedVisits }, ); - const createOrphanVisitsWrapper = (isOrphanVisits: boolean) => wrapperFactory({ isOrphanVisits }); + const createOrphanVisitsWrapper = (isOrphanVisits: boolean, version: SemVer) => wrapperFactory({ + isOrphanVisits, + selectedServer: Mock.of({ printableVersion: version, version }), + }); const createServerVersionWrapper = (version: SemVer) => wrapperFactory({ selectedServer: Mock.of({ printableVersion: version, version }), }); + const createWrapperWithBots = () => wrapperFactory({ + selectedServer: Mock.of({ printableVersion: '2.7.0', version: '2.7.0' }), + visits: [ + Mock.of({ potentialBot: false }), + Mock.of({ potentialBot: true }), + ], + }); afterEach(jest.resetAllMocks); afterEach(() => wrapper?.unmount()); @@ -146,10 +156,12 @@ describe('', () => { }); it.each([ - [ true, 8 ], - [ false, 7 ], - ])('displays proper amount of columns for orphan and non-orphan visits', (isOrphanVisits, expectedCols) => { - const wrapper = createOrphanVisitsWrapper(isOrphanVisits); + [ true, '2.6.0' as SemVer, 8 ], + [ false, '2.6.0' as SemVer, 7 ], + [ true, '2.7.0' as SemVer, 9 ], + [ 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 cols = wrapper.find('th'); @@ -157,4 +169,12 @@ describe('', () => { expect(rowsWithColspan).toHaveLength(2); 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'); + }); }); From 7b80948eeae4aa215d5d171434dc9b51744a025b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 13 Jun 2021 11:54:51 +0200 Subject: [PATCH 19/49] Fixed TS errors in tests --- test/visits/OrphanVisits.test.tsx | 2 ++ test/visits/VisitsStats.test.tsx | 2 ++ test/visits/services/VisitsExporter.test.ts | 1 + 3 files changed, 5 insertions(+) diff --git a/test/visits/OrphanVisits.test.tsx b/test/visits/OrphanVisits.test.tsx index 020d54f9..e980c4fa 100644 --- a/test/visits/OrphanVisits.test.tsx +++ b/test/visits/OrphanVisits.test.tsx @@ -9,6 +9,7 @@ import VisitsStats from '../../src/visits/VisitsStats'; import { OrphanVisitsHeader } from '../../src/visits/OrphanVisitsHeader'; import { Settings } from '../../src/settings/reducers/settings'; import { VisitsExporter } from '../../src/visits/services/VisitsExporter'; +import { SelectedServer } from '../../src/servers/data'; describe('', () => { it('wraps visits stats and header', () => { @@ -28,6 +29,7 @@ describe('', () => { location={Mock.all()} match={Mock.of({ url: 'the_base_url' })} settings={Mock.all()} + selectedServer={Mock.all()} />, ).dive(); const stats = wrapper.find(VisitsStats); diff --git a/test/visits/VisitsStats.test.tsx b/test/visits/VisitsStats.test.tsx index 2edb4fbe..c67bff66 100644 --- a/test/visits/VisitsStats.test.tsx +++ b/test/visits/VisitsStats.test.tsx @@ -10,6 +10,7 @@ import LineChartCard from '../../src/visits/helpers/LineChartCard'; import VisitsTable from '../../src/visits/VisitsTable'; import { Result } from '../../src/utils/Result'; import { Settings } from '../../src/settings/reducers/settings'; +import { SelectedServer } from '../../src/servers/data'; describe('', () => { const visits = [ Mock.all(), Mock.all(), Mock.all() ]; @@ -27,6 +28,7 @@ describe('', () => { baseUrl={''} settings={Mock.all()} exportCsv={exportCsv} + selectedServer={Mock.all()} />, ); diff --git a/test/visits/services/VisitsExporter.test.ts b/test/visits/services/VisitsExporter.test.ts index e21c923a..da91fd78 100644 --- a/test/visits/services/VisitsExporter.test.ts +++ b/test/visits/services/VisitsExporter.test.ts @@ -39,6 +39,7 @@ describe('VisitsExporter', () => { longitude: 0, os: 'os', referer: 'referer', + potentialBot: false, }, ]; From 638ce8978030f47f3ef259c055455df388030aab Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 22 Jun 2021 20:34:28 +0200 Subject: [PATCH 20/49] Improved dropdown to filter visits, adding support to filter out bots --- src/utils/DropdownBtn.tsx | 6 +- src/visits/VisitsStats.tsx | 25 +++---- .../helpers/OrphanVisitTypeDropdown.tsx | 26 ------- src/visits/helpers/VisitsFilterDropdown.tsx | 49 +++++++++++++ src/visits/types/helpers.ts | 28 +++++--- .../helpers/OrphanVisitTypeDropdown.test.tsx | 56 --------------- .../helpers/VisitsFilterDropdown.test.tsx | 71 +++++++++++++++++++ 7 files changed, 151 insertions(+), 110 deletions(-) delete mode 100644 src/visits/helpers/OrphanVisitTypeDropdown.tsx create mode 100644 src/visits/helpers/VisitsFilterDropdown.tsx delete mode 100644 test/visits/helpers/OrphanVisitTypeDropdown.test.tsx create mode 100644 test/visits/helpers/VisitsFilterDropdown.test.tsx diff --git a/src/utils/DropdownBtn.tsx b/src/utils/DropdownBtn.tsx index b658c218..7aeec368 100644 --- a/src/utils/DropdownBtn.tsx +++ b/src/utils/DropdownBtn.tsx @@ -9,18 +9,20 @@ export interface DropdownBtnProps { className?: string; dropdownClassName?: string; right?: boolean; + minWidth?: number; } export const DropdownBtn: FC = ( - { text, disabled = false, className = '', children, dropdownClassName, right = false }, + { text, disabled = false, className = '', children, dropdownClassName, right = false, minWidth }, ) => { const [ isOpen, toggle ] = useToggle(); const toggleClasses = `dropdown-btn__toggle btn-block ${className}`; + const style = { minWidth: minWidth && `${minWidth}px` }; return ( {text} - {children} + {children} ); }; diff --git a/src/visits/VisitsStats.tsx b/src/visits/VisitsStats.tsx index 628964ca..f0cad255 100644 --- a/src/visits/VisitsStats.tsx +++ b/src/visits/VisitsStats.tsx @@ -20,10 +20,10 @@ import SortableBarGraph from './helpers/SortableBarGraph'; import GraphCard from './helpers/GraphCard'; import LineChartCard from './helpers/LineChartCard'; import VisitsTable from './VisitsTable'; -import { NormalizedOrphanVisit, NormalizedVisit, OrphanVisitType, VisitsInfo } from './types'; +import { NormalizedOrphanVisit, NormalizedVisit, VisitsInfo } from './types'; import OpenMapModalBtn from './helpers/OpenMapModalBtn'; import { processStatsFromVisits } from './services/VisitsParser'; -import { OrphanVisitTypeDropdown } from './helpers/OrphanVisitTypeDropdown'; +import { VisitsFilter, VisitsFilterDropdown } from './helpers/VisitsFilterDropdown'; import { HighlightableProps, highlightedVisitsToStats, normalizeAndFilterVisits } from './types/helpers'; import './VisitsStats.scss'; @@ -85,7 +85,7 @@ const VisitsStats: FC = ({ const [ dateRange, setDateRange ] = useState(intervalToDateRange(initialInterval)); const [ highlightedVisits, setHighlightedVisits ] = useState([]); const [ highlightedLabel, setHighlightedLabel ] = useState(); - const [ orphanVisitType, setOrphanVisitType ] = useState(); + const [ visitsFilter, setVisitsFilter ] = useState({}); const buildSectionUrl = (subPath?: string) => { const query = domain ? `?domain=${domain}` : ''; @@ -93,10 +93,7 @@ const VisitsStats: FC = ({ return !subPath ? `${baseUrl}${query}` : `${baseUrl}${subPath}${query}`; }; const { visits, loading, loadingLarge, error, errorData, progress } = visitsInfo; - const normalizedVisits = useMemo( - () => normalizeAndFilterVisits(visits, orphanVisitType), - [ visits, orphanVisitType ], - ); + const normalizedVisits = useMemo(() => normalizeAndFilterVisits(visits, visitsFilter), [ visits, visitsFilter ]); const { os, browsers, referrers, countries, cities, citiesForMap, visitedUrls } = useMemo( () => processStatsFromVisits(normalizedVisits), [ normalizedVisits ], @@ -282,14 +279,12 @@ const VisitsStats: FC = ({ onDatesChange={setDateRange} />
- {isOrphanVisits && ( - - )} +
{visits.length > 0 && ( diff --git a/src/visits/helpers/OrphanVisitTypeDropdown.tsx b/src/visits/helpers/OrphanVisitTypeDropdown.tsx deleted file mode 100644 index 61273c14..00000000 --- a/src/visits/helpers/OrphanVisitTypeDropdown.tsx +++ /dev/null @@ -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) => ( - - onChange('base_url')}> - Base URL - - onChange('invalid_short_url')}> - Invalid short URL - - onChange('regular_404')}> - Regular 404 - - - onChange(undefined)}>Clear selection - -); diff --git a/src/visits/helpers/VisitsFilterDropdown.tsx b/src/visits/helpers/VisitsFilterDropdown.tsx new file mode 100644 index 00000000..85e46869 --- /dev/null +++ b/src/visits/helpers/VisitsFilterDropdown.tsx @@ -0,0 +1,49 @@ +import { DropdownItem, DropdownItemProps } from 'reactstrap'; // eslint-disable-line import/named +import { OrphanVisitType } from '../types'; +import { DropdownBtn } from '../../utils/DropdownBtn'; +import { hasValue } from '../../utils/utils'; + +export interface VisitsFilter { + orphanVisitsType?: OrphanVisitType | undefined; + excludeBots?: boolean; +} + +interface VisitsFilterDropdownProps { + onChange: (filters: VisitsFilter) => void; + selected?: VisitsFilter; + className?: string; + isOrphanVisits: boolean; +} + +export const VisitsFilterDropdown = ( + { onChange, selected = {}, className, isOrphanVisits }: VisitsFilterDropdownProps, +) => { + const { orphanVisitsType, excludeBots = false } = selected; + const propsForOrphanVisitsTypeItem = (type: OrphanVisitType): DropdownItemProps => ({ + active: orphanVisitsType === type, + onClick: () => onChange({ ...selected, orphanVisitsType: type }), + }); + + return ( + + Bots: + onChange({ ...selected, excludeBots: !selected?.excludeBots })}> + Exclude potential bots + + + {isOrphanVisits && ( + <> + + + Orphan visits type: + Base URL + Invalid short URL + Regular 404 + + )} + + + onChange({})}>Clear filters + + ); +}; diff --git a/src/visits/types/helpers.ts b/src/visits/types/helpers.ts index d2691504..00d248d8 100644 --- a/src/visits/types/helpers.ts +++ b/src/visits/types/helpers.ts @@ -1,14 +1,8 @@ import { countBy, filter, groupBy, pipe, prop } from 'ramda'; import { normalizeVisits } from '../services/VisitsParser'; -import { - Visit, - OrphanVisit, - CreateVisit, - NormalizedVisit, - NormalizedOrphanVisit, - Stats, - OrphanVisitType, -} from './index'; +import { VisitsFilter } from '../helpers/VisitsFilterDropdown'; +import { hasValue } from '../../utils/utils'; +import { Visit, OrphanVisit, CreateVisit, NormalizedVisit, NormalizedOrphanVisit, Stats } from './index'; export const isOrphanVisit = (visit: Visit): visit is OrphanVisit => visit.hasOwnProperty('visitedUrl'); @@ -35,7 +29,19 @@ export const highlightedVisitsToStats = ( property: HighlightableProps, ): Stats => countBy(prop(property) as any, highlightedVisits); -export const normalizeAndFilterVisits = (visits: Visit[], type: OrphanVisitType | undefined) => pipe( +export const normalizeAndFilterVisits = (visits: Visit[], filters: VisitsFilter) => pipe( normalizeVisits, - filter((normalizedVisit) => type === undefined || (normalizedVisit as NormalizedOrphanVisit).type === type), + filter((normalizedVisit: NormalizedVisit) => { + if (!hasValue(filters)) { + return true; + } + + const { orphanVisitsType, excludeBots } = filters; + + if (orphanVisitsType && orphanVisitsType !== (normalizedVisit as NormalizedOrphanVisit).type) { + return false; + } + + return !(excludeBots && normalizedVisit.potentialBot); + }), )(visits); diff --git a/test/visits/helpers/OrphanVisitTypeDropdown.test.tsx b/test/visits/helpers/OrphanVisitTypeDropdown.test.tsx deleted file mode 100644 index c41b340a..00000000 --- a/test/visits/helpers/OrphanVisitTypeDropdown.test.tsx +++ /dev/null @@ -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('', () => { - let wrapper: ShallowWrapper; - const onChange = jest.fn(); - const createWrapper = (selected?: OrphanVisitType) => { - wrapper = shallow(); - - 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); - }); -}); diff --git a/test/visits/helpers/VisitsFilterDropdown.test.tsx b/test/visits/helpers/VisitsFilterDropdown.test.tsx new file mode 100644 index 00000000..a947eb31 --- /dev/null +++ b/test/visits/helpers/VisitsFilterDropdown.test.tsx @@ -0,0 +1,71 @@ +import { shallow, ShallowWrapper } from 'enzyme'; +import { DropdownItem } from 'reactstrap'; +import { OrphanVisitType } from '../../../src/visits/types'; +import { VisitsFilter, VisitsFilterDropdown } from '../../../src/visits/helpers/VisitsFilterDropdown'; + +describe('', () => { + let wrapper: ShallowWrapper; + const onChange = jest.fn(); + const createWrapper = (selected: VisitsFilter = {}, isOrphanVisits = true) => { + wrapper = shallow( + , + ); + + 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); + }); +}); From affe2309b067f1baaf12f4f9f0287fedec8dd647 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 22 Jun 2021 21:01:20 +0200 Subject: [PATCH 21/49] Ensured filter for bots does not show for Shlink older than 2.7.0 --- src/visits/VisitsStats.tsx | 3 +++ src/visits/helpers/VisitsFilterDropdown.tsx | 22 +++++++++++++------ test/short-urls/EditShortUrl.test.tsx | 2 +- test/short-urls/ShortUrlForm.test.tsx | 2 +- .../helpers/VisitsFilterDropdown.test.tsx | 20 ++++++++++++++++- 5 files changed, 39 insertions(+), 10 deletions(-) diff --git a/src/visits/VisitsStats.tsx b/src/visits/VisitsStats.tsx index f0cad255..feb354f1 100644 --- a/src/visits/VisitsStats.tsx +++ b/src/visits/VisitsStats.tsx @@ -16,6 +16,7 @@ import { Result } from '../utils/Result'; import { ShlinkApiError } from '../api/ShlinkApiError'; import { Settings } from '../settings/reducers/settings'; import { SelectedServer } from '../servers/data'; +import { supportsBotVisits } from '../utils/helpers/features'; import SortableBarGraph from './helpers/SortableBarGraph'; import GraphCard from './helpers/GraphCard'; import LineChartCard from './helpers/LineChartCard'; @@ -86,6 +87,7 @@ const VisitsStats: FC = ({ const [ highlightedVisits, setHighlightedVisits ] = useState([]); const [ highlightedLabel, setHighlightedLabel ] = useState(); const [ visitsFilter, setVisitsFilter ] = useState({}); + const botsSupported = supportsBotVisits(selectedServer); const buildSectionUrl = (subPath?: string) => { const query = domain ? `?domain=${domain}` : ''; @@ -282,6 +284,7 @@ const VisitsStats: FC = ({ diff --git a/src/visits/helpers/VisitsFilterDropdown.tsx b/src/visits/helpers/VisitsFilterDropdown.tsx index 85e46869..6cbdcdc9 100644 --- a/src/visits/helpers/VisitsFilterDropdown.tsx +++ b/src/visits/helpers/VisitsFilterDropdown.tsx @@ -13,28 +13,36 @@ interface VisitsFilterDropdownProps { selected?: VisitsFilter; className?: string; isOrphanVisits: boolean; + botsSupported: boolean; } export const VisitsFilterDropdown = ( - { onChange, selected = {}, className, isOrphanVisits }: VisitsFilterDropdownProps, + { 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 }), }); + const onBotsClick = () => onChange({ ...selected, excludeBots: !selected?.excludeBots }); return ( - Bots: - onChange({ ...selected, excludeBots: !selected?.excludeBots })}> - Exclude potential bots - + {botsSupported && ( + <> + Bots: + Exclude potential bots + + )} + + {botsSupported && isOrphanVisits && } {isOrphanVisits && ( <> - - Orphan visits type: Base URL Invalid short URL diff --git a/test/short-urls/EditShortUrl.test.tsx b/test/short-urls/EditShortUrl.test.tsx index 2522cfee..51243bf7 100644 --- a/test/short-urls/EditShortUrl.test.tsx +++ b/test/short-urls/EditShortUrl.test.tsx @@ -14,7 +14,7 @@ describe('', () => { const ShortUrlForm = () => null; const goBack = jest.fn(); const getShortUrlDetail = jest.fn(); - const editShortUrl = jest.fn(); + const editShortUrl = jest.fn(async () => Promise.resolve()); const shortUrlCreation = { validateUrls: true }; const createWrapper = (detail: Partial = {}, edition: Partial = {}) => { const EditSHortUrl = createEditShortUrl(ShortUrlForm); diff --git a/test/short-urls/ShortUrlForm.test.tsx b/test/short-urls/ShortUrlForm.test.tsx index b5b9dc6d..7fca5360 100644 --- a/test/short-urls/ShortUrlForm.test.tsx +++ b/test/short-urls/ShortUrlForm.test.tsx @@ -12,7 +12,7 @@ import { SimpleCard } from '../../src/utils/SimpleCard'; describe('', () => { let wrapper: ShallowWrapper; const TagsSelector = () => null; - const createShortUrl = jest.fn(); + const createShortUrl = jest.fn(async () => Promise.resolve()); const createWrapper = (selectedServer: SelectedServer = null, mode: Mode = 'create') => { const ShortUrlForm = createShortUrlForm(TagsSelector, () => null); diff --git a/test/visits/helpers/VisitsFilterDropdown.test.tsx b/test/visits/helpers/VisitsFilterDropdown.test.tsx index a947eb31..9476ac9c 100644 --- a/test/visits/helpers/VisitsFilterDropdown.test.tsx +++ b/test/visits/helpers/VisitsFilterDropdown.test.tsx @@ -8,7 +8,12 @@ describe('', () => { const onChange = jest.fn(); const createWrapper = (selected: VisitsFilter = {}, isOrphanVisits = true) => { wrapper = shallow( - , + , ); return wrapper; @@ -68,4 +73,17 @@ describe('', () => { expect(onChange).toHaveBeenCalledWith(expectedSelection); }); + + it('does not render the component when neither orphan visits or bots filtering will be displayed', () => { + const wrapper = shallow( + , + ); + + expect(wrapper.text()).toEqual(''); + }); }); From c4ed838510a07e310cacd31a59aceac121bcdba3 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 22 Jun 2021 21:06:29 +0200 Subject: [PATCH 22/49] Updated changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a096c278..522ddf62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), * [#432](https://github.com/shlinkio/shlink-web-client/pull/432) Added support to provide the `servers.json` file inside a 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. ### Changed * *Nothing* From 5bd57e71fd8551e5805cf56e9ff82a54867ff417 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 22 Jun 2021 21:12:06 +0200 Subject: [PATCH 23/49] Improved DropdownBtn test --- test/utils/DropdownBtn.test.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/utils/DropdownBtn.test.tsx b/test/utils/DropdownBtn.test.tsx index 2076f6ec..a8ee2012 100644 --- a/test/utils/DropdownBtn.test.tsx +++ b/test/utils/DropdownBtn.test.tsx @@ -38,4 +38,15 @@ describe('', () => { 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); + }); }); From 5ef719c592de8bbeba289a9181a05a6a44075ae6 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 23 Jun 2021 19:52:19 +0200 Subject: [PATCH 24/49] Added support to set crawlable short URLs during creation and edition --- src/short-urls/EditShortUrl.tsx | 1 + src/short-urls/ShortUrlForm.tsx | 38 ++++++++++-------- src/short-urls/data/index.ts | 2 + .../helpers/ShortUrlFormCheckboxGroup.tsx | 39 +++++++++++++++++++ src/utils/helpers/features.ts | 2 + 5 files changed, 65 insertions(+), 17 deletions(-) create mode 100644 src/short-urls/helpers/ShortUrlFormCheckboxGroup.tsx diff --git a/src/short-urls/EditShortUrl.tsx b/src/short-urls/EditShortUrl.tsx index fdcec096..56151bfa 100644 --- a/src/short-urls/EditShortUrl.tsx +++ b/src/short-urls/EditShortUrl.tsx @@ -41,6 +41,7 @@ const getInitialState = (shortUrl?: ShortUrl, settings?: ShortUrlCreationSetting validSince: shortUrl.meta.validSince ?? undefined, validUntil: shortUrl.meta.validUntil ?? undefined, maxVisits: shortUrl.meta.maxVisits ?? undefined, + crawlable: shortUrl.crawlable, validateUrl, }; }; diff --git a/src/short-urls/ShortUrlForm.tsx b/src/short-urls/ShortUrlForm.tsx index 51714bbc..e30b6c7a 100644 --- a/src/short-urls/ShortUrlForm.tsx +++ b/src/short-urls/ShortUrlForm.tsx @@ -6,6 +6,7 @@ import m from 'moment'; import classNames from 'classnames'; import DateInput, { DateInputProps } from '../utils/DateInput'; import { + supportsCrawlableVisits, supportsListingDomains, supportsSettingShortCodeLength, supportsShortUrlTitle, @@ -20,6 +21,7 @@ import { DomainSelectorProps } from '../domains/DomainSelector'; import { formatIsoDate } from '../utils/helpers/date'; import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon'; import { ShortUrlData } from './data'; +import { ShortUrlFormCheckboxGroup } from './helpers/ShortUrlFormCheckboxGroup'; import './ShortUrlForm.scss'; export type Mode = 'create' | 'create-basic' | 'edit'; @@ -108,7 +110,8 @@ export const ShortUrlForm = ( 'col-sm-12': !showCustomizeCard, }); const showValidateUrl = supportsValidateUrl(selectedServer); - const showExtraValidationsCard = showValidateUrl || !isEdit; + const showCrawlableControl = supportsCrawlableVisits(selectedServer); + const showExtraValidationsCard = showValidateUrl || showCrawlableControl || !isEdit; return (
@@ -167,23 +170,24 @@ export const ShortUrlForm = ( {showExtraValidationsCard && ( - - {!isEdit && ( -

- Make sure the long URL is valid, or ensure an existing short URL is returned if it matches all - provided data. -

- )} + {showValidateUrl && ( -

- setShortUrlData({ ...shortUrlData, validateUrl })} - > - Validate URL - -

+ setShortUrlData({ ...shortUrlData, validateUrl })} + > + Validate URL + + )} + {showCrawlableControl && ( + setShortUrlData({ ...shortUrlData, crawlable })} + > + Make it crawlable + )} {!isEdit && (

diff --git a/src/short-urls/data/index.ts b/src/short-urls/data/index.ts index c3e5dfea..341f7e8f 100644 --- a/src/short-urls/data/index.ts +++ b/src/short-urls/data/index.ts @@ -9,6 +9,7 @@ export interface EditShortUrlData { validUntil?: m.Moment | string | null; maxVisits?: number | null; validateUrl?: boolean; + crawlable?: boolean; } export interface ShortUrlData extends EditShortUrlData { @@ -29,6 +30,7 @@ export interface ShortUrl { tags: string[]; domain: string | null; title?: string | null; + crawlable?: boolean; } export interface ShortUrlMeta { diff --git a/src/short-urls/helpers/ShortUrlFormCheckboxGroup.tsx b/src/short-urls/helpers/ShortUrlFormCheckboxGroup.tsx new file mode 100644 index 00000000..91dff0bc --- /dev/null +++ b/src/short-urls/helpers/ShortUrlFormCheckboxGroup.tsx @@ -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) => void; + infoTooltip?: string; +} + +const InfoTooltip: FC<{ tooltip: string }> = ({ tooltip }) => { + const ref = useRef(); + + return ( + <> + { + ref.current = el; + }} + > + + + ref.current) as any} placement="right">{tooltip} + + ); +}; + +export const ShortUrlFormCheckboxGroup: FC = ( + { children, infoTooltip, checked, onChange }, +) => ( +

+ + {children} + + {infoTooltip && } +

+); diff --git a/src/utils/helpers/features.ts b/src/utils/helpers/features.ts index 4d7633df..9eb314f2 100644 --- a/src/utils/helpers/features.ts +++ b/src/utils/helpers/features.ts @@ -25,3 +25,5 @@ export const supportsQrCodeMargin = supportsShortUrlTitle; export const supportsTagsInPatch = supportsShortUrlTitle; export const supportsBotVisits = serverMatchesVersions({ minVersion: '2.7.0' }); + +export const supportsCrawlableVisits = supportsBotVisits; From 55716a8f7f05fe25513598d3c4d6e587830b6869 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 23 Jun 2021 19:59:06 +0200 Subject: [PATCH 25/49] Created ShortUrlFormCheckboxGroup test --- .../helpers/ShortUrlFormCheckboxGroup.test.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 test/short-urls/helpers/ShortUrlFormCheckboxGroup.test.tsx diff --git a/test/short-urls/helpers/ShortUrlFormCheckboxGroup.test.tsx b/test/short-urls/helpers/ShortUrlFormCheckboxGroup.test.tsx new file mode 100644 index 00000000..76df7496 --- /dev/null +++ b/test/short-urls/helpers/ShortUrlFormCheckboxGroup.test.tsx @@ -0,0 +1,16 @@ +import { shallow } from 'enzyme'; +import { ShortUrlFormCheckboxGroup } from '../../../src/short-urls/helpers/ShortUrlFormCheckboxGroup'; +import Checkbox from '../../../src/utils/Checkbox'; + +describe('', () => { + test.each([ + [ undefined, '', 0 ], + [ 'This is the tooltip', 'mr-2', 1 ], + ])('renders tooltip only when provided', (infoTooltip, expectedClassName, expectedAmountOfTooltips) => { + const wrapper = shallow(); + const checkbox = wrapper.find(Checkbox); + + expect(checkbox.prop('className')).toEqual(expectedClassName); + expect(wrapper.find('InfoTooltip')).toHaveLength(expectedAmountOfTooltips); + }); +}); From d718329b52e09822c9fbe7ec1a55a5a5820077f2 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 23 Jun 2021 19:59:47 +0200 Subject: [PATCH 26/49] Updated changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 522ddf62..065c3ce6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), * [#432](https://github.com/shlinkio/shlink-web-client/pull/432) Added support to provide the `servers.json` file inside a 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. ### Changed * *Nothing* From 4be1a295d83ecd38e1924de162b9398e7d3f3e4d Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 24 Jun 2021 20:13:06 +0200 Subject: [PATCH 27/49] Replaced most of the usages of moment with date-fns --- package-lock.json | 6 +-- package.json | 1 + src/short-urls/SearchBar.tsx | 4 +- src/short-urls/ShortUrlForm.tsx | 9 +++-- src/short-urls/data/index.ts | 5 +-- src/short-urls/helpers/ShortUrlsRow.tsx | 6 +-- src/utils/DateInput.tsx | 27 ++----------- src/utils/Time.tsx | 18 +++++++++ src/utils/dates/DateRangeRow.tsx | 5 +-- src/utils/dates/types/index.ts | 24 +++++------ src/utils/helpers/date.ts | 19 +++++---- src/visits/ShortUrlVisitsHeader.tsx | 12 ++---- src/visits/VisitsTable.tsx | 6 +-- test/short-urls/ShortUrlForm.test.tsx | 10 ++--- test/short-urls/helpers/ShortUrlsRow.test.tsx | 10 ++--- test/utils/DateInput.test.tsx | 3 +- test/utils/dates/DateRangeSelector.test.tsx | 3 +- test/utils/dates/types/index.test.ts | 40 +++++++++++-------- test/utils/helpers/date.test.ts | 15 ++++--- test/visits/ShortUrlVisitsHeader.test.tsx | 6 +-- test/visits/helpers/LineChartCard.test.tsx | 14 +++---- 21 files changed, 124 insertions(+), 119 deletions(-) create mode 100644 src/utils/Time.tsx diff --git a/package-lock.json b/package-lock.json index f0e9a83d..6eb86c72 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12082,9 +12082,9 @@ } }, "date-fns": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.16.1.tgz", - "integrity": "sha512-sAJVKx/FqrLYHAQeN7VpJrPhagZc9R4ImZIWYRFZaaohR3KzmuK88touwsSwSVT8Qcbd4zoDsnGfX4GFB4imyQ==" + "version": "2.22.1", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.22.1.tgz", + "integrity": "sha512-yUFPQjrxEmIsMqlHhAhmxkuH769baF21Kk+nZwZGyrMoyLA+LugaQtC0+Tqf9CBUUULWwUJt6Q5ySI3LJDDCGg==" }, "date-format": { "version": "3.0.0", diff --git a/package.json b/package.json index b903f58c..8436189c 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "classnames": "^2.2.6", "compare-versions": "^3.6.0", "csvjson": "^5.1.0", + "date-fns": "^2.22.1", "event-source-polyfill": "^1.0.22", "leaflet": "^1.7.1", "moment": "^2.29.1", diff --git a/src/short-urls/SearchBar.tsx b/src/short-urls/SearchBar.tsx index 9f78f352..de135dd4 100644 --- a/src/short-urls/SearchBar.tsx +++ b/src/short-urls/SearchBar.tsx @@ -1,7 +1,7 @@ import { faTags as tagsIcon } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { isEmpty, pipe } from 'ramda'; -import moment from 'moment'; +import { parseISO } from 'date-fns'; import SearchField from '../utils/SearchField'; import Tag from '../tags/helpers/Tag'; import { DateRangeSelector } from '../utils/dates/DateRangeSelector'; @@ -16,7 +16,7 @@ interface SearchBarProps { 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 selectedTags = shortUrlsListParams.tags ?? []; diff --git a/src/short-urls/ShortUrlForm.tsx b/src/short-urls/ShortUrlForm.tsx index e30b6c7a..7fcfba3e 100644 --- a/src/short-urls/ShortUrlForm.tsx +++ b/src/short-urls/ShortUrlForm.tsx @@ -2,8 +2,8 @@ import { FC, useEffect, useState } from 'react'; import { InputType } from 'reactstrap/lib/Input'; import { Button, FormGroup, Input, Row } from 'reactstrap'; import { isEmpty, pipe, replace, trim } from 'ramda'; -import m from 'moment'; import classNames from 'classnames'; +import { parseISO } from 'date-fns'; import DateInput, { DateInputProps } from '../utils/DateInput'; import { supportsCrawlableVisits, @@ -38,6 +38,7 @@ export interface ShortUrlFormProps { } const normalizeTag = pipe(trim, replace(/ /g, '-')); +const toDate = (date?: string | Date): Date | undefined => typeof date === 'string' ? parseISO(date) : date; export const ShortUrlForm = ( TagsSelector: FC, @@ -74,7 +75,7 @@ export const ShortUrlForm = ( const renderDateInput = (id: DateFields, placeholder: string, props: Partial = {}) => (
setShortUrlData({ ...shortUrlData, [id]: date })} @@ -163,8 +164,8 @@ export const ShortUrlForm = (
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })} - {renderDateInput('validSince', 'Enabled since...', { maxDate: shortUrlData.validUntil ? m(shortUrlData.validUntil) : undefined })} - {renderDateInput('validUntil', 'Enabled until...', { minDate: shortUrlData.validSince ? m(shortUrlData.validSince) : undefined })} + {renderDateInput('validSince', 'Enabled since...', { maxDate: shortUrlData.validUntil ? toDate(shortUrlData.validUntil) : undefined })} + {renderDateInput('validUntil', 'Enabled until...', { minDate: shortUrlData.validSince ? toDate(shortUrlData.validSince) : undefined })}
diff --git a/src/short-urls/data/index.ts b/src/short-urls/data/index.ts index 341f7e8f..c0fded8b 100644 --- a/src/short-urls/data/index.ts +++ b/src/short-urls/data/index.ts @@ -1,12 +1,11 @@ -import * as m from 'moment'; import { Nullable, OptionalString } from '../../utils/utils'; export interface EditShortUrlData { longUrl?: string; tags?: string[]; title?: string; - validSince?: m.Moment | string | null; - validUntil?: m.Moment | string | null; + validSince?: Date | string | null; + validUntil?: Date | string | null; maxVisits?: number | null; validateUrl?: boolean; crawlable?: boolean; diff --git a/src/short-urls/helpers/ShortUrlsRow.tsx b/src/short-urls/helpers/ShortUrlsRow.tsx index 2b57399b..e04710c9 100644 --- a/src/short-urls/helpers/ShortUrlsRow.tsx +++ b/src/short-urls/helpers/ShortUrlsRow.tsx @@ -1,6 +1,5 @@ -import { isEmpty } from 'ramda'; import { FC, useEffect, useRef } from 'react'; -import Moment from 'react-moment'; +import { isEmpty } from 'ramda'; import { ExternalLink } from 'react-external-link'; import ColorGenerator from '../../utils/services/ColorGenerator'; import { StateFlagTimeout } from '../../utils/helpers/hooks'; @@ -8,6 +7,7 @@ import Tag from '../../tags/helpers/Tag'; import { SelectedServer } from '../../servers/data'; import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon'; import { ShortUrl } from '../data'; +import { Time } from '../../utils/Time'; import ShortUrlVisitsCount from './ShortUrlVisitsCount'; import { ShortUrlsRowMenuProps } from './ShortUrlsRowMenu'; import './ShortUrlsRow.scss'; @@ -53,7 +53,7 @@ const ShortUrlsRow = ( return ( - {shortUrl.dateCreated} +
- -

This app has just been updated!

-

Restart it to enjoy the new features.

-
+ ); }; diff --git a/src/common/AppUpdateBanner.scss b/src/common/AppUpdateBanner.scss new file mode 100644 index 00000000..7f6f833a --- /dev/null +++ b/src/common/AppUpdateBanner.scss @@ -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); +} diff --git a/src/common/AppUpdateBanner.tsx b/src/common/AppUpdateBanner.tsx new file mode 100644 index 00000000..be6b05cd --- /dev/null +++ b/src/common/AppUpdateBanner.tsx @@ -0,0 +1,16 @@ +import { FC, MouseEventHandler } from 'react'; +import { Alert } from 'reactstrap'; +import { SimpleCard } from '../utils/SimpleCard'; +import './AppUpdateBanner.scss'; + +interface AppUpdateBannerProps { + isOpen: boolean; + toggle: MouseEventHandler; +} + +export const AppUpdateBanner: FC = (props) => ( + +

This app has just been updated!

+

Restart it to enjoy the new features.

+
+); diff --git a/test/App.test.tsx b/test/App.test.tsx index 1607a7b4..2ce9c408 100644 --- a/test/App.test.tsx +++ b/test/App.test.tsx @@ -1,9 +1,9 @@ import { shallow, ShallowWrapper } from 'enzyme'; import { Route } from 'react-router-dom'; import { Mock } from 'ts-mockery'; -import { Alert } from 'reactstrap'; import { Settings } from '../src/settings/reducers/settings'; import appFactory from '../src/App'; +import { AppUpdateBanner } from '../src/common/AppUpdateBanner'; describe('', () => { let wrapper: ShallowWrapper; @@ -29,7 +29,7 @@ describe('', () => { it('renders versions', () => expect(wrapper.find(ShlinkVersions)).toHaveLength(1)); - it('renders an Alert', () => expect(wrapper.find(Alert)).toHaveLength(1)); + it('renders an update banner', () => expect(wrapper.find(AppUpdateBanner)).toHaveLength(1)); it('renders app main routes', () => { const routes = wrapper.find(Route); From 69905c4b3877fc33319cec12860148f71f601bf5 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 12 Jul 2021 16:16:18 +0200 Subject: [PATCH 47/49] Added logic to allow refreshing the PWA without closing the tabs --- src/App.tsx | 3 ++- src/common/AppUpdateBanner.tsx | 32 +++++++++++++++++++++++++------- src/utils/helpers/sw.ts | 16 ++++++++++++++++ 3 files changed, 43 insertions(+), 8 deletions(-) create mode 100644 src/utils/helpers/sw.ts diff --git a/src/App.tsx b/src/App.tsx index 57108ea0..d8ac9b2a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import { ServersMap } from './servers/data'; import { Settings } from './settings/reducers/settings'; import { changeThemeInMarkup } from './utils/theme'; import { AppUpdateBanner } from './common/AppUpdateBanner'; +import { forceUpdate } from './utils/helpers/sw'; import './App.scss'; interface AppProps { @@ -54,7 +55,7 @@ const App = ( - + ); }; diff --git a/src/common/AppUpdateBanner.tsx b/src/common/AppUpdateBanner.tsx index be6b05cd..c114dd3d 100644 --- a/src/common/AppUpdateBanner.tsx +++ b/src/common/AppUpdateBanner.tsx @@ -1,16 +1,34 @@ import { FC, MouseEventHandler } from 'react'; -import { Alert } from 'reactstrap'; +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; + forceUpdate: Function; } -export const AppUpdateBanner: FC = (props) => ( - -

This app has just been updated!

-

Restart it to enjoy the new features.

-
-); +export const AppUpdateBanner: FC = ({ isOpen, toggle, forceUpdate }) => { + const [ isUpdating,, setUpdating ] = useToggle(); + const update = () => { + setUpdating(); + forceUpdate(); + }; + + return ( + +

This app has just been updated!

+

+ Restart it to enjoy the new features. + +

+
+ ); +}; diff --git a/src/utils/helpers/sw.ts b/src/utils/helpers/sw.ts new file mode 100644 index 00000000..a318047b --- /dev/null +++ b/src/utils/helpers/sw.ts @@ -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' }); + } +}; From d5e8f810761168d0ce9caf4ddbbb47af13c22562 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 12 Jul 2021 16:34:58 +0200 Subject: [PATCH 48/49] Created AppUpdateBanner test --- CHANGELOG.md | 1 + test/common/AppUpdateBanner.test.tsx | 43 ++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 test/common/AppUpdateBanner.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bce85c1..2c111486 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), * [#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. diff --git a/test/common/AppUpdateBanner.test.tsx b/test/common/AppUpdateBanner.test.tsx new file mode 100644 index 00000000..b03458b8 --- /dev/null +++ b/test/common/AppUpdateBanner.test.tsx @@ -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('', () => { + const toggle = jest.fn(); + const forceUpdate = jest.fn(); + let wrapper: ShallowWrapper; + + beforeEach(() => { + wrapper = shallow(); + }); + + 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(); + }); +}); From 0e4667e59ca0d20fc051160e4f8093e0bd986f9e Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 12 Jul 2021 16:40:36 +0200 Subject: [PATCH 49/49] Added v3.2.0 to changelog --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c111486..363b2b14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ 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). -## [Unreleased] +## [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: @@ -12,7 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), * `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 a `conf.d` folder. +* [#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.