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]
### Added
* [#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
* Update to Vite 4.1

267
package-lock.json generated
View file

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

View file

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

View file

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

View file

@ -5,6 +5,7 @@ import { Card, CardBody, CardHeader, Row } from 'reactstrap';
import type { ShlinkShortUrlsListParams } from '../api/types';
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
import { Topics } from '../mercure/helpers/Topics';
import type { Settings } from '../settings/reducers/settings';
import type { CreateShortUrlProps } from '../short-urls/CreateShortUrl';
import type { ShortUrlsList as ShortUrlsListState } 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 { getServerId } from './data';
import { HighlightCard } from './helpers/HighlightCard';
import { VisitsHighlightCard } from './helpers/VisitsHighlightCard';
interface OverviewConnectProps {
shortUrlsList: ShortUrlsListState;
@ -25,6 +27,7 @@ interface OverviewConnectProps {
selectedServer: SelectedServer;
visitsOverview: VisitsOverview;
loadVisitsOverview: Function;
settings: Settings;
}
export const Overview = (
@ -38,10 +41,11 @@ export const Overview = (
selectedServer,
loadVisitsOverview,
visitsOverview,
settings: { visits },
}: OverviewConnectProps) => {
const { loading, shortUrls } = shortUrlsList;
const { loading: loadingTags } = tagsList;
const { loading: loadingVisits, visitsCount, orphanVisitsCount } = visitsOverview;
const { loading: loadingVisits, nonOrphanVisits, orphanVisits } = visitsOverview;
const serverId = getServerId(selectedServer);
const linkToNonOrphanVisits = useFeature('nonOrphanVisits', selectedServer);
const navigate = useNavigate();
@ -56,14 +60,22 @@ export const Overview = (
<>
<Row>
<div className="col-lg-6 col-xl-3 mb-3">
<HighlightCard title="Visits" link={linkToNonOrphanVisits && `/server/${serverId}/non-orphan-visits`}>
{loadingVisits ? 'Loading...' : prettify(visitsCount)}
</HighlightCard>
<VisitsHighlightCard
title="Visits"
link={linkToNonOrphanVisits ? `/server/${serverId}/non-orphan-visits` : undefined}
excludeBots={visits?.excludeBots ?? false}
loading={loadingVisits}
visitsSummary={nonOrphanVisits}
/>
</div>
<div className="col-lg-6 col-xl-3 mb-3">
<HighlightCard title="Orphan visits" link={`/server/${serverId}/orphan-visits`}>
{loadingVisits ? 'Loading...' : prettify(orphanVisitsCount)}
</HighlightCard>
<VisitsHighlightCard
title="Orphan visits"
link={`/server/${serverId}/orphan-visits`}
excludeBots={visits?.excludeBots ?? false}
loading={loadingVisits}
visitsSummary={orphanVisits}
/>
</div>
<div className="col-lg-6 col-xl-3 mb-3">
<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 { 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 { Card, CardText, CardTitle } from 'reactstrap';
import { Card, CardText, CardTitle, UncontrolledTooltip } from 'reactstrap';
import { useElementRef } from '../../utils/helpers/hooks';
import './HighlightCard.scss';
export type HighlightCardProps = PropsWithChildren<{
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 }) => (
<Card className="highlight-card" body {...buildExtraProps(link)}>
{link && <FontAwesomeIcon size="3x" className="highlight-card__link-icon" icon={linkIcon} />}
<CardTitle tag="h5" className="highlight-card__title">{title}</CardTitle>
<CardText tag="h2">{children}</CardText>
</Card>
);
export const HighlightCard: FC<HighlightCardProps> = ({ children, title, link, tooltip }) => {
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} />}
<CardTitle tag="h5" className="highlight-card__title">{title}</CardTitle>
<CardText tag="h2">{children}</CardText>
</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.decorator('Overview', connect(
['shortUrlsList', 'tagsList', 'selectedServer', 'mercureInfo', 'visitsOverview'],
['shortUrlsList', 'tagsList', 'selectedServer', 'mercureInfo', 'visitsOverview', 'settings'],
['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 { Nullable, OptionalString } from '../../utils/utils';
@ -41,7 +42,7 @@ export interface ShortUrl {
dateCreated: string;
/** @deprecated */
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>>;
tags: string[];
domain: string | null;
@ -56,12 +57,6 @@ export interface ShortUrlMeta {
maxVisits?: number;
}
export interface ShortUrlVisitsSummary {
total: number;
nonBots: number;
bots: number;
}
export interface ShortUrlModalProps {
shortUrl: ShortUrl;
isOpen: boolean;

View file

@ -3,14 +3,24 @@ import { createSlice } from '@reduxjs/toolkit';
import type { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
import type { ShlinkVisitsOverview } from '../../api/types';
import { createAsyncThunk } from '../../utils/helpers/redux';
import type { CreateVisit } from '../types';
import { groupNewVisitsByType } from '../types/helpers';
import { createNewVisits } from './visitCreation';
const REDUCER_PREFIX = 'shlink/visitsOverview';
export interface VisitsOverview {
visitsCount: number;
orphanVisitsCount: number;
export type PartialVisitsSummary = {
total: number;
nonBots?: number;
bots?: number;
};
export type ParsedVisitsOverview = {
nonOrphanVisits: PartialVisitsSummary;
orphanVisits: PartialVisitsSummary;
};
export interface VisitsOverview extends ParsedVisitsOverview {
loading: boolean;
error: boolean;
}
@ -18,15 +28,34 @@ export interface VisitsOverview {
export type GetVisitsOverviewAction = PayloadAction<ShlinkVisitsOverview>;
const initialState: VisitsOverview = {
visitsCount: 0,
orphanVisitsCount: 0,
nonOrphanVisits: {
total: 0,
},
orphanVisits: {
total: 0,
},
loading: false,
error: false,
};
const countBots = (visits: CreateVisit[]) => visits.filter(({ visit }) => visit.potentialBot).length;
export const loadVisitsOverview = (buildShlinkApiClient: ShlinkApiClientBuilder) => createAsyncThunk(
`${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 = (
@ -40,13 +69,31 @@ export const visitsOverviewReducerCreator = (
builder.addCase(loadVisitsOverviewThunk.rejected, () => ({ ...initialState, error: true }));
builder.addCase(loadVisitsOverviewThunk.fulfilled, (_, { payload }) => ({ ...initialState, ...payload }));
builder.addCase(createNewVisits, ({ visitsCount, orphanVisitsCount = 0, ...rest }, { payload }) => {
const { createdVisits } = payload;
const { regularVisits, orphanVisits } = groupNewVisitsByType(createdVisits);
builder.addCase(createNewVisits, ({ nonOrphanVisits, orphanVisits, ...rest }, { payload }) => {
const { nonOrphanVisits: newNonOrphanVisits, orphanVisits: newOrphanVisits } = groupNewVisitsByType(
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 {
...rest,
visitsCount: visitsCount + regularVisits.length,
orphanVisitsCount: orphanVisitsCount + orphanVisits.length,
nonOrphanVisits: {
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 {
orphanVisits: CreateVisit[];
regularVisits: CreateVisit[];
nonOrphanVisits: CreateVisit[];
}
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
(result): GroupedNewVisits => ({ orphanVisits: [], regularVisits: [], ...result }),
(result): GroupedNewVisits => ({ orphanVisits: [], nonOrphanVisits: [], ...result }),
);
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 { Mock } from 'ts-mockery';
import type { MercureInfo } from '../../src/mercure/reducers/mercureInfo';
import type { ReachableServer } from '../../src/servers/data';
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 { TagsList } from '../../src/tags/reducers/tagsList';
import { prettify } from '../../src/utils/helpers/numbers';
import type { VisitsOverview } from '../../src/visits/reducers/visitsOverview';
import { renderWithEvents } from '../__helpers__/setUpTest';
describe('<Overview />', () => {
const ShortUrlsTable = () => <>ShortUrlsTable</>;
@ -20,7 +22,7 @@ describe('<Overview />', () => {
pagination: { totalItems: 83710 },
};
const serverId = '123';
const setUp = (loading = false) => render(
const setUp = (loading = false, excludeBots = false) => renderWithEvents(
<MemoryRouter>
<Overview
listShortUrls={listShortUrls}
@ -28,11 +30,16 @@ describe('<Overview />', () => {
loadVisitsOverview={loadVisitsOverview}
shortUrlsList={Mock.of<ShortUrlsListState>({ loading, shortUrls })}
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 })}
createNewVisits={jest.fn()}
loadMercureInfo={jest.fn()}
mercureInfo={Mock.all<MercureInfo>()}
settings={Mock.of<Settings>({ visits: { excludeBots } })}
/>
</MemoryRouter>,
);
@ -42,16 +49,19 @@ describe('<Overview />', () => {
expect(screen.getAllByText('Loading...')).toHaveLength(4);
});
it('displays amounts in cards after finishing loading', () => {
setUp();
it.each([
[false, 3456, 28],
[true, 2456, 13],
])('displays amounts in cards after finishing loading', (excludeBots, expectedVisits, expectedOrphanVisits) => {
setUp(false, excludeBots);
const headingElements = screen.getAllByRole('heading');
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
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[3]).toHaveTextContent(prettify(28));
expect(headingElements[3]).toHaveTextContent(prettify(expectedOrphanVisits));
expect(headingElements[4]).toHaveTextContent('Short URLs');
expect(headingElements[5]).toHaveTextContent(prettify(83710));
expect(headingElements[6]).toHaveTextContent('Tags');
@ -77,4 +87,20 @@ describe('<Overview />', () => {
expect(links[3]).toHaveAttribute('href', `/server/${serverId}/create-short-url`);
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 { MemoryRouter } from 'react-router-dom';
import type { HighlightCardProps } from '../../../src/servers/helpers/HighlightCard';
import { HighlightCard } from '../../../src/servers/helpers/HighlightCard';
import { renderWithEvents } from '../../__helpers__/setUpTest';
describe('<HighlightCard />', () => {
const setUp = (props: HighlightCardProps & { children?: ReactNode }) => render(
const setUp = (props: HighlightCardProps & { children?: ReactNode }) => renderWithEvents(
<MemoryRouter>
<HighlightCard {...props} />
</MemoryRouter>,
@ -13,9 +14,9 @@ describe('<HighlightCard />', () => {
it.each([
[undefined],
[false],
[''],
])('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('link')).not.toBeInTheDocument();
@ -27,7 +28,7 @@ describe('<HighlightCard />', () => {
['baz'],
])('renders provided title', (title) => {
setUp({ title });
expect(screen.getByText(title)).toHaveAttribute('class', expect.stringContaining('highlight-card__title'));
expect(screen.getByText(title)).toHaveClass('highlight-card__title');
});
it.each([
@ -36,7 +37,7 @@ describe('<HighlightCard />', () => {
['baz'],
])('renders provided children', (children) => {
setUp({ title: 'title', children });
expect(screen.getByText(children)).toHaveAttribute('class', expect.stringContaining('card-text'));
expect(screen.getByText(children)).toHaveClass('card-text');
});
it.each([
@ -49,4 +50,11 @@ describe('<HighlightCard />', () => {
expect(screen.getByRole('img', { hidden: true })).toBeInTheDocument();
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 userEvent from '@testing-library/user-event';
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';
describe('<ShortUrlStatus />', () => {
@ -23,12 +24,12 @@ describe('<ShortUrlStatus />', () => {
],
[
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.',
],
[
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 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>({ maxVisits: 10 }),
Mock.of<ShortUrlVisitsSummary>({ total: 1 }),
Mock.of<ShlinkVisitsSummary>({ total: 1 }),
'This short URL can be visited normally.',
],
])('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 { createNewVisits } from '../../../src/visits/reducers/visitCreation';
import type {
GetVisitsOverviewAction,
VisitsOverview } from '../../../src/visits/reducers/visitsOverview';
GetVisitsOverviewAction, ParsedVisitsOverview,
PartialVisitsSummary, VisitsOverview } from '../../../src/visits/reducers/visitsOverview';
import {
loadVisitsOverview as loadVisitsOverviewCreator,
visitsOverviewReducerCreator,
@ -46,45 +46,98 @@ describe('visitsOverviewReducer', () => {
});
it('return visits overview on GET_OVERVIEW', () => {
const { loading, error, visitsCount } = reducer(state({ loading: true, error: false }), {
type: loadVisitsOverview.fulfilled.toString(),
payload: { visitsCount: 100 },
});
const action = loadVisitsOverview.fulfilled(Mock.of<ParsedVisitsOverview>({
nonOrphanVisits: { total: 100 },
}), 'requestId');
const { loading, error, nonOrphanVisits } = reducer(state({ loading: true, error: false }), action);
expect(loading).toEqual(false);
expect(error).toEqual(false);
expect(visitsCount).toEqual(100);
expect(nonOrphanVisits.total).toEqual(100);
});
it.each([
[50, 53],
[0, 3],
[undefined, 3],
])('returns updated amounts on CREATE_VISITS', (providedOrphanVisitsCount, expectedOrphanVisitsCount) => {
const { visitsCount, orphanVisitsCount } = reducer(
state({ visitsCount: 100, orphanVisitsCount: providedOrphanVisitsCount }),
{
type: createNewVisits.toString(),
payload: {
createdVisits: [
Mock.of<CreateVisit>({ visit: Mock.all<Visit>() }),
Mock.of<CreateVisit>({ visit: Mock.all<Visit>() }),
Mock.of<CreateVisit>({
visit: Mock.of<OrphanVisit>({ visitedUrl: '' }),
}),
Mock.of<CreateVisit>({
visit: Mock.of<OrphanVisit>({ visitedUrl: '' }),
}),
Mock.of<CreateVisit>({
visit: Mock.of<OrphanVisit>({ visitedUrl: '' }),
}),
],
},
} as unknown as GetVisitsOverviewAction & CreateVisitsAction,
const { nonOrphanVisits, orphanVisits } = reducer(
state({
nonOrphanVisits: { total: 100 },
orphanVisits: { total: providedOrphanVisitsCount },
}),
createNewVisits([
Mock.of<CreateVisit>({ visit: Mock.all<Visit>() }),
Mock.of<CreateVisit>({ visit: Mock.all<Visit>() }),
Mock.of<CreateVisit>({
visit: Mock.of<OrphanVisit>({ visitedUrl: '' }),
}),
Mock.of<CreateVisit>({
visit: Mock.of<OrphanVisit>({ visitedUrl: '' }),
}),
Mock.of<CreateVisit>({
visit: Mock.of<OrphanVisit>({ visitedUrl: '' }),
}),
]),
);
expect(visitsCount).toEqual(102);
expect(orphanVisitsCount).toEqual(expectedOrphanVisitsCount);
expect(nonOrphanVisits.total).toEqual(102);
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);
});
it('dispatches start and success when promise is resolved', async () => {
const resolvedOverview = Mock.of<ShlinkVisitsOverview>({ visitsCount: 50 });
it.each([
[
// 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);
await loadVisitsOverview()(dispatchMock, getState, {});
@ -121,7 +196,7 @@ describe('visitsOverviewReducer', () => {
}));
expect(dispatchMock).toHaveBeenNthCalledWith(2, expect.objectContaining({
type: loadVisitsOverview.fulfilled.toString(),
payload: { visitsCount: 50 },
payload: dispatchedPayload,
}));
expect(getVisitsOverview).toHaveBeenCalledTimes(1);
});

View file

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