Merge pull request #815 from acelaya-forks/feature/overview-bots

Feature/overview bots
This commit is contained in:
Alejandro Celaya 2023-03-18 11:15:01 +01:00 committed by GitHub
commit a6d000714b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 531 additions and 205 deletions

View file

@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
## [Unreleased] ## [Unreleased]
### Added ### Added
* [#807](https://github.com/shlinkio/shlink-web-client/issues/807) Add support for device-specific long-URLs when creating or editing short URLs. * [#807](https://github.com/shlinkio/shlink-web-client/issues/807) Add support for device-specific long-URLs when creating or editing short URLs.
* [#808](https://github.com/shlinkio/shlink-web-client/issues/808) Respect settings on excluding bots in the overview section, for visits cards.
### Changed ### Changed
* Update to Vite 4.1 * Update to Vite 4.1

267
package-lock.json generated
View file

@ -9,7 +9,7 @@
"dependencies": { "dependencies": {
"@babel/preset-env": "^7.20.2", "@babel/preset-env": "^7.20.2",
"@babel/preset-react": "^7.18.6", "@babel/preset-react": "^7.18.6",
"@babel/preset-typescript": "^7.18.6", "@babel/preset-typescript": "^7.21.0",
"@fortawesome/fontawesome-free": "^6.3.0", "@fortawesome/fontawesome-free": "^6.3.0",
"@fortawesome/fontawesome-svg-core": "^6.3.0", "@fortawesome/fontawesome-svg-core": "^6.3.0",
"@fortawesome/free-brands-svg-icons": "^6.3.0", "@fortawesome/free-brands-svg-icons": "^6.3.0",
@ -74,7 +74,7 @@
"@types/uuid": "^8.3.4", "@types/uuid": "^8.3.4",
"@vitejs/plugin-react": "^3.1.0", "@vitejs/plugin-react": "^3.1.0",
"adm-zip": "^0.5.10", "adm-zip": "^0.5.10",
"babel-jest": "^29.3.1", "babel-jest": "^29.5.0",
"chalk": "^5.2.0", "chalk": "^5.2.0",
"eslint": "^8.30.0", "eslint": "^8.30.0",
"identity-obj-proxy": "^3.0.0", "identity-obj-proxy": "^3.0.0",
@ -257,15 +257,17 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/@babel/helper-create-class-features-plugin": { "node_modules/@babel/helper-create-class-features-plugin": {
"version": "7.20.7", "version": "7.21.0",
"license": "MIT", "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.21.0.tgz",
"integrity": "sha512-Q8wNiMIdwsv5la5SPxNYzzkPnjgC0Sy0i7jLkVOCdllu/xcVNkr3TeZzbHBJrj+XXRqzX5uCyCoV9eu6xUG7KQ==",
"dependencies": { "dependencies": {
"@babel/helper-annotate-as-pure": "^7.18.6", "@babel/helper-annotate-as-pure": "^7.18.6",
"@babel/helper-environment-visitor": "^7.18.9", "@babel/helper-environment-visitor": "^7.18.9",
"@babel/helper-function-name": "^7.19.0", "@babel/helper-function-name": "^7.21.0",
"@babel/helper-member-expression-to-functions": "^7.20.7", "@babel/helper-member-expression-to-functions": "^7.21.0",
"@babel/helper-optimise-call-expression": "^7.18.6", "@babel/helper-optimise-call-expression": "^7.18.6",
"@babel/helper-replace-supers": "^7.20.7", "@babel/helper-replace-supers": "^7.20.7",
"@babel/helper-skip-transparent-expression-wrappers": "^7.20.0",
"@babel/helper-split-export-declaration": "^7.18.6" "@babel/helper-split-export-declaration": "^7.18.6"
}, },
"engines": { "engines": {
@ -369,10 +371,11 @@
} }
}, },
"node_modules/@babel/helper-member-expression-to-functions": { "node_modules/@babel/helper-member-expression-to-functions": {
"version": "7.20.7", "version": "7.21.0",
"license": "MIT", "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.21.0.tgz",
"integrity": "sha512-Muu8cdZwNN6mRRNG6lAYErJ5X3bRevgYR2O8wN0yn7jJSnGDu6eG59RfT29JHxGUovyfrh6Pj0XzmR7drNVL3Q==",
"dependencies": { "dependencies": {
"@babel/types": "^7.20.7" "@babel/types": "^7.21.0"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@ -498,8 +501,9 @@
} }
}, },
"node_modules/@babel/helper-validator-option": { "node_modules/@babel/helper-validator-option": {
"version": "7.18.6", "version": "7.21.0",
"license": "MIT", "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.21.0.tgz",
"integrity": "sha512-rmL/B8/f0mKS2baE9ZpyTcTavvEuWhTTW8amjzXNvYG4AwBsqTLikfXsEofsJEfKHf+HQVQbFOHy6o+4cnC/fQ==",
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
@ -1008,10 +1012,11 @@
} }
}, },
"node_modules/@babel/plugin-syntax-typescript": { "node_modules/@babel/plugin-syntax-typescript": {
"version": "7.18.6", "version": "7.20.0",
"license": "MIT", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.20.0.tgz",
"integrity": "sha512-rd9TkG+u1CExzS4SM1BlMEhMXwFLKVjOAFFCDx9PbX5ycJWDoWMcwdJH9RhkPu1dOgn5TrxLot/Gx6lWFuAUNQ==",
"dependencies": { "dependencies": {
"@babel/helper-plugin-utils": "^7.18.6" "@babel/helper-plugin-utils": "^7.19.0"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@ -1522,12 +1527,14 @@
} }
}, },
"node_modules/@babel/plugin-transform-typescript": { "node_modules/@babel/plugin-transform-typescript": {
"version": "7.19.3", "version": "7.21.3",
"license": "MIT", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.21.3.tgz",
"integrity": "sha512-RQxPz6Iqt8T0uw/WsJNReuBpWpBqs/n7mNo18sKLoTbMp+UrEekhH+pKSVC7gWz+DNjo9gryfV8YzCiT45RgMw==",
"dependencies": { "dependencies": {
"@babel/helper-create-class-features-plugin": "^7.19.0", "@babel/helper-annotate-as-pure": "^7.18.6",
"@babel/helper-plugin-utils": "^7.19.0", "@babel/helper-create-class-features-plugin": "^7.21.0",
"@babel/plugin-syntax-typescript": "^7.18.6" "@babel/helper-plugin-utils": "^7.20.2",
"@babel/plugin-syntax-typescript": "^7.20.0"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@ -1690,12 +1697,13 @@
} }
}, },
"node_modules/@babel/preset-typescript": { "node_modules/@babel/preset-typescript": {
"version": "7.18.6", "version": "7.21.0",
"license": "MIT", "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.21.0.tgz",
"integrity": "sha512-myc9mpoVA5m1rF8K8DgLEatOYFDpwC+RkMkjZ0Du6uI62YvDe8uxIEYVs/VCdSJ097nlALiU/yBC7//3nI+hNg==",
"dependencies": { "dependencies": {
"@babel/helper-plugin-utils": "^7.18.6", "@babel/helper-plugin-utils": "^7.20.2",
"@babel/helper-validator-option": "^7.18.6", "@babel/helper-validator-option": "^7.21.0",
"@babel/plugin-transform-typescript": "^7.18.6" "@babel/plugin-transform-typescript": "^7.21.0"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@ -2861,11 +2869,12 @@
} }
}, },
"node_modules/@jest/schemas": { "node_modules/@jest/schemas": {
"version": "29.0.0", "version": "29.4.3",
"resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.4.3.tgz",
"integrity": "sha512-VLYKXQmtmuEz6IxJsrZwzG9NvtkQsWNnWMsKxqWNu3+CnfzJQhp0WDDKWLVV9hLKr0l3SLLFRqcYHjhtyuDVxg==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@sinclair/typebox": "^0.24.1" "@sinclair/typebox": "^0.25.16"
}, },
"engines": { "engines": {
"node": "^14.15.0 || ^16.10.0 || >=18.0.0" "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
@ -2913,25 +2922,26 @@
} }
}, },
"node_modules/@jest/transform": { "node_modules/@jest/transform": {
"version": "29.3.1", "version": "29.5.0",
"resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.5.0.tgz",
"integrity": "sha512-8vbeZWqLJOvHaDfeMuoHITGKSz5qWc9u04lnWrQE3VyuSw604PzQM824ZeX9XSjUCeDiE3GuxZe5UKa8J61NQw==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@babel/core": "^7.11.6", "@babel/core": "^7.11.6",
"@jest/types": "^29.3.1", "@jest/types": "^29.5.0",
"@jridgewell/trace-mapping": "^0.3.15", "@jridgewell/trace-mapping": "^0.3.15",
"babel-plugin-istanbul": "^6.1.1", "babel-plugin-istanbul": "^6.1.1",
"chalk": "^4.0.0", "chalk": "^4.0.0",
"convert-source-map": "^2.0.0", "convert-source-map": "^2.0.0",
"fast-json-stable-stringify": "^2.1.0", "fast-json-stable-stringify": "^2.1.0",
"graceful-fs": "^4.2.9", "graceful-fs": "^4.2.9",
"jest-haste-map": "^29.3.1", "jest-haste-map": "^29.5.0",
"jest-regex-util": "^29.2.0", "jest-regex-util": "^29.4.3",
"jest-util": "^29.3.1", "jest-util": "^29.5.0",
"micromatch": "^4.0.4", "micromatch": "^4.0.4",
"pirates": "^4.0.4", "pirates": "^4.0.4",
"slash": "^3.0.0", "slash": "^3.0.0",
"write-file-atomic": "^4.0.1" "write-file-atomic": "^4.0.2"
}, },
"engines": { "engines": {
"node": "^14.15.0 || ^16.10.0 || >=18.0.0" "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
@ -3014,11 +3024,12 @@
} }
}, },
"node_modules/@jest/types": { "node_modules/@jest/types": {
"version": "29.3.1", "version": "29.5.0",
"resolved": "https://registry.npmjs.org/@jest/types/-/types-29.5.0.tgz",
"integrity": "sha512-qbu7kN6czmVRc3xWFQcAN03RAUamgppVUdXrvl1Wr3jlNF93o9mJbGcDWrwGB6ht44u7efB1qCFgVQmca24Uog==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@jest/schemas": "^29.0.0", "@jest/schemas": "^29.4.3",
"@types/istanbul-lib-coverage": "^2.0.0", "@types/istanbul-lib-coverage": "^2.0.0",
"@types/istanbul-reports": "^3.0.0", "@types/istanbul-reports": "^3.0.0",
"@types/node": "*", "@types/node": "*",
@ -3301,9 +3312,10 @@
} }
}, },
"node_modules/@sinclair/typebox": { "node_modules/@sinclair/typebox": {
"version": "0.24.44", "version": "0.25.24",
"dev": true, "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.25.24.tgz",
"license": "MIT" "integrity": "sha512-XJfwUVUKDHF5ugKwIcxEgc9k8b7HbznCp6eUfWgu710hMPNIO4aw4/zB5RogDQz8nd6gyCDpU9O/m6qYEWY6yQ==",
"dev": true
}, },
"node_modules/@sinonjs/commons": { "node_modules/@sinonjs/commons": {
"version": "1.8.6", "version": "1.8.6",
@ -4585,14 +4597,15 @@
"peer": true "peer": true
}, },
"node_modules/babel-jest": { "node_modules/babel-jest": {
"version": "29.3.1", "version": "29.5.0",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.5.0.tgz",
"integrity": "sha512-mA4eCDh5mSo2EcA9xQjVTpmbbNk32Zb3Q3QFQsNhaK56Q+yoXowzFodLux30HRgyOho5rsQ6B0P9QpMkvvnJ0Q==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@jest/transform": "^29.3.1", "@jest/transform": "^29.5.0",
"@types/babel__core": "^7.1.14", "@types/babel__core": "^7.1.14",
"babel-plugin-istanbul": "^6.1.1", "babel-plugin-istanbul": "^6.1.1",
"babel-preset-jest": "^29.2.0", "babel-preset-jest": "^29.5.0",
"chalk": "^4.0.0", "chalk": "^4.0.0",
"graceful-fs": "^4.2.9", "graceful-fs": "^4.2.9",
"slash": "^3.0.0" "slash": "^3.0.0"
@ -4679,9 +4692,10 @@
} }
}, },
"node_modules/babel-plugin-jest-hoist": { "node_modules/babel-plugin-jest-hoist": {
"version": "29.2.0", "version": "29.5.0",
"resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.5.0.tgz",
"integrity": "sha512-zSuuuAlTMT4mzLj2nPnUm6fsE6270vdOfnpbJ+RmruU75UhLFvL0N2NgI7xpeS7NaB6hGqmd5pVpGTDYvi4Q3w==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@babel/template": "^7.3.3", "@babel/template": "^7.3.3",
"@babel/types": "^7.3.3", "@babel/types": "^7.3.3",
@ -4755,11 +4769,12 @@
} }
}, },
"node_modules/babel-preset-jest": { "node_modules/babel-preset-jest": {
"version": "29.2.0", "version": "29.5.0",
"resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.5.0.tgz",
"integrity": "sha512-JOMloxOqdiBSxMAzjRaH023/vvcaSaec49zvg+2LmNsktC7ei39LTJGw02J+9uUtTZUq6xbLyJ4dxe9sSmIuAg==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"babel-plugin-jest-hoist": "^29.2.0", "babel-plugin-jest-hoist": "^29.5.0",
"babel-preset-current-node-syntax": "^1.0.0" "babel-preset-current-node-syntax": "^1.0.0"
}, },
"engines": { "engines": {
@ -8324,19 +8339,20 @@
} }
}, },
"node_modules/jest-haste-map": { "node_modules/jest-haste-map": {
"version": "29.3.1", "version": "29.5.0",
"resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.5.0.tgz",
"integrity": "sha512-IspOPnnBro8YfVYSw6yDRKh/TiCdRngjxeacCps1cQ9cgVN6+10JUcuJ1EabrgYLOATsIAigxA0rLR9x/YlrSA==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@jest/types": "^29.3.1", "@jest/types": "^29.5.0",
"@types/graceful-fs": "^4.1.3", "@types/graceful-fs": "^4.1.3",
"@types/node": "*", "@types/node": "*",
"anymatch": "^3.0.3", "anymatch": "^3.0.3",
"fb-watchman": "^2.0.0", "fb-watchman": "^2.0.0",
"graceful-fs": "^4.2.9", "graceful-fs": "^4.2.9",
"jest-regex-util": "^29.2.0", "jest-regex-util": "^29.4.3",
"jest-util": "^29.3.1", "jest-util": "^29.5.0",
"jest-worker": "^29.3.1", "jest-worker": "^29.5.0",
"micromatch": "^4.0.4", "micromatch": "^4.0.4",
"walker": "^1.0.8" "walker": "^1.0.8"
}, },
@ -8627,9 +8643,10 @@
} }
}, },
"node_modules/jest-regex-util": { "node_modules/jest-regex-util": {
"version": "29.2.0", "version": "29.4.3",
"resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.4.3.tgz",
"integrity": "sha512-O4FglZaMmWXbGHSQInfXewIsd1LMn9p3ZXB/6r4FOkyhX2/iP/soMG98jGvk/A3HAN78+5VWcBGO0BJAPRh4kg==",
"dev": true, "dev": true,
"license": "MIT",
"engines": { "engines": {
"node": "^14.15.0 || ^16.10.0 || >=18.0.0" "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
} }
@ -9073,11 +9090,12 @@
} }
}, },
"node_modules/jest-util": { "node_modules/jest-util": {
"version": "29.3.1", "version": "29.5.0",
"resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.5.0.tgz",
"integrity": "sha512-RYMgG/MTadOr5t8KdhejfvUU82MxsCu5MF6KuDUHl+NuwzUt+Sm6jJWxTJVrDR1j5M/gJVCPKQEpWXY+yIQ6lQ==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@jest/types": "^29.3.1", "@jest/types": "^29.5.0",
"@types/node": "*", "@types/node": "*",
"chalk": "^4.0.0", "chalk": "^4.0.0",
"ci-info": "^3.2.0", "ci-info": "^3.2.0",
@ -9329,12 +9347,13 @@
} }
}, },
"node_modules/jest-worker": { "node_modules/jest-worker": {
"version": "29.3.1", "version": "29.5.0",
"resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.5.0.tgz",
"integrity": "sha512-NcrQnevGoSp4b5kg+akIpthoAFHxPBcb5P6mYPY0fUNT+sSvmtu6jlkEle3anczUKIKEbMxFimk9oTP/tpIPgA==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@types/node": "*", "@types/node": "*",
"jest-util": "^29.3.1", "jest-util": "^29.5.0",
"merge-stream": "^2.0.0", "merge-stream": "^2.0.0",
"supports-color": "^8.0.0" "supports-color": "^8.0.0"
}, },
@ -13033,14 +13052,17 @@
} }
}, },
"@babel/helper-create-class-features-plugin": { "@babel/helper-create-class-features-plugin": {
"version": "7.20.7", "version": "7.21.0",
"resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.21.0.tgz",
"integrity": "sha512-Q8wNiMIdwsv5la5SPxNYzzkPnjgC0Sy0i7jLkVOCdllu/xcVNkr3TeZzbHBJrj+XXRqzX5uCyCoV9eu6xUG7KQ==",
"requires": { "requires": {
"@babel/helper-annotate-as-pure": "^7.18.6", "@babel/helper-annotate-as-pure": "^7.18.6",
"@babel/helper-environment-visitor": "^7.18.9", "@babel/helper-environment-visitor": "^7.18.9",
"@babel/helper-function-name": "^7.19.0", "@babel/helper-function-name": "^7.21.0",
"@babel/helper-member-expression-to-functions": "^7.20.7", "@babel/helper-member-expression-to-functions": "^7.21.0",
"@babel/helper-optimise-call-expression": "^7.18.6", "@babel/helper-optimise-call-expression": "^7.18.6",
"@babel/helper-replace-supers": "^7.20.7", "@babel/helper-replace-supers": "^7.20.7",
"@babel/helper-skip-transparent-expression-wrappers": "^7.20.0",
"@babel/helper-split-export-declaration": "^7.18.6" "@babel/helper-split-export-declaration": "^7.18.6"
} }
}, },
@ -13099,9 +13121,11 @@
} }
}, },
"@babel/helper-member-expression-to-functions": { "@babel/helper-member-expression-to-functions": {
"version": "7.20.7", "version": "7.21.0",
"resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.21.0.tgz",
"integrity": "sha512-Muu8cdZwNN6mRRNG6lAYErJ5X3bRevgYR2O8wN0yn7jJSnGDu6eG59RfT29JHxGUovyfrh6Pj0XzmR7drNVL3Q==",
"requires": { "requires": {
"@babel/types": "^7.20.7" "@babel/types": "^7.21.0"
} }
}, },
"@babel/helper-module-imports": { "@babel/helper-module-imports": {
@ -13177,7 +13201,9 @@
"version": "7.19.1" "version": "7.19.1"
}, },
"@babel/helper-validator-option": { "@babel/helper-validator-option": {
"version": "7.18.6" "version": "7.21.0",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.21.0.tgz",
"integrity": "sha512-rmL/B8/f0mKS2baE9ZpyTcTavvEuWhTTW8amjzXNvYG4AwBsqTLikfXsEofsJEfKHf+HQVQbFOHy6o+4cnC/fQ=="
}, },
"@babel/helper-wrap-function": { "@babel/helper-wrap-function": {
"version": "7.20.5", "version": "7.20.5",
@ -13456,9 +13482,11 @@
} }
}, },
"@babel/plugin-syntax-typescript": { "@babel/plugin-syntax-typescript": {
"version": "7.18.6", "version": "7.20.0",
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.20.0.tgz",
"integrity": "sha512-rd9TkG+u1CExzS4SM1BlMEhMXwFLKVjOAFFCDx9PbX5ycJWDoWMcwdJH9RhkPu1dOgn5TrxLot/Gx6lWFuAUNQ==",
"requires": { "requires": {
"@babel/helper-plugin-utils": "^7.18.6" "@babel/helper-plugin-utils": "^7.19.0"
} }
}, },
"@babel/plugin-transform-arrow-functions": { "@babel/plugin-transform-arrow-functions": {
@ -13711,11 +13739,14 @@
} }
}, },
"@babel/plugin-transform-typescript": { "@babel/plugin-transform-typescript": {
"version": "7.19.3", "version": "7.21.3",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.21.3.tgz",
"integrity": "sha512-RQxPz6Iqt8T0uw/WsJNReuBpWpBqs/n7mNo18sKLoTbMp+UrEekhH+pKSVC7gWz+DNjo9gryfV8YzCiT45RgMw==",
"requires": { "requires": {
"@babel/helper-create-class-features-plugin": "^7.19.0", "@babel/helper-annotate-as-pure": "^7.18.6",
"@babel/helper-plugin-utils": "^7.19.0", "@babel/helper-create-class-features-plugin": "^7.21.0",
"@babel/plugin-syntax-typescript": "^7.18.6" "@babel/helper-plugin-utils": "^7.20.2",
"@babel/plugin-syntax-typescript": "^7.20.0"
} }
}, },
"@babel/plugin-transform-unicode-escapes": { "@babel/plugin-transform-unicode-escapes": {
@ -13838,11 +13869,13 @@
} }
}, },
"@babel/preset-typescript": { "@babel/preset-typescript": {
"version": "7.18.6", "version": "7.21.0",
"resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.21.0.tgz",
"integrity": "sha512-myc9mpoVA5m1rF8K8DgLEatOYFDpwC+RkMkjZ0Du6uI62YvDe8uxIEYVs/VCdSJ097nlALiU/yBC7//3nI+hNg==",
"requires": { "requires": {
"@babel/helper-plugin-utils": "^7.18.6", "@babel/helper-plugin-utils": "^7.20.2",
"@babel/helper-validator-option": "^7.18.6", "@babel/helper-validator-option": "^7.21.0",
"@babel/plugin-transform-typescript": "^7.18.6" "@babel/plugin-transform-typescript": "^7.21.0"
} }
}, },
"@babel/runtime": { "@babel/runtime": {
@ -14508,10 +14541,12 @@
} }
}, },
"@jest/schemas": { "@jest/schemas": {
"version": "29.0.0", "version": "29.4.3",
"resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.4.3.tgz",
"integrity": "sha512-VLYKXQmtmuEz6IxJsrZwzG9NvtkQsWNnWMsKxqWNu3+CnfzJQhp0WDDKWLVV9hLKr0l3SLLFRqcYHjhtyuDVxg==",
"dev": true, "dev": true,
"requires": { "requires": {
"@sinclair/typebox": "^0.24.1" "@sinclair/typebox": "^0.25.16"
} }
}, },
"@jest/source-map": { "@jest/source-map": {
@ -14544,24 +14579,26 @@
} }
}, },
"@jest/transform": { "@jest/transform": {
"version": "29.3.1", "version": "29.5.0",
"resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.5.0.tgz",
"integrity": "sha512-8vbeZWqLJOvHaDfeMuoHITGKSz5qWc9u04lnWrQE3VyuSw604PzQM824ZeX9XSjUCeDiE3GuxZe5UKa8J61NQw==",
"dev": true, "dev": true,
"requires": { "requires": {
"@babel/core": "^7.11.6", "@babel/core": "^7.11.6",
"@jest/types": "^29.3.1", "@jest/types": "^29.5.0",
"@jridgewell/trace-mapping": "^0.3.15", "@jridgewell/trace-mapping": "^0.3.15",
"babel-plugin-istanbul": "^6.1.1", "babel-plugin-istanbul": "^6.1.1",
"chalk": "^4.0.0", "chalk": "^4.0.0",
"convert-source-map": "^2.0.0", "convert-source-map": "^2.0.0",
"fast-json-stable-stringify": "^2.1.0", "fast-json-stable-stringify": "^2.1.0",
"graceful-fs": "^4.2.9", "graceful-fs": "^4.2.9",
"jest-haste-map": "^29.3.1", "jest-haste-map": "^29.5.0",
"jest-regex-util": "^29.2.0", "jest-regex-util": "^29.4.3",
"jest-util": "^29.3.1", "jest-util": "^29.5.0",
"micromatch": "^4.0.4", "micromatch": "^4.0.4",
"pirates": "^4.0.4", "pirates": "^4.0.4",
"slash": "^3.0.0", "slash": "^3.0.0",
"write-file-atomic": "^4.0.1" "write-file-atomic": "^4.0.2"
}, },
"dependencies": { "dependencies": {
"ansi-styles": { "ansi-styles": {
@ -14612,10 +14649,12 @@
} }
}, },
"@jest/types": { "@jest/types": {
"version": "29.3.1", "version": "29.5.0",
"resolved": "https://registry.npmjs.org/@jest/types/-/types-29.5.0.tgz",
"integrity": "sha512-qbu7kN6czmVRc3xWFQcAN03RAUamgppVUdXrvl1Wr3jlNF93o9mJbGcDWrwGB6ht44u7efB1qCFgVQmca24Uog==",
"dev": true, "dev": true,
"requires": { "requires": {
"@jest/schemas": "^29.0.0", "@jest/schemas": "^29.4.3",
"@types/istanbul-lib-coverage": "^2.0.0", "@types/istanbul-lib-coverage": "^2.0.0",
"@types/istanbul-reports": "^3.0.0", "@types/istanbul-reports": "^3.0.0",
"@types/node": "*", "@types/node": "*",
@ -14782,7 +14821,9 @@
} }
}, },
"@sinclair/typebox": { "@sinclair/typebox": {
"version": "0.24.44", "version": "0.25.24",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.25.24.tgz",
"integrity": "sha512-XJfwUVUKDHF5ugKwIcxEgc9k8b7HbznCp6eUfWgu710hMPNIO4aw4/zB5RogDQz8nd6gyCDpU9O/m6qYEWY6yQ==",
"dev": true "dev": true
}, },
"@sinonjs/commons": { "@sinonjs/commons": {
@ -15627,13 +15668,15 @@
"peer": true "peer": true
}, },
"babel-jest": { "babel-jest": {
"version": "29.3.1", "version": "29.5.0",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.5.0.tgz",
"integrity": "sha512-mA4eCDh5mSo2EcA9xQjVTpmbbNk32Zb3Q3QFQsNhaK56Q+yoXowzFodLux30HRgyOho5rsQ6B0P9QpMkvvnJ0Q==",
"dev": true, "dev": true,
"requires": { "requires": {
"@jest/transform": "^29.3.1", "@jest/transform": "^29.5.0",
"@types/babel__core": "^7.1.14", "@types/babel__core": "^7.1.14",
"babel-plugin-istanbul": "^6.1.1", "babel-plugin-istanbul": "^6.1.1",
"babel-preset-jest": "^29.2.0", "babel-preset-jest": "^29.5.0",
"chalk": "^4.0.0", "chalk": "^4.0.0",
"graceful-fs": "^4.2.9", "graceful-fs": "^4.2.9",
"slash": "^3.0.0" "slash": "^3.0.0"
@ -15686,7 +15729,9 @@
} }
}, },
"babel-plugin-jest-hoist": { "babel-plugin-jest-hoist": {
"version": "29.2.0", "version": "29.5.0",
"resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.5.0.tgz",
"integrity": "sha512-zSuuuAlTMT4mzLj2nPnUm6fsE6270vdOfnpbJ+RmruU75UhLFvL0N2NgI7xpeS7NaB6hGqmd5pVpGTDYvi4Q3w==",
"dev": true, "dev": true,
"requires": { "requires": {
"@babel/template": "^7.3.3", "@babel/template": "^7.3.3",
@ -15740,10 +15785,12 @@
} }
}, },
"babel-preset-jest": { "babel-preset-jest": {
"version": "29.2.0", "version": "29.5.0",
"resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.5.0.tgz",
"integrity": "sha512-JOMloxOqdiBSxMAzjRaH023/vvcaSaec49zvg+2LmNsktC7ei39LTJGw02J+9uUtTZUq6xbLyJ4dxe9sSmIuAg==",
"dev": true, "dev": true,
"requires": { "requires": {
"babel-plugin-jest-hoist": "^29.2.0", "babel-plugin-jest-hoist": "^29.5.0",
"babel-preset-current-node-syntax": "^1.0.0" "babel-preset-current-node-syntax": "^1.0.0"
} }
}, },
@ -17968,19 +18015,21 @@
"dev": true "dev": true
}, },
"jest-haste-map": { "jest-haste-map": {
"version": "29.3.1", "version": "29.5.0",
"resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.5.0.tgz",
"integrity": "sha512-IspOPnnBro8YfVYSw6yDRKh/TiCdRngjxeacCps1cQ9cgVN6+10JUcuJ1EabrgYLOATsIAigxA0rLR9x/YlrSA==",
"dev": true, "dev": true,
"requires": { "requires": {
"@jest/types": "^29.3.1", "@jest/types": "^29.5.0",
"@types/graceful-fs": "^4.1.3", "@types/graceful-fs": "^4.1.3",
"@types/node": "*", "@types/node": "*",
"anymatch": "^3.0.3", "anymatch": "^3.0.3",
"fb-watchman": "^2.0.0", "fb-watchman": "^2.0.0",
"fsevents": "^2.3.2", "fsevents": "^2.3.2",
"graceful-fs": "^4.2.9", "graceful-fs": "^4.2.9",
"jest-regex-util": "^29.2.0", "jest-regex-util": "^29.4.3",
"jest-util": "^29.3.1", "jest-util": "^29.5.0",
"jest-worker": "^29.3.1", "jest-worker": "^29.5.0",
"micromatch": "^4.0.4", "micromatch": "^4.0.4",
"walker": "^1.0.8" "walker": "^1.0.8"
} }
@ -18160,7 +18209,9 @@
"requires": {} "requires": {}
}, },
"jest-regex-util": { "jest-regex-util": {
"version": "29.2.0", "version": "29.4.3",
"resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.4.3.tgz",
"integrity": "sha512-O4FglZaMmWXbGHSQInfXewIsd1LMn9p3ZXB/6r4FOkyhX2/iP/soMG98jGvk/A3HAN78+5VWcBGO0BJAPRh4kg==",
"dev": true "dev": true
}, },
"jest-resolve": { "jest-resolve": {
@ -18457,10 +18508,12 @@
} }
}, },
"jest-util": { "jest-util": {
"version": "29.3.1", "version": "29.5.0",
"resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.5.0.tgz",
"integrity": "sha512-RYMgG/MTadOr5t8KdhejfvUU82MxsCu5MF6KuDUHl+NuwzUt+Sm6jJWxTJVrDR1j5M/gJVCPKQEpWXY+yIQ6lQ==",
"dev": true, "dev": true,
"requires": { "requires": {
"@jest/types": "^29.3.1", "@jest/types": "^29.5.0",
"@types/node": "*", "@types/node": "*",
"chalk": "^4.0.0", "chalk": "^4.0.0",
"ci-info": "^3.2.0", "ci-info": "^3.2.0",
@ -18619,11 +18672,13 @@
} }
}, },
"jest-worker": { "jest-worker": {
"version": "29.3.1", "version": "29.5.0",
"resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.5.0.tgz",
"integrity": "sha512-NcrQnevGoSp4b5kg+akIpthoAFHxPBcb5P6mYPY0fUNT+sSvmtu6jlkEle3anczUKIKEbMxFimk9oTP/tpIPgA==",
"dev": true, "dev": true,
"requires": { "requires": {
"@types/node": "*", "@types/node": "*",
"jest-util": "^29.3.1", "jest-util": "^29.5.0",
"merge-stream": "^2.0.0", "merge-stream": "^2.0.0",
"supports-color": "^8.0.0" "supports-color": "^8.0.0"
}, },

View file

@ -25,7 +25,7 @@
"dependencies": { "dependencies": {
"@babel/preset-env": "^7.20.2", "@babel/preset-env": "^7.20.2",
"@babel/preset-react": "^7.18.6", "@babel/preset-react": "^7.18.6",
"@babel/preset-typescript": "^7.18.6", "@babel/preset-typescript": "^7.21.0",
"@fortawesome/fontawesome-free": "^6.3.0", "@fortawesome/fontawesome-free": "^6.3.0",
"@fortawesome/fontawesome-svg-core": "^6.3.0", "@fortawesome/fontawesome-svg-core": "^6.3.0",
"@fortawesome/free-brands-svg-icons": "^6.3.0", "@fortawesome/free-brands-svg-icons": "^6.3.0",
@ -90,7 +90,7 @@
"@types/uuid": "^8.3.4", "@types/uuid": "^8.3.4",
"@vitejs/plugin-react": "^3.1.0", "@vitejs/plugin-react": "^3.1.0",
"adm-zip": "^0.5.10", "adm-zip": "^0.5.10",
"babel-jest": "^29.3.1", "babel-jest": "^29.5.0",
"chalk": "^5.2.0", "chalk": "^5.2.0",
"eslint": "^8.30.0", "eslint": "^8.30.0",
"identity-obj-proxy": "^3.0.0", "identity-obj-proxy": "^3.0.0",

View file

@ -40,13 +40,24 @@ export interface ShlinkPaginator {
totalItems: number; totalItems: number;
} }
export interface ShlinkVisitsSummary {
total: number;
nonBots: number;
bots: number;
}
export interface ShlinkVisits { export interface ShlinkVisits {
data: Visit[]; data: Visit[];
pagination: ShlinkPaginator; pagination: ShlinkPaginator;
} }
export interface ShlinkVisitsOverview { export interface ShlinkVisitsOverview {
nonOrphanVisits?: ShlinkVisitsSummary; // Optional only before Shlink 3.5.0
orphanVisits?: ShlinkVisitsSummary; // Optional only before Shlink 3.5.0
/** @deprecated */
visitsCount: number; visitsCount: number;
/** @deprecated */
orphanVisitsCount: number; orphanVisitsCount: number;
} }

View file

@ -5,6 +5,7 @@ import { Card, CardBody, CardHeader, Row } from 'reactstrap';
import type { ShlinkShortUrlsListParams } from '../api/types'; import type { ShlinkShortUrlsListParams } from '../api/types';
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
import { Topics } from '../mercure/helpers/Topics'; import { Topics } from '../mercure/helpers/Topics';
import type { Settings } from '../settings/reducers/settings';
import type { CreateShortUrlProps } from '../short-urls/CreateShortUrl'; import type { CreateShortUrlProps } from '../short-urls/CreateShortUrl';
import type { ShortUrlsList as ShortUrlsListState } from '../short-urls/reducers/shortUrlsList'; import type { ShortUrlsList as ShortUrlsListState } from '../short-urls/reducers/shortUrlsList';
import { ITEMS_IN_OVERVIEW_PAGE } from '../short-urls/reducers/shortUrlsList'; import { ITEMS_IN_OVERVIEW_PAGE } from '../short-urls/reducers/shortUrlsList';
@ -16,6 +17,7 @@ import type { VisitsOverview } from '../visits/reducers/visitsOverview';
import type { SelectedServer } from './data'; import type { SelectedServer } from './data';
import { getServerId } from './data'; import { getServerId } from './data';
import { HighlightCard } from './helpers/HighlightCard'; import { HighlightCard } from './helpers/HighlightCard';
import { VisitsHighlightCard } from './helpers/VisitsHighlightCard';
interface OverviewConnectProps { interface OverviewConnectProps {
shortUrlsList: ShortUrlsListState; shortUrlsList: ShortUrlsListState;
@ -25,6 +27,7 @@ interface OverviewConnectProps {
selectedServer: SelectedServer; selectedServer: SelectedServer;
visitsOverview: VisitsOverview; visitsOverview: VisitsOverview;
loadVisitsOverview: Function; loadVisitsOverview: Function;
settings: Settings;
} }
export const Overview = ( export const Overview = (
@ -38,10 +41,11 @@ export const Overview = (
selectedServer, selectedServer,
loadVisitsOverview, loadVisitsOverview,
visitsOverview, visitsOverview,
settings: { visits },
}: OverviewConnectProps) => { }: OverviewConnectProps) => {
const { loading, shortUrls } = shortUrlsList; const { loading, shortUrls } = shortUrlsList;
const { loading: loadingTags } = tagsList; const { loading: loadingTags } = tagsList;
const { loading: loadingVisits, visitsCount, orphanVisitsCount } = visitsOverview; const { loading: loadingVisits, nonOrphanVisits, orphanVisits } = visitsOverview;
const serverId = getServerId(selectedServer); const serverId = getServerId(selectedServer);
const linkToNonOrphanVisits = useFeature('nonOrphanVisits', selectedServer); const linkToNonOrphanVisits = useFeature('nonOrphanVisits', selectedServer);
const navigate = useNavigate(); const navigate = useNavigate();
@ -56,14 +60,22 @@ export const Overview = (
<> <>
<Row> <Row>
<div className="col-lg-6 col-xl-3 mb-3"> <div className="col-lg-6 col-xl-3 mb-3">
<HighlightCard title="Visits" link={linkToNonOrphanVisits && `/server/${serverId}/non-orphan-visits`}> <VisitsHighlightCard
{loadingVisits ? 'Loading...' : prettify(visitsCount)} title="Visits"
</HighlightCard> link={linkToNonOrphanVisits ? `/server/${serverId}/non-orphan-visits` : undefined}
excludeBots={visits?.excludeBots ?? false}
loading={loadingVisits}
visitsSummary={nonOrphanVisits}
/>
</div> </div>
<div className="col-lg-6 col-xl-3 mb-3"> <div className="col-lg-6 col-xl-3 mb-3">
<HighlightCard title="Orphan visits" link={`/server/${serverId}/orphan-visits`}> <VisitsHighlightCard
{loadingVisits ? 'Loading...' : prettify(orphanVisitsCount)} title="Orphan visits"
</HighlightCard> link={`/server/${serverId}/orphan-visits`}
excludeBots={visits?.excludeBots ?? false}
loading={loadingVisits}
visitsSummary={orphanVisits}
/>
</div> </div>
<div className="col-lg-6 col-xl-3 mb-3"> <div className="col-lg-6 col-xl-3 mb-3">
<HighlightCard title="Short URLs" link={`/server/${serverId}/list-short-urls/1`}> <HighlightCard title="Short URLs" link={`/server/${serverId}/list-short-urls/1`}>

View file

@ -1,21 +1,30 @@
import { faArrowAltCircleRight as linkIcon } from '@fortawesome/free-regular-svg-icons'; import { faArrowAltCircleRight as linkIcon } from '@fortawesome/free-regular-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import type { FC, PropsWithChildren } from 'react'; import type { FC, PropsWithChildren, ReactNode } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Card, CardText, CardTitle } from 'reactstrap'; import { Card, CardText, CardTitle, UncontrolledTooltip } from 'reactstrap';
import { useElementRef } from '../../utils/helpers/hooks';
import './HighlightCard.scss'; import './HighlightCard.scss';
export type HighlightCardProps = PropsWithChildren<{ export type HighlightCardProps = PropsWithChildren<{
title: string; title: string;
link?: string | false; link?: string;
tooltip?: ReactNode;
}>; }>;
const buildExtraProps = (link?: string | false) => (!link ? {} : { tag: Link, to: link }); const buildExtraProps = (link?: string) => (!link ? {} : { tag: Link, to: link });
export const HighlightCard: FC<HighlightCardProps> = ({ children, title, link }) => ( export const HighlightCard: FC<HighlightCardProps> = ({ children, title, link, tooltip }) => {
<Card className="highlight-card" body {...buildExtraProps(link)}> const ref = useElementRef<HTMLElement>();
return (
<>
<Card innerRef={ref} className="highlight-card" body {...buildExtraProps(link)}>
{link && <FontAwesomeIcon size="3x" className="highlight-card__link-icon" icon={linkIcon} />} {link && <FontAwesomeIcon size="3x" className="highlight-card__link-icon" icon={linkIcon} />}
<CardTitle tag="h5" className="highlight-card__title">{title}</CardTitle> <CardTitle tag="h5" className="highlight-card__title">{title}</CardTitle>
<CardText tag="h2">{children}</CardText> <CardText tag="h2">{children}</CardText>
</Card> </Card>
{tooltip && <UncontrolledTooltip target={ref} placement="bottom">{tooltip}</UncontrolledTooltip>}
</>
); );
};

View file

@ -0,0 +1,26 @@
import type { FC } from 'react';
import { prettify } from '../../utils/helpers/numbers';
import type { PartialVisitsSummary } from '../../visits/reducers/visitsOverview';
import type { HighlightCardProps } from './HighlightCard';
import { HighlightCard } from './HighlightCard';
export type VisitsHighlightCardProps = Omit<HighlightCardProps, 'tooltip' | 'children'> & {
loading: boolean;
excludeBots: boolean;
visitsSummary: PartialVisitsSummary;
};
export const VisitsHighlightCard: FC<VisitsHighlightCardProps> = ({ loading, excludeBots, visitsSummary, ...rest }) => (
<HighlightCard
tooltip={
visitsSummary.bots !== undefined
? <>{excludeBots ? 'Plus' : 'Including'} <b>{prettify(visitsSummary.bots)}</b> potential bot visits</>
: undefined
}
{...rest}
>
{loading ? 'Loading...' : prettify(
excludeBots && visitsSummary.nonBots ? visitsSummary.nonBots : visitsSummary.total,
)}
</HighlightCard>
);

View file

@ -65,7 +65,7 @@ export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('Overview', Overview, 'ShortUrlsTable', 'CreateShortUrl'); bottle.serviceFactory('Overview', Overview, 'ShortUrlsTable', 'CreateShortUrl');
bottle.decorator('Overview', connect( bottle.decorator('Overview', connect(
['shortUrlsList', 'tagsList', 'selectedServer', 'mercureInfo', 'visitsOverview'], ['shortUrlsList', 'tagsList', 'selectedServer', 'mercureInfo', 'visitsOverview', 'settings'],
['listShortUrls', 'listTags', 'createNewVisits', 'loadMercureInfo', 'loadVisitsOverview'], ['listShortUrls', 'listTags', 'createNewVisits', 'loadMercureInfo', 'loadVisitsOverview'],
)); ));

View file

@ -1,3 +1,4 @@
import type { ShlinkVisitsSummary } from '../../api/types';
import type { Order } from '../../utils/helpers/ordering'; import type { Order } from '../../utils/helpers/ordering';
import type { Nullable, OptionalString } from '../../utils/utils'; import type { Nullable, OptionalString } from '../../utils/utils';
@ -41,7 +42,7 @@ export interface ShortUrl {
dateCreated: string; dateCreated: string;
/** @deprecated */ /** @deprecated */
visitsCount: number; // Deprecated since Shlink 3.4.0 visitsCount: number; // Deprecated since Shlink 3.4.0
visitsSummary?: ShortUrlVisitsSummary; // Optional only before Shlink 3.4.0 visitsSummary?: ShlinkVisitsSummary; // Optional only before Shlink 3.4.0
meta: Required<Nullable<ShortUrlMeta>>; meta: Required<Nullable<ShortUrlMeta>>;
tags: string[]; tags: string[];
domain: string | null; domain: string | null;
@ -56,12 +57,6 @@ export interface ShortUrlMeta {
maxVisits?: number; maxVisits?: number;
} }
export interface ShortUrlVisitsSummary {
total: number;
nonBots: number;
bots: number;
}
export interface ShortUrlModalProps { export interface ShortUrlModalProps {
shortUrl: ShortUrl; shortUrl: ShortUrl;
isOpen: boolean; isOpen: boolean;

View file

@ -3,14 +3,24 @@ import { createSlice } from '@reduxjs/toolkit';
import type { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import type { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
import type { ShlinkVisitsOverview } from '../../api/types'; import type { ShlinkVisitsOverview } from '../../api/types';
import { createAsyncThunk } from '../../utils/helpers/redux'; import { createAsyncThunk } from '../../utils/helpers/redux';
import type { CreateVisit } from '../types';
import { groupNewVisitsByType } from '../types/helpers'; import { groupNewVisitsByType } from '../types/helpers';
import { createNewVisits } from './visitCreation'; import { createNewVisits } from './visitCreation';
const REDUCER_PREFIX = 'shlink/visitsOverview'; const REDUCER_PREFIX = 'shlink/visitsOverview';
export interface VisitsOverview { export type PartialVisitsSummary = {
visitsCount: number; total: number;
orphanVisitsCount: number; nonBots?: number;
bots?: number;
};
export type ParsedVisitsOverview = {
nonOrphanVisits: PartialVisitsSummary;
orphanVisits: PartialVisitsSummary;
};
export interface VisitsOverview extends ParsedVisitsOverview {
loading: boolean; loading: boolean;
error: boolean; error: boolean;
} }
@ -18,15 +28,34 @@ export interface VisitsOverview {
export type GetVisitsOverviewAction = PayloadAction<ShlinkVisitsOverview>; export type GetVisitsOverviewAction = PayloadAction<ShlinkVisitsOverview>;
const initialState: VisitsOverview = { const initialState: VisitsOverview = {
visitsCount: 0, nonOrphanVisits: {
orphanVisitsCount: 0, total: 0,
},
orphanVisits: {
total: 0,
},
loading: false, loading: false,
error: false, error: false,
}; };
const countBots = (visits: CreateVisit[]) => visits.filter(({ visit }) => visit.potentialBot).length;
export const loadVisitsOverview = (buildShlinkApiClient: ShlinkApiClientBuilder) => createAsyncThunk( export const loadVisitsOverview = (buildShlinkApiClient: ShlinkApiClientBuilder) => createAsyncThunk(
`${REDUCER_PREFIX}/loadVisitsOverview`, `${REDUCER_PREFIX}/loadVisitsOverview`,
(_: void, { getState }): Promise<ShlinkVisitsOverview> => buildShlinkApiClient(getState).getVisitsOverview(), (_: void, { getState }): Promise<ParsedVisitsOverview> => buildShlinkApiClient(getState).getVisitsOverview().then(
(resp) => ({
nonOrphanVisits: {
total: resp.nonOrphanVisits?.total ?? resp.visitsCount,
nonBots: resp.nonOrphanVisits?.nonBots,
bots: resp.nonOrphanVisits?.bots,
},
orphanVisits: {
total: resp.orphanVisits?.total ?? resp.orphanVisitsCount,
nonBots: resp.orphanVisits?.nonBots,
bots: resp.orphanVisits?.bots,
},
}),
),
); );
export const visitsOverviewReducerCreator = ( export const visitsOverviewReducerCreator = (
@ -40,13 +69,31 @@ export const visitsOverviewReducerCreator = (
builder.addCase(loadVisitsOverviewThunk.rejected, () => ({ ...initialState, error: true })); builder.addCase(loadVisitsOverviewThunk.rejected, () => ({ ...initialState, error: true }));
builder.addCase(loadVisitsOverviewThunk.fulfilled, (_, { payload }) => ({ ...initialState, ...payload })); builder.addCase(loadVisitsOverviewThunk.fulfilled, (_, { payload }) => ({ ...initialState, ...payload }));
builder.addCase(createNewVisits, ({ visitsCount, orphanVisitsCount = 0, ...rest }, { payload }) => { builder.addCase(createNewVisits, ({ nonOrphanVisits, orphanVisits, ...rest }, { payload }) => {
const { createdVisits } = payload; const { nonOrphanVisits: newNonOrphanVisits, orphanVisits: newOrphanVisits } = groupNewVisitsByType(
const { regularVisits, orphanVisits } = groupNewVisitsByType(createdVisits); payload.createdVisits,
);
const newNonOrphanTotalVisits = newNonOrphanVisits.length;
const newNonOrphanBotVisits = countBots(newNonOrphanVisits);
const newNonOrphanNonBotVisits = newNonOrphanTotalVisits - newNonOrphanBotVisits;
const newOrphanTotalVisits = newOrphanVisits.length;
const newOrphanBotVisits = countBots(newOrphanVisits);
const newOrphanNonBotVisits = newOrphanTotalVisits - newOrphanBotVisits;
return { return {
...rest, ...rest,
visitsCount: visitsCount + regularVisits.length, nonOrphanVisits: {
orphanVisitsCount: orphanVisitsCount + orphanVisits.length, total: nonOrphanVisits.total + newNonOrphanTotalVisits,
bots: nonOrphanVisits.bots && nonOrphanVisits.bots + newNonOrphanBotVisits,
nonBots: nonOrphanVisits.nonBots && nonOrphanVisits.nonBots + newNonOrphanNonBotVisits,
},
orphanVisits: {
total: orphanVisits.total + newOrphanTotalVisits,
bots: orphanVisits.bots && orphanVisits.bots + newOrphanBotVisits,
nonBots: orphanVisits.nonBots && orphanVisits.nonBots + newOrphanNonBotVisits,
},
}; };
}); });
}, },

View file

@ -10,13 +10,13 @@ export const isNormalizedOrphanVisit = (visit: NormalizedVisit): visit is Normal
export interface GroupedNewVisits { export interface GroupedNewVisits {
orphanVisits: CreateVisit[]; orphanVisits: CreateVisit[];
regularVisits: CreateVisit[]; nonOrphanVisits: CreateVisit[];
} }
export const groupNewVisitsByType = pipe( export const groupNewVisitsByType = pipe(
groupBy((newVisit: CreateVisit) => (isOrphanVisit(newVisit.visit) ? 'orphanVisits' : 'regularVisits')), groupBy((newVisit: CreateVisit) => (isOrphanVisit(newVisit.visit) ? 'orphanVisits' : 'nonOrphanVisits')),
// @ts-expect-error Type declaration on groupBy is not correct. It can return undefined props // @ts-expect-error Type declaration on groupBy is not correct. It can return undefined props
(result): GroupedNewVisits => ({ orphanVisits: [], regularVisits: [], ...result }), (result): GroupedNewVisits => ({ orphanVisits: [], nonOrphanVisits: [], ...result }),
); );
export type HighlightableProps<T extends NormalizedVisit> = T extends NormalizedOrphanVisit export type HighlightableProps<T extends NormalizedVisit> = T extends NormalizedOrphanVisit

View file

@ -1,13 +1,15 @@
import { render, screen } from '@testing-library/react'; import { screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import { Mock } from 'ts-mockery'; import { Mock } from 'ts-mockery';
import type { MercureInfo } from '../../src/mercure/reducers/mercureInfo'; import type { MercureInfo } from '../../src/mercure/reducers/mercureInfo';
import type { ReachableServer } from '../../src/servers/data'; import type { ReachableServer } from '../../src/servers/data';
import { Overview as overviewCreator } from '../../src/servers/Overview'; import { Overview as overviewCreator } from '../../src/servers/Overview';
import type { Settings } from '../../src/settings/reducers/settings';
import type { ShortUrlsList as ShortUrlsListState } from '../../src/short-urls/reducers/shortUrlsList'; import type { ShortUrlsList as ShortUrlsListState } from '../../src/short-urls/reducers/shortUrlsList';
import type { TagsList } from '../../src/tags/reducers/tagsList'; import type { TagsList } from '../../src/tags/reducers/tagsList';
import { prettify } from '../../src/utils/helpers/numbers'; import { prettify } from '../../src/utils/helpers/numbers';
import type { VisitsOverview } from '../../src/visits/reducers/visitsOverview'; import type { VisitsOverview } from '../../src/visits/reducers/visitsOverview';
import { renderWithEvents } from '../__helpers__/setUpTest';
describe('<Overview />', () => { describe('<Overview />', () => {
const ShortUrlsTable = () => <>ShortUrlsTable</>; const ShortUrlsTable = () => <>ShortUrlsTable</>;
@ -20,7 +22,7 @@ describe('<Overview />', () => {
pagination: { totalItems: 83710 }, pagination: { totalItems: 83710 },
}; };
const serverId = '123'; const serverId = '123';
const setUp = (loading = false) => render( const setUp = (loading = false, excludeBots = false) => renderWithEvents(
<MemoryRouter> <MemoryRouter>
<Overview <Overview
listShortUrls={listShortUrls} listShortUrls={listShortUrls}
@ -28,11 +30,16 @@ describe('<Overview />', () => {
loadVisitsOverview={loadVisitsOverview} loadVisitsOverview={loadVisitsOverview}
shortUrlsList={Mock.of<ShortUrlsListState>({ loading, shortUrls })} shortUrlsList={Mock.of<ShortUrlsListState>({ loading, shortUrls })}
tagsList={Mock.of<TagsList>({ loading, tags: ['foo', 'bar', 'baz'] })} tagsList={Mock.of<TagsList>({ loading, tags: ['foo', 'bar', 'baz'] })}
visitsOverview={Mock.of<VisitsOverview>({ loading, visitsCount: 3456, orphanVisitsCount: 28 })} visitsOverview={Mock.of<VisitsOverview>({
loading,
nonOrphanVisits: { total: 3456, bots: 1000, nonBots: 2456 },
orphanVisits: { total: 28, bots: 15, nonBots: 13 },
})}
selectedServer={Mock.of<ReachableServer>({ id: serverId })} selectedServer={Mock.of<ReachableServer>({ id: serverId })}
createNewVisits={jest.fn()} createNewVisits={jest.fn()}
loadMercureInfo={jest.fn()} loadMercureInfo={jest.fn()}
mercureInfo={Mock.all<MercureInfo>()} mercureInfo={Mock.all<MercureInfo>()}
settings={Mock.of<Settings>({ visits: { excludeBots } })}
/> />
</MemoryRouter>, </MemoryRouter>,
); );
@ -42,16 +49,19 @@ describe('<Overview />', () => {
expect(screen.getAllByText('Loading...')).toHaveLength(4); expect(screen.getAllByText('Loading...')).toHaveLength(4);
}); });
it('displays amounts in cards after finishing loading', () => { it.each([
setUp(); [false, 3456, 28],
[true, 2456, 13],
])('displays amounts in cards after finishing loading', (excludeBots, expectedVisits, expectedOrphanVisits) => {
setUp(false, excludeBots);
const headingElements = screen.getAllByRole('heading'); const headingElements = screen.getAllByRole('heading');
expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
expect(headingElements[0]).toHaveTextContent('Visits'); expect(headingElements[0]).toHaveTextContent('Visits');
expect(headingElements[1]).toHaveTextContent(prettify(3456)); expect(headingElements[1]).toHaveTextContent(prettify(expectedVisits));
expect(headingElements[2]).toHaveTextContent('Orphan visits'); expect(headingElements[2]).toHaveTextContent('Orphan visits');
expect(headingElements[3]).toHaveTextContent(prettify(28)); expect(headingElements[3]).toHaveTextContent(prettify(expectedOrphanVisits));
expect(headingElements[4]).toHaveTextContent('Short URLs'); expect(headingElements[4]).toHaveTextContent('Short URLs');
expect(headingElements[5]).toHaveTextContent(prettify(83710)); expect(headingElements[5]).toHaveTextContent(prettify(83710));
expect(headingElements[6]).toHaveTextContent('Tags'); expect(headingElements[6]).toHaveTextContent('Tags');
@ -77,4 +87,20 @@ describe('<Overview />', () => {
expect(links[3]).toHaveAttribute('href', `/server/${serverId}/create-short-url`); expect(links[3]).toHaveAttribute('href', `/server/${serverId}/create-short-url`);
expect(links[4]).toHaveAttribute('href', `/server/${serverId}/list-short-urls/1`); expect(links[4]).toHaveAttribute('href', `/server/${serverId}/list-short-urls/1`);
}); });
it.each([
[true],
[false],
])('displays amounts of bots when hovering visits cards', async (excludeBots) => {
const { user } = setUp(false, excludeBots);
const expectTooltipToBeInTheDocument = async (tooltip: string) => waitFor(
() => expect(screen.getByText(/potential bot visits$/)).toHaveTextContent(tooltip),
);
await user.hover(screen.getByText(/^Visits/));
await expectTooltipToBeInTheDocument(`${excludeBots ? 'Plus' : 'Including'} 1,000 potential bot visits`);
await user.hover(screen.getByText(/^Orphan visits/));
await expectTooltipToBeInTheDocument(`${excludeBots ? 'Plus' : 'Including'} 15 potential bot visits`);
});
}); });

View file

@ -1,11 +1,12 @@
import { render, screen } from '@testing-library/react'; import { screen, waitFor } from '@testing-library/react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import type { HighlightCardProps } from '../../../src/servers/helpers/HighlightCard'; import type { HighlightCardProps } from '../../../src/servers/helpers/HighlightCard';
import { HighlightCard } from '../../../src/servers/helpers/HighlightCard'; import { HighlightCard } from '../../../src/servers/helpers/HighlightCard';
import { renderWithEvents } from '../../__helpers__/setUpTest';
describe('<HighlightCard />', () => { describe('<HighlightCard />', () => {
const setUp = (props: HighlightCardProps & { children?: ReactNode }) => render( const setUp = (props: HighlightCardProps & { children?: ReactNode }) => renderWithEvents(
<MemoryRouter> <MemoryRouter>
<HighlightCard {...props} /> <HighlightCard {...props} />
</MemoryRouter>, </MemoryRouter>,
@ -13,9 +14,9 @@ describe('<HighlightCard />', () => {
it.each([ it.each([
[undefined], [undefined],
[false], [''],
])('does not render icon when there is no link', (link) => { ])('does not render icon when there is no link', (link) => {
setUp({ title: 'foo', link: link as undefined | false }); setUp({ title: 'foo', link });
expect(screen.queryByRole('img', { hidden: true })).not.toBeInTheDocument(); expect(screen.queryByRole('img', { hidden: true })).not.toBeInTheDocument();
expect(screen.queryByRole('link')).not.toBeInTheDocument(); expect(screen.queryByRole('link')).not.toBeInTheDocument();
@ -27,7 +28,7 @@ describe('<HighlightCard />', () => {
['baz'], ['baz'],
])('renders provided title', (title) => { ])('renders provided title', (title) => {
setUp({ title }); setUp({ title });
expect(screen.getByText(title)).toHaveAttribute('class', expect.stringContaining('highlight-card__title')); expect(screen.getByText(title)).toHaveClass('highlight-card__title');
}); });
it.each([ it.each([
@ -36,7 +37,7 @@ describe('<HighlightCard />', () => {
['baz'], ['baz'],
])('renders provided children', (children) => { ])('renders provided children', (children) => {
setUp({ title: 'title', children }); setUp({ title: 'title', children });
expect(screen.getByText(children)).toHaveAttribute('class', expect.stringContaining('card-text')); expect(screen.getByText(children)).toHaveClass('card-text');
}); });
it.each([ it.each([
@ -49,4 +50,11 @@ describe('<HighlightCard />', () => {
expect(screen.getByRole('img', { hidden: true })).toBeInTheDocument(); expect(screen.getByRole('img', { hidden: true })).toBeInTheDocument();
expect(screen.getByRole('link')).toHaveAttribute('href', `/${link}`); expect(screen.getByRole('link')).toHaveAttribute('href', `/${link}`);
}); });
it('renders tooltip when provided', async () => {
const { user } = setUp({ title: 'title', children: 'Foo', tooltip: 'This is the tooltip' });
await user.hover(screen.getByText('Foo'));
await waitFor(() => expect(screen.getByText('This is the tooltip')).toBeInTheDocument());
});
}); });

View file

@ -0,0 +1,60 @@
import { screen, waitFor } from '@testing-library/react';
import type { VisitsHighlightCardProps } from '../../../src/servers/helpers/VisitsHighlightCard';
import { VisitsHighlightCard } from '../../../src/servers/helpers/VisitsHighlightCard';
import { renderWithEvents } from '../../__helpers__/setUpTest';
describe('<VisitsHighlightCard />', () => {
const setUp = (props: Partial<VisitsHighlightCardProps> = {}) => renderWithEvents(
<VisitsHighlightCard loading={false} visitsSummary={{ total: 0 }} excludeBots={false} title="" {...props} />,
);
it.each([
[true, () => expect(screen.getByText('Loading...')).toBeInTheDocument()],
[false, () => expect(screen.queryByText('Loading...')).not.toBeInTheDocument()],
])('displays loading message on loading', (loading, assert) => {
setUp({ loading });
assert();
});
it('does not render tooltip when summary has no bots', async () => {
const { user } = setUp({ title: 'Foo' });
await user.hover(screen.getByText('Foo'));
await waitFor(() => expect(screen.queryByText(/potential bot visits$/)).not.toBeInTheDocument());
});
it('renders tooltip when summary has bots', async () => {
const { user } = setUp({
title: 'Foo',
visitsSummary: { total: 50, bots: 30 },
});
await user.hover(screen.getByText('Foo'));
await waitFor(() => expect(screen.getByText(/potential bot visits$/)).toBeInTheDocument());
});
it.each([
[true, 20, () => {
expect(screen.getByText('20')).toBeInTheDocument();
expect(screen.queryByText('50')).not.toBeInTheDocument();
}],
[true, undefined, () => {
expect(screen.getByText('50')).toBeInTheDocument();
expect(screen.queryByText('20')).not.toBeInTheDocument();
}],
[false, 20, () => {
expect(screen.getByText('50')).toBeInTheDocument();
expect(screen.queryByText('20')).not.toBeInTheDocument();
}],
[false, undefined, () => {
expect(screen.getByText('50')).toBeInTheDocument();
expect(screen.queryByText('20')).not.toBeInTheDocument();
}],
])('displays non-bots when present and bots are excluded', (excludeBots, nonBots, assert) => {
setUp({
excludeBots,
visitsSummary: { total: 50, nonBots },
});
assert();
});
});

View file

@ -1,7 +1,8 @@
import { render, screen, waitFor } from '@testing-library/react'; import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { Mock } from 'ts-mockery'; import { Mock } from 'ts-mockery';
import type { ShortUrl, ShortUrlMeta, ShortUrlVisitsSummary } from '../../../src/short-urls/data'; import type { ShlinkVisitsSummary } from '../../../src/api/types';
import type { ShortUrl, ShortUrlMeta } from '../../../src/short-urls/data';
import { ShortUrlStatus } from '../../../src/short-urls/helpers/ShortUrlStatus'; import { ShortUrlStatus } from '../../../src/short-urls/helpers/ShortUrlStatus';
describe('<ShortUrlStatus />', () => { describe('<ShortUrlStatus />', () => {
@ -23,12 +24,12 @@ describe('<ShortUrlStatus />', () => {
], ],
[ [
Mock.of<ShortUrlMeta>({ maxVisits: 10 }), Mock.of<ShortUrlMeta>({ maxVisits: 10 }),
Mock.of<ShortUrlVisitsSummary>({ total: 10 }), Mock.of<ShlinkVisitsSummary>({ total: 10 }),
'This short URL cannot be currently visited because it has reached the maximum amount of 10 visits.', 'This short URL cannot be currently visited because it has reached the maximum amount of 10 visits.',
], ],
[ [
Mock.of<ShortUrlMeta>({ maxVisits: 1 }), Mock.of<ShortUrlMeta>({ maxVisits: 1 }),
Mock.of<ShortUrlVisitsSummary>({ total: 1 }), Mock.of<ShlinkVisitsSummary>({ total: 1 }),
'This short URL cannot be currently visited because it has reached the maximum amount of 1 visit.', 'This short URL cannot be currently visited because it has reached the maximum amount of 1 visit.',
], ],
[{}, {}, 'This short URL can be visited normally.'], [{}, {}, 'This short URL can be visited normally.'],
@ -36,7 +37,7 @@ describe('<ShortUrlStatus />', () => {
[Mock.of<ShortUrlMeta>({ validSince: '2020-01-01T10:30:15' }), {}, 'This short URL can be visited normally.'], [Mock.of<ShortUrlMeta>({ validSince: '2020-01-01T10:30:15' }), {}, 'This short URL can be visited normally.'],
[ [
Mock.of<ShortUrlMeta>({ maxVisits: 10 }), Mock.of<ShortUrlMeta>({ maxVisits: 10 }),
Mock.of<ShortUrlVisitsSummary>({ total: 1 }), Mock.of<ShlinkVisitsSummary>({ total: 1 }),
'This short URL can be visited normally.', 'This short URL can be visited normally.',
], ],
])('shows expected tooltip', async (meta, visitsSummary, expectedTooltip) => { ])('shows expected tooltip', async (meta, visitsSummary, expectedTooltip) => {

View file

@ -5,8 +5,8 @@ import type { ShlinkState } from '../../../src/container/types';
import type { CreateVisitsAction } from '../../../src/visits/reducers/visitCreation'; import type { CreateVisitsAction } from '../../../src/visits/reducers/visitCreation';
import { createNewVisits } from '../../../src/visits/reducers/visitCreation'; import { createNewVisits } from '../../../src/visits/reducers/visitCreation';
import type { import type {
GetVisitsOverviewAction, GetVisitsOverviewAction, ParsedVisitsOverview,
VisitsOverview } from '../../../src/visits/reducers/visitsOverview'; PartialVisitsSummary, VisitsOverview } from '../../../src/visits/reducers/visitsOverview';
import { import {
loadVisitsOverview as loadVisitsOverviewCreator, loadVisitsOverview as loadVisitsOverviewCreator,
visitsOverviewReducerCreator, visitsOverviewReducerCreator,
@ -46,27 +46,26 @@ describe('visitsOverviewReducer', () => {
}); });
it('return visits overview on GET_OVERVIEW', () => { it('return visits overview on GET_OVERVIEW', () => {
const { loading, error, visitsCount } = reducer(state({ loading: true, error: false }), { const action = loadVisitsOverview.fulfilled(Mock.of<ParsedVisitsOverview>({
type: loadVisitsOverview.fulfilled.toString(), nonOrphanVisits: { total: 100 },
payload: { visitsCount: 100 }, }), 'requestId');
}); const { loading, error, nonOrphanVisits } = reducer(state({ loading: true, error: false }), action);
expect(loading).toEqual(false); expect(loading).toEqual(false);
expect(error).toEqual(false); expect(error).toEqual(false);
expect(visitsCount).toEqual(100); expect(nonOrphanVisits.total).toEqual(100);
}); });
it.each([ it.each([
[50, 53], [50, 53],
[0, 3], [0, 3],
[undefined, 3],
])('returns updated amounts on CREATE_VISITS', (providedOrphanVisitsCount, expectedOrphanVisitsCount) => { ])('returns updated amounts on CREATE_VISITS', (providedOrphanVisitsCount, expectedOrphanVisitsCount) => {
const { visitsCount, orphanVisitsCount } = reducer( const { nonOrphanVisits, orphanVisits } = reducer(
state({ visitsCount: 100, orphanVisitsCount: providedOrphanVisitsCount }), state({
{ nonOrphanVisits: { total: 100 },
type: createNewVisits.toString(), orphanVisits: { total: providedOrphanVisitsCount },
payload: { }),
createdVisits: [ createNewVisits([
Mock.of<CreateVisit>({ visit: Mock.all<Visit>() }), Mock.of<CreateVisit>({ visit: Mock.all<Visit>() }),
Mock.of<CreateVisit>({ visit: Mock.all<Visit>() }), Mock.of<CreateVisit>({ visit: Mock.all<Visit>() }),
Mock.of<CreateVisit>({ Mock.of<CreateVisit>({
@ -78,13 +77,67 @@ describe('visitsOverviewReducer', () => {
Mock.of<CreateVisit>({ Mock.of<CreateVisit>({
visit: Mock.of<OrphanVisit>({ visitedUrl: '' }), visit: Mock.of<OrphanVisit>({ visitedUrl: '' }),
}), }),
], ]),
},
} as unknown as GetVisitsOverviewAction & CreateVisitsAction,
); );
expect(visitsCount).toEqual(102); expect(nonOrphanVisits.total).toEqual(102);
expect(orphanVisitsCount).toEqual(expectedOrphanVisitsCount); expect(orphanVisits.total).toEqual(expectedOrphanVisitsCount);
});
it.each([
[
{} satisfies Omit<PartialVisitsSummary, 'total'>,
{} satisfies Omit<PartialVisitsSummary, 'total'>,
{ total: 103 } satisfies PartialVisitsSummary,
{ total: 203 } satisfies PartialVisitsSummary,
],
[
{ bots: 35 } satisfies Omit<PartialVisitsSummary, 'total'>,
{ bots: 35 } satisfies Omit<PartialVisitsSummary, 'total'>,
{ total: 103, bots: 37 } satisfies PartialVisitsSummary,
{ total: 203, bots: 36 } satisfies PartialVisitsSummary,
],
[
{ nonBots: 41, bots: 85 } satisfies Omit<PartialVisitsSummary, 'total'>,
{ nonBots: 63, bots: 27 } satisfies Omit<PartialVisitsSummary, 'total'>,
{ total: 103, nonBots: 42, bots: 87 } satisfies PartialVisitsSummary,
{ total: 203, nonBots: 65, bots: 28 } satisfies PartialVisitsSummary,
],
[
{ nonBots: 56 } satisfies Omit<PartialVisitsSummary, 'total'>,
{ nonBots: 99 } satisfies Omit<PartialVisitsSummary, 'total'>,
{ total: 103, nonBots: 57 } satisfies PartialVisitsSummary,
{ total: 203, nonBots: 101 } satisfies PartialVisitsSummary,
],
])('takes bots and non-bots into consideration when creating visits', (
initialNonOrphanVisits,
initialOrphanVisits,
expectedNonOrphanVisits,
expectedOrphanVisits,
) => {
const { nonOrphanVisits, orphanVisits } = reducer(
state({
nonOrphanVisits: { total: 100, ...initialNonOrphanVisits },
orphanVisits: { total: 200, ...initialOrphanVisits },
}),
createNewVisits([
Mock.of<CreateVisit>({ visit: Mock.all<Visit>() }),
Mock.of<CreateVisit>({ visit: Mock.of<Visit>({ potentialBot: true }) }),
Mock.of<CreateVisit>({ visit: Mock.of<Visit>({ potentialBot: true }) }),
Mock.of<CreateVisit>({
visit: Mock.of<OrphanVisit>({ visitedUrl: '' }),
}),
Mock.of<CreateVisit>({
visit: Mock.of<OrphanVisit>({ visitedUrl: '' }),
}),
Mock.of<CreateVisit>({
visit: Mock.of<OrphanVisit>({ visitedUrl: '', potentialBot: true }),
}),
]),
);
expect(nonOrphanVisits).toEqual(expectedNonOrphanVisits);
expect(orphanVisits).toEqual(expectedOrphanVisits);
}); });
}); });
@ -109,8 +162,30 @@ describe('visitsOverviewReducer', () => {
expect(getVisitsOverview).toHaveBeenCalledTimes(1); expect(getVisitsOverview).toHaveBeenCalledTimes(1);
}); });
it('dispatches start and success when promise is resolved', async () => { it.each([
const resolvedOverview = Mock.of<ShlinkVisitsOverview>({ visitsCount: 50 }); [
// Shlink <3.5.0
{ visitsCount: 50, orphanVisitsCount: 20 } satisfies ShlinkVisitsOverview,
{
nonOrphanVisits: { total: 50, nonBots: undefined, bots: undefined },
orphanVisits: { total: 20, nonBots: undefined, bots: undefined },
},
],
[
// Shlink >=3.5.0
{
nonOrphanVisits: { total: 50, nonBots: 20, bots: 30 },
orphanVisits: { total: 50, nonBots: 20, bots: 30 },
visitsCount: 3,
orphanVisitsCount: 3,
} satisfies ShlinkVisitsOverview,
{
nonOrphanVisits: { total: 50, nonBots: 20, bots: 30 },
orphanVisits: { total: 50, nonBots: 20, bots: 30 },
},
],
])('dispatches start and success when promise is resolved', async (serverResult, dispatchedPayload) => {
const resolvedOverview = Mock.of<ShlinkVisitsOverview>(serverResult);
getVisitsOverview.mockResolvedValue(resolvedOverview); getVisitsOverview.mockResolvedValue(resolvedOverview);
await loadVisitsOverview()(dispatchMock, getState, {}); await loadVisitsOverview()(dispatchMock, getState, {});
@ -121,7 +196,7 @@ describe('visitsOverviewReducer', () => {
})); }));
expect(dispatchMock).toHaveBeenNthCalledWith(2, expect.objectContaining({ expect(dispatchMock).toHaveBeenNthCalledWith(2, expect.objectContaining({
type: loadVisitsOverview.fulfilled.toString(), type: loadVisitsOverview.fulfilled.toString(),
payload: { visitsCount: 50 }, payload: dispatchedPayload,
})); }));
expect(getVisitsOverview).toHaveBeenCalledTimes(1); expect(getVisitsOverview).toHaveBeenCalledTimes(1);
}); });

View file

@ -8,7 +8,7 @@ import { groupNewVisitsByType, toApiParams } from '../../../src/visits/types/hel
describe('visitsTypeHelpers', () => { describe('visitsTypeHelpers', () => {
describe('groupNewVisitsByType', () => { describe('groupNewVisitsByType', () => {
it.each([ it.each([
[[], { orphanVisits: [], regularVisits: [] }], [[], { orphanVisits: [], nonOrphanVisits: [] }],
((): [CreateVisit[], GroupedNewVisits] => { ((): [CreateVisit[], GroupedNewVisits] => {
const orphanVisits: CreateVisit[] = [ const orphanVisits: CreateVisit[] = [
Mock.of<CreateVisit>({ Mock.of<CreateVisit>({
@ -18,7 +18,7 @@ describe('visitsTypeHelpers', () => {
visit: Mock.of<OrphanVisit>({ visitedUrl: '' }), visit: Mock.of<OrphanVisit>({ visitedUrl: '' }),
}), }),
]; ];
const regularVisits: CreateVisit[] = [ const nonOrphanVisits: CreateVisit[] = [
Mock.of<CreateVisit>({ visit: Mock.all<Visit>() }), Mock.of<CreateVisit>({ visit: Mock.all<Visit>() }),
Mock.of<CreateVisit>({ visit: Mock.all<Visit>() }), Mock.of<CreateVisit>({ visit: Mock.all<Visit>() }),
Mock.of<CreateVisit>({ visit: Mock.all<Visit>() }), Mock.of<CreateVisit>({ visit: Mock.all<Visit>() }),
@ -27,8 +27,8 @@ describe('visitsTypeHelpers', () => {
]; ];
return [ return [
[...orphanVisits, ...regularVisits], [...orphanVisits, ...nonOrphanVisits],
{ orphanVisits, regularVisits }, { orphanVisits, nonOrphanVisits },
]; ];
})(), })(),
((): [CreateVisit[], GroupedNewVisits] => { ((): [CreateVisit[], GroupedNewVisits] => {
@ -44,16 +44,16 @@ describe('visitsTypeHelpers', () => {
}), }),
]; ];
return [orphanVisits, { orphanVisits, regularVisits: [] }]; return [orphanVisits, { orphanVisits, nonOrphanVisits: [] }];
})(), })(),
((): [CreateVisit[], GroupedNewVisits] => { ((): [CreateVisit[], GroupedNewVisits] => {
const regularVisits: CreateVisit[] = [ const nonOrphanVisits: CreateVisit[] = [
Mock.of<CreateVisit>({ visit: Mock.all<Visit>() }), Mock.of<CreateVisit>({ visit: Mock.all<Visit>() }),
Mock.of<CreateVisit>({ visit: Mock.all<Visit>() }), Mock.of<CreateVisit>({ visit: Mock.all<Visit>() }),
Mock.of<CreateVisit>({ visit: Mock.all<Visit>() }), Mock.of<CreateVisit>({ visit: Mock.all<Visit>() }),
]; ];
return [regularVisits, { orphanVisits: [], regularVisits }]; return [nonOrphanVisits, { orphanVisits: [], nonOrphanVisits }];
})(), })(),
])('groups new visits as expected', (createdVisits, expectedResult) => { ])('groups new visits as expected', (createdVisits, expectedResult) => {
expect(groupNewVisitsByType(createdVisits)).toEqual(expectedResult); expect(groupNewVisitsByType(createdVisits)).toEqual(expectedResult);