diff --git a/CHANGELOG.md b/CHANGELOG.md index 375c5c1b..9ac74924 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] ### Added -* *Nothing* +* [#622](https://github.com/shlinkio/shlink-web-client/pull/622) Added support to load domain visits when consuming Shlink 3.1.0 or newer. ### Changed * [#616](https://github.com/shlinkio/shlink-web-client/pull/616) Updated to React 18. diff --git a/config/jest/setupTests.ts b/config/jest/setupTests.ts new file mode 100644 index 00000000..b75cbb7d --- /dev/null +++ b/config/jest/setupTests.ts @@ -0,0 +1,5 @@ +import '@testing-library/jest-dom'; +import 'jest-canvas-mock'; +import ResizeObserver from 'resize-observer-polyfill'; + +(global as any).ResizeObserver = ResizeObserver; diff --git a/jest.config.js b/jest.config.js index 9a46a4c3..65f89256 100644 --- a/jest.config.js +++ b/jest.config.js @@ -15,8 +15,9 @@ module.exports = { lines: 85, }, }, - setupFiles: [ '/config/jest/setupEnzyme.js' ], - testMatch: [ '/test/**/*.test.{ts,tsx}' ], + setupFiles: ['/config/jest/setupEnzyme.js'], + setupFilesAfterEnv: ['/config/jest/setupTests.ts'], + testMatch: ['/test/**/*.test.{ts,tsx}'], testEnvironment: 'jsdom', testURL: 'http://localhost', transform: { @@ -33,5 +34,5 @@ module.exports = { // Reactstrap module resolution does not work in jest for some reason. Manually mapping it solves the problem 'reactstrap': '/node_modules/reactstrap/dist/reactstrap.umd.js', }, - moduleFileExtensions: [ 'js', 'ts', 'tsx', 'json' ], + moduleFileExtensions: ['js', 'ts', 'tsx', 'json'], }; diff --git a/package-lock.json b/package-lock.json index 7858c964..e63f3651 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,12 +26,12 @@ "leaflet": "^1.7.1", "qs": "^6.9.6", "ramda": "^0.27.2", - "react": "^18.0.0", + "react": "^18.1.0", "react-chartjs-2": "^3.3.0", "react-colorful": "^5.5.1", "react-copy-to-clipboard": "^5.0.4", "react-datepicker": "^4.7.0", - "react-dom": "^18.0.0", + "react-dom": "^18.1.0", "react-external-link": "^1.2.2", "react-leaflet": "^4.0.0", "react-redux": "^8.0.0", @@ -55,6 +55,8 @@ "@stryker-mutator/core": "^5.6.1", "@stryker-mutator/jest-runner": "^5.6.1", "@stryker-mutator/typescript-checker": "^5.6.1", + "@testing-library/jest-dom": "^5.16.4", + "@testing-library/react": "^13.1.1", "@types/classnames": "^2.3.1", "@types/enzyme": "^3.10.11", "@types/jest": "^27.4.1", @@ -62,11 +64,11 @@ "@types/leaflet": "^1.7.9", "@types/qs": "^6.9.7", "@types/ramda": "0.27.38", - "@types/react": "^18.0.6", + "@types/react": "^18.0.8", "@types/react-color": "^3.0.6", "@types/react-copy-to-clipboard": "^5.0.2", "@types/react-datepicker": "^4.3.4", - "@types/react-dom": "^18.0.2", + "@types/react-dom": "^18.0.3", "@types/react-tag-autocomplete": "^6.1.1", "@types/uuid": "^8.3.4", "@wojtekmaj/enzyme-adapter-react-17": "0.6.5", @@ -78,7 +80,9 @@ "eslint": "^8.12.0", "identity-obj-proxy": "^3.0.0", "jest": "^27.5.1", + "jest-canvas-mock": "^2.4.0", "react-scripts": "^5.0.0", + "resize-observer-polyfill": "^1.5.1", "sass": "^1.49.9", "serve": "^13.0.2", "stryker-cli": "^1.0.2", @@ -4486,6 +4490,208 @@ "node": ">=8.9.0" } }, + "node_modules/@testing-library/dom": { + "version": "8.13.0", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.13.0.tgz", + "integrity": "sha512-9VHgfIatKNXQNaZTtLnalIy0jNZzY35a4S3oi08YAt9Hv1VsfZ/DfA45lM8D/UhtHBGJ4/lGwp0PZkVndRkoOQ==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^4.2.0", + "aria-query": "^5.0.0", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.4.4", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@testing-library/dom/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.0.0.tgz", + "integrity": "sha512-V+SM7AbUwJ+EBnB8+DXs0hPZHO0W6pqBcc0dW90OwtVG02PswOu/teuARoLQjdDOH+t9pJgGnW5/Qmouf3gPJg==", + "dev": true, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/@testing-library/dom/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@testing-library/dom/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@testing-library/dom/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@testing-library/dom/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "5.16.4", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.16.4.tgz", + "integrity": "sha512-Gy+IoFutbMQcky0k+bqqumXZ1cTGswLsFqmNLzNdSKkU9KGV2u9oXhukCbbJ9/LRPKiqwxEE8VpV/+YZlfkPUA==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.9.2", + "@types/testing-library__jest-dom": "^5.9.1", + "aria-query": "^5.0.0", + "chalk": "^3.0.0", + "css": "^3.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.5.6", + "lodash": "^4.17.15", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=8", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/aria-query": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.0.0.tgz", + "integrity": "sha512-V+SM7AbUwJ+EBnB8+DXs0hPZHO0W6pqBcc0dW90OwtVG02PswOu/teuARoLQjdDOH+t9pJgGnW5/Qmouf3gPJg==", + "dev": true, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@testing-library/react": { + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-13.1.1.tgz", + "integrity": "sha512-8mirlAa0OKaUvnqnZF6MdAh2tReYA2KtWVw1PKvaF5EcCZqgK5pl8iF+3uW90JdG5Ua2c2c2E2wtLdaug3dsVg==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@testing-library/dom": "^8.5.0", + "@types/react-dom": "^18.0.0" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, "node_modules/@tootallnate/once": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", @@ -4512,6 +4718,12 @@ "optional": true, "peer": true }, + "node_modules/@types/aria-query": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-4.2.2.tgz", + "integrity": "sha512-HnYpAE1Y6kRyKM/XkEuiRQhTHvkzMBurTHnpFLYLBGPIylZNPs9jJcuOOYWxPLJCSEtmZT0Y8rHDokKN7rRTig==", + "dev": true + }, "node_modules/@types/babel__core": { "version": "7.1.16", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.16.tgz", @@ -4835,9 +5047,9 @@ "dev": true }, "node_modules/@types/react": { - "version": "18.0.6", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.0.6.tgz", - "integrity": "sha512-bPqwzJRzKtfI0mVYr5R+1o9BOE8UEXefwc1LwcBtfnaAn6OoqMhLa/91VA8aeWfDPJt1kHvYKI8RHcQybZLHHA==", + "version": "18.0.8", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.0.8.tgz", + "integrity": "sha512-+j2hk9BzCOrrOSJASi5XiOyBbERk9jG5O73Ya4M0env5Ixi6vUNli4qy994AINcEF+1IEHISYFfIT4zwr++LKw==", "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -4904,9 +5116,9 @@ } }, "node_modules/@types/react-dom": { - "version": "18.0.2", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.0.2.tgz", - "integrity": "sha512-UxeS+Wtj5bvLRREz9tIgsK4ntCuLDo0EcAcACgw3E+9wE8ePDr9uQpq53MfcyxyIS55xJ+0B6mDS8c4qkkHLBg==", + "version": "18.0.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.0.3.tgz", + "integrity": "sha512-1RRW9kst+67gveJRYPxGmVy8eVJ05O43hg77G2j5m76/RFJtMbcfAs2viQ2UNsvvDg8F7OfQZx8qQcl6ymygaQ==", "devOptional": true, "dependencies": { "@types/react": "*" @@ -5000,6 +5212,15 @@ "optional": true, "peer": true }, + "node_modules/@types/testing-library__jest-dom": { + "version": "5.14.3", + "resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.3.tgz", + "integrity": "sha512-oKZe+Mf4ioWlMuzVBaXQ9WDnEm1+umLx0InILg+yvZVBBDmzV5KfZyLrCvadtWcx8+916jLmHafcmqqffl+iIw==", + "dev": true, + "dependencies": { + "@types/jest": "*" + } + }, "node_modules/@types/trusted-types": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.2.tgz", @@ -8047,6 +8268,17 @@ "node": ">=4.8" } }, + "node_modules/css": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/css/-/css-3.0.0.tgz", + "integrity": "sha512-DG9pFfwOrzc+hawpmqX/dHYHJG+Bsdb0klhyi1sDneOgGOXy9wQIC8hzyVp1e4NRYDBdxcylvywPkkXCHAzTyQ==", + "dev": true, + "dependencies": { + "inherits": "^2.0.4", + "source-map": "^0.6.1", + "source-map-resolve": "^0.6.0" + } + }, "node_modules/css-blank-pseudo": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/css-blank-pseudo/-/css-blank-pseudo-3.0.3.tgz", @@ -8150,6 +8382,29 @@ "node": "*" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha1-QuJ9T6BK4y+TGktNQZH6nN3ul8s=", + "dev": true + }, + "node_modules/css/node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "node_modules/css/node_modules/source-map-resolve": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.6.0.tgz", + "integrity": "sha512-KXBr9d/fO/bWo97NXsPIAW1bFSBOuCnjbNTBMO7N59hsv5i9yzRDfcYwwt0l04+VqnKC+EwzvJZIP/qkuMgR/w==", + "deprecated": "See https://github.com/lydell/source-map-resolve#deprecated", + "dev": true, + "dependencies": { + "atob": "^2.1.2", + "decode-uri-component": "^0.2.0" + } + }, "node_modules/cssdb": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-6.5.0.tgz", @@ -8168,6 +8423,12 @@ "node": ">=4" } }, + "node_modules/cssfontparser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/cssfontparser/-/cssfontparser-1.2.1.tgz", + "integrity": "sha1-9AIvyPlwDGgCnVQghK+69CWj8+M=", + "dev": true + }, "node_modules/cssnano-utils": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-3.1.0.tgz", @@ -8848,6 +9109,12 @@ "node": ">=0.10.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.13.tgz", + "integrity": "sha512-R305kwb5CcMDIpSHUnLyIAp7SrSPBx6F0VfQFB3M75xVMHhXJJIdePYgbPPh1o57vCHNu5QztokWUPsLjWzFqw==", + "dev": true + }, "node_modules/dom-converter": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz", @@ -13314,6 +13581,16 @@ } } }, + "node_modules/jest-canvas-mock": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/jest-canvas-mock/-/jest-canvas-mock-2.4.0.tgz", + "integrity": "sha512-mmMpZzpmLzn5vepIaHk5HoH3Ka4WykbSoLuG/EKoJd0x0ID/t+INo1l8ByfcUJuDM+RIsL4QDg/gDnBbrj2/IQ==", + "dev": true, + "dependencies": { + "cssfontparser": "^1.2.1", + "moo-color": "^1.0.2" + } + }, "node_modules/jest-changed-files": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-27.5.1.tgz", @@ -16160,6 +16437,15 @@ "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", "dev": true }, + "node_modules/lz-string": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz", + "integrity": "sha1-wNjq82BZ9wV5bh40SBHPTEmNOiY=", + "dev": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", @@ -16611,6 +16897,15 @@ "integrity": "sha512-I1mnb5xn4fO80BH9BLcF0yLypy2UKl+Cb01Fu0hJRkJjlCRtxZMWkTdAtDd5ZqCOxtCkhmRwyI57vWT+1iZ67w==", "dev": true }, + "node_modules/moo-color": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/moo-color/-/moo-color-1.0.3.tgz", + "integrity": "sha512-i/+ZKXMDf6aqYtBhuOcej71YSlbjT3wCO/4H1j8rPvxDJEifdwgg5MaFyu6iYAT8GBZJg2z0dkgK4YMzvURALQ==", + "dev": true, + "dependencies": { + "color-name": "^1.1.4" + } + }, "node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -18776,9 +19071,9 @@ } }, "node_modules/react": { - "version": "18.0.0", - "resolved": "https://registry.npmjs.org/react/-/react-18.0.0.tgz", - "integrity": "sha512-x+VL6wbT4JRVPm7EGxXhZ8w8LTROaxPXOqhlGyVSrv0sB1jkyFGgXxJ8LVoPRLvPR6/CIZGFmfzqUa2NYeMr2A==", + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/react/-/react-18.1.0.tgz", + "integrity": "sha512-4oL8ivCz5ZEPyclFQXaNksK3adutVS8l2xzZU0cqEFrE9Sb7fC0EFK5uEk74wIreL1DERyjvsU915j1pcT2uEQ==", "dependencies": { "loose-envify": "^1.1.0" }, @@ -19390,21 +19685,21 @@ } }, "node_modules/react-dom": { - "version": "18.0.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.0.0.tgz", - "integrity": "sha512-XqX7uzmFo0pUceWFCt7Gff6IyIMzFUn7QMZrbrQfGxtaxXZIcGQzoNpRLE3fQLnS4XzLLPMZX2T9TRcSrasicw==", + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.1.0.tgz", + "integrity": "sha512-fU1Txz7Budmvamp7bshe4Zi32d0ll7ect+ccxNu9FlObT605GOEB8BfO4tmRJ39R5Zj831VCpvQ05QPBW5yb+w==", "dependencies": { "loose-envify": "^1.1.0", - "scheduler": "^0.21.0" + "scheduler": "^0.22.0" }, "peerDependencies": { - "react": "^18.0.0" + "react": "^18.1.0" } }, "node_modules/react-dom/node_modules/scheduler": { - "version": "0.21.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.21.0.tgz", - "integrity": "sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ==", + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.22.0.tgz", + "integrity": "sha512-6QAm1BgQI88NPYymgGQLCZgvep4FyePDWFpXVK+zNSUgHwlqpJy8VEh8Et0KxTACS4VWwMousBElAZOH9nkkoQ==", "dependencies": { "loose-envify": "^1.1.0" } @@ -21734,6 +22029,12 @@ "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=", "dev": true }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "dev": true + }, "node_modules/resolve": { "version": "1.22.0", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.0.tgz", @@ -30117,6 +30418,152 @@ } } }, + "@testing-library/dom": { + "version": "8.13.0", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.13.0.tgz", + "integrity": "sha512-9VHgfIatKNXQNaZTtLnalIy0jNZzY35a4S3oi08YAt9Hv1VsfZ/DfA45lM8D/UhtHBGJ4/lGwp0PZkVndRkoOQ==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^4.2.0", + "aria-query": "^5.0.0", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.4.4", + "pretty-format": "^27.0.2" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "aria-query": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.0.0.tgz", + "integrity": "sha512-V+SM7AbUwJ+EBnB8+DXs0hPZHO0W6pqBcc0dW90OwtVG02PswOu/teuARoLQjdDOH+t9pJgGnW5/Qmouf3gPJg==", + "dev": true + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "@testing-library/jest-dom": { + "version": "5.16.4", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.16.4.tgz", + "integrity": "sha512-Gy+IoFutbMQcky0k+bqqumXZ1cTGswLsFqmNLzNdSKkU9KGV2u9oXhukCbbJ9/LRPKiqwxEE8VpV/+YZlfkPUA==", + "dev": true, + "requires": { + "@babel/runtime": "^7.9.2", + "@types/testing-library__jest-dom": "^5.9.1", + "aria-query": "^5.0.0", + "chalk": "^3.0.0", + "css": "^3.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.5.6", + "lodash": "^4.17.15", + "redent": "^3.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "aria-query": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.0.0.tgz", + "integrity": "sha512-V+SM7AbUwJ+EBnB8+DXs0hPZHO0W6pqBcc0dW90OwtVG02PswOu/teuARoLQjdDOH+t9pJgGnW5/Qmouf3gPJg==", + "dev": true + }, + "chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "@testing-library/react": { + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-13.1.1.tgz", + "integrity": "sha512-8mirlAa0OKaUvnqnZF6MdAh2tReYA2KtWVw1PKvaF5EcCZqgK5pl8iF+3uW90JdG5Ua2c2c2E2wtLdaug3dsVg==", + "dev": true, + "requires": { + "@babel/runtime": "^7.12.5", + "@testing-library/dom": "^8.5.0", + "@types/react-dom": "^18.0.0" + } + }, "@tootallnate/once": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", @@ -30137,6 +30584,12 @@ "optional": true, "peer": true }, + "@types/aria-query": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-4.2.2.tgz", + "integrity": "sha512-HnYpAE1Y6kRyKM/XkEuiRQhTHvkzMBurTHnpFLYLBGPIylZNPs9jJcuOOYWxPLJCSEtmZT0Y8rHDokKN7rRTig==", + "dev": true + }, "@types/babel__core": { "version": "7.1.16", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.16.tgz", @@ -30459,9 +30912,9 @@ "dev": true }, "@types/react": { - "version": "18.0.6", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.0.6.tgz", - "integrity": "sha512-bPqwzJRzKtfI0mVYr5R+1o9BOE8UEXefwc1LwcBtfnaAn6OoqMhLa/91VA8aeWfDPJt1kHvYKI8RHcQybZLHHA==", + "version": "18.0.8", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.0.8.tgz", + "integrity": "sha512-+j2hk9BzCOrrOSJASi5XiOyBbERk9jG5O73Ya4M0env5Ixi6vUNli4qy994AINcEF+1IEHISYFfIT4zwr++LKw==", "requires": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -30523,9 +30976,9 @@ } }, "@types/react-dom": { - "version": "18.0.2", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.0.2.tgz", - "integrity": "sha512-UxeS+Wtj5bvLRREz9tIgsK4ntCuLDo0EcAcACgw3E+9wE8ePDr9uQpq53MfcyxyIS55xJ+0B6mDS8c4qkkHLBg==", + "version": "18.0.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.0.3.tgz", + "integrity": "sha512-1RRW9kst+67gveJRYPxGmVy8eVJ05O43hg77G2j5m76/RFJtMbcfAs2viQ2UNsvvDg8F7OfQZx8qQcl6ymygaQ==", "devOptional": true, "requires": { "@types/react": "*" @@ -30619,6 +31072,15 @@ "optional": true, "peer": true }, + "@types/testing-library__jest-dom": { + "version": "5.14.3", + "resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.3.tgz", + "integrity": "sha512-oKZe+Mf4ioWlMuzVBaXQ9WDnEm1+umLx0InILg+yvZVBBDmzV5KfZyLrCvadtWcx8+916jLmHafcmqqffl+iIw==", + "dev": true, + "requires": { + "@types/jest": "*" + } + }, "@types/trusted-types": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.2.tgz", @@ -32959,6 +33421,35 @@ "which": "^1.2.9" } }, + "css": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/css/-/css-3.0.0.tgz", + "integrity": "sha512-DG9pFfwOrzc+hawpmqX/dHYHJG+Bsdb0klhyi1sDneOgGOXy9wQIC8hzyVp1e4NRYDBdxcylvywPkkXCHAzTyQ==", + "dev": true, + "requires": { + "inherits": "^2.0.4", + "source-map": "^0.6.1", + "source-map-resolve": "^0.6.0" + }, + "dependencies": { + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "source-map-resolve": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.6.0.tgz", + "integrity": "sha512-KXBr9d/fO/bWo97NXsPIAW1bFSBOuCnjbNTBMO7N59hsv5i9yzRDfcYwwt0l04+VqnKC+EwzvJZIP/qkuMgR/w==", + "dev": true, + "requires": { + "atob": "^2.1.2", + "decode-uri-component": "^0.2.0" + } + } + } + }, "css-blank-pseudo": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/css-blank-pseudo/-/css-blank-pseudo-3.0.3.tgz", @@ -33026,6 +33517,12 @@ "integrity": "sha512-a+EPoD+uZiNfh+5fxw2nO9QwFa6nJe2Or35fGY6Ipw1R3R4AGz1d1TEZrCegvw2YTmZ0jXirGYlzxxpYSHwpEg==", "dev": true }, + "css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha1-QuJ9T6BK4y+TGktNQZH6nN3ul8s=", + "dev": true + }, "cssdb": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-6.5.0.tgz", @@ -33038,6 +33535,12 @@ "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", "dev": true }, + "cssfontparser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/cssfontparser/-/cssfontparser-1.2.1.tgz", + "integrity": "sha1-9AIvyPlwDGgCnVQghK+69CWj8+M=", + "dev": true + }, "cssnano-utils": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-3.1.0.tgz", @@ -33561,6 +34064,12 @@ "esutils": "^2.0.2" } }, + "dom-accessibility-api": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.13.tgz", + "integrity": "sha512-R305kwb5CcMDIpSHUnLyIAp7SrSPBx6F0VfQFB3M75xVMHhXJJIdePYgbPPh1o57vCHNu5QztokWUPsLjWzFqw==", + "dev": true + }, "dom-converter": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz", @@ -36981,6 +37490,16 @@ "jest-cli": "^27.5.1" } }, + "jest-canvas-mock": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/jest-canvas-mock/-/jest-canvas-mock-2.4.0.tgz", + "integrity": "sha512-mmMpZzpmLzn5vepIaHk5HoH3Ka4WykbSoLuG/EKoJd0x0ID/t+INo1l8ByfcUJuDM+RIsL4QDg/gDnBbrj2/IQ==", + "dev": true, + "requires": { + "cssfontparser": "^1.2.1", + "moo-color": "^1.0.2" + } + }, "jest-changed-files": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-27.5.1.tgz", @@ -39077,6 +39596,12 @@ } } }, + "lz-string": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz", + "integrity": "sha1-wNjq82BZ9wV5bh40SBHPTEmNOiY=", + "dev": true + }, "magic-string": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", @@ -39425,6 +39950,15 @@ "integrity": "sha512-I1mnb5xn4fO80BH9BLcF0yLypy2UKl+Cb01Fu0hJRkJjlCRtxZMWkTdAtDd5ZqCOxtCkhmRwyI57vWT+1iZ67w==", "dev": true }, + "moo-color": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/moo-color/-/moo-color-1.0.3.tgz", + "integrity": "sha512-i/+ZKXMDf6aqYtBhuOcej71YSlbjT3wCO/4H1j8rPvxDJEifdwgg5MaFyu6iYAT8GBZJg2z0dkgK4YMzvURALQ==", + "dev": true, + "requires": { + "color-name": "^1.1.4" + } + }, "ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -41053,9 +41587,9 @@ } }, "react": { - "version": "18.0.0", - "resolved": "https://registry.npmjs.org/react/-/react-18.0.0.tgz", - "integrity": "sha512-x+VL6wbT4JRVPm7EGxXhZ8w8LTROaxPXOqhlGyVSrv0sB1jkyFGgXxJ8LVoPRLvPR6/CIZGFmfzqUa2NYeMr2A==", + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/react/-/react-18.1.0.tgz", + "integrity": "sha512-4oL8ivCz5ZEPyclFQXaNksK3adutVS8l2xzZU0cqEFrE9Sb7fC0EFK5uEk74wIreL1DERyjvsU915j1pcT2uEQ==", "requires": { "loose-envify": "^1.1.0" } @@ -41475,18 +42009,18 @@ } }, "react-dom": { - "version": "18.0.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.0.0.tgz", - "integrity": "sha512-XqX7uzmFo0pUceWFCt7Gff6IyIMzFUn7QMZrbrQfGxtaxXZIcGQzoNpRLE3fQLnS4XzLLPMZX2T9TRcSrasicw==", + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.1.0.tgz", + "integrity": "sha512-fU1Txz7Budmvamp7bshe4Zi32d0ll7ect+ccxNu9FlObT605GOEB8BfO4tmRJ39R5Zj831VCpvQ05QPBW5yb+w==", "requires": { "loose-envify": "^1.1.0", - "scheduler": "^0.21.0" + "scheduler": "^0.22.0" }, "dependencies": { "scheduler": { - "version": "0.21.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.21.0.tgz", - "integrity": "sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ==", + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.22.0.tgz", + "integrity": "sha512-6QAm1BgQI88NPYymgGQLCZgvep4FyePDWFpXVK+zNSUgHwlqpJy8VEh8Et0KxTACS4VWwMousBElAZOH9nkkoQ==", "requires": { "loose-envify": "^1.1.0" } @@ -43068,6 +43602,12 @@ "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=", "dev": true }, + "resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "dev": true + }, "resolve": { "version": "1.22.0", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.0.tgz", diff --git a/package.json b/package.json index fd9dd228..91f5ccb2 100644 --- a/package.json +++ b/package.json @@ -42,12 +42,12 @@ "leaflet": "^1.7.1", "qs": "^6.9.6", "ramda": "^0.27.2", - "react": "^18.0.0", + "react": "^18.1.0", "react-chartjs-2": "^3.3.0", "react-colorful": "^5.5.1", "react-copy-to-clipboard": "^5.0.4", "react-datepicker": "^4.7.0", - "react-dom": "^18.0.0", + "react-dom": "^18.1.0", "react-external-link": "^1.2.2", "react-leaflet": "^4.0.0", "react-redux": "^8.0.0", @@ -71,6 +71,8 @@ "@stryker-mutator/core": "^5.6.1", "@stryker-mutator/jest-runner": "^5.6.1", "@stryker-mutator/typescript-checker": "^5.6.1", + "@testing-library/jest-dom": "^5.16.4", + "@testing-library/react": "^13.1.1", "@types/classnames": "^2.3.1", "@types/enzyme": "^3.10.11", "@types/jest": "^27.4.1", @@ -78,11 +80,11 @@ "@types/leaflet": "^1.7.9", "@types/qs": "^6.9.7", "@types/ramda": "0.27.38", - "@types/react": "^18.0.6", + "@types/react": "^18.0.8", "@types/react-color": "^3.0.6", "@types/react-copy-to-clipboard": "^5.0.2", "@types/react-datepicker": "^4.3.4", - "@types/react-dom": "^18.0.2", + "@types/react-dom": "^18.0.3", "@types/react-tag-autocomplete": "^6.1.1", "@types/uuid": "^8.3.4", "@wojtekmaj/enzyme-adapter-react-17": "0.6.5", @@ -94,7 +96,9 @@ "eslint": "^8.12.0", "identity-obj-proxy": "^3.0.0", "jest": "^27.5.1", + "jest-canvas-mock": "^2.4.0", "react-scripts": "^5.0.0", + "resize-observer-polyfill": "^1.5.1", "sass": "^1.49.9", "serve": "^13.0.2", "stryker-cli": "^1.0.2", diff --git a/src/api/services/ShlinkApiClient.ts b/src/api/services/ShlinkApiClient.ts index 39e3ade1..552254e9 100644 --- a/src/api/services/ShlinkApiClient.ts +++ b/src/api/services/ShlinkApiClient.ts @@ -56,6 +56,10 @@ export default class ShlinkApiClient { this.performRequest<{ visits: ShlinkVisits }>(`/tags/${tag}/visits`, 'GET', query) .then(({ data }) => data.visits); + public readonly getDomainVisits = async (domain: string, query?: Omit): Promise => + this.performRequest<{ visits: ShlinkVisits }>(`/domains/${domain}/visits`, 'GET', query) + .then(({ data }) => data.visits); + public readonly getOrphanVisits = async (query?: Omit): Promise => this.performRequest<{ visits: ShlinkVisits }>('/visits/orphan', 'GET', query) .then(({ data }) => data.visits); diff --git a/src/common/MenuLayout.tsx b/src/common/MenuLayout.tsx index 76361b2e..d7166288 100644 --- a/src/common/MenuLayout.tsx +++ b/src/common/MenuLayout.tsx @@ -5,7 +5,12 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import classNames from 'classnames'; import { withSelectedServer } from '../servers/helpers/withSelectedServer'; import { useSwipeable, useToggle } from '../utils/helpers/hooks'; -import { supportsDomainRedirects, supportsNonOrphanVisits, supportsOrphanVisits } from '../utils/helpers/features'; +import { + supportsDomainRedirects, + supportsDomainVisits, + supportsNonOrphanVisits, + supportsOrphanVisits, +} from '../utils/helpers/features'; import { isReachableServer } from '../servers/data'; import NotFound from './NotFound'; import { AsideMenuProps } from './AsideMenu'; @@ -23,6 +28,7 @@ const MenuLayout = ( CreateShortUrl: FC, ShortUrlVisits: FC, TagVisits: FC, + DomainVisits: FC, OrphanVisits: FC, NonOrphanVisits: FC, ServerError: FC, @@ -48,6 +54,7 @@ const MenuLayout = ( const addOrphanVisitsRoute = supportsOrphanVisits(selectedServer); const addNonOrphanVisitsRoute = supportsNonOrphanVisits(selectedServer); const addManageDomainsRoute = supportsDomainRedirects(selectedServer); + const addDomainVisitsRoute = supportsDomainVisits(selectedServer); const burgerClasses = classNames('menu-layout__burger-icon', { 'menu-layout__burger-icon--active': sidebarVisible }); const swipeableProps = useSwipeable(showSidebar, hideSidebar); @@ -68,6 +75,7 @@ const MenuLayout = ( } /> } /> } /> + {addDomainVisitsRoute && } />} {addOrphanVisitsRoute && } />} {addNonOrphanVisitsRoute && } />} } /> diff --git a/src/common/services/provideServices.ts b/src/common/services/provideServices.ts index 687b9d26..598661a3 100644 --- a/src/common/services/provideServices.ts +++ b/src/common/services/provideServices.ts @@ -40,6 +40,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { 'CreateShortUrl', 'ShortUrlVisits', 'TagVisits', + 'DomainVisits', 'OrphanVisits', 'NonOrphanVisits', 'ServerError', diff --git a/src/container/types.ts b/src/container/types.ts index aeb4a8b5..84cb2df1 100644 --- a/src/container/types.ts +++ b/src/container/types.ts @@ -15,6 +15,7 @@ import { DomainsList } from '../domains/reducers/domainsList'; import { VisitsOverview } from '../visits/reducers/visitsOverview'; import { VisitsInfo } from '../visits/types'; import { Sidebar } from '../common/reducers/sidebar'; +import { DomainVisits } from '../visits/reducers/domainVisits'; export interface ShlinkState { servers: ServersMap; @@ -25,6 +26,7 @@ export interface ShlinkState { shortUrlEdition: ShortUrlEdition; shortUrlVisits: ShortUrlVisits; tagVisits: TagVisits; + domainVisits: DomainVisits; orphanVisits: VisitsInfo; nonOrphanVisits: VisitsInfo; shortUrlDetail: ShortUrlDetail; diff --git a/src/domains/DomainRow.tsx b/src/domains/DomainRow.tsx index e5173ad2..f3ca7426 100644 --- a/src/domains/DomainRow.tsx +++ b/src/domains/DomainRow.tsx @@ -1,19 +1,13 @@ import { FC, useEffect } from 'react'; -import { Button, UncontrolledTooltip } from 'reactstrap'; +import { UncontrolledTooltip } from 'reactstrap'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { - faBan as forbiddenIcon, - faDotCircle as defaultDomainIcon, - faEdit as editIcon, -} from '@fortawesome/free-solid-svg-icons'; +import { faDotCircle as defaultDomainIcon } from '@fortawesome/free-solid-svg-icons'; import { ShlinkDomainRedirects } from '../api/types'; -import { useToggle } from '../utils/helpers/hooks'; import { OptionalString } from '../utils/utils'; import { SelectedServer } from '../servers/data'; -import { supportsDefaultDomainRedirectsEdition } from '../utils/helpers/features'; -import { EditDomainRedirectsModal } from './helpers/EditDomainRedirectsModal'; import { Domain } from './data'; import { DomainStatusIcon } from './helpers/DomainStatusIcon'; +import { DomainDropdown } from './helpers/DomainDropdown'; interface DomainRowProps { domain: Domain; @@ -39,9 +33,7 @@ const DefaultDomain: FC = () => ( export const DomainRow: FC = ( { domain, editDomainRedirects, checkDomainHealth, defaultRedirects, selectedServer }, ) => { - const [isOpen, toggle] = useToggle(); const { domain: authority, isDefault, redirects, status } = domain; - const canEditDomain = !isDefault || supportsDefaultDomainRedirectsEdition(selectedServer); useEffect(() => { checkDomainHealth(domain.domain); @@ -64,25 +56,8 @@ export const DomainRow: FC = ( - - - - {!canEditDomain && ( - - Redirects for default domain cannot be edited here. -
- Use config options or env vars directly on the server. -
- )} + - ); }; diff --git a/src/domains/helpers/DomainDropdown.tsx b/src/domains/helpers/DomainDropdown.tsx new file mode 100644 index 00000000..423c4db5 --- /dev/null +++ b/src/domains/helpers/DomainDropdown.tsx @@ -0,0 +1,51 @@ +import { FC } from 'react'; +import { DropdownItem } from 'reactstrap'; +import { Link } from 'react-router-dom'; +import { faChartPie as pieChartIcon, faEdit as editIcon } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { useToggle } from '../../utils/helpers/hooks'; +import { DropdownBtnMenu } from '../../utils/DropdownBtnMenu'; +import { EditDomainRedirectsModal } from './EditDomainRedirectsModal'; +import { Domain } from '../data'; +import { ShlinkDomainRedirects } from '../../api/types'; +import { supportsDefaultDomainRedirectsEdition, supportsDomainVisits } from '../../utils/helpers/features'; +import { getServerId, SelectedServer } from '../../servers/data'; +import { DEFAULT_DOMAIN } from '../../visits/reducers/domainVisits'; + +interface DomainDropdownProps { + domain: Domain; + editDomainRedirects: (domain: string, redirects: Partial) => Promise; + selectedServer: SelectedServer; +} + +export const DomainDropdown: FC = ({ domain, editDomainRedirects, selectedServer }) => { + const [isOpen, toggle] = useToggle(); + const [isModalOpen, toggleModal] = useToggle(); + const { isDefault } = domain; + const canBeEdited = !isDefault || supportsDefaultDomainRedirectsEdition(selectedServer); + const withVisits = supportsDomainVisits(selectedServer); + const serverId = getServerId(selectedServer); + + return ( + + {withVisits && ( + + Visit stats + + )} + + Edit redirects + + + + + ); +}; diff --git a/src/reducers/index.ts b/src/reducers/index.ts index c6573632..da523b81 100644 --- a/src/reducers/index.ts +++ b/src/reducers/index.ts @@ -7,6 +7,7 @@ import shortUrlDeletionReducer from '../short-urls/reducers/shortUrlDeletion'; import shortUrlEditionReducer from '../short-urls/reducers/shortUrlEdition'; import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits'; import tagVisitsReducer from '../visits/reducers/tagVisits'; +import domainVisitsReducer from '../visits/reducers/domainVisits'; import orphanVisitsReducer from '../visits/reducers/orphanVisits'; import nonOrphanVisitsReducer from '../visits/reducers/nonOrphanVisits'; import shortUrlDetailReducer from '../short-urls/reducers/shortUrlDetail'; @@ -30,6 +31,7 @@ export default combineReducers({ shortUrlEdition: shortUrlEditionReducer, shortUrlVisits: shortUrlVisitsReducer, tagVisits: tagVisitsReducer, + domainVisits: domainVisitsReducer, orphanVisits: orphanVisitsReducer, nonOrphanVisits: nonOrphanVisitsReducer, shortUrlDetail: shortUrlDetailReducer, diff --git a/src/short-urls/helpers/index.ts b/src/short-urls/helpers/index.ts index e40fa4a3..e5994edb 100644 --- a/src/short-urls/helpers/index.ts +++ b/src/short-urls/helpers/index.ts @@ -1,6 +1,7 @@ import { isNil } from 'ramda'; import { ShortUrl } from '../data'; import { OptionalString } from '../../utils/utils'; +import { DEFAULT_DOMAIN } from '../../visits/reducers/domainVisits'; export const shortUrlMatches = (shortUrl: ShortUrl, shortCode: string, domain: OptionalString): boolean => { if (isNil(domain)) { @@ -9,3 +10,11 @@ export const shortUrlMatches = (shortUrl: ShortUrl, shortCode: string, domain: O return shortUrl.shortCode === shortCode && shortUrl.domain === domain; }; + +export const domainMatches = (shortUrl: ShortUrl, domain: string): boolean => { + if (!shortUrl.domain && domain === DEFAULT_DOMAIN) { + return true; + } + + return shortUrl.domain === domain; +}; diff --git a/src/utils/helpers/features.ts b/src/utils/helpers/features.ts index 850f3b57..6f8f7810 100644 --- a/src/utils/helpers/features.ts +++ b/src/utils/helpers/features.ts @@ -17,3 +17,4 @@ export const supportsForwardQuery = serverMatchesVersions({ minVersion: '2.9.0' export const supportsDefaultDomainRedirectsEdition = serverMatchesVersions({ minVersion: '2.10.0' }); export const supportsNonOrphanVisits = serverMatchesVersions({ minVersion: '3.0.0' }); export const supportsAllTagsFiltering = supportsNonOrphanVisits; +export const supportsDomainVisits = serverMatchesVersions({ minVersion: '3.1.0' }); diff --git a/src/visits/DomainVisits.tsx b/src/visits/DomainVisits.tsx new file mode 100644 index 00000000..b0bdca5f --- /dev/null +++ b/src/visits/DomainVisits.tsx @@ -0,0 +1,46 @@ +import { useParams } from 'react-router-dom'; +import { CommonVisitsProps } from './types/CommonVisitsProps'; +import { ShlinkVisitsParams } from '../api/types'; +import { DomainVisits as DomainVisitsState } from './reducers/domainVisits'; +import { ReportExporter } from '../common/services/ReportExporter'; +import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; +import { Topics } from '../mercure/helpers/Topics'; +import { useGoBack } from '../utils/helpers/hooks'; +import { toApiParams } from './types/helpers'; +import { NormalizedVisit } from './types'; +import VisitsStats from './VisitsStats'; +import VisitsHeader from './VisitsHeader'; + +export interface DomainVisitsProps extends CommonVisitsProps { + getDomainVisits: (domain: string, query?: ShlinkVisitsParams, doIntervalFallback?: boolean) => void; + domainVisits: DomainVisitsState; + cancelGetDomainVisits: () => void; +} + +export const DomainVisits = ({ exportVisits }: ReportExporter) => boundToMercureHub(({ + getDomainVisits, + domainVisits, + cancelGetDomainVisits, + settings, + selectedServer, +}: DomainVisitsProps) => { + const goBack = useGoBack(); + const { domain = '' } = useParams(); + const [authority, domainId = authority] = domain.split('_'); + const loadVisits = (params: ShlinkVisitsParams, doIntervalFallback?: boolean) => + getDomainVisits(domainId, toApiParams(params), doIntervalFallback); + const exportCsv = (visits: NormalizedVisit[]) => exportVisits(`domain_${authority}_visits.csv`, visits); + + return ( + + + + ); +}, () => [Topics.visits]); diff --git a/src/visits/NonOrphanVisits.tsx b/src/visits/NonOrphanVisits.tsx index 231c2f7e..2ebfe913 100644 --- a/src/visits/NonOrphanVisits.tsx +++ b/src/visits/NonOrphanVisits.tsx @@ -7,7 +7,7 @@ import VisitsStats from './VisitsStats'; import { NormalizedVisit, VisitsInfo, VisitsParams } from './types'; import { CommonVisitsProps } from './types/CommonVisitsProps'; import { toApiParams } from './types/helpers'; -import { NonOrphanVisitsHeader } from './NonOrphanVisitsHeader'; +import VisitsHeader from './VisitsHeader'; export interface NonOrphanVisitsProps extends CommonVisitsProps { getNonOrphanVisits: (params?: ShlinkVisitsParams, doIntervalFallback?: boolean) => void; @@ -36,7 +36,7 @@ export const NonOrphanVisits = ({ exportVisits }: ReportExporter) => boundToMerc exportCsv={exportCsv} selectedServer={selectedServer} > - + ); }, () => [Topics.visits]); diff --git a/src/visits/NonOrphanVisitsHeader.tsx b/src/visits/NonOrphanVisitsHeader.tsx deleted file mode 100644 index a361defe..00000000 --- a/src/visits/NonOrphanVisitsHeader.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import VisitsHeader from './VisitsHeader'; -import { VisitsInfo } from './types'; -import './ShortUrlVisitsHeader.scss'; - -interface NonOrphanVisitsHeaderProps { - nonOrphanVisits: VisitsInfo; - goBack: () => void; -} - -export const NonOrphanVisitsHeader = ({ nonOrphanVisits, goBack }: NonOrphanVisitsHeaderProps) => { - const { visits } = nonOrphanVisits; - - return ; -}; diff --git a/src/visits/OrphanVisits.tsx b/src/visits/OrphanVisits.tsx index 8779be4b..bc2ee1d1 100644 --- a/src/visits/OrphanVisits.tsx +++ b/src/visits/OrphanVisits.tsx @@ -4,10 +4,10 @@ import { Topics } from '../mercure/helpers/Topics'; import { useGoBack } from '../utils/helpers/hooks'; import { ReportExporter } from '../common/services/ReportExporter'; import VisitsStats from './VisitsStats'; -import { OrphanVisitsHeader } from './OrphanVisitsHeader'; import { NormalizedVisit, OrphanVisitType, VisitsInfo, VisitsParams } from './types'; import { CommonVisitsProps } from './types/CommonVisitsProps'; import { toApiParams } from './types/helpers'; +import VisitsHeader from './VisitsHeader'; export interface OrphanVisitsProps extends CommonVisitsProps { getOrphanVisits: ( @@ -41,7 +41,7 @@ export const OrphanVisits = ({ exportVisits }: ReportExporter) => boundToMercure selectedServer={selectedServer} isOrphanVisits > - + ); }, () => [Topics.orphanVisits]); diff --git a/src/visits/OrphanVisitsHeader.tsx b/src/visits/OrphanVisitsHeader.tsx deleted file mode 100644 index cf8c7191..00000000 --- a/src/visits/OrphanVisitsHeader.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import VisitsHeader from './VisitsHeader'; -import { VisitsInfo } from './types'; -import './ShortUrlVisitsHeader.scss'; - -interface OrphanVisitsHeaderProps { - orphanVisits: VisitsInfo; - goBack: () => void; -} - -export const OrphanVisitsHeader = ({ orphanVisits, goBack }: OrphanVisitsHeaderProps) => { - const { visits } = orphanVisits; - - return ; -}; diff --git a/src/visits/reducers/domainVisits.ts b/src/visits/reducers/domainVisits.ts new file mode 100644 index 00000000..3570e023 --- /dev/null +++ b/src/visits/reducers/domainVisits.ts @@ -0,0 +1,96 @@ +import { Action, Dispatch } from 'redux'; +import { Visit, VisitsFallbackIntervalAction, VisitsInfo, VisitsLoadProgressChangedAction } from '../types'; +import { buildActionCreator, buildReducer } from '../../utils/helpers/redux'; +import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; +import { GetState } from '../../container/types'; +import { ShlinkVisitsParams } from '../../api/types'; +import { ApiErrorAction } from '../../api/types/actions'; +import { isBetween } from '../../utils/helpers/date'; +import { getVisitsWithLoader, lastVisitLoaderForLoader } from './common'; +import { CREATE_VISITS, CreateVisitsAction } from './visitCreation'; +import { domainMatches } from '../../short-urls/helpers'; + +export const GET_DOMAIN_VISITS_START = 'shlink/domainVisits/GET_DOMAIN_VISITS_START'; +export const GET_DOMAIN_VISITS_ERROR = 'shlink/domainVisits/GET_DOMAIN_VISITS_ERROR'; +export const GET_DOMAIN_VISITS = 'shlink/domainVisits/GET_DOMAIN_VISITS'; +export const GET_DOMAIN_VISITS_LARGE = 'shlink/domainVisits/GET_DOMAIN_VISITS_LARGE'; +export const GET_DOMAIN_VISITS_CANCEL = 'shlink/domainVisits/GET_DOMAIN_VISITS_CANCEL'; +export const GET_DOMAIN_VISITS_PROGRESS_CHANGED = 'shlink/domainVisits/GET_DOMAIN_VISITS_PROGRESS_CHANGED'; +export const GET_DOMAIN_VISITS_FALLBACK_TO_INTERVAL = 'shlink/domainVisits/GET_DOMAIN_VISITS_FALLBACK_TO_INTERVAL'; + +export const DEFAULT_DOMAIN = 'DEFAULT'; + +export interface DomainVisits extends VisitsInfo { + domain: string; +} + +export interface DomainVisitsAction extends Action { + visits: Visit[]; + domain: string; + query?: ShlinkVisitsParams; +} + +type DomainVisitsCombinedAction = DomainVisitsAction +& VisitsLoadProgressChangedAction +& VisitsFallbackIntervalAction +& CreateVisitsAction +& ApiErrorAction; + +const initialState: DomainVisits = { + visits: [], + domain: '', + loading: false, + loadingLarge: false, + error: false, + cancelLoad: false, + progress: 0, +}; + +export default buildReducer({ + [GET_DOMAIN_VISITS_START]: () => ({ ...initialState, loading: true }), + [GET_DOMAIN_VISITS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }), + [GET_DOMAIN_VISITS]: (state, { visits, domain, query }) => ( + { ...state, visits, domain, query, loading: false, error: false } + ), + [GET_DOMAIN_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }), + [GET_DOMAIN_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }), + [GET_DOMAIN_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }), + [GET_DOMAIN_VISITS_FALLBACK_TO_INTERVAL]: (state, { fallbackInterval }) => ({ ...state, fallbackInterval }), + [CREATE_VISITS]: (state, { createdVisits }) => { + const { domain, visits, query = {} } = state; + const { startDate, endDate } = query; + const newVisits = createdVisits + .filter(({ shortUrl, visit }) => + shortUrl && domainMatches(shortUrl, domain) && isBetween(visit.date, startDate, endDate)) + .map(({ visit }) => visit); + + return { ...state, visits: [...newVisits, ...visits] }; + }, +}, initialState); + +export const getDomainVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( + domain: string, + query: ShlinkVisitsParams = {}, + doIntervalFallback = false, +) => async (dispatch: Dispatch, getState: GetState) => { + const { getDomainVisits: getVisits } = buildShlinkApiClient(getState); + const visitsLoader = async (page: number, itemsPerPage: number) => getVisits( + domain, + { ...query, page, itemsPerPage }, + ); + const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, async (params) => getVisits(domain, params)); + const shouldCancel = () => getState().domainVisits.cancelLoad; + const extraFinishActionData: Partial = { domain, query }; + const actionMap = { + start: GET_DOMAIN_VISITS_START, + large: GET_DOMAIN_VISITS_LARGE, + finish: GET_DOMAIN_VISITS, + error: GET_DOMAIN_VISITS_ERROR, + progress: GET_DOMAIN_VISITS_PROGRESS_CHANGED, + fallbackToInterval: GET_DOMAIN_VISITS_FALLBACK_TO_INTERVAL, + }; + + return getVisitsWithLoader(visitsLoader, lastVisitLoader, extraFinishActionData, actionMap, dispatch, shouldCancel); +}; + +export const cancelGetDomainVisits = buildActionCreator(GET_DOMAIN_VISITS_CANCEL); diff --git a/src/visits/services/provideServices.ts b/src/visits/services/provideServices.ts index 3a53d7e1..efc9d620 100644 --- a/src/visits/services/provideServices.ts +++ b/src/visits/services/provideServices.ts @@ -7,11 +7,13 @@ import { OrphanVisits } from '../OrphanVisits'; import { NonOrphanVisits } from '../NonOrphanVisits'; import { cancelGetShortUrlVisits, getShortUrlVisits } from '../reducers/shortUrlVisits'; import { cancelGetTagVisits, getTagVisits } from '../reducers/tagVisits'; +import { cancelGetDomainVisits, getDomainVisits } from '../reducers/domainVisits'; import { cancelGetOrphanVisits, getOrphanVisits } from '../reducers/orphanVisits'; import { cancelGetNonOrphanVisits, getNonOrphanVisits } from '../reducers/nonOrphanVisits'; import { ConnectDecorator } from '../../container/types'; import { loadVisitsOverview } from '../reducers/visitsOverview'; import * as visitsParser from './VisitsParser'; +import { DomainVisits } from '../DomainVisits'; const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { // Components @@ -29,6 +31,12 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { ['getTagVisits', 'cancelGetTagVisits', 'createNewVisits', 'loadMercureInfo'], )); + bottle.serviceFactory('DomainVisits', DomainVisits, 'ReportExporter'); + bottle.decorator('DomainVisits', connect( + ['domainVisits', 'mercureInfo', 'settings', 'selectedServer'], + ['getDomainVisits', 'cancelGetDomainVisits', 'createNewVisits', 'loadMercureInfo'], + )); + bottle.serviceFactory('OrphanVisits', OrphanVisits, 'ReportExporter'); bottle.decorator('OrphanVisits', connect( ['orphanVisits', 'mercureInfo', 'settings', 'selectedServer'], @@ -51,6 +59,9 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('getTagVisits', getTagVisits, 'buildShlinkApiClient'); bottle.serviceFactory('cancelGetTagVisits', () => cancelGetTagVisits); + bottle.serviceFactory('getDomainVisits', getDomainVisits, 'buildShlinkApiClient'); + bottle.serviceFactory('cancelGetDomainVisits', () => cancelGetDomainVisits); + bottle.serviceFactory('getOrphanVisits', getOrphanVisits, 'buildShlinkApiClient'); bottle.serviceFactory('cancelGetOrphanVisits', () => cancelGetOrphanVisits); diff --git a/test/api/services/ShlinkApiClient.test.ts b/test/api/services/ShlinkApiClient.test.ts index 6b08a82e..88bab25f 100644 --- a/test/api/services/ShlinkApiClient.test.ts +++ b/test/api/services/ShlinkApiClient.test.ts @@ -115,6 +115,28 @@ describe('ShlinkApiClient', () => { }); }); + describe('getDomainVisits', () => { + it('properly returns domain visits', async () => { + const expectedVisits = ['foo', 'bar']; + const axiosSpy = createAxiosMock({ + data: { + visits: { + data: expectedVisits, + }, + }, + }); + const { getDomainVisits } = new ShlinkApiClient(axiosSpy, '', ''); + + const actualVisits = await getDomainVisits('foo.com', {}); + + expect({ data: expectedVisits }).toEqual(actualVisits); + expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ + url: '/domains/foo.com/visits', + method: 'GET', + })); + }); + }); + describe('getShortUrl', () => { it.each(shortCodesWithDomainCombinations)('properly returns short URL', async (shortCode, domain) => { const expectedShortUrl = { foo: 'bar' }; diff --git a/test/common/MenuLayout.test.tsx b/test/common/MenuLayout.test.tsx index 47d942f1..2cf03f78 100644 --- a/test/common/MenuLayout.test.tsx +++ b/test/common/MenuLayout.test.tsx @@ -15,7 +15,7 @@ jest.mock('react-router-dom', () => ({ describe('', () => { const ServerError = jest.fn(); const C = jest.fn(); - const MenuLayout = createMenuLayout(C, C, C, C, C, C, C, C, ServerError, C, C, C); + const MenuLayout = createMenuLayout(C, C, C, C, C, C, C, C, C, ServerError, C, C, C); let wrapper: ShallowWrapper; const createWrapper = (selectedServer: SelectedServer) => { (useParams as any).mockReturnValue({ serverId: 'abc123' }); @@ -59,6 +59,7 @@ describe('', () => { ['2.8.0' as SemVer, 11], ['2.10.0' as SemVer, 11], ['3.0.0' as SemVer, 12], + ['3.1.0' as SemVer, 13], ])('has expected amount of routes based on selected server\'s version', (version, expectedAmountOfRoutes) => { const selectedServer = Mock.of({ version }); const wrapper = createWrapper(selectedServer).dive(); diff --git a/test/domains/DomainRow.test.tsx b/test/domains/DomainRow.test.tsx index ea87f53f..387c4164 100644 --- a/test/domains/DomainRow.test.tsx +++ b/test/domains/DomainRow.test.tsx @@ -1,11 +1,8 @@ import { shallow, ShallowWrapper } from 'enzyme'; import { Mock } from 'ts-mockery'; -import { Button, UncontrolledTooltip } from 'reactstrap'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faBan as forbiddenIcon, faEdit as editIcon } from '@fortawesome/free-solid-svg-icons'; import { ShlinkDomainRedirects } from '../../src/api/types'; import { DomainRow } from '../../src/domains/DomainRow'; -import { ReachableServer, SelectedServer } from '../../src/servers/data'; +import { SelectedServer } from '../../src/servers/data'; import { Domain } from '../../src/domains/data'; describe('', () => { @@ -25,65 +22,6 @@ describe('', () => { afterEach(() => wrapper?.unmount()); - it.each([ - [Mock.of({ domain: '', isDefault: true }), undefined, 1, 1, 'defaultDomainBtn'], - [Mock.of({ domain: '', isDefault: false }), undefined, 0, 0, undefined], - [Mock.of({ domain: 'foo.com', isDefault: true }), undefined, 1, 1, 'defaultDomainBtn'], - [Mock.of({ domain: 'foo.bar.com', isDefault: true }), undefined, 1, 1, 'defaultDomainBtn'], - [Mock.of({ domain: 'foo.baz', isDefault: false }), undefined, 0, 0, undefined], - [ - Mock.of({ domain: 'foo.baz', isDefault: true }), - Mock.of({ version: '2.10.0' }), - 1, - 0, - undefined, - ], - [ - Mock.of({ domain: 'foo.baz', isDefault: true }), - Mock.of({ version: '2.9.0' }), - 1, - 1, - 'defaultDomainBtn', - ], - [ - Mock.of({ domain: 'foo.baz', isDefault: false }), - Mock.of({ version: '2.9.0' }), - 0, - 0, - undefined, - ], - [ - Mock.of({ domain: 'foo.baz', isDefault: false }), - Mock.of({ version: '2.10.0' }), - 0, - 0, - undefined, - ], - ])('shows proper components based on provided domain and selectedServer', ( - domain, - selectedServer, - expectedDefaultDomainIcons, - expectedDisabledComps, - expectedDomainId, - ) => { - const wrapper = createWrapper(domain, selectedServer); - const defaultDomainComp = wrapper.find('td').first().find('DefaultDomain'); - const disabledBtn = wrapper.find(Button).findWhere((btn) => !!btn.prop('disabled')); - const tooltip = wrapper.find(UncontrolledTooltip); - const button = wrapper.find(Button); - const icon = wrapper.find(FontAwesomeIcon); - - expect(defaultDomainComp).toHaveLength(expectedDefaultDomainIcons); - expect(disabledBtn).toHaveLength(expectedDisabledComps); - expect(button.prop('disabled')).toEqual(expectedDisabledComps > 0); - expect(icon.prop('icon')).toEqual(expectedDisabledComps > 0 ? forbiddenIcon : editIcon); - expect(tooltip).toHaveLength(expectedDisabledComps); - - if (expectedDisabledComps > 0) { - expect(tooltip.prop('target')).toEqual(expectedDomainId); - } - }); - it.each([ [undefined, 3], [Mock.of(), 3], diff --git a/test/domains/helpers/DomainDropdown.test.tsx b/test/domains/helpers/DomainDropdown.test.tsx new file mode 100644 index 00000000..a9d92cf0 --- /dev/null +++ b/test/domains/helpers/DomainDropdown.test.tsx @@ -0,0 +1,85 @@ +import { fireEvent, render, screen, waitForElementToBeRemoved } from '@testing-library/react'; +import { Mock } from 'ts-mockery'; +import { MemoryRouter } from 'react-router-dom'; +import { DomainDropdown } from '../../../src/domains/helpers/DomainDropdown'; +import { Domain } from '../../../src/domains/data'; +import { ReachableServer, SelectedServer } from '../../../src/servers/data'; +import { SemVer } from '../../../src/utils/helpers/version'; + +describe('', () => { + const editDomainRedirects = jest.fn().mockResolvedValue(undefined); + const setUp = (domain?: Domain, selectedServer?: SelectedServer) => render( + + ()} + selectedServer={selectedServer ?? Mock.all()} + editDomainRedirects={editDomainRedirects} + /> + , + ); + + afterEach(jest.clearAllMocks); + + it('renders expected menu items', () => { + setUp(); + + expect(screen.queryByText('Visit stats')).not.toBeInTheDocument(); + expect(screen.getByText('Edit redirects')).toBeInTheDocument(); + }); + + it.each([ + [true, '_DEFAULT'], + [false, ''], + ])('points first link to the proper section', (isDefault, expectedLink) => { + setUp( + Mock.of({ domain: 'foo.com', isDefault }), + Mock.of({ version: '3.1.0', id: '123' }), + ); + + expect(screen.getByText('Visit stats')).toHaveAttribute('href', `/server/123/domain/foo.com${expectedLink}/visits`); + }); + + it.each([ + [true, '2.9.0' as SemVer, false], + [true, '2.10.0' as SemVer, true], + [false, '2.9.0' as SemVer, true], + ])('allows editing certain the domains', (isDefault, serverVersion, canBeEdited) => { + setUp( + Mock.of({ domain: 'foo.com', isDefault }), + Mock.of({ version: serverVersion, id: '123' }), + ); + + if (canBeEdited) { + expect(screen.getByText('Edit redirects')).not.toHaveAttribute('disabled'); + } else { + expect(screen.getByText('Edit redirects')).toHaveAttribute('disabled'); + } + }); + + it.each([ + ['foo.com'], + ['bar.org'], + ['baz.net'], + ])('displays modal when editing redirects', async (domain) => { + setUp(Mock.of({ domain, isDefault: false })); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(screen.queryByRole('form')).not.toBeInTheDocument(); + fireEvent.click(screen.getByText('Edit redirects')); + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + + expect(editDomainRedirects).not.toHaveBeenCalled(); + fireEvent.click(screen.getByText('Save')); + expect(editDomainRedirects).toHaveBeenCalledWith(domain, expect.anything()); + + await waitForElementToBeRemoved(() => screen.queryByRole('dialog')); + }); + + it('displays dropdown when clicked', async () => { + setUp(); + + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { expanded: false })); + expect(await screen.findByRole('menu')).toBeInTheDocument(); + }); +}); diff --git a/test/visits/DomainVisits.test.tsx b/test/visits/DomainVisits.test.tsx new file mode 100644 index 00000000..7e9761d2 --- /dev/null +++ b/test/visits/DomainVisits.test.tsx @@ -0,0 +1,52 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { Mock } from 'ts-mockery'; +import { formatISO } from 'date-fns'; +import { DomainVisits as createDomainVisits } from '../../src/visits/DomainVisits'; +import { ReportExporter } from '../../src/common/services/ReportExporter'; +import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub'; +import { DomainVisits } from '../../src/visits/reducers/domainVisits'; +import { Settings } from '../../src/settings/reducers/settings'; +import { SelectedServer } from '../../src/servers/data'; +import { Visit } from '../../src/visits/types'; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: jest.fn().mockReturnValue({ domain: 'foo.com_DEFAULT' }), +})); + +describe('', () => { + const exportVisits = jest.fn(); + const getDomainVisits = jest.fn(); + const cancelGetDomainVisits = jest.fn(); + const domainVisits = Mock.of({ visits: [Mock.of({ date: formatISO(new Date()) })] }); + const DomainVisits = createDomainVisits(Mock.of({ exportVisits })); + + beforeEach(() => render( + + ({ mercureInfo: {} })} + getDomainVisits={getDomainVisits} + cancelGetDomainVisits={cancelGetDomainVisits} + domainVisits={domainVisits} + settings={Mock.all()} + selectedServer={Mock.all()} + /> + , + )); + + it('wraps visits stats and header', () => { + expect(screen.getByRole('heading', { name: '"foo.com" visits' })).toBeInTheDocument(); + expect(getDomainVisits).toHaveBeenCalledWith('DEFAULT', expect.anything(), expect.anything()); + }); + + it('exports visits when clicking the button', () => { + const btn = screen.getByRole('button', { name: 'Export (1)' }); + + expect(exportVisits).not.toHaveBeenCalled(); + expect(btn).toBeInTheDocument(); + + fireEvent.click(btn); + expect(exportVisits).toHaveBeenCalledWith('domain_foo.com_visits.csv', expect.anything()); + }); +}); diff --git a/test/visits/NonOrphanVisits.test.tsx b/test/visits/NonOrphanVisits.test.tsx index db6e7666..9419b327 100644 --- a/test/visits/NonOrphanVisits.test.tsx +++ b/test/visits/NonOrphanVisits.test.tsx @@ -4,10 +4,10 @@ import { NonOrphanVisits as createNonOrphanVisits } from '../../src/visits/NonOr import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub'; import { VisitsInfo } from '../../src/visits/types'; import VisitsStats from '../../src/visits/VisitsStats'; -import { NonOrphanVisitsHeader } from '../../src/visits/NonOrphanVisitsHeader'; import { Settings } from '../../src/settings/reducers/settings'; import { ReportExporter } from '../../src/common/services/ReportExporter'; import { SelectedServer } from '../../src/servers/data'; +import VisitsHeader from '../../src/visits/VisitsHeader'; jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), @@ -33,14 +33,14 @@ describe('', () => { />, ).dive(); const stats = wrapper.find(VisitsStats); - const header = wrapper.find(NonOrphanVisitsHeader); + const header = wrapper.find(VisitsHeader); expect(stats).toHaveLength(1); expect(header).toHaveLength(1); expect(stats.prop('cancelGetVisits')).toEqual(cancelGetNonOrphanVisits); expect(stats.prop('visitsInfo')).toEqual(nonOrphanVisits); expect(stats.prop('isOrphanVisits')).not.toBeDefined(); - expect(header.prop('nonOrphanVisits')).toEqual(nonOrphanVisits); + expect(header.prop('visits')).toEqual(nonOrphanVisits.visits); expect(header.prop('goBack')).toEqual(expect.any(Function)); }); }); diff --git a/test/visits/NonOrphanVisitsHeader.test.tsx b/test/visits/NonOrphanVisitsHeader.test.tsx deleted file mode 100644 index ea0f1fdc..00000000 --- a/test/visits/NonOrphanVisitsHeader.test.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { shallow } from 'enzyme'; -import { Mock } from 'ts-mockery'; -import { NonOrphanVisitsHeader } from '../../src/visits/NonOrphanVisitsHeader'; -import VisitsHeader from '../../src/visits/VisitsHeader'; -import { Visit, VisitsInfo } from '../../src/visits/types'; - -describe('', () => { - it('wraps a VisitsHeader with provided data', () => { - const visits: Visit[] = []; - const orphanVisits = Mock.of({ visits }); - const goBack = jest.fn(); - - const wrapper = shallow(); - const visitsHeader = wrapper.find(VisitsHeader); - - expect(visitsHeader).toHaveLength(1); - expect(visitsHeader.prop('visits')).toEqual(visits); - expect(visitsHeader.prop('goBack')).toEqual(goBack); - expect(visitsHeader.prop('title')).toEqual('Non-orphan visits'); - }); -}); diff --git a/test/visits/OrphanVisits.test.tsx b/test/visits/OrphanVisits.test.tsx index 1f8cd22d..b9b284d2 100644 --- a/test/visits/OrphanVisits.test.tsx +++ b/test/visits/OrphanVisits.test.tsx @@ -4,10 +4,10 @@ import { OrphanVisits as createOrphanVisits } from '../../src/visits/OrphanVisit import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub'; import { VisitsInfo } from '../../src/visits/types'; import VisitsStats from '../../src/visits/VisitsStats'; -import { OrphanVisitsHeader } from '../../src/visits/OrphanVisitsHeader'; import { Settings } from '../../src/settings/reducers/settings'; import { ReportExporter } from '../../src/common/services/ReportExporter'; import { SelectedServer } from '../../src/servers/data'; +import VisitsHeader from '../../src/visits/VisitsHeader'; jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), @@ -33,14 +33,14 @@ describe('', () => { />, ).dive(); const stats = wrapper.find(VisitsStats); - const header = wrapper.find(OrphanVisitsHeader); + const header = wrapper.find(VisitsHeader); expect(stats).toHaveLength(1); expect(header).toHaveLength(1); expect(stats.prop('cancelGetVisits')).toEqual(cancelGetOrphanVisits); expect(stats.prop('visitsInfo')).toEqual(orphanVisits); expect(stats.prop('isOrphanVisits')).toEqual(true); - expect(header.prop('orphanVisits')).toEqual(orphanVisits); + expect(header.prop('visits')).toEqual(orphanVisits.visits); expect(header.prop('goBack')).toEqual(expect.any(Function)); }); }); diff --git a/test/visits/OrphanVisitsHeader.test.tsx b/test/visits/OrphanVisitsHeader.test.tsx deleted file mode 100644 index 66eccad0..00000000 --- a/test/visits/OrphanVisitsHeader.test.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { shallow } from 'enzyme'; -import { Mock } from 'ts-mockery'; -import { OrphanVisitsHeader } from '../../src/visits/OrphanVisitsHeader'; -import VisitsHeader from '../../src/visits/VisitsHeader'; -import { Visit, VisitsInfo } from '../../src/visits/types'; - -describe('', () => { - it('wraps a VisitsHeader with provided data', () => { - const visits: Visit[] = []; - const orphanVisits = Mock.of({ visits }); - const goBack = jest.fn(); - - const wrapper = shallow(); - const visitsHeader = wrapper.find(VisitsHeader); - - expect(visitsHeader).toHaveLength(1); - expect(visitsHeader.prop('visits')).toEqual(visits); - expect(visitsHeader.prop('goBack')).toEqual(goBack); - expect(visitsHeader.prop('title')).toEqual('Orphan visits'); - }); -}); diff --git a/test/visits/reducers/domainVisits.test.ts b/test/visits/reducers/domainVisits.test.ts new file mode 100644 index 00000000..94499dfb --- /dev/null +++ b/test/visits/reducers/domainVisits.test.ts @@ -0,0 +1,242 @@ +import { Mock } from 'ts-mockery'; +import { addDays, formatISO, subDays } from 'date-fns'; +import reducer, { + getDomainVisits, + cancelGetDomainVisits, + GET_DOMAIN_VISITS_START, + GET_DOMAIN_VISITS_ERROR, + GET_DOMAIN_VISITS, + GET_DOMAIN_VISITS_LARGE, + GET_DOMAIN_VISITS_CANCEL, + GET_DOMAIN_VISITS_PROGRESS_CHANGED, + GET_DOMAIN_VISITS_FALLBACK_TO_INTERVAL, + DomainVisits, + DEFAULT_DOMAIN, +} from '../../../src/visits/reducers/domainVisits'; +import { CREATE_VISITS } from '../../../src/visits/reducers/visitCreation'; +import { rangeOf } from '../../../src/utils/utils'; +import { Visit } from '../../../src/visits/types'; +import { ShlinkVisits } from '../../../src/api/types'; +import ShlinkApiClient from '../../../src/api/services/ShlinkApiClient'; +import { ShlinkState } from '../../../src/container/types'; +import { formatIsoDate } from '../../../src/utils/helpers/date'; +import { DateInterval } from '../../../src/utils/dates/types'; +import { ShortUrl } from '../../../src/short-urls/data'; + +describe('domainVisitsReducer', () => { + const now = new Date(); + const visitsMocks = rangeOf(2, () => Mock.all()); + + describe('reducer', () => { + const buildState = (data: Partial) => Mock.of(data); + + it('returns loading on GET_DOMAIN_VISITS_START', () => { + const state = reducer(buildState({ loading: false }), { type: GET_DOMAIN_VISITS_START } as any); + const { loading } = state; + + expect(loading).toEqual(true); + }); + + it('returns loadingLarge on GET_DOMAIN_VISITS_LARGE', () => { + const state = reducer(buildState({ loadingLarge: false }), { type: GET_DOMAIN_VISITS_LARGE } as any); + const { loadingLarge } = state; + + expect(loadingLarge).toEqual(true); + }); + + it('returns cancelLoad on GET_DOMAIN_VISITS_CANCEL', () => { + const state = reducer(buildState({ cancelLoad: false }), { type: GET_DOMAIN_VISITS_CANCEL } as any); + const { cancelLoad } = state; + + expect(cancelLoad).toEqual(true); + }); + + it('stops loading and returns error on GET_DOMAIN_VISITS_ERROR', () => { + const state = reducer(buildState({ loading: true, error: false }), { type: GET_DOMAIN_VISITS_ERROR } as any); + const { loading, error } = state; + + expect(loading).toEqual(false); + expect(error).toEqual(true); + }); + + it('return visits on GET_DOMAIN_VISITS', () => { + const actionVisits = [{}, {}]; + const state = reducer( + buildState({ loading: true, error: false }), + { type: GET_DOMAIN_VISITS, visits: actionVisits } as any, + ); + const { loading, error, visits } = state; + + expect(loading).toEqual(false); + expect(error).toEqual(false); + expect(visits).toEqual(actionVisits); + }); + + it.each([ + [{ domain: 'foo.com' }, 'foo.com', visitsMocks.length + 1], + [{ domain: 'bar.com' }, 'foo.com', visitsMocks.length], + [Mock.of({ domain: 'foo.com' }), 'foo.com', visitsMocks.length + 1], + [Mock.of({ domain: DEFAULT_DOMAIN }), null, visitsMocks.length + 1], + [ + Mock.of({ + domain: 'foo.com', + query: { endDate: formatIsoDate(subDays(now, 1)) ?? undefined }, + }), + 'foo.com', + visitsMocks.length, + ], + [ + Mock.of({ + domain: 'foo.com', + query: { startDate: formatIsoDate(addDays(now, 1)) ?? undefined }, + }), + 'foo.com', + visitsMocks.length, + ], + [ + Mock.of({ + domain: 'foo.com', + query: { + startDate: formatIsoDate(subDays(now, 5)) ?? undefined, + endDate: formatIsoDate(subDays(now, 2)) ?? undefined, + }, + }), + 'foo.com', + visitsMocks.length, + ], + [ + Mock.of({ + domain: 'foo.com', + query: { + startDate: formatIsoDate(subDays(now, 5)) ?? undefined, + endDate: formatIsoDate(addDays(now, 3)) ?? undefined, + }, + }), + 'foo.com', + visitsMocks.length + 1, + ], + [ + Mock.of({ + domain: 'bar.com', + query: { + startDate: formatIsoDate(subDays(now, 5)) ?? undefined, + endDate: formatIsoDate(addDays(now, 3)) ?? undefined, + }, + }), + 'foo.com', + visitsMocks.length, + ], + ])('prepends new visits on CREATE_VISIT', (state, shortUrlDomain, expectedVisits) => { + const shortUrl = Mock.of({ domain: shortUrlDomain }); + const prevState = buildState({ + ...state, + visits: visitsMocks, + }); + + const { visits } = reducer(prevState, { + type: CREATE_VISITS, + createdVisits: [{ shortUrl, visit: { date: formatIsoDate(now) ?? undefined } }], + } as any); + + expect(visits).toHaveLength(expectedVisits); + }); + + it('returns new progress on GET_DOMAIN_VISITS_PROGRESS_CHANGED', () => { + const state = reducer(undefined, { type: GET_DOMAIN_VISITS_PROGRESS_CHANGED, progress: 85 } as any); + + expect(state).toEqual(expect.objectContaining({ progress: 85 })); + }); + + it('returns fallbackInterval on GET_DOMAIN_VISITS_FALLBACK_TO_INTERVAL', () => { + const fallbackInterval: DateInterval = 'last30Days'; + const state = reducer(undefined, { type: GET_DOMAIN_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval } as any); + + expect(state).toEqual(expect.objectContaining({ fallbackInterval })); + }); + }); + + describe('getDomainVisits', () => { + type GetVisitsReturn = Promise | ((shortCode: string, query: any) => Promise); + + const buildApiClientMock = (returned: GetVisitsReturn) => Mock.of({ + getDomainVisits: jest.fn(typeof returned === 'function' ? returned : async () => returned), + }); + const dispatchMock = jest.fn(); + const getState = () => Mock.of({ + domainVisits: { cancelLoad: false }, + }); + const domain = 'foo.com'; + + beforeEach(jest.clearAllMocks); + + it('dispatches start and error when promise is rejected', async () => { + const shlinkApiClient = buildApiClientMock(Promise.reject(new Error())); + + await getDomainVisits(() => shlinkApiClient)('foo.com')(dispatchMock, getState); + + expect(dispatchMock).toHaveBeenCalledTimes(2); + expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_DOMAIN_VISITS_START }); + expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_DOMAIN_VISITS_ERROR }); + expect(shlinkApiClient.getDomainVisits).toHaveBeenCalledTimes(1); + }); + + it.each([ + [undefined], + [{}], + ])('dispatches start and success when promise is resolved', async (query) => { + const visits = visitsMocks; + const shlinkApiClient = buildApiClientMock(Promise.resolve({ + data: visitsMocks, + pagination: { + currentPage: 1, + pagesCount: 1, + totalItems: 1, + }, + })); + + await getDomainVisits(() => shlinkApiClient)(domain, query)(dispatchMock, getState); + + expect(dispatchMock).toHaveBeenCalledTimes(2); + expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_DOMAIN_VISITS_START }); + expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_DOMAIN_VISITS, visits, domain, query: query ?? {} }); + expect(shlinkApiClient.getDomainVisits).toHaveBeenCalledTimes(1); + }); + + it.each([ + [ + [Mock.of({ date: formatISO(subDays(new Date(), 20)) })], + { type: GET_DOMAIN_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval: 'last30Days' }, + ], + [ + [Mock.of({ date: formatISO(subDays(new Date(), 100)) })], + { type: GET_DOMAIN_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval: 'last180Days' }, + ], + [[], expect.objectContaining({ type: GET_DOMAIN_VISITS })], + ])('dispatches fallback interval when the list of visits is empty', async (lastVisits, expectedSecondDispatch) => { + const buildVisitsResult = (data: Visit[] = []): ShlinkVisits => ({ + data, + pagination: { + currentPage: 1, + pagesCount: 1, + totalItems: 1, + }, + }); + const getShlinkDomainVisits = jest.fn() + .mockResolvedValueOnce(buildVisitsResult()) + .mockResolvedValueOnce(buildVisitsResult(lastVisits)); + const ShlinkApiClient = Mock.of({ getDomainVisits: getShlinkDomainVisits }); + + await getDomainVisits(() => ShlinkApiClient)(domain, {}, true)(dispatchMock, getState); + + expect(dispatchMock).toHaveBeenCalledTimes(2); + expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_DOMAIN_VISITS_START }); + expect(dispatchMock).toHaveBeenNthCalledWith(2, expectedSecondDispatch); + expect(getShlinkDomainVisits).toHaveBeenCalledTimes(2); + }); + }); + + describe('cancelGetDomainVisits', () => { + it('just returns the action with proper type', () => + expect(cancelGetDomainVisits()).toEqual({ type: GET_DOMAIN_VISITS_CANCEL })); + }); +});