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.
This commit is contained in:
Maxim Beder
2021-09-10 13:05:22 +02:00
committed by Maxim Beder
parent 451495ce6a
commit 929c859046
11 changed files with 694 additions and 23 deletions

417
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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() {
<Comment comment={comment} />
</div>
))}
{hasMorePages && (
<div className="list-group-item list-group-item-action">
<Button
onClick={handleLoadMoreComments}
variant="link"
block="true"
>
{intl.formatMessage(messages.loadMoreComments)}
</Button>
</div>
)}
</div>
</div>
<ResponseEditor postId={postId} />
@@ -60,6 +81,8 @@ function CommentsView() {
);
}
CommentsView.propTypes = {};
CommentsView.propTypes = {
intl: intlShape.isRequired,
};
export default CommentsView;
export default injectIntl(CommentsView);

View File

@@ -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(
<IntlProvider locale="en">
<AppProvider store={store}>
<MemoryRouter initialEntries={['comments/1']}>
<Route path="comments/:postId">
<CommentsView />
</Route>
</MemoryRouter>
</AppProvider>
</IntlProvider>,
);
}
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();
});
});

View File

@@ -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);

View File

@@ -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;

View File

@@ -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];
},
},

View File

@@ -80,6 +80,7 @@ export function fetchThreadComments(threadId, { page = 1 } = {}) {
dispatch(fetchCommentsSuccess({
...normaliseComments(camelCaseObject(data)),
page,
threadId,
}));
} catch (error) {
if (getHttpErrorStatus(error) === 403) {

View File

@@ -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,

View File

@@ -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(),
})),
});

View File

@@ -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,
});
}