From 929c8590468d9e940a7d6a4460dddfa89ee97eb8 Mon Sep 17 00:00:00 2001 From: Maxim Beder Date: Fri, 10 Sep 2021 13:05:22 +0200 Subject: [PATCH] feat: Add pagination to posts' comments This change adds pagination to posts' comments. When viewing a post, if there are more than one page of comments, initially users will only see the first page of the comments, and can load more comments by clicking "load more comments" button. This change only affects comments to posts. For comments that are responses to other comments the pagination has not been implemented. --- package-lock.json | 417 ++++++++++++++++++ package.json | 2 + src/discussions/comments/CommentsView.jsx | 37 +- .../comments/CommentsView.test.jsx | 199 +++++++++ src/discussions/comments/data/redux.test.js | 9 +- src/discussions/comments/data/selectors.js | 8 + src/discussions/comments/data/slices.js | 19 +- src/discussions/comments/data/thunks.js | 1 + src/discussions/comments/messages.js | 5 + src/setupTest.js | 17 + src/store.js | 3 +- 11 files changed, 694 insertions(+), 23 deletions(-) create mode 100644 src/discussions/comments/CommentsView.test.jsx diff --git a/package-lock.json b/package-lock.json index c6e22f95..7c8e19cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5170,6 +5170,243 @@ } } }, + "@testing-library/dom": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.5.0.tgz", + "integrity": "sha512-O0fmHFaPlqaYCpa/cBL0cvroMridb9vZsMLacgIqrlxj+fd+bGF8UfAgwsLCHRF84KLBafWlm9CuOvxeNTlodw==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^4.2.0", + "aria-query": "^4.2.2", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.6", + "lz-string": "^1.4.4", + "pretty-format": "^27.0.2" + }, + "dependencies": { + "@babel/runtime": { + "version": "7.15.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.15.4.tgz", + "integrity": "sha512-99catp6bHCaxr4sJ/DbTGgHS4+Rs2RVd2g7iOap6SLGPDknRK9ztKNsE/Fg6QhSeh1FGE5f6gHGQmvvn3I3xhw==", + "dev": true, + "requires": { + "regenerator-runtime": "^0.13.4" + } + }, + "@jest/types": { + "version": "27.1.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.1.1.tgz", + "integrity": "sha512-yqJPDDseb0mXgKqmNqypCsb85C22K1aY5+LUxh7syIM9n/b0AsaltxNy+o6tt29VcfGDpYEve175bm3uOhcehA==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "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": "4.2.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-4.2.2.tgz", + "integrity": "sha512-o/HelwhuKpTj/frsOsbNLNgnNGVIFsVP/SW2BSF14gVl7kAfMOJ6/8wUAUvG1R1NHKrfG+2sHZTu0yauT1qBrA==", + "dev": true, + "requires": { + "@babel/runtime": "^7.10.2", + "@babel/runtime-corejs3": "^7.10.2" + } + }, + "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" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "pretty-format": { + "version": "27.2.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.2.0.tgz", + "integrity": "sha512-KyJdmgBkMscLqo8A7K77omgLx5PWPiXJswtTtFV7XgVZv2+qPk6UivpXXO+5k6ZEbWIbLoKdx1pZ6ldINzbwTA==", + "dev": true, + "requires": { + "@jest/types": "^27.1.1", + "ansi-regex": "^5.0.0", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "dependencies": { + "ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true + } + } + }, + "react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true + } + } + }, + "@testing-library/jest-dom": { + "version": "5.14.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.14.1.tgz", + "integrity": "sha512-dfB7HVIgTNCxH22M1+KU6viG5of2ldoA5ly8Ar8xkezKHKXjRvznCdbMbqjYGgO2xjRbwnR+rR8MLUIqF3kKbQ==", + "dev": true, + "requires": { + "@babel/runtime": "^7.9.2", + "@types/testing-library__jest-dom": "^5.9.1", + "aria-query": "^4.2.2", + "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": { + "@babel/runtime": { + "version": "7.15.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.15.4.tgz", + "integrity": "sha512-99catp6bHCaxr4sJ/DbTGgHS4+Rs2RVd2g7iOap6SLGPDknRK9ztKNsE/Fg6QhSeh1FGE5f6gHGQmvvn3I3xhw==", + "dev": true, + "requires": { + "regenerator-runtime": "^0.13.4" + } + }, + "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": "4.2.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-4.2.2.tgz", + "integrity": "sha512-o/HelwhuKpTj/frsOsbNLNgnNGVIFsVP/SW2BSF14gVl7kAfMOJ6/8wUAUvG1R1NHKrfG+2sHZTu0yauT1qBrA==", + "dev": true, + "requires": { + "@babel/runtime": "^7.10.2", + "@babel/runtime-corejs3": "^7.10.2" + } + }, + "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" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true + }, + "redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "requires": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + } + }, + "strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "requires": { + "min-indent": "^1.0.0" + } + } + } + }, + "@testing-library/react": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-12.1.0.tgz", + "integrity": "sha512-Ge3Ht3qXE82Yv9lyPpQ7ZWgzo/HgOcHu569Y4ZGWcZME38iOFiOg87qnu6hTEa8jTJVL7zYovnvD3GE2nsNIoQ==", + "dev": true, + "requires": { + "@babel/runtime": "^7.12.5", + "@testing-library/dom": "^8.0.0" + }, + "dependencies": { + "@babel/runtime": { + "version": "7.15.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.15.4.tgz", + "integrity": "sha512-99catp6bHCaxr4sJ/DbTGgHS4+Rs2RVd2g7iOap6SLGPDknRK9ztKNsE/Fg6QhSeh1FGE5f6gHGQmvvn3I3xhw==", + "dev": true, + "requires": { + "regenerator-runtime": "^0.13.4" + } + } + } + }, "@tinymce/tinymce-react": { "version": "3.12.6", "resolved": "https://registry.npmjs.org/@tinymce/tinymce-react/-/tinymce-react-3.12.6.tgz", @@ -5191,6 +5428,12 @@ "integrity": "sha512-Z6DoceYb/1xSg5+e+ZlPZ9v0N16ZvZ+wYMraFue4HYrE4ttONKtsvruIRf6t9TBR0YvSOfi1hUU0fJfBLCDYow==", "dev": 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.14", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.14.tgz", @@ -5344,6 +5587,124 @@ "@types/istanbul-lib-report": "*" } }, + "@types/jest": { + "version": "27.0.1", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-27.0.1.tgz", + "integrity": "sha512-HTLpVXHrY69556ozYkcq47TtQJXpcWAWfkoqz+ZGz2JnmZhzlRjprCIyFnetSy8gpDWwTTGBcRVv1J1I1vBrHw==", + "dev": true, + "requires": { + "jest-diff": "^27.0.0", + "pretty-format": "^27.0.0" + }, + "dependencies": { + "@jest/types": { + "version": "27.1.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.1.1.tgz", + "integrity": "sha512-yqJPDDseb0mXgKqmNqypCsb85C22K1aY5+LUxh7syIM9n/b0AsaltxNy+o6tt29VcfGDpYEve175bm3uOhcehA==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "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" + } + }, + "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" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "diff-sequences": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.0.6.tgz", + "integrity": "sha512-ag6wfpBFyNXZ0p8pcuIDS//D8H062ZQJ3fzYxjpmeKjnz8W4pekL3AI8VohmyZmsWW2PWaHgjsmqR6L13101VQ==", + "dev": true + }, + "jest-diff": { + "version": "27.2.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-27.2.0.tgz", + "integrity": "sha512-QSO9WC6btFYWtRJ3Hac0sRrkspf7B01mGrrQEiCW6TobtViJ9RWL0EmOs/WnBsZDsI/Y2IoSHZA2x6offu0sYw==", + "dev": true, + "requires": { + "chalk": "^4.0.0", + "diff-sequences": "^27.0.6", + "jest-get-type": "^27.0.6", + "pretty-format": "^27.2.0" + } + }, + "jest-get-type": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.0.6.tgz", + "integrity": "sha512-XTkK5exIeUbbveehcSR8w0bhH+c0yloW/Wpl+9vZrjzztCPWrxhHwkIFpZzCt71oRBsgxmuUfxEqOYoZI2macg==", + "dev": true + }, + "pretty-format": { + "version": "27.2.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.2.0.tgz", + "integrity": "sha512-KyJdmgBkMscLqo8A7K77omgLx5PWPiXJswtTtFV7XgVZv2+qPk6UivpXXO+5k6ZEbWIbLoKdx1pZ6ldINzbwTA==", + "dev": true, + "requires": { + "@jest/types": "^27.1.1", + "ansi-regex": "^5.0.0", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "dependencies": { + "ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true + } + } + }, + "react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true + } + } + }, "@types/json-schema": { "version": "7.0.9", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.9.tgz", @@ -5464,6 +5825,15 @@ "integrity": "sha512-ipixuVrh2OdNmauvtT51o3d8z12p6LtFW9in7U79der/kwejjdNchQC5UMn5u/KxNoM7VHHOs/l8KS8uHxhODQ==", "dev": true }, + "@types/testing-library__jest-dom": { + "version": "5.14.1", + "resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.1.tgz", + "integrity": "sha512-Gk9vaXfbzc5zCXI9eYE9BI5BNHEp4D3FWjgqBE/ePGYElLAP+KvxBcsdkwfIVvezs605oiyd/VrpiHe3Oeg+Aw==", + "dev": true, + "requires": { + "@types/jest": "*" + } + }, "@types/uglify-js": { "version": "3.13.1", "resolved": "https://registry.npmjs.org/@types/uglify-js/-/uglify-js-3.13.1.tgz", @@ -8104,6 +8474,29 @@ "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", "integrity": "sha1-iNf/fsDfuG9xPch7u0LQRNPmxBs=" }, + "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": { + "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-color-names": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/css-color-names/-/css-color-names-1.0.1.tgz", @@ -8187,6 +8580,12 @@ "integrity": "sha512-ACUm3L0/jiZTqfzRM3Hi9Q8eZqd6IK37mMWPLz9PJxkLWllYeRf+EHUSHYEtFop2Eqytaq1FizFVh7XfBnXCDQ==", "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 + }, "cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -8836,6 +9235,12 @@ "esutils": "^2.0.2" } }, + "dom-accessibility-api": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.7.tgz", + "integrity": "sha512-ml3lJIq9YjUfM9TUnEPvEYWFSwivwIGBPKpewX7tii7fwCazA8yCioGdqQcNsItPpfFvSJ3VIdMQPj60LJhcQA==", + "dev": true + }, "dom-converter": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz", @@ -16149,6 +16554,12 @@ "yallist": "^4.0.0" } }, + "lz-string": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz", + "integrity": "sha1-wNjq82BZ9wV5bh40SBHPTEmNOiY=", + "dev": true + }, "mailto-link": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/mailto-link/-/mailto-link-1.0.0.tgz", @@ -16479,6 +16890,12 @@ "dev": true, "optional": true }, + "min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true + }, "mini-create-react-context": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/mini-create-react-context/-/mini-create-react-context-0.4.1.tgz", diff --git a/package.json b/package.json index 2582bc53..d9ef73cb 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,8 @@ }, "devDependencies": { "@edx/frontend-build": "8.0.4", + "@testing-library/jest-dom": "5.14.1", + "@testing-library/react": "12.1.0", "axios-mock-adapter": "1.20.0", "codecov": "3.8.3", "es-check": "5.2.3", diff --git a/src/discussions/comments/CommentsView.jsx b/src/discussions/comments/CommentsView.jsx index 04900745..b03a54dd 100644 --- a/src/discussions/comments/CommentsView.jsx +++ b/src/discussions/comments/CommentsView.jsx @@ -4,25 +4,35 @@ import { useDispatch, useSelector } from 'react-redux'; import { useParams } from 'react-router'; import { ensureConfig, getConfig } from '@edx/frontend-platform'; -import { Spinner } from '@edx/paragon'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { Button, Spinner } from '@edx/paragon'; import { Post } from '../posts'; import { selectThread } from '../posts/data/selectors'; import { markThreadAsRead } from '../posts/data/thunks'; -import { selectThreadComments } from './data/selectors'; +import { + selectThreadComments, + selectThreadCurrentPage, + selectThreadHasMorePages, +} from './data/selectors'; import { fetchThreadComments } from './data/thunks'; import { Comment, ResponseEditor } from './comment'; +import messages from './messages'; ensureConfig(['POST_MARK_AS_READ_DELAY'], 'Comment thread view'); -function CommentsView() { +function CommentsView({ intl }) { const { postId } = useParams(); const dispatch = useDispatch(); const thread = useSelector(selectThread(postId)); const comments = useSelector(selectThreadComments(postId)); - + const hasMorePages = useSelector(selectThreadHasMorePages(postId)); + const currentPage = useSelector(selectThreadCurrentPage(postId)); + const handleLoadMoreComments = () => dispatch(fetchThreadComments(postId, { page: currentPage + 1 })); useEffect(() => { - dispatch(fetchThreadComments(postId)); + if (!currentPage) { + dispatch(fetchThreadComments(postId, { page: 1 })); + } const markReadTimer = setTimeout(() => { if (thread && !thread.read) { dispatch(markThreadAsRead(postId)); @@ -53,6 +63,17 @@ function CommentsView() { ))} + {hasMorePages && ( +
+ +
+ )} @@ -60,6 +81,8 @@ function CommentsView() { ); } -CommentsView.propTypes = {}; +CommentsView.propTypes = { + intl: intlShape.isRequired, +}; -export default CommentsView; +export default injectIntl(CommentsView); diff --git a/src/discussions/comments/CommentsView.test.jsx b/src/discussions/comments/CommentsView.test.jsx new file mode 100644 index 00000000..c99f96be --- /dev/null +++ b/src/discussions/comments/CommentsView.test.jsx @@ -0,0 +1,199 @@ +/** + * @jest-environment jsdom + */ +import React from 'react'; + +import { fireEvent, render, screen } from '@testing-library/react'; +import MockAdapter from 'axios-mock-adapter'; +import { IntlProvider } from 'react-intl'; +import { MemoryRouter, Route } from 'react-router'; + +import { initializeMockApp } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { AppProvider } from '@edx/frontend-platform/react'; + +import { initializeStore } from '../../store'; +import { commentsApiUrl } from './data/api'; +import CommentsView from './CommentsView'; + +const postId = '1'; +let store; +let axiosMock; + +const mockCommentsPaged = [ + [ + { + threadId: postId, + id: '1', + renderedBody: 'test comment 1', + voteCount: 0, + author: 'testauthor', + users: { + testauthor: { + profile: { + image: { + image_url_small: '', + }, + }, + }, + }, + editableFields: [], + }, + ], + [ + { + threadId: postId, + id: '2', + renderedBody: 'test comment 2', + voteCount: 0, + author: 'testauthor', + users: { + testauthor: { + profile: { + image: { + image_url_small: '', + }, + }, + }, + }, + editableFields: [], + }, + ], +]; + +function mockAxiosReturnPagedComments() { + const paramsTemplate = { + thread_id: postId, + page: undefined, + page_size: undefined, + requested_fields: 'profile_image', + }; + + const numPages = mockCommentsPaged.length; + for (let page = 1; page <= numPages; page++) { + const comments = mockCommentsPaged[page - 1]; + axiosMock + .onGet(commentsApiUrl, { params: { ...paramsTemplate, page } }) + .reply(200, { + results: comments, + pagination: { + page, + numPages, + next: page < numPages ? page + 1 : null, + }, + }); + } +} + +function renderComponent() { + render( + + + + + + + + + , + ); +} + +describe('CommentsView', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + adminsitrator: true, + roles: [], + }, + }); + + store = initializeStore({ + threads: { + threadsById: { + [postId]: { + id: postId.toString(), + author: 'testauthor', + title: 'test thread', + voteCount: 0, + type: 'discussion', + pinned: false, + abuseFlagged: false, + commentCount: mockCommentsPaged.reduce((acc, cur) => acc + cur.length, 0), + courseId: 'course_id', + following: false, + rawBody: '', + read: true, + topicId: '', + updatedAt: '', + editableFields: [], + }, + }, + avatars: { + testauthor: { + profile: { + image: '', + }, + }, + }, + }, + }); + + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + }); + + // TODO: use test id to prevent breaking from text changes + const findLoadMoreCommentsButton = () => screen.findByRole('button', { name: /load more comments/i }); + + it('initially loads only the first page', async () => { + const firstPageComment = mockCommentsPaged[0][0]; + const secondPageComment = mockCommentsPaged[1][0]; + mockAxiosReturnPagedComments(); + renderComponent(); + + await screen.findByText(firstPageComment.renderedBody); + expect(screen.queryByText(secondPageComment.renderedBody)).not.toBeInTheDocument(); + }); + + it('pressing load more button will load next page of comments', async () => { + const secondPageComment = mockCommentsPaged[1][0]; + mockAxiosReturnPagedComments(); + renderComponent(); + + const loadMoreButton = await findLoadMoreCommentsButton(); + fireEvent.click(loadMoreButton); + + await screen.findByText(secondPageComment.renderedBody); + }); + + it('newly loaded comments are appended to the old ones', async () => { + const firstPageComment = mockCommentsPaged[0][0]; + const secondPageComment = mockCommentsPaged[1][0]; + mockAxiosReturnPagedComments(); + renderComponent(); + + const loadMoreButton = await findLoadMoreCommentsButton(); + fireEvent.click(loadMoreButton); + + await screen.findByText(secondPageComment.renderedBody); + // check that comments from the first pages are also displayed + expect(screen.queryByText(firstPageComment.renderedBody)).toBeInTheDocument(); + }); + + it('load more button is hidden when no more comments pages to load', async () => { + const totalePages = mockCommentsPaged.length; + const lastPageComment = mockCommentsPaged[totalePages - 1][0]; + mockAxiosReturnPagedComments(); + renderComponent(); + + const loadMoreButton = await findLoadMoreCommentsButton(); + for (let page = 1; page < totalePages; page++) { + fireEvent.click(loadMoreButton); + } + + await screen.findByText(lastPageComment.renderedBody); + await expect(findLoadMoreCommentsButton()).rejects.toThrow(); + }); +}); diff --git a/src/discussions/comments/data/redux.test.js b/src/discussions/comments/data/redux.test.js index 09a090f0..51fa8c97 100644 --- a/src/discussions/comments/data/redux.test.js +++ b/src/discussions/comments/data/redux.test.js @@ -45,8 +45,8 @@ describe('Comments/Responses data layer tests', () => { expect(store.getState().comments.commentsInThreads) .toEqual({ 'test-thread': ['comment-1', 'comment-2', 'comment-3'] }); - expect(store.getState().comments.pages) - .toEqual([['comment-1', 'comment-2', 'comment-3']]); + expect(store.getState().comments.pagination) + .toEqual({ 'test-thread': { currentPage: 1, totalPages: 1, hasMorePages: false } }); expect(Object.keys(store.getState().comments.commentsById)) .toEqual(['comment-1', 'comment-2', 'comment-3']); expect(store.getState().comments.commentsById['comment-1']) @@ -137,8 +137,6 @@ describe('Comments/Responses data layer tests', () => { expect(store.getState().comments.commentsById) .toHaveProperty(commentId); - expect(store.getState().comments.pages[0]) - .toContain(commentId); axiosMock.onDelete(`${commentsApiUrl}${commentId}/`) .reply(201); @@ -148,9 +146,6 @@ describe('Comments/Responses data layer tests', () => { expect(store.getState().comments.commentsById) .not .toHaveProperty(commentId); - expect(store.getState().comments.pages[0]) - .not - .toContain(commentId); expect(store.getState().comments.commentsInThreads[threadId]) .not .toContain(commentId); diff --git a/src/discussions/comments/data/selectors.js b/src/discussions/comments/data/selectors.js index a0f77f42..e4b8bd92 100644 --- a/src/discussions/comments/data/selectors.js +++ b/src/discussions/comments/data/selectors.js @@ -20,4 +20,12 @@ export const selectCommentResponses = commentId => createSelector( mapIdToComment, ); +export const selectThreadHasMorePages = threadId => ( + store => store.comments.pagination[threadId]?.hasMorePages || false +); + +export const selectThreadCurrentPage = threadId => ( + store => store.comments.pagination[threadId]?.currentPage || null +); + export const commentsStatus = state => state.comments.status; diff --git a/src/discussions/comments/data/slices.js b/src/discussions/comments/data/slices.js index 5f78832c..7b42478c 100644 --- a/src/discussions/comments/data/slices.js +++ b/src/discussions/comments/data/slices.js @@ -16,13 +16,12 @@ const commentsSlice = createSlice({ commentsById: { // Map comment ids to comments. }, - pages: [], // Stores the comment being posted in case it needs to be reposted due to network failure. // TODO: save in localstorage so user can continue editing? commentDraft: null, - totalPages: null, - totalThreads: null, postStatus: RequestStatus.SUCCESSFUL, + pagination: { + }, }, reducers: { fetchCommentsRequest: (state) => { @@ -30,12 +29,17 @@ const commentsSlice = createSlice({ }, fetchCommentsSuccess: (state, { payload }) => { state.status = RequestStatus.SUCCESSFUL; - state.pages[payload.page - 1] = payload.ids; - state.commentsInThreads = { ...state.commentsInThreads, ...payload.commentsInThreads }; + state.commentsInThreads[payload.threadId] = [ + ...(state.commentsInThreads[payload.threadId] || []), + ...(payload.commentsInThreads[payload.threadId] || []), + ]; state.commentsInComments = { ...state.commentsInComments, ...payload.commentsInComments }; state.commentsById = { ...state.commentsById, ...payload.commentsById }; - state.totalPages = payload.pagination.numPages; - state.totalThreads = payload.pagination.count; + state.pagination[payload.threadId] = { + currentPage: payload.page, + totalPages: payload.pagination.numPages, + hasMorePages: Boolean(payload.pagination.next), + }; }, fetchCommentsFailed: (state) => { state.status = RequestStatus.FAILED; @@ -109,7 +113,6 @@ const commentsSlice = createSlice({ if (parentId) { state.commentsInComments[parentId] = state.commentsInComments[parentId].filter(item => item !== commentId); } - state.pages = state.pages.map(page => page?.filter(item => item !== commentId)); delete state.commentsById[commentId]; }, }, diff --git a/src/discussions/comments/data/thunks.js b/src/discussions/comments/data/thunks.js index 1a51a4c5..5eb8ebd2 100644 --- a/src/discussions/comments/data/thunks.js +++ b/src/discussions/comments/data/thunks.js @@ -80,6 +80,7 @@ export function fetchThreadComments(threadId, { page = 1 } = {}) { dispatch(fetchCommentsSuccess({ ...normaliseComments(camelCaseObject(data)), page, + threadId, })); } catch (error) { if (getHttpErrorStatus(error) === 403) { diff --git a/src/discussions/comments/messages.js b/src/discussions/comments/messages.js index ad313dcf..442fbe41 100644 --- a/src/discussions/comments/messages.js +++ b/src/discussions/comments/messages.js @@ -11,6 +11,11 @@ const messages = defineMessages({ defaultMessage: 'Add a comment', description: 'Button to add a comment to a response', }, + loadMoreComments: { + id: 'discussions.comments.comment.loadMoreComments', + defaultMessage: 'Load more comments', + description: 'Button to load more comments of forum posts', + }, postVisibility: { id: 'discussions.comments.comment.visibility', defaultMessage: `This post is visible to {group, select, diff --git a/src/setupTest.js b/src/setupTest.js index b012711b..0c487ad9 100755 --- a/src/setupTest.js +++ b/src/setupTest.js @@ -1 +1,18 @@ import 'babel-polyfill'; +import '@testing-library/jest-dom/extend-expect'; + +// mock methods which are not implemented in JSDOM: +// https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation(query => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), // deprecated + removeListener: jest.fn(), // deprecated + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), +}); diff --git a/src/store.js b/src/store.js index d22ac18c..8d5eff2d 100644 --- a/src/store.js +++ b/src/store.js @@ -4,13 +4,14 @@ import { commentsReducer } from './discussions/comments/data'; import { threadsReducer } from './discussions/posts/data'; import { topicsReducer } from './discussions/topics/data'; -export function initializeStore() { +export function initializeStore(preloadedState = undefined) { return configureStore({ reducer: { topics: topicsReducer, threads: threadsReducer, comments: commentsReducer, }, + preloadedState, }); }