refactor: update GradebookFilter components to function component (#308)

* feat: update assignmetn filter component to function component

* chore: redux hook tests

* fix: tests

* chore: update more components to hooks

* chore: moar componentsAssignment

* chore: moar component (StudentGroupsFilter)

* chore: gradebook filter component update

* chore: fix a few places of typo

* chore: use global store for dispatch to avoid out of component

* chore: compromise the test string for local machine

* chore: linting

* test: add more tests for coverage 100%

* chore: linting

* chore: add coverage badge on readme

* chore: bump package version

---------

Co-authored-by: Leangseu Kim <lkim@edx.org>
This commit is contained in:
Ben Warzeski
2023-03-06 11:02:30 -05:00
committed by GitHub
parent 182dc396d5
commit e1c8b01531
77 changed files with 3120 additions and 2637 deletions

View File

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

554
package-lock.json generated
View File

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

View File

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

View File

@@ -4,7 +4,7 @@ exports[`App router component snapshot 1`] = `
<AppProvider
store="testStore"
>
<injectIntl(ShimmedIntlComponent) />
<Head />
<BrowserRouter>
<div>
<Header />

View File

@@ -0,0 +1,52 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AssignmentFilter component render snapshot 1`] = `
<div
className="student-filters"
>
<SelectGroup
disabled={false}
id="assignment"
label="Assignment"
onChange={[MockFunction]}
options={
Array [
<option
value=""
>
All
</option>,
<option
value="label1"
>
label1
:
sLabel1
</option>,
<option
value="label2"
>
label2
:
sLabel2
</option>,
<option
value="label3"
>
label3
:
sLabel3
</option>,
<option
value="label4"
>
label4
:
sLabel4
</option>,
]
}
value="test-label"
/>
</div>
`;

View File

@@ -1,44 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AssignmentFilter Component snapshots basic snapshot 1`] = `
<div
className="student-filters"
>
<SelectGroup
disabled={false}
id="assignment"
label={
<FormattedMessage
defaultMessage="Assignment"
description="Assignment filter select label in Gradebook Filters"
id="gradebook.GradebookFilters.assignmentFilterLabel"
/>
}
onChange={[MockFunction handleChange]}
options={
Array [
<option
value=""
>
All
</option>,
<option
value="assgN1"
>
assgN1
:
subLabel1
</option>,
<option
value="assgN2"
>
assgN2
:
subLabel2
</option>,
]
}
value="assgN1"
/>
</div>
`;

View File

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

View File

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

View File

@@ -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';
import useAssignmentFilterData from './hooks';
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 }) => (
<option key={label} value={label}>
{label}: {subsectionLabel}
</option>
);
return ([
<option key="0" value="">All</option>,
...this.props.assignmentFilterOptions.map(mapper),
]);
}
render() {
return (
<div className="student-filters">
<SelectGroup
id="assignment"
label={<FormattedMessage {...messages.assignment} />}
value={this.props.selectedAssignment}
onChange={this.handleChange}
disabled={this.props.assignmentFilterOptions.length === 0}
options={this.options}
/>
</div>
);
}
}
AssignmentFilter.defaultProps = {
assignmentFilterOptions: [],
selectedAssignment: '',
const AssignmentFilter = ({ updateQueryParams }) => {
const {
handleChange,
selectedAssignmentLabel,
assignmentFilterOptions,
} = useAssignmentFilterData({ updateQueryParams });
const { formatMessage } = useIntl();
const filterOptions = assignmentFilterOptions.map(({ label, subsectionLabel }) => (
<option key={label} value={label}>
{label}: {subsectionLabel}
</option>
));
return (
<div className="student-filters">
<SelectGroup
id="assignment"
label={formatMessage(messages.assignment)}
value={selectedAssignmentLabel}
onChange={handleChange}
disabled={assignmentFilterOptions.length === 0}
options={[
<option key="0" value="">All</option>,
...filterOptions,
]}
/>
</div>
);
};
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;

View File

@@ -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(<AssignmentFilter updateQueryParams={updateQueryParams} />);
});
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}`);
});
});
});

View File

@@ -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(<AssignmentFilter {...props} />);
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(<AssignmentFilter {...props} />);
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(<AssignmentFilter {...props} />);
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);
});
});
});

View File

@@ -0,0 +1,67 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AssignmentFilter component render with selected assignment snapshot 1`] = `
<div
className="grade-filter-inputs"
>
<PercentGroup
disabled={false}
id="assignmentGradeMin"
label="Min Grade"
onChange={[MockFunction]}
value={23}
/>
<PercentGroup
disabled={false}
id="assignmentGradeMax"
label="Max Grade"
onChange={[MockFunction]}
value={300}
/>
<div
className="grade-filter-action"
>
<Button
disabled={false}
name="assignmentGradeMinMax"
type="submit"
variant="outline-secondary"
>
Apply
</Button>
</div>
</div>
`;
exports[`AssignmentFilter component render without selected assignment snapshot 1`] = `
<div
className="grade-filter-inputs"
>
<PercentGroup
disabled={true}
id="assignmentGradeMin"
label="Min Grade"
onChange={[MockFunction]}
value={23}
/>
<PercentGroup
disabled={true}
id="assignmentGradeMax"
label="Max Grade"
onChange={[MockFunction]}
value={300}
/>
<div
className="grade-filter-action"
>
<Button
disabled={true}
name="assignmentGradeMinMax"
type="submit"
variant="outline-secondary"
>
Apply
</Button>
</div>
</div>
`;

View File

@@ -1,95 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AssignmentGradeFilter Component snapshots buttons and groups disabled if no selected assignment 1`] = `
<div
className="grade-filter-inputs"
>
<PercentGroup
disabled={true}
id="assignmentGradeMin"
label={
<FormattedMessage
defaultMessage="Min Grade"
description="Min-grade filter select label in Gradebook Filters"
id="gradebook.GradebookFilters.minGradeFilterLabel"
/>
}
onChange={[MockFunction handleSetMin]}
value="2"
/>
<PercentGroup
disabled={true}
id="assignmentGradeMax"
label={
<FormattedMessage
defaultMessage="Max Grade"
description="Max-grade filter select label in Gradebook Filters"
id="gradebook.GradebookFilters.maxGradeFilterLabel"
/>
}
onChange={[MockFunction handleSetMax]}
value="98"
/>
<div
className="grade-filter-action"
>
<ForwardRef
active={false}
disabled={true}
name="assignmentGradeMinMax"
onClick={[MockFunction handleSubmit]}
type="submit"
variant="outline-secondary"
>
Apply
</ForwardRef>
</div>
</div>
`;
exports[`AssignmentGradeFilter Component snapshots smoke test 1`] = `
<div
className="grade-filter-inputs"
>
<PercentGroup
disabled={false}
id="assignmentGradeMin"
label={
<FormattedMessage
defaultMessage="Min Grade"
description="Min-grade filter select label in Gradebook Filters"
id="gradebook.GradebookFilters.minGradeFilterLabel"
/>
}
onChange={[MockFunction handleSetMin]}
value="2"
/>
<PercentGroup
disabled={false}
id="assignmentGradeMax"
label={
<FormattedMessage
defaultMessage="Max Grade"
description="Max-grade filter select label in Gradebook Filters"
id="gradebook.GradebookFilters.maxGradeFilterLabel"
/>
}
onChange={[MockFunction handleSetMax]}
value="98"
/>
<div
className="grade-filter-action"
>
<ForwardRef
active={false}
disabled={false}
name="assignmentGradeMinMax"
onClick={[MockFunction handleSubmit]}
type="submit"
variant="outline-secondary"
>
Apply
</ForwardRef>
</div>
</div>
`;

View File

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

View File

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

View File

@@ -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 (
<div className="grade-filter-inputs">
<PercentGroup
id="assignmentGradeMin"
label={<FormattedMessage {...messages.minGrade} />}
value={assignmentGradeMin}
disabled={!this.props.selectedAssignment}
onChange={this.handleSetMin}
/>
<PercentGroup
id="assignmentGradeMax"
label={<FormattedMessage {...messages.maxGrade} />}
value={assignmentGradeMax}
disabled={!this.props.selectedAssignment}
onChange={this.handleSetMax}
/>
<div className="grade-filter-action">
<Button
type="submit"
variant="outline-secondary"
name="assignmentGradeMinMax"
disabled={!this.props.selectedAssignment}
onClick={this.handleSubmit}
>
Apply
</Button>
</div>
export const AssignmentGradeFilter = ({ updateQueryParams }) => {
const {
assignmentGradeMin,
assignmentGradeMax,
selectedAssignment,
handleSetMax,
handleSetMin,
handleSubmit,
} = useAssignmentGradeFilterData({ updateQueryParams });
const { formatMessage } = useIntl();
return (
<div className="grade-filter-inputs">
<PercentGroup
id="assignmentGradeMin"
label={formatMessage(messages.minGrade)}
value={assignmentGradeMin}
disabled={!selectedAssignment}
onChange={handleSetMin}
/>
<PercentGroup
id="assignmentGradeMax"
label={formatMessage(messages.maxGrade)}
value={assignmentGradeMax}
disabled={!selectedAssignment}
onChange={handleSetMax}
/>
<div className="grade-filter-action">
<Button
type="submit"
variant="outline-secondary"
name="assignmentGradeMinMax"
disabled={!selectedAssignment}
onClick={handleSubmit}
>
{formatMessage(messages.apply)}
</Button>
</div>
);
}
}
AssignmentGradeFilter.defaultProps = {
selectedAssignment: '',
</div>
);
};
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;

View File

@@ -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(<AssignmentFilter updateQueryParams={updateQueryParams} />);
});
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(<AssignmentFilter updateQueryParams={updateQueryParams} />);
});
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);
});
});
});
});

View File

@@ -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(<AssignmentGradeFilter {...props} />);
});
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(<AssignmentGradeFilter {...props} />);
mockMethods(el);
expect(el.instance().render()).toMatchSnapshot();
});
test('buttons and groups disabled if no selected assignment', () => {
el = shallow(<AssignmentGradeFilter
{...props}
selectedAssignment={undefined}
/>);
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,
);
});
});
});

View File

@@ -0,0 +1,44 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AssignmentFilterType component render snapshot 1`] = `
<div
className="student-filters"
>
<SelectGroup
disabled={true}
id="assignment-types"
label="Assignment Types"
onChange={[MockFunction]}
options={
Array [
<option
value=""
>
All
</option>,
<option
value="test-type"
>
test-type
</option>,
<option
value="type1"
>
type1
</option>,
<option
value="type2"
>
type2
</option>,
<option
value="type3"
>
type3
</option>,
]
}
value="test-type"
/>
</div>
`;

View File

@@ -1,79 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AssignmentTypeFilter Component snapshots SelectGroup disabled if no assignmentFilterOptions 1`] = `
<div
className="student-filters"
>
<SelectGroup
disabled={true}
id="assignment-types"
label={
<FormattedMessage
defaultMessage="Assignment Types"
description="Assignment Types filter select label in Gradebook Filters"
id="gradebook.GradebookFilters.assignmentTypesLabel"
/>
}
onChange={[MockFunction handleChange]}
options={
Array [
<option
value=""
>
All
</option>,
<option
value="assignMentType1"
>
assignMentType1
</option>,
<option
value="AssigNmentType2"
>
AssigNmentType2
</option>,
]
}
value="assigNmentType2"
/>
</div>
`;
exports[`AssignmentTypeFilter Component snapshots smoke test 1`] = `
<div
className="student-filters"
>
<SelectGroup
disabled={false}
id="assignment-types"
label={
<FormattedMessage
defaultMessage="Assignment Types"
description="Assignment Types filter select label in Gradebook Filters"
id="gradebook.GradebookFilters.assignmentTypesLabel"
/>
}
onChange={[MockFunction handleChange]}
options={
Array [
<option
value=""
>
All
</option>,
<option
value="assignMentType1"
>
assignMentType1
</option>,
<option
value="AssigNmentType2"
>
AssigNmentType2
</option>,
]
}
value="assigNmentType2"
/>
</div>
`;

View File

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

View File

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

View File

@@ -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';
import useAssignmentTypeFilterData from './hooks';
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) => (
<option key={entry} value={entry}>{entry}</option>
);
return [
<option key="0" value="">All</option>,
...this.props.assignmentTypes.map(mapper),
];
}
render() {
return (
<div className="student-filters">
<SelectGroup
id="assignment-types"
label={<FormattedMessage {...messages.assignmentTypes} />}
value={this.props.selectedAssignmentType}
onChange={this.handleChange}
disabled={this.props.assignmentFilterOptions.length === 0}
options={this.options}
/>
</div>
);
}
}
AssignmentTypeFilter.defaultProps = {
assignmentTypes: [],
assignmentFilterOptions: [],
selectedAssignmentType: '',
export const AssignmentTypeFilter = ({ updateQueryParams }) => {
const {
assignmentTypes,
handleChange,
isDisabled,
selectedAssignmentType,
} = useAssignmentTypeFilterData({ updateQueryParams });
const { formatMessage } = useIntl();
return (
<div className="student-filters">
<SelectGroup
id="assignment-types"
label={formatMessage(messages.assignmentTypes)}
value={selectedAssignmentType}
onChange={handleChange}
disabled={isDisabled}
options={[
<option key="0" value="">All</option>,
...assignmentTypes.map(entry => (
<option key={entry} value={entry}>{entry}</option>
)),
]}
/>
</div>
);
};
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;

View File

@@ -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(<AssignmentFilterType updateQueryParams={updateQueryParams} />);
});
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);
});
});
});

View File

@@ -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(<AssignmentTypeFilter {...props} />);
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(<AssignmentTypeFilter {...props} />);
mockMethods(el);
expect(el.instance().render()).toMatchSnapshot();
});
test('SelectGroup disabled if no assignmentFilterOptions', () => {
el = shallow(<AssignmentTypeFilter
{...props}
assignmentFilterOptions={[]}
/>);
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,
);
});
});
});

View File

@@ -0,0 +1,63 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CourseFilter component render if disabled snapshot 1`] = `
<Fragment>
<div
className="grade-filter-inputs"
>
<PercentGroup
id="minimum-grade"
label="Min Grade"
onChange={[MockFunction]}
value={23}
/>
<PercentGroup
id="maximum-grade"
label="Max Grade"
onChange={[MockFunction]}
value={300}
/>
</div>
<div
className="grade-filter-action"
>
<Button
disabled={true}
variant="outline-secondary"
>
Apply
</Button>
</div>
</Fragment>
`;
exports[`CourseFilter component render with selected assignment snapshot 1`] = `
<Fragment>
<div
className="grade-filter-inputs"
>
<PercentGroup
id="minimum-grade"
label="Min Grade"
onChange={[MockFunction]}
value={23}
/>
<PercentGroup
id="maximum-grade"
label="Max Grade"
onChange={[MockFunction]}
value={300}
/>
</div>
<div
className="grade-filter-action"
>
<Button
disabled={false}
variant="outline-secondary"
>
Apply
</Button>
</div>
</Fragment>
`;

View File

@@ -1,44 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CourseGradeFilter Component snapshots basic snapshot 1`] = `
<React.Fragment>
<div
className="grade-filter-inputs"
>
<PercentGroup
id="minimum-grade"
label={
<FormattedMessage
defaultMessage="Min Grade"
description="Min-grade filter select label in Gradebook Filters"
id="gradebook.GradebookFilters.minGradeFilterLabel"
/>
}
onChange={[MockFunction handleUpdateMin]}
value="5"
/>
<PercentGroup
id="maximum-grade"
label={
<FormattedMessage
defaultMessage="Max Grade"
description="Max-grade filter select label in Gradebook Filters"
id="gradebook.GradebookFilters.maxGradeFilterLabel"
/>
}
onChange={[MockFunction handleUpdateMax]}
value="92"
/>
</div>
<div
className="grade-filter-action"
>
<Button
onClick={[MockFunction handleApplyClick]}
variant="outline-secondary"
>
Apply
</Button>
</div>
</React.Fragment>
`;

View File

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

View File

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

View File

@@ -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';
import useCourseGradeFilterData from './hooks';
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);
}
export const CourseGradeFilter = ({ updateQueryParams }) => {
const {
max,
min,
isDisabled,
handleApplyClick,
} = useCourseGradeFilterData({ updateQueryParams });
const { formatMessage } = useIntl();
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 (
<>
<div className="grade-filter-inputs">
<PercentGroup
id="minimum-grade"
label={<FormattedMessage {...messages.minGrade} />}
value={courseGradeMin}
onChange={this.handleUpdateMin}
/>
<PercentGroup
id="maximum-grade"
label={<FormattedMessage {...messages.maxGrade} />}
value={courseGradeMax}
onChange={this.handleUpdateMax}
/>
</div>
<div className="grade-filter-action">
<Button
variant="outline-secondary"
onClick={this.handleApplyClick}
>
Apply
</Button>
</div>
</>
);
}
}
return (
<>
<div className="grade-filter-inputs">
<PercentGroup
id="minimum-grade"
label={formatMessage(messages.minGrade)}
value={min.value}
onChange={min.onChange}
/>
<PercentGroup
id="maximum-grade"
label={formatMessage(messages.maxGrade)}
value={max.value}
onChange={max.onChange}
/>
</div>
<div className="grade-filter-action">
<Button
variant="outline-secondary"
onClick={handleApplyClick}
disabled={isDisabled}
>
{formatMessage(messages.apply)}
</Button>
</div>
</>
);
};
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;

View File

@@ -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(<CourseFilter updateQueryParams={updateQueryParams} />);
});
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(<CourseFilter updateQueryParams={updateQueryParams} />);
});
test('snapshot', () => {
expect(el).toMatchSnapshot();
});
it('disables submit', () => {
const props = el.find(Button).props();
expect(props.disabled).toEqual(true);
});
});
});
});

View File

@@ -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(<CourseGradeFilter {...props} />);
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(<CourseGradeFilter {...props} />);
});
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);
});
});
});

View File

@@ -0,0 +1,72 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`StudentGroupsFilter component render snapshot 1`] = `
<Fragment>
<SelectGroup
id="Tracks"
label="Tracks"
onChange={[MockFunction]}
options={
Array [
<option
value="Track-All"
>
Track-All
</option>,
<option
value="v1"
>
n1
</option>,
<option
value="v2"
>
n2
</option>,
<option
value="v3"
>
n3
</option>,
<option
value="v4"
>
n4
</option>,
]
}
value="test-track"
/>
<SelectGroup
disabled={false}
id="Cohorts"
label="Cohorts"
onChange={[MockFunction]}
options={
Array [
<option
value="Cohort-All"
>
Cohort-All
</option>,
<option
value="v1"
>
n1
</option>,
<option
value="v2"
>
n2
</option>,
<option
value="v3"
>
n3
</option>,
]
}
value="test-cohort"
/>
</Fragment>
`;

View File

@@ -1,190 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`StudentGroupsFilter Component snapshots Cohorts group disabled if no cohorts 1`] = `
<React.Fragment>
<SelectGroup
disabled={false}
id="Tracks"
label="Tracks"
onChange={[Function]}
options={
Array [
<option
value="Track-All"
>
Track-All
</option>,
<option
value="TracK1"
>
TracK1
</option>,
<option
value="TracK2"
>
TracK2
</option>,
<option
value="TRACK3"
>
TRACK3
</option>,
]
}
value="TracK2"
/>
<SelectGroup
disabled={true}
id="Cohorts"
label="Cohorts"
onChange={[Function]}
options={
Array [
<option
value="Cohort-All"
>
Cohort-All
</option>,
]
}
value="cohorT3"
/>
</React.Fragment>
`;
exports[`StudentGroupsFilter Component snapshots basic snapshot 1`] = `
<React.Fragment>
<SelectGroup
disabled={false}
id="Tracks"
label="Tracks"
onChange={[MockFunction updateTracks]}
options={
Array [
<option
value="Track-All"
>
Track-All
</option>,
<option
value="TracK1"
>
TracK1
</option>,
<option
value="TracK2"
>
TracK2
</option>,
<option
value="TRACK3"
>
TRACK3
</option>,
]
}
value="TracK2"
/>
<SelectGroup
disabled={false}
id="Cohorts"
label="Cohorts"
onChange={[MockFunction updateCohorts]}
options={
Array [
<option
value="Cohort-All"
>
Cohort-All
</option>,
<option
value="cohorT1"
>
cohorT1
</option>,
<option
value="cohorT2"
>
cohorT2
</option>,
<option
value="cohorT3"
>
cohorT3
</option>,
]
}
value="cohorT3"
/>
</React.Fragment>
`;
exports[`StudentGroupsFilter Component snapshots mapCohortsEntries cohort options: [Cohort-All, <{slug, name}...>] 1`] = `
Array [
<option
value="Cohort-All"
>
Cohort-All
</option>,
<option
value="cohorT1"
>
cohorT1
</option>,
<option
value="cohorT2"
>
cohorT2
</option>,
<option
value="cohorT3"
>
cohorT3
</option>,
]
`;
exports[`StudentGroupsFilter Component snapshots mapTracksEntries cohort options: [Track-All, <{id, name}...>] 1`] = `
Array [
<option
value="Track-All"
>
Track-All
</option>,
<option
value="TracK1"
>
TracK1
</option>,
<option
value="TracK2"
>
TracK2
</option>,
<option
value="TRACK3"
>
TRACK3
</option>,
]
`;
exports[`StudentGroupsFilter optionFactory returns a list of options with a default first entry 1`] = `
Array [
<option
value="All-Ponies"
>
All-Ponies
</option>,
<option
value="RDash"
>
RDash
</option>,
<option
value="PPie"
>
PPie
</option>,
]
`;

View File

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

View File

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

View File

@@ -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';
import useStudentGroupsFilterData from './hooks';
export const optionFactory = ({ data, defaultOption, key }) => [
<option value={defaultOption} key="0">{defaultOption}</option>,
...data.map(
entry => (<option key={entry[key]} value={entry.name}>{entry.name}</option>),
),
];
const mapOptions = ({ value, name }) => (
<option key={name} value={value}>{name}</option>
);
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 (
<>
<SelectGroup
id="Tracks"
label={this.translate(messages.tracks)}
value={this.props.selectedTrackEntry.name}
onChange={this.updateTracks}
options={this.mapTracksEntries()}
/>
<SelectGroup
id="Cohorts"
label={this.translate(messages.cohorts)}
value={this.props.selectedCohortEntry.name}
disabled={this.props.cohorts.length === 0}
onChange={this.updateCohorts}
options={this.mapCohortsEntries()}
/>
</>
);
}
}
StudentGroupsFilter.defaultProps = {
cohorts: [],
cohortsByName: {},
selectedCohortEntry: { name: '' },
selectedTrackEntry: { name: '' },
tracks: [],
tracksByName: {},
export const StudentGroupsFilter = ({ updateQueryParams }) => {
const { tracks, cohorts } = useStudentGroupsFilterData({ updateQueryParams });
const { formatMessage } = useIntl();
return (
<>
<SelectGroup
id="Tracks"
label={formatMessage(messages.tracks)}
value={tracks.value}
onChange={tracks.handleChange}
options={[
<option value={formatMessage(messages.trackAll)} key="0">
{formatMessage(messages.trackAll)}
</option>,
...tracks.entries.map(mapOptions),
]}
/>
<SelectGroup
id="Cohorts"
label={formatMessage(messages.cohorts)}
value={cohorts.value}
disabled={cohorts.isDisabled}
onChange={cohorts.handleChange}
options={[
<option value={formatMessage(messages.cohortAll)} key="0">
{formatMessage(messages.cohortAll)}
</option>,
...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;

View File

@@ -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(<StudentGroupsFilter updateQueryParams={updateQueryParams} />);
});
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);
});
});
});

View File

@@ -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(<StudentGroupsFilter {...props} />);
});
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(<StudentGroupsFilter {...props} />);
});
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(<StudentGroupsFilter {...props} />);
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(<StudentGroupsFilter {...props} />);
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);
});
});
});

View File

@@ -4,29 +4,22 @@ exports[`PercentGroup Component snapshots basic snapshot 1`] = `
<div
className="percent-group"
>
<FormGroup
as="div"
<Form.Group
controlId="group id"
isInvalid={false}
isValid={false}
>
<FormLabel
isInline={false}
>
<Form.Label>
Group Label
</FormLabel>
<ForwardRef
as="input"
</Form.Label>
<Form.Control
disabled={false}
max={100}
min={0}
onChange={[MockFunction props.onChange]}
plaintext={false}
step={1}
type="number"
value="group VALUE"
/>
</FormGroup>
</Form.Group>
<span
className="input-percent-label"
>
@@ -39,29 +32,22 @@ exports[`PercentGroup Component snapshots disabled 1`] = `
<div
className="percent-group"
>
<FormGroup
as="div"
<Form.Group
controlId="group id"
isInvalid={false}
isValid={false}
>
<FormLabel
isInline={false}
>
<Form.Label>
Group Label
</FormLabel>
<ForwardRef
as="input"
</Form.Label>
<Form.Control
disabled={true}
max={100}
min={0}
onChange={[MockFunction props.onChange]}
plaintext={false}
step={1}
type="number"
value="group VALUE"
/>
</FormGroup>
</Form.Group>
<span
className="input-percent-label"
>

View File

@@ -4,22 +4,16 @@ exports[`SelectGroup Component snapshots basic snapshot 1`] = `
<div
className="student-filters"
>
<FormGroup
as="div"
<Form.Group
controlId="group id"
isInvalid={false}
isValid={false}
>
<FormLabel
isInline={false}
>
<Form.Label>
Group Label
</FormLabel>
<ForwardRef
</Form.Label>
<Form.Control
as="select"
disabled={false}
onChange={[MockFunction props.onChange]}
plaintext={false}
value="group VALUE"
>
<option
@@ -40,8 +34,8 @@ exports[`SelectGroup Component snapshots basic snapshot 1`] = `
>
Option 3
</option>
</ForwardRef>
</FormGroup>
</Form.Control>
</Form.Group>
</div>
`;
@@ -49,22 +43,16 @@ exports[`SelectGroup Component snapshots disabled 1`] = `
<div
className="student-filters"
>
<FormGroup
as="div"
<Form.Group
controlId="group id"
isInvalid={false}
isValid={false}
>
<FormLabel
isInline={false}
>
<Form.Label>
Group Label
</FormLabel>
<ForwardRef
</Form.Label>
<Form.Control
as="select"
disabled={true}
onChange={[MockFunction props.onChange]}
plaintext={false}
value="group VALUE"
>
<option
@@ -85,7 +73,7 @@ exports[`SelectGroup Component snapshots disabled 1`] = `
>
Option 3
</option>
</ForwardRef>
</FormGroup>
</Form.Control>
</Form.Group>
</div>
`;

View File

@@ -0,0 +1,70 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`GradebookFilters render snapshot 1`] = `
<Fragment>
<div
className="filter-sidebar-header"
>
<h2>
<Icon
className="fa fa-filter"
/>
</h2>
<IconButton
alt="Close Filters"
aria-label="Close Filters"
className="p-1"
iconAs="Icon"
onClick={[MockFunction hook.closeMenu]}
src={[Function]}
/>
</div>
<Collapsible
className="filter-group mb-3"
defaultOpen={true}
title="Assignments"
>
<div>
<AssignmentTypeFilter
updateQueryParams={[MockFunction]}
/>
<AssignmentFilter
updateQueryParams={[MockFunction]}
/>
<AssignmentGradeFilter
updateQueryParams={[MockFunction]}
/>
</div>
</Collapsible>
<Collapsible
className="filter-group mb-3"
defaultOpen={true}
title="Overall Grade"
>
<CourseGradeFilter
updateQueryParams={[MockFunction]}
/>
</Collapsible>
<Collapsible
className="filter-group mb-3"
defaultOpen={true}
title="Student Groups"
>
<StudentGroupsFilter
updateQueryParams={[MockFunction]}
/>
</Collapsible>
<Collapsible
className="filter-group mb-3"
defaultOpen={true}
title="Include Course Team Members"
>
<Form.Checkbox
checked={true}
onChange={[MockFunction hook.handleChange]}
>
Include Course Team Members
</Form.Checkbox>
</Collapsible>
</Fragment>
`;

View File

@@ -1,98 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`GradebookFilters Component snapshots basic snapshot 1`] = `
<React.Fragment>
<div
className="filter-sidebar-header"
>
<h2>
<Icon
className="fa fa-filter"
/>
</h2>
<IconButton
alt="Close Filters"
aria-label="Close Filters"
className="p-1"
iconAs="Icon"
onClick={[MockFunction this.props.closeMenu]}
src="paragon.icons.Close"
/>
</div>
<Collapsible
className="filter-group mb-3"
defaultOpen={true}
title={
<FormattedMessage
defaultMessage="Assignments"
description="Assignment filter group label in Gradebook Filters"
id="gradebook.GradebookFilters.assignmentsFilterLabel"
/>
}
>
<div>
<AssignmentTypeFilter
updateQueryParams={[MockFunction this.props.updateQueryParams]}
/>
<AssignmentFilter
updateQueryParams={[MockFunction this.props.updateQueryParams]}
/>
<AssignmentGradeFilter
updateQueryParams={[MockFunction this.props.updateQueryParams]}
/>
</div>
</Collapsible>
<Collapsible
className="filter-group mb-3"
defaultOpen={true}
title={
<FormattedMessage
defaultMessage="Overall Grade"
description="Overall Grade filter group label in Gradebook Filters"
id="gradebook.GradebookFilters.overallGradeFilterLabel"
/>
}
>
<CourseGradeFilter
updateQueryParams={[MockFunction this.props.updateQueryParams]}
/>
</Collapsible>
<Collapsible
className="filter-group mb-3"
defaultOpen={true}
title={
<FormattedMessage
defaultMessage="Student Groups"
description="Student Groups filter group label in Gradebook Filters"
id="gradebook.GradebookFilters.studentGroupsFilterLabel"
/>
}
>
<StudentGroupsFilter
updateQueryParams={[MockFunction this.props.updateQueryParams]}
/>
</Collapsible>
<Collapsible
className="filter-group mb-3"
defaultOpen={true}
title={
<FormattedMessage
defaultMessage="Include Course Team Members"
description="Include Course Team Members filter label in Gradebook Filters"
id="gradebook.GradebookFilters.includeCourseTeamMembersFilterLabel"
/>
}
>
<Checkbox
checked={true}
onChange={[MockFunction handleIncludeTeamMembersChange]}
>
<FormattedMessage
defaultMessage="Include Course Team Members"
description="Include Course Team Members filter label in Gradebook Filters"
id="gradebook.GradebookFilters.includeCourseTeamMembersFilterLabel"
/>
</Checkbox>
</Collapsible>
</React.Fragment>
`;

View File

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

View File

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

View File

@@ -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);
}
export const GradebookFilters = ({ updateQueryParams }) => {
const {
closeMenu,
includeCourseTeamMembers,
} = useGradebookFiltersData({ updateQueryParams });
const { formatMessage } = useIntl();
const collapsibleClassName = 'filter-group mb-3';
return (
<>
<div className="filter-sidebar-header">
<h2><Icon className="fa fa-filter" /></h2>
<IconButton
className="p-1"
onClick={closeMenu}
iconAs={Icon}
src={Close}
alt={formatMessage(messages.closeFilters)}
aria-label={formatMessage(messages.closeFilters)}
/>
</div>
handleIncludeTeamMembersChange(event) {
const includeCourseRoleMembers = event.target.checked;
this.setState({ includeCourseRoleMembers });
this.props.updateIncludeCourseRoleMembers(includeCourseRoleMembers);
this.props.fetchGrades();
this.props.updateQueryParams({ includeCourseRoleMembers });
}
collapsibleGroup = (title, content) => (
<Collapsible
title={<FormattedMessage {...title} />}
defaultOpen
className="filter-group mb-3"
>
{content}
</Collapsible>
);
render() {
const {
intl,
updateQueryParams,
} = this.props;
return (
<>
<div className="filter-sidebar-header">
<h2><Icon className="fa fa-filter" /></h2>
<IconButton
className="p-1"
onClick={this.props.closeMenu}
iconAs={Icon}
src={Close}
alt={intl.formatMessage(messages.closeFilters)}
aria-label={intl.formatMessage(messages.closeFilters)}
/>
<Collapsible
title={formatMessage(messages.assignments)}
defaultOpen
className={collapsibleClassName}
>
<div>
<AssignmentTypeFilter updateQueryParams={updateQueryParams} />
<AssignmentFilter updateQueryParams={updateQueryParams} />
<AssignmentGradeFilter updateQueryParams={updateQueryParams} />
</div>
</Collapsible>
{this.collapsibleGroup(
messages.assignments, (
<div>
<AssignmentTypeFilter updateQueryParams={updateQueryParams} />
<AssignmentFilter updateQueryParams={updateQueryParams} />
<AssignmentGradeFilter updateQueryParams={updateQueryParams} />
</div>
),
)}
<Collapsible
title={formatMessage(messages.overallGrade)}
defaultOpen
className={collapsibleClassName}
>
<CourseGradeFilter updateQueryParams={updateQueryParams} />
</Collapsible>
{this.collapsibleGroup(
messages.overallGrade, (
<CourseGradeFilter updateQueryParams={updateQueryParams} />
),
)}
<Collapsible
title={formatMessage(messages.studentGroups)}
defaultOpen
className={collapsibleClassName}
>
<StudentGroupsFilter updateQueryParams={updateQueryParams} />
</Collapsible>
{this.collapsibleGroup(
messages.studentGroups, (
<StudentGroupsFilter updateQueryParams={updateQueryParams} />
),
)}
{this.collapsibleGroup(
messages.includeCourseTeamMembers, (
<Form.Checkbox
checked={this.state.includeCourseRoleMembers}
onChange={this.handleIncludeTeamMembersChange}
>
<FormattedMessage {...messages.includeCourseTeamMembers} />
</Form.Checkbox>
),
)}
</>
);
}
}
GradebookFilters.defaultProps = {
includeCourseRoleMembers: false,
<Collapsible
title={formatMessage(messages.includeCourseTeamMembers)}
defaultOpen
className={collapsibleClassName}
>
<Form.Checkbox
checked={includeCourseTeamMembers.value}
onChange={includeCourseTeamMembers.handleChange}
>
{formatMessage(messages.includeCourseTeamMembers)}
</Form.Checkbox>
</Collapsible>
</>
);
};
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;

View File

@@ -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(<GradebookFilters updateQueryParams={updateQueryParams} />);
});
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(
<div>
<AssignmentTypeFilter updateQueryParams={updateQueryParams} />
<AssignmentFilter updateQueryParams={updateQueryParams} />
<AssignmentGradeFilter updateQueryParams={updateQueryParams} />
</div>,
));
});
test('CourseGrade filters', () => {
expect(el.find(Collapsible).at(1).children()).toMatchObject(shallow(
<CourseGradeFilter updateQueryParams={updateQueryParams} />,
));
});
test('StudentGroups filters', () => {
expect(el.find(Collapsible).at(2).children()).toMatchObject(shallow(
<StudentGroupsFilter updateQueryParams={updateQueryParams} />,
));
});
test('includeCourseTeamMembers', () => {
const checkbox = el.find(Collapsible).at(3).children();
expect(checkbox.props()).toEqual({
checked: true,
onChange: hookProps.includeCourseTeamMembers.handleChange,
children: formatMessage(messages.includeCourseTeamMembers),
});
});
});
});

View File

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

View File

@@ -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(<GradebookFilters {...props} />);
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(<GradebookFilters {...props} />);
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,
);
});
});
});
});

View File

@@ -2,46 +2,23 @@
exports[`EditModal Component snapshots gradeOverrideHistoryError is and empty and open is true modal open and StatusAlert showing 1`] = `
<ModalDialog
closeLabel="Close"
hasCloseButton={true}
isBlocking={false}
isFullscreenOnMobile={true}
isFullscreenScroll={false}
isOpen={true}
onClose={[MockFunction this.closeAssignmentModal]}
size="xl"
title="Edit Grades"
variant="default"
>
<ModalDialogBody
as="div"
>
<ModalDialog.Body>
<div>
<ModalHeaders />
<ForwardRef
closeLabel="Dismiss"
<Alert
dismissible={false}
onClose={[Function]}
show={true}
stacked={false}
transition={
Object {
"$$typeof": Symbol(react.forward_ref),
"defaultProps": Object {
"appear": false,
"in": false,
"mountOnEnter": false,
"timeout": 300,
"unmountOnExit": false,
},
"displayName": "Fade",
"render": [Function],
}
}
variant="danger"
>
Weve been trying to contact you regarding...
</ForwardRef>
</Alert>
<OverrideTable />
<div>
<FormattedMessage
@@ -58,37 +35,10 @@ exports[`EditModal Component snapshots gradeOverrideHistoryError is and empty an
/>
</div>
</div>
</ModalDialogBody>
<ModalDialogFooter
as="div"
>
<ActionRow
as="div"
isStacked={false}
>
<ForwardRef
as={
Object {
"$$typeof": Symbol(react.forward_ref),
"Deprecated": [Function],
"defaultProps": Object {
"active": false,
"children": undefined,
"className": undefined,
"disabled": false,
"iconAfter": undefined,
"iconBefore": undefined,
"variant": "primary",
},
"propTypes": Object {
"children": [Function],
"className": [Function],
"iconAfter": [Function],
"iconBefore": [Function],
},
"render": [Function],
}
}
</ModalDialog.Body>
<ModalDialog.Footer>
<ActionRow>
<ModalDialog.CloseButton
variant="tertiary"
>
<FormattedMessage
@@ -96,10 +46,8 @@ exports[`EditModal Component snapshots gradeOverrideHistoryError is and empty an
description="Edit Modal close button text"
id="gradebook.GradesView.EditModal.closeText"
/>
</ForwardRef>
<ForwardRef
active={false}
disabled={false}
</ModalDialog.CloseButton>
<Button
onClick={[MockFunction this.handleAdjustedGradeClick]}
variant="primary"
>
@@ -108,54 +56,31 @@ exports[`EditModal Component snapshots gradeOverrideHistoryError is and empty an
description="Edit Modal Save button label"
id="gradebook.GradesView.EditModal.saveGrade"
/>
</ForwardRef>
</Button>
</ActionRow>
</ModalDialogFooter>
</ModalDialog.Footer>
</ModalDialog>
`;
exports[`EditModal Component snapshots gradeOverrideHistoryError is empty and open is false modal closed and StatusAlert closed 1`] = `
<ModalDialog
closeLabel="Close"
hasCloseButton={true}
isBlocking={false}
isFullscreenOnMobile={true}
isFullscreenScroll={false}
isOpen={false}
onClose={[MockFunction this.closeAssignmentModal]}
size="xl"
title="Edit Grades"
variant="default"
>
<ModalDialogBody
as="div"
>
<ModalDialog.Body>
<div>
<ModalHeaders />
<ForwardRef
closeLabel="Dismiss"
<Alert
dismissible={false}
onClose={[Function]}
show={false}
stacked={false}
transition={
Object {
"$$typeof": Symbol(react.forward_ref),
"defaultProps": Object {
"appear": false,
"in": false,
"mountOnEnter": false,
"timeout": 300,
"unmountOnExit": false,
},
"displayName": "Fade",
"render": [Function],
}
}
variant="danger"
>
</ForwardRef>
</Alert>
<OverrideTable />
<div>
<FormattedMessage
@@ -172,37 +97,10 @@ exports[`EditModal Component snapshots gradeOverrideHistoryError is empty and op
/>
</div>
</div>
</ModalDialogBody>
<ModalDialogFooter
as="div"
>
<ActionRow
as="div"
isStacked={false}
>
<ForwardRef
as={
Object {
"$$typeof": Symbol(react.forward_ref),
"Deprecated": [Function],
"defaultProps": Object {
"active": false,
"children": undefined,
"className": undefined,
"disabled": false,
"iconAfter": undefined,
"iconBefore": undefined,
"variant": "primary",
},
"propTypes": Object {
"children": [Function],
"className": [Function],
"iconAfter": [Function],
"iconBefore": [Function],
},
"render": [Function],
}
}
</ModalDialog.Body>
<ModalDialog.Footer>
<ActionRow>
<ModalDialog.CloseButton
variant="tertiary"
>
<FormattedMessage
@@ -210,10 +108,8 @@ exports[`EditModal Component snapshots gradeOverrideHistoryError is empty and op
description="Edit Modal close button text"
id="gradebook.GradesView.EditModal.closeText"
/>
</ForwardRef>
<ForwardRef
active={false}
disabled={false}
</ModalDialog.CloseButton>
<Button
onClick={[MockFunction this.handleAdjustedGradeClick]}
variant="primary"
>
@@ -222,8 +118,8 @@ exports[`EditModal Component snapshots gradeOverrideHistoryError is empty and op
description="Edit Modal Save button label"
id="gradebook.GradesView.EditModal.saveGrade"
/>
</ForwardRef>
</Button>
</ActionRow>
</ModalDialogFooter>
</ModalDialog.Footer>
</ModalDialog>
`;

View File

@@ -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';
/**
* <ImportGradesButton />
* 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 (
<>
<Form action={gradeExportUrl} method="post">
<FormGroup controlId="csv">
<FormControl
className="d-none"
type="file"
label={<FormattedMessage {...messages.csvUploadLabel} />}
onChange={this.handleFileInputChange}
ref={this.fileInputRef}
/>
</FormGroup>
</Form>
<NetworkButton
className="import-grades-btn"
label={messages.importGradesBtnText}
onClick={this.handleClickImportGrades}
import
/>
</>
);
}
}
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);

View File

@@ -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(<ImportGradesButton {...props} />);
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(
<ImportGradesButton {...props} />,
{ 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(
<ImportGradesButton {...props} />,
{ 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(
<ImportGradesButton {...props} />,
{ 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(<ImportGradesButton {...props} submitImportGradesButtonData={submit} />);
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);
});
});
});

View File

@@ -0,0 +1,28 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ImportGradesButton component render snapshot 1`] = `
<Fragment>
<Form
action="test-grade-export-url"
method="post"
>
<Form.Group
controlId="csv"
>
<Form.Control
className="d-none"
data-testid="file-control"
label="Upload Grade CSV"
onChange={[MockFunction]}
type="file"
/>
</Form.Group>
</Form>
<NetworkButton
className="import-grades-btn"
import={true}
label="Import Grades"
onClick={[MockFunction]}
/>
</Fragment>
`;

View File

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

View File

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

View File

@@ -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';
/**
* <ImportGradesButton />
* 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 (
<>
<Form action={gradeExportUrl} method="post">
<Form.Group controlId="csv">
<Form.Control
data-testid="file-control"
className="d-none"
type="file"
label={formatMessage(messages.csvUploadLabel)}
onChange={handleFileInputChange}
ref={fileInputRef}
/>
</Form.Group>
</Form>
<NetworkButton
className="import-grades-btn"
label={formatMessage(messages.importGradesBtnText)}
onClick={handleClickImportGrades}
import
/>
</>
);
};
ImportGradesButton.propTypes = {};
export default ImportGradesButton;

View File

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

View File

@@ -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(<ImportGradesButton />);
const input = el.getByTestId('file-control');
expect(input).toEqual(props.fileInputRef.current);
});
});

View File

@@ -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`] = `
<React.Fragment>
<Form
action="fakeUrl"
inline={false}
method="post"
>
<FormGroup
as="div"
controlId="csv"
isInvalid={false}
isValid={false}
>
<ForwardRef
as="input"
className="d-none"
label={
<FormattedMessage
defaultMessage="Upload Grade CSV"
description="A labeled button to upload a CSV containing course grades."
id="gradebook.BulkManagementHistoryView.csvUploadLabel"
/>
}
onChange={[MockFunction this.handleFileInputChange]}
plaintext={false}
type="file"
/>
</FormGroup>
</Form>
<NetworkButton
className="import-grades-btn"
import={true}
label={
Object {
"defaultMessage": "Import Grades",
"description": "A labeled button to import grades in the BulkManagement Tab File Upload Form",
"id": "gradebook.GradesView.importGradesBtnText",
}
}
onClick={[MockFunction this.handleClickImportGrades]}
/>
</React.Fragment>
`;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 }) => (
<Helmet>
<title>
{intl.formatMessage(messages['gradebook.page.title'], { siteName: getConfig().SITE_NAME })}
</title>
<link rel="shortcut icon" href={getConfig().FAVICON_URL} type="image/x-icon" />
</Helmet>
);
Head.propTypes = {
intl: intlShape.isRequired,
const Head = () => {
const { formatMessage } = useIntl();
return (
<Helmet>
<title>
{formatMessage(messages['gradebook.page.title'], { siteName: getConfig().SITE_NAME })}
</title>
<link rel="shortcut icon" href={getConfig().FAVICON_URL} type="image/x-icon" />
</Helmet>
);
};
export default injectIntl(Head);
Head.propTypes = {
};
export default Head;

View File

@@ -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(<IntlProvider locale="en"><Head {...props} /></IntlProvider>);
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(<Head />);
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);
});
});

View File

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

View File

@@ -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 <format-message-function {...{ message: msg, values }} />;
}
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 });
}
}

View File

@@ -1,2 +1,3 @@
/* eslint-disable import/prefer-default-export */
export { default as StrictDict } from './StrictDict';
export { default as keyStore } from './keyStore';

10
src/utils/keyStore.js Normal file
View File

@@ -0,0 +1,10 @@
import StrictDict from './StrictDict';
const keyStore = (collection) => StrictDict(
Object.keys(collection).reduce(
(obj, key) => ({ ...obj, [key]: key }),
{},
),
);
export default keyStore;