diff --git a/README.md b/README.md index 806afcbd..ace280c6 100755 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ [![Build Status](https://api.travis-ci.com/edx/frontend-app-gradebook.svg?branch=master)](https://travis-ci.com/edx/frontend-app-gradebook) +[![Codecov](https://img.shields.io/codecov/c/gh/openedx/frontend-app-gradebook)](https://app.codecov.io/gh/openedx/frontend-app-gradebook) [![npm_version](https://img.shields.io/npm/v/@edx/frontend-app-gradebook.svg)](@edx/frontend-app-gradebook) [![npm_downloads](https://img.shields.io/npm/dt/@edx/frontend-app-gradebook.svg)](@edx/frontend-app-gradebook) [![license](https://img.shields.io/npm/l/@edx/frontend-app-gradebook.svg)](@edx/frontend-app-gradebook) diff --git a/package-lock.json b/package-lock.json index b637ee0c..089de846 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@edx/frontend-app-gradebook", - "version": "1.6.0", + "version": "1.6.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@edx/frontend-app-gradebook", - "version": "1.6.0", + "version": "1.6.1", "license": "AGPL-3.0", "dependencies": { "@edx/brand": "npm:@edx/brand-edx.org@^1.3.2", @@ -49,6 +49,7 @@ "devDependencies": { "@edx/browserslist-config": "^1.1.1", "@edx/frontend-build": "^12.4.15", + "@testing-library/react": "^12.1.0", "axios": "0.21.2", "axios-mock-adapter": "^1.17.0", "enzyme-adapter-react-16": "^1.14.0", @@ -6657,6 +6658,188 @@ "url": "https://github.com/sponsors/gregberge" } }, + "node_modules/@testing-library/dom": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.0.tgz", + "integrity": "sha512-d9ULIT+a4EXLX3UU8FBjauG9NnsZHkHztXoIcTsOKoOw030fyjheN9svkTULjJxtYag9DZz5Jz5qkWZDPxTFwA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "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.1.3", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", + "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", + "dev": true, + "dependencies": { + "deep-equal": "^2.0.5" + } + }, + "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/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 + }, + "node_modules/@testing-library/dom/node_modules/deep-equal": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.0.tgz", + "integrity": "sha512-RdpzE0Hv4lhowpIUKKMJfeH6C1pXdtT1/it80ubgWqwI3qpuxUBpC1S4hnHg+zjnuOoDkzUtUCEEkG+XG5l3Mw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "es-get-iterator": "^1.1.2", + "get-intrinsic": "^1.1.3", + "is-arguments": "^1.1.1", + "is-array-buffer": "^3.0.1", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "isarray": "^2.0.5", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.4.3", + "side-channel": "^1.0.4", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.1", + "which-typed-array": "^1.1.9" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true + }, + "node_modules/@testing-library/dom/node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@testing-library/dom/node_modules/pretty-format/node_modules/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, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@testing-library/dom/node_modules/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 + }, + "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/react": { + "version": "12.1.5", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-12.1.5.tgz", + "integrity": "sha512-OfTXCJUFgjd/digLUuPxa0+/3ZxsQmE7ub9kcbW/wi96Bh3o/p5vrETcBGfP17NWPGqeYYl5LTRpwyGoMC4ysg==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@testing-library/dom": "^8.0.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": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -6675,6 +6858,12 @@ "node": ">=10.13.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.1.tgz", + "integrity": "sha512-XTIieEY+gvJ39ChLcB4If5zHtPxt3Syj5rgZR+e1ctpmK8NjPf0zFqsz4JpLJT0xla9GFDKjy8Cpu331nrmE1Q==", + "dev": true + }, "node_modules/@types/babel__core": { "version": "7.20.0", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.0.tgz", @@ -6977,6 +7166,15 @@ "csstype": "^3.0.2" } }, + "node_modules/@types/react-dom": { + "version": "17.0.18", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.18.tgz", + "integrity": "sha512-rLVtIfbwyur2iFKykP2w0pl/1unw26b5td16d5xMgp7/yjTHomkyxPYChFoCr/FtEX1lN9wY6lFj1qvKdS5kDw==", + "dev": true, + "dependencies": { + "@types/react": "^17" + } + }, "node_modules/@types/react-redux": { "version": "7.1.25", "resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.25.tgz", @@ -10710,6 +10908,12 @@ "node": ">=6.0.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true + }, "node_modules/dom-converter": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz", @@ -11308,6 +11512,32 @@ "node": ">= 4" } }, + "node_modules/es-get-iterator": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", + "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.3", + "has-symbols": "^1.0.3", + "is-arguments": "^1.1.1", + "is-map": "^2.0.2", + "is-set": "^2.0.2", + "is-string": "^1.0.7", + "isarray": "^2.0.5", + "stop-iteration-iterator": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-get-iterator/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true + }, "node_modules/es-module-lexer": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-0.9.3.tgz", @@ -15156,6 +15386,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-map": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.2.tgz", + "integrity": "sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-negative-zero": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", @@ -15294,6 +15533,15 @@ "node": ">=6" } }, + "node_modules/is-set": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.2.tgz", + "integrity": "sha512-+2cnTEZeY5z/iXGbLhPrOAaK/Mau5k5eXq9j14CpRTftq0pAJu2MwVRSZhyZWBzx3o6X795Lz6Bpb6R0GKf37g==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-shared-array-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz", @@ -15395,6 +15643,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-weakmap": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.1.tgz", + "integrity": "sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-weakref": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", @@ -15406,6 +15663,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-weakset": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.2.tgz", + "integrity": "sha512-t2yVvttHkQktwnNNmBQ98AhENLdPUTDTE21uPqAQ0ARwQfGeQKRVS0NNurH7bTf7RrvcVn1OOge45CnBeHCSmg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-windows": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", @@ -20539,6 +20809,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz", + "integrity": "sha512-0ckx7ZHRPqb0oUm8zNr+90mtf9DQB60H1wMCjBtfi62Kl3a7JbHob6gA2bC+xRvZoOL+1hzUK8jeuEIQE8svEQ==", + "dev": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/mailto-link": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/mailto-link/-/mailto-link-1.0.0.tgz", @@ -28981,6 +29260,18 @@ "node": ">= 0.8" } }, + "node_modules/stop-iteration-iterator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz", + "integrity": "sha512-iCGQj+0l0HOdZ2AEeBADlsRC+vsnDsZsbdSiH1yNSjcfKM7fdpCMfqAL/dwF5BLiw/XhRft/Wax6zQbhq2BcjQ==", + "dev": true, + "dependencies": { + "internal-slot": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/stream-combiner2": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/stream-combiner2/-/stream-combiner2-1.1.1.tgz", @@ -31326,6 +31617,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/which-collection": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.1.tgz", + "integrity": "sha512-W8xeTUwaln8i3K/cY1nGXzdnVZlidBcagyNFtBdD5kxnb4TvGKR7FfSIS3mYpwWS1QUCutfKz8IY8RjftB0+1A==", + "dev": true, + "dependencies": { + "is-map": "^2.0.1", + "is-set": "^2.0.1", + "is-weakmap": "^2.0.1", + "is-weakset": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/which-module": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", @@ -36506,6 +36812,149 @@ "@svgr/plugin-svgo": "^6.2.0" } }, + "@testing-library/dom": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.0.tgz", + "integrity": "sha512-d9ULIT+a4EXLX3UU8FBjauG9NnsZHkHztXoIcTsOKoOw030fyjheN9svkTULjJxtYag9DZz5Jz5qkWZDPxTFwA==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "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.1.3", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", + "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", + "dev": true, + "requires": { + "deep-equal": "^2.0.5" + } + }, + "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 + }, + "deep-equal": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.0.tgz", + "integrity": "sha512-RdpzE0Hv4lhowpIUKKMJfeH6C1pXdtT1/it80ubgWqwI3qpuxUBpC1S4hnHg+zjnuOoDkzUtUCEEkG+XG5l3Mw==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "es-get-iterator": "^1.1.2", + "get-intrinsic": "^1.1.3", + "is-arguments": "^1.1.1", + "is-array-buffer": "^3.0.1", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "isarray": "^2.0.5", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.4.3", + "side-channel": "^1.0.4", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.1", + "which-typed-array": "^1.1.9" + } + }, + "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 + }, + "isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true + }, + "pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "requires": { + "ansi-regex": "^5.0.1", + "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 + }, + "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": "12.1.5", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-12.1.5.tgz", + "integrity": "sha512-OfTXCJUFgjd/digLUuPxa0+/3ZxsQmE7ub9kcbW/wi96Bh3o/p5vrETcBGfP17NWPGqeYYl5LTRpwyGoMC4ysg==", + "dev": true, + "requires": { + "@babel/runtime": "^7.12.5", + "@testing-library/dom": "^8.0.0", + "@types/react-dom": "<18.0.0" + } + }, "@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -36518,6 +36967,12 @@ "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", "dev": true }, + "@types/aria-query": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.1.tgz", + "integrity": "sha512-XTIieEY+gvJ39ChLcB4If5zHtPxt3Syj5rgZR+e1ctpmK8NjPf0zFqsz4JpLJT0xla9GFDKjy8Cpu331nrmE1Q==", + "dev": true + }, "@types/babel__core": { "version": "7.20.0", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.0.tgz", @@ -36820,6 +37275,15 @@ "csstype": "^3.0.2" } }, + "@types/react-dom": { + "version": "17.0.18", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.18.tgz", + "integrity": "sha512-rLVtIfbwyur2iFKykP2w0pl/1unw26b5td16d5xMgp7/yjTHomkyxPYChFoCr/FtEX1lN9wY6lFj1qvKdS5kDw==", + "dev": true, + "requires": { + "@types/react": "^17" + } + }, "@types/react-redux": { "version": "7.1.25", "resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.25.tgz", @@ -39738,6 +40202,12 @@ "esutils": "^2.0.2" } }, + "dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true + }, "dom-converter": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz", @@ -40201,6 +40671,31 @@ "glob": "^7.1.2" } }, + "es-get-iterator": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", + "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.3", + "has-symbols": "^1.0.3", + "is-arguments": "^1.1.1", + "is-map": "^2.0.2", + "is-set": "^2.0.2", + "is-string": "^1.0.7", + "isarray": "^2.0.5", + "stop-iteration-iterator": "^1.0.0" + }, + "dependencies": { + "isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true + } + } + }, "es-module-lexer": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-0.9.3.tgz", @@ -43053,6 +43548,12 @@ } } }, + "is-map": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.2.tgz", + "integrity": "sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg==", + "dev": true + }, "is-negative-zero": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", @@ -43148,6 +43649,12 @@ "integrity": "sha512-AGOriNp96vNBd3HtU+RzFEc75FfR5ymiYv8E553I71SCeXBiMsVDUtdio1OEFvrPyLIQ9tVR5RxXIFe5PUFjMg==", "dev": true }, + "is-set": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.2.tgz", + "integrity": "sha512-+2cnTEZeY5z/iXGbLhPrOAaK/Mau5k5eXq9j14CpRTftq0pAJu2MwVRSZhyZWBzx3o6X795Lz6Bpb6R0GKf37g==", + "dev": true + }, "is-shared-array-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz", @@ -43219,6 +43726,12 @@ "is-invalid-path": "^0.1.0" } }, + "is-weakmap": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.1.tgz", + "integrity": "sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA==", + "dev": true + }, "is-weakref": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", @@ -43227,6 +43740,16 @@ "call-bind": "^1.0.2" } }, + "is-weakset": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.2.tgz", + "integrity": "sha512-t2yVvttHkQktwnNNmBQ98AhENLdPUTDTE21uPqAQ0ARwQfGeQKRVS0NNurH7bTf7RrvcVn1OOge45CnBeHCSmg==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.1" + } + }, "is-windows": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", @@ -47179,6 +47702,12 @@ "yallist": "^3.0.2" } }, + "lz-string": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz", + "integrity": "sha512-0ckx7ZHRPqb0oUm8zNr+90mtf9DQB60H1wMCjBtfi62Kl3a7JbHob6gA2bC+xRvZoOL+1hzUK8jeuEIQE8svEQ==", + "dev": true + }, "mailto-link": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/mailto-link/-/mailto-link-1.0.0.tgz", @@ -53408,6 +53937,15 @@ "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "dev": true }, + "stop-iteration-iterator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz", + "integrity": "sha512-iCGQj+0l0HOdZ2AEeBADlsRC+vsnDsZsbdSiH1yNSjcfKM7fdpCMfqAL/dwF5BLiw/XhRft/Wax6zQbhq2BcjQ==", + "dev": true, + "requires": { + "internal-slot": "^1.0.4" + } + }, "stream-combiner2": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/stream-combiner2/-/stream-combiner2-1.1.1.tgz", @@ -55126,6 +55664,18 @@ "is-symbol": "^1.0.3" } }, + "which-collection": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.1.tgz", + "integrity": "sha512-W8xeTUwaln8i3K/cY1nGXzdnVZlidBcagyNFtBdD5kxnb4TvGKR7FfSIS3mYpwWS1QUCutfKz8IY8RjftB0+1A==", + "dev": true, + "requires": { + "is-map": "^2.0.1", + "is-set": "^2.0.1", + "is-weakmap": "^2.0.1", + "is-weakset": "^2.0.1" + } + }, "which-module": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", diff --git a/package.json b/package.json index e8410e33..4fc98503 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@edx/frontend-app-gradebook", - "version": "1.6.0", + "version": "1.6.1", "description": "edx editable gradebook-ui to manipulate grade overrides on subsections", "repository": { "type": "git", @@ -68,6 +68,7 @@ "devDependencies": { "@edx/browserslist-config": "^1.1.1", "@edx/frontend-build": "^12.4.15", + "@testing-library/react": "^12.1.0", "axios": "0.21.2", "axios-mock-adapter": "^1.17.0", "enzyme-adapter-react-16": "^1.14.0", diff --git a/src/__snapshots__/App.test.jsx.snap b/src/__snapshots__/App.test.jsx.snap index 5a87d300..8f505dd4 100644 --- a/src/__snapshots__/App.test.jsx.snap +++ b/src/__snapshots__/App.test.jsx.snap @@ -4,7 +4,7 @@ exports[`App router component snapshot 1`] = ` - +
diff --git a/src/components/GradebookFilters/AssignmentFilter/__snapshots__/index.test.jsx.snap b/src/components/GradebookFilters/AssignmentFilter/__snapshots__/index.test.jsx.snap new file mode 100644 index 00000000..fcd1e748 --- /dev/null +++ b/src/components/GradebookFilters/AssignmentFilter/__snapshots__/index.test.jsx.snap @@ -0,0 +1,52 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AssignmentFilter component render snapshot 1`] = ` +
+ + All + , + , + , + , + , + ] + } + value="test-label" + /> +
+`; diff --git a/src/components/GradebookFilters/AssignmentFilter/__snapshots__/test.jsx.snap b/src/components/GradebookFilters/AssignmentFilter/__snapshots__/test.jsx.snap deleted file mode 100644 index da2fc3d7..00000000 --- a/src/components/GradebookFilters/AssignmentFilter/__snapshots__/test.jsx.snap +++ /dev/null @@ -1,44 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`AssignmentFilter Component snapshots basic snapshot 1`] = ` -
- - } - onChange={[MockFunction handleChange]} - options={ - Array [ - , - , - , - ] - } - value="assgN1" - /> -
-`; diff --git a/src/components/GradebookFilters/AssignmentFilter/hooks.js b/src/components/GradebookFilters/AssignmentFilter/hooks.js new file mode 100644 index 00000000..e349ed6e --- /dev/null +++ b/src/components/GradebookFilters/AssignmentFilter/hooks.js @@ -0,0 +1,33 @@ +import { + selectors, + actions, + thunkActions, +} from 'data/redux/hooks'; + +export const useAssignmentFilterData = ({ + updateQueryParams, +}) => { + const assignmentFilterOptions = selectors.filters.useSelectableAssignmentLabels(); + const selectedAssignmentLabel = selectors.filters.useSelectedAssignmentLabel() || ''; + + const updateAssignmentFilter = actions.filters.useUpdateAssignment(); + const conditionalFetch = thunkActions.grades.useFetchGradesIfAssignmentGradeFiltersSet(); + + const handleChange = ({ target: { value: assignment } }) => { + const selectedFilterOption = assignmentFilterOptions.find( + ({ label }) => label === assignment, + ); + const { type, id } = selectedFilterOption || {}; + updateAssignmentFilter({ label: assignment, type, id }); + updateQueryParams({ assignment: id }); + conditionalFetch(); + }; + + return { + handleChange, + selectedAssignmentLabel, + assignmentFilterOptions, + }; +}; + +export default useAssignmentFilterData; diff --git a/src/components/GradebookFilters/AssignmentFilter/hooks.test.js b/src/components/GradebookFilters/AssignmentFilter/hooks.test.js new file mode 100644 index 00000000..e19cd805 --- /dev/null +++ b/src/components/GradebookFilters/AssignmentFilter/hooks.test.js @@ -0,0 +1,88 @@ +import { selectors, actions, thunkActions } from 'data/redux/hooks'; + +import useAssignmentFilterData from './hooks'; + +jest.mock('data/redux/hooks', () => ({ + selectors: { + filters: { + useSelectableAssignmentLabels: jest.fn(), + useSelectedAssignmentLabel: jest.fn(), + }, + }, + actions: { + filters: { useUpdateAssignment: jest.fn() }, + }, + thunkActions: { + grades: { useFetchGradesIfAssignmentGradeFiltersSet: jest.fn() }, + }, +})); + +let out; +const testKey = 'test-key'; +const event = { target: { value: testKey } }; +const testId = 'test-id'; +const testType = 'test-type'; + +const testLabel = { label: testKey, id: testId, type: testType }; +const selectableAssignmentLabels = [ + { label: 'some' }, + { label: 'test' }, + { label: 'labels' }, + testLabel, +]; +const selectedAssignmentLabel = 'test-assignment-label'; +selectors.filters.useSelectableAssignmentLabels.mockReturnValue(selectableAssignmentLabels); +selectors.filters.useSelectedAssignmentLabel.mockReturnValue(selectedAssignmentLabel); + +const updateAssignment = jest.fn(); +const fetch = jest.fn(); +actions.filters.useUpdateAssignment.mockReturnValue(updateAssignment); +thunkActions.grades.useFetchGradesIfAssignmentGradeFiltersSet.mockReturnValue(fetch); + +const updateQueryParams = jest.fn(); + +describe('useAssignmentFilterData hook', () => { + beforeEach(() => { + out = useAssignmentFilterData({ updateQueryParams }); + }); + describe('behavior', () => { + it('initializes redux hooks', () => { + expect(selectors.filters.useSelectableAssignmentLabels).toHaveBeenCalledWith(); + expect(selectors.filters.useSelectedAssignmentLabel).toHaveBeenCalledWith(); + expect(actions.filters.useUpdateAssignment).toHaveBeenCalledWith(); + expect(thunkActions.grades.useFetchGradesIfAssignmentGradeFiltersSet) + .toHaveBeenCalledWith(); + }); + }); + describe('output', () => { + describe('handleEvent', () => { + beforeEach(() => { + out.handleChange(event); + }); + it('updates assignment filter with selected filter', () => { + expect(updateAssignment).toHaveBeenCalledWith(testLabel); + }); + it('updates queryParams', () => { + expect(updateQueryParams).toHaveBeenCalledWith({ assignment: testId }); + }); + it('updates assignment filter with only label if no match', () => { + out.handleChange({ target: { value: 'no-match' } }); + expect(updateAssignment).toHaveBeenCalledWith({ label: 'no-match' }); + }); + it('calls conditional fetch', () => { + expect(fetch).toHaveBeenCalled(); + }); + }); + it('passes selectedAssignmentLabel from hook', () => { + expect(out.selectedAssignmentLabel).toEqual(selectedAssignmentLabel); + }); + test('selectedAssignmentLabel is empty string if not set', () => { + selectors.filters.useSelectedAssignmentLabel.mockReturnValue(undefined); + out = useAssignmentFilterData({ updateQueryParams }); + expect(out.selectedAssignmentLabel).toEqual(''); + }); + it('passes assignmentFilterOptions from hook', () => { + expect(out.assignmentFilterOptions).toEqual(selectableAssignmentLabels); + }); + }); +}); diff --git a/src/components/GradebookFilters/AssignmentFilter/index.jsx b/src/components/GradebookFilters/AssignmentFilter/index.jsx index 11106247..06dd44d2 100644 --- a/src/components/GradebookFilters/AssignmentFilter/index.jsx +++ b/src/components/GradebookFilters/AssignmentFilter/index.jsx @@ -1,98 +1,44 @@ /* eslint-disable react/sort-comp, react/button-has-type */ import React from 'react'; import PropTypes from 'prop-types'; -import { connect } from 'react-redux'; -import { FormattedMessage } from '@edx/frontend-platform/i18n'; - -import selectors from 'data/selectors'; -import actions from 'data/actions'; -import thunkActions from 'data/thunkActions'; +import { useIntl } from '@edx/frontend-platform/i18n'; import messages from '../messages'; import SelectGroup from '../SelectGroup'; - -const { fetchGradesIfAssignmentGradeFiltersSet } = thunkActions.grades; - -export class AssignmentFilter extends React.Component { - constructor(props) { - super(props); - this.handleChange = this.handleChange.bind(this); - } - - handleChange(event) { - const assignment = event.target.value; - const selectedFilterOption = this.props.assignmentFilterOptions.find( - ({ label }) => label === assignment, - ); - const { type, id } = selectedFilterOption || {}; - const typedValue = { label: assignment, type, id }; - this.props.updateAssignmentFilter(typedValue); - this.props.updateQueryParams({ assignment: id }); - this.props.fetchGradesIfAssignmentGradeFiltersSet(); - } - - get options() { - const mapper = ({ label, subsectionLabel }) => ( - - ); - return ([ - , - ...this.props.assignmentFilterOptions.map(mapper), - ]); - } - - render() { - return ( -
- } - value={this.props.selectedAssignment} - onChange={this.handleChange} - disabled={this.props.assignmentFilterOptions.length === 0} - options={this.options} - /> -
- ); - } -} - -AssignmentFilter.defaultProps = { - assignmentFilterOptions: [], - selectedAssignment: '', +import useAssignmentFilterData from './hooks'; + +const AssignmentFilter = ({ updateQueryParams }) => { + const { + handleChange, + selectedAssignmentLabel, + assignmentFilterOptions, + } = useAssignmentFilterData({ updateQueryParams }); + const { formatMessage } = useIntl(); + const filterOptions = assignmentFilterOptions.map(({ label, subsectionLabel }) => ( + + )); + return ( +
+ All, + ...filterOptions, + ]} + /> +
+ ); }; AssignmentFilter.propTypes = { updateQueryParams: PropTypes.func.isRequired, - // redux - assignmentFilterOptions: PropTypes.arrayOf(PropTypes.shape({ - label: PropTypes.string, - subsectionLabel: PropTypes.string, - type: PropTypes.string, - id: PropTypes.string, - })), - selectedAssignment: PropTypes.string, - fetchGradesIfAssignmentGradeFiltersSet: PropTypes.func.isRequired, - updateAssignmentFilter: PropTypes.func.isRequired, -}; - -export const mapStateToProps = (state) => { - const { filters } = selectors; - return { - assignmentFilterOptions: filters.selectableAssignmentLabels(state), - selectedAssignment: filters.selectedAssignmentLabel(state), - selectedAssignmentType: filters.assignmentType(state), - selectedCohort: filters.cohort(state), - selectedTrack: filters.track(state), - }; -}; - -export const mapDispatchToProps = { - updateAssignmentFilter: actions.filters.update.assignment, - fetchGradesIfAssignmentGradeFiltersSet, }; -export default connect(mapStateToProps, mapDispatchToProps)(AssignmentFilter); +export default AssignmentFilter; diff --git a/src/components/GradebookFilters/AssignmentFilter/index.test.jsx b/src/components/GradebookFilters/AssignmentFilter/index.test.jsx new file mode 100644 index 00000000..ceec6358 --- /dev/null +++ b/src/components/GradebookFilters/AssignmentFilter/index.test.jsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import SelectGroup from '../SelectGroup'; +import useAssignmentFilterData from './hooks'; +import AssignmentFilter from '.'; + +jest.mock('../SelectGroup', () => 'SelectGroup'); +jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() })); + +const handleChange = jest.fn(); +const selectedAssignmentLabel = 'test-label'; +const assignmentFilterOptions = [ + { label: 'label1', subsectionLabel: 'sLabel1' }, + { label: 'label2', subsectionLabel: 'sLabel2' }, + { label: 'label3', subsectionLabel: 'sLabel3' }, + { label: 'label4', subsectionLabel: 'sLabel4' }, +]; +useAssignmentFilterData.mockReturnValue({ + handleChange, + selectedAssignmentLabel, + assignmentFilterOptions, +}); + +const updateQueryParams = jest.fn(); + +let el; +describe('AssignmentFilter component', () => { + beforeAll(() => { + el = shallow(); + }); + describe('behavior', () => { + it('initializes hooks', () => { + expect(useAssignmentFilterData).toHaveBeenCalledWith({ updateQueryParams }); + expect(useIntl).toHaveBeenCalledWith(); + }); + }); + describe('render', () => { + test('snapshot', () => { + expect(el).toMatchSnapshot(); + }); + test('filter options', () => { + const { options } = el.find(SelectGroup).props(); + expect(options.length).toEqual(5); + const testOption = assignmentFilterOptions[0]; + const optionProps = options[1].props; + expect(optionProps.value).toEqual(testOption.label); + expect(optionProps.children.join('')) + .toEqual(`${testOption.label}: ${testOption.subsectionLabel}`); + }); + }); +}); diff --git a/src/components/GradebookFilters/AssignmentFilter/test.jsx b/src/components/GradebookFilters/AssignmentFilter/test.jsx deleted file mode 100644 index b2ac511a..00000000 --- a/src/components/GradebookFilters/AssignmentFilter/test.jsx +++ /dev/null @@ -1,166 +0,0 @@ -import React from 'react'; -import { mount, shallow } from 'enzyme'; - -import selectors from 'data/selectors'; -import actions from 'data/actions'; -import { fetchGradesIfAssignmentGradeFiltersSet } from 'data/thunkActions/grades'; -import { - AssignmentFilter, - mapStateToProps, - mapDispatchToProps, -} from '.'; - -jest.mock('data/thunkActions/grades', () => ({ - updateGradesIfAssignmentGradeFiltersSet: jest.fn(), -})); - -jest.mock('data/selectors', () => ({ - /** Mocking to use passed state for validation purposes */ - filters: { - selectableAssignmentLabels: jest.fn(() => ([{ - label: 'assigNment', - subsectionLabel: 'subsection', - type: 'assignMentType', - id: 'subsectionId', - }])), - selectedAssignmentLabel: jest.fn(() => 'assigNment'), - assignmentType: jest.fn(() => 'assignMentType'), - cohort: jest.fn(() => 'COhort'), - track: jest.fn(() => 'traCK'), - }, -})); - -describe('AssignmentFilter', () => { - let props = { - assignmentFilterOptions: [ - { - label: 'assgN1', - subsectionLabel: 'subLabel1', - type: 'assgn_Type1', - id: 'assgn_iD1', - }, - { - label: 'assgN2', - subsectionLabel: 'subLabel2', - type: 'assgn_Type2', - id: 'assgn_iD2', - }, - ], - selectedAssignment: 'assgN1', - }; - - beforeEach(() => { - props = { - ...props, - updateQueryParams: jest.fn(), - fetchGradesIfAssignmentGradeFiltersSet: jest.fn(), - updateAssignmentFilter: jest.fn(), - }; - }); - - describe('Component', () => { - describe('behavior', () => { - describe('handleChange', () => { - let el; - const newAssgn = 'assgN1'; - const event = { target: { value: newAssgn } }; - const selected = props.assignmentFilterOptions[0]; - beforeEach(() => { - el = mount(); - el.instance().handleChange(event); - }); - it('calls props.updateAssignmentFilter with selection', () => { - expect(props.updateAssignmentFilter).toHaveBeenCalledWith({ - label: newAssgn, - type: selected.type, - id: selected.id, - }); - }); - it( - 'calls props.updateQueryParams with selected assignment id', - () => { - expect(props.updateQueryParams).toHaveBeenCalledWith({ - assignment: selected.id, - }); - }, - ); - it('calls props.fetchGradesIfAssignmentGradeFiltersSet', () => { - const method = props.fetchGradesIfAssignmentGradeFiltersSet; - expect(method).toHaveBeenCalledWith(); - }); - describe('no selected option', () => { - const value = 'fake'; - beforeEach(() => { - el = mount(); - el.instance().handleChange({ target: { value } }); - }); - it('calls props.updateAssignmentFilter with selection', () => { - expect(props.updateAssignmentFilter).toHaveBeenCalledWith({ - label: value, - type: undefined, - id: undefined, - }); - }); - it( - 'calls props.updateQueryParams with selected assignment id', - () => { - expect(props.updateQueryParams).toHaveBeenCalledWith({ - assignment: undefined, - }); - }, - ); - it('calls props.fetchGradesIfAssignmentGradeFiltersSet', () => { - const method = props.fetchGradesIfAssignmentGradeFiltersSet; - expect(method).toHaveBeenCalledWith(); - }); - }); - }); - }); - describe('snapshots', () => { - test('basic snapshot', () => { - const el = shallow(); - el.instance().handleChange = jest.fn().mockName('handleChange'); - expect(el.instance().render()).toMatchSnapshot(); - }); - }); - }); - describe('mapStateToProps', () => { - const state = { - filters: { - assignment: { label: 'assigNment' }, - assignmentType: 'assignMentType', - cohort: 'COhort', - track: 'traCK', - }, - }; - describe('assignmentFilterOptions', () => { - it('is selected from filters.selectableAssignmentLabels', () => { - expect( - mapStateToProps(state).assignmentFilterOptions, - ).toEqual( - selectors.filters.selectableAssignmentLabels(state), - ); - }); - }); - describe('selectedAssignment', () => { - it('is selected from filters.selectedAssignmentLabel', () => { - expect( - mapStateToProps(state).selectedAssignment, - ).toEqual( - selectors.filters.selectedAssignmentLabel(state), - ); - }); - }); - }); - describe('mapDispatchToProps', () => { - test('updateAssignmentFilter', () => { - expect(mapDispatchToProps.updateAssignmentFilter).toEqual( - actions.filters.update.assignment, - ); - }); - test('fetchGradesIfAsssignmentGradeFiltersSet', () => { - const prop = mapDispatchToProps.fetchGradesIfAssignmentGradeFiltersSet; - expect(prop).toEqual(fetchGradesIfAssignmentGradeFiltersSet); - }); - }); -}); diff --git a/src/components/GradebookFilters/AssignmentGradeFilter/__snapshots__/index.test.jsx.snap b/src/components/GradebookFilters/AssignmentGradeFilter/__snapshots__/index.test.jsx.snap new file mode 100644 index 00000000..109d1d7d --- /dev/null +++ b/src/components/GradebookFilters/AssignmentGradeFilter/__snapshots__/index.test.jsx.snap @@ -0,0 +1,67 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AssignmentFilter component render with selected assignment snapshot 1`] = ` +
+ + +
+ +
+
+`; + +exports[`AssignmentFilter component render without selected assignment snapshot 1`] = ` +
+ + +
+ +
+
+`; diff --git a/src/components/GradebookFilters/AssignmentGradeFilter/__snapshots__/test.jsx.snap b/src/components/GradebookFilters/AssignmentGradeFilter/__snapshots__/test.jsx.snap deleted file mode 100644 index 2ad68a9e..00000000 --- a/src/components/GradebookFilters/AssignmentGradeFilter/__snapshots__/test.jsx.snap +++ /dev/null @@ -1,95 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`AssignmentGradeFilter Component snapshots buttons and groups disabled if no selected assignment 1`] = ` -
- - } - onChange={[MockFunction handleSetMin]} - value="2" - /> - - } - onChange={[MockFunction handleSetMax]} - value="98" - /> -
- - Apply - -
-
-`; - -exports[`AssignmentGradeFilter Component snapshots smoke test 1`] = ` -
- - } - onChange={[MockFunction handleSetMin]} - value="2" - /> - - } - onChange={[MockFunction handleSetMax]} - value="98" - /> -
- - Apply - -
-
-`; diff --git a/src/components/GradebookFilters/AssignmentGradeFilter/hooks.js b/src/components/GradebookFilters/AssignmentGradeFilter/hooks.js new file mode 100644 index 00000000..adaf0a6d --- /dev/null +++ b/src/components/GradebookFilters/AssignmentGradeFilter/hooks.js @@ -0,0 +1,36 @@ +/* eslint-disable react/sort-comp, react/button-has-type */ +import { selectors, actions, thunkActions } from 'data/redux/hooks'; + +const useAssignmentGradeFilterData = ({ updateQueryParams }) => { + const localAssignmentLimits = selectors.app.useAssignmentGradeLimits(); + const selectedAssignment = selectors.filters.useSelectedAssignmentLabel(); + const fetchGrades = thunkActions.grades.useFetchGrades(); + const setFilter = actions.app.useSetLocalFilter(); + const updateAssignmentLimits = actions.filters.useUpdateAssignmentLimits(); + + const handleSubmit = () => { + updateAssignmentLimits(localAssignmentLimits); + fetchGrades(); + updateQueryParams(localAssignmentLimits); + }; + + const handleSetMax = ({ target: { value } }) => { + setFilter({ assignmentGradeMax: value }); + }; + + const handleSetMin = ({ target: { value } }) => { + setFilter({ assignmentGradeMin: value }); + }; + + const { assignmentGradeMax, assignmentGradeMin } = localAssignmentLimits; + return { + assignmentGradeMin, + assignmentGradeMax, + selectedAssignment, + handleSetMax, + handleSetMin, + handleSubmit, + }; +}; + +export default useAssignmentGradeFilterData; diff --git a/src/components/GradebookFilters/AssignmentGradeFilter/hooks.test.js b/src/components/GradebookFilters/AssignmentGradeFilter/hooks.test.js new file mode 100644 index 00000000..817f3769 --- /dev/null +++ b/src/components/GradebookFilters/AssignmentGradeFilter/hooks.test.js @@ -0,0 +1,81 @@ +import { selectors, actions, thunkActions } from 'data/redux/hooks'; + +import useAssignmentGradeFilterData from './hooks'; + +jest.mock('data/redux/hooks', () => ({ + selectors: { + app: { useAssignmentGradeLimits: jest.fn() }, + filters: { useSelectedAssignmentLabel: jest.fn() }, + }, + actions: { + app: { useSetLocalFilter: jest.fn() }, + filters: { useUpdateAssignmentLimits: jest.fn() }, + }, + thunkActions: { + grades: { useFetchGrades: jest.fn() }, + }, +})); + +let out; + +const assignmentGradeLimits = { assignmentGradeMax: 200, assignmentGradeMin: 3 }; +const selectedAssignmentLabel = 'test-assignment-label'; +selectors.app.useAssignmentGradeLimits.mockReturnValue(assignmentGradeLimits); +selectors.filters.useSelectedAssignmentLabel.mockReturnValue(selectedAssignmentLabel); + +const setLocalFilter = jest.fn(); +const updateAssignmentLimits = jest.fn(); +const fetch = jest.fn(); +actions.app.useSetLocalFilter.mockReturnValue(setLocalFilter); +actions.filters.useUpdateAssignmentLimits.mockReturnValue(updateAssignmentLimits); +thunkActions.grades.useFetchGrades.mockReturnValue(fetch); + +const testValue = 42; + +const updateQueryParams = jest.fn(); + +describe('useAssignmentFilterData hook', () => { + beforeEach(() => { + out = useAssignmentGradeFilterData({ updateQueryParams }); + }); + describe('behavior', () => { + it('initializes redux hooks', () => { + expect(selectors.app.useAssignmentGradeLimits).toHaveBeenCalledWith(); + expect(selectors.filters.useSelectedAssignmentLabel).toHaveBeenCalledWith(); + expect(actions.app.useSetLocalFilter).toHaveBeenCalledWith(); + expect(actions.filters.useUpdateAssignmentLimits).toHaveBeenCalledWith(); + expect(thunkActions.grades.useFetchGrades).toHaveBeenCalledWith(); + }); + }); + describe('output', () => { + describe('handleSubmit', () => { + beforeEach(() => { + out.handleSubmit(); + }); + it('updates assignment limits filter', () => { + expect(updateAssignmentLimits).toHaveBeenCalledWith(assignmentGradeLimits); + }); + it('updates queryParams', () => { + expect(updateQueryParams).toHaveBeenCalledWith(assignmentGradeLimits); + }); + it('calls conditional fetch', () => { + expect(fetch).toHaveBeenCalled(); + }); + }); + test('handleSetMax sets assignmentGradeMax', () => { + out.handleSetMax({ target: { value: testValue } }); + expect(setLocalFilter).toHaveBeenCalledWith({ assignmentGradeMax: testValue }); + }); + test('handleSetMin sets assignmentGradeMin', () => { + out.handleSetMin({ target: { value: testValue } }); + expect(setLocalFilter).toHaveBeenCalledWith({ assignmentGradeMin: testValue }); + }); + it('passes selectedAssignment from hook', () => { + expect(out.selectedAssignment).toEqual(selectedAssignmentLabel); + }); + it('passes assignmentGradeMin and assignmentGradeMax from hook', () => { + expect(out.assignmentGradeMax).toEqual(assignmentGradeLimits.assignmentGradeMax); + expect(out.assignmentGradeMin).toEqual(assignmentGradeLimits.assignmentGradeMin); + }); + }); +}); diff --git a/src/components/GradebookFilters/AssignmentGradeFilter/index.jsx b/src/components/GradebookFilters/AssignmentGradeFilter/index.jsx index adf0a31d..9b10d5a7 100644 --- a/src/components/GradebookFilters/AssignmentGradeFilter/index.jsx +++ b/src/components/GradebookFilters/AssignmentGradeFilter/index.jsx @@ -1,103 +1,56 @@ -/* eslint-disable react/sort-comp, react/button-has-type */ import React from 'react'; import PropTypes from 'prop-types'; -import { connect } from 'react-redux'; -import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { Button } from '@edx/paragon'; -import selectors from 'data/selectors'; -import actions from 'data/actions'; -import thunkActions from 'data/thunkActions'; - +import useAssignmentGradeFilterData from './hooks'; import messages from '../messages'; import PercentGroup from '../PercentGroup'; -export class AssignmentGradeFilter extends React.Component { - constructor(props) { - super(props); - this.handleSubmit = this.handleSubmit.bind(this); - this.handleSetMax = this.handleSetMax.bind(this); - this.handleSetMin = this.handleSetMin.bind(this); - } - - handleSubmit() { - this.props.updateAssignmentLimits(this.props.localAssignmentLimits); - this.props.fetchGrades(); - this.props.updateQueryParams(this.props.localAssignmentLimits); - } - - handleSetMax({ target: { value } }) { - this.props.setFilter({ assignmentGradeMax: value }); - } - - handleSetMin({ target: { value } }) { - this.props.setFilter({ assignmentGradeMin: value }); - } - - render() { - const { - localAssignmentLimits: { assignmentGradeMax, assignmentGradeMin }, - } = this.props; - return ( -
- } - value={assignmentGradeMin} - disabled={!this.props.selectedAssignment} - onChange={this.handleSetMin} - /> - } - value={assignmentGradeMax} - disabled={!this.props.selectedAssignment} - onChange={this.handleSetMax} - /> -
- -
+export const AssignmentGradeFilter = ({ updateQueryParams }) => { + const { + assignmentGradeMin, + assignmentGradeMax, + selectedAssignment, + handleSetMax, + handleSetMin, + handleSubmit, + } = useAssignmentGradeFilterData({ updateQueryParams }); + const { formatMessage } = useIntl(); + return ( +
+ + +
+
- ); - } -} - -AssignmentGradeFilter.defaultProps = { - selectedAssignment: '', +
+ ); }; AssignmentGradeFilter.propTypes = { updateQueryParams: PropTypes.func.isRequired, - - // redux - fetchGrades: PropTypes.func.isRequired, - localAssignmentLimits: PropTypes.shape({ - assignmentGradeMax: PropTypes.string, - assignmentGradeMin: PropTypes.string, - }).isRequired, - selectedAssignment: PropTypes.string, - setFilter: PropTypes.func.isRequired, - updateAssignmentLimits: PropTypes.func.isRequired, -}; - -export const mapStateToProps = (state) => ({ - localAssignmentLimits: selectors.app.assignmentGradeLimits(state), - selectedAssignment: selectors.filters.selectedAssignmentLabel(state), -}); - -export const mapDispatchToProps = { - fetchGrades: thunkActions.grades.fetchGrades, - setFilter: actions.app.setLocalFilter, - updateAssignmentLimits: actions.filters.update.assignmentLimits, }; -export default connect(mapStateToProps, mapDispatchToProps)(AssignmentGradeFilter); +export default AssignmentGradeFilter; diff --git a/src/components/GradebookFilters/AssignmentGradeFilter/index.test.jsx b/src/components/GradebookFilters/AssignmentGradeFilter/index.test.jsx new file mode 100644 index 00000000..332da15e --- /dev/null +++ b/src/components/GradebookFilters/AssignmentGradeFilter/index.test.jsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Button } from '@edx/paragon'; + +import PercentGroup from '../PercentGroup'; +import useAssignmentGradeFilterData from './hooks'; +import AssignmentFilter from '.'; + +jest.mock('../PercentGroup', () => 'PercentGroup'); +jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() })); + +const hookData = { + handleChange: jest.fn(), + handleSetMax: jest.fn(), + handleSetMin: jest.fn(), + selectedAssignment: 'test-assignment', + assignmentGradeMax: 300, + assignmentGradeMin: 23, +}; +useAssignmentGradeFilterData.mockReturnValue(hookData); + +const updateQueryParams = jest.fn(); + +let el; +describe('AssignmentFilter component', () => { + beforeEach(() => { + jest.clearAllMocks(); + el = shallow(); + }); + describe('behavior', () => { + it('initializes hooks', () => { + expect(useAssignmentGradeFilterData).toHaveBeenCalledWith({ updateQueryParams }); + expect(useIntl).toHaveBeenCalledWith(); + }); + }); + describe('render', () => { + describe('with selected assignment', () => { + test('snapshot', () => { + expect(el).toMatchSnapshot(); + }); + it('renders a PercentGroup for both Max and Min filters', () => { + let props = el.find(PercentGroup).at(0).props(); + expect(props.value).toEqual(hookData.assignmentGradeMin); + expect(props.disabled).toEqual(false); + expect(props.onChange).toEqual(hookData.handleSetMin); + props = el.find(PercentGroup).at(1).props(); + expect(props.value).toEqual(hookData.assignmentGradeMax); + expect(props.disabled).toEqual(false); + expect(props.onChange).toEqual(hookData.handleSetMax); + }); + it('renders a submit button', () => { + const props = el.find(Button).props(); + expect(props.disabled).toEqual(false); + expect(props.onClick).toEqual(hookData.handleSubmit); + }); + }); + describe('without selected assignment', () => { + beforeEach(() => { + useAssignmentGradeFilterData.mockReturnValueOnce({ + ...hookData, + selectedAssignment: null, + }); + el = shallow(); + }); + test('snapshot', () => { + expect(el).toMatchSnapshot(); + }); + it('disables controls', () => { + let props = el.find(PercentGroup).at(0).props(); + expect(props.disabled).toEqual(true); + props = el.find(PercentGroup).at(1).props(); + expect(props.disabled).toEqual(true); + }); + }); + }); +}); diff --git a/src/components/GradebookFilters/AssignmentGradeFilter/test.jsx b/src/components/GradebookFilters/AssignmentGradeFilter/test.jsx deleted file mode 100644 index cf786178..00000000 --- a/src/components/GradebookFilters/AssignmentGradeFilter/test.jsx +++ /dev/null @@ -1,143 +0,0 @@ -import React from 'react'; -import { mount, shallow } from 'enzyme'; - -import actions from 'data/actions'; -import selectors from 'data/selectors'; -import { fetchGrades } from 'data/thunkActions/grades'; - -import { - AssignmentGradeFilter, - mapStateToProps, - mapDispatchToProps, -} from '.'; - -jest.mock('data/selectors', () => ({ - __esModule: true, - default: { - app: {}, - filters: {}, - grades: {}, - }, -})); - -jest.mock('data/thunkActions/grades', () => ({ - fetchGrades: jest.fn(), -})); - -describe('AssignmentGradeFilter', () => { - let props = {}; - beforeEach(() => { - props = { - ...props, - updateQueryParams: jest.fn(), - fetchGrades: jest.fn(), - localAssignmentLimits: { - assignmentGradeMax: '98', - assignmentGradeMin: '2', - }, - selectedAssignment: 'Potions 101.5', - setFilter: jest.fn(), - updateAssignmentLimits: jest.fn(), - }; - }); - - describe('Component', () => { - describe('behavior', () => { - let el; - beforeEach(() => { - el = mount(); - }); - describe('handleSubmit', () => { - beforeEach(() => { - el.instance().handleSubmit(); - }); - it('calls props.updateAssignmentLimits with local assignment limits', () => { - expect(props.updateAssignmentLimits).toHaveBeenCalledWith(props.localAssignmentLimits); - }); - it('calls fetchUserGrades', () => { - expect(props.fetchGrades).toHaveBeenCalledWith(); - }); - it('updates queryParams with assignment grade min and max', () => { - expect(props.updateQueryParams).toHaveBeenCalledWith(props.localAssignmentLimits); - }); - }); - describe('handleSetMin', () => { - it('calls setFilters for assignmentGradeMin', () => { - const testVal = 23; - el.instance().handleSetMin({ target: { value: testVal } }); - expect(props.setFilter).toHaveBeenCalledWith({ - assignmentGradeMin: testVal, - }); - }); - }); - describe('handleSetMax', () => { - it('calls setFilters for assignmentGradeMax', () => { - const testVal = 92; - el.instance().handleSetMax({ target: { value: testVal } }); - expect(props.setFilter).toHaveBeenCalledWith({ - assignmentGradeMax: testVal, - }); - }); - }); - }); - describe('snapshots', () => { - let el; - const mockMethods = () => { - el.instance().handleSubmit = jest.fn().mockName('handleSubmit'); - el.instance().handleSetMax = jest.fn().mockName('handleSetMax'); - el.instance().handleSetMin = jest.fn().mockName('handleSetMin'); - }; - test('smoke test', () => { - el = shallow(); - mockMethods(el); - expect(el.instance().render()).toMatchSnapshot(); - }); - test('buttons and groups disabled if no selected assignment', () => { - el = shallow(); - mockMethods(el); - expect(el.instance().render()).toMatchSnapshot(); - }); - }); - }); - describe('mapStateToProps', () => { - const testState = { belle: 'in', the: 'castle' }; - let mappedProps; - beforeEach(() => { - selectors.app.assignmentGradeLimits = jest.fn((state) => ({ gradeLimits: state })); - selectors.filters.selectedAssignmentLabel = jest.fn((state) => ({ assignmentLabel: state })); - mappedProps = mapStateToProps(testState); - }); - describe('localAssignmentLimits', () => { - it('returns selectors.app.assignmentGradeLimits', () => { - expect( - mappedProps.localAssignmentLimits, - ).toEqual(selectors.app.assignmentGradeLimits(testState)); - }); - }); - describe('selectedAsssignment', () => { - it('returns selectors.filters.selectedAssignmentLabel', () => { - expect( - mappedProps.selectedAssignment, - ).toEqual(selectors.filters.selectedAssignmentLabel(testState)); - }); - }); - }); - describe('mapDispatchToProps', () => { - test('fetchGrades', () => { - expect(mapDispatchToProps.fetchGrades).toEqual(fetchGrades); - }); - test('setFilters', () => { - expect(mapDispatchToProps.setFilter).toEqual(actions.app.setLocalFilter); - }); - test('updateAssignmentLimits', () => { - expect( - mapDispatchToProps.updateAssignmentLimits, - ).toEqual( - actions.filters.update.assignmentLimits, - ); - }); - }); -}); diff --git a/src/components/GradebookFilters/AssignmentTypeFilter/__snapshots__/index.test.jsx.snap b/src/components/GradebookFilters/AssignmentTypeFilter/__snapshots__/index.test.jsx.snap new file mode 100644 index 00000000..70382c97 --- /dev/null +++ b/src/components/GradebookFilters/AssignmentTypeFilter/__snapshots__/index.test.jsx.snap @@ -0,0 +1,44 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AssignmentFilterType component render snapshot 1`] = ` +
+ + All + , + , + , + , + , + ] + } + value="test-type" + /> +
+`; diff --git a/src/components/GradebookFilters/AssignmentTypeFilter/__snapshots__/test.jsx.snap b/src/components/GradebookFilters/AssignmentTypeFilter/__snapshots__/test.jsx.snap deleted file mode 100644 index f21b12ad..00000000 --- a/src/components/GradebookFilters/AssignmentTypeFilter/__snapshots__/test.jsx.snap +++ /dev/null @@ -1,79 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`AssignmentTypeFilter Component snapshots SelectGroup disabled if no assignmentFilterOptions 1`] = ` -
- - } - onChange={[MockFunction handleChange]} - options={ - Array [ - , - , - , - ] - } - value="assigNmentType2" - /> -
-`; - -exports[`AssignmentTypeFilter Component snapshots smoke test 1`] = ` -
- - } - onChange={[MockFunction handleChange]} - options={ - Array [ - , - , - , - ] - } - value="assigNmentType2" - /> -
-`; diff --git a/src/components/GradebookFilters/AssignmentTypeFilter/hooks.js b/src/components/GradebookFilters/AssignmentTypeFilter/hooks.js new file mode 100644 index 00000000..3dd25ae7 --- /dev/null +++ b/src/components/GradebookFilters/AssignmentTypeFilter/hooks.js @@ -0,0 +1,22 @@ +import { selectors, actions } from 'data/redux/hooks'; + +export const useAssignmentTypeFilterData = ({ updateQueryParams }) => { + const assignmentTypes = selectors.assignmentTypes.useAllAssignmentTypes() || {}; + const assignmentFilterOptions = selectors.filters.useSelectableAssignmentLabels(); + const selectedAssignmentType = selectors.filters.useAssignmentType() || ''; + const filterAssignmentType = actions.filters.useUpdateAssignmentType(); + + const handleChange = (event) => { + const assignmentType = event.target.value; + filterAssignmentType(assignmentType); + updateQueryParams({ assignmentType }); + }; + + return { + assignmentTypes, + handleChange, + isDisabled: assignmentFilterOptions.length === 0, + selectedAssignmentType, + }; +}; +export default useAssignmentTypeFilterData; diff --git a/src/components/GradebookFilters/AssignmentTypeFilter/hooks.test.js b/src/components/GradebookFilters/AssignmentTypeFilter/hooks.test.js new file mode 100644 index 00000000..ff9d05c3 --- /dev/null +++ b/src/components/GradebookFilters/AssignmentTypeFilter/hooks.test.js @@ -0,0 +1,92 @@ +import { selectors, actions } from 'data/redux/hooks'; + +import useAssignmentTypeFilterData from './hooks'; + +jest.mock('data/redux/hooks', () => ({ + selectors: { + assignmentTypes: { + useAllAssignmentTypes: jest.fn(), + }, + filters: { + useSelectableAssignmentLabels: jest.fn(), + useAssignmentType: jest.fn(), + }, + }, + actions: { + filters: { useUpdateAssignmentType: jest.fn() }, + }, +})); + +let out; +const testId = 'test-id'; +const testKey = 'test-key'; + +const testType = 'test-type'; +const allTypes = [testType, 'and', 'some', 'other', 'types']; +selectors.assignmentTypes.useAllAssignmentTypes.mockReturnValue(allTypes); +const event = { target: { value: testType } }; + +const testLabel = { label: testKey, id: testId, type: testType }; +const selectableAssignmentLabels = [ + { label: 'some' }, + { label: 'test' }, + { label: 'labels' }, + testLabel, +]; +selectors.filters.useSelectableAssignmentLabels.mockReturnValue(selectableAssignmentLabels); +selectors.filters.useAssignmentType.mockReturnValue(testType); + +const updateAssignmentType = jest.fn(); +actions.filters.useUpdateAssignmentType.mockReturnValue(updateAssignmentType); + +const updateQueryParams = jest.fn(); + +describe('useAssignmentTypeFilterData hook', () => { + beforeEach(() => { + out = useAssignmentTypeFilterData({ updateQueryParams }); + }); + describe('behavior', () => { + it('initializes redux hooks', () => { + expect(selectors.assignmentTypes.useAllAssignmentTypes).toHaveBeenCalledWith(); + expect(selectors.filters.useSelectableAssignmentLabels).toHaveBeenCalledWith(); + expect(selectors.filters.useAssignmentType).toHaveBeenCalledWith(); + expect(actions.filters.useUpdateAssignmentType).toHaveBeenCalledWith(); + }); + }); + describe('output', () => { + describe('handleEvent', () => { + beforeEach(() => { + out.handleChange(event); + }); + it('updates assignmentType filter with selected filter', () => { + expect(updateAssignmentType).toHaveBeenCalledWith(testType); + }); + it('updates queryParams', () => { + expect(updateQueryParams).toHaveBeenCalledWith({ assignmentType: testType }); + }); + }); + describe('selectedAssignmentType', () => { + it('returns selected assignmentType', () => { + expect(out.selectedAssignmentType).toEqual(testType); + }); + it('returns empty string if no assignmentType is selected', () => { + selectors.filters.useAssignmentType.mockReturnValue(undefined); + out = useAssignmentTypeFilterData({ updateQueryParams }); + expect(out.selectedAssignmentType).toEqual(''); + }); + }); + it('passes assignmentTypes from hook', () => { + expect(out.assignmentTypes).toEqual(allTypes); + }); + test('assignmentTypes is empty object if hook returns undefined', () => { + selectors.assignmentTypes.useAllAssignmentTypes.mockReturnValue(undefined); + out = useAssignmentTypeFilterData({ updateQueryParams }); + expect(out.assignmentTypes).toEqual({}); + }); + it('returns isDisabled if assigmentFilterOptions is empty', () => { + expect(out.isDisabled).toEqual(false); + selectors.assignmentTypes.useAllAssignmentTypes.mockReturnValue([]); + out = useAssignmentTypeFilterData({ updateQueryParams }); + }); + }); +}); diff --git a/src/components/GradebookFilters/AssignmentTypeFilter/index.jsx b/src/components/GradebookFilters/AssignmentTypeFilter/index.jsx index 989736ca..368bee84 100644 --- a/src/components/GradebookFilters/AssignmentTypeFilter/index.jsx +++ b/src/components/GradebookFilters/AssignmentTypeFilter/index.jsx @@ -1,81 +1,42 @@ /* eslint-disable react/sort-comp, react/button-has-type */ import React from 'react'; import PropTypes from 'prop-types'; -import { connect } from 'react-redux'; -import { FormattedMessage } from '@edx/frontend-platform/i18n'; - -import selectors from 'data/selectors'; -import actions from 'data/actions'; +import { useIntl } from '@edx/frontend-platform/i18n'; import SelectGroup from '../SelectGroup'; import messages from '../messages'; - -export class AssignmentTypeFilter extends React.Component { - constructor(props) { - super(props); - this.handleChange = this.handleChange.bind(this); - } - - handleChange(event) { - const assignmentType = event.target.value; - this.props.filterAssignmentType(assignmentType); - this.props.updateQueryParams({ assignmentType }); - } - - get options() { - const mapper = (entry) => ( - - ); - return [ - , - ...this.props.assignmentTypes.map(mapper), - ]; - } - - render() { - return ( -
- } - value={this.props.selectedAssignmentType} - onChange={this.handleChange} - disabled={this.props.assignmentFilterOptions.length === 0} - options={this.options} - /> -
- ); - } -} - -AssignmentTypeFilter.defaultProps = { - assignmentTypes: [], - assignmentFilterOptions: [], - selectedAssignmentType: '', +import useAssignmentTypeFilterData from './hooks'; + +export const AssignmentTypeFilter = ({ updateQueryParams }) => { + const { + assignmentTypes, + handleChange, + isDisabled, + selectedAssignmentType, + } = useAssignmentTypeFilterData({ updateQueryParams }); + const { formatMessage } = useIntl(); + return ( +
+ All, + ...assignmentTypes.map(entry => ( + + )), + ]} + /> +
+ ); }; AssignmentTypeFilter.propTypes = { updateQueryParams: PropTypes.func.isRequired, - - // redux - assignmentTypes: PropTypes.arrayOf(PropTypes.string), - assignmentFilterOptions: PropTypes.arrayOf(PropTypes.shape({ - label: PropTypes.string, - subsectionLabel: PropTypes.string, - })), - filterAssignmentType: PropTypes.func.isRequired, - selectedAssignmentType: PropTypes.string, -}; - -export const mapStateToProps = (state) => ({ - assignmentTypes: selectors.assignmentTypes.allAssignmentTypes(state), - assignmentFilterOptions: selectors.filters.selectableAssignmentLabels(state), - selectedAssignmentType: selectors.filters.assignmentType(state), -}); - -export const mapDispatchToProps = { - filterAssignmentType: actions.filters.update.assignmentType, }; -export default connect(mapStateToProps, mapDispatchToProps)(AssignmentTypeFilter); +export default AssignmentTypeFilter; diff --git a/src/components/GradebookFilters/AssignmentTypeFilter/index.test.jsx b/src/components/GradebookFilters/AssignmentTypeFilter/index.test.jsx new file mode 100644 index 00000000..b1a9cb0a --- /dev/null +++ b/src/components/GradebookFilters/AssignmentTypeFilter/index.test.jsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import SelectGroup from '../SelectGroup'; +import useAssignmentFilterTypeData from './hooks'; +import AssignmentFilterType from '.'; + +jest.mock('../SelectGroup', () => 'SelectGroup'); +jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() })); + +const handleChange = jest.fn(); +const testType = 'test-type'; +const assignmentTypes = [testType, 'type1', 'type2', 'type3']; +useAssignmentFilterTypeData.mockReturnValue({ + handleChange, + selectedAssignmentType: testType, + assignmentTypes, + isDisabled: true, +}); + +const updateQueryParams = jest.fn(); + +let el; +describe('AssignmentFilterType component', () => { + beforeAll(() => { + el = shallow(); + }); + describe('behavior', () => { + it('initializes hooks', () => { + expect(useAssignmentFilterTypeData).toHaveBeenCalledWith({ updateQueryParams }); + expect(useIntl).toHaveBeenCalledWith(); + }); + }); + describe('render', () => { + test('snapshot', () => { + expect(el).toMatchSnapshot(); + }); + test('filter options', () => { + const { options } = el.find(SelectGroup).props(); + expect(options.length).toEqual(5); + const optionProps = options[1].props; + expect(optionProps.value).toEqual(assignmentTypes[0]); + expect(optionProps.children).toEqual(testType); + }); + }); +}); diff --git a/src/components/GradebookFilters/AssignmentTypeFilter/test.jsx b/src/components/GradebookFilters/AssignmentTypeFilter/test.jsx deleted file mode 100644 index 88750e08..00000000 --- a/src/components/GradebookFilters/AssignmentTypeFilter/test.jsx +++ /dev/null @@ -1,135 +0,0 @@ -import React from 'react'; -import { shallow } from 'enzyme'; - -import selectors from 'data/selectors'; -import actions from 'data/actions'; - -import { - AssignmentTypeFilter, - mapStateToProps, - mapDispatchToProps, -} from '.'; - -jest.mock('data/selectors', () => ({ - /** Mocking to use passed state for validation purposes */ - assignmentTypes: { - allAssignmentTypes: jest.fn(() => (['assignment', 'labs'])), - }, - filters: { - selectableAssignmentLabels: jest.fn(() => ([{ - label: 'assigNment', - subsectionLabel: 'subsection', - type: 'assignMentType', - id: 'subsectionId', - }])), - assignmentType: jest.fn(() => 'assignMentType'), - }, -})); - -describe('AssignmentTypeFilter', () => { - let props = { - assignmentTypes: ['assignMentType1', 'AssigNmentType2'], - assignmentFilterOptions: [ - { label: 'filterLabel1', subsectionLabel: 'filterSubLabel2' }, - { label: 'filterLabel2', subsectionLabel: 'filterSubLabel1' }, - ], - selectedAssignmentType: 'assigNmentType2', - }; - - beforeEach(() => { - props = { - ...props, - filterAssignmentType: jest.fn(), - updateQueryParams: jest.fn(), - }; - }); - - describe('Component', () => { - describe('behavior', () => { - describe('handleChange', () => { - let el; - const newType = 'new Type'; - const event = { target: { value: newType } }; - beforeEach(() => { - el = shallow(); - el.instance().handleChange(event); - }); - it('calls props.filterAssignmentType with new type', () => { - expect(props.filterAssignmentType).toHaveBeenCalledWith( - newType, - ); - }); - it('updates queryParams with assignmentType', () => { - expect(props.updateQueryParams).toHaveBeenCalledWith({ - assignmentType: newType, - }); - }); - }); - }); - describe('snapshots', () => { - let el; - const mockMethods = () => { - el.instance().handleChange = jest.fn().mockName('handleChange'); - }; - test('smoke test', () => { - el = shallow(); - mockMethods(el); - expect(el.instance().render()).toMatchSnapshot(); - }); - test('SelectGroup disabled if no assignmentFilterOptions', () => { - el = shallow(); - mockMethods(el); - expect(el.instance().render()).toMatchSnapshot(); - }); - }); - }); - describe('mapStateToProps', () => { - const state = { - assignmentTypes: { - results: ['assignMentType1', 'assignMentType2'], - }, - filters: { - assignmentType: 'selectedAssignMent', - cohort: 'selectedCOHOrt', - track: 'SELectedTrack', - }, - }; - describe('assignmentTypes', () => { - it('is selected from assignmentTypes.allAssignmentTypes', () => { - expect( - mapStateToProps(state).assignmentTypes, - ).toEqual( - selectors.assignmentTypes.allAssignmentTypes(state), - ); - }); - }); - describe('assignmentFilterOptions', () => { - it('is selected from filters.selectableAssignmentLabels', () => { - expect( - mapStateToProps(state).assignmentFilterOptions, - ).toEqual( - selectors.filters.selectableAssignmentLabels(state), - ); - }); - }); - describe('selectedAssignmentType', () => { - it('is selected from filters.assignmentType', () => { - expect( - mapStateToProps(state).selectedAssignmentType, - ).toEqual( - selectors.filters.assignmentType(state), - ); - }); - }); - }); - describe('mapDispatchToProps', () => { - test('filterAssignmentType', () => { - expect(mapDispatchToProps.filterAssignmentType).toEqual( - actions.filters.update.assignmentType, - ); - }); - }); -}); diff --git a/src/components/GradebookFilters/CourseGradeFilter/__snapshots__/index.test.jsx.snap b/src/components/GradebookFilters/CourseGradeFilter/__snapshots__/index.test.jsx.snap new file mode 100644 index 00000000..b60179ed --- /dev/null +++ b/src/components/GradebookFilters/CourseGradeFilter/__snapshots__/index.test.jsx.snap @@ -0,0 +1,63 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CourseFilter component render if disabled snapshot 1`] = ` + +
+ + +
+
+ +
+
+`; + +exports[`CourseFilter component render with selected assignment snapshot 1`] = ` + +
+ + +
+
+ +
+
+`; diff --git a/src/components/GradebookFilters/CourseGradeFilter/__snapshots__/test.jsx.snap b/src/components/GradebookFilters/CourseGradeFilter/__snapshots__/test.jsx.snap deleted file mode 100644 index beb2dc62..00000000 --- a/src/components/GradebookFilters/CourseGradeFilter/__snapshots__/test.jsx.snap +++ /dev/null @@ -1,44 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`CourseGradeFilter Component snapshots basic snapshot 1`] = ` - -
- - } - onChange={[MockFunction handleUpdateMin]} - value="5" - /> - - } - onChange={[MockFunction handleUpdateMax]} - value="92" - /> -
-
- -
-
-`; diff --git a/src/components/GradebookFilters/CourseGradeFilter/hooks.js b/src/components/GradebookFilters/CourseGradeFilter/hooks.js new file mode 100644 index 00000000..3ca017d4 --- /dev/null +++ b/src/components/GradebookFilters/CourseGradeFilter/hooks.js @@ -0,0 +1,33 @@ +import { actions, selectors, thunkActions } from 'data/redux/hooks'; + +export const useCourseGradeFilterData = ({ + updateQueryParams, +}) => { + const isDisabled = !selectors.app.useAreCourseGradeFiltersValid(); + const localCourseLimits = selectors.app.useCourseGradeLimits(); + const fetchGrades = thunkActions.grades.useFetchGrades(); + const setLocalFilter = actions.app.useSetLocalFilter(); + const updateFilter = actions.filters.useUpdateCourseGradeLimits(); + + const handleApplyClick = () => { + updateFilter(localCourseLimits); + fetchGrades(); + updateQueryParams(localCourseLimits); + }; + + const { courseGradeMin, courseGradeMax } = localCourseLimits; + return { + max: { + value: courseGradeMax, + onChange: (e) => setLocalFilter({ courseGradeMax: e.target.value }), + }, + min: { + value: courseGradeMin, + onChange: (e) => setLocalFilter({ courseGradeMin: e.target.value }), + }, + handleApplyClick, + isDisabled, + }; +}; + +export default useCourseGradeFilterData; diff --git a/src/components/GradebookFilters/CourseGradeFilter/hooks.test.js b/src/components/GradebookFilters/CourseGradeFilter/hooks.test.js new file mode 100644 index 00000000..545ead91 --- /dev/null +++ b/src/components/GradebookFilters/CourseGradeFilter/hooks.test.js @@ -0,0 +1,78 @@ +import { selectors, actions, thunkActions } from 'data/redux/hooks'; + +import useCourseTypeFilterData from './hooks'; + +jest.mock('data/redux/hooks', () => ({ + selectors: { + app: { + useAreCourseGradeFiltersValid: jest.fn(), + useCourseGradeLimits: jest.fn(), + }, + }, + actions: { + app: { useSetLocalFilter: jest.fn() }, + filters: { useUpdateCourseGradeLimits: jest.fn() }, + }, + thunkActions: { + grades: { useFetchGrades: jest.fn() }, + }, +})); + +let out; + +const courseGradeLimits = { courseGradeMax: 120, courseGradeMin: 32 }; +selectors.app.useAreCourseGradeFiltersValid.mockReturnValue(true); +selectors.app.useCourseGradeLimits.mockReturnValue(courseGradeLimits); + +const setLocalFilter = jest.fn(); +actions.app.useSetLocalFilter.mockReturnValue(setLocalFilter); +const updateCourseGradeLimits = jest.fn(); +actions.filters.useUpdateCourseGradeLimits.mockReturnValue(updateCourseGradeLimits); +const fetch = jest.fn(); +thunkActions.grades.useFetchGrades.mockReturnValue(fetch); + +const testValue = 55; + +const updateQueryParams = jest.fn(); + +describe('useCourseTypeFilterData hook', () => { + beforeEach(() => { + jest.clearAllMocks(); + out = useCourseTypeFilterData({ updateQueryParams }); + }); + describe('behavior', () => { + it('initializes redux hooks', () => { + expect(selectors.app.useAreCourseGradeFiltersValid).toHaveBeenCalledWith(); + expect(selectors.app.useCourseGradeLimits).toHaveBeenCalledWith(); + expect(actions.app.useSetLocalFilter).toHaveBeenCalledWith(); + expect(actions.filters.useUpdateCourseGradeLimits).toHaveBeenCalledWith(); + expect(thunkActions.grades.useFetchGrades).toHaveBeenCalledWith(); + }); + }); + describe('output', () => { + it('returns isDisabled if assigmentFilterOptions is empty', () => { + expect(out.isDisabled).toEqual(false); + selectors.app.useAreCourseGradeFiltersValid.mockReturnValue(false); + out = useCourseTypeFilterData({ updateQueryParams }); + expect(out.isDisabled).toEqual(true); + }); + test('min value and onChange', () => { + const { courseGradeMin } = courseGradeLimits; + expect(out.min.value).toEqual(courseGradeMin); + out.min.onChange({ target: { value: testValue } }); + expect(setLocalFilter).toHaveBeenCalledWith({ courseGradeMin: testValue }); + }); + test('max value and onChange', () => { + const { courseGradeMax } = courseGradeLimits; + expect(out.max.value).toEqual(courseGradeMax); + out.max.onChange({ target: { value: testValue } }); + expect(setLocalFilter).toHaveBeenCalledWith({ courseGradeMax: testValue }); + }); + it('updates filter, fetches grades, and updates query params on apply click', () => { + out.handleApplyClick(); + expect(updateCourseGradeLimits).toHaveBeenCalledWith(courseGradeLimits); + expect(fetch).toHaveBeenCalledWith(); + expect(updateQueryParams).toHaveBeenCalledWith(courseGradeLimits); + }); + }); +}); diff --git a/src/components/GradebookFilters/CourseGradeFilter/index.jsx b/src/components/GradebookFilters/CourseGradeFilter/index.jsx index ad0117f1..36995b99 100644 --- a/src/components/GradebookFilters/CourseGradeFilter/index.jsx +++ b/src/components/GradebookFilters/CourseGradeFilter/index.jsx @@ -1,103 +1,52 @@ -/* eslint-disable react/sort-comp, react/button-has-type, import/no-named-as-default */ -import React from 'react'; -import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import { Button } from '@edx/paragon'; -import { FormattedMessage } from '@edx/frontend-platform/i18n'; - -import selectors from 'data/selectors'; -import actions from 'data/actions'; -import thunkActions from 'data/thunkActions'; +import { useIntl } from '@edx/frontend-platform/i18n'; import messages from '../messages'; import PercentGroup from '../PercentGroup'; - -export class CourseGradeFilter extends React.Component { - constructor(props) { - super(props); - this.handleApplyClick = this.handleApplyClick.bind(this); - this.handleUpdateMin = this.handleUpdateMin.bind(this); - this.handleUpdateMax = this.handleUpdateMax.bind(this); - this.updateCourseGradeFilters = this.updateCourseGradeFilters.bind(this); - } - - handleApplyClick() { - if (this.props.areLimitsValid) { - this.updateCourseGradeFilters(); - } - } - - handleUpdateMin({ target: { value } }) { - this.props.setLocalFilter({ courseGradeMin: value }); - } - - handleUpdateMax({ target: { value } }) { - this.props.setLocalFilter({ courseGradeMax: value }); - } - - updateCourseGradeFilters() { - this.props.updateFilter(this.props.localCourseLimits); - this.props.fetchGrades(); - this.props.updateQueryParams(this.props.localCourseLimits); - } - - render() { - const { - localCourseLimits: { courseGradeMin, courseGradeMax }, - } = this.props; - return ( - <> -
- } - value={courseGradeMin} - onChange={this.handleUpdateMin} - /> - } - value={courseGradeMax} - onChange={this.handleUpdateMax} - /> -
-
- -
- - ); - } -} +import useCourseGradeFilterData from './hooks'; + +export const CourseGradeFilter = ({ updateQueryParams }) => { + const { + max, + min, + isDisabled, + handleApplyClick, + } = useCourseGradeFilterData({ updateQueryParams }); + const { formatMessage } = useIntl(); + + return ( + <> +
+ + +
+
+ +
+ + ); +}; CourseGradeFilter.propTypes = { updateQueryParams: PropTypes.func.isRequired, - - // Redux - areLimitsValid: PropTypes.bool.isRequired, - fetchGrades: PropTypes.func.isRequired, - localCourseLimits: PropTypes.shape({ - courseGradeMin: PropTypes.string.isRequired, - courseGradeMax: PropTypes.string.isRequired, - }).isRequired, - setLocalFilter: PropTypes.func.isRequired, - updateFilter: PropTypes.func.isRequired, -}; - -export const mapStateToProps = (state) => ({ - areLimitsValid: selectors.app.areCourseGradeFiltersValid(state), - localCourseLimits: selectors.app.courseGradeLimits(state), -}); - -export const mapDispatchToProps = { - fetchGrades: thunkActions.grades.fetchGrades, - setLocalFilter: actions.app.setLocalFilter, - updateFilter: actions.filters.update.courseGradeLimits, }; -export default connect(mapStateToProps, mapDispatchToProps)(CourseGradeFilter); +export default CourseGradeFilter; diff --git a/src/components/GradebookFilters/CourseGradeFilter/index.test.jsx b/src/components/GradebookFilters/CourseGradeFilter/index.test.jsx new file mode 100644 index 00000000..881cb218 --- /dev/null +++ b/src/components/GradebookFilters/CourseGradeFilter/index.test.jsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Button } from '@edx/paragon'; + +import PercentGroup from '../PercentGroup'; +import useCourseGradeFilterData from './hooks'; +import CourseFilter from '.'; + +jest.mock('../PercentGroup', () => 'PercentGroup'); +jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() })); + +const hookData = { + handleChange: jest.fn(), + max: { + value: 300, + onChange: jest.fn(), + }, + min: { + value: 23, + onChange: jest.fn(), + }, + selectedCourse: 'test-assignment', + isDisabled: false, +}; +useCourseGradeFilterData.mockReturnValue(hookData); + +const updateQueryParams = jest.fn(); + +let el; +describe('CourseFilter component', () => { + beforeEach(() => { + jest.clearAllMocks(); + el = shallow(); + }); + describe('behavior', () => { + it('initializes hooks', () => { + expect(useCourseGradeFilterData).toHaveBeenCalledWith({ updateQueryParams }); + expect(useIntl).toHaveBeenCalledWith(); + }); + }); + describe('render', () => { + describe('with selected assignment', () => { + test('snapshot', () => { + expect(el).toMatchSnapshot(); + }); + it('renders a PercentGroup for both Max and Min filters', () => { + let props = el.find(PercentGroup).at(0).props(); + expect(props.value).toEqual(hookData.min.value); + expect(props.onChange).toEqual(hookData.min.onChange); + props = el.find(PercentGroup).at(1).props(); + expect(props.value).toEqual(hookData.max.value); + expect(props.onChange).toEqual(hookData.max.onChange); + }); + it('renders a submit button', () => { + const props = el.find(Button).props(); + expect(props.disabled).toEqual(false); + expect(props.onClick).toEqual(hookData.handleApplyClick); + }); + }); + describe('if disabled', () => { + beforeEach(() => { + useCourseGradeFilterData.mockReturnValueOnce({ ...hookData, isDisabled: true }); + el = shallow(); + }); + test('snapshot', () => { + expect(el).toMatchSnapshot(); + }); + it('disables submit', () => { + const props = el.find(Button).props(); + expect(props.disabled).toEqual(true); + }); + }); + }); +}); diff --git a/src/components/GradebookFilters/CourseGradeFilter/test.jsx b/src/components/GradebookFilters/CourseGradeFilter/test.jsx deleted file mode 100644 index af136e22..00000000 --- a/src/components/GradebookFilters/CourseGradeFilter/test.jsx +++ /dev/null @@ -1,150 +0,0 @@ -/* eslint-disable import/no-named-as-default */ - -import React from 'react'; -import { shallow } from 'enzyme'; - -import actions from 'data/actions'; -import selectors from 'data/selectors'; -import { fetchGrades } from 'data/thunkActions/grades'; -import { - CourseGradeFilter, - mapStateToProps, - mapDispatchToProps, -} from '.'; - -jest.mock('@edx/paragon', () => ({ - Button: () => 'Button', -})); -jest.mock('../PercentGroup', () => 'PercentGroup'); - -jest.mock('data/thunkActions/grades', () => ({ - fetchGrades: jest.fn(), -})); - -jest.mock('data/selectors', () => ({ - __esModule: true, - default: { - app: { - areCourseGradeFiltersValid: jest.fn(state => ({ areCourseGradeFiltersValid: state })), - courseGradeLimits: jest.fn(state => ({ courseGradeLimits: state })), - }, - }, -})); - -describe('CourseGradeFilter', () => { - let props = { - localCourseLimits: { - courseGradeMin: '5', - courseGradeMax: '92', - }, - areLimitsValid: true, - }; - - beforeEach(() => { - props = { - ...props, - fetchGrades: jest.fn(), - setLocalFilter: jest.fn(), - updateQueryParams: jest.fn(), - updateFilter: jest.fn(), - }; - }); - - describe('Component', () => { - describe('snapshots', () => { - test('basic snapshot', () => { - const el = shallow(); - el.instance().handleUpdateMin = jest.fn().mockName( - 'handleUpdateMin', - ); - el.instance().handleUpdateMax = jest.fn().mockName( - 'handleUpdateMax', - ); - el.instance().handleApplyClick = jest.fn().mockName( - 'handleApplyClick', - ); - expect(el.instance().render()).toMatchSnapshot(); - }); - }); - - describe('behavior', () => { - let el; - const testVal = 'TESTvalue'; - beforeEach(() => { - el = shallow(); - }); - describe('handleApplyClick', () => { - beforeEach(() => { - el.instance().updateCourseGradeFilters = jest.fn(); - }); - it('calls updateCourseGradeFilters is limits are valid', () => { - el.instance().handleApplyClick(); - expect(el.instance().updateCourseGradeFilters).toHaveBeenCalledWith(); - }); - it('does not call updateCourseGradeFilters if limits are not valid', () => { - el.setProps({ areLimitsValid: false }); - el.instance().handleApplyClick(); - expect(el.instance().updateCourseGradeFilters).not.toHaveBeenCalled(); - }); - }); - describe('updateCourseGradeFilters', () => { - beforeEach(() => { - el.instance().updateCourseGradeFilters(); - }); - it('calls props.updateFilter with selection', () => { - expect(props.updateFilter).toHaveBeenCalledWith(props.localCourseLimits); - }); - it('calls props.getUserGrades with selection', () => { - expect(props.fetchGrades).toHaveBeenCalledWith(); - }); - it('updates query params with courseGradeMin and courseGradeMax', () => { - expect(props.updateQueryParams).toHaveBeenCalledWith(props.localCourseLimits); - }); - }); - describe('handleUpdateMin', () => { - it('calls props.setCourseGradeMin with event value', () => { - el.instance().handleUpdateMin( - { target: { value: testVal } }, - ); - expect(props.setLocalFilter).toHaveBeenCalledWith({ - courseGradeMin: testVal, - }); - }); - }); - describe('handleUpdateMax', () => { - it('calls props.setCourseGradeMax with event value', () => { - el.instance().handleUpdateMax( - { target: { value: testVal } }, - ); - expect(props.setLocalFilter).toHaveBeenCalledWith({ - courseGradeMax: testVal, - }); - }); - }); - }); - }); - describe('mapStateToProps', () => { - const testState = { peanut: 'butter', jelly: 'time' }; - let mapped; - beforeEach(() => { - mapped = mapStateToProps(testState); - }); - test('areLimitsValid from app.areCourseGradeFiltersValid', () => { - expect(mapped.areLimitsValid).toEqual(selectors.app.areCourseGradeFiltersValid(testState)); - }); - test('localCourseLimits from app.courseGradeLimits', () => { - expect(mapped.localCourseLimits).toEqual(selectors.app.courseGradeLimits(testState)); - }); - }); - describe('mapDispatchToProps', () => { - test('fetchGrades from thunkActions.grades.fetchGrades', () => { - expect(mapDispatchToProps.fetchGrades).toEqual(fetchGrades); - }); - test('setLocalFilter from actions.app.setLocalFilter', () => { - expect(mapDispatchToProps.setLocalFilter).toEqual(actions.app.setLocalFilter); - }); - test('updateFilter from actions.filters.update.courseGradeLimits', () => { - expect(mapDispatchToProps.updateFilter).toEqual(actions.filters.update.courseGradeLimits); - }); - }); -}); diff --git a/src/components/GradebookFilters/StudentGroupsFilter/__snapshots__/index.test.jsx.snap b/src/components/GradebookFilters/StudentGroupsFilter/__snapshots__/index.test.jsx.snap new file mode 100644 index 00000000..d1fae6f2 --- /dev/null +++ b/src/components/GradebookFilters/StudentGroupsFilter/__snapshots__/index.test.jsx.snap @@ -0,0 +1,72 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`StudentGroupsFilter component render snapshot 1`] = ` + + + Track-All + , + , + , + , + , + ] + } + value="test-track" + /> + + Cohort-All + , + , + , + , + ] + } + value="test-cohort" + /> + +`; diff --git a/src/components/GradebookFilters/StudentGroupsFilter/__snapshots__/test.jsx.snap b/src/components/GradebookFilters/StudentGroupsFilter/__snapshots__/test.jsx.snap deleted file mode 100644 index 0e07561c..00000000 --- a/src/components/GradebookFilters/StudentGroupsFilter/__snapshots__/test.jsx.snap +++ /dev/null @@ -1,190 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`StudentGroupsFilter Component snapshots Cohorts group disabled if no cohorts 1`] = ` - - - Track-All - , - , - , - , - ] - } - value="TracK2" - /> - - Cohort-All - , - ] - } - value="cohorT3" - /> - -`; - -exports[`StudentGroupsFilter Component snapshots basic snapshot 1`] = ` - - - Track-All - , - , - , - , - ] - } - value="TracK2" - /> - - Cohort-All - , - , - , - , - ] - } - value="cohorT3" - /> - -`; - -exports[`StudentGroupsFilter Component snapshots mapCohortsEntries cohort options: [Cohort-All, <{slug, name}...>] 1`] = ` -Array [ - , - , - , - , -] -`; - -exports[`StudentGroupsFilter Component snapshots mapTracksEntries cohort options: [Track-All, <{id, name}...>] 1`] = ` -Array [ - , - , - , - , -] -`; - -exports[`StudentGroupsFilter optionFactory returns a list of options with a default first entry 1`] = ` -Array [ - , - , - , -] -`; diff --git a/src/components/GradebookFilters/StudentGroupsFilter/hooks.js b/src/components/GradebookFilters/StudentGroupsFilter/hooks.js new file mode 100644 index 00000000..d304b56c --- /dev/null +++ b/src/components/GradebookFilters/StudentGroupsFilter/hooks.js @@ -0,0 +1,45 @@ +import { actions, selectors, thunkActions } from 'data/redux/hooks'; + +export const useStudentGroupsFilterData = ({ updateQueryParams }) => { + const selectedCohortEntry = selectors.root.useSelectedCohortEntry(); + const selectedTrackEntry = selectors.root.useSelectedTrackEntry(); + + const cohorts = selectors.cohorts.useAllCohorts(); + const tracks = selectors.tracks.useAllTracks(); + + const updateCohort = actions.filters.useUpdateCohort(); + const updateTrack = actions.filters.useUpdateTrack(); + + const fetchGrades = thunkActions.grades.useFetchGrades(); + + const handleUpdateTrack = (event) => { + const selectedTrackItem = tracks.find(track => track.name === event.target.value); + const track = selectedTrackItem ? selectedTrackItem.slug.toString() : null; + updateQueryParams({ track }); + updateTrack(track); + fetchGrades(); + }; + + const handleUpdateCohort = (event) => { + const selectedCohortItem = cohorts.find(cohort => cohort.name === event.target.value); + const cohort = selectedCohortItem ? selectedCohortItem.id.toString() : null; + updateQueryParams({ cohort }); + updateCohort(cohort); + fetchGrades(); + }; + return { + cohorts: { + value: selectedCohortEntry?.name || '', + isDisabled: cohorts.length === 0, + handleChange: handleUpdateCohort, + entries: cohorts.map(({ id: value, name }) => ({ value, name })), + }, + tracks: { + value: selectedTrackEntry?.name || '', + handleChange: handleUpdateTrack, + entries: tracks.map(({ slug: value, name }) => ({ value, name })), + }, + }; +}; + +export default useStudentGroupsFilterData; diff --git a/src/components/GradebookFilters/StudentGroupsFilter/hooks.test.js b/src/components/GradebookFilters/StudentGroupsFilter/hooks.test.js new file mode 100644 index 00000000..6759f8e5 --- /dev/null +++ b/src/components/GradebookFilters/StudentGroupsFilter/hooks.test.js @@ -0,0 +1,141 @@ +import { selectors, actions, thunkActions } from 'data/redux/hooks'; + +import useAssignmentFilterData from './hooks'; + +jest.mock('data/redux/hooks', () => ({ + selectors: { + root: { + useSelectedCohortEntry: jest.fn(), + useSelectedTrackEntry: jest.fn(), + }, + cohorts: { useAllCohorts: jest.fn() }, + tracks: { useAllTracks: jest.fn() }, + }, + actions: { + filters: { + useUpdateCohort: jest.fn(), + useUpdateTrack: jest.fn(), + }, + }, + thunkActions: { + grades: { useFetchGrades: jest.fn() }, + }, +})); + +let out; + +const testCohort = { name: 'cohort-name', id: 999 }; +selectors.root.useSelectedCohortEntry.mockReturnValue(testCohort); +const testTrack = { name: 'track-name', slug: 8080 }; +selectors.root.useSelectedTrackEntry.mockReturnValue(testTrack); +const allCohorts = [ + testCohort, + { name: 'cohort1', id: 11 }, + { name: 'cohort2', id: 22 }, + { name: 'cohort3', id: 33 }, +]; +selectors.cohorts.useAllCohorts.mockReturnValue(allCohorts); +const allTracks = [ + testTrack, + { name: 'track1', slug: 111 }, + { name: 'track2', slug: 222 }, + { name: 'track3', slug: 333 }, +]; +selectors.tracks.useAllTracks.mockReturnValue(allTracks); + +const updateCohort = jest.fn(); +actions.filters.useUpdateCohort.mockReturnValue(updateCohort); +const updateTrack = jest.fn(); +actions.filters.useUpdateTrack.mockReturnValue(updateTrack); +const fetch = jest.fn(); +thunkActions.grades.useFetchGrades.mockReturnValue(fetch); + +const updateQueryParams = jest.fn(); + +describe('useAssignmentFilterData hook', () => { + beforeEach(() => { + jest.clearAllMocks(); + out = useAssignmentFilterData({ updateQueryParams }); + }); + describe('behavior', () => { + it('initializes redux hooks', () => { + expect(selectors.root.useSelectedCohortEntry).toHaveBeenCalledWith(); + expect(selectors.root.useSelectedTrackEntry).toHaveBeenCalledWith(); + expect(selectors.cohorts.useAllCohorts).toHaveBeenCalledWith(); + expect(selectors.tracks.useAllTracks).toHaveBeenCalledWith(); + expect(actions.filters.useUpdateCohort).toHaveBeenCalledWith(); + expect(actions.filters.useUpdateTrack).toHaveBeenCalledWith(); + expect(thunkActions.grades.useFetchGrades).toHaveBeenCalledWith(); + }); + }); + describe('output', () => { + describe('cohorts', () => { + test('value from hook', () => { + expect(out.cohorts.value).toEqual(testCohort.name); + }); + test('disabled iff no cohorts found', () => { + expect(out.cohorts.isDisabled).toEqual(false); + selectors.cohorts.useAllCohorts.mockReturnValueOnce([]); + out = useAssignmentFilterData({ updateQueryParams }); + expect(out.cohorts.isDisabled).toEqual(true); + }); + test('entries map id to value', () => { + const { entries } = out.cohorts; + expect(entries[0]).toEqual({ value: testCohort.id, name: testCohort.name }); + expect(entries[1]).toEqual({ value: allCohorts[1].id, name: allCohorts[1].name }); + expect(entries[2]).toEqual({ value: allCohorts[2].id, name: allCohorts[2].name }); + expect(entries[3]).toEqual({ value: allCohorts[3].id, name: allCohorts[3].name }); + }); + test('value defaults to empty string', () => { + selectors.root.useSelectedCohortEntry.mockReturnValueOnce(null); + out = useAssignmentFilterData({ updateQueryParams }); + expect(out.cohorts.value).toEqual(''); + }); + describe('handleEvent', () => { + it('updates filter and query params and fetches grades', () => { + out.cohorts.handleChange({ target: { value: testCohort.name } }); + expect(updateCohort).toHaveBeenCalledWith(testCohort.id.toString()); + expect(updateQueryParams).toHaveBeenCalledWith({ cohort: testCohort.id.toString() }); + expect(fetch).toHaveBeenCalled(); + }); + it('passes null if no matching track is found', () => { + out.cohorts.handleChange({ target: { value: 'fake-name' } }); + expect(updateCohort).toHaveBeenCalledWith(null); + expect(updateQueryParams).toHaveBeenCalledWith({ cohort: null }); + expect(fetch).toHaveBeenCalled(); + }); + }); + }); + describe('tracks', () => { + test('value from hook', () => { + expect(out.tracks.value).toEqual(testTrack.name); + }); + test('entries map slug to value', () => { + const { entries } = out.tracks; + expect(entries[0]).toEqual({ value: testTrack.slug, name: testTrack.name }); + expect(entries[1]).toEqual({ value: allTracks[1].slug, name: allTracks[1].name }); + expect(entries[2]).toEqual({ value: allTracks[2].slug, name: allTracks[2].name }); + expect(entries[3]).toEqual({ value: allTracks[3].slug, name: allTracks[3].name }); + }); + test('value defaults to empty string', () => { + selectors.root.useSelectedTrackEntry.mockReturnValueOnce(null); + out = useAssignmentFilterData({ updateQueryParams }); + expect(out.tracks.value).toEqual(''); + }); + describe('handleEvent', () => { + it('updates filter and query params and fetches grades', () => { + out.tracks.handleChange({ target: { value: testTrack.name } }); + expect(updateTrack).toHaveBeenCalledWith(testTrack.slug.toString()); + expect(updateQueryParams).toHaveBeenCalledWith({ track: testTrack.slug.toString() }); + expect(fetch).toHaveBeenCalled(); + }); + it('passes null if no matching track is found', () => { + out.tracks.handleChange({ target: { value: 'fake-name' } }); + expect(updateTrack).toHaveBeenCalledWith(null); + expect(updateQueryParams).toHaveBeenCalledWith({ track: null }); + expect(fetch).toHaveBeenCalled(); + }); + }); + }); + }); +}); diff --git a/src/components/GradebookFilters/StudentGroupsFilter/index.jsx b/src/components/GradebookFilters/StudentGroupsFilter/index.jsx index e1fd0c70..8ae31112 100644 --- a/src/components/GradebookFilters/StudentGroupsFilter/index.jsx +++ b/src/components/GradebookFilters/StudentGroupsFilter/index.jsx @@ -1,152 +1,53 @@ /* eslint-disable react/sort-comp, react/button-has-type, import/no-named-as-default */ import React from 'react'; -import { connect } from 'react-redux'; import PropTypes from 'prop-types'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; - -import actions from 'data/actions'; -import selectors from 'data/selectors'; -import thunkActions from 'data/thunkActions'; +import { useIntl } from '@edx/frontend-platform/i18n'; import messages from '../messages'; import SelectGroup from '../SelectGroup'; - -export const optionFactory = ({ data, defaultOption, key }) => [ - , - ...data.map( - entry => (), - ), -]; - -export class StudentGroupsFilter extends React.Component { - constructor(props) { - super(props); - this.mapCohortsEntries = this.mapCohortsEntries.bind(this); - this.mapTracksEntries = this.mapTracksEntries.bind(this); - this.updateCohorts = this.updateCohorts.bind(this); - this.updateTracks = this.updateTracks.bind(this); - } - - mapCohortsEntries() { - return optionFactory({ - data: this.props.cohorts, - defaultOption: this.translate(messages.cohortAll), - key: 'id', - }); - } - - mapTracksEntries() { - return optionFactory({ - data: this.props.tracks, - defaultOption: this.translate(messages.trackAll), - key: 'slug', - }); - } - - selectedTrackSlugFromEvent({ target: { value } }) { - const selectedTrackItem = this.props.tracksByName[value]; - return selectedTrackItem ? selectedTrackItem.slug : null; - } - - selectedCohortIdFromEvent({ target: { value } }) { - const selectedCohortItem = this.props.cohortsByName[value]; - return selectedCohortItem ? selectedCohortItem.id.toString() : null; - } - - updateTracks(event) { - const track = this.selectedTrackSlugFromEvent(event); - this.props.updateQueryParams({ track }); - this.props.updateTrack(track); - this.props.fetchGrades(); - } - - updateCohorts(event) { - const cohort = this.selectedCohortIdFromEvent(event); - this.props.updateQueryParams({ cohort }); - this.props.updateCohort(cohort); - this.props.fetchGrades(); - } - - translate(message) { - return this.props.intl.formatMessage(message); - } - - render() { - return ( - <> - - - - ); - } -} - -StudentGroupsFilter.defaultProps = { - cohorts: [], - cohortsByName: {}, - selectedCohortEntry: { name: '' }, - selectedTrackEntry: { name: '' }, - tracks: [], - tracksByName: {}, +import useStudentGroupsFilterData from './hooks'; + +const mapOptions = ({ value, name }) => ( + +); + +export const StudentGroupsFilter = ({ updateQueryParams }) => { + const { tracks, cohorts } = useStudentGroupsFilterData({ updateQueryParams }); + const { formatMessage } = useIntl(); + return ( + <> + + {formatMessage(messages.trackAll)} + , + ...tracks.entries.map(mapOptions), + ]} + /> + + {formatMessage(messages.cohortAll)} + , + ...cohorts.entries.map(mapOptions), + ]} + /> + + ); }; StudentGroupsFilter.propTypes = { updateQueryParams: PropTypes.func.isRequired, - - // injected - intl: intlShape.isRequired, - - // redux - cohorts: PropTypes.arrayOf(PropTypes.shape({ - name: PropTypes.string, - id: PropTypes.number, - })), - cohortsByName: PropTypes.objectOf(PropTypes.shape({ - name: PropTypes.string, - id: PropTypes.number, - })), - fetchGrades: PropTypes.func.isRequired, - selectedTrackEntry: PropTypes.shape({ name: PropTypes.string }), - selectedCohortEntry: PropTypes.shape({ name: PropTypes.string }), - tracks: PropTypes.arrayOf(PropTypes.shape({ - name: PropTypes.string, - slug: PropTypes.string, - })), - tracksByName: PropTypes.objectOf(PropTypes.shape({ - name: PropTypes.string, - slug: PropTypes.string, - })), - updateCohort: PropTypes.func.isRequired, - updateTrack: PropTypes.func.isRequired, -}; - -export const mapStateToProps = (state) => ({ - cohorts: selectors.cohorts.allCohorts(state), - cohortsByName: selectors.cohorts.cohortsByName(state), - selectedCohortEntry: selectors.root.selectedCohortEntry(state), - selectedTrackEntry: selectors.root.selectedTrackEntry(state), - tracks: selectors.tracks.allTracks(state), - tracksByName: selectors.tracks.tracksByName(state), -}); - -export const mapDispatchToProps = { - fetchGrades: thunkActions.grades.fetchGrades, - updateCohort: actions.filters.update.cohort, - updateTrack: actions.filters.update.track, }; -export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(StudentGroupsFilter)); +export default StudentGroupsFilter; diff --git a/src/components/GradebookFilters/StudentGroupsFilter/index.test.jsx b/src/components/GradebookFilters/StudentGroupsFilter/index.test.jsx new file mode 100644 index 00000000..ee339d4a --- /dev/null +++ b/src/components/GradebookFilters/StudentGroupsFilter/index.test.jsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import SelectGroup from '../SelectGroup'; +import useStudentGroupsFilterData from './hooks'; +import StudentGroupsFilter from '.'; + +jest.mock('../SelectGroup', () => 'SelectGroup'); +jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() })); + +const props = { + cohorts: { + value: 'test-cohort', + entries: [ + { value: 'v1', name: 'n1' }, + { value: 'v2', name: 'n2' }, + { value: 'v3', name: 'n3' }, + ], + handleChange: jest.fn(), + isDisabled: false, + }, + tracks: { + value: 'test-track', + entries: [ + { value: 'v1', name: 'n1' }, + { value: 'v2', name: 'n2' }, + { value: 'v3', name: 'n3' }, + { value: 'v4', name: 'n4' }, + ], + handleChange: jest.fn(), + }, +}; +useStudentGroupsFilterData.mockReturnValue(props); +const updateQueryParams = jest.fn(); + +let el; +describe('StudentGroupsFilter component', () => { + beforeAll(() => { + jest.clearAllMocks(); + el = shallow(); + }); + describe('behavior', () => { + it('initializes hooks', () => { + expect(useStudentGroupsFilterData).toHaveBeenCalledWith({ updateQueryParams }); + expect(useIntl).toHaveBeenCalledWith(); + }); + }); + describe('render', () => { + test('snapshot', () => { + expect(el).toMatchSnapshot(); + }); + test('track options', () => { + const { + options, + onChange, + value, + } = el.find(SelectGroup).at(0).props(); + expect(value).toEqual(props.tracks.value); + expect(onChange).toEqual(props.tracks.handleChange); + expect(options.length).toEqual(5); + const testEntry = props.tracks.entries[0]; + const optionProps = options[1].props; + expect(optionProps.value).toEqual(testEntry.value); + expect(optionProps.children).toEqual(testEntry.name); + }); + test('cohort options', () => { + const { + options, + onChange, + disabled, + value, + } = el.find(SelectGroup).at(1).props(); + expect(value).toEqual(props.cohorts.value); + expect(disabled).toEqual(false); + expect(onChange).toEqual(props.cohorts.handleChange); + expect(options.length).toEqual(4); + const testEntry = props.cohorts.entries[0]; + const optionProps = options[1].props; + expect(optionProps.value).toEqual(testEntry.value); + expect(optionProps.children).toEqual(testEntry.name); + }); + }); +}); diff --git a/src/components/GradebookFilters/StudentGroupsFilter/test.jsx b/src/components/GradebookFilters/StudentGroupsFilter/test.jsx deleted file mode 100644 index 38f5c62c..00000000 --- a/src/components/GradebookFilters/StudentGroupsFilter/test.jsx +++ /dev/null @@ -1,239 +0,0 @@ -/* eslint-disable import/no-named-as-default */ - -import React from 'react'; -import { shallow } from 'enzyme'; - -import { fetchGrades } from 'data/thunkActions/grades'; -import actions from 'data/actions'; -import selectors from 'data/selectors'; -import { - optionFactory, - StudentGroupsFilter, - mapStateToProps, - mapDispatchToProps, -} from '.'; - -jest.mock('data/selectors', () => ({ - __esModule: true, - default: { - root: { - selectedCohortEntry: jest.fn(state => ({ selectedCohortEntry: state })), - selectedTrackEntry: jest.fn(state => ({ selectedTrackEntry: state })), - }, - cohorts: { - allCohorts: jest.fn(state => ({ allCohorts: state })), - cohortsByName: jest.fn(state => ({ cohortsByName: state })), - }, - tracks: { - allTracks: jest.fn(state => ({ allTracks: state })), - tracksByName: jest.fn(state => ({ tracksByName: state })), - }, - }, -})); - -jest.mock('data/thunkActions/grades', () => ({ - fetchGrades: jest.fn(), -})); - -describe('StudentGroupsFilter', () => { - let props = { - cohorts: [ - { name: 'cohorT1', id: 8001 }, - { name: 'cohorT2', id: 8002 }, - { name: 'cohorT3', id: 8003 }, - ], - tracks: [ - { name: 'TracK1', slug: 'TracK1_slug' }, - { name: 'TracK2', slug: 'TracK2_slug' }, - { name: 'TRACK3', slug: 'TRACK3_slug' }, - ], - }; - - describe('optionFactory', () => { - it('returns a list of options with a default first entry', () => { - const data = [{ cMark: 'rainbow', name: 'RDash' }, { cMark: 'balloons', name: 'PPie' }]; - const defaultOption = 'All-Ponies'; - const key = 'cMark'; - const options = optionFactory({ data, defaultOption, key }); - expect(options).toMatchSnapshot(); - }); - }); - - describe('Component', () => { - beforeEach(() => { - props = { - ...props, - intl: { formatMessage: (msg) => msg.defaultMessage }, - cohortsByName: { - [props.cohorts[0].name]: props.cohorts[0], - [props.cohorts[1].name]: props.cohorts[1], - [props.cohorts[2].name]: props.cohorts[2], - }, - tracksByName: { - [props.tracks[0].name]: props.tracks[0], - [props.tracks[1].name]: props.tracks[1], - [props.tracks[2].name]: props.tracks[2], - }, - fetchGrades: jest.fn(), - selectedCohortEntry: props.cohorts[2], - selectedTrackEntry: props.tracks[1], - updateQueryParams: jest.fn(), - updateCohort: jest.fn().mockName('updateCohort'), - updateTrack: jest.fn().mockName('updateTrack'), - }; - }); - - describe('snapshots', () => { - let el; - beforeEach(() => { - el = shallow(); - }); - test('basic snapshot', () => { - el.instance().updateTracks = jest.fn().mockName( - 'updateTracks', - ); - el.instance().updateCohorts = jest.fn().mockName( - 'updateCohorts', - ); - expect(el.instance().render()).toMatchSnapshot(); - }); - test('Cohorts group disabled if no cohorts', () => { - el.setProps({ cohorts: [] }); - expect(el.instance().render()).toMatchSnapshot(); - }); - describe('mapCohortsEntries', () => { - test('cohort options: [Cohort-All, <{slug, name}...>]', () => { - expect(el.instance().mapCohortsEntries()).toMatchSnapshot(); - }); - }); - describe('mapTracksEntries', () => { - test('cohort options: [Track-All, <{id, name}...>]', () => { - expect(el.instance().mapTracksEntries()).toMatchSnapshot(); - }); - }); - }); - - describe('behavior', () => { - let el; - beforeEach(() => { - el = shallow(); - }); - describe('selectedCohortIdFromEvent', () => { - it('returns the id of the cohort with the name matching the event', () => { - expect( - el.instance().selectedCohortIdFromEvent( - { target: { value: props.cohorts[1].name } }, - ), - ).toEqual(props.cohorts[1].id.toString()); - }); - it('returns null if no matching cohort is found', () => { - expect( - el.instance().selectedCohortIdFromEvent( - { target: { value: 'FAKE' } }, - ), - ).toEqual(null); - }); - }); - describe('selectedTrackSlugFromEvent', () => { - it('returns the slug of the track with the name matching the event', () => { - expect( - el.instance().selectedTrackSlugFromEvent( - { target: { value: props.tracks[1].name } }, - ), - ).toEqual(props.tracks[1].slug); - }); - it('returns null if no matching track is found', () => { - expect( - el.instance().selectedTrackSlugFromEvent( - { target: { value: 'FAKE' } }, - ), - ).toEqual(null); - }); - }); - describe('updateTracks', () => { - const selectedSlug = 'SLUG'; - beforeEach(() => { - el = shallow(); - jest.spyOn( - el.instance(), - 'selectedTrackSlugFromEvent', - ).mockReturnValue(selectedSlug); - el.instance().updateTracks({ target: {} }); - }); - it('calls updateTrack with new value', () => { - expect(props.updateTrack).toHaveBeenCalledWith(selectedSlug); - }); - it('calls fetchGrades', () => { - expect(props.fetchGrades).toHaveBeenCalledWith(); - }); - it('updates queryParams with track value', () => { - expect(props.updateQueryParams).toHaveBeenCalledWith({ - track: selectedSlug, - }); - }); - }); - describe('updateCohorts', () => { - const selectedId = 23; - beforeEach(() => { - el = shallow(); - jest.spyOn( - el.instance(), - 'selectedCohortIdFromEvent', - ).mockReturnValue(selectedId); - el.instance().updateCohorts({ target: {} }); - }); - it('calls updateCohort with new value', () => { - expect(props.updateCohort).toHaveBeenCalledWith(selectedId); - }); - it('calls fetchGrades', () => { - expect(props.fetchGrades).toHaveBeenCalledWith(); - }); - it('updates queryParams with cohort value', () => { - expect(props.updateQueryParams).toHaveBeenCalledWith({ - cohort: selectedId, - }); - }); - }); - }); - }); - describe('mapStateToProps', () => { - const testState = { h: 'e', l: 'l', o: 'oooooooooo' }; - let mappedProps; - beforeAll(() => { - mappedProps = mapStateToProps(testState); - }); - test('cohorts from selectors.cohorts.allCohorts', () => { - expect(mappedProps.cohorts).toEqual(selectors.cohorts.allCohorts(testState)); - }); - test('cohortsByName from selectors.cohorts.cohortsByName', () => { - expect(mappedProps.cohortsByName).toEqual(selectors.cohorts.cohortsByName(testState)); - }); - test('selectedCohortEntry from selectors.root.selectedCohortEntry', () => { - expect( - mappedProps.selectedCohortEntry, - ).toEqual(selectors.root.selectedCohortEntry(testState)); - }); - test('selectedTrackEntry from selectors.root.selectedTrackEntry', () => { - expect( - mappedProps.selectedTrackEntry, - ).toEqual(selectors.root.selectedTrackEntry(testState)); - }); - test('tracks from selectors.tracks.allTracks', () => { - expect(mappedProps.tracks).toEqual(selectors.tracks.allTracks(testState)); - }); - test('tracksByName from selectors.tracks.tracksByName', () => { - expect(mappedProps.tracksByName).toEqual(selectors.tracks.tracksByName(testState)); - }); - }); - describe('mapDispatchToProps', () => { - test('fetchGrades from thunkActions.grades.fetchGrades', () => { - expect(mapDispatchToProps.fetchGrades).toEqual(fetchGrades); - }); - test('updateCohort from actions.filters.update.cohort', () => { - expect(mapDispatchToProps.updateCohort).toEqual(actions.filters.update.cohort); - }); - test('updateTrack from actions.filters.update.track', () => { - expect(mapDispatchToProps.updateTrack).toEqual(actions.filters.update.track); - }); - }); -}); diff --git a/src/components/GradebookFilters/__snapshots__/PercentGroup.test.jsx.snap b/src/components/GradebookFilters/__snapshots__/PercentGroup.test.jsx.snap index b22762b4..c0958d27 100644 --- a/src/components/GradebookFilters/__snapshots__/PercentGroup.test.jsx.snap +++ b/src/components/GradebookFilters/__snapshots__/PercentGroup.test.jsx.snap @@ -4,29 +4,22 @@ exports[`PercentGroup Component snapshots basic snapshot 1`] = `
- - + Group Label - - + - + @@ -39,29 +32,22 @@ exports[`PercentGroup Component snapshots disabled 1`] = `
- - + Group Label - - + - + diff --git a/src/components/GradebookFilters/__snapshots__/SelectGroup.test.jsx.snap b/src/components/GradebookFilters/__snapshots__/SelectGroup.test.jsx.snap index 7aab3c2d..07a5fc0f 100644 --- a/src/components/GradebookFilters/__snapshots__/SelectGroup.test.jsx.snap +++ b/src/components/GradebookFilters/__snapshots__/SelectGroup.test.jsx.snap @@ -4,22 +4,16 @@ exports[`SelectGroup Component snapshots basic snapshot 1`] = `
- - + Group Label - - + - - + +
`; @@ -49,22 +43,16 @@ exports[`SelectGroup Component snapshots disabled 1`] = `
- - + Group Label - - + - - + +
`; diff --git a/src/components/GradebookFilters/__snapshots__/index.test.jsx.snap b/src/components/GradebookFilters/__snapshots__/index.test.jsx.snap new file mode 100644 index 00000000..4ee3e950 --- /dev/null +++ b/src/components/GradebookFilters/__snapshots__/index.test.jsx.snap @@ -0,0 +1,70 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`GradebookFilters render snapshot 1`] = ` + +
+

+ +

+ +
+ +
+ + + +
+
+ + + + + + + + + Include Course Team Members + + +
+`; diff --git a/src/components/GradebookFilters/__snapshots__/test.jsx.snap b/src/components/GradebookFilters/__snapshots__/test.jsx.snap deleted file mode 100644 index 50b4834a..00000000 --- a/src/components/GradebookFilters/__snapshots__/test.jsx.snap +++ /dev/null @@ -1,98 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`GradebookFilters Component snapshots basic snapshot 1`] = ` - -
-

- -

- -
- - } - > -
- - - -
-
- - } - > - - - - } - > - - - - } - > - - - - -
-`; diff --git a/src/components/GradebookFilters/hooks.js b/src/components/GradebookFilters/hooks.js new file mode 100644 index 00000000..e55a7180 --- /dev/null +++ b/src/components/GradebookFilters/hooks.js @@ -0,0 +1,36 @@ +import React from 'react'; +import { StrictDict } from 'utils'; +import { actions, selectors, thunkActions } from 'data/redux/hooks'; +import * as module from './hooks'; + +export const state = StrictDict({ + includeCourseRoleMembers: (val) => React.useState(val), // eslint-disable-line +}); + +export const useGradebookFiltersData = ({ updateQueryParams }) => { + const includeCourseRoleMembers = selectors.filters.useIncludeCourseRoleMembers(); + const updateIncludeCourseRoleMembers = actions.filters.useUpdateIncludeCourseRoleMembers(); + const closeMenu = thunkActions.app.useCloseFilterMenu(); + const fetchGrades = thunkActions.grades.useFetchGrades(); + + const [ + localIncludeCourseRoleMembers, + setLocalIncludeCourseRoleMembers, + ] = module.state.includeCourseRoleMembers(includeCourseRoleMembers); + + const handleIncludeTeamMembersChange = ({ target: { checked } }) => { + setLocalIncludeCourseRoleMembers(checked); + updateIncludeCourseRoleMembers(checked); + fetchGrades(); + updateQueryParams({ includeCourseRoleMembers: checked }); + }; + return { + closeMenu, + includeCourseTeamMembers: { + handleChange: handleIncludeTeamMembersChange, + value: localIncludeCourseRoleMembers, + }, + }; +}; + +export default useGradebookFiltersData; diff --git a/src/components/GradebookFilters/hooks.test.jsx b/src/components/GradebookFilters/hooks.test.jsx new file mode 100644 index 00000000..de44b0fa --- /dev/null +++ b/src/components/GradebookFilters/hooks.test.jsx @@ -0,0 +1,65 @@ +import { MockUseState } from 'testUtils'; +import { actions, selectors, thunkActions } from 'data/redux/hooks'; +import * as hooks from './hooks'; + +jest.mock('data/redux/hooks', () => ({ + actions: { + filters: { useUpdateIncludeCourseRoleMembers: jest.fn() }, + }, + selectors: { + filters: { useIncludeCourseRoleMembers: jest.fn() }, + }, + thunkActions: { + app: { useCloseFilterMenu: jest.fn() }, + grades: { useFetchGrades: jest.fn() }, + }, +})); + +const state = new MockUseState(hooks); + +selectors.filters.useIncludeCourseRoleMembers.mockReturnValue(true); +const updateIncludeCourseRoleMembers = jest.fn(); +actions.filters.useUpdateIncludeCourseRoleMembers.mockReturnValue(updateIncludeCourseRoleMembers); +const closeFilterMenu = jest.fn(); +thunkActions.app.useCloseFilterMenu.mockReturnValue(closeFilterMenu); +const fetchGrades = jest.fn(); +thunkActions.grades.useFetchGrades.mockReturnValue(fetchGrades); + +const updateQueryParams = jest.fn(); + +let out; +describe('GradebookFiltersData component hooks', () => { + describe('state', () => { + state.testGetter(state.keys.includeCourseRoleMembers); + }); + describe('useGradebookFiltersData', () => { + beforeEach(() => { + state.mock(); + out = hooks.useGradebookFiltersData({ updateQueryParams }); + }); + describe('behavior', () => { + it('initializes hooks', () => { + expect(actions.filters.useUpdateIncludeCourseRoleMembers).toHaveBeenCalledWith(); + expect(selectors.filters.useIncludeCourseRoleMembers).toHaveBeenCalledWith(); + expect(thunkActions.app.useCloseFilterMenu).toHaveBeenCalledWith(); + expect(thunkActions.grades.useFetchGrades).toHaveBeenCalledWith(); + }); + }); + describe('output', () => { + test('closeMenu', () => { + expect(out.closeMenu).toEqual(closeFilterMenu); + }); + test('includeCourseTeamMembers value', () => { + expect(out.includeCourseTeamMembers.value).toEqual(true); + }); + test('includeCourseTeamMembers handleChange', () => { + const event = { target: { checked: false } }; + out.includeCourseTeamMembers.handleChange(event); + expect(state.setState.includeCourseRoleMembers).toHaveBeenCalledWith(false); + expect(updateIncludeCourseRoleMembers).toHaveBeenCalledWith(false); + expect(fetchGrades).toHaveBeenCalledWith(); + expect(updateQueryParams).toHaveBeenCalledWith({ includeCourseRoleMembers: false }); + }); + }); + }); +}); diff --git a/src/components/GradebookFilters/index.jsx b/src/components/GradebookFilters/index.jsx index f0ef59fe..74de63d9 100644 --- a/src/components/GradebookFilters/index.jsx +++ b/src/components/GradebookFilters/index.jsx @@ -1,7 +1,5 @@ -/* eslint-disable react/sort-comp, import/no-named-as-default */ import React from 'react'; import PropTypes from 'prop-types'; -import { connect } from 'react-redux'; import { Collapsible, @@ -10,11 +8,7 @@ import { Form, } from '@edx/paragon'; import { Close } from '@edx/paragon/icons'; -import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n'; - -import actions from 'data/actions'; -import selectors from 'data/selectors'; -import thunkActions from 'data/thunkActions'; +import { useIntl } from '@edx/frontend-platform/i18n'; import messages from './messages'; import AssignmentTypeFilter from './AssignmentTypeFilter'; @@ -22,111 +16,74 @@ import AssignmentFilter from './AssignmentFilter'; import AssignmentGradeFilter from './AssignmentGradeFilter'; import CourseGradeFilter from './CourseGradeFilter'; import StudentGroupsFilter from './StudentGroupsFilter'; +import useGradebookFiltersData from './hooks'; -export class GradebookFilters extends React.Component { - constructor(props) { - super(props); - this.state = { - includeCourseRoleMembers: this.props.includeCourseRoleMembers, - }; - this.handleIncludeTeamMembersChange = this.handleIncludeTeamMembersChange.bind(this); - } - - handleIncludeTeamMembersChange(event) { - const includeCourseRoleMembers = event.target.checked; - this.setState({ includeCourseRoleMembers }); - this.props.updateIncludeCourseRoleMembers(includeCourseRoleMembers); - this.props.fetchGrades(); - this.props.updateQueryParams({ includeCourseRoleMembers }); - } +export const GradebookFilters = ({ updateQueryParams }) => { + const { + closeMenu, + includeCourseTeamMembers, + } = useGradebookFiltersData({ updateQueryParams }); + const { formatMessage } = useIntl(); + const collapsibleClassName = 'filter-group mb-3'; + return ( + <> +
+

+ +
- collapsibleGroup = (title, content) => ( - } - defaultOpen - className="filter-group mb-3" - > - {content} - - ); - - render() { - const { - intl, - updateQueryParams, - } = this.props; - return ( - <> -
-

- + +
+ + +
+
- {this.collapsibleGroup( - messages.assignments, ( -
- - - -
- ), - )} - - {this.collapsibleGroup( - messages.overallGrade, ( - - ), - )} + + + - {this.collapsibleGroup( - messages.studentGroups, ( - - ), - )} + + + - {this.collapsibleGroup( - messages.includeCourseTeamMembers, ( - - - - ), - )} - - ); - } -} -GradebookFilters.defaultProps = { - includeCourseRoleMembers: false, + + + {formatMessage(messages.includeCourseTeamMembers)} + + + + ); }; GradebookFilters.propTypes = { updateQueryParams: PropTypes.func.isRequired, - // injected - intl: intlShape.isRequired, - // redux - closeMenu: PropTypes.func.isRequired, - fetchGrades: PropTypes.func.isRequired, - includeCourseRoleMembers: PropTypes.bool, - updateIncludeCourseRoleMembers: PropTypes.func.isRequired, -}; - -export const mapStateToProps = (state) => ({ - includeCourseRoleMembers: selectors.filters.includeCourseRoleMembers(state), -}); - -export const mapDispatchToProps = { - closeMenu: thunkActions.app.filterMenu.close, - fetchGrades: thunkActions.grades.fetchGrades, - updateIncludeCourseRoleMembers: actions.filters.update.includeCourseRoleMembers, }; -export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(GradebookFilters)); +export default GradebookFilters; diff --git a/src/components/GradebookFilters/index.test.jsx b/src/components/GradebookFilters/index.test.jsx new file mode 100644 index 00000000..be5235a4 --- /dev/null +++ b/src/components/GradebookFilters/index.test.jsx @@ -0,0 +1,82 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Collapsible } from '@edx/paragon'; + +import { formatMessage } from 'testUtils'; + +import AssignmentTypeFilter from './AssignmentTypeFilter'; +import AssignmentFilter from './AssignmentFilter'; +import AssignmentGradeFilter from './AssignmentGradeFilter'; +import CourseGradeFilter from './CourseGradeFilter'; +import StudentGroupsFilter from './StudentGroupsFilter'; +import messages from './messages'; + +import useGradebookFiltersData from './hooks'; +import GradebookFilters from '.'; + +jest.mock('./AssignmentTypeFilter', () => 'AssignmentTypeFilter'); +jest.mock('./AssignmentFilter', () => 'AssignmentFilter'); +jest.mock('./AssignmentGradeFilter', () => 'AssignmentGradeFilter'); +jest.mock('./CourseGradeFilter', () => 'CourseGradeFilter'); +jest.mock('./StudentGroupsFilter', () => 'StudentGroupsFilter'); + +jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() })); + +const hookProps = { + closeMenu: jest.fn().mockName('hook.closeMenu'), + includeCourseTeamMembers: { + value: true, + handleChange: jest.fn().mockName('hook.handleChange'), + }, +}; +useGradebookFiltersData.mockReturnValue(hookProps); + +let el; +const updateQueryParams = jest.fn(); + +describe('GradebookFilters', () => { + beforeEach(() => { + jest.clearAllMocks(); + el = shallow(); + }); + describe('behavior', () => { + it('initializes hooks', () => { + expect(useGradebookFiltersData).toHaveBeenCalledWith({ updateQueryParams }); + expect(useIntl).toHaveBeenCalledWith(); + }); + }); + describe('render', () => { + test('snapshot', () => { + expect(el).toMatchSnapshot(); + }); + test('Assignment filters', () => { + expect(el.find(Collapsible).at(0).children()).toMatchObject(shallow( +
+ + + +
, + )); + }); + test('CourseGrade filters', () => { + expect(el.find(Collapsible).at(1).children()).toMatchObject(shallow( + , + )); + }); + test('StudentGroups filters', () => { + expect(el.find(Collapsible).at(2).children()).toMatchObject(shallow( + , + )); + }); + test('includeCourseTeamMembers', () => { + const checkbox = el.find(Collapsible).at(3).children(); + expect(checkbox.props()).toEqual({ + checked: true, + onChange: hookProps.includeCourseTeamMembers.handleChange, + children: formatMessage(messages.includeCourseTeamMembers), + }); + }); + }); +}); diff --git a/src/components/GradebookFilters/messages.js b/src/components/GradebookFilters/messages.js index 317d8005..adbd5a5e 100644 --- a/src/components/GradebookFilters/messages.js +++ b/src/components/GradebookFilters/messages.js @@ -66,6 +66,11 @@ const messages = defineMessages({ defaultMessage: 'Close Filters', description: 'Button label for Close button in Gradebook Filters', }, + apply: { + id: 'gradebook.GradebookFilters.apply', + defaultMessage: 'Apply', + description: 'Apply filter button text', + }, }); export default messages; diff --git a/src/components/GradebookFilters/test.jsx b/src/components/GradebookFilters/test.jsx deleted file mode 100644 index ba98358a..00000000 --- a/src/components/GradebookFilters/test.jsx +++ /dev/null @@ -1,126 +0,0 @@ -import React from 'react'; -import { shallow } from 'enzyme'; - -import actions from 'data/actions'; -import selectors from 'data/selectors'; -import thunkActions from 'data/thunkActions'; - -import { - GradebookFilters, - mapStateToProps, - mapDispatchToProps, -} from '.'; - -jest.mock('@edx/paragon', () => ({ - Collapsible: 'Collapsible', - Form: { - Checkbox: 'Checkbox', - }, - Icon: 'Icon', - IconButton: 'IconButton', -})); -jest.mock('@edx/paragon/icons', () => ({ - Close: 'paragon.icons.Close', -})); -jest.mock('./AssignmentTypeFilter', () => 'AssignmentTypeFilter'); -jest.mock('./AssignmentFilter', () => 'AssignmentFilter'); -jest.mock('./AssignmentGradeFilter', () => 'AssignmentGradeFilter'); -jest.mock('./CourseGradeFilter', () => 'CourseGradeFilter'); -jest.mock('./StudentGroupsFilter', () => 'StudentGroupsFilter'); -jest.mock('data/selectors', () => ({ - __esModule: true, - default: { - filters: { - includeCourseRoleMembers: jest.fn((state) => ({ includeCourseRoleMembers: state })), - }, - }, -})); -jest.mock('data/thunkActions', () => ({ - __esModule: true, - default: { - app: { filterMenu: { close: jest.fn() } }, - grades: { fetchGrades: jest.fn() }, - }, -})); - -describe('GradebookFilters', () => { - let props = { - includeCourseRoleMembers: true, - }; - - beforeEach(() => { - props = { - ...props, - intl: { formatMessage: (msg) => msg.defaultMessage }, - closeMenu: jest.fn().mockName('this.props.closeMenu'), - fetchGrades: jest.fn(), - updateIncludeCourseRoleMembers: jest.fn(), - updateQueryParams: jest.fn().mockName('this.props.updateQueryParams'), - }; - }); - - describe('Component', () => { - describe('behavior', () => { - describe('handleIncludeTeamMembersChange', () => { - let el; - beforeEach(() => { - el = shallow(); - el.instance().setState = jest.fn(); - }); - it('calls setState with newVal', () => { - el.instance().handleIncludeTeamMembersChange( - { target: { checked: true } }, - ); - expect( - el.instance().setState, - ).toHaveBeenCalledWith({ includeCourseRoleMembers: true }); - }); - it('calls props.updateIncludeCourseRoleMembers with newVal', () => { - el.instance().handleIncludeTeamMembersChange( - { target: { checked: false } }, - ); - expect( - props.updateIncludeCourseRoleMembers, - ).toHaveBeenCalledWith(false); - }); - it('calls props.updateQueryParams with newVal', () => { - el.instance().handleIncludeTeamMembersChange( - { target: { checked: true } }, - ); - expect( - props.updateQueryParams, - ).toHaveBeenCalledWith({ includeCourseRoleMembers: true }); - }); - }); - }); - describe('snapshots', () => { - test('basic snapshot', () => { - const el = shallow(); - el.instance().handleIncludeTeamMembersChange = jest.fn().mockName( - 'handleIncludeTeamMembersChange', - ); - expect(el.instance().render()).toMatchSnapshot(); - }); - }); - }); - describe('mapStateToProps', () => { - const testState = { A: 'laska' }; - test('includeCourseRoleMembers from filters.includeCourseRoleMembers', () => { - expect( - mapStateToProps(testState).includeCourseRoleMembers, - ).toEqual(selectors.filters.includeCourseRoleMembers(testState)); - }); - }); - describe('mapDispatchToProps', () => { - test('fetchGrades from thunkActions.grades.fetchGrades', () => { - expect(mapDispatchToProps.fetchGrades).toEqual(thunkActions.grades.fetchGrades); - }); - describe('updateIncludeCourseRoleMembers', () => { - test('from actions.filters.update.includeCourseRoleMembers', () => { - expect(mapDispatchToProps.updateIncludeCourseRoleMembers).toEqual( - actions.filters.update.includeCourseRoleMembers, - ); - }); - }); - }); -}); diff --git a/src/components/GradesView/EditModal/__snapshots__/test.jsx.snap b/src/components/GradesView/EditModal/__snapshots__/test.jsx.snap index 7ba9de08..3bcdeb7f 100644 --- a/src/components/GradesView/EditModal/__snapshots__/test.jsx.snap +++ b/src/components/GradesView/EditModal/__snapshots__/test.jsx.snap @@ -2,46 +2,23 @@ exports[`EditModal Component snapshots gradeOverrideHistoryError is and empty and open is true modal open and StatusAlert showing 1`] = ` - +
- Weve been trying to contact you regarding... - +
-
- - - + + + - - + - +
`; exports[`EditModal Component snapshots gradeOverrideHistoryError is empty and open is false modal closed and StatusAlert closed 1`] = ` - +
- - +
-
- - - + + + - - + - +
`; diff --git a/src/components/GradesView/ImportGradesButton.jsx b/src/components/GradesView/ImportGradesButton.jsx deleted file mode 100644 index 220f3ba8..00000000 --- a/src/components/GradesView/ImportGradesButton.jsx +++ /dev/null @@ -1,98 +0,0 @@ -/* eslint-disable react/button-has-type, import/no-named-as-default */ -import React from 'react'; -import PropTypes from 'prop-types'; -import { connect } from 'react-redux'; - -import { FormattedMessage } from '@edx/frontend-platform/i18n'; - -import { - Form, - FormControl, - FormGroup, -} from '@edx/paragon'; - -import selectors from 'data/selectors'; -import thunkActions from 'data/thunkActions'; -import NetworkButton from 'components/NetworkButton'; -import messages from './ImportGradesButton.messages'; - -/** - * - * File-type input wrapped with hidden control such that when a valid file is - * added, it is automattically uploaded. - */ -export class ImportGradesButton extends React.Component { - constructor(props) { - super(props); - this.fileInputRef = React.createRef(); - this.handleClickImportGrades = this.handleClickImportGrades.bind(this); - this.handleFileInputChange = this.handleFileInputChange.bind(this); - } - - handleClickImportGrades() { - if (this.fileInput) { this.fileInput.click(); } - } - - handleFileInputChange() { - return this.hasFile && ( - this.props.submitImportGradesButtonData(this.formData).then( - () => { this.fileInput.value = null; }, - ) - ); - } - - get fileInput() { - return this.fileInputRef.current; - } - - get formData() { - const data = new FormData(); - data.append('csv', this.fileInput.files[0]); - return data; - } - - get hasFile() { - return this.fileInput && this.fileInput.files[0]; - } - - render() { - const { gradeExportUrl } = this.props; - return ( - <> -
- - } - onChange={this.handleFileInputChange} - ref={this.fileInputRef} - /> - -
- - - - ); - } -} -ImportGradesButton.propTypes = { - // redux - gradeExportUrl: PropTypes.string.isRequired, - submitImportGradesButtonData: PropTypes.func.isRequired, -}; - -export const mapStateToProps = (state) => ({ - gradeExportUrl: selectors.root.gradeExportUrl(state), -}); - -export const mapDispatchToProps = { - submitImportGradesButtonData: thunkActions.grades.submitImportGradesButtonData, -}; - -export default connect(mapStateToProps, mapDispatchToProps)(ImportGradesButton); diff --git a/src/components/GradesView/ImportGradesButton.test.jsx b/src/components/GradesView/ImportGradesButton.test.jsx deleted file mode 100644 index 0a672056..00000000 --- a/src/components/GradesView/ImportGradesButton.test.jsx +++ /dev/null @@ -1,212 +0,0 @@ -/* eslint-disable import/no-named-as-default */ -import React from 'react'; -import { shallow } from 'enzyme'; -import TestRenderer from 'react-test-renderer'; -import { - Form, - FormControl, - FormGroup, -} from '@edx/paragon'; - -import NetworkButton from 'components/NetworkButton'; - -import selectors from 'data/selectors'; -import thunkActions from 'data/thunkActions'; -import { ImportGradesButton, mapStateToProps, mapDispatchToProps } from './ImportGradesButton'; - -import messages from './ImportGradesButton.messages'; - -jest.mock('@edx/frontend-platform/i18n', () => ({ - defineMessages: m => m, - FormattedMessage: () => 'FormattedMessage', -})); -jest.mock('components/NetworkButton', () => 'NetworkButton'); -jest.mock('data/selectors', () => ({ - __esModule: true, - default: { - grades: { - bulkImportError: jest.fn(state => ({ bulkImportError: state })), - }, - root: { - gradeExportUrl: jest.fn(state => ({ gradeExportUrl: state })), - }, - }, -})); -jest.mock('data/thunkActions', () => ({ - __esModule: true, - default: { - grades: { submitImportGradesButtonData: jest.fn() }, - }, - -})); - -const mockRef = { click: jest.fn(), files: [] }; - -describe('ImportGradesButton', () => { - beforeEach(() => { - mockRef.click.mockClear(); - }); - describe('component', () => { - let props; - let el; - let inst; - beforeEach(() => { - props = { - gradeExportUrl: 'fakeUrl', - submitImportGradesButtonData: jest.fn(), - }; - }); - describe('snapshot', () => { - const snapshotSegments = [ - 'export form w/ alerts and file input', - 'import btn', - ]; - test(`snapshot - loads ${snapshotSegments.join(', ')}`, () => { - jest.mock('@edx/paragon', () => ({ - Form: () => 'Form', - FormControl: () => 'FormControl', - FormGroup: () => 'FormGroup', - })); - el = shallow(); - el.instance().handleFileInputChange = jest.fn().mockName('this.handleFileInputChange'); - el.instance().fileInputRef = jest.fn().mockName('this.fileInputRef'); - el.instance().handleClickImportGrades = jest.fn().mockName('this.handleClickImportGrades'); - el.instance().formatHistoryRow = jest.fn(entry => entry.originalFilename); - expect(el.instance().render()).toMatchSnapshot(); - }); - }); - describe('render', () => { - beforeEach(() => { - el = TestRenderer.create( - , - { createNodeMock: () => mockRef }, - ); - inst = el.root; - }); - describe('alert form', () => { - let form; - beforeEach(() => { - form = inst.findByType(Form); - }); - test('post action points to gradeExportUrl', () => { - expect(form.props.action).toEqual(props.gradeExportUrl); - expect(form.props.method).toEqual('post'); - }); - describe('file input', () => { - let formGroup; - beforeEach(() => { - formGroup = inst.findByType(FormGroup); - }); - test('group with controlId="csv"', () => { - expect(formGroup.props.controlId).toEqual('csv'); - }); - test('file control with onChange from handleFileInputChange', () => { - const control = inst.findByType(FormControl); - expect( - control.props.onChange, - ).toEqual(el.getInstance().handleFileInputChange); - }); - test('fileInputRef points to control', () => { - expect( - // eslint-disable-next-line no-underscore-dangle - inst.findByType(FormControl)._fiber.ref, - ).toEqual(el.getInstance().fileInputRef); - }); - }); - }); - describe('import button', () => { - let btn; - beforeEach(() => { - btn = inst.findByType(NetworkButton); - }); - test('handleClickImportGrade on click', () => { - expect(btn.props.onClick).toEqual(el.getInstance().handleClickImportGrades); - }); - test('label from messages.importGradesBtnText and import true', () => { - expect(btn.props.label).toEqual(messages.importGradesBtnText); - expect(btn.props.import).toEqual(true); - }); - }); - }); - describe('fileInput helper', () => { - test('links to fileInputRef.current', () => { - el = TestRenderer.create( - , - { createNodeMock: () => mockRef }, - ); - expect(el.getInstance().fileInput).not.toEqual(undefined); - expect(el.getInstance().fileInput).toEqual(el.getInstance().fileInputRef.current); - }); - }); - describe('behavior', () => { - let fileInput; - beforeEach(() => { - el = TestRenderer.create( - , - { createNodeMock: () => mockRef }, - ); - fileInput = jest.spyOn(el.getInstance(), 'fileInput', 'get'); - }); - describe('handleFileInputChange', () => { - it('does nothing (does not fail) if fileInput has not loaded', () => { - fileInput.mockReturnValue(null); - el.getInstance().handleClickImportGrades(); - expect(mockRef.click).not.toHaveBeenCalled(); - }); - it('calls fileInput.click if is loaded', () => { - el.getInstance().handleClickImportGrades(); - expect(mockRef.click).toHaveBeenCalled(); - }); - }); - describe('handleClickImportGrades', () => { - it('does nothing if file input has not loaded with files', () => { - fileInput.mockReturnValue(null); - el.getInstance().handleFileInputChange(); - expect(props.submitImportGradesButtonData).not.toHaveBeenCalled(); - fileInput.mockReturnValue({ files: [] }); - el.getInstance().handleFileInputChange(); - expect(props.submitImportGradesButtonData).not.toHaveBeenCalled(); - }); - it('calls submitImportGradesButtonData and then clears fileInput if has files', () => { - fileInput.mockReturnValue({ files: ['some', 'files'], value: 'a value' }); - const formData = { fake: 'form data' }; - jest.spyOn(el.getInstance(), 'formData', 'get').mockReturnValue(formData); - const submit = jest.fn(() => ({ then: (thenCB) => { thenCB(); } })); - el.update(); - el.getInstance().handleFileInputChange(); - expect(submit).toHaveBeenCalledWith(formData); - expect(el.getInstance().fileInput.value).toEqual(null); - }); - }); - describe('formData', () => { - test('returns FormData object with csv value from fileInput.files[0]', () => { - const file = { a: 'fake file' }; - const value = 'aValue'; - fileInput.mockReturnValue({ files: [file], value }); - const expected = new FormData(); - expected.append('csv', file); - expect([...el.getInstance().formData.entries()]).toEqual([...expected.entries()]); - }); - }); - }); - }); - - describe('mapStateToProps', () => { - const testState = { a: 'simple', test: 'state' }; - let mapped; - beforeEach(() => { - mapped = mapStateToProps(testState); - }); - test('gradeExportUrl from root.gradeExportUrl', () => { - expect(mapped.gradeExportUrl).toEqual(selectors.root.gradeExportUrl(testState)); - }); - }); - - describe('mapDispatchToProps', () => { - test('submitImportGradesButtonData from thunkActions.grades', () => { - expect( - mapDispatchToProps.submitImportGradesButtonData, - ).toEqual(thunkActions.grades.submitImportGradesButtonData); - }); - }); -}); diff --git a/src/components/GradesView/ImportGradesButton/__snapshots__/index.test.jsx.snap b/src/components/GradesView/ImportGradesButton/__snapshots__/index.test.jsx.snap new file mode 100644 index 00000000..168bac2b --- /dev/null +++ b/src/components/GradesView/ImportGradesButton/__snapshots__/index.test.jsx.snap @@ -0,0 +1,28 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ImportGradesButton component render snapshot 1`] = ` + +
+ + + +
+ +
+`; diff --git a/src/components/GradesView/ImportGradesButton/hooks.js b/src/components/GradesView/ImportGradesButton/hooks.js new file mode 100644 index 00000000..cbac03b5 --- /dev/null +++ b/src/components/GradesView/ImportGradesButton/hooks.js @@ -0,0 +1,31 @@ +import { useRef } from 'react'; +import { selectors, thunkActions } from 'data/redux/hooks'; + +export const useImportButtonData = () => { + const gradeExportUrl = selectors.useGradeExportUrl(); + const submitImportGradesButtonData = thunkActions.grades.useSubmitImportGradesButtonData(); + + const fileInputRef = useRef(); + const hasFile = fileInputRef.current && fileInputRef.current.files[0]; + + const handleClickImportGrades = () => hasFile && fileInputRef.current.click(); + const handleFileInputChange = () => { + if (hasFile) { + const clearInput = () => { + fileInputRef.current.value = null; + }; + const formData = new FormData(); + formData.append('csv', fileInputRef.current.files[0]); + submitImportGradesButtonData(formData).then(clearInput); + } + }; + + return { + fileInputRef, + gradeExportUrl, + handleClickImportGrades, + handleFileInputChange, + }; +}; + +export default useImportButtonData; diff --git a/src/components/GradesView/ImportGradesButton/hooks.test.js b/src/components/GradesView/ImportGradesButton/hooks.test.js new file mode 100644 index 00000000..b19df06c --- /dev/null +++ b/src/components/GradesView/ImportGradesButton/hooks.test.js @@ -0,0 +1,81 @@ +import React from 'react'; +import { selectors, thunkActions } from 'data/redux/hooks'; + +import useImportButtonData from './hooks'; + +jest.mock('data/redux/hooks', () => ({ + selectors: { + useGradeExportUrl: jest.fn(), + }, + thunkActions: { + grades: { useSubmitImportGradesButtonData: jest.fn() }, + }, +})); + +let out; + +let submitThen; +const submit = jest.fn().mockReturnValue(new Promise((resolve) => { + submitThen = resolve; +})); +const gradeExportUrl = 'test-grade-export-url'; +selectors.useGradeExportUrl.mockReturnValue(gradeExportUrl); +thunkActions.grades.useSubmitImportGradesButtonData.mockReturnValue(submit); + +const testFile = 'test-file'; +const testFormData = new FormData(); +testFormData.append('csv', testFile); + +const ref = { + current: { click: jest.fn(), files: [testFile], value: 'test-value' }, +}; +describe('useAssignmentFilterData hook', () => { + beforeEach(() => { + jest.clearAllMocks(); + React.useRef.mockReturnValue(ref); + out = useImportButtonData(); + }); + describe('behavior', () => { + it('initializes redux hooks', () => { + expect(selectors.useGradeExportUrl).toHaveBeenCalledWith(); + expect(thunkActions.grades.useSubmitImportGradesButtonData).toHaveBeenCalledWith(); + }); + it('initializes react ref', () => { + expect(React.useRef).toHaveBeenCalled(); + }); + }); + describe('output', () => { + describe('handleClickImportGrades', () => { + it('clicks the file input if populated', () => { + out.handleClickImportGrades(); + expect(ref.current.click).toHaveBeenCalled(); + }); + it('does not crash if no file input available', () => { + React.useRef.mockReturnValueOnce({ current: null }); + out = useImportButtonData(); + out.handleClickImportGrades(); + expect(ref.current.click).not.toHaveBeenCalled(); + }); + }); + describe('handleFileInputChange', () => { + it('does not crash if no file input available', () => { + React.useRef.mockReturnValueOnce({ current: null }); + out = useImportButtonData(); + out.handleFileInputChange(); + }); + it('submits formData, clearingInput on success', async () => { + out.handleFileInputChange(); + const [[callArg]] = submit.mock.calls; + expect([...callArg.entries()]).toEqual([...testFormData.entries()]); + await submitThen(); + expect(out.fileInputRef.current.value).toEqual(null); + }); + }); + it('passes fileInputRef from hook', () => { + expect(out.fileInputRef).toEqual(ref); + }); + it('passes gradeExportUrl from hook', () => { + expect(out.gradeExportUrl).toEqual(gradeExportUrl); + }); + }); +}); diff --git a/src/components/GradesView/ImportGradesButton/index.jsx b/src/components/GradesView/ImportGradesButton/index.jsx new file mode 100644 index 00000000..d398103f --- /dev/null +++ b/src/components/GradesView/ImportGradesButton/index.jsx @@ -0,0 +1,50 @@ +/* eslint-disable react/button-has-type, import/no-named-as-default */ +import React from 'react'; + +import { useIntl } from '@edx/frontend-platform/i18n'; + +import { Form } from '@edx/paragon'; + +import NetworkButton from 'components/NetworkButton'; +import messages from './messages'; +import useImportGradesButtonData from './hooks'; + +/** + * + * File-type input wrapped with hidden control such that when a valid file is + * added, it is automattically uploaded. + */ +export const ImportGradesButton = () => { + const { + fileInputRef, + gradeExportUrl, + handleClickImportGrades, + handleFileInputChange, + } = useImportGradesButtonData(); + const { formatMessage } = useIntl(); + return ( + <> +
+ + + +
+ + + ); +}; +ImportGradesButton.propTypes = {}; + +export default ImportGradesButton; diff --git a/src/components/GradesView/ImportGradesButton/index.test.jsx b/src/components/GradesView/ImportGradesButton/index.test.jsx new file mode 100644 index 00000000..6811ad80 --- /dev/null +++ b/src/components/GradesView/ImportGradesButton/index.test.jsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Form } from '@edx/paragon'; + +import NetworkButton from 'components/NetworkButton'; +import useImportGradesButtonData from './hooks'; +import ImportGradesButton from '.'; + +jest.mock('components/NetworkButton', () => 'NetworkButton'); +jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() })); + +let el; +let props; +describe('ImportGradesButton component', () => { + beforeAll(() => { + props = { + fileInputRef: { current: null }, + gradeExportUrl: 'test-grade-export-url', + handleClickImportGrades: jest.fn(), + handleFileInputChange: jest.fn(), + }; + useImportGradesButtonData.mockReturnValue(props); + el = shallow(); + }); + describe('behavior', () => { + it('initializes hooks', () => { + expect(useImportGradesButtonData).toHaveBeenCalledWith(); + expect(useIntl).toHaveBeenCalledWith(); + }); + }); + describe('render', () => { + test('snapshot', () => { + expect(el).toMatchSnapshot(); + }); + test('Form', () => { + expect(el.find(Form).props().action).toEqual(props.gradeExportUrl); + expect(el.find(Form.Control).props().onChange).toEqual(props.handleFileInputChange); + }); + test('import button', () => { + expect(el.find(NetworkButton).props().onClick).toEqual(props.handleClickImportGrades); + }); + }); +}); diff --git a/src/components/GradesView/ImportGradesButton.messages.js b/src/components/GradesView/ImportGradesButton/messages.js similarity index 100% rename from src/components/GradesView/ImportGradesButton.messages.js rename to src/components/GradesView/ImportGradesButton/messages.js diff --git a/src/components/GradesView/ImportGradesButton/ref.test.jsx b/src/components/GradesView/ImportGradesButton/ref.test.jsx new file mode 100644 index 00000000..57b0e530 --- /dev/null +++ b/src/components/GradesView/ImportGradesButton/ref.test.jsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { render } from '@testing-library/react'; + +import useImportGradesButtonData from './hooks'; +import ImportGradesButton from '.'; + +jest.unmock('react'); +jest.unmock('@edx/paragon'); +jest.mock('components/NetworkButton', () => 'network-button'); +jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() })); + +const props = { + fileInputRef: { current: { click: jest.fn() }, useRef: jest.fn() }, + gradeExportUrl: 'test-grade-export-utl', + handleClickImportGrades: jest.fn(), + handleFileInputChange: jest.fn(), +}; +useImportGradesButtonData.mockReturnValue(props); + +let el; +describe('ImportGradesButton ref test', () => { + it('loads ref from hook', () => { + el = render(); + const input = el.getByTestId('file-control'); + expect(input).toEqual(props.fileInputRef.current); + }); +}); diff --git a/src/components/GradesView/__snapshots__/ImportGradesButton.test.jsx.snap b/src/components/GradesView/__snapshots__/ImportGradesButton.test.jsx.snap deleted file mode 100644 index 79973a9f..00000000 --- a/src/components/GradesView/__snapshots__/ImportGradesButton.test.jsx.snap +++ /dev/null @@ -1,45 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`ImportGradesButton component snapshot snapshot - loads export form w/ alerts and file input, import btn 1`] = ` - -
- - - } - onChange={[MockFunction this.handleFileInputChange]} - plaintext={false} - type="file" - /> - -
- -
-`; diff --git a/src/data/actions/utils.test.js b/src/data/actions/utils.test.js index f31e69a9..2b31b4c0 100644 --- a/src/data/actions/utils.test.js +++ b/src/data/actions/utils.test.js @@ -8,8 +8,10 @@ jest.mock('@reduxjs/toolkit', () => ({ describe('redux action utils', () => { describe('formatDateForDisplay', () => { it('returns the datetime as a formatted string', () => { - expect(utils.formatDateForDisplay(new Date('Jun 3 2021 11:59 AM EDT'))).toEqual( - 'June 3, 2021 at 03:59 PM UTC', + // using toLocaleTimeString because mac/linux seems to generate strings + const date = new Date('Jun 3 2021 11:59 AM EDT'); + expect(utils.formatDateForDisplay(date)).toEqual( + `June 3, 2021 at ${date.toLocaleTimeString('en-US', utils.timeOptions)}`, ); }); }); diff --git a/src/data/redux/hooks/actions.js b/src/data/redux/hooks/actions.js new file mode 100644 index 00000000..bfe11490 --- /dev/null +++ b/src/data/redux/hooks/actions.js @@ -0,0 +1,22 @@ +import { StrictDict } from 'utils'; +import actions from 'data/actions'; +import { actionHook } from './utils'; + +const app = StrictDict({ + useSetLocalFilter: actionHook(actions.app.setLocalFilter), +}); + +const filters = StrictDict({ + useUpdateAssignment: actionHook(actions.filters.update.assignment), + useUpdateAssignmentLimits: actionHook(actions.filters.update.assignmentLimits), + useUpdateAssignmentType: actionHook(actions.filters.update.assignmentType), + useUpdateCohort: actionHook(actions.filters.update.cohort), + useUpdateCourseGradeLimits: actionHook(actions.filters.update.courseGradeLimits), + useUpdateIncludeCourseRoleMembers: actionHook(actions.filters.update.includeCourseRoleMembers), + useUpdateTrack: actionHook(actions.filters.update.track), +}); + +export default StrictDict({ + app, + filters, +}); diff --git a/src/data/redux/hooks/actions.test.js b/src/data/redux/hooks/actions.test.js new file mode 100644 index 00000000..95cf0157 --- /dev/null +++ b/src/data/redux/hooks/actions.test.js @@ -0,0 +1,50 @@ +import { keyStore } from 'utils'; +import actions from 'data/actions'; + +import { actionHook } from './utils'; +import actionHooks from './actions'; + +jest.mock('data/actions', () => ({ + app: { + setLocalFilter: jest.fn(), + }, + filters: { + update: { + assignment: jest.fn(), + assignmentLimits: jest.fn(), + }, + }, +})); +jest.mock('./utils', () => ({ + actionHook: (action) => ({ actionHook: action }), +})); + +let hooks; + +const testActionHook = (hookKey, action) => { + test(hookKey, () => { + expect(hooks[hookKey]).toEqual(actionHook(action)); + }); +}; + +describe('action hooks', () => { + describe('app', () => { + const hookKeys = keyStore(actionHooks.app); + beforeEach(() => { hooks = actionHooks.app; }); + testActionHook(hookKeys.useSetLocalFilter, actions.app.setLocalFilter); + }); + describe('filters', () => { + const hookKeys = keyStore(actionHooks.filters); + const actionGroup = actions.filters.update; + beforeEach(() => { hooks = actionHooks.filters; }); + testActionHook(hookKeys.useUpdateAssignment, actionGroup.assignment); + testActionHook(hookKeys.useUpdateAssignmentLimits, actionGroup.assignmentLimits); + testActionHook(hookKeys.useUpdateCohort, actionGroup.updateCohort); + testActionHook(hookKeys.useUpdateCourseGradeLimits, actionGroup.courseGradeLimits); + testActionHook( + hookKeys.useUpdateIncludeCourseRoleMembers, + actionGroup.updateIncludeCourseRoleMembers, + ); + testActionHook(hookKeys.useUpdateTrack, actionGroup.updateTrack); + }); +}); diff --git a/src/data/redux/hooks/index.js b/src/data/redux/hooks/index.js new file mode 100644 index 00000000..f83ebd69 --- /dev/null +++ b/src/data/redux/hooks/index.js @@ -0,0 +1,15 @@ +import { StrictDict } from 'utils'; + +import selectorHooks from './selectors'; +import actionHooks from './actions'; +import thunkActionHooks from './thunkActions'; + +export const selectors = selectorHooks; +export const actions = actionHooks; +export const thunkActions = thunkActionHooks; + +export default StrictDict({ + selectors, + actions, + thunkActions, +}); diff --git a/src/data/redux/hooks/index.test.js b/src/data/redux/hooks/index.test.js new file mode 100644 index 00000000..2adbc5ce --- /dev/null +++ b/src/data/redux/hooks/index.test.js @@ -0,0 +1,17 @@ +import hooks from '.'; + +import selectors from './selectors'; +import actions from './actions'; +import thunkActions from './thunkActions'; + +jest.mock('./selectors', () => jest.fn()); +jest.mock('./actions', () => jest.fn()); +jest.mock('./thunkActions', () => jest.fn()); + +describe('redux hooks', () => { + it('exports selectors, actions, and thunkActions', () => { + expect(hooks.actions).toEqual(actions); + expect(hooks.selectors).toEqual(selectors); + expect(hooks.thunkActions).toEqual(thunkActions); + }); +}); diff --git a/src/data/redux/hooks/selectors.js b/src/data/redux/hooks/selectors.js new file mode 100644 index 00000000..4b865a65 --- /dev/null +++ b/src/data/redux/hooks/selectors.js @@ -0,0 +1,49 @@ +import { useSelector } from 'react-redux'; + +import { StrictDict } from 'utils'; +import selectors from 'data/selectors'; + +export const root = StrictDict({ + useGradeExportUrl: () => useSelector(selectors.root.gradeExportUrl), + useSelectedCohortEntry: () => useSelector(selectors.root.selectedCohortEntry), + useSelectedTrackEntry: () => useSelector(selectors.root.selectedTrackEntry), +}); + +export const app = StrictDict({ + useAssignmentGradeLimits: () => useSelector(selectors.app.assignmentGradeLimits), + useAreCourseGradeFiltersValid: () => useSelector(selectors.app.areCourseGradeFiltersValid), + useCourseGradeLimits: () => useSelector(selectors.app.courseGradeLimits), +}); + +export const assignmentTypes = StrictDict({ + useAllAssignmentTypes: () => useSelector(selectors.assignmentTypes.allAssignmentTypes), +}); + +export const cohorts = StrictDict({ + useAllCohorts: () => useSelector(selectors.cohorts.allCohorts), + // maybe not needed? + useCohortsByName: () => useSelector(selectors.cohorts.cohortsByName), +}); + +export const filters = StrictDict({ + useData: () => useSelector(selectors.filters.allFilters), + useIncludeCourseRoleMembers: () => useSelector(selectors.filters.includeCourseRoleMembers), + useSelectableAssignmentLabels: () => useSelector(selectors.filters.selectableAssignmentLabels), + useSelectedAssignmentLabel: () => useSelector(selectors.filters.selectedAssignmentLabel), + useAssignmentType: () => useSelector(selectors.filters.assignmentType), +}); + +export const tracks = StrictDict({ + useAllTracks: () => useSelector(selectors.tracks.allTracks), + // maybe not needed? + useTracksByName: () => useSelector(selectors.tracks.tracksByName), +}); + +export default StrictDict({ + app, + assignmentTypes, + cohorts, + filters, + tracks, + root, +}); diff --git a/src/data/redux/hooks/selectors.test.js b/src/data/redux/hooks/selectors.test.js new file mode 100644 index 00000000..c19ad9a3 --- /dev/null +++ b/src/data/redux/hooks/selectors.test.js @@ -0,0 +1,91 @@ +import { keyStore } from 'utils'; +import { useSelector } from 'react-redux'; +import selectors from 'data/selectors'; +import selectorHooks from './selectors'; + +jest.mock('react-redux', () => ({ + useSelector: (selector) => ({ useSelector: selector }), +})); + +jest.mock('data/selectors', () => ({ + app: { + assignmentGradeLimits: jest.fn(), + areCourseGradeFiltersValid: jest.fn(), + courseGradelimits: jest.fn(), + }, + assignmentTypes: { allAssignmentTypes: jest.fn() }, + cohorts: { + allCohorts: jest.fn(), + cohortsByName: jest.fn(), + }, + filters: { + allFilters: jest.fn(), + includeCourseRoleMembers: jest.fn(), + selectableAssignmentLabels: jest.fn(), + selectedAssignmentLabel: jest.fn(), + assignmentType: jest.fn(), + }, + tracks: { + allTracks: jest.fn(), + tracksByName: jest.fn(), + }, + root: { + gradeExportUrl: jest.fn(), + selectedCohortEntry: jest.fn(), + selectedTrackEntry: jest.fn(), + }, +})); + +let hooks; +const testHook = (hookKey, selector) => { + test(hookKey, () => { + expect(hooks[hookKey]()).toEqual(useSelector(selector)); + }); +}; +describe('selector hooks', () => { + describe('root selectors', () => { + const hookKeys = keyStore(selectorHooks.root); + beforeEach(() => { hooks = selectorHooks.root; }); + testHook(hookKeys.useGradeExportUrl, selectors.root.gradeExportUrl); + testHook(hookKeys.useSelectedCohortEntry, selectors.root.selectedCohortEntry); + testHook(hookKeys.useSelectedTrackEntry, selectors.root.selectedTrackEntry); + }); + describe('app', () => { + const hookKeys = keyStore(selectorHooks.app); + const selGroup = selectors.app; + beforeEach(() => { hooks = selectorHooks.app; }); + testHook(hookKeys.useAssignmentGradeLimits, selGroup.assignmentGradeLimits); + testHook(hookKeys.useAreCourseGradeFiltersValid, selGroup.areCourseGradeFiltersValid); + testHook(hookKeys.useCourseGradeLimits, selGroup.courseGradeLimits); + }); + describe('assignmentTypes', () => { + const hookKeys = keyStore(selectorHooks.assignmentTypes); + const selGroup = selectors.assignmentTypes; + beforeEach(() => { hooks = selectorHooks.assignmentTypes; }); + testHook(hookKeys.useAllAssignmentTypes, selGroup.allAssignmentTypes); + }); + describe('cohorts', () => { + const hookKeys = keyStore(selectorHooks.cohorts); + const selGroup = selectors.cohorts; + beforeEach(() => { hooks = selectorHooks.cohorts; }); + testHook(hookKeys.useAllCohorts, selGroup.allCohorts); + testHook(hookKeys.useCohortsByName, selGroup.cohortsByName); + }); + describe('filters', () => { + const hookKeys = keyStore(selectorHooks.filters); + const selGroup = selectors.filters; + beforeEach(() => { hooks = selectorHooks.filters; }); + testHook(hookKeys.useData, selGroup.allFilters); + testHook(hookKeys.useIncludeCourseRoleMembers, selGroup.includeCourseRoleMembers); + testHook(hookKeys.useSelectableAssignmentLabels, selGroup.selectableAssignmentLabels); + testHook(hookKeys.useSelectedAssignmentLabel, selGroup.selectedAssignmentLabel); + testHook(hookKeys.useAssignmentType, selGroup.assignmentType); + }); + describe('tracks', () => { + const hookKeys = keyStore(selectorHooks.tracks); + const selGroup = selectors.tracks; + beforeEach(() => { hooks = selectorHooks.tracks; }); + testHook(hookKeys.useAllTracks, selGroup.allTracks); + testHook(hookKeys.useTracksByName, selGroup.tracksByName); + }); +}); diff --git a/src/data/redux/hooks/thunkActions.js b/src/data/redux/hooks/thunkActions.js new file mode 100644 index 00000000..6b5bc5bc --- /dev/null +++ b/src/data/redux/hooks/thunkActions.js @@ -0,0 +1,20 @@ +import { StrictDict } from 'utils'; +import thunkActions from 'data/thunkActions'; +import { actionHook } from './utils'; + +const app = StrictDict({ + useCloseFilterMenu: actionHook(thunkActions.app.filterMenu.close), +}); + +const grades = StrictDict({ + useFetchGradesIfAssignmentGradeFiltersSet: actionHook( + thunkActions.grades.fetchGradesIfAssignmentGradeFiltersSet, + ), + useFetchGrades: actionHook(thunkActions.grades.fetchGrades), + useSubmitImportGradesButtonData: actionHook(thunkActions.grades.submitImportGradesButtonData), +}); + +export default StrictDict({ + app, + grades, +}); diff --git a/src/data/redux/hooks/thunkActions.test.js b/src/data/redux/hooks/thunkActions.test.js new file mode 100644 index 00000000..7ec8ebb3 --- /dev/null +++ b/src/data/redux/hooks/thunkActions.test.js @@ -0,0 +1,48 @@ +import { keyStore } from 'utils'; +import thunkActions from 'data/thunkActions'; +import { actionHook } from './utils'; +import thunkActionHooks from './thunkActions'; + +jest.mock('data/thunkActions', () => ({ + app: { + filterMenu: { close: jest.fn() }, + }, + grades: { + fetchGrades: jest.fn(), + fetchGradesIfAssignmentGradeFiltersSet: jest.fn(), + submitImportGradesButtonData: jest.fn(), + }, +})); + +jest.mock('./utils', () => ({ + actionHook: (action) => ({ actionHook: action }), +})); + +let hooks; + +const testActionHook = (hookKey, action) => { + test(hookKey, () => { + expect(hooks[hookKey]).toEqual(actionHook(action)); + }); +}; +describe('thunkAction hooks', () => { + describe('app', () => { + const hookKeys = keyStore(thunkActionHooks.app); + beforeEach(() => { hooks = thunkActionHooks.app; }); + testActionHook(hookKeys.useCloseFilterMenu, thunkActions.app.filterMenu.close); + }); + describe('grades', () => { + const hookKeys = keyStore(thunkActionHooks.grades); + const actionGroup = thunkActions.grades; + beforeEach(() => { hooks = thunkActionHooks.grades; }); + testActionHook(hookKeys.useFetchGrades, actionGroup.fetchGrades); + testActionHook( + hookKeys.useFetchGradesIfAssignmentGradeFiltersSet, + actionGroup.fetchGradesIfAssignmentGradeFiltersSet, + ); + testActionHook( + hookKeys.useSubmitImportGradesButtonData, + actionGroup.submitImportGradesButtonData, + ); + }); +}); diff --git a/src/data/redux/hooks/utils.js b/src/data/redux/hooks/utils.js new file mode 100644 index 00000000..5bcbd15b --- /dev/null +++ b/src/data/redux/hooks/utils.js @@ -0,0 +1,9 @@ +import { StrictDict } from 'utils'; +// useDispatch hook wouldn't work here because it is out of scope of the component +import store from 'data/store'; + +export const actionHook = (action) => () => (...args) => store.dispatch(action(...args)); + +export default StrictDict({ + actionHook, +}); diff --git a/src/data/redux/hooks/utils.test.js b/src/data/redux/hooks/utils.test.js new file mode 100644 index 00000000..6459c326 --- /dev/null +++ b/src/data/redux/hooks/utils.test.js @@ -0,0 +1,18 @@ +import store from 'data/store'; +import { actionHook } from './utils'; + +jest.mock('data/store', () => ({ + dispatch: jest.fn(), +})); + +describe('actionHook', () => { + it('returns a function that dispatches the action', () => { + const action = jest.fn(); + const useHook = actionHook(action); + const args = [1, 2, 3]; + const hook = useHook(); + hook(...args); + expect(action).toHaveBeenCalledWith(...args); + expect(store.dispatch).toHaveBeenCalledWith(action(...args)); + }); +}); diff --git a/src/head/Head.jsx b/src/head/Head.jsx index f7513d4b..cfbb08eb 100644 --- a/src/head/Head.jsx +++ b/src/head/Head.jsx @@ -1,21 +1,23 @@ import React from 'react'; import { Helmet } from 'react-helmet'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { getConfig } from '@edx/frontend-platform'; import messages from './messages'; -const Head = ({ intl }) => ( - - - {intl.formatMessage(messages['gradebook.page.title'], { siteName: getConfig().SITE_NAME })} - - - -); +const Head = () => { + const { formatMessage } = useIntl(); + return ( + + + {formatMessage(messages['gradebook.page.title'], { siteName: getConfig().SITE_NAME })} + + + + ); +}; Head.propTypes = { - intl: intlShape.isRequired, }; -export default injectIntl(Head); +export default Head; diff --git a/src/head/Head.test.jsx b/src/head/Head.test.jsx index c0d9e5f7..72b2c15b 100644 --- a/src/head/Head.test.jsx +++ b/src/head/Head.test.jsx @@ -1,17 +1,31 @@ import React from 'react'; -import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { shallow } from 'enzyme'; import { Helmet } from 'react-helmet'; -import { mount } from 'enzyme'; import { getConfig } from '@edx/frontend-platform'; import Head from './Head'; +jest.mock('react-helmet', () => ({ + Helmet: () => 'Helmet', +})); +jest.mock('@edx/frontend-platform', () => ({ + getConfig: jest.fn(), +})); + +const config = { + SITE_NAME: 'test-site-name', + FAVICON_URL: 'test-favicon-url', +}; + +getConfig.mockReturnValue(config); + describe('Head', () => { - const props = {}; it('should match render title tag and favicon with the site configuration values', () => { - mount(); - const helmet = Helmet.peek(); - expect(helmet.title).toEqual(`Gradebook | ${getConfig().SITE_NAME}`); - expect(helmet.linkTags[0].rel).toEqual('shortcut icon'); - expect(helmet.linkTags[0].href).toEqual(getConfig().FAVICON_URL); + const el = shallow(); + const helmet = el.find(Helmet); + const title = helmet.find('title'); + const link = el.find('link'); + expect(title.props().children).toEqual(`Gradebook | ${config.SITE_NAME}`); + expect(link.props().rel).toEqual('shortcut icon'); + expect(link.props().href).toEqual(config.FAVICON_URL); }); }); diff --git a/src/setupTest.js b/src/setupTest.js index 478a13fd..fddb405b 100755 --- a/src/setupTest.js +++ b/src/setupTest.js @@ -14,13 +14,83 @@ process.env.FAVICON_URL = 'http://localhost:18000/favicon.ico'; jest.mock('@edx/frontend-platform/i18n', () => { const i18n = jest.requireActual('@edx/frontend-platform/i18n'); const PropTypes = jest.requireActual('prop-types'); + const { formatMessage } = jest.requireActual('./testUtils'); + const formatDate = jest.fn(date => new Date(date).toLocaleDateString()).mockName('useIntl.formatDate'); return { ...i18n, intlShape: PropTypes.shape({ - formatMessage: jest.fn(msg => msg.defaultMessage), + formatMessage: PropTypes.func, }), + useIntl: jest.fn(() => ({ + formatMessage, + formatDate, + })), + IntlProvider: () => 'IntlProvider', defineMessages: m => m, FormattedMessage: () => 'FormattedMessage', getLocale: jest.fn(), }; }); + +jest.mock('@edx/paragon', () => jest.requireActual('testUtils').mockNestedComponents({ + Alert: 'Alert', + ActionRow: 'ActionRow', + Badge: 'Badge', + Button: 'Button', + Collapsible: { + Advanced: 'Collapsible.Advanced', + Body: 'Collapsible.Body', + Trigger: 'Collapsible.Trigger', + Visible: 'Collapsible.Visible', + }, + DataTable: { + EmptyTable: 'DataTable.EmptyTable', + Table: 'DataTable.Table', + TableControlBar: 'DataTable.TableControlBar', + TableController: 'DataTable.TableController', + TableFooter: 'DataTable.TableFooter', + }, + Form: { + Checkbox: 'Form.Checkbox', + CheckboxSet: 'Form.CheckboxSet', + Control: { + Feedback: 'Form.Control.Feedback', + }, + Group: 'Form.Group', + Label: 'Form.Label', + Radio: 'Form.Radio', + RadioSet: 'Form.RadioSet', + Switch: 'Form.Switch', + }, + FormControl: 'FormControl', + FormGroup: 'FormGroup', + FormLabel: 'FormLabel', + Hyperlink: 'Hyperlink', + Icon: 'Icon', + IconButton: 'IconButton', + ModalDialog: { + Body: 'ModalDialog.Body', + CloseButton: 'ModalDialog.CloseButton', + Header: 'ModalDialog.Header', + Hero: 'ModalDialog.Hero', + Footer: 'ModalDialog.Footer', + }, + OverlayTrigger: 'OverlayTrigger', + Row: 'Row', + StatefulButton: 'StatefulButton', + Spinner: 'Spinner', + + useCheckboxSetValues: () => jest.fn().mockImplementation((values) => ([values, { + add: jest.fn().mockName('useCheckboxSetValues.add'), + remove: jest.fn().mockName('useCheckboxSetValues.remove'), + }])), +})); + +jest.mock('react', () => ({ + ...jest.requireActual('react'), + useRef: jest.fn((val) => ({ current: val, useRef: true })), + useCallback: jest.fn((cb, prereqs) => ({ useCallback: { cb, prereqs } })), + useEffect: jest.fn((cb, prereqs) => ({ useEffect: { cb, prereqs } })), + useMemo: jest.fn((cb, prereqs) => cb(prereqs)), + useContext: jest.fn(context => context), +})); diff --git a/src/testUtils.js b/src/testUtils.js index baf986a5..9e9b4621 100644 --- a/src/testUtils.js +++ b/src/testUtils.js @@ -10,9 +10,14 @@ export const formatMessage = (msg, values) => { if (values === undefined) { return message; } + // check if value is not a primitive type. + if (Object.values(values).filter(value => Object(value) === value).length) { + // eslint-disable-next-line react/jsx-filename-extension + return ; + } Object.keys(values).forEach((key) => { // eslint-disable-next-line - message = message.replace(`{${key}}`, values[key]); + message = message.replaceAll(`{${key}}`, values[key]); }); return message; }; @@ -160,6 +165,14 @@ export class MockUseState { ); } + expectInitializedWith(key, value) { + expect(this.hooks.state[key]).toHaveBeenCalledWith(value); + } + + expectSetStateCalledWith(key, value) { + expect(this.setState[key]).toHaveBeenCalledWith(value); + } + /** * Restore the hook module's state object to the actual code. */ @@ -184,4 +197,8 @@ export class MockUseState { expect(this.hooks.state[key](testValue)).toEqual(useState(testValue)); }); } + + get values() { + return StrictDict({ ...this.hooks.state }); + } } diff --git a/src/utils/index.js b/src/utils/index.js index 5351f65e..678ea705 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -1,2 +1,3 @@ /* eslint-disable import/prefer-default-export */ export { default as StrictDict } from './StrictDict'; +export { default as keyStore } from './keyStore'; diff --git a/src/utils/keyStore.js b/src/utils/keyStore.js new file mode 100644 index 00000000..a670f436 --- /dev/null +++ b/src/utils/keyStore.js @@ -0,0 +1,10 @@ +import StrictDict from './StrictDict'; + +const keyStore = (collection) => StrictDict( + Object.keys(collection).reduce( + (obj, key) => ({ ...obj, [key]: key }), + {}, + ), +); + +export default keyStore;