Compare commits
354 Commits
v1.2.1
...
open-relea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88d6ecd405 | ||
|
|
3e0879e66e | ||
|
|
1eb08d7ea0 | ||
|
|
7aef4ca9a7 | ||
|
|
edbbe42f3b | ||
|
|
3ee7e40ecd | ||
|
|
310d0844fd | ||
|
|
7fd06c2373 | ||
|
|
45b922d6e6 | ||
|
|
12574a7561 | ||
|
|
8f1c89a025 | ||
|
|
a1de3a8612 | ||
|
|
4e26247ac3 | ||
|
|
f21e6da598 | ||
|
|
650e9321b1 | ||
|
|
e8a8cca483 | ||
|
|
f5d2a34660 | ||
|
|
97d3a29a7f | ||
|
|
5b8f67e8d2 | ||
|
|
c6e33307ba | ||
|
|
a4df8f7238 | ||
|
|
15b76edb5d | ||
|
|
1ab2fee004 | ||
|
|
9c9ba45fec | ||
|
|
85d566d257 | ||
|
|
5688adcd57 | ||
|
|
b4f4a27f73 | ||
|
|
868048381b | ||
|
|
02c154ef50 | ||
|
|
7acefe0468 | ||
|
|
a836cc1b5b | ||
|
|
6a3db4a11b | ||
|
|
d727420c37 | ||
|
|
2029a7cef3 | ||
|
|
8462249d55 | ||
|
|
40059ec41e | ||
|
|
2ee522352e | ||
|
|
189152f51b | ||
|
|
3bc2511cc1 | ||
|
|
f60e3c1188 | ||
|
|
807a57d947 | ||
|
|
0c242ab6f0 | ||
|
|
ee2c573017 | ||
|
|
4fdc541992 | ||
|
|
658b45136e | ||
|
|
61fdb31316 | ||
|
|
93f45d0784 | ||
|
|
6c88291626 | ||
|
|
621c297f1a | ||
|
|
76b349e377 | ||
|
|
d88475aab5 | ||
|
|
ddad9d9513 | ||
|
|
9f7e29ed76 | ||
|
|
539202f511 | ||
|
|
c42a995b11 | ||
|
|
78644daf26 | ||
|
|
7fd38dbcf1 | ||
|
|
62aad2aa2f | ||
|
|
12d32efe08 | ||
|
|
c60358941e | ||
|
|
1345666e53 | ||
|
|
c4bd8dc416 | ||
|
|
83986ea994 | ||
|
|
f891f90f77 | ||
|
|
313840fa10 | ||
|
|
84a7531530 | ||
|
|
27296449b4 | ||
|
|
2b37919222 | ||
|
|
384d6cc296 | ||
|
|
a0943b3946 | ||
|
|
8bc1fc82f2 | ||
|
|
1c26aa1d71 | ||
|
|
582b6cb1c5 | ||
|
|
bc04f6d86f | ||
|
|
84f1efefb3 | ||
|
|
e9f01ea3a3 | ||
|
|
100fbc08bf | ||
|
|
500364dc99 | ||
|
|
609c0a8d3a | ||
|
|
5f81624342 | ||
|
|
647ecbab75 | ||
|
|
de539382bd | ||
|
|
cc01ab0a92 | ||
|
|
8881e62337 | ||
|
|
92e7cc39cd | ||
|
|
d6d09205f4 | ||
|
|
b2e4e330bf | ||
|
|
d10dc54116 | ||
|
|
15d7dcfe85 | ||
|
|
6717663c07 | ||
|
|
ac229ebc85 | ||
|
|
4c481721bc | ||
|
|
40f52b2dc9 | ||
|
|
a25e446998 | ||
|
|
326ae93ed7 | ||
|
|
95e9b51aca | ||
|
|
4d76329946 | ||
|
|
5c565bebb0 | ||
|
|
d1ca314565 | ||
|
|
677521808b | ||
|
|
139a0de6a6 | ||
|
|
42b4a5b3dd | ||
|
|
9b9c703214 | ||
|
|
d46f4da9d5 | ||
|
|
fa3826b452 | ||
|
|
fd3eb71820 | ||
|
|
3dff787b37 | ||
|
|
ac56ab766b | ||
|
|
dbe3dfa323 | ||
|
|
9e0c326dfc | ||
|
|
351bf48561 | ||
|
|
30c51668c4 | ||
|
|
9bc86fc4f6 | ||
|
|
aa39fcc7e0 | ||
|
|
f7fcaef03a | ||
|
|
6f9c051ded | ||
|
|
dabd923b10 | ||
|
|
da60ff9f1d | ||
|
|
f9d5987488 | ||
|
|
493d5df8fa | ||
|
|
cf4f806d76 | ||
|
|
ee274a2fb0 | ||
|
|
baa26f3b20 | ||
|
|
0f7ffcbec1 | ||
|
|
f5616f77fd | ||
|
|
ecf5ef4664 | ||
|
|
480ea1915c | ||
|
|
a646b7b77c | ||
|
|
a2503d7067 | ||
|
|
7413c2100f | ||
|
|
8733ab5e1f | ||
|
|
8dc5836daa | ||
|
|
993809b5b0 | ||
|
|
9665859ea2 | ||
|
|
386bbea84b | ||
|
|
ee8e56a792 | ||
|
|
80aa4f10a0 | ||
|
|
2d0bf8c322 | ||
|
|
eaa2db5019 | ||
|
|
de6b2f1219 | ||
|
|
12db9448d8 | ||
|
|
52da367d18 | ||
|
|
e8cbc66e6f | ||
|
|
bc39443d94 | ||
|
|
15cdd7a256 | ||
|
|
81601ca6ea | ||
|
|
c773c35bbc | ||
|
|
b4e77b2b3f | ||
|
|
56b90cd223 | ||
|
|
7fa24969ac | ||
|
|
924dcda088 | ||
|
|
18f9ffcfde | ||
|
|
1e7871795c | ||
|
|
cd309c47e1 | ||
|
|
5d9d2af578 | ||
|
|
c5306296c9 | ||
|
|
49e3fd23d7 | ||
|
|
7bdd1533c3 | ||
|
|
22cf805960 | ||
|
|
be84c91981 | ||
|
|
4434857ada | ||
|
|
7764f78386 | ||
|
|
b4a5288b4b | ||
|
|
caea72b92e | ||
|
|
93128ac728 | ||
|
|
05bd0d740c | ||
|
|
f041d6b16d | ||
|
|
b90df949b9 | ||
|
|
9c1f5ed239 | ||
|
|
f38cd91fb1 | ||
|
|
588528abf6 | ||
|
|
d62b9ca520 | ||
|
|
ec08ef4bcb | ||
|
|
d5b3edc850 | ||
|
|
4f7d764d20 | ||
|
|
2dacc2c037 | ||
|
|
88fa2e60f4 | ||
|
|
2822e36b7b | ||
|
|
f46fe3f8ee | ||
|
|
e787d3a697 | ||
|
|
d77737f132 | ||
|
|
9ed5c6cb34 | ||
|
|
5b16a5dbb2 | ||
|
|
2c9c505b32 | ||
|
|
9425e06f8f | ||
|
|
e7134d8f68 | ||
|
|
e547d67f19 | ||
|
|
8b1d5aacf2 | ||
|
|
5a243e1b5f | ||
|
|
a957e16424 | ||
|
|
0d12eaf979 | ||
|
|
3e2787b0e0 | ||
|
|
35c632b716 | ||
|
|
801e2d6d59 | ||
|
|
7bd81d7a2b | ||
|
|
2b8ecf2c78 | ||
|
|
de13ac5093 | ||
|
|
ade906896c | ||
|
|
43181e39cc | ||
|
|
f60a9647b4 | ||
|
|
7dc1ffdd42 | ||
|
|
2913f1d965 | ||
|
|
7f85fb12e3 | ||
|
|
90f66d3759 | ||
|
|
fb09bee8d9 | ||
|
|
e31495b00b | ||
|
|
0557f29e0b | ||
|
|
e08b13f218 | ||
|
|
dee42eee7e | ||
|
|
1a0fe945a5 | ||
|
|
704144f9d1 | ||
|
|
a1ab29702f | ||
|
|
f619308ce5 | ||
|
|
c235da8cc0 | ||
|
|
777e0696ee | ||
|
|
95b8ba146e | ||
|
|
8463a28c7d | ||
|
|
bdee388dbd | ||
|
|
e0633dc816 | ||
|
|
668620a5f0 | ||
|
|
cfbb02a54c | ||
|
|
b22b44bbf5 | ||
|
|
80d9e32fba | ||
|
|
ef8975df86 | ||
|
|
09e482e893 | ||
|
|
d4421d47fc | ||
|
|
0ef8e773cc | ||
|
|
1dac20b866 | ||
|
|
ed2d715ce0 | ||
|
|
c82c49ea59 | ||
|
|
a9f8aec5f9 | ||
|
|
a63e9a5347 | ||
|
|
2581812118 | ||
|
|
c4fe803a95 | ||
|
|
93be5329ca | ||
|
|
80ba7e7152 | ||
|
|
f88526aa3a | ||
|
|
c0f08eee58 | ||
|
|
ef62ea35dc | ||
|
|
34eaa31776 | ||
|
|
a7316e6824 | ||
|
|
c0ab04f20c | ||
|
|
ed72e7c203 | ||
|
|
223d9a00bd | ||
|
|
8379f48e50 | ||
|
|
9e1268e388 | ||
|
|
57e0f2254a | ||
|
|
2cc14191b4 | ||
|
|
603dbeb823 | ||
|
|
55cb1f4140 | ||
|
|
55648a62ff | ||
|
|
62f9d24704 | ||
|
|
f036b0cf34 | ||
|
|
67493d1e9e | ||
|
|
e5bca7e526 | ||
|
|
52c5357ce7 | ||
|
|
d469cc2de7 | ||
|
|
86092f22b3 | ||
|
|
c8cb07228f | ||
|
|
a1946e7bc4 | ||
|
|
01d80e0fff | ||
|
|
e6da087e83 | ||
|
|
ac5eaed5cb | ||
|
|
88997ca242 | ||
|
|
d5daf9086f | ||
|
|
8a01a60d63 | ||
|
|
66cdcc7f2a | ||
|
|
0c73d66666 | ||
|
|
28e3e6d0e6 | ||
|
|
6473bafa3d | ||
|
|
167901e665 | ||
|
|
50a0d6e579 | ||
|
|
a284c286f5 | ||
|
|
dd967e703c | ||
|
|
725dc071e3 | ||
|
|
3da7730f23 | ||
|
|
bea36fb387 | ||
|
|
1408b0ae7e | ||
|
|
7b817a4234 | ||
|
|
a762c47d77 | ||
|
|
aecb93c252 | ||
|
|
5a489b1bd5 | ||
|
|
5c642a1be5 | ||
|
|
9a0e0e0ece | ||
|
|
7486a342e2 | ||
|
|
fd807c54f8 | ||
|
|
9b894b502f | ||
|
|
afd9692f6b | ||
|
|
8f77dea222 | ||
|
|
3bf3acaaec | ||
|
|
f1ab3d0330 | ||
|
|
8d89bc16b1 | ||
|
|
d93476c198 | ||
|
|
b46d47286b | ||
|
|
4aecfce14a | ||
|
|
14f7ad01b8 | ||
|
|
104cb30ef5 | ||
|
|
e9bc4cebe4 | ||
|
|
559180592c | ||
|
|
0f1f0ae89d | ||
|
|
a812ee3816 | ||
|
|
2e725e0441 | ||
|
|
a1c2ccc539 | ||
|
|
a70ddd79f6 | ||
|
|
dd82054bbc | ||
|
|
6a4bc67841 | ||
|
|
adfefac85d | ||
|
|
c92144c436 | ||
|
|
ca0156ea4c | ||
|
|
61c4bc11bd | ||
|
|
db25a18f9d | ||
|
|
0d7fa18acd | ||
|
|
012bb3a1f3 | ||
|
|
de233e0285 | ||
|
|
ae7544cd53 | ||
|
|
14df81b312 | ||
|
|
4706cfcd94 | ||
|
|
1f5a2469b2 | ||
|
|
e31c670938 | ||
|
|
db9f683297 | ||
|
|
7a43cdcaea | ||
|
|
d5637a4550 | ||
|
|
0b9fa36fb7 | ||
|
|
7bd0c49c14 | ||
|
|
44f91bb453 | ||
|
|
d8f229838f | ||
|
|
7b5a095898 | ||
|
|
ff7937c2d7 | ||
|
|
d057497105 | ||
|
|
e1402b0d4f | ||
|
|
3ea337e3f8 | ||
|
|
9c2c16e378 | ||
|
|
7e9ef204a7 | ||
|
|
f779e7fd35 | ||
|
|
a5a62922b5 | ||
|
|
2c6aa96f8e | ||
|
|
30e866128f | ||
|
|
ec81eb47d9 | ||
|
|
cd2a5ae903 | ||
|
|
7a02330e9e | ||
|
|
a929194a29 | ||
|
|
febf4d99c6 | ||
|
|
83ed8ab875 | ||
|
|
6563f54590 | ||
|
|
e1fe31dc94 | ||
|
|
8754263584 | ||
|
|
c660bd8d15 | ||
|
|
df32123f34 | ||
|
|
e81db01be2 | ||
|
|
05c3468d93 | ||
|
|
1687a6ca1a | ||
|
|
1a88343be9 | ||
|
|
6f752f3a18 | ||
|
|
7bbc9a84dc |
17
.babelrc
17
.babelrc
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"presets": [
|
||||
[
|
||||
"env",
|
||||
{
|
||||
"targets": {
|
||||
"browsers": ["last 2 versions", "ie 11"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"babel-preset-react"
|
||||
],
|
||||
"plugins": [
|
||||
"transform-object-rest-spread",
|
||||
"transform-class-properties"
|
||||
]
|
||||
}
|
||||
32
.env
Normal file
32
.env
Normal file
@@ -0,0 +1,32 @@
|
||||
NODE_ENV='production'
|
||||
NODE_PATH=./src
|
||||
BASE_URL=''
|
||||
LMS_BASE_URL=''
|
||||
LOGIN_URL=''
|
||||
LOGOUT_URL=''
|
||||
CSRF_TOKEN_API_PATH=''
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT=''
|
||||
DATA_API_BASE_URL=''
|
||||
SEGMENT_KEY=''
|
||||
FEATURE_FLAGS={}
|
||||
ACCESS_TOKEN_COOKIE_NAME=''
|
||||
NEW_RELIC_APP_ID=''
|
||||
NEW_RELIC_LICENSE_KEY=''
|
||||
SITE_NAME=''
|
||||
MARKETING_SITE_BASE_URL=''
|
||||
SUPPORT_URL=''
|
||||
CONTACT_URL=''
|
||||
OPEN_SOURCE_URL=''
|
||||
TERMS_OF_SERVICE_URL=''
|
||||
PRIVACY_POLICY_URL=''
|
||||
FACEBOOK_URL=''
|
||||
TWITTER_URL=''
|
||||
YOU_TUBE_URL=''
|
||||
LINKED_IN_URL=''
|
||||
REDDIT_URL=''
|
||||
APPLE_APP_STORE_URL=''
|
||||
GOOGLE_PLAY_URL=''
|
||||
ENTERPRISE_MARKETING_URL=''
|
||||
ENTERPRISE_MARKETING_UTM_SOURCE=''
|
||||
ENTERPRISE_MARKETING_UTM_CAMPAIGN=''
|
||||
ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM=''
|
||||
39
.env.development
Normal file
39
.env.development
Normal file
@@ -0,0 +1,39 @@
|
||||
NODE_ENV='development'
|
||||
PORT=1994
|
||||
BASE_URL='localhost:1994'
|
||||
LMS_BASE_URL='http://localhost:18000'
|
||||
LOGIN_URL='http://localhost:18000/login'
|
||||
LOGOUT_URL='http://localhost:18000/login'
|
||||
LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
|
||||
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
|
||||
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
|
||||
LOGO_POWERED_BY_OPEN_EDX_URL_SVG=https://edx-cdn.org/v3/stage/open-edx-tag.svg
|
||||
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
|
||||
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||
SITE_NAME=localhost
|
||||
DATA_API_BASE_URL='http://localhost:8000'
|
||||
// LMS_CLIENT_ID should match the lms DOT client application id your LMS containe
|
||||
LMS_CLIENT_ID='login-service-client-id'
|
||||
SEGMENT_KEY=''
|
||||
FEATURE_FLAGS={}
|
||||
MARKETING_SITE_BASE_URL='http://localhost:18000'
|
||||
SUPPORT_URL='http://localhost:18000/support'
|
||||
CONTACT_URL='http://localhost:18000/contact'
|
||||
OPEN_SOURCE_URL='http://localhost:18000/openedx'
|
||||
ORDER_HISTORY_URL='http://localhost:1996/orders'
|
||||
TERMS_OF_SERVICE_URL='http://localhost:18000/terms-of-service'
|
||||
PRIVACY_POLICY_URL='http://localhost:18000/privacy-policy'
|
||||
FACEBOOK_URL='https://www.facebook.com'
|
||||
TWITTER_URL='https://twitter.com'
|
||||
YOU_TUBE_URL='https://www.youtube.com'
|
||||
LINKED_IN_URL='https://www.linkedin.com'
|
||||
REDDIT_URL='https://www.reddit.com'
|
||||
APPLE_APP_STORE_URL='https://www.apple.com/ios/app-store/'
|
||||
GOOGLE_PLAY_URL='https://play.google.com/store'
|
||||
ENTERPRISE_MARKETING_URL='http://example.com'
|
||||
ENTERPRISE_MARKETING_UTM_SOURCE='example.com'
|
||||
ENTERPRISE_MARKETING_UTM_CAMPAIGN='example.com Referral'
|
||||
ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM='Footer'
|
||||
@@ -1,3 +1,5 @@
|
||||
coverage/*
|
||||
dist/
|
||||
node_modules/
|
||||
src/postcss.config.js
|
||||
src/segment.js
|
||||
|
||||
24
.eslintrc
24
.eslintrc
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"extends": "eslint-config-edx",
|
||||
"parser": "babel-eslint",
|
||||
"rules": {
|
||||
"import/no-extraneous-dependencies": [
|
||||
"error",
|
||||
{
|
||||
"devDependencies": [
|
||||
"config/*.js",
|
||||
"**/*.test.jsx",
|
||||
"**/*.test.js"
|
||||
]
|
||||
}
|
||||
],
|
||||
// https://github.com/evcohen/eslint-plugin-jsx-a11y/issues/340#issuecomment-338424908
|
||||
"jsx-a11y/anchor-is-valid": [ "error", {
|
||||
"components": [ "Link" ],
|
||||
"specialLink": [ "to" ]
|
||||
}]
|
||||
},
|
||||
"env": {
|
||||
"jest": true
|
||||
}
|
||||
}
|
||||
21
.eslintrc.js
Normal file
21
.eslintrc.js
Normal file
@@ -0,0 +1,21 @@
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
|
||||
const config = createConfig('eslint', {
|
||||
rules: {
|
||||
'import/no-named-as-default': 'off',
|
||||
'import/no-named-as-default-member': 'off',
|
||||
'import/no-self-import': 'off',
|
||||
'spaced-comment': ['error', 'always', { 'block': { 'exceptions': ['*'] } }],
|
||||
},
|
||||
});
|
||||
|
||||
config.settings = {
|
||||
"import/resolver": {
|
||||
node: {
|
||||
paths: ["src", "node_modules"],
|
||||
extensions: [".js", ".jsx"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
6
.github/CODEOWNERS
vendored
Normal file
6
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
# Code owners for frontend-app-gradebook, editable gradebook micro-frontend (MFE)
|
||||
|
||||
# These owners will be the default owners for everything in
|
||||
# the repo. Unless a later match takes precedence, they will
|
||||
# be requested for review when someone opens a pull request.
|
||||
* @edx/masters-devs-gta
|
||||
29
.github/pull_request_template.md
vendored
Normal file
29
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
**TL;DR -** [ A short summary of what this PR does and why ]
|
||||
|
||||
JIRA: [JIRA-XXXX](https://openedx.atlassian.net/browse/JIRA-XXXX)
|
||||
|
||||
**What changed?**
|
||||
|
||||
- [ More in depth breakdown of changes ]
|
||||
- [ Peripheral things that got changed ]
|
||||
- [ etc... ]
|
||||
|
||||
**Developer Checklist**
|
||||
- [ ] Test suites passing
|
||||
- [ ] Documentation and test plan updated, if applicable
|
||||
- [ ] Received code-owner approving review
|
||||
- [ ] Bumped version number [package.json](../package.json)
|
||||
|
||||
**Testing Instructions**
|
||||
|
||||
[ How should a reviewer test this PR? ]
|
||||
|
||||
**Reviewer Checklist**
|
||||
|
||||
Collectively, these should be completed by reviewers of this PR:
|
||||
|
||||
- [ ] I've done a visual code review
|
||||
- [ ] I've tested the new functionality
|
||||
|
||||
|
||||
FYI: @edx/masters-devs-gta
|
||||
10
.github/workflows/commitlint.yml
vendored
Normal file
10
.github/workflows/commitlint.yml
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
# Run commitlint on the commit messages in a pull request.
|
||||
|
||||
name: Lint Commit Messages
|
||||
|
||||
on:
|
||||
- pull_request
|
||||
|
||||
jobs:
|
||||
commitlint:
|
||||
uses: edx/.github/.github/workflows/commitlint.yml@master
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -5,9 +5,21 @@ npm-debug.log
|
||||
coverage
|
||||
|
||||
dist/
|
||||
src/i18n/transifex_input.json
|
||||
temp/babel-plugin-react-intl
|
||||
|
||||
### pyenv ###
|
||||
.python-version
|
||||
|
||||
### Emacs ###
|
||||
*~
|
||||
*.swo
|
||||
*.swp
|
||||
|
||||
### Development environments ###
|
||||
.idea
|
||||
.vscode
|
||||
|
||||
### transifex ###
|
||||
src/i18n/transifex_input.json
|
||||
temp
|
||||
|
||||
12
.travis.yml
12
.travis.yml
@@ -1,21 +1,14 @@
|
||||
language: node_js
|
||||
node_js:
|
||||
- lts/*
|
||||
cache:
|
||||
directories:
|
||||
- "~/.npm"
|
||||
node_js: 12
|
||||
notifications:
|
||||
email:
|
||||
recipients:
|
||||
- adusenbery@edx.org
|
||||
- rreilly@edx.org
|
||||
- schen@edx.org
|
||||
- masters-grades@edx.org
|
||||
on_success: never
|
||||
on_failure: always
|
||||
webhooks: https://www.travisbuddy.com/
|
||||
on_success: never
|
||||
before_install:
|
||||
- npm install -g npm@latest
|
||||
- npm install -g greenkeeper-lockfile@1.14.0
|
||||
install:
|
||||
- npm ci
|
||||
@@ -23,6 +16,7 @@ before_script: greenkeeper-lockfile-update
|
||||
after_script: greenkeeper-lockfile-upload
|
||||
script:
|
||||
- make validate-no-uncommitted-package-lock-changes
|
||||
- npm run lint
|
||||
- npm run test
|
||||
- npm run build
|
||||
after_success:
|
||||
|
||||
9
.tx/config
Normal file
9
.tx/config
Normal file
@@ -0,0 +1,9 @@
|
||||
[main]
|
||||
host = https://www.transifex.com
|
||||
|
||||
[edx-platform.frontend-app-gradebook]
|
||||
file_filter = src/i18n/messages/<lang>.json
|
||||
source_file = src/i18n/transifex_input.json
|
||||
source_lang = en
|
||||
type = KEYVALUEJSON
|
||||
|
||||
29
Dockerfile
29
Dockerfile
@@ -1,29 +0,0 @@
|
||||
# Copied from https://github.com/BretFisher/node-docker-good-defaults/blob/master/Dockerfile
|
||||
|
||||
FROM node:8.9.3
|
||||
|
||||
# Create app directory
|
||||
RUN mkdir -p /edx/app
|
||||
|
||||
ARG NODE_ENV=production
|
||||
ENV NODE_ENV $NODE_ENV
|
||||
|
||||
ARG PORT=80
|
||||
ENV PORT $PORT
|
||||
EXPOSE $PORT 1991
|
||||
|
||||
WORKDIR /edx
|
||||
# Install app dependencies
|
||||
# A wildcard is used to ensure both package.json AND package-lock.json are copied
|
||||
# where available (npm@5+)
|
||||
COPY package*.json ./
|
||||
|
||||
# If you are building your code for production
|
||||
# RUN npm install --only=production
|
||||
RUN npm install
|
||||
ENV PATH /edx/app/node_modules/.bin:$PATH
|
||||
|
||||
WORKDIR /edx/app
|
||||
COPY . /edx/app
|
||||
|
||||
ENTRYPOINT npm install && npm run start
|
||||
149
LICENSE
149
LICENSE
@@ -1,23 +1,21 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
@@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
@@ -72,7 +60,7 @@ modification follow.
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
@@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
@@ -635,40 +633,29 @@ the "copyright" line and a pointer to where the full notice is found.
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<http://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
85
Makefile
85
Makefile
@@ -1,32 +1,65 @@
|
||||
shell: ## run a shell on the cookie-cutter container
|
||||
docker exec -it edx.gradebook /bin/bash
|
||||
|
||||
build:
|
||||
docker-compose build
|
||||
|
||||
up: ## bring up cookie-cutter container
|
||||
docker-compose up
|
||||
|
||||
up-detached: ## bring up cookie-cutter container in detached mode
|
||||
docker-compose up -d
|
||||
|
||||
logs: ## show logs for cookie-cutter container
|
||||
docker-compose logs -f
|
||||
|
||||
down: ## stop and remove cookie-cutter container
|
||||
docker-compose down
|
||||
|
||||
npm-install-%: ## install specified % npm package on the cookie-cutter container
|
||||
docker exec npm install $* --save-dev
|
||||
npm-install-%: ## install specified % npm package
|
||||
npm install $* --save-dev
|
||||
git add package.json
|
||||
|
||||
restart:
|
||||
make down
|
||||
make up
|
||||
transifex_resource = frontend-app-gradebook
|
||||
transifex_langs = "ar,fr,es_419,zh_CN"
|
||||
|
||||
restart-detached:
|
||||
make down
|
||||
make up-detached
|
||||
transifex_utils = ./node_modules/.bin/transifex-utils.js
|
||||
i18n = ./src/i18n
|
||||
transifex_input = $(i18n)/transifex_input.json
|
||||
tx_url1 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/translation/en/strings/
|
||||
tx_url2 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/source/
|
||||
|
||||
# This directory must match .babelrc .
|
||||
transifex_temp = ./temp/babel-plugin-react-intl
|
||||
|
||||
NPM_TESTS=build i18n_extract lint test is-es5
|
||||
|
||||
.PHONY: test
|
||||
test: $(addprefix test.npm.,$(NPM_TESTS)) ## validate ci suite
|
||||
|
||||
.PHONY: test.npm.*
|
||||
test.npm.%: validate-no-uncommitted-package-lock-changes
|
||||
test -d node_modules || $(MAKE) requirements
|
||||
npm run $(*)
|
||||
|
||||
.PHONY: requirements
|
||||
requirements: ## install ci requirements
|
||||
npm ci
|
||||
|
||||
i18n.extract:
|
||||
# Pulling display strings from .jsx files into .json files...
|
||||
rm -rf $(transifex_temp)
|
||||
npm run-script i18n_extract
|
||||
|
||||
i18n.concat:
|
||||
# Gathering JSON messages into one file...
|
||||
$(transifex_utils) $(transifex_temp) $(transifex_input)
|
||||
|
||||
extract_translations: | requirements i18n.extract i18n.concat
|
||||
|
||||
# Despite the name, we actually need this target to detect changes in the incoming translated message files as well.
|
||||
detect_changed_source_translations:
|
||||
# Checking for changed translations...
|
||||
git diff --exit-code $(i18n)
|
||||
|
||||
# Pushes translations to Transifex. You must run make extract_translations first.
|
||||
push_translations:
|
||||
# Pushing strings to Transifex...
|
||||
tx push -s
|
||||
# Fetching hashes from Transifex...
|
||||
./node_modules/reactifex/bash_scripts/get_hashed_strings.sh $(tx_url1)
|
||||
# Writing out comments to file...
|
||||
$(transifex_utils) $(transifex_temp) --comments
|
||||
# Pushing comments to Transifex...
|
||||
./node_modules/reactifex/bash_scripts/put_comments.sh $(tx_url2)
|
||||
|
||||
# Pulls translations from Transifex.
|
||||
pull_translations:
|
||||
tx pull -f --mode reviewed --language=$(transifex_langs)
|
||||
|
||||
# This target is used by Travis.
|
||||
validate-no-uncommitted-package-lock-changes:
|
||||
# Checking for package-lock.json changes...
|
||||
git diff --exit-code package-lock.json
|
||||
|
||||
151
README.md
151
README.md
@@ -1,33 +1,101 @@
|
||||
[](https://travis-ci.org/edx/gradebook) [](https://coveralls.io/github/edx/gradebook)
|
||||
[](@edx/gradebook)
|
||||
[](@edx/gradebook)
|
||||
[](@edx/gradebook)
|
||||
[](https://travis-ci.com/edx/frontend-app-gradebook) [](https://coveralls.io/github/edx/frontend-app-gradebook)
|
||||
[](@edx/frontend-app-gradebook)
|
||||
[](@edx/frontend-app-gradebook)
|
||||
[](@edx/frontend-app-gradebook)
|
||||
[](https://github.com/semantic-release/semantic-release)
|
||||
|
||||
# gradebook
|
||||
# Gradebook
|
||||
|
||||
Please tag **@edx/educator-neem** on any PRs or issues.
|
||||
Gradebook allows course staff to view, filter, and override subsection grades for a course. Additionally for Masters courses, Gradebook enables bulk management of subsection grades.
|
||||
|
||||
## Introduction
|
||||
Jump to:
|
||||
|
||||
The front-end of our editable Gradebook feature.
|
||||
- [Should I use Gradebook in my course?](#should-i-use-gradebook-in-my-course)
|
||||
- [Quickstart](#quickstart)
|
||||
|
||||
## Usage
|
||||
For existing documentation see:
|
||||
|
||||
- Basic Usage: [Review Learner Grades (read-the-docs)](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/student_progress/course_grades.html#review-learner-grades-on-the-instructor-dashboard)
|
||||
- Bulk Grade Management: [Override Learner Subsection Scores in Bulk (read-the-docs)](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/student_progress/course_grades.html#override-learner-subsection-scores-in-bulk)
|
||||
|
||||
## Should I use Gradebook in my course?
|
||||
|
||||
### What does this offer over the legacy gradebook?
|
||||
|
||||
The micro-frontend offers a great deal more granularity when searching for problems, an easy interface for editing grades, an
|
||||
audit trail for seeing who edited what grade and what reason they gave (if any) for doing so.
|
||||
|
||||
UsageProblems can be filtered by student as in the traditional gradebook, but can also be filtered by scores to see who
|
||||
scored within a certain range, and by assignment types (note: Not problem types, but categories like ‘Exams’ or
|
||||
‘Homework’).
|
||||
|
||||
### What does the legacy gradebook offer that this project does not?
|
||||
|
||||
This project does not (yet, at least) create any graphs, which the traditional gradebook does. It also does not give
|
||||
quick links to the problems for the instructor to visit. It expects the instructor to be familiar with the problems they
|
||||
are grading and which unit they refer to.
|
||||
|
||||
The gradebook is expected to be much more performant for larger numbers of students as well. The Instructor Dashboard
|
||||
link for the legacy gradebook reports that "this feature is available only to courses with a small number of enrolled
|
||||
learners." However, this project comes with no such warning.
|
||||
|
||||
### Who should not change to this gradebook?
|
||||
|
||||
Groups whose instructors need not ever manually override grades do not need this project, but may not be any worse off
|
||||
depending on their needs. Instructors that expect to review grades infrequently enough that not having a direct link
|
||||
to the problem in question will have a worse UX than the legacy gradebook provides. Instructors that rely on the graphs
|
||||
generated by the current gradebook might find the lack of autogenerated graphs to be frustrating.
|
||||
|
||||
## Quickstart
|
||||
|
||||
### Installation
|
||||
|
||||
To install gradebook into your project:
|
||||
```
|
||||
npm i --save @edx/gradebook
|
||||
npm i --save @edx/frontend-app-gradebook
|
||||
```
|
||||
|
||||
## Running the UI Standalone
|
||||
|
||||
After cloning the repository, run `make up-detached` in the `gradebook` directory - this will build and start the `gradebook` web application in a docker container.
|
||||
To install the project please refer to the [`edX Developer Stack`](https://github.com/edx/devstack) instructions.
|
||||
|
||||
The web application runs on port **1991**, so when you go to `http://localhost:1991` you should see the UI.
|
||||
The web application runs on port **1994**, so when you go to `http://localhost:1994/course-v1:edX+DemoX+Demo_Course` you should see the UI (assuming you have such a Demo Course in your devstack). Note that you always have to provide a course id to actually see a gradebook.
|
||||
|
||||
If you don't, you can see the log messages for the docker container by executing `make logs` in the `gradebook` directory.
|
||||
If you don't, you can see the log messages for the docker container by executing `make gradebook-logs` in the `devstack` directory.
|
||||
|
||||
Note that `make up-detached` executes the `npm run start` script which will hot-reload JavaScript and Sass files changes, so you should (:crossed_fingers:) not need to do anything (other than wait) when making changes.
|
||||
Note that starting the container executes the `npm run start` script which will hot-reload JavaScript and Sass files changes, so you should (:crossed_fingers:) not need to do anything (other than wait) when making changes.
|
||||
|
||||
## Configuring for local use in edx-platform
|
||||
|
||||
Assuming you've got the UI running at `http://localhost:1994`, you can configure the LMS in edx-platform
|
||||
to point to your local gradebook from the instructor dashboard by putting this setting in `lms/env/private.py`:
|
||||
```
|
||||
WRITABLE_GRADEBOOK_URL = 'http://localhost:1994'
|
||||
```
|
||||
|
||||
There are also several edx-platform waffle and feature flags you'll have to enable from the Django admin:
|
||||
|
||||
1. Grades > Persistent grades enabled flag. Add this flag if it doesn't exist,
|
||||
check the ``enabled`` and ``enabled for all courses`` boxes.
|
||||
|
||||
2. Waffle > Switches. Add the ``grades.assume_zero_grade_if_absent`` switch and make it active.
|
||||
|
||||
3. Waffle_utils > Waffle flag course overrides. Activate waffle flags for courses where you want to enable Gradebook functionality:
|
||||
- Enable Gradebook by adding the ``grades.writable_gradebook`` add checking the ``enabled`` box.
|
||||
- Enable Bulk Grade Management by adding the ``grades.bulk_management`` flag and checking the ``enabled`` box.
|
||||
|
||||
Alternatively, you could add these as regular waffle flags to enable the functionality for all courses.
|
||||
|
||||
**NOTE:** IF the above flags are not configured correctly, the gradebook may appear to work, but will return bogus
|
||||
numbers for grades. If your gradebook isn't accepting your changes, or the changes aren't resulting in sane,
|
||||
recalculated grade values, verify you've set all flags correctly.
|
||||
|
||||
## Running tests
|
||||
|
||||
1. Assuming that you're operating in the context of the edX devstack,
|
||||
run `gradebook-shell` from your devstack directory. This will start a bash shell inside your
|
||||
running gradebook container.
|
||||
2. Run `make test` (which executes `npm run test`). This will run all of the gradebook tests.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
@@ -46,61 +114,6 @@ Note that `make up-detached` executes the `npm run start` script which will hot-
|
||||
* `constants`
|
||||
* `reducers`
|
||||
* Directory for `Redux` reducers
|
||||
* [`.babelrc`](#babelrc)
|
||||
* [`.dockerignore`](#dockerignore)
|
||||
* [`.eslintignore`](#eslintignore)
|
||||
* [`.eslintrc.js`](#eslintrcjs)
|
||||
* `.gitignore`
|
||||
* [`npmignore`](#npmignore)
|
||||
* [`.travis.yml`](#travisyml)
|
||||
* `docker-compose.yml`
|
||||
* `Dockerfile`
|
||||
* `LICENSE`
|
||||
* `Makefile`
|
||||
* `package-lock.json`
|
||||
* [`package.json`](#packagejson)
|
||||
|
||||
### `.babelrc`
|
||||
|
||||
We use [`Babel`](https://babeljs.io/) to transpile `ES2015+` JavaScript to `ES5` JavaScript. `ES5` JavaScript has [greater browser compatibility](http://kangax.github.io/compat-table/es5/) than [`ES2015+`](http://kangax.github.io/compat-table/es6/).
|
||||
|
||||
The `.babelrc` file is used to specify a particular configuration - for example, we use the [`babel-preset-react`](https://babeljs.io/docs/plugins/preset-react/), which, among other things, allows `babel` to parse `JSX`.
|
||||
|
||||
### `.dockerignore`
|
||||
|
||||
The important thing to remember is to add the `node_modules` directory to `.dockerignore` - for more information [see the Docker documentation](https://docs.docker.com/engine/reference/builder/#dockerignore-file).
|
||||
|
||||
### `.eslintignore`
|
||||
|
||||
We use [`eslint`](https://eslint.org/) for our `JavaScript` linting needs. The `.eslintignore` file is used to [specify files or directories to, well, ignore](https://eslint.org/docs/user-guide/configuring#ignoring-files-and-directories).
|
||||
|
||||
While `eslint` automatically ignores `node_modules`, we like to add it to the `.eslintignore` just for the added explicitness. In addition, you probably want to add the directory for your compiled files (in our case, `./dist`) and your coverage directory (in our case, `./coverage`).
|
||||
|
||||
### `.eslintrc`
|
||||
|
||||
This is where the actual `eslint` configuration is specified. All `edX` JavaScript projects should extend either the [`eslint-config-edx`](https://github.com/edx/eslint-config-edx/blob/master/packages/eslint-config-edx/README.md) or [`eslint-config-edx-es5`](https://github.com/edx/eslint-config-edx/blob/master/packages/eslint-config-edx-es5/README.md) configurations (for `ES2015+` and `ES5` JavaScript, respectively). Both configurations can be found in [the `eslint-config-edx` repository](https://github.com/edx/eslint-config-edx).
|
||||
|
||||
### `.npmignore`
|
||||
|
||||
We are not currently publishing this package to [`npm`](https://www.npmjs.com/). If we did, we would want to exclude certain files from getting uploaded to `npm` (like our coverage files, for example). For more information, see [the `npm` documentation](https://docs.npmjs.com/misc/developers#keeping-files-out-of-your-package).
|
||||
|
||||
### `.travis.yml`
|
||||
|
||||
We use [`Travis CI`](https://travis-ci.org/) to build (and deploy) our application. The `.travis.yml` file specifies the configuration for `Travis` builds. For more information, see [the `Travis` documentation](https://docs.travis-ci.com/user/customizing-the-build/).
|
||||
|
||||
### `package.json`
|
||||
|
||||
Arguably, one of the **most important files in an `npm`-based application**, the `package.json` file specifies everything from the `name` of the application, were it to be published to `npm`, to it's `dependencies`.
|
||||
|
||||
For more information, see [the `npm` documentation](https://docs.npmjs.com/files/package.json).
|
||||
|
||||
## Helpful Applications
|
||||
|
||||
### [`Greenkeeper`](https://greenkeeper.io/)
|
||||
|
||||
[`Greenkeeper`](https://greenkeeper.io/) is basically a `GitHub` application that handles `npm` dependencies. It will automatically open PRs with `package.json` updates when new versions of your `npm` dependencies get published. There are ways to also automatically keep the `package-lock.json` in-line, in the same PR, using [`greenkeeper-lockfile`].
|
||||
|
||||
For more information, see [the `Greenkeeper` documentation](https://greenkeeper.io/docs.html#what-greenkeeper-does).
|
||||
|
||||
## Authentication with backend API services
|
||||
|
||||
|
||||
3
babel.config.js
Normal file
3
babel.config.js
Normal file
@@ -0,0 +1,3 @@
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
|
||||
module.exports = createConfig('babel');
|
||||
@@ -1,15 +0,0 @@
|
||||
// This is the common Webpack config. The dev and prod Webpack configs both
|
||||
// inherit config defined here.
|
||||
const path = require('path');
|
||||
|
||||
module.exports = {
|
||||
entry: {
|
||||
app: path.resolve(__dirname, '../src/index.jsx'),
|
||||
},
|
||||
output: {
|
||||
path: path.resolve(__dirname, '../dist'),
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.js', '.jsx'],
|
||||
},
|
||||
};
|
||||
@@ -1,105 +0,0 @@
|
||||
// This is the dev Webpack config. All settings here should prefer a fast build
|
||||
// time at the expense of creating larger, unoptimized bundles.
|
||||
const Merge = require('webpack-merge');
|
||||
const path = require('path');
|
||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||
const webpack = require('webpack');
|
||||
|
||||
const commonConfig = require('./webpack.common.config.js');
|
||||
|
||||
module.exports = Merge.smart(commonConfig, {
|
||||
mode: 'development',
|
||||
entry: [
|
||||
// enable react's custom hot dev client so we get errors reported in the browser
|
||||
require.resolve('react-dev-utils/webpackHotDevClient'),
|
||||
path.resolve(__dirname, '../src/index.jsx'),
|
||||
],
|
||||
module: {
|
||||
// Specify file-by-file rules to Webpack. Some file-types need a particular kind of loader.
|
||||
rules: [
|
||||
// The babel-loader transforms newer ES2015+ syntax to older ES5 for older browsers.
|
||||
// Babel is configured with the .babelrc file at the root of the project.
|
||||
{
|
||||
test: /\.(js|jsx)$/,
|
||||
include: [
|
||||
path.resolve(__dirname, '../src'),
|
||||
],
|
||||
loader: 'babel-loader',
|
||||
options: {
|
||||
// Caches result of loader to the filesystem. Future builds will attempt to read from the
|
||||
// cache to avoid needing to run the expensive recompilation process on each run.
|
||||
cacheDirectory: true,
|
||||
},
|
||||
},
|
||||
// We are not extracting CSS from the javascript bundles in development because extracting
|
||||
// prevents hot-reloading from working, it increases build time, and we don't care about
|
||||
// flash-of-unstyled-content issues in development.
|
||||
{
|
||||
test: /(.scss|.css)$/,
|
||||
use: [
|
||||
'style-loader', // creates style nodes from JS strings
|
||||
{
|
||||
loader: 'css-loader', // translates CSS into CommonJS
|
||||
options: {
|
||||
sourceMap: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
loader: 'sass-loader', // compiles Sass to CSS
|
||||
options: {
|
||||
sourceMap: true,
|
||||
includePaths: [
|
||||
path.join(__dirname, '../node_modules'),
|
||||
path.join(__dirname, '../src'),
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
// Webpack, by default, uses the url-loader for images and fonts that are required/included by
|
||||
// files it processes, which just base64 encodes them and inlines them in the javascript
|
||||
// bundles. This makes the javascript bundles ginormous and defeats caching so we will use the
|
||||
// file-loader instead to copy the files directly to the output directory.
|
||||
{
|
||||
test: /\.(woff2?|ttf|svg|eot)(\?v=\d+\.\d+\.\d+)?$/,
|
||||
loader: 'file-loader',
|
||||
},
|
||||
],
|
||||
},
|
||||
// Specify additional processing or side-effects done on the Webpack output bundles as a whole.
|
||||
plugins: [
|
||||
// Generates an HTML file in the output directory.
|
||||
new HtmlWebpackPlugin({
|
||||
inject: true, // Appends script tags linking to the webpack bundles at the end of the body
|
||||
template: path.resolve(__dirname, '../public/index.html'),
|
||||
}),
|
||||
new webpack.EnvironmentPlugin({
|
||||
NODE_ENV: 'development',
|
||||
BASE_URL: 'localhost:1991',
|
||||
LMS_BASE_URL: 'http://localhost:18000',
|
||||
LOGIN_URL: 'http://localhost:18000/login',
|
||||
LOGOUT_URL: 'http://localhost:18000/login',
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT: 'http://localhost:18000/login',
|
||||
DATA_API_BASE_URL: 'http://localhost:8000',
|
||||
// LMS_CLIENT_ID should match the lms DOT client application id your LMS container
|
||||
LMS_CLIENT_ID: 'login-service-client-id',
|
||||
SEGMENT_KEY: null,
|
||||
FEATURE_FLAGS: {},
|
||||
ACCESS_TOKEN_COOKIE_NAME: 'edx-jwt-cookie-header-payload',
|
||||
CSRF_COOKIE_NAME: 'csrftoken',
|
||||
}),
|
||||
// when the --hot option is not passed in as part of the command
|
||||
// the HotModuleReplacementPlugin has to be specified in the Webpack configuration
|
||||
// https://webpack.js.org/configuration/dev-server/#devserver-hot
|
||||
new webpack.HotModuleReplacementPlugin(),
|
||||
],
|
||||
// This configures webpack-dev-server which serves bundles from memory and provides live
|
||||
// reloading.
|
||||
devServer: {
|
||||
host: '0.0.0.0',
|
||||
port: 1991,
|
||||
historyApiFallback: true,
|
||||
hot: true,
|
||||
inline: true,
|
||||
},
|
||||
});
|
||||
@@ -1,111 +0,0 @@
|
||||
// This is the prod Webpack config. All settings here should prefer smaller,
|
||||
// optimized bundles at the expense of a longer build time.
|
||||
const Merge = require('webpack-merge');
|
||||
const commonConfig = require('./webpack.common.config.js');
|
||||
const path = require('path');
|
||||
const webpack = require('webpack');
|
||||
const ExtractTextPlugin = require('extract-text-webpack-plugin');
|
||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||
|
||||
module.exports = Merge.smart(commonConfig, {
|
||||
mode: 'production',
|
||||
devtool: 'source-map',
|
||||
output: {
|
||||
filename: '[name].[chunkhash].js',
|
||||
path: path.resolve(__dirname, '../dist'),
|
||||
},
|
||||
module: {
|
||||
// Specify file-by-file rules to Webpack. Some file-types need a particular kind of loader.
|
||||
rules: [
|
||||
// The babel-loader transforms newer ES2015+ syntax to older ES5 for older browsers.
|
||||
// Babel is configured with the .babelrc file at the root of the project.
|
||||
{
|
||||
test: /\.(js|jsx)$/,
|
||||
include: [
|
||||
path.resolve(__dirname, '../src'),
|
||||
],
|
||||
loader: 'babel-loader',
|
||||
},
|
||||
// Webpack, by default, includes all CSS in the javascript bundles. Unfortunately, that means:
|
||||
// a) The CSS won't be cached by browsers separately (a javascript change will force CSS
|
||||
// re-download). b) Since CSS is applied asyncronously, it causes an ugly
|
||||
// flash-of-unstyled-content.
|
||||
//
|
||||
// To avoid these problems, we extract the CSS from the bundles into separate CSS files that
|
||||
// can be included as <link> tags in the HTML <head> manually.
|
||||
//
|
||||
// We will not do this in development because it prevents hot-reloading from working and it
|
||||
// increases build time.
|
||||
{
|
||||
test: /(.scss|.css)$/,
|
||||
use: ExtractTextPlugin.extract({
|
||||
// creates style nodes from JS strings, only used if extracting fails
|
||||
fallback: 'style-loader',
|
||||
use: [
|
||||
{
|
||||
loader: 'css-loader', // translates CSS into CommonJS
|
||||
options: {
|
||||
sourceMap: true,
|
||||
minimize: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
loader: 'sass-loader', // compiles Sass to CSS
|
||||
options: {
|
||||
sourceMap: true,
|
||||
includePaths: [
|
||||
path.join(__dirname, '../node_modules'),
|
||||
path.join(__dirname, '../src'),
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
// Webpack, by default, uses the url-loader for images and fonts that are required/included by
|
||||
// files it processes, which just base64 encodes them and inlines them in the javascript
|
||||
// bundles. This makes the javascript bundles ginormous and defeats caching so we will use the
|
||||
// file-loader instead to copy the files directly to the output directory.
|
||||
{
|
||||
test: /\.(woff2?|ttf|svg|eot)(\?v=\d+\.\d+\.\d+)?$/,
|
||||
loader: 'file-loader',
|
||||
},
|
||||
],
|
||||
},
|
||||
// New in Webpack 4. Replaces CommonChunksPlugin. Extract common modules among all chunks to one
|
||||
// common chunk and extract the Webpack runtime to a single runtime chunk.
|
||||
optimization: {
|
||||
runtimeChunk: 'single',
|
||||
splitChunks: {
|
||||
chunks: 'all',
|
||||
},
|
||||
},
|
||||
// Specify additional processing or side-effects done on the Webpack output bundles as a whole.
|
||||
plugins: [
|
||||
// Writes the extracted CSS from each entry to a file in the output directory.
|
||||
new ExtractTextPlugin({
|
||||
filename: '[name].min.css',
|
||||
allChunks: true,
|
||||
}),
|
||||
// Generates an HTML file in the output directory.
|
||||
new HtmlWebpackPlugin({
|
||||
inject: true, // Appends script tags linking to the webpack bundles at the end of the body
|
||||
template: path.resolve(__dirname, '../public/index.html'),
|
||||
}),
|
||||
new webpack.EnvironmentPlugin({
|
||||
NODE_ENV: 'production',
|
||||
BASE_URL: null,
|
||||
LMS_BASE_URL: null,
|
||||
LOGIN_URL: null,
|
||||
LOGOUT_URL: null,
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT: null,
|
||||
DATA_API_BASE_URL: null,
|
||||
SEGMENT_KEY: null,
|
||||
FEATURE_FLAGS: {},
|
||||
ACCESS_TOKEN_COOKIE_NAME: null,
|
||||
CSRF_COOKIE_NAME: 'csrftoken',
|
||||
NEW_RELIC_APP_ID: null,
|
||||
NEW_RELIC_LICENSE_KEY: null,
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
version: "2"
|
||||
services:
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
- NODE_ENV=development
|
||||
container_name: edx.gradebook
|
||||
image: edxops/front-end-cookie-cutter:latest
|
||||
volumes:
|
||||
- .:/edx/app:delegated
|
||||
- notused:/edx/app/node_modules
|
||||
ports:
|
||||
- "1991:1991"
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
|
||||
volumes:
|
||||
notused:
|
||||
46
documentation/decisions/0001-update-api-usage.rst
Normal file
46
documentation/decisions/0001-update-api-usage.rst
Normal file
@@ -0,0 +1,46 @@
|
||||
Usage of the bulk-update API
|
||||
============================
|
||||
|
||||
Context
|
||||
=======
|
||||
|
||||
The LMS Grades API exposes a set of Gradebook-related endpoints:
|
||||
https://github.com/edx/edx-platform/blob/master/lms/djangoapps/grades/api/v1/gradebook_views.py
|
||||
The ``bulk-update`` endpoint defined therein allows for the creation/modification of subsection
|
||||
grades for multiple users and sections in a single request. This allows clients of the API to limit
|
||||
the number of network requests made and to more easily manage client-side data. Moreover,
|
||||
the course grade updates that occur during calls to this API are synchronous - the entire update operation
|
||||
is completed before a response is given to the client.
|
||||
|
||||
For decisions made about the implementation of this API, see:
|
||||
https://github.com/edx/edx-platform/blob/master/lms/djangoapps/grades/docs/decisions/0001-gradebook-api.rst
|
||||
|
||||
Decision
|
||||
========
|
||||
|
||||
The Gradebook front-end will post data about a single subsection and user in a single request
|
||||
to the ``bulk-update`` API. That is, we currently need only the "update" aspect of this
|
||||
endpoint, and not the "bulk" aspect, for satisfying the requirements of the current UX.
|
||||
|
||||
Status
|
||||
======
|
||||
|
||||
Accepted (circa December 2018)
|
||||
|
||||
Consequences
|
||||
============
|
||||
|
||||
This is a scenario in which the implementation of the API is coupled to the
|
||||
UX that depends on the API. Because the course grade update is synchronous, it means
|
||||
the API response can contain the updated subsection and course grade data. Because
|
||||
a response from the API contains this data, the UI can operate in a very familiar way:
|
||||
|
||||
- A user clicks a button to submit a request with grade update data to the update endpoint.
|
||||
- On the server, the subsection and course grades are modified.
|
||||
- In the meantime, the client-side user looks at a spinner.
|
||||
- A response is returned with updated data and the spinner goes away.
|
||||
- Updated data is displayed to the user, along with a message indicative of the update.
|
||||
|
||||
If the update becomes asynchronous, the user experience outlined above has to change.
|
||||
Because a single call to this endpoint updates grades data for only a single user,
|
||||
the endpoint does not necessarily have to utilize an asynchronous operation at this time.
|
||||
107
documentation/testing/test-plan.md
Normal file
107
documentation/testing/test-plan.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# Test Plan
|
||||
|
||||
Designed to be a catalog of major Gradebook workflows to aid in testing. This should be kept up-to-date with new feature changes.
|
||||
|
||||
## Quickstart
|
||||
|
||||
Check that the items below are complete and continue to [Workflow Tests](#workflow-tests). Otherwise, followed the detailed setup in [test-setup.md](./test-setup.md).
|
||||
|
||||
- [ ] Course set up with graded content.
|
||||
- [ ] Gradebook & feature toggles set up for course.
|
||||
- [ ] Course has a Master's track for testing Master's-only features.
|
||||
- [ ] Different types of students enrolled in course (e.g. Master's, TA's).
|
||||
- [ ] Gradebook running.
|
||||
|
||||
## Workflow Tests
|
||||
|
||||
Visit a course as an instructor/staff then **Instructor** tab > **Student Admin** sub-tab > click **Show Gradebook**. Should navigate to `<root-url>:1994/{course-id}`.
|
||||
|
||||
Confirm the following workflows:
|
||||
|
||||
- [ ] Grades table results can be filtered from the "Filter" panel.
|
||||
- The "Edit Filters" button renders for all courses.
|
||||
- Click the "Edit Filters" button to open the "Filter" panel.
|
||||
- [ ] Filter panel shows the sections: Assignments, Overall Grade, Student Groups, Include Course Team Members.
|
||||
- **Note:** Filters are cumulative and act with other applied filters.
|
||||
- Assignments pane
|
||||
- [ ] Applying the "Assignment Types" filter limits the assignment columns show in the grades table to the selected assignment types.
|
||||
- [ ] Applying an "Assignment" filter shows only the selected assignment column in the grades table.
|
||||
- [ ] With an "Assignment" filter already selected, setting a "Min/Max Grade" filter shows only student rows with grades for the assignment within the filtered range.
|
||||
- Overall Grade pane
|
||||
- [ ] Applying a "Min/Max Grade" filter shows only students with Total Course Grades within the filtered range.
|
||||
- Student Groups pane
|
||||
- [ ] Applying a "Tracks" filter shows only student rows matching the selected track.
|
||||
- [ ] Applying a "Cohorts" filter shows only student rows matching the selected cohort.
|
||||
- Include Course Team Members pane
|
||||
- By default, any user with a course role (e.g. staff, beta testers, TA's) are hidden from the grades table.
|
||||
- [ ] Selecting "Include Course Team Members" shows course team members in the grades table.
|
||||
- [ ] Deselecting "Include Course Team Members" shows only students without course roles in the grades table.
|
||||
|
||||
- [ ] Users can be searched/filtered using the Search box.
|
||||
- The Search Box renders for all courses.
|
||||
- [ ] Entering characters into the Search Box filters students on top of already applied filters.
|
||||
- Note: characters can appear anywhere in a name or email, even though emails are only shown for masters-track students. It doesn't appear that search actually works for student keys.
|
||||
|
||||
- [ ] Grades table "Score View" allows selecting how scores are displayed.
|
||||
- [ ] The "Score View" selector renders with the options: Absolute, Percent.
|
||||
- [ ] Changing the "Score View" dropdown to "Percent" shows scores as percentages in the assignment columns (note that scores can be over 100%).
|
||||
- [ ] Changing the "Score View" dropdown to "Absolute" shows scores as {awarded-points}/{possible-points} values, rounded to 2 decimal points.
|
||||
- [ ] For unattempted problems score shows '0'.
|
||||
- [ ] For attempted problems, score always shows an {awarded-points}/{possible-points} value.
|
||||
- [ ] "Total Course Grade" always shows scores as percentages (including 0% for unattempted).
|
||||
|
||||
- [ ] Grades table displays correctly.
|
||||
- [ ] The grades table shows with columns: Username, Email, {numbered-assignments}, Total.
|
||||
- [ ] Usernames appear in the "Username" column.
|
||||
- [ ] Student external keys (where applicable) also appear in the "Username" column.
|
||||
- [ ] Student emails appear in the "Email" column only for masters-track students.
|
||||
- [ ] Assignment scores show in their respective assignment columns.
|
||||
- [ ] Total course grade shows in the "Total Course Grade" column.
|
||||
|
||||
- [ ] Grade overrides can be applied.
|
||||
- [ ] Clicking on an assignment score in the grades table opens the "Edit Grades" modal.
|
||||
- [ ] "Assignment name", "Student username", "Original grade", and "Current grade" display in the modal.
|
||||
- [ ] A history of grade overrides including "Date", "Grader", "Reason", and "Adjusted Grade" shows (if the subsection was previously overridden).
|
||||
- [ ] An entry with the current time appears in the table with areas to enter adjusted grades and reasons for adjusting.
|
||||
- Enter an "Adjusted Grade" and "Reason" for the override.
|
||||
- [ ] Modal can be navigated away from by clicking outside the modal, clicking the "x" button, or hitting "Cancel".
|
||||
- [ ] Clicking "Save Grade" applies the override, shows the successful "grade has been edited" banner and updates score in grades table (may take a few seconds).
|
||||
- [ ] Opening back up the "Edit Grades" modal shows the change as an entry in the override history table.
|
||||
|
||||
- [ ] *Master's (or selectively-enabled) only*: "Bulk Management" allows overriding grades in bulk.
|
||||
- Open a non-masters-track course.
|
||||
- [ ] Verify that the "Bulk Management" button does not appear.
|
||||
- [ ] Verify that the "Download Interventions" interface does not appear.
|
||||
- Open a masters-track course.
|
||||
- [ ] Verify that the "Bulk Management History" button appears at the right of the header.
|
||||
- [ ] Verify that the "Download Interventions" interface appears.
|
||||
- [ ] Verify that the "Download Grades" button appears.
|
||||
- [ ] Verify that the "Import Grades" button appears.
|
||||
- Click the "Download Grades" button. This downloads existing student/assignment info.
|
||||
- [ ] Open the downloaded CSV and verify that students and assignments in the file match applied filters/searches.
|
||||
- Navigate to Bulk Management History tab.
|
||||
- [ ] Clicking the "ViewBulk Management History" tab shows the Bulk Management History view.
|
||||
- [ ] The bulk management history table appears with columns: "Gradebook", "Download Summary", "Who", "When".
|
||||
- [ ] Previous bulk management imports (if applicable) appear in the table.
|
||||
- Add values in the "new_override-{subsection-short-id}" columns for student grades to be overridden and save the CSV file.
|
||||
- Navigate back to Gradebook view
|
||||
- Click the "Import Grades" button and select the modified CSV file.
|
||||
- [ ] Verify that the "CSV processing" banner appears.
|
||||
- Wait for processing to complete and reload the page. (Can take seconds to minutes depending on environment and size of the override.)
|
||||
- [ ] Verify that Import Grades Success toast appears (and disappears after 5 seconds)
|
||||
- Navigate back to the "Bulk Management History" view.
|
||||
- [ ] Verify that a new entry appears in the results table indicating how many students were affected by the bulk grade change.
|
||||
- Click the "Download Summary" link to see the summary of changes from the bulk grade changes.
|
||||
- [ ] Verify that students are shown with modified subsections and actions: "No Action" for unchanged users, "Success" for successful overrides.
|
||||
|
||||
- [ ] *Masters only*: Interventions report shows student activity in the course.
|
||||
- Open a non-masters-track course.
|
||||
- [ ] Verify that the "View Bulk Management History" button does not appear.
|
||||
- [ ] Verify that the "Interventions" interface does not appear.
|
||||
- [ ] Verify that the "Download Grades" and "Import Grades" buttons do not appear.
|
||||
- Open a masters-track course.
|
||||
- [ ] Verify that the "View Bulk Management History" button appears at the right of the header.
|
||||
- [ ] Verify that the "Interventions" interface appears.
|
||||
- [ ] Verify that the "Download Grades" and "Import Grades" buttons appear.
|
||||
- Click on the "Download Interventions" button to generate a CSV students and activity info.
|
||||
- Open the interventions report and verify student info and activity info appear.
|
||||
58
documentation/testing/test-setup.md
Normal file
58
documentation/testing/test-setup.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Test Setup
|
||||
|
||||
Instructions for setting up environments and data for testing Gradebook.
|
||||
|
||||
## Set up a course with graded content
|
||||
|
||||
A course with graded content is the first prerequisite to testing. Use an existing course (e.g. the DemoX Demonstration Course in Devstack) or see [Building and Running an edX Course > Developing Your Course](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/developing_course/index.html) for notes on how to develop a course from scratch.
|
||||
|
||||
Notably, the course needs a grading policy and subsections with scoreable content.
|
||||
After creating subsections with content, they need to be configured with an "Assignment Type" to be included in grading.
|
||||
|
||||
Suggested resources:
|
||||
- [Establishing a Grading Policy For Your Course](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/grading/index.html)
|
||||
- [Adding Exercises and Tools](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/grading/index.html)
|
||||
- [Set the Assignment Type and Due Date for a Subsection](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/developing_course/course_subsections.html#set-the-assignment-type-and-due-date-for-a-subsection)
|
||||
|
||||
## Enable Gradebook for course
|
||||
|
||||
See README.md #Quickstart for more detailed instructions.
|
||||
|
||||
As an admin user, visit Django Admin (`{lms-url}/admin`) to modify features.
|
||||
- In Grades > Persistent Grades Enabled flag, click "Add persistent grades enabled flag"
|
||||
- [ ] Enable the flag globally or for the course and click "Save"
|
||||
- In Django-Waffle > Switches, click "Add switch"
|
||||
- [ ] Set name to `grades.assume_zero_grade_if_absent`, select "Active", and click "Save"
|
||||
- In Waffle_Utils > Waffle flag course overrides:
|
||||
- [ ] Add a new flag called `grades.writeable_gradebook`, select "Force On", and enable it for your course
|
||||
|
||||
## Enable Bulk Management
|
||||
|
||||
Bulk Management is an added feature to allow modifying grades in bulk via CSV upload. Bulk Management is default enabled for Master's track courses but can be selectively enabled for other courses with a waffle flag following the steps below.
|
||||
|
||||
- In Waffle_Utils > Waffle flag course overrides:
|
||||
- [ ] Add a new flag called `grades.bulk_management`, select "Force On", and enable it for your course.
|
||||
|
||||
## Create a Master's track for testing Master's-only features
|
||||
|
||||
[source](https://openedx.atlassian.net/wiki/spaces/MS/pages/1453818012/Add+a+learner+into+a+master+s+track)
|
||||
|
||||
Add a Master's track in your course:
|
||||
- As an admin user, go to Django Admin (`{lms-url}/admin`) > Course Modes and add a new course mode
|
||||
- Set the Mode to "Master's"
|
||||
- Set any valid price and currency values
|
||||
- Click "Save"
|
||||
|
||||
Enroll a student in the Master's track:
|
||||
- As a staff/admin user, go to `{lms-url}/support/enrollment`
|
||||
- Search for the username or email of student to enroll
|
||||
- In the results table row matching the user/course, click the "Change Enrollment" button
|
||||
- Select the "Master's" enrollment mode and click "Submit enrollment change"
|
||||
|
||||
## Setup different types of students in course
|
||||
|
||||
To fully test features the course should have at least:
|
||||
- [ ] An audit-track student
|
||||
- [ ] A master's-track student
|
||||
- [ ] A staff member
|
||||
- [ ] A non-staff user
|
||||
15
jest.config.js
Normal file
15
jest.config.js
Normal file
@@ -0,0 +1,15 @@
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
|
||||
module.exports = createConfig('jest', {
|
||||
setupFilesAfterEnv: [
|
||||
'<rootDir>/src/setupTest.js',
|
||||
],
|
||||
modulePaths: ['<rootDir>/src/'],
|
||||
snapshotSerializers: [
|
||||
'enzyme-to-json/serializer',
|
||||
],
|
||||
coveragePathIgnorePatterns: [
|
||||
'src/segment.js',
|
||||
'src/postcss.config.js',
|
||||
],
|
||||
});
|
||||
9
openedx.yaml
Normal file
9
openedx.yaml
Normal file
@@ -0,0 +1,9 @@
|
||||
# This file describes this Open edX repo, as described in OEP-2:
|
||||
# http://open-edx-proposals.readthedocs.io/en/latest/oeps/oep-0002.html#specification
|
||||
|
||||
tags:
|
||||
- frontend-app
|
||||
- masters
|
||||
oeps:
|
||||
oep-2: true # Repository metadata
|
||||
openedx-release: {ref: master}
|
||||
36905
package-lock.json
generated
Executable file → Normal file
36905
package-lock.json
generated
Executable file → Normal file
File diff suppressed because it is too large
Load Diff
145
package.json
145
package.json
@@ -1,106 +1,85 @@
|
||||
{
|
||||
"name": "@edx/gradebook",
|
||||
"version": "0.1.0",
|
||||
"name": "@edx/frontend-app-gradebook",
|
||||
"version": "1.4.47",
|
||||
"description": "edx editable gradebook-ui to manipulate grade overrides on subsections",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/edx/gradebook.git"
|
||||
"url": "git+https://github.com/edx/frontend-app-gradebook.git"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "NODE_ENV=production BABEL_ENV=production webpack --config=config/webpack.prod.config.js",
|
||||
"build": "fedx-scripts webpack",
|
||||
"coveralls": "cat ./coverage/lcov.info | coveralls",
|
||||
"is-es5": "es-check es5 ./dist/*.js",
|
||||
"lint": "eslint --ext .js --ext .jsx .",
|
||||
"precommit": "npm run lint",
|
||||
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
|
||||
"lint": "fedx-scripts eslint --ext .jsx,.js src/",
|
||||
"lint-fix": "fedx-scripts eslint --fix --ext .jsx,.js src/",
|
||||
"prepush": "npm run lint",
|
||||
"semantic-release": "semantic-release",
|
||||
"start": "NODE_ENV=development BABEL_ENV=development node_modules/.bin/webpack-dev-server --config=config/webpack.dev.config.js --progress",
|
||||
"test": "jest --coverage --passWithNoTests",
|
||||
"start": "fedx-scripts webpack-dev-server --progress",
|
||||
"test": "TZ=GMT fedx-scripts jest --coverage --passWithNoTests",
|
||||
"watch-tests": "jest --watch",
|
||||
"travis-deploy-once": "travis-deploy-once"
|
||||
},
|
||||
"author": "edX",
|
||||
"license": "AGPL-3.0",
|
||||
"homepage": "https://github.com/edx/gradebook#readme",
|
||||
"homepage": "https://github.com/edx/frontend-app-gradebook#readme",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edx/edx-bootstrap": "^0.4.3",
|
||||
"@edx/frontend-auth": "^1.1.0",
|
||||
"@edx/paragon": "^3.7.0",
|
||||
"babel-polyfill": "^6.26.0",
|
||||
"classnames": "^2.2.5",
|
||||
"email-prop-type": "^1.1.5",
|
||||
"font-awesome": "^4.7.0",
|
||||
"history": "^4.7.2",
|
||||
"prop-types": "^15.5.10",
|
||||
"query-string": "^6.2.0",
|
||||
"react": "^16.2.0",
|
||||
"react-dom": "^16.2.0",
|
||||
"react-redux": "^5.0.7",
|
||||
"react-router": "^4.2.0",
|
||||
"react-router-dom": "^4.2.2",
|
||||
"@edx/brand": "npm:@edx/brand-edx.org@^1.3.2",
|
||||
"@edx/frontend-component-footer": "10.1.6",
|
||||
"@edx/frontend-component-header": "2.2.5",
|
||||
"@edx/frontend-platform": "1.9.5",
|
||||
"@edx/paragon": "14.16.4",
|
||||
"@fortawesome/fontawesome-svg-core": "^1.2.25",
|
||||
"@fortawesome/free-brands-svg-icons": "^5.11.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^5.11.2",
|
||||
"@fortawesome/react-fontawesome": "^0.1.5",
|
||||
"@redux-beacon/segment": "^1.0.0",
|
||||
"@reduxjs/toolkit": "^1.5.1",
|
||||
"classnames": "^2.2.6",
|
||||
"core-js": "3.6.5",
|
||||
"email-prop-type": "^1.1.7",
|
||||
"enzyme": "^3.10.0",
|
||||
"enzyme-to-json": "^3.6.2",
|
||||
"font-awesome": "4.7.0",
|
||||
"history": "4.10.1",
|
||||
"node-sass": "^4.14.1",
|
||||
"prop-types": "15.7.2",
|
||||
"query-string": "6.13.0",
|
||||
"react": "16.13.1",
|
||||
"react-dom": "16.13.1",
|
||||
"react-intl": "^2.9.0",
|
||||
"react-redux": "^5.1.1",
|
||||
"react-router": "5.2.0",
|
||||
"react-router-dom": "5.2.0",
|
||||
"react-router-redux": "^5.0.0-alpha.9",
|
||||
"redux": "^3.7.2",
|
||||
"redux-devtools-extension": "^2.13.2",
|
||||
"redux-logger": "^3.0.6",
|
||||
"redux-thunk": "^2.2.0",
|
||||
"whatwg-fetch": "^2.0.3"
|
||||
"redux": "4.0.5",
|
||||
"redux-beacon": "^2.1.0",
|
||||
"redux-devtools-extension": "2.13.8",
|
||||
"redux-logger": "3.0.6",
|
||||
"redux-thunk": "2.3.0",
|
||||
"regenerator-runtime": "^0.13.7",
|
||||
"util": "^0.12.3",
|
||||
"whatwg-fetch": "^2.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"babel-cli": "^6.26.0",
|
||||
"babel-eslint": "^8.2.2",
|
||||
"babel-jest": "^22.4.0",
|
||||
"babel-loader": "^7.1.2",
|
||||
"babel-plugin-transform-class-properties": "^6.24.1",
|
||||
"babel-plugin-transform-object-rest-spread": "^6.26.0",
|
||||
"babel-preset-env": "^1.6.1",
|
||||
"babel-preset-react": "^6.24.1",
|
||||
"codecov": "^3.0.0",
|
||||
"css-loader": "^0.28.9",
|
||||
"enzyme": "^3.3.0",
|
||||
"enzyme-adapter-react-16": "^1.1.1",
|
||||
"es-check": "^2.0.2",
|
||||
"eslint-config-edx": "^4.0.3",
|
||||
"extract-text-webpack-plugin": "^4.0.0-beta.0",
|
||||
"fetch-mock": "^6.3.0",
|
||||
"file-loader": "^1.1.9",
|
||||
"html-webpack-harddisk-plugin": "^0.2.0",
|
||||
"html-webpack-plugin": "^3.0.3",
|
||||
"husky": "^0.14.3",
|
||||
"@edx/frontend-build": "5.5.2",
|
||||
"axios": "0.21.1",
|
||||
"axios-mock-adapter": "^1.17.0",
|
||||
"codecov": "^3.6.1",
|
||||
"enzyme-adapter-react-16": "^1.14.0",
|
||||
"es-check": "^2.3.0",
|
||||
"fetch-mock": "^6.5.2",
|
||||
"husky": "2.7.0",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"jest": "^22.4.0",
|
||||
"node-sass": "^4.7.2",
|
||||
"react-dev-utils": "^5.0.0",
|
||||
"react-test-renderer": "^16.2.0",
|
||||
"redux-mock-store": "^1.5.1",
|
||||
"sass-loader": "^6.0.6",
|
||||
"semantic-release": "^15.10.7",
|
||||
"style-loader": "^0.20.2",
|
||||
"travis-deploy-once": "^5.0.9",
|
||||
"webpack": "^4.1.0",
|
||||
"webpack-cli": "^2.0.10",
|
||||
"webpack-dev-server": "^3.1.0",
|
||||
"webpack-merge": "^4.1.1"
|
||||
},
|
||||
"jest": {
|
||||
"setupFiles": [
|
||||
"./src/setupTest.js"
|
||||
],
|
||||
"moduleNameMapper": {
|
||||
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/__mocks__/fileMock.js",
|
||||
"\\.(css|scss)$": "identity-obj-proxy"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"src/**/*.{js,jsx}"
|
||||
],
|
||||
"coveragePathIgnorePatterns": [
|
||||
"/node_modules/",
|
||||
"src/setupTest.js",
|
||||
"src/index.js",
|
||||
"/tests/"
|
||||
],
|
||||
"transformIgnorePatterns": [
|
||||
"/node_modules/(?!(@edx/paragon)/).*/"
|
||||
]
|
||||
"jest": "24.9.0",
|
||||
"react-dev-utils": "^5.0.3",
|
||||
"react-test-renderer": "^16.10.1",
|
||||
"reactifex": "1.1.1",
|
||||
"redux-mock-store": "^1.5.3",
|
||||
"semantic-release": "^17.2.3",
|
||||
"travis-deploy-once": "^5.0.11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head></head>
|
||||
<html lang="en-us">
|
||||
<head>
|
||||
<title>Gradebook | <%= process.env.SITE_NAME %></title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="shortcut icon" href="<%=htmlWebpackPlugin.options.FAVICON_URL%>" type="image/x-icon" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
|
||||
34
src/App.jsx
Executable file
34
src/App.jsx
Executable file
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
|
||||
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import Footer from '@edx/frontend-component-footer';
|
||||
import Header from '@edx/frontend-component-header';
|
||||
|
||||
import { routePath } from 'data/constants/app';
|
||||
import store from 'data/store';
|
||||
import GradebookPage from 'containers/GradebookPage';
|
||||
import './App.scss';
|
||||
|
||||
const App = () => (
|
||||
<AppProvider store={store}>
|
||||
<Router>
|
||||
<div>
|
||||
<Header />
|
||||
<main>
|
||||
<Switch>
|
||||
<Route
|
||||
exact
|
||||
path={routePath}
|
||||
component={GradebookPage}
|
||||
/>
|
||||
</Switch>
|
||||
</main>
|
||||
<Footer logo={process.env.LOGO_POWERED_BY_OPEN_EDX_URL_SVG} />
|
||||
</div>
|
||||
</Router>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
export default App;
|
||||
17
src/App.scss
17
src/App.scss
@@ -1,9 +1,18 @@
|
||||
@import "~@edx/edx-bootstrap/sass/edx/theme";
|
||||
@import "~bootstrap/scss/bootstrap";
|
||||
// frontend-app-*/src/index.scss
|
||||
@import "~@edx/brand/paragon/fonts";
|
||||
@import "~@edx/brand/paragon/variables";
|
||||
@import "~@edx/paragon/scss/core/core";
|
||||
@import "~@edx/brand/paragon/overrides";
|
||||
|
||||
$fa-font-path: "~font-awesome/fonts";
|
||||
@import "~font-awesome/scss/font-awesome";
|
||||
|
||||
@import "~@edx/paragon/src/SearchField/SearchField";
|
||||
$input-focus-box-shadow: $input-box-shadow; // hack to get upgrade to paragon 4.0.0 to work
|
||||
|
||||
@import "./components/Gradebook/gradebook";
|
||||
@import "~@edx/frontend-component-header/dist/index";
|
||||
@import "~@edx/frontend-component-footer/dist/_footer";
|
||||
|
||||
@import "./components/GradesView/GradesView";
|
||||
@import "./components/BulkManagementHistoryView/BulkManagementHistoryView";
|
||||
@import "./components/WithSidebar/WithSidebar";
|
||||
@import "./components/GradebookFilters/GradebookFilters";
|
||||
|
||||
74
src/App.test.jsx
Normal file
74
src/App.test.jsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import Footer from '@edx/frontend-component-footer';
|
||||
import Header from '@edx/frontend-component-header';
|
||||
|
||||
import { routePath } from 'data/constants/app';
|
||||
import store from 'data/store';
|
||||
import GradebookPage from 'containers/GradebookPage';
|
||||
|
||||
import App from './App';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
BrowserRouter: () => 'BrowserRouter',
|
||||
Route: () => 'Route',
|
||||
Switch: () => 'Switch',
|
||||
}));
|
||||
jest.mock('@edx/frontend-platform/react', () => ({
|
||||
AppProvider: () => 'AppProvider',
|
||||
}));
|
||||
jest.mock('data/constants/app', () => ({
|
||||
routePath: '/:courseId',
|
||||
}));
|
||||
jest.mock('@edx/frontend-component-footer', () => 'Footer');
|
||||
jest.mock('data/store', () => 'testStore');
|
||||
jest.mock('containers/GradebookPage', () => 'GradebookPage');
|
||||
jest.mock('@edx/frontend-component-header', () => 'Header');
|
||||
|
||||
const logo = 'fakeLogo.png';
|
||||
let el;
|
||||
let router;
|
||||
|
||||
describe('App router component', () => {
|
||||
test('snapshot', () => {
|
||||
expect(shallow(<App />)).toMatchSnapshot();
|
||||
});
|
||||
describe('component', () => {
|
||||
beforeEach(() => {
|
||||
process.env.LOGO_POWERED_BY_OPEN_EDX_URL_SVG = logo;
|
||||
el = shallow(<App />);
|
||||
router = el.childAt(0);
|
||||
});
|
||||
describe('AppProvider', () => {
|
||||
test('AppProvider is the parent component, passed the redux store props', () => {
|
||||
expect(el.type()).toBe(AppProvider);
|
||||
expect(el.props().store).toEqual(store);
|
||||
});
|
||||
});
|
||||
describe('Router', () => {
|
||||
test('first child of AppProvider', () => {
|
||||
expect(router.type()).toBe(Router);
|
||||
});
|
||||
test('Header is above/outside-of the routing', () => {
|
||||
expect(router.childAt(0).childAt(0).type()).toBe(Header);
|
||||
expect(router.childAt(0).childAt(1).type()).toBe('main');
|
||||
});
|
||||
test('Routing - GradebookPage is only route', () => {
|
||||
expect(router.find('main')).toEqual(shallow(
|
||||
<main>
|
||||
<Switch>
|
||||
<Route exact path={routePath} component={GradebookPage} />
|
||||
</Switch>
|
||||
</main>,
|
||||
));
|
||||
});
|
||||
});
|
||||
test('Footer logo drawn from env variable', () => {
|
||||
expect(router.find(Footer).props().logo).toEqual(logo);
|
||||
});
|
||||
});
|
||||
});
|
||||
23
src/__snapshots__/App.test.jsx.snap
Normal file
23
src/__snapshots__/App.test.jsx.snap
Normal file
@@ -0,0 +1,23 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`App router component snapshot 1`] = `
|
||||
<AppProvider
|
||||
store="testStore"
|
||||
>
|
||||
<BrowserRouter>
|
||||
<div>
|
||||
<Header />
|
||||
<main>
|
||||
<Switch>
|
||||
<Route
|
||||
component="GradebookPage"
|
||||
exact={true}
|
||||
path="/:courseId"
|
||||
/>
|
||||
</Switch>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
</AppProvider>
|
||||
`;
|
||||
@@ -0,0 +1,54 @@
|
||||
/* eslint-disable react/sort-comp, react/button-has-type */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { Alert } from '@edx/paragon';
|
||||
|
||||
import selectors from 'data/selectors';
|
||||
import messages from './messages';
|
||||
|
||||
/**
|
||||
* <BulkManagementAlerts />
|
||||
* Alerts to display at the top of the BulkManagement tab
|
||||
*/
|
||||
export const BulkManagementAlerts = ({
|
||||
bulkImportError,
|
||||
uploadSuccess,
|
||||
}) => (
|
||||
<>
|
||||
<Alert
|
||||
variant="danger"
|
||||
show={!!bulkImportError}
|
||||
dismissible={false}
|
||||
>
|
||||
{bulkImportError}
|
||||
</Alert>
|
||||
<Alert
|
||||
variant="success"
|
||||
show={uploadSuccess}
|
||||
dismissible={false}
|
||||
>
|
||||
<FormattedMessage {...messages.successDialog} />
|
||||
</Alert>
|
||||
</>
|
||||
);
|
||||
|
||||
BulkManagementAlerts.defaultProps = {
|
||||
bulkImportError: '',
|
||||
uploadSuccess: false,
|
||||
};
|
||||
|
||||
BulkManagementAlerts.propTypes = {
|
||||
// redux
|
||||
bulkImportError: PropTypes.string,
|
||||
uploadSuccess: PropTypes.bool,
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => ({
|
||||
bulkImportError: selectors.grades.bulkImportError(state),
|
||||
uploadSuccess: selectors.grades.uploadSuccess(state),
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps)(BulkManagementAlerts);
|
||||
@@ -0,0 +1,89 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { Alert } from '@edx/paragon';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import selectors from 'data/selectors';
|
||||
import messages from './messages';
|
||||
|
||||
import { BulkManagementAlerts, mapStateToProps } from './BulkManagementAlerts';
|
||||
|
||||
jest.mock('@edx/frontend-platform/i18n', () => ({
|
||||
defineMessages: m => m,
|
||||
FormattedMessage: () => 'FormattedMessage',
|
||||
}));
|
||||
jest.mock('@edx/paragon', () => ({
|
||||
Alert: () => 'Alert',
|
||||
}));
|
||||
jest.mock('data/selectors', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
grades: {
|
||||
bulkImportError: (state) => ({ bulkImportError: state }),
|
||||
uploadSuccess: (state) => ({ uploadSuccess: state }),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const errorMessage = 'Oh noooooo';
|
||||
|
||||
describe('BulkManagementAlerts', () => {
|
||||
describe('component', () => {
|
||||
let el;
|
||||
describe('no errer, no upload success', () => {
|
||||
beforeEach(() => {
|
||||
el = shallow(<BulkManagementAlerts />);
|
||||
});
|
||||
test('snapshot - bulkImportError closed, success closed', () => {
|
||||
expect(el).toMatchSnapshot();
|
||||
});
|
||||
test('closed danger alert', () => {
|
||||
expect(el.childAt(0).is(Alert)).toEqual(true);
|
||||
expect(el.childAt(0).props().show).toEqual(false);
|
||||
expect(el.childAt(0).props().variant).toEqual('danger');
|
||||
});
|
||||
test('closed success alert', () => {
|
||||
expect(el.childAt(1).is(Alert)).toEqual(true);
|
||||
expect(el.childAt(1).props().show).toEqual(false);
|
||||
expect(el.childAt(1).props().variant).toEqual('success');
|
||||
});
|
||||
});
|
||||
describe('no errer, no upload success', () => {
|
||||
beforeEach(() => {
|
||||
el = shallow(<BulkManagementAlerts uploadSuccess bulkImportError={errorMessage} />);
|
||||
});
|
||||
const assertions = [
|
||||
'danger alert open with bulkImportError',
|
||||
'success alert open with messages.successDialog',
|
||||
];
|
||||
test(`snapshot - ${assertions.join(', ')}`, () => {
|
||||
expect(el).toMatchSnapshot();
|
||||
});
|
||||
test('open danger alert with bulkImportError content', () => {
|
||||
expect(el.childAt(0).is(Alert)).toEqual(true);
|
||||
expect(el.childAt(0).children().text()).toEqual(errorMessage);
|
||||
expect(el.childAt(0).props().show).toEqual(true);
|
||||
});
|
||||
test('open success alert with messages.successDialog content', () => {
|
||||
expect(el.childAt(1).is(Alert)).toEqual(true);
|
||||
expect(el.childAt(1).children().getElement()).toEqual(
|
||||
<FormattedMessage {...messages.successDialog} />,
|
||||
);
|
||||
expect(el.childAt(1).props().show).toEqual(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('mapStateToProps', () => {
|
||||
let mapped;
|
||||
const testState = { a: 'puppy', named: 'Ember' };
|
||||
beforeEach(() => {
|
||||
mapped = mapStateToProps(testState);
|
||||
});
|
||||
test('bulkImportError from grades.bulkImportError', () => {
|
||||
expect(mapped.bulkImportError).toEqual(selectors.grades.bulkImportError(testState));
|
||||
});
|
||||
test('uploadSuccess from grades.uploadSuccess', () => {
|
||||
expect(mapped.uploadSuccess).toEqual(selectors.grades.uploadSuccess(testState));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
.bulk-management-history-view {
|
||||
.help-text {
|
||||
margin-bottom: 40px;
|
||||
max-width: 70%;
|
||||
}
|
||||
}
|
||||
62
src/components/BulkManagementHistoryView/HistoryTable.jsx
Normal file
62
src/components/BulkManagementHistoryView/HistoryTable.jsx
Normal file
@@ -0,0 +1,62 @@
|
||||
/* 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 { Table } from '@edx/paragon';
|
||||
|
||||
import { bulkManagementColumns } from 'data/constants/app';
|
||||
import selectors from 'data/selectors';
|
||||
|
||||
import ResultsSummary from './ResultsSummary';
|
||||
|
||||
export const mapHistoryRows = ({
|
||||
resultsSummary,
|
||||
originalFilename,
|
||||
user,
|
||||
...rest
|
||||
}) => ({
|
||||
resultsSummary: (<ResultsSummary {...resultsSummary} />),
|
||||
filename: (<span className="wrap-text-in-cell">{originalFilename}</span>),
|
||||
user: (<span className="wrap-text-in-cell">{user}</span>),
|
||||
...rest,
|
||||
});
|
||||
|
||||
/**
|
||||
* <HistoryTable />
|
||||
* Table with history of bulk management uploads, including a results summary which
|
||||
* displays total, skipped, and failed uploads
|
||||
*/
|
||||
export const HistoryTable = ({
|
||||
bulkManagementHistory,
|
||||
}) => (
|
||||
<>
|
||||
<Table
|
||||
data={bulkManagementHistory.map(mapHistoryRows)}
|
||||
hasFixedColumnWidths
|
||||
columns={bulkManagementColumns}
|
||||
className="table-striped"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
HistoryTable.defaultProps = {
|
||||
bulkManagementHistory: [],
|
||||
};
|
||||
HistoryTable.propTypes = {
|
||||
// redux
|
||||
bulkManagementHistory: PropTypes.arrayOf(PropTypes.shape({
|
||||
originalFilename: PropTypes.string.isRequired,
|
||||
user: PropTypes.string.isRequired,
|
||||
timeUploaded: PropTypes.string.isRequired,
|
||||
resultsSummary: PropTypes.shape({
|
||||
rowId: PropTypes.number.isRequired,
|
||||
text: PropTypes.string.isRequired,
|
||||
}),
|
||||
})),
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => ({
|
||||
bulkManagementHistory: selectors.grades.bulkManagementHistoryEntries(state),
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps)(HistoryTable);
|
||||
109
src/components/BulkManagementHistoryView/HistoryTable.test.jsx
Normal file
109
src/components/BulkManagementHistoryView/HistoryTable.test.jsx
Normal file
@@ -0,0 +1,109 @@
|
||||
/* eslint-disable import/no-named-as-default */
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { Table } from '@edx/paragon';
|
||||
|
||||
import selectors from 'data/selectors';
|
||||
import { bulkManagementColumns } from 'data/constants/app';
|
||||
|
||||
import ResultsSummary from './ResultsSummary';
|
||||
import { HistoryTable, mapStateToProps } from './HistoryTable';
|
||||
|
||||
jest.mock('@edx/frontend-platform/i18n', () => ({
|
||||
defineMessages: m => m,
|
||||
FormattedMessage: () => 'FormattedMessage',
|
||||
}));
|
||||
jest.mock('@edx/paragon', () => ({
|
||||
Table: () => 'Table',
|
||||
}));
|
||||
jest.mock('data/selectors', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
grades: {
|
||||
bulkManagementHistoryEntries: jest.fn(state => ({ historyEntries: state })),
|
||||
},
|
||||
},
|
||||
}));
|
||||
jest.mock('./ResultsSummary', () => 'ResultsSummary');
|
||||
|
||||
describe('HistoryTable', () => {
|
||||
describe('component', () => {
|
||||
const entry1 = {
|
||||
originalFilename: 'blue.png',
|
||||
user: 'Eifel',
|
||||
timeUploaded: '65',
|
||||
resultsSummary: {
|
||||
rowId: 12,
|
||||
courseId: 'Da Bu Dee',
|
||||
text: 'Da ba daa',
|
||||
},
|
||||
};
|
||||
const entry2 = {
|
||||
originalFilename: 'allStar.jpg',
|
||||
user: 'Smashmouth',
|
||||
timeUploaded: '2000s?',
|
||||
resultsSummary: {
|
||||
courseId: 'rockstar',
|
||||
rowId: 2,
|
||||
text: 'all that glitters is gold',
|
||||
},
|
||||
};
|
||||
const props = {
|
||||
bulkManagementHistory: [entry1, entry2],
|
||||
};
|
||||
let el;
|
||||
describe('snapshot', () => {
|
||||
beforeEach(() => {
|
||||
el = shallow(<HistoryTable {...props} />);
|
||||
});
|
||||
test('snapshot - loads formatted table', () => {
|
||||
expect(el).toMatchSnapshot();
|
||||
});
|
||||
describe('history table', () => {
|
||||
let table;
|
||||
beforeEach(() => {
|
||||
table = el.find(Table);
|
||||
});
|
||||
describe('data (from bulkManagementHistory.map(this.formatHistoryRow)', () => {
|
||||
const fieldAssertions = [
|
||||
'maps resultsSummay to ResultsSummary',
|
||||
'wraps filename and user',
|
||||
'forwards the rest',
|
||||
];
|
||||
test(`snapshot: ${fieldAssertions.join(', ')}`, () => {
|
||||
expect(table.props().data).toMatchSnapshot();
|
||||
});
|
||||
test(fieldAssertions.join(', '), () => {
|
||||
const rows = table.props().data;
|
||||
expect(rows[0].resultsSummary).toEqual(<ResultsSummary {...entry1.resultsSummary} />);
|
||||
expect(rows[0].user).toEqual(<span className="wrap-text-in-cell">{entry1.user}</span>);
|
||||
expect(
|
||||
rows[0].filename,
|
||||
).toEqual(<span className="wrap-text-in-cell">{entry1.originalFilename}</span>);
|
||||
expect(rows[1].resultsSummary).toEqual(<ResultsSummary {...entry2.resultsSummary} />);
|
||||
expect(rows[1].user).toEqual(<span className="wrap-text-in-cell">{entry2.user}</span>);
|
||||
expect(
|
||||
rows[1].filename,
|
||||
).toEqual(<span className="wrap-text-in-cell">{entry2.originalFilename}</span>);
|
||||
});
|
||||
});
|
||||
test('columns from bulkManagementColumns', () => {
|
||||
expect(table.props().columns).toEqual(bulkManagementColumns);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapStateToProps', () => {
|
||||
const testState = { a: 'simple', test: 'state' };
|
||||
let mapped;
|
||||
beforeEach(() => {
|
||||
mapped = mapStateToProps(testState);
|
||||
});
|
||||
test('bulkManagementHistory from grades.bulkManagementHistoryEntries', () => {
|
||||
expect(
|
||||
mapped.bulkManagementHistory,
|
||||
).toEqual(selectors.grades.bulkManagementHistoryEntries(testState));
|
||||
});
|
||||
});
|
||||
});
|
||||
38
src/components/BulkManagementHistoryView/ResultsSummary.jsx
Normal file
38
src/components/BulkManagementHistoryView/ResultsSummary.jsx
Normal file
@@ -0,0 +1,38 @@
|
||||
/* eslint-disable react/sort-comp, react/button-has-type */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Hyperlink, Icon } from '@edx/paragon';
|
||||
import { Download } from '@edx/paragon/icons';
|
||||
|
||||
import lms from 'data/services/lms';
|
||||
|
||||
/**
|
||||
* <ResultsSummary {...{ courseId, rowId, text }} />
|
||||
* displays a result summary cell for a single bulk management upgrade history entry.
|
||||
* @param {string} courseId - course identifier
|
||||
* @param {number} rowId - row/error identifier
|
||||
* @param {string} text - summary string
|
||||
*/
|
||||
const ResultsSummary = ({
|
||||
rowId,
|
||||
text,
|
||||
}) => (
|
||||
<Hyperlink
|
||||
href={lms.urls.bulkGradesUrlByRow(rowId)}
|
||||
destination="www.edx.org"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
showLaunchIcon={false}
|
||||
>
|
||||
<Icon src={Download} className="d-inline-block" />
|
||||
{text}
|
||||
</Hyperlink>
|
||||
);
|
||||
|
||||
ResultsSummary.propTypes = {
|
||||
rowId: PropTypes.number.isRequired,
|
||||
text: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default ResultsSummary;
|
||||
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { Icon } from '@edx/paragon';
|
||||
import { Download } from '@edx/paragon/icons';
|
||||
|
||||
import lms from 'data/services/lms';
|
||||
import ResultsSummary from './ResultsSummary';
|
||||
|
||||
jest.mock('@edx/paragon', () => ({
|
||||
Hyperlink: () => 'Hyperlink',
|
||||
Icon: () => 'Icon',
|
||||
}));
|
||||
jest.mock('@edx/paragon/icons', () => ({
|
||||
Download: 'DownloadIcon',
|
||||
}));
|
||||
jest.mock('data/services/lms', () => ({
|
||||
urls: {
|
||||
bulkGradesUrlByRow: jest.fn((rowId) => ({ url: { rowId } })),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('ResultsSummary component', () => {
|
||||
const props = {
|
||||
rowId: 42,
|
||||
text: 'texty',
|
||||
};
|
||||
let el;
|
||||
const assertions = [
|
||||
'safe hyperlink with bulkGradesUrl with course and row id',
|
||||
'download icon',
|
||||
'results text',
|
||||
];
|
||||
beforeEach(() => {
|
||||
el = shallow(<ResultsSummary {...props} />);
|
||||
});
|
||||
test(`snapshot - ${assertions.join(', ')}`, () => {
|
||||
expect(el).toMatchSnapshot();
|
||||
});
|
||||
test('Hyperlink has target="_blank" and rel="noopener noreferrer"', () => {
|
||||
expect(el.props().target).toEqual('_blank');
|
||||
expect(el.props().rel).toEqual('noopener noreferrer');
|
||||
});
|
||||
test('Hyperlink has href to bulkGradesUrl', () => {
|
||||
expect(el.props().href).toEqual(lms.urls.bulkGradesUrlByRow(props.rowId));
|
||||
});
|
||||
test('displays Download Icon and text', () => {
|
||||
const icon = el.childAt(0);
|
||||
expect(icon.is(Icon)).toEqual(true);
|
||||
expect(icon.props().src).toEqual(Download);
|
||||
expect(el.childAt(1).text()).toEqual(props.text);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`BulkManagementAlerts component no errer, no upload success snapshot - bulkImportError closed, success closed 1`] = `
|
||||
<Fragment>
|
||||
<Alert
|
||||
dismissible={false}
|
||||
show={false}
|
||||
variant="danger"
|
||||
/>
|
||||
<Alert
|
||||
dismissible={false}
|
||||
show={false}
|
||||
variant="success"
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="CSV processing. File uploads may take several minutes to complete."
|
||||
description="Success Dialog message in BulkManagement Tab File Upload Form"
|
||||
id="gradebook.BulkManagementHistoryView.successDialog"
|
||||
/>
|
||||
</Alert>
|
||||
</Fragment>
|
||||
`;
|
||||
|
||||
exports[`BulkManagementAlerts component no errer, no upload success snapshot - danger alert open with bulkImportError, success alert open with messages.successDialog 1`] = `
|
||||
<Fragment>
|
||||
<Alert
|
||||
dismissible={false}
|
||||
show={true}
|
||||
variant="danger"
|
||||
>
|
||||
Oh noooooo
|
||||
</Alert>
|
||||
<Alert
|
||||
dismissible={false}
|
||||
show={true}
|
||||
variant="success"
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="CSV processing. File uploads may take several minutes to complete."
|
||||
description="Success Dialog message in BulkManagement Tab File Upload Form"
|
||||
id="gradebook.BulkManagementHistoryView.successDialog"
|
||||
/>
|
||||
</Alert>
|
||||
</Fragment>
|
||||
`;
|
||||
@@ -0,0 +1,119 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`HistoryTable component snapshot history table data (from bulkManagementHistory.map(this.formatHistoryRow) snapshot: maps resultsSummay to ResultsSummary, wraps filename and user, forwards the rest 1`] = `
|
||||
Array [
|
||||
Object {
|
||||
"filename": <span
|
||||
className="wrap-text-in-cell"
|
||||
>
|
||||
blue.png
|
||||
</span>,
|
||||
"resultsSummary": <ResultsSummary
|
||||
courseId="Da Bu Dee"
|
||||
rowId={12}
|
||||
text="Da ba daa"
|
||||
/>,
|
||||
"timeUploaded": "65",
|
||||
"user": <span
|
||||
className="wrap-text-in-cell"
|
||||
>
|
||||
Eifel
|
||||
</span>,
|
||||
},
|
||||
Object {
|
||||
"filename": <span
|
||||
className="wrap-text-in-cell"
|
||||
>
|
||||
allStar.jpg
|
||||
</span>,
|
||||
"resultsSummary": <ResultsSummary
|
||||
courseId="rockstar"
|
||||
rowId={2}
|
||||
text="all that glitters is gold"
|
||||
/>,
|
||||
"timeUploaded": "2000s?",
|
||||
"user": <span
|
||||
className="wrap-text-in-cell"
|
||||
>
|
||||
Smashmouth
|
||||
</span>,
|
||||
},
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`HistoryTable component snapshot snapshot - loads formatted table 1`] = `
|
||||
<Fragment>
|
||||
<Table
|
||||
className="table-striped"
|
||||
columns={
|
||||
Array [
|
||||
Object {
|
||||
"columnSortable": false,
|
||||
"key": "filename",
|
||||
"label": "Gradebook",
|
||||
"width": "col-5",
|
||||
},
|
||||
Object {
|
||||
"columnSortable": false,
|
||||
"key": "resultsSummary",
|
||||
"label": "Download Summary",
|
||||
"width": "col",
|
||||
},
|
||||
Object {
|
||||
"columnSortable": false,
|
||||
"key": "user",
|
||||
"label": "Who",
|
||||
"width": "col-1",
|
||||
},
|
||||
Object {
|
||||
"columnSortable": false,
|
||||
"key": "timeUploaded",
|
||||
"label": "When",
|
||||
"width": "col",
|
||||
},
|
||||
]
|
||||
}
|
||||
data={
|
||||
Array [
|
||||
Object {
|
||||
"filename": <span
|
||||
className="wrap-text-in-cell"
|
||||
>
|
||||
blue.png
|
||||
</span>,
|
||||
"resultsSummary": <ResultsSummary
|
||||
courseId="Da Bu Dee"
|
||||
rowId={12}
|
||||
text="Da ba daa"
|
||||
/>,
|
||||
"timeUploaded": "65",
|
||||
"user": <span
|
||||
className="wrap-text-in-cell"
|
||||
>
|
||||
Eifel
|
||||
</span>,
|
||||
},
|
||||
Object {
|
||||
"filename": <span
|
||||
className="wrap-text-in-cell"
|
||||
>
|
||||
allStar.jpg
|
||||
</span>,
|
||||
"resultsSummary": <ResultsSummary
|
||||
courseId="rockstar"
|
||||
rowId={2}
|
||||
text="all that glitters is gold"
|
||||
/>,
|
||||
"timeUploaded": "2000s?",
|
||||
"user": <span
|
||||
className="wrap-text-in-cell"
|
||||
>
|
||||
Smashmouth
|
||||
</span>,
|
||||
},
|
||||
]
|
||||
}
|
||||
hasFixedColumnWidths={true}
|
||||
/>
|
||||
</Fragment>
|
||||
`;
|
||||
@@ -0,0 +1,23 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`ResultsSummary component snapshot - safe hyperlink with bulkGradesUrl with course and row id, download icon, results text 1`] = `
|
||||
<Hyperlink
|
||||
destination="www.edx.org"
|
||||
href={
|
||||
Object {
|
||||
"url": Object {
|
||||
"rowId": 42,
|
||||
},
|
||||
}
|
||||
}
|
||||
rel="noopener noreferrer"
|
||||
showLaunchIcon={false}
|
||||
target="_blank"
|
||||
>
|
||||
<Icon
|
||||
className="d-inline-block"
|
||||
src="DownloadIcon"
|
||||
/>
|
||||
texty
|
||||
</Hyperlink>
|
||||
`;
|
||||
@@ -0,0 +1,26 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`BulkManagementHistoryView component snapshot snapshot - loads heading from messages.BulkManagementHistoryView.heading, <BulkManagementAlerts />, <HistoryTable /> 1`] = `
|
||||
<div
|
||||
className="bulk-management-history-view"
|
||||
>
|
||||
<h4>
|
||||
<FormattedMessage
|
||||
defaultMessage="Bulk Management History"
|
||||
description="Heading text for BulkManagement History Tab"
|
||||
id="gradebook.BulkManagementHistoryView.heading"
|
||||
/>
|
||||
</h4>
|
||||
<p
|
||||
className="help-text"
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="Below is a log of previous grade imports. To download a CSV of your gradebook and import grades for override, return to the Gradebook. Please note, after importing grades, it may take a few seconds to process the override."
|
||||
description="Bulk Management History View help text"
|
||||
id="gradebook.BulkManagementHistoryView"
|
||||
/>
|
||||
</p>
|
||||
<BulkManagementAlerts />
|
||||
<HistoryTable />
|
||||
</div>
|
||||
`;
|
||||
24
src/components/BulkManagementHistoryView/index.jsx
Normal file
24
src/components/BulkManagementHistoryView/index.jsx
Normal file
@@ -0,0 +1,24 @@
|
||||
/* eslint-disable react/button-has-type, import/no-named-as-default */
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import messages from './messages';
|
||||
import BulkManagementAlerts from './BulkManagementAlerts';
|
||||
import HistoryTable from './HistoryTable';
|
||||
|
||||
/**
|
||||
* <BulkManagementHistoryView />
|
||||
* top-level view for managing uploads of bulk management override csvs.
|
||||
*/
|
||||
export const BulkManagementHistoryView = () => (
|
||||
<div className="bulk-management-history-view">
|
||||
<h4><FormattedMessage {...messages.heading} /></h4>
|
||||
<p className="help-text">
|
||||
<FormattedMessage {...messages.helpText} />
|
||||
</p>
|
||||
<BulkManagementAlerts />
|
||||
<HistoryTable />
|
||||
</div>
|
||||
);
|
||||
|
||||
export default BulkManagementHistoryView;
|
||||
44
src/components/BulkManagementHistoryView/index.test.jsx
Normal file
44
src/components/BulkManagementHistoryView/index.test.jsx
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable import/no-named-as-default */
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { BulkManagementHistoryView } from '.';
|
||||
import BulkManagementAlerts from './BulkManagementAlerts';
|
||||
import HistoryTable from './HistoryTable';
|
||||
import messages from './messages';
|
||||
|
||||
jest.mock('./BulkManagementAlerts', () => 'BulkManagementAlerts');
|
||||
jest.mock('./HistoryTable', () => 'HistoryTable');
|
||||
|
||||
describe('BulkManagementHistoryView', () => {
|
||||
describe('component', () => {
|
||||
let el;
|
||||
beforeEach(() => {
|
||||
el = shallow(<BulkManagementHistoryView />);
|
||||
});
|
||||
describe('snapshot', () => {
|
||||
const snapshotSegments = [
|
||||
'heading from messages.BulkManagementHistoryView.heading',
|
||||
'<BulkManagementAlerts />',
|
||||
'<HistoryTable />',
|
||||
];
|
||||
test(`snapshot - loads ${snapshotSegments.join(', ')}`, () => {
|
||||
expect(el).toMatchSnapshot();
|
||||
});
|
||||
test('heading - h4 loaded from messages', () => {
|
||||
const heading = el.find('h4');
|
||||
expect(heading.getElement()).toEqual((
|
||||
<h4>
|
||||
<FormattedMessage {...messages.heading} />
|
||||
</h4>
|
||||
));
|
||||
});
|
||||
test('heading, then alerts, then upload form, then table', () => {
|
||||
expect(el.childAt(0).is('h4')).toEqual(true);
|
||||
expect(el.childAt(2).is(BulkManagementAlerts)).toEqual(true);
|
||||
expect(el.childAt(3).is(HistoryTable)).toEqual(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
21
src/components/BulkManagementHistoryView/messages.js
Normal file
21
src/components/BulkManagementHistoryView/messages.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: {
|
||||
id: 'gradebook.BulkManagementHistoryView.heading',
|
||||
defaultMessage: 'Bulk Management History',
|
||||
description: 'Heading text for BulkManagement History Tab',
|
||||
},
|
||||
helpText: {
|
||||
id: 'gradebook.BulkManagementHistoryView',
|
||||
defaultMessage: 'Below is a log of previous grade imports. To download a CSV of your gradebook and import grades for override, return to the Gradebook. Please note, after importing grades, it may take a few seconds to process the override.',
|
||||
description: 'Bulk Management History View help text',
|
||||
},
|
||||
successDialog: {
|
||||
id: 'gradebook.BulkManagementHistoryView.successDialog',
|
||||
defaultMessage: 'CSV processing. File uploads may take several minutes to complete.',
|
||||
description: 'Success Dialog message in BulkManagement Tab File Upload Form',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
23
src/components/EdxHeader/__snapshots__/test.jsx.snap
Normal file
23
src/components/EdxHeader/__snapshots__/test.jsx.snap
Normal file
@@ -0,0 +1,23 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Header snapshot - has edx link with logo url 1`] = `
|
||||
<div
|
||||
className="mb-3"
|
||||
>
|
||||
<header
|
||||
className="d-flex justify-content-center align-items-center p-3 border-bottom-blue"
|
||||
>
|
||||
<Hyperlink
|
||||
destination="undefined/dashboard"
|
||||
>
|
||||
<img
|
||||
alt="edX logo"
|
||||
height="30"
|
||||
src="www.ourLogo.url"
|
||||
width="60"
|
||||
/>
|
||||
</Hyperlink>
|
||||
<div />
|
||||
</header>
|
||||
</div>
|
||||
`;
|
||||
21
src/components/EdxHeader/index.jsx
Normal file
21
src/components/EdxHeader/index.jsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import { Hyperlink } from '@edx/paragon';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
/**
|
||||
* <EdxHeader />
|
||||
* Gradebook MFE app header.
|
||||
* Displays edx logo, linked to lms dashboard
|
||||
*/
|
||||
const EdxHeader = () => (
|
||||
<div className="mb-3">
|
||||
<header className="d-flex justify-content-center align-items-center p-3 border-bottom-blue">
|
||||
<Hyperlink destination={`${getConfig().LMS_BASE_URL}/dashboard`}>
|
||||
<img src={getConfig().LOGO_URL} alt="edX logo" height="30" width="60" />
|
||||
</Hyperlink>
|
||||
<div />
|
||||
</header>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default EdxHeader;
|
||||
21
src/components/EdxHeader/test.jsx
Normal file
21
src/components/EdxHeader/test.jsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import Header from '.';
|
||||
|
||||
jest.mock('@edx/paragon', () => ({
|
||||
Hyperlink: () => 'Hyperlink',
|
||||
}));
|
||||
jest.mock('@edx/frontend-platform', () => ({
|
||||
getConfig: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('Header', () => {
|
||||
test('snapshot - has edx link with logo url', () => {
|
||||
const url = 'www.ourLogo.url';
|
||||
getConfig.mockReturnValue({ LOGO_URL: url });
|
||||
expect(shallow(<Header />)).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
.student-filters{
|
||||
display: flex;
|
||||
.label{
|
||||
padding-top: 30px;
|
||||
}
|
||||
.form-group{
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
.gbook {
|
||||
overflow-x: scroll;
|
||||
|
||||
.table {
|
||||
padding-left: 244px;
|
||||
}
|
||||
|
||||
.table tr th:first-child {
|
||||
position: absolute;
|
||||
width: 160px;
|
||||
height:50px;
|
||||
display: block;
|
||||
background-color: #fff;
|
||||
border-bottom: none;
|
||||
}
|
||||
.table tr td:first-child {
|
||||
position: absolute;
|
||||
width: 160px;
|
||||
height:50px;
|
||||
display: block;
|
||||
background-color: #fff;
|
||||
}
|
||||
.table tr td:not(:first-child) {
|
||||
//not real sylz. plz kill before prod
|
||||
min-width: 250px;
|
||||
}
|
||||
.table tr td:nth-child(2) {
|
||||
box-sizing: content-box;
|
||||
padding-left: 170px;
|
||||
}
|
||||
.table tr th:nth-child(2) {
|
||||
padding-left: 170px;
|
||||
}
|
||||
|
||||
.link-style {
|
||||
color: #0075b4;
|
||||
&:hover, &:focus {
|
||||
color: #004368;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,462 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import emailPropType from 'email-prop-type';
|
||||
import { Button, Modal, SearchField, Table, InputSelect } from '@edx/paragon';
|
||||
import queryString from 'query-string';
|
||||
|
||||
|
||||
export default class Gradebook extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
grades: [], // this.mapUserEntriesPercent(this.props.grades).sort(this.sortAlphaDesc),
|
||||
headings: [], // this.mapHeadings(this.props.grades[0]),
|
||||
filterValue: '',
|
||||
modalContent: (<h1>Hello, World!</h1>),
|
||||
modalOpen: false,
|
||||
modalModel: [{}],
|
||||
updateVal: 0,
|
||||
updateModuleId: null,
|
||||
updateUserId: null,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const urlQuery = queryString.parse(this.props.location.search);
|
||||
this.props.getUserGrades(
|
||||
this.props.match.params.courseId,
|
||||
urlQuery.cohort,
|
||||
urlQuery.track,
|
||||
);
|
||||
this.props.getTracks(this.props.match.params.courseId);
|
||||
this.props.getCohorts(this.props.match.params.courseId);
|
||||
}
|
||||
|
||||
sortAlphaDesc = (gradeRowA, gradeRowB) => {
|
||||
const a = gradeRowA.username.toUpperCase();
|
||||
const b = gradeRowB.username.toUpperCase();
|
||||
if (a < b) {
|
||||
return -1;
|
||||
}
|
||||
if (a > b) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
sortAlphaAsc = (gradeRowA, gradeRowB) => {
|
||||
const a = gradeRowA.username.toUpperCase();
|
||||
const b = gradeRowB.username.toUpperCase();
|
||||
if (a < b) {
|
||||
return 1;
|
||||
}
|
||||
if (a > b) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
sortNumerically = (colKey, direction) => {
|
||||
function sortNumAsc(gradeRowA, gradeRowB) {
|
||||
if (gradeRowA[colKey] < gradeRowB[colKey]) {
|
||||
return -1;
|
||||
}
|
||||
if (gradeRowA[colKey] > gradeRowB[colKey]) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function sortNumDesc(gradeRowA, gradeRowB) {
|
||||
if (gradeRowA[colKey] < gradeRowB[colKey]) {
|
||||
return 1;
|
||||
}
|
||||
if (gradeRowA[colKey] > gradeRowB[colKey]) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
this.setState({ grades: [...this.state.grades].sort(direction === 'desc' ? sortNumDesc : sortNumAsc) });
|
||||
}
|
||||
|
||||
mapHeadings = (entry) => {
|
||||
if (entry) {
|
||||
const results = [{
|
||||
label: 'Username',
|
||||
key: 'username',
|
||||
columnSortable: true,
|
||||
onSort: (direction) => {
|
||||
this.setState({
|
||||
grades: [...this.state.grades].sort(direction === 'desc' ? this.sortAlphaDesc : this.sortAlphaAsc),
|
||||
});
|
||||
},
|
||||
}];
|
||||
|
||||
const assignmentHeadings = entry.section_breakdown
|
||||
.filter(section => section.is_graded && section.label)
|
||||
.map(s => ({
|
||||
label: s.label,
|
||||
key: s.label,
|
||||
columnSortable: true,
|
||||
onSort: (direction) => { this.sortNumerically(s.label, direction); },
|
||||
}));
|
||||
|
||||
const totals = [{
|
||||
label: 'Total',
|
||||
key: 'total',
|
||||
columnSortable: true,
|
||||
onSort: (direction) => { this.sortNumerically('total', direction); },
|
||||
}];
|
||||
|
||||
return results.concat(assignmentHeadings).concat(totals);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
mapHeadingsHw = (entry) => {
|
||||
const results = [{
|
||||
label: 'Username',
|
||||
key: 'username',
|
||||
columnSortable: true,
|
||||
onSort: (direction) => {
|
||||
this.setState({
|
||||
grades: [...this.state.grades].sort(direction === 'desc' ? this.sortAlphaDesc : this.sortAlphaAsc),
|
||||
});
|
||||
},
|
||||
}];
|
||||
const assignmentHeadings = entry.section_breakdown
|
||||
.filter(section => section.is_graded && section.label && section.category == 'Homework')
|
||||
.map(s => ({
|
||||
label: s.label,
|
||||
key: s.label,
|
||||
columnSortable: true,
|
||||
onSort: (direction) => { this.sortNumerically(s.label, direction); },
|
||||
}));
|
||||
|
||||
return results.concat(assignmentHeadings);
|
||||
};
|
||||
|
||||
mapHeadingsExam = (entry) => {
|
||||
const results = [{
|
||||
label: 'Username',
|
||||
key: 'username',
|
||||
columnSortable: true,
|
||||
onSort: (direction) => {
|
||||
this.setState({
|
||||
grades: [...this.state.grades].sort(direction === 'desc' ? this.sortAlphaDesc : this.sortAlphaAsc),
|
||||
});
|
||||
},
|
||||
}];
|
||||
const assignmentHeadings = entry.section_breakdown
|
||||
.filter(section => section.is_graded && section.label && section.category == 'Exam')
|
||||
.map(s => ({
|
||||
label: s.label,
|
||||
key: s.label,
|
||||
columnSortable: true,
|
||||
onSort: (direction) => { this.sortNumerically(s.label, direction); },
|
||||
}));
|
||||
|
||||
return results.concat(assignmentHeadings);
|
||||
};
|
||||
|
||||
setNewModalState = (userEntry, subsection) => {
|
||||
this.setState({
|
||||
modalModel: [{
|
||||
username: userEntry.username,
|
||||
currentGrade: `${subsection.score_earned}/${subsection.score_possible}`,
|
||||
adjustedGrade: (
|
||||
<span>
|
||||
<input
|
||||
style={{ width: '25px' }}
|
||||
type="text"
|
||||
onChange={(event) => this.setState({updateVal: event.target.value})}
|
||||
/> / {subsection.score_possible}
|
||||
</span>
|
||||
),
|
||||
assignmentName: `${subsection.subsection_name}`,
|
||||
}],
|
||||
modalOpen: true,
|
||||
updateModuleId: subsection.module_id,
|
||||
updateUserId: userEntry.user_id,
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
mapUserEntriesPercent = entries => entries.map((entry) => {
|
||||
const results = { username: entry.username };
|
||||
const assignments = entry.section_breakdown
|
||||
.filter(section => section.is_graded)
|
||||
.reduce((acc, subsection) => {
|
||||
acc[subsection.label] = (
|
||||
<button
|
||||
className="btn btn-header link-style"
|
||||
onClick={() => this.setNewModalState(entry, subsection)}
|
||||
>
|
||||
{subsection.percent}
|
||||
</button>);
|
||||
return acc;
|
||||
}, {});
|
||||
const totals = { total: entry.percent * 100 };
|
||||
return Object.assign(results, assignments, totals);
|
||||
});
|
||||
|
||||
mapUserEntriesAbsolute = entries => entries.map((entry) => {
|
||||
const results = { username: entry.username };
|
||||
const assignments = entry.section_breakdown
|
||||
.filter(section => section.is_graded)
|
||||
.reduce((acc, subsection) => {
|
||||
acc[subsection.label] = (
|
||||
<button
|
||||
className="btn btn-header link-style"
|
||||
onClick={() => this.setNewModalState(entry, subsection)}
|
||||
>
|
||||
{subsection.score_earned}/{subsection.score_possible}
|
||||
</button>);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const totals = { total: entry.percent * 100 };
|
||||
return Object.assign(results, assignments, totals);
|
||||
});
|
||||
|
||||
handleAdjustedGradeClick = () => {
|
||||
this.props.updateGrades(this.props.match.params.courseId, [
|
||||
{
|
||||
'user_id': this.state.updateUserId,
|
||||
'usage_id': this.state.updateModuleId,
|
||||
'grade': {
|
||||
'earned_graded_override': this.state.updateVal,
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
updateQueryParams = (queryKey, queryValue) => {
|
||||
const parsed = queryString.parse(this.props.location.search);
|
||||
parsed[queryKey] = queryValue;
|
||||
return `?${queryString.stringify(parsed)}`;
|
||||
};
|
||||
|
||||
mapCohortsEntries = (entries) => {
|
||||
let mapped = entries.map(entry => ({
|
||||
id: entry.id,
|
||||
label: entry.name,
|
||||
}));
|
||||
mapped.unshift({id:0, label:'Cohorts'});
|
||||
return mapped;
|
||||
};
|
||||
|
||||
mapTracksEntries = (entries) => {
|
||||
let mapped = entries.map(entry => ({
|
||||
id: entry.slug,
|
||||
label: entry.name,
|
||||
}));
|
||||
mapped.unshift({ label:'Tracks' });
|
||||
return mapped;
|
||||
};
|
||||
|
||||
updateTracks = (event) => {
|
||||
const selectedTrackItem = this.props.tracks.find(x=>x.name===event);
|
||||
let selectedTrackSlug = null;
|
||||
if(selectedTrackItem) {
|
||||
selectedTrackSlug = selectedTrackItem.slug;
|
||||
}
|
||||
this.props.getUserGrades(
|
||||
this.props.match.params.courseId,
|
||||
this.props.selectedCohort,
|
||||
selectedTrackSlug,
|
||||
);
|
||||
const updatedQueryStrings = this.updateQueryParams('track', selectedTrackSlug)
|
||||
this.props.history.push(updatedQueryStrings);
|
||||
};
|
||||
|
||||
updateCohorts = (event) => {
|
||||
const selectedCohortItem = this.props.cohorts.find(x=>x.name===event);
|
||||
let selectedCohortId = null;
|
||||
if(selectedCohortItem) {
|
||||
selectedCohortId = selectedCohortItem.id;
|
||||
}
|
||||
this.props.getUserGrades(
|
||||
this.props.match.params.courseId,
|
||||
selectedCohortId,
|
||||
this.props.selectedTrack,
|
||||
);
|
||||
const updatedQueryStrings = this.updateQueryParams('cohort', selectedCohortId)
|
||||
this.props.history.push(updatedQueryStrings);
|
||||
};
|
||||
|
||||
mapSelectedCohortEntry = (entry) => {
|
||||
const selectedCohortEntry = this.props.cohorts.find(x => x.id === parseInt(entry, 10));
|
||||
if (selectedCohortEntry) {
|
||||
return selectedCohortEntry.name;
|
||||
}
|
||||
return 'Cohorts';
|
||||
};
|
||||
|
||||
mapSelectedTrackEntry = (entry) => {
|
||||
const selectedTrackEntry = this.props.tracks.find(x => x.slug === entry);
|
||||
if (selectedTrackEntry) {
|
||||
return selectedTrackEntry.name;
|
||||
}
|
||||
return 'Tracks';
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="d-flex justify-content-center">
|
||||
<div className="card" style={{ width: '50rem' }}>
|
||||
<div className="card-body">
|
||||
<h1>Gradebook</h1>
|
||||
<hr />
|
||||
<div className="d-flex justify-content-between" >
|
||||
<div>
|
||||
<div>
|
||||
Score View:
|
||||
<span>
|
||||
<input
|
||||
id="score-view-percent"
|
||||
className="ml-2"
|
||||
type="radio"
|
||||
name="score-view"
|
||||
value="percent"
|
||||
onClick={() => this.setState({ grades: this.mapUserEntriesPercent(this.props.results).sort(this.sortAlphaDesc) })}
|
||||
/>
|
||||
<label className="ml-2 mr-2" htmlFor="score-view-percent">Percent</label>
|
||||
</span>
|
||||
<span>
|
||||
<input
|
||||
id="score-view-absolute"
|
||||
type="radio"
|
||||
name="score-view"
|
||||
value="absolute"
|
||||
onClick={() => this.setState({ grades: this.mapUserEntriesAbsolute(this.props.results).sort(this.sortAlphaDesc) })}
|
||||
/>
|
||||
<label className="ml-2 mr-2" htmlFor="score-view-absolute">Absolute</label>
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
Category:
|
||||
<span>
|
||||
<label className="ml-2 mr-2" htmlFor="category-all">
|
||||
<input
|
||||
id="category-all"
|
||||
className="ml-2"
|
||||
type="radio"
|
||||
name="category"
|
||||
value="all"
|
||||
onClick={() =>
|
||||
this.setState({ headings: this.mapHeadings(this.props.results[0]) })}
|
||||
/>
|
||||
All
|
||||
</label>
|
||||
</span>
|
||||
<span>
|
||||
<input
|
||||
id="category-homework"
|
||||
className="ml-2"
|
||||
type="radio"
|
||||
name="category"
|
||||
value="homework"
|
||||
onClick={() =>
|
||||
this.setState({
|
||||
headings: this.mapHeadingsHw(this.props.results[0]),
|
||||
})}
|
||||
/>
|
||||
<label className="ml-2 mr-2" htmlFor="category-homework">Homework</label>
|
||||
</span>
|
||||
<span>
|
||||
<label className="ml-2 mr-2" htmlFor="Exam">
|
||||
<input
|
||||
id="category-exam"
|
||||
type="radio"
|
||||
name="category"
|
||||
value="exam"
|
||||
onClick={() => this.setState({ headings: this.mapHeadingsExam(this.props.results[0]) })}
|
||||
/>
|
||||
Exam
|
||||
</label>
|
||||
</span>
|
||||
</div>
|
||||
{(this.props.tracks.length > 0 || this.props.cohorts.length > 0) &&
|
||||
<div className="student-filters">
|
||||
<span className="label">
|
||||
Student Groups:
|
||||
</span>
|
||||
{this.props.tracks.length > 0 &&
|
||||
<InputSelect
|
||||
name='Tracks'
|
||||
value={this.mapSelectedTrackEntry(this.props.selectedTrack)}
|
||||
options={this.mapTracksEntries(this.props.tracks)}
|
||||
onChange={this.updateTracks}
|
||||
/>
|
||||
}
|
||||
{this.props.cohorts.length > 0 &&
|
||||
<InputSelect
|
||||
name='Cohorts'
|
||||
value={this.mapSelectedCohortEntry(this.props.selectedCohort)}
|
||||
options={this.mapCohortsEntries(this.props.cohorts)}
|
||||
onChange={this.updateCohorts}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ marginLeft: '10px', marginBottom: '10px' }}>
|
||||
<a href="https://www.google./com">Download Grade Report</a>
|
||||
</div>
|
||||
<SearchField
|
||||
onSubmit={value => this.props.searchForUser(this.props.match.params.courseId, value, this.props.selectedCohort, this.props.selectedTrack)}
|
||||
onChange={filterValue => this.setState({ filterValue })}
|
||||
onClear={() => this.props.getUserGrades(this.props.match.params.courseId, this.props.selectedCohort, this.props.selectedTrack)}
|
||||
value={this.state.filterValue}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
<div className="gbook">
|
||||
<Table
|
||||
columns={this.mapHeadings(this.props.grades[0])}
|
||||
data={this.mapUserEntriesPercent(this.props.grades)}
|
||||
tableSortable
|
||||
defaultSortDirection="desc"
|
||||
defaultSortedColumn="username"
|
||||
/>
|
||||
</div>
|
||||
<Modal
|
||||
open={this.state.modalOpen}
|
||||
title="Edit Grades"
|
||||
body={(
|
||||
<div>
|
||||
<h3>{this.state.modalModel[0].assignmentName}</h3>
|
||||
<Table
|
||||
columns={[{ label: 'Username', key: 'username' }, { label: 'Current grade', key: 'currentGrade' }, { label: 'Adjusted grade', key: 'adjustedGrade' }]}
|
||||
data={this.state.modalModel}
|
||||
tableSortable
|
||||
defaultSortDirection="desc"
|
||||
defaultSortedColumn="username"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
buttons={[
|
||||
<Button
|
||||
label="Edit Grade"
|
||||
buttonType="primary"
|
||||
onClick={this.handleAdjustedGradeClick}
|
||||
/>
|
||||
]}
|
||||
onClose={() => this.setState({
|
||||
modalOpen: false,
|
||||
modalModel: [{}],
|
||||
updateVal: 0,
|
||||
updateModuleId: null,
|
||||
updateUserId: null,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// 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>
|
||||
`;
|
||||
98
src/components/GradebookFilters/AssignmentFilter/index.jsx
Normal file
98
src/components/GradebookFilters/AssignmentFilter/index.jsx
Normal file
@@ -0,0 +1,98 @@
|
||||
/* 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 messages from '../messages';
|
||||
import SelectGroup from '../SelectGroup';
|
||||
|
||||
const { fetchGradesIfAssignmentGradeFiltersSet } = thunkActions.grades;
|
||||
|
||||
export class AssignmentFilter extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
}
|
||||
|
||||
handleChange(event) {
|
||||
const assignment = event.target.value;
|
||||
const selectedFilterOption = this.props.assignmentFilterOptions.find(
|
||||
({ label }) => label === assignment,
|
||||
);
|
||||
const { type, id } = selectedFilterOption || {};
|
||||
const typedValue = { label: assignment, type, id };
|
||||
this.props.updateAssignmentFilter(typedValue);
|
||||
this.props.updateQueryParams({ assignment: id });
|
||||
this.props.fetchGradesIfAssignmentGradeFiltersSet();
|
||||
}
|
||||
|
||||
get options() {
|
||||
const mapper = ({ label, subsectionLabel }) => (
|
||||
<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: '',
|
||||
};
|
||||
|
||||
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);
|
||||
162
src/components/GradebookFilters/AssignmentFilter/test.jsx
Normal file
162
src/components/GradebookFilters/AssignmentFilter/test.jsx
Normal file
@@ -0,0 +1,162 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
// 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>
|
||||
`;
|
||||
103
src/components/GradebookFilters/AssignmentGradeFilter/index.jsx
Normal file
103
src/components/GradebookFilters/AssignmentGradeFilter/index.jsx
Normal file
@@ -0,0 +1,103 @@
|
||||
/* 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 { Button } from '@edx/paragon';
|
||||
|
||||
import selectors from 'data/selectors';
|
||||
import actions from 'data/actions';
|
||||
import thunkActions from 'data/thunkActions';
|
||||
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AssignmentGradeFilter.defaultProps = {
|
||||
selectedAssignment: '',
|
||||
};
|
||||
|
||||
AssignmentGradeFilter.propTypes = {
|
||||
updateQueryParams: PropTypes.func.isRequired,
|
||||
|
||||
// redux
|
||||
fetchGrades: PropTypes.func.isRequired,
|
||||
localAssignmentLimits: PropTypes.shape({
|
||||
assignmentGradeMax: PropTypes.string,
|
||||
assignmentGradeMin: PropTypes.string,
|
||||
}).isRequired,
|
||||
selectedAssignment: PropTypes.string,
|
||||
setFilter: PropTypes.func.isRequired,
|
||||
updateAssignmentLimits: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => ({
|
||||
localAssignmentLimits: selectors.app.assignmentGradeLimits(state),
|
||||
selectedAssignment: selectors.filters.selectedAssignmentLabel(state),
|
||||
});
|
||||
|
||||
export const mapDispatchToProps = {
|
||||
fetchGrades: thunkActions.grades.fetchGrades,
|
||||
setFilter: actions.app.setLocalFilter,
|
||||
updateAssignmentLimits: actions.filters.update.assignmentLimits,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(AssignmentGradeFilter);
|
||||
143
src/components/GradebookFilters/AssignmentGradeFilter/test.jsx
Normal file
143
src/components/GradebookFilters/AssignmentGradeFilter/test.jsx
Normal file
@@ -0,0 +1,143 @@
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
// 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>
|
||||
`;
|
||||
@@ -0,0 +1,81 @@
|
||||
/* 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 SelectGroup from '../SelectGroup';
|
||||
import messages from '../messages';
|
||||
|
||||
export class AssignmentTypeFilter extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
}
|
||||
|
||||
handleChange(event) {
|
||||
const assignmentType = event.target.value;
|
||||
this.props.filterAssignmentType(assignmentType);
|
||||
this.props.updateQueryParams({ assignmentType });
|
||||
}
|
||||
|
||||
get options() {
|
||||
const mapper = (entry) => (
|
||||
<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: '',
|
||||
};
|
||||
|
||||
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);
|
||||
135
src/components/GradebookFilters/AssignmentTypeFilter/test.jsx
Normal file
135
src/components/GradebookFilters/AssignmentTypeFilter/test.jsx
Normal file
@@ -0,0 +1,135 @@
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
// 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>
|
||||
`;
|
||||
103
src/components/GradebookFilters/CourseGradeFilter/index.jsx
Normal file
103
src/components/GradebookFilters/CourseGradeFilter/index.jsx
Normal file
@@ -0,0 +1,103 @@
|
||||
/* 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 messages from '../messages';
|
||||
import PercentGroup from '../PercentGroup';
|
||||
|
||||
export class CourseGradeFilter extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.handleApplyClick = this.handleApplyClick.bind(this);
|
||||
this.handleUpdateMin = this.handleUpdateMin.bind(this);
|
||||
this.handleUpdateMax = this.handleUpdateMax.bind(this);
|
||||
this.updateCourseGradeFilters = this.updateCourseGradeFilters.bind(this);
|
||||
}
|
||||
|
||||
handleApplyClick() {
|
||||
if (this.props.areLimitsValid) {
|
||||
this.updateCourseGradeFilters();
|
||||
}
|
||||
}
|
||||
|
||||
handleUpdateMin({ target: { value } }) {
|
||||
this.props.setLocalFilter({ courseGradeMin: value });
|
||||
}
|
||||
|
||||
handleUpdateMax({ target: { value } }) {
|
||||
this.props.setLocalFilter({ courseGradeMax: value });
|
||||
}
|
||||
|
||||
updateCourseGradeFilters() {
|
||||
this.props.updateFilter(this.props.localCourseLimits);
|
||||
this.props.fetchGrades();
|
||||
this.props.updateQueryParams(this.props.localCourseLimits);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
localCourseLimits: { courseGradeMin, courseGradeMax },
|
||||
} = this.props;
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
150
src/components/GradebookFilters/CourseGradeFilter/test.jsx
Normal file
150
src/components/GradebookFilters/CourseGradeFilter/test.jsx
Normal file
@@ -0,0 +1,150 @@
|
||||
/* 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
6
src/components/GradebookFilters/GradebookFilters.scss
Normal file
6
src/components/GradebookFilters/GradebookFilters.scss
Normal file
@@ -0,0 +1,6 @@
|
||||
.filter-sidebar-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 15px;
|
||||
}
|
||||
39
src/components/GradebookFilters/PercentGroup.jsx
Normal file
39
src/components/GradebookFilters/PercentGroup.jsx
Normal file
@@ -0,0 +1,39 @@
|
||||
/* eslint-disable react/sort-comp */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Form } from '@edx/paragon';
|
||||
|
||||
const PercentGroup = ({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
disabled,
|
||||
onChange,
|
||||
}) => (
|
||||
<div className="percent-group">
|
||||
<Form.Group controlId={id}>
|
||||
<Form.Label>{label}</Form.Label>
|
||||
<Form.Control
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
{...{ value, disabled, onChange }}
|
||||
/>
|
||||
</Form.Group>
|
||||
<span className="input-percent-label">%</span>
|
||||
</div>
|
||||
);
|
||||
PercentGroup.defaultProps = {
|
||||
disabled: false,
|
||||
};
|
||||
PercentGroup.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
label: PropTypes.node.isRequired,
|
||||
value: PropTypes.string.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
disabled: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default PercentGroup;
|
||||
33
src/components/GradebookFilters/PercentGroup.test.jsx
Normal file
33
src/components/GradebookFilters/PercentGroup.test.jsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import PercentGroup from './PercentGroup';
|
||||
|
||||
describe('PercentGroup', () => {
|
||||
let props = {
|
||||
id: 'group id',
|
||||
label: 'Group Label',
|
||||
value: 'group VALUE',
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
props = {
|
||||
...props,
|
||||
onChange: jest.fn().mockName('props.onChange'),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Component', () => {
|
||||
describe('snapshots', () => {
|
||||
test('basic snapshot', () => {
|
||||
const el = shallow(<PercentGroup {...props} />);
|
||||
expect(el).toMatchSnapshot();
|
||||
});
|
||||
test('disabled', () => {
|
||||
const el = shallow(<PercentGroup {...props} disabled />);
|
||||
expect(el).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
36
src/components/GradebookFilters/SelectGroup.jsx
Normal file
36
src/components/GradebookFilters/SelectGroup.jsx
Normal file
@@ -0,0 +1,36 @@
|
||||
/* eslint-disable react/sort-comp, react/button-has-type */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Form } from '@edx/paragon';
|
||||
|
||||
const SelectGroup = ({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
options,
|
||||
}) => (
|
||||
<div className="student-filters">
|
||||
<Form.Group controlId={id}>
|
||||
<Form.Label>{label}</Form.Label>
|
||||
<Form.Control as="select" {...{ value, onChange, disabled }}>
|
||||
{options}
|
||||
</Form.Control>
|
||||
</Form.Group>
|
||||
</div>
|
||||
);
|
||||
SelectGroup.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
label: PropTypes.node.isRequired,
|
||||
value: PropTypes.string.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
disabled: PropTypes.bool,
|
||||
options: PropTypes.arrayOf(PropTypes.node).isRequired,
|
||||
};
|
||||
SelectGroup.defaultProps = {
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
export default SelectGroup;
|
||||
38
src/components/GradebookFilters/SelectGroup.test.jsx
Normal file
38
src/components/GradebookFilters/SelectGroup.test.jsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import SelectGroup from './SelectGroup';
|
||||
|
||||
describe('SelectGroup', () => {
|
||||
let props = {
|
||||
id: 'group id',
|
||||
label: 'Group Label',
|
||||
value: 'group VALUE',
|
||||
disabled: false,
|
||||
options: [
|
||||
<option value="opt1" key="opt1">Option 1</option>,
|
||||
<option value="opt2" key="opt2">Option 2</option>,
|
||||
<option value="opt3" key="opt3">Option 3</option>,
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
props = {
|
||||
...props,
|
||||
onChange: jest.fn().mockName('props.onChange'),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Component', () => {
|
||||
describe('snapshots', () => {
|
||||
test('basic snapshot', () => {
|
||||
const el = shallow(<SelectGroup {...props} />);
|
||||
expect(el).toMatchSnapshot();
|
||||
});
|
||||
test('disabled', () => {
|
||||
const el = shallow(<SelectGroup {...props} disabled />);
|
||||
expect(el).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
// 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>,
|
||||
]
|
||||
`;
|
||||
152
src/components/GradebookFilters/StudentGroupsFilter/index.jsx
Normal file
152
src/components/GradebookFilters/StudentGroupsFilter/index.jsx
Normal file
@@ -0,0 +1,152 @@
|
||||
/* 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 messages from '../messages';
|
||||
import SelectGroup from '../SelectGroup';
|
||||
|
||||
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>),
|
||||
),
|
||||
];
|
||||
|
||||
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: {},
|
||||
};
|
||||
|
||||
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));
|
||||
239
src/components/GradebookFilters/StudentGroupsFilter/test.jsx
Normal file
239
src/components/GradebookFilters/StudentGroupsFilter/test.jsx
Normal file
@@ -0,0 +1,239 @@
|
||||
/* 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`PercentGroup Component snapshots basic snapshot 1`] = `
|
||||
<div
|
||||
className="percent-group"
|
||||
>
|
||||
<FormGroup
|
||||
as="div"
|
||||
controlId="group id"
|
||||
isInvalid={false}
|
||||
isValid={false}
|
||||
>
|
||||
<FormLabel
|
||||
isInline={false}
|
||||
>
|
||||
Group Label
|
||||
</FormLabel>
|
||||
<ForwardRef
|
||||
as="input"
|
||||
disabled={false}
|
||||
max={100}
|
||||
min={0}
|
||||
onChange={[MockFunction props.onChange]}
|
||||
plaintext={false}
|
||||
step={1}
|
||||
type="number"
|
||||
value="group VALUE"
|
||||
/>
|
||||
</FormGroup>
|
||||
<span
|
||||
className="input-percent-label"
|
||||
>
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`PercentGroup Component snapshots disabled 1`] = `
|
||||
<div
|
||||
className="percent-group"
|
||||
>
|
||||
<FormGroup
|
||||
as="div"
|
||||
controlId="group id"
|
||||
isInvalid={false}
|
||||
isValid={false}
|
||||
>
|
||||
<FormLabel
|
||||
isInline={false}
|
||||
>
|
||||
Group Label
|
||||
</FormLabel>
|
||||
<ForwardRef
|
||||
as="input"
|
||||
disabled={true}
|
||||
max={100}
|
||||
min={0}
|
||||
onChange={[MockFunction props.onChange]}
|
||||
plaintext={false}
|
||||
step={1}
|
||||
type="number"
|
||||
value="group VALUE"
|
||||
/>
|
||||
</FormGroup>
|
||||
<span
|
||||
className="input-percent-label"
|
||||
>
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,91 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`SelectGroup Component snapshots basic snapshot 1`] = `
|
||||
<div
|
||||
className="student-filters"
|
||||
>
|
||||
<FormGroup
|
||||
as="div"
|
||||
controlId="group id"
|
||||
isInvalid={false}
|
||||
isValid={false}
|
||||
>
|
||||
<FormLabel
|
||||
isInline={false}
|
||||
>
|
||||
Group Label
|
||||
</FormLabel>
|
||||
<ForwardRef
|
||||
as="select"
|
||||
disabled={false}
|
||||
onChange={[MockFunction props.onChange]}
|
||||
plaintext={false}
|
||||
value="group VALUE"
|
||||
>
|
||||
<option
|
||||
key="opt1"
|
||||
value="opt1"
|
||||
>
|
||||
Option 1
|
||||
</option>
|
||||
<option
|
||||
key="opt2"
|
||||
value="opt2"
|
||||
>
|
||||
Option 2
|
||||
</option>
|
||||
<option
|
||||
key="opt3"
|
||||
value="opt3"
|
||||
>
|
||||
Option 3
|
||||
</option>
|
||||
</ForwardRef>
|
||||
</FormGroup>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`SelectGroup Component snapshots disabled 1`] = `
|
||||
<div
|
||||
className="student-filters"
|
||||
>
|
||||
<FormGroup
|
||||
as="div"
|
||||
controlId="group id"
|
||||
isInvalid={false}
|
||||
isValid={false}
|
||||
>
|
||||
<FormLabel
|
||||
isInline={false}
|
||||
>
|
||||
Group Label
|
||||
</FormLabel>
|
||||
<ForwardRef
|
||||
as="select"
|
||||
disabled={true}
|
||||
onChange={[MockFunction props.onChange]}
|
||||
plaintext={false}
|
||||
value="group VALUE"
|
||||
>
|
||||
<option
|
||||
key="opt1"
|
||||
value="opt1"
|
||||
>
|
||||
Option 1
|
||||
</option>
|
||||
<option
|
||||
key="opt2"
|
||||
value="opt2"
|
||||
>
|
||||
Option 2
|
||||
</option>
|
||||
<option
|
||||
key="opt3"
|
||||
value="opt3"
|
||||
>
|
||||
Option 3
|
||||
</option>
|
||||
</ForwardRef>
|
||||
</FormGroup>
|
||||
</div>
|
||||
`;
|
||||
98
src/components/GradebookFilters/__snapshots__/test.jsx.snap
Normal file
98
src/components/GradebookFilters/__snapshots__/test.jsx.snap
Normal file
@@ -0,0 +1,98 @@
|
||||
// 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>
|
||||
<Connect(AssignmentTypeFilter)
|
||||
updateQueryParams={[MockFunction]}
|
||||
/>
|
||||
<Connect(AssignmentFilter)
|
||||
updateQueryParams={[MockFunction]}
|
||||
/>
|
||||
<Connect(AssignmentGradeFilter)
|
||||
updateQueryParams={[MockFunction]}
|
||||
/>
|
||||
</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"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Connect(CourseGradeFilter)
|
||||
updateQueryParams={[MockFunction]}
|
||||
/>
|
||||
</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"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<InjectIntl(ShimmedIntlComponent)
|
||||
updateQueryParams={[MockFunction]}
|
||||
/>
|
||||
</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>
|
||||
`;
|
||||
124
src/components/GradebookFilters/index.jsx
Normal file
124
src/components/GradebookFilters/index.jsx
Normal file
@@ -0,0 +1,124 @@
|
||||
/* 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,
|
||||
Icon,
|
||||
IconButton,
|
||||
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 messages from './messages';
|
||||
import AssignmentTypeFilter from './AssignmentTypeFilter';
|
||||
import AssignmentFilter from './AssignmentFilter';
|
||||
import AssignmentGradeFilter from './AssignmentGradeFilter';
|
||||
import CourseGradeFilter from './CourseGradeFilter';
|
||||
import StudentGroupsFilter from './StudentGroupsFilter';
|
||||
|
||||
export class GradebookFilters extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
includeCourseRoleMembers: this.props.includeCourseRoleMembers,
|
||||
};
|
||||
this.handleIncludeTeamMembersChange = this.handleIncludeTeamMembersChange.bind(this);
|
||||
}
|
||||
|
||||
handleIncludeTeamMembersChange(event) {
|
||||
const includeCourseRoleMembers = event.target.checked;
|
||||
this.setState({ includeCourseRoleMembers });
|
||||
this.props.updateIncludeCourseRoleMembers(includeCourseRoleMembers);
|
||||
this.props.fetchGrades();
|
||||
this.props.updateQueryParams({ includeCourseRoleMembers });
|
||||
}
|
||||
|
||||
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)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{this.collapsibleGroup(messages.assignments, (
|
||||
<div>
|
||||
<AssignmentTypeFilter updateQueryParams={updateQueryParams} />
|
||||
<AssignmentFilter updateQueryParams={updateQueryParams} />
|
||||
<AssignmentGradeFilter updateQueryParams={updateQueryParams} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{this.collapsibleGroup(messages.overallGrade, (
|
||||
<CourseGradeFilter updateQueryParams={updateQueryParams} />
|
||||
))}
|
||||
|
||||
{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,
|
||||
};
|
||||
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));
|
||||
71
src/components/GradebookFilters/messages.js
Normal file
71
src/components/GradebookFilters/messages.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
assignments: {
|
||||
id: 'gradebook.GradebookFilters.assignmentsFilterLabel',
|
||||
defaultMessage: 'Assignments',
|
||||
description: 'Assignment filter group label in Gradebook Filters',
|
||||
},
|
||||
overallGrade: {
|
||||
id: 'gradebook.GradebookFilters.overallGradeFilterLabel',
|
||||
defaultMessage: 'Overall Grade',
|
||||
description: 'Overall Grade filter group label in Gradebook Filters',
|
||||
},
|
||||
studentGroups: {
|
||||
id: 'gradebook.GradebookFilters.studentGroupsFilterLabel',
|
||||
defaultMessage: 'Student Groups',
|
||||
description: 'Student Groups filter group label in Gradebook Filters',
|
||||
},
|
||||
includeCourseTeamMembers: {
|
||||
id: 'gradebook.GradebookFilters.includeCourseTeamMembersFilterLabel',
|
||||
defaultMessage: 'Include Course Team Members',
|
||||
description: 'Include Course Team Members filter label in Gradebook Filters',
|
||||
},
|
||||
assignment: {
|
||||
id: 'gradebook.GradebookFilters.assignmentFilterLabel',
|
||||
defaultMessage: 'Assignment',
|
||||
description: 'Assignment filter select label in Gradebook Filters',
|
||||
},
|
||||
assignmentTypes: {
|
||||
id: 'gradebook.GradebookFilters.assignmentTypesLabel',
|
||||
defaultMessage: 'Assignment Types',
|
||||
description: 'Assignment Types filter select label in Gradebook Filters',
|
||||
},
|
||||
maxGrade: {
|
||||
id: 'gradebook.GradebookFilters.maxGradeFilterLabel',
|
||||
defaultMessage: 'Max Grade',
|
||||
description: 'Max-grade filter select label in Gradebook Filters',
|
||||
},
|
||||
minGrade: {
|
||||
id: 'gradebook.GradebookFilters.minGradeFilterLabel',
|
||||
defaultMessage: 'Min Grade',
|
||||
description: 'Min-grade filter select label in Gradebook Filters',
|
||||
},
|
||||
cohorts: {
|
||||
id: 'gradebook.GradebookFilters.cohorts',
|
||||
defaultMessage: 'Cohorts',
|
||||
description: 'Cohorts filter select label in Gradebook Filters',
|
||||
},
|
||||
cohortAll: {
|
||||
id: 'gradebook.GradebookFilters.cohortsAll',
|
||||
defaultMessage: 'Cohort-All',
|
||||
description: 'Cohorts filter select default in Gradebook Filters',
|
||||
},
|
||||
tracks: {
|
||||
id: 'gradebook.GradebookFilters.tracks',
|
||||
defaultMessage: 'Tracks',
|
||||
description: 'Tracks filter select label in Gradebook Filters',
|
||||
},
|
||||
trackAll: {
|
||||
id: 'gradebook.GradebookFilters.trackAll',
|
||||
defaultMessage: 'Track-All',
|
||||
description: 'Tracks filter select default in Gradebook Filters',
|
||||
},
|
||||
closeFilters: {
|
||||
id: 'gradebook.GradebookFilters.closeFilters',
|
||||
defaultMessage: 'Close Filters',
|
||||
description: 'Button label for Close button in Gradebook Filters',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
121
src/components/GradebookFilters/test.jsx
Normal file
121
src/components/GradebookFilters/test.jsx
Normal file
@@ -0,0 +1,121 @@
|
||||
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('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(),
|
||||
};
|
||||
});
|
||||
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
261
src/components/GradebookHeader/__snapshots__/test.jsx.snap
Normal file
261
src/components/GradebookHeader/__snapshots__/test.jsx.snap
Normal file
@@ -0,0 +1,261 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`GradebookHeader component snapshots default values (grades frozen, cannot view). unauthorized warning, but no grades frozen warning 1`] = `
|
||||
<div
|
||||
className="gradebook-header"
|
||||
>
|
||||
<a
|
||||
className="mb-3"
|
||||
href="http://localhost:18000/courses/fakeID/instructor"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
>
|
||||
<<
|
||||
</span>
|
||||
<FormattedMessage
|
||||
defaultMessage="Back to Dashboard"
|
||||
description="Button text to take user back to LMS dashboard in Gradebook Header"
|
||||
id="gradebook.GradebookHeader.backButton"
|
||||
/>
|
||||
</a>
|
||||
<h1>
|
||||
<FormattedMessage
|
||||
defaultMessage="Gradebook"
|
||||
description="Top-level app title in Gradebook Header component"
|
||||
id="gradebook.GradebookHeader.appLabel"
|
||||
/>
|
||||
</h1>
|
||||
<div
|
||||
className="subtitle-row d-flex justify-content-between align-items-center"
|
||||
>
|
||||
<h3>
|
||||
fakeID
|
||||
</h3>
|
||||
</div>
|
||||
<div
|
||||
className="alert alert-warning"
|
||||
role="alert"
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="You are not authorized to view the gradebook for this course."
|
||||
description="Warning message in Gradebook Header when user is not allowed to view the app"
|
||||
id="gradebook.GradebookHeader.unauthorizedWarning"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`GradebookHeader component snapshots grades frozen, can view. grades frozen warning but no unauthorized warning 1`] = `
|
||||
<div
|
||||
className="gradebook-header"
|
||||
>
|
||||
<a
|
||||
className="mb-3"
|
||||
href="http://localhost:18000/courses/fakeID/instructor"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
>
|
||||
<<
|
||||
</span>
|
||||
<FormattedMessage
|
||||
defaultMessage="Back to Dashboard"
|
||||
description="Button text to take user back to LMS dashboard in Gradebook Header"
|
||||
id="gradebook.GradebookHeader.backButton"
|
||||
/>
|
||||
</a>
|
||||
<h1>
|
||||
<FormattedMessage
|
||||
defaultMessage="Gradebook"
|
||||
description="Top-level app title in Gradebook Header component"
|
||||
id="gradebook.GradebookHeader.appLabel"
|
||||
/>
|
||||
</h1>
|
||||
<div
|
||||
className="subtitle-row d-flex justify-content-between align-items-center"
|
||||
>
|
||||
<h3>
|
||||
fakeID
|
||||
</h3>
|
||||
</div>
|
||||
<div
|
||||
className="alert alert-warning"
|
||||
role="alert"
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="The grades for this course are now frozen. Editing of grades is no longer allowed."
|
||||
description="Warning message in Gradebook Header for frozen messages"
|
||||
id="gradebook.GradebookHeader.frozenWarning"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`GradebookHeader component snapshots grades frozen, cannot view unauthorized warning, and grades frozen warning. 1`] = `
|
||||
<div
|
||||
className="gradebook-header"
|
||||
>
|
||||
<a
|
||||
className="mb-3"
|
||||
href="http://localhost:18000/courses/fakeID/instructor"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
>
|
||||
<<
|
||||
</span>
|
||||
<FormattedMessage
|
||||
defaultMessage="Back to Dashboard"
|
||||
description="Button text to take user back to LMS dashboard in Gradebook Header"
|
||||
id="gradebook.GradebookHeader.backButton"
|
||||
/>
|
||||
</a>
|
||||
<h1>
|
||||
<FormattedMessage
|
||||
defaultMessage="Gradebook"
|
||||
description="Top-level app title in Gradebook Header component"
|
||||
id="gradebook.GradebookHeader.appLabel"
|
||||
/>
|
||||
</h1>
|
||||
<div
|
||||
className="subtitle-row d-flex justify-content-between align-items-center"
|
||||
>
|
||||
<h3>
|
||||
fakeID
|
||||
</h3>
|
||||
</div>
|
||||
<div
|
||||
className="alert alert-warning"
|
||||
role="alert"
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="The grades for this course are now frozen. Editing of grades is no longer allowed."
|
||||
description="Warning message in Gradebook Header for frozen messages"
|
||||
id="gradebook.GradebookHeader.frozenWarning"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="alert alert-warning"
|
||||
role="alert"
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="You are not authorized to view the gradebook for this course."
|
||||
description="Warning message in Gradebook Header when user is not allowed to view the app"
|
||||
id="gradebook.GradebookHeader.unauthorizedWarning"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`GradebookHeader component snapshots show bulk management, active view is bulkManagementHistory view toggle view button to grades 1`] = `
|
||||
<div
|
||||
className="gradebook-header"
|
||||
>
|
||||
<a
|
||||
className="mb-3"
|
||||
href="http://localhost:18000/courses/fakeID/instructor"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
>
|
||||
<<
|
||||
</span>
|
||||
<FormattedMessage
|
||||
defaultMessage="Back to Dashboard"
|
||||
description="Button text to take user back to LMS dashboard in Gradebook Header"
|
||||
id="gradebook.GradebookHeader.backButton"
|
||||
/>
|
||||
</a>
|
||||
<h1>
|
||||
<FormattedMessage
|
||||
defaultMessage="Gradebook"
|
||||
description="Top-level app title in Gradebook Header component"
|
||||
id="gradebook.GradebookHeader.appLabel"
|
||||
/>
|
||||
</h1>
|
||||
<div
|
||||
className="subtitle-row d-flex justify-content-between align-items-center"
|
||||
>
|
||||
<h3>
|
||||
fakeID
|
||||
</h3>
|
||||
<Button
|
||||
onClick={[MockFunction this.handleToggleViewClick]}
|
||||
variant="tertiary"
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="Return to Gradebook"
|
||||
description="Button text for button navigating to Grades view."
|
||||
id="gradebook.GradebookHeader.toGradesView"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className="alert alert-warning"
|
||||
role="alert"
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="You are not authorized to view the gradebook for this course."
|
||||
description="Warning message in Gradebook Header when user is not allowed to view the app"
|
||||
id="gradebook.GradebookHeader.unauthorizedWarning"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`GradebookHeader component snapshots show bulk management, active view is grades view toggle view button to activity log 1`] = `
|
||||
<div
|
||||
className="gradebook-header"
|
||||
>
|
||||
<a
|
||||
className="mb-3"
|
||||
href="http://localhost:18000/courses/fakeID/instructor"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
>
|
||||
<<
|
||||
</span>
|
||||
<FormattedMessage
|
||||
defaultMessage="Back to Dashboard"
|
||||
description="Button text to take user back to LMS dashboard in Gradebook Header"
|
||||
id="gradebook.GradebookHeader.backButton"
|
||||
/>
|
||||
</a>
|
||||
<h1>
|
||||
<FormattedMessage
|
||||
defaultMessage="Gradebook"
|
||||
description="Top-level app title in Gradebook Header component"
|
||||
id="gradebook.GradebookHeader.appLabel"
|
||||
/>
|
||||
</h1>
|
||||
<div
|
||||
className="subtitle-row d-flex justify-content-between align-items-center"
|
||||
>
|
||||
<h3>
|
||||
fakeID
|
||||
</h3>
|
||||
<Button
|
||||
onClick={[MockFunction this.handleToggleViewClick]}
|
||||
variant="tertiary"
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="View Bulk Management History"
|
||||
description="Button text for button navigating to Bulk Managment Activity Log"
|
||||
id="gradebook.GradebookHeader.toActivityLogButton"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className="alert alert-warning"
|
||||
role="alert"
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="You are not authorized to view the gradebook for this course."
|
||||
description="Warning message in Gradebook Header when user is not allowed to view the app"
|
||||
id="gradebook.GradebookHeader.unauthorizedWarning"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
106
src/components/GradebookHeader/index.jsx
Normal file
106
src/components/GradebookHeader/index.jsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { Button } from '@edx/paragon';
|
||||
|
||||
import { configuration } from 'config';
|
||||
import { views } from 'data/constants/app';
|
||||
import actions from 'data/actions';
|
||||
import selectors from 'data/selectors';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
export class GradebookHeader extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.handleToggleViewClick = this.handleToggleViewClick.bind(this);
|
||||
}
|
||||
|
||||
get toggleViewMessage() {
|
||||
return this.props.activeView === views.grades
|
||||
? messages.toActivityLog
|
||||
: messages.toGradesView;
|
||||
}
|
||||
|
||||
lmsInstructorDashboardUrl = courseId => (
|
||||
`${configuration.LMS_BASE_URL}/courses/${courseId}/instructor`
|
||||
);
|
||||
|
||||
handleToggleViewClick() {
|
||||
const newView = this.props.activeView === views.grades ? views.bulkManagementHistory : views.grades;
|
||||
this.props.setView(newView);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="gradebook-header">
|
||||
<a
|
||||
href={this.lmsInstructorDashboardUrl(this.props.courseId)}
|
||||
className="mb-3"
|
||||
>
|
||||
<span aria-hidden="true">{'<< '}</span>
|
||||
<FormattedMessage {...messages.backToDashboard} />
|
||||
</a>
|
||||
<h1>
|
||||
<FormattedMessage {...messages.gradebook} />
|
||||
</h1>
|
||||
<div className="subtitle-row d-flex justify-content-between align-items-center">
|
||||
<h3>{this.props.courseId}</h3>
|
||||
{ this.props.showBulkManagement && (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
onClick={this.handleToggleViewClick}
|
||||
>
|
||||
<FormattedMessage {...this.toggleViewMessage} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{this.props.areGradesFrozen
|
||||
&& (
|
||||
<div className="alert alert-warning" role="alert">
|
||||
<FormattedMessage {...messages.frozenWarning} />
|
||||
</div>
|
||||
)}
|
||||
{(this.props.canUserViewGradebook === false) && (
|
||||
<div className="alert alert-warning" role="alert">
|
||||
<FormattedMessage {...messages.unauthorizedWarning} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
GradebookHeader.defaultProps = {
|
||||
// redux
|
||||
courseId: '',
|
||||
areGradesFrozen: false,
|
||||
canUserViewGradebook: false,
|
||||
showBulkManagement: false,
|
||||
};
|
||||
|
||||
GradebookHeader.propTypes = {
|
||||
// redux
|
||||
activeView: PropTypes.string.isRequired,
|
||||
courseId: PropTypes.string,
|
||||
areGradesFrozen: PropTypes.bool,
|
||||
canUserViewGradebook: PropTypes.bool,
|
||||
setView: PropTypes.func.isRequired,
|
||||
showBulkManagement: PropTypes.bool,
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => ({
|
||||
activeView: selectors.app.activeView(state),
|
||||
courseId: selectors.app.courseId(state),
|
||||
areGradesFrozen: selectors.assignmentTypes.areGradesFrozen(state),
|
||||
canUserViewGradebook: selectors.roles.canUserViewGradebook(state),
|
||||
showBulkManagement: selectors.root.showBulkManagement(state),
|
||||
});
|
||||
|
||||
export const mapDispatchToProps = {
|
||||
setView: actions.app.setView,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(GradebookHeader);
|
||||
36
src/components/GradebookHeader/messages.js
Normal file
36
src/components/GradebookHeader/messages.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
backToDashboard: {
|
||||
id: 'gradebook.GradebookHeader.backButton',
|
||||
defaultMessage: 'Back to Dashboard',
|
||||
description: 'Button text to take user back to LMS dashboard in Gradebook Header',
|
||||
},
|
||||
gradebook: {
|
||||
id: 'gradebook.GradebookHeader.appLabel',
|
||||
defaultMessage: 'Gradebook',
|
||||
description: 'Top-level app title in Gradebook Header component',
|
||||
},
|
||||
frozenWarning: {
|
||||
id: 'gradebook.GradebookHeader.frozenWarning',
|
||||
defaultMessage: 'The grades for this course are now frozen. Editing of grades is no longer allowed.',
|
||||
description: 'Warning message in Gradebook Header for frozen messages',
|
||||
},
|
||||
unauthorizedWarning: {
|
||||
id: 'gradebook.GradebookHeader.unauthorizedWarning',
|
||||
defaultMessage: 'You are not authorized to view the gradebook for this course.',
|
||||
description: 'Warning message in Gradebook Header when user is not allowed to view the app',
|
||||
},
|
||||
toActivityLog: {
|
||||
id: 'gradebook.GradebookHeader.toActivityLogButton',
|
||||
defaultMessage: 'View Bulk Management History',
|
||||
description: 'Button text for button navigating to Bulk Managment Activity Log',
|
||||
},
|
||||
toGradesView: {
|
||||
id: 'gradebook.GradebookHeader.toGradesView',
|
||||
defaultMessage: 'Return to Gradebook',
|
||||
description: 'Button text for button navigating to Grades view.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
152
src/components/GradebookHeader/test.jsx
Normal file
152
src/components/GradebookHeader/test.jsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { Button } from '@edx/paragon';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import actions from 'data/actions';
|
||||
import selectors from 'data/selectors';
|
||||
import { views } from 'data/constants/app';
|
||||
import messages from './messages';
|
||||
import { GradebookHeader, mapDispatchToProps, mapStateToProps } from '.';
|
||||
|
||||
jest.mock('@edx/paragon', () => ({
|
||||
Button: () => 'Button',
|
||||
}));
|
||||
jest.mock('@edx/frontend-platform/i18n', () => ({
|
||||
defineMessages: m => m,
|
||||
FormattedMessage: () => 'FormattedMessage',
|
||||
}));
|
||||
jest.mock('data/actions', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
app: { setView: jest.fn() },
|
||||
},
|
||||
}));
|
||||
jest.mock('data/selectors', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
app: {
|
||||
activeView: jest.fn(state => ({ aciveView: state })),
|
||||
courseId: jest.fn(state => ({ courseId: state })),
|
||||
},
|
||||
assignmentTypes: { areGradesFrozen: jest.fn(state => ({ areGradesFrozen: state })) },
|
||||
roles: { canUserViewGradebook: jest.fn(state => ({ canUserViewGradebook: state })) },
|
||||
root: { showBulkManagement: jest.fn(state => ({ showBulkManagement: state })) },
|
||||
},
|
||||
}));
|
||||
|
||||
const courseId = 'fakeID';
|
||||
describe('GradebookHeader component', () => {
|
||||
const props = {
|
||||
activeView: views.grades,
|
||||
areGradesFrozen: false,
|
||||
canUserViewGradebook: false,
|
||||
courseId,
|
||||
showBulkManagement: false,
|
||||
};
|
||||
beforeEach(() => {
|
||||
props.setView = jest.fn();
|
||||
});
|
||||
describe('snapshots', () => {
|
||||
let el;
|
||||
beforeEach(() => {
|
||||
el = shallow(<GradebookHeader {...props} />);
|
||||
el.instance().handleToggleViewClick = jest.fn().mockName('this.handleToggleViewClick');
|
||||
});
|
||||
describe('default values (grades frozen, cannot view).', () => {
|
||||
test('unauthorized warning, but no grades frozen warning', () => {
|
||||
expect(el.instance().render()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
describe('grades frozen, cannot view', () => {
|
||||
test('unauthorized warning, and grades frozen warning.', () => {
|
||||
el.setProps({ areGradesFrozen: true });
|
||||
expect(el.instance().render()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
describe('grades frozen, can view.', () => {
|
||||
test('grades frozen warning but no unauthorized warning', () => {
|
||||
el.setProps({ areGradesFrozen: true, canUserViewGradebook: true });
|
||||
expect(el.instance().render()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
describe('show bulk management, active view is grades view', () => {
|
||||
test('toggle view button to activity log', () => {
|
||||
el.setProps({ showBulkManagement: true });
|
||||
expect(el.find(Button).getElement()).toEqual((
|
||||
<Button
|
||||
variant="tertiary"
|
||||
onClick={el.instance().handleToggleViewClick}
|
||||
>
|
||||
<FormattedMessage {...messages.toActivityLog} />
|
||||
</Button>
|
||||
));
|
||||
expect(el.instance().render()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
describe('show bulk management, active view is bulkManagementHistory view', () => {
|
||||
test('toggle view button to grades', () => {
|
||||
el.setProps({ showBulkManagement: true, activeView: views.bulkManagementHistory });
|
||||
expect(el.find(Button).getElement()).toEqual((
|
||||
<Button
|
||||
variant="tertiary"
|
||||
onClick={el.instance().handleToggleViewClick}
|
||||
>
|
||||
<FormattedMessage {...messages.toGradesView} />
|
||||
</Button>
|
||||
));
|
||||
expect(el.instance().render()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('behavior', () => {
|
||||
let el;
|
||||
beforeEach(() => {
|
||||
el = shallow(<GradebookHeader {...props} />);
|
||||
});
|
||||
describe('handleToggleViewClick', () => {
|
||||
test('calls setView with activity view if activeView is grades', () => {
|
||||
el.instance().handleToggleViewClick();
|
||||
expect(props.setView).toHaveBeenCalledWith(views.bulkManagementHistory);
|
||||
});
|
||||
test('calls setView with grades view if activeView is bulkManagementHistory', () => {
|
||||
el.setProps({ activeView: views.bulkManagementHistory });
|
||||
el.instance().handleToggleViewClick();
|
||||
expect(props.setView).toHaveBeenCalledWith(views.grades);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('mapStateToProps', () => {
|
||||
let mapped;
|
||||
const testState = { a: 'test', example: 'state' };
|
||||
beforeEach(() => {
|
||||
mapped = mapStateToProps(testState);
|
||||
});
|
||||
test('activeView from app.activeView', () => {
|
||||
expect(mapped.activeView).toEqual(selectors.app.activeView(testState));
|
||||
});
|
||||
test('courseId from app.courseId', () => {
|
||||
expect(mapped.courseId).toEqual(selectors.app.courseId(testState));
|
||||
});
|
||||
test('areGradesFrozen from assignmentTypes selector', () => {
|
||||
expect(
|
||||
mapped.areGradesFrozen,
|
||||
).toEqual(selectors.assignmentTypes.areGradesFrozen(testState));
|
||||
});
|
||||
test('canUserViewGradebook from roles selector', () => {
|
||||
expect(
|
||||
mapped.canUserViewGradebook,
|
||||
).toEqual(selectors.roles.canUserViewGradebook(testState));
|
||||
});
|
||||
test('showBulkManagement from root showBulkManagement selector', () => {
|
||||
expect(mapped.showBulkManagement).toEqual(selectors.root.showBulkManagement(testState));
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapDispatchToProps', () => {
|
||||
test('setView from actions.app.setView', () => {
|
||||
expect(mapDispatchToProps.setView).toEqual(actions.app.setView);
|
||||
});
|
||||
});
|
||||
});
|
||||
71
src/components/GradesView/BulkManagementControls.jsx
Normal file
71
src/components/GradesView/BulkManagementControls.jsx
Normal file
@@ -0,0 +1,71 @@
|
||||
/* eslint-disable react/sort-comp, react/button-has-type */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { views } from 'data/constants/app';
|
||||
import actions from 'data/actions';
|
||||
import selectors from 'data/selectors';
|
||||
|
||||
import NetworkButton from 'components/NetworkButton';
|
||||
import ImportGradesButton from './ImportGradesButton';
|
||||
|
||||
import messages from './BulkManagementControls.messages';
|
||||
|
||||
/**
|
||||
* <BulkManagementControls />
|
||||
* Provides download buttons for Bulk Management and Intervention reports, only if
|
||||
* showBulkManagement is set in redus.
|
||||
*/
|
||||
export class BulkManagementControls extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.handleClickExportGrades = this.handleClickExportGrades.bind(this);
|
||||
this.handleViewActivityLog = this.handleViewActivityLog.bind(this);
|
||||
}
|
||||
|
||||
handleClickExportGrades() {
|
||||
this.props.downloadBulkGradesReport();
|
||||
window.location.assign(this.props.gradeExportUrl);
|
||||
}
|
||||
|
||||
handleViewActivityLog() {
|
||||
this.props.setView(views.bulkManagementHistory);
|
||||
}
|
||||
|
||||
render() {
|
||||
return this.props.showBulkManagement && (
|
||||
<div className="d-flex">
|
||||
<NetworkButton
|
||||
label={messages.downloadGradesBtn}
|
||||
onClick={this.handleClickExportGrades}
|
||||
/>
|
||||
<ImportGradesButton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
BulkManagementControls.defaultProps = {
|
||||
showBulkManagement: false,
|
||||
};
|
||||
|
||||
BulkManagementControls.propTypes = {
|
||||
// redux
|
||||
downloadBulkGradesReport: PropTypes.func.isRequired,
|
||||
gradeExportUrl: PropTypes.string.isRequired,
|
||||
showBulkManagement: PropTypes.bool,
|
||||
setView: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => ({
|
||||
gradeExportUrl: selectors.root.gradeExportUrl(state),
|
||||
showBulkManagement: selectors.root.showBulkManagement(state),
|
||||
});
|
||||
|
||||
export const mapDispatchToProps = {
|
||||
downloadBulkGradesReport: actions.grades.downloadReport.bulkGrades,
|
||||
setView: actions.app.setView,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(BulkManagementControls);
|
||||
11
src/components/GradesView/BulkManagementControls.messages.js
Normal file
11
src/components/GradesView/BulkManagementControls.messages.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
downloadGradesBtn: {
|
||||
id: 'gradebook.GradesView.BulkManagementControls.bulkManagementLabel',
|
||||
defaultMessage: 'Download Grades',
|
||||
description: 'Button text for bulk grades download control in GradesView',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
124
src/components/GradesView/BulkManagementControls.test.jsx
Normal file
124
src/components/GradesView/BulkManagementControls.test.jsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import actions from 'data/actions';
|
||||
import selectors from 'data/selectors';
|
||||
import { views } from 'data/constants/app';
|
||||
|
||||
import {
|
||||
BulkManagementControls,
|
||||
mapStateToProps,
|
||||
mapDispatchToProps,
|
||||
} from './BulkManagementControls';
|
||||
|
||||
jest.mock('./ImportGradesButton', () => 'ImportGradesButton');
|
||||
jest.mock('components/NetworkButton', () => 'NetworkButton');
|
||||
jest.mock('data/selectors', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
root: {
|
||||
gradeExportUrl: (state) => ({ gradeExportUrl: state }),
|
||||
interventionExportUrl: (state) => ({ interventionExportUrl: state }),
|
||||
showBulkManagement: (state) => ({ showBulkManagement: state }),
|
||||
},
|
||||
},
|
||||
}));
|
||||
jest.mock('data/actions', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
app: { setView: jest.fn() },
|
||||
grades: {
|
||||
downloadReport: {
|
||||
bulkGrades: jest.fn(),
|
||||
intervention: jest.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe('BulkManagementControls', () => {
|
||||
describe('component', () => {
|
||||
let el;
|
||||
let props = {
|
||||
gradeExportUrl: 'gradesGoHere',
|
||||
interventionExportUrl: 'interventionsGoHere',
|
||||
};
|
||||
beforeEach(() => {
|
||||
props = {
|
||||
...props,
|
||||
downloadBulkGradesReport: jest.fn(),
|
||||
downloadInterventionReport: jest.fn(),
|
||||
setView: jest.fn(),
|
||||
};
|
||||
});
|
||||
test('snapshot - empty if showBulkManagement is not truthy', () => {
|
||||
expect(shallow(<BulkManagementControls {...props} />)).toEqual({});
|
||||
});
|
||||
describe('behavior', () => {
|
||||
const oldWindowLocation = window.location;
|
||||
|
||||
beforeAll(() => {
|
||||
delete window.location;
|
||||
window.location = Object.defineProperties(
|
||||
{},
|
||||
{
|
||||
...Object.getOwnPropertyDescriptors(oldWindowLocation),
|
||||
assign: {
|
||||
configurable: true,
|
||||
value: jest.fn(),
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
beforeEach(() => {
|
||||
window.location.assign.mockReset();
|
||||
el = shallow(<BulkManagementControls {...props} showBulkManagement />);
|
||||
});
|
||||
afterAll(() => {
|
||||
// restore `window.location` to the `jsdom` `Location` object
|
||||
window.location = oldWindowLocation;
|
||||
});
|
||||
describe('handleViewActivityLog', () => {
|
||||
it('calls props.setView(views.bulkManagementHistory)', () => {
|
||||
el.instance().handleViewActivityLog();
|
||||
expect(props.setView).toHaveBeenCalledWith(views.bulkManagementHistory);
|
||||
});
|
||||
});
|
||||
describe('handleClickExportGrades', () => {
|
||||
const assertions = [
|
||||
'calls props.downloadBulkGradesReport',
|
||||
'sets location to props.gradeExportUrl',
|
||||
];
|
||||
it(assertions.join(' and '), () => {
|
||||
el.instance().handleClickExportGrades();
|
||||
expect(props.downloadBulkGradesReport).toHaveBeenCalled();
|
||||
expect(window.location.assign).toHaveBeenCalledWith(props.gradeExportUrl);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapStateToProps', () => {
|
||||
let mapped;
|
||||
const testState = { do: 'not', test: 'me' };
|
||||
beforeEach(() => {
|
||||
mapped = mapStateToProps(testState);
|
||||
});
|
||||
test('gradeExportUrl from root.gradeExportUrl', () => {
|
||||
expect(mapped.gradeExportUrl).toEqual(selectors.root.gradeExportUrl(testState));
|
||||
});
|
||||
test('showBulkManagement from root.showBulkManagement', () => {
|
||||
expect(mapped.showBulkManagement).toEqual(selectors.root.showBulkManagement(testState));
|
||||
});
|
||||
});
|
||||
describe('mapDispatchToProps', () => {
|
||||
test('downloadBulkGradesReport from actions.grades.downloadReport.bulkGrades', () => {
|
||||
expect(
|
||||
mapDispatchToProps.downloadBulkGradesReport,
|
||||
).toEqual(actions.grades.downloadReport.bulkGrades);
|
||||
});
|
||||
test('setView from actions.app.setView', () => {
|
||||
expect(mapDispatchToProps.setView).toEqual(actions.app.setView);
|
||||
});
|
||||
});
|
||||
});
|
||||
26
src/components/GradesView/EditModal/HistoryHeader.jsx
Normal file
26
src/components/GradesView/EditModal/HistoryHeader.jsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
/**
|
||||
* HistoryHeader
|
||||
* simple display container for an individual history table header
|
||||
* @param {string} id - header id
|
||||
* @param {string} label - header label
|
||||
* @param {string} value - header value
|
||||
*/
|
||||
const HistoryHeader = ({ id, label, value }) => (
|
||||
<div>
|
||||
<div className={`grade-history-header grade-history-${id}`}>{label}: </div>
|
||||
<div>{value}</div>
|
||||
</div>
|
||||
);
|
||||
HistoryHeader.defaultProps = {
|
||||
value: null,
|
||||
};
|
||||
HistoryHeader.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
label: PropTypes.node.isRequired,
|
||||
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
};
|
||||
|
||||
export default HistoryHeader;
|
||||
17
src/components/GradesView/EditModal/HistoryHeader.test.jsx
Normal file
17
src/components/GradesView/EditModal/HistoryHeader.test.jsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import HistoryHeader from './HistoryHeader';
|
||||
|
||||
describe('HistoryHeader', () => {
|
||||
const props = {
|
||||
id: 'water',
|
||||
label: 'Brita',
|
||||
value: 'hydration',
|
||||
};
|
||||
describe('Component', () => {
|
||||
test('snapshot', () => {
|
||||
expect(shallow(<HistoryHeader {...props} />)).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
68
src/components/GradesView/EditModal/ModalHeaders.jsx
Normal file
68
src/components/GradesView/EditModal/ModalHeaders.jsx
Normal file
@@ -0,0 +1,68 @@
|
||||
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 messages from './messages';
|
||||
import HistoryHeader from './HistoryHeader';
|
||||
|
||||
/**
|
||||
* <ModalHeaders />
|
||||
* Provides a list of HistoryHeaders for the student name, assignment,
|
||||
* original grade, and current override grade.
|
||||
*/
|
||||
export const ModalHeaders = ({
|
||||
modalState,
|
||||
originalGrade,
|
||||
currentGrade,
|
||||
}) => (
|
||||
<div>
|
||||
<HistoryHeader
|
||||
id="assignment"
|
||||
label={<FormattedMessage {...messages.assignmentHeader} />}
|
||||
value={modalState.assignmentName}
|
||||
/>
|
||||
<HistoryHeader
|
||||
id="student"
|
||||
label={<FormattedMessage {...messages.studentHeader} />}
|
||||
value={modalState.updateUserName}
|
||||
/>
|
||||
<HistoryHeader
|
||||
id="original-grade"
|
||||
label={<FormattedMessage {...messages.originalGradeHeader} />}
|
||||
value={originalGrade}
|
||||
/>
|
||||
<HistoryHeader
|
||||
id="current-grade"
|
||||
label={<FormattedMessage {...messages.currentGradeHeader} />}
|
||||
value={currentGrade}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
ModalHeaders.defaultProps = {
|
||||
currentGrade: null,
|
||||
originalGrade: null,
|
||||
};
|
||||
ModalHeaders.propTypes = {
|
||||
// redux
|
||||
currentGrade: PropTypes.number,
|
||||
originalGrade: PropTypes.number,
|
||||
modalState: PropTypes.shape({
|
||||
assignmentName: PropTypes.string.isRequired,
|
||||
updateUserName: PropTypes.string,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => ({
|
||||
modalState: {
|
||||
assignmentName: selectors.app.modalState.assignmentName(state),
|
||||
updateUserName: selectors.app.modalState.updateUserName(state),
|
||||
},
|
||||
currentGrade: selectors.grades.gradeOverrideCurrentEarnedGradedOverride(state),
|
||||
originalGrade: selectors.grades.gradeOriginalEarnedGraded(state),
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps)(ModalHeaders);
|
||||
94
src/components/GradesView/EditModal/ModalHeaders.test.jsx
Normal file
94
src/components/GradesView/EditModal/ModalHeaders.test.jsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import selectors from 'data/selectors';
|
||||
|
||||
import {
|
||||
ModalHeaders,
|
||||
mapStateToProps,
|
||||
} from './ModalHeaders';
|
||||
|
||||
jest.mock('./HistoryHeader', () => 'HistoryHeader');
|
||||
|
||||
jest.mock('data/selectors', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
app: {
|
||||
editUpdateData: jest.fn(state => ({ editUpdateData: state })),
|
||||
modalState: {
|
||||
assignmentName: jest.fn(state => ({ assignmentName: state })),
|
||||
updateUserName: jest.fn(state => ({ updateUserName: state })),
|
||||
},
|
||||
},
|
||||
grades: {
|
||||
gradeOverrideCurrentEarnedGradedOverride: jest.fn(state => ({ currentGrade: state })),
|
||||
gradeOriginalEarnedGraded: jest.fn(state => ({ originalGrade: state })),
|
||||
},
|
||||
},
|
||||
}));
|
||||
describe('ModalHeaders', () => {
|
||||
let el;
|
||||
const props = {
|
||||
currentGrade: 2,
|
||||
originalGrade: 20,
|
||||
modalState: {
|
||||
assignmentName: 'Qwerty',
|
||||
updateUserName: 'Uiop',
|
||||
},
|
||||
};
|
||||
|
||||
describe('Component', () => {
|
||||
describe('snapshots', () => {
|
||||
beforeEach(() => {
|
||||
});
|
||||
describe('gradeOverrideHistoryError is and empty and open is true', () => {
|
||||
test('modal open and StatusAlert showing', () => {
|
||||
el = shallow(<ModalHeaders {...props} />);
|
||||
expect(el).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
describe('gradeOverrideHistoryError is empty and open is false', () => {
|
||||
test('modal closed and StatusAlert closed', () => {
|
||||
el = shallow(
|
||||
<ModalHeaders {...props} open={false} gradeOverrideHistoryError="" />,
|
||||
);
|
||||
expect(el).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapStateToProps', () => {
|
||||
const testState = { he: 'lives in a', pineapple: 'under the sea' };
|
||||
let mapped;
|
||||
beforeEach(() => {
|
||||
mapped = mapStateToProps(testState);
|
||||
});
|
||||
describe('modalState', () => {
|
||||
test('assignmentName from app.modalState.assignmentName', () => {
|
||||
expect(
|
||||
mapped.modalState.assignmentName,
|
||||
).toEqual(selectors.app.modalState.assignmentName(testState));
|
||||
});
|
||||
test('updateUserName from app.modalState.updateUserName', () => {
|
||||
expect(
|
||||
mapped.modalState.updateUserName,
|
||||
).toEqual(selectors.app.modalState.updateUserName(testState));
|
||||
});
|
||||
});
|
||||
describe('originalGrade', () => {
|
||||
test('from grades.gradeOverrideCurrentEarnedGradedOverride', () => {
|
||||
expect(mapped.currentGrade).toEqual(
|
||||
selectors.grades.gradeOverrideCurrentEarnedGradedOverride(testState),
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('originalGrade', () => {
|
||||
test('from grades.gradeOriginalEarnedGrades', () => {
|
||||
expect(mapped.originalGrade).toEqual(
|
||||
selectors.grades.gradeOriginalEarnedGraded(testState),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
/* eslint-disable react/sort-comp, react/button-has-type */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { Form } from '@edx/paragon';
|
||||
|
||||
import selectors from 'data/selectors';
|
||||
import actions from 'data/actions';
|
||||
|
||||
/**
|
||||
* <AdjustedGradeInput />
|
||||
* Input control for adjusting the grade of a unit
|
||||
* displays an "/ ${possibleGrade} if there is one in the data model.
|
||||
*/
|
||||
export class AdjustedGradeInput extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.onChange = this.onChange.bind(this);
|
||||
}
|
||||
|
||||
onChange = ({ target }) => {
|
||||
let adjustedGradeValue;
|
||||
switch (true) {
|
||||
case target.value < 0:
|
||||
adjustedGradeValue = 0;
|
||||
break;
|
||||
case this.props.possibleGrade && target.value > this.props.possibleGrade:
|
||||
adjustedGradeValue = this.props.possibleGrade;
|
||||
break;
|
||||
default:
|
||||
adjustedGradeValue = target.value;
|
||||
}
|
||||
this.props.setModalState({ adjustedGradeValue });
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<span>
|
||||
<Form.Control
|
||||
type="number"
|
||||
name="adjustedGradeValue"
|
||||
min="0"
|
||||
max={this.props.possibleGrade ? this.props.possibleGrade : ''}
|
||||
value={this.props.value}
|
||||
onChange={this.onChange}
|
||||
/>
|
||||
{this.props.possibleGrade && ` / ${this.props.possibleGrade}`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
AdjustedGradeInput.defaultProps = {
|
||||
possibleGrade: null,
|
||||
};
|
||||
AdjustedGradeInput.propTypes = {
|
||||
value: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.number,
|
||||
]).isRequired,
|
||||
possibleGrade: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.number,
|
||||
]),
|
||||
setModalState: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => ({
|
||||
possibleGrade: selectors.root.editModalPossibleGrade(state),
|
||||
value: selectors.app.modalState.adjustedGradeValue(state),
|
||||
});
|
||||
|
||||
export const mapDispatchToProps = {
|
||||
setModalState: actions.app.setModalState,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(AdjustedGradeInput);
|
||||
@@ -0,0 +1,117 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import actions from 'data/actions';
|
||||
import selectors from 'data/selectors';
|
||||
|
||||
import {
|
||||
AdjustedGradeInput,
|
||||
mapStateToProps,
|
||||
mapDispatchToProps,
|
||||
} from './AdjustedGradeInput';
|
||||
|
||||
jest.mock('@edx/paragon', () => ({
|
||||
Form: { Control: () => 'Form.Control' },
|
||||
}));
|
||||
jest.mock('data/selectors', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
root: {
|
||||
editModalPossibleGrade: jest.fn(state => ({ updateUserName: state })),
|
||||
},
|
||||
app: {
|
||||
modalState: { adjustedGradeValue: jest.fn(state => ({ adjustedGradeValue: state })) },
|
||||
},
|
||||
},
|
||||
}));
|
||||
jest.mock('data/actions', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
app: { setModalState: jest.fn() },
|
||||
},
|
||||
}));
|
||||
describe('AdjustedGradeInput', () => {
|
||||
let el;
|
||||
let props = {
|
||||
value: 1,
|
||||
possibleGrade: 5,
|
||||
};
|
||||
beforeEach(() => {
|
||||
props = {
|
||||
...props,
|
||||
setModalState: jest.fn(),
|
||||
};
|
||||
});
|
||||
describe('Component', () => {
|
||||
beforeEach(() => {
|
||||
el = shallow(<AdjustedGradeInput {...props} />);
|
||||
});
|
||||
describe('snapshots', () => {
|
||||
test('displays input control and "out of possible grade" label', () => {
|
||||
el.instance().onChange = jest.fn().mockName('this.onChange');
|
||||
expect(el.instance().render()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
describe('behavior', () => {
|
||||
describe('onChange', () => {
|
||||
it('calls props.setModalState event target value with correct value', () => {
|
||||
const value = 3;
|
||||
el.instance().onChange({ target: { value } });
|
||||
expect(props.setModalState).toHaveBeenCalledWith({
|
||||
adjustedGradeValue: value,
|
||||
});
|
||||
});
|
||||
|
||||
it('calls props.setModalState event target value with a value more then the possibleGrade value', () => {
|
||||
const value = 42;
|
||||
el.instance().onChange({ target: { value } });
|
||||
expect(props.setModalState).toHaveBeenCalledWith({
|
||||
adjustedGradeValue: props.possibleGrade,
|
||||
});
|
||||
});
|
||||
|
||||
it('calls props.setModalState event target value with less then 0', () => {
|
||||
const value = -5;
|
||||
el.instance().onChange({ target: { value } });
|
||||
expect(props.setModalState).toHaveBeenCalledWith({
|
||||
adjustedGradeValue: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('calls props.setModalState event target value without possibleGrade value', () => {
|
||||
const value = 100;
|
||||
const newEl = shallow(<AdjustedGradeInput {...props} possibleGrade={null} />);
|
||||
newEl.instance().onChange({ target: { value } });
|
||||
expect(props.setModalState).toHaveBeenCalledWith({
|
||||
adjustedGradeValue: value,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapStateToProps', () => {
|
||||
const testState = { like: 'no one', ever: 'was' };
|
||||
let mapped;
|
||||
beforeEach(() => {
|
||||
mapped = mapStateToProps(testState);
|
||||
});
|
||||
describe('modalState', () => {
|
||||
test('possibleGrade from root.editModalPossibleGrade', () => {
|
||||
expect(
|
||||
mapped.possibleGrade,
|
||||
).toEqual(selectors.root.editModalPossibleGrade(testState));
|
||||
});
|
||||
test('updateUserName from app.modalState.updateUserName', () => {
|
||||
expect(
|
||||
mapped.value,
|
||||
).toEqual(selectors.app.modalState.adjustedGradeValue(testState));
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('mapDispatchToProps', () => {
|
||||
test('setModalState from actions.app.setModalState', () => {
|
||||
expect(mapDispatchToProps.setModalState).toEqual(actions.app.setModalState);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { Form } from '@edx/paragon';
|
||||
|
||||
import selectors from 'data/selectors';
|
||||
import actions from 'data/actions';
|
||||
|
||||
/**
|
||||
* <ReasonInput />
|
||||
* Input control for the "reason for change" field in the Edit modal.
|
||||
*/
|
||||
export class ReasonInput extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.ref = React.createRef();
|
||||
this.onChange = this.onChange.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.ref.current.focus();
|
||||
}
|
||||
|
||||
onChange = (event) => {
|
||||
this.props.setModalState({ reasonForChange: event.target.value });
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Form.Control
|
||||
type="text"
|
||||
name="reasonForChange"
|
||||
value={this.props.value}
|
||||
onChange={this.onChange}
|
||||
ref={this.ref}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
ReasonInput.propTypes = {
|
||||
// redux
|
||||
setModalState: PropTypes.func.isRequired,
|
||||
value: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => ({
|
||||
value: selectors.app.modalState.reasonForChange(state),
|
||||
});
|
||||
|
||||
export const mapDispatchToProps = {
|
||||
setModalState: actions.app.setModalState,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ReasonInput);
|
||||
@@ -0,0 +1,90 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import actions from 'data/actions';
|
||||
import selectors from 'data/selectors';
|
||||
|
||||
import {
|
||||
ReasonInput,
|
||||
mapStateToProps,
|
||||
mapDispatchToProps,
|
||||
} from './ReasonInput';
|
||||
|
||||
jest.mock('@edx/paragon', () => ({
|
||||
Form: { Control: () => 'Form.Control' },
|
||||
}));
|
||||
jest.mock('data/selectors', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
app: {
|
||||
modalState: { reasonForChange: jest.fn(state => ({ reasonForChange: state })) },
|
||||
},
|
||||
},
|
||||
}));
|
||||
jest.mock('data/actions', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
app: { setModalState: jest.fn() },
|
||||
},
|
||||
}));
|
||||
describe('ReasonInput', () => {
|
||||
let el;
|
||||
let props = {
|
||||
value: 'did not answer the question',
|
||||
};
|
||||
beforeEach(() => {
|
||||
props = {
|
||||
...props,
|
||||
setModalState: jest.fn(),
|
||||
};
|
||||
});
|
||||
describe('Component', () => {
|
||||
beforeEach(() => {
|
||||
el = shallow(<ReasonInput {...props} />, { disableLifecycleMethods: true });
|
||||
});
|
||||
describe('snapshots', () => {
|
||||
test('displays reason for change input control', () => {
|
||||
el.instance().onChange = jest.fn().mockName('this.onChange');
|
||||
expect(el.instance().render()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
describe('behavior', () => {
|
||||
describe('onChange', () => {
|
||||
it('calls props.setModalState event target value', () => {
|
||||
const value = 42;
|
||||
el.instance().onChange({ target: { value } });
|
||||
expect(props.setModalState).toHaveBeenCalledWith({
|
||||
reasonForChange: value,
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('componentDidMount', () => {
|
||||
it('focuses the input ref', () => {
|
||||
const focus = jest.fn();
|
||||
expect(el.instance().ref).toEqual({ current: null });
|
||||
el.instance().ref.current = { focus };
|
||||
el.instance().componentDidMount();
|
||||
expect(el.instance().ref.current.focus).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapStateToProps', () => {
|
||||
const testState = { to: { catchThem: 'my real test', trainThem: 'my cause!' } };
|
||||
let mapped;
|
||||
beforeEach(() => {
|
||||
mapped = mapStateToProps(testState);
|
||||
});
|
||||
describe('modalState', () => {
|
||||
test('value from app.modalState.reasonForChange', () => {
|
||||
expect(mapped.value).toEqual(selectors.app.modalState.reasonForChange(testState));
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('mapDispatchToProps', () => {
|
||||
test('setModalState from actions.app.setModalState', () => {
|
||||
expect(mapDispatchToProps.setModalState).toEqual(actions.app.setModalState);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`AdjustedGradeInput Component snapshots displays input control and "out of possible grade" label 1`] = `
|
||||
<span>
|
||||
<Control
|
||||
max={5}
|
||||
min="0"
|
||||
name="adjustedGradeValue"
|
||||
onChange={[MockFunction this.onChange]}
|
||||
type="number"
|
||||
value={1}
|
||||
/>
|
||||
/ 5
|
||||
</span>
|
||||
`;
|
||||
@@ -0,0 +1,10 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`ReasonInput Component snapshots displays reason for change input control 1`] = `
|
||||
<Control
|
||||
name="reasonForChange"
|
||||
onChange={[MockFunction this.onChange]}
|
||||
type="text"
|
||||
value="did not answer the question"
|
||||
/>
|
||||
`;
|
||||
@@ -0,0 +1,63 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`OverrideTable Component snapshots basic snapshot shows a row for each entry and one editable row 1`] = `
|
||||
<Table
|
||||
columns={
|
||||
Array [
|
||||
Object {
|
||||
"key": "date",
|
||||
"label": <FormattedMessage
|
||||
defaultMessage="Date"
|
||||
description="Edit Modal Override Table Date column header"
|
||||
id="gradebook.GradesView.EditModal.Overrides.dateHeader"
|
||||
/>,
|
||||
},
|
||||
Object {
|
||||
"key": "grader",
|
||||
"label": <FormattedMessage
|
||||
defaultMessage="Grader"
|
||||
description="Edit Modal Override Table Grader column header"
|
||||
id="gradebook.GradesView.EditModal.Overrides.graderHeader"
|
||||
/>,
|
||||
},
|
||||
Object {
|
||||
"key": "reason",
|
||||
"label": <FormattedMessage
|
||||
defaultMessage="Reason"
|
||||
description="Edit Modal Override Table Reason column header"
|
||||
id="gradebook.GradesView.EditModal.Overrides.reasonHeader"
|
||||
/>,
|
||||
},
|
||||
Object {
|
||||
"key": "adjustedGrade",
|
||||
"label": <FormattedMessage
|
||||
defaultMessage="Adjusted grade"
|
||||
description="Edit Modal Override Table Adjusted grade column header"
|
||||
id="gradebook.GradesView.EditModal.Overrides.adjustedGradeHeader"
|
||||
/>,
|
||||
},
|
||||
]
|
||||
}
|
||||
data={
|
||||
Array [
|
||||
Object {
|
||||
"adjustedGrade": 0,
|
||||
"date": "yesterday",
|
||||
"grader": "me",
|
||||
"reason": "you ate my sandwich",
|
||||
},
|
||||
Object {
|
||||
"adjustedGrade": 20,
|
||||
"date": "today",
|
||||
"grader": "me",
|
||||
"reason": "you brought me a new sandwich",
|
||||
},
|
||||
Object {
|
||||
"adjustedGrade": <AdjustedGradeInput />,
|
||||
"date": "todaaaaaay",
|
||||
"reason": <ReasonInput />,
|
||||
},
|
||||
]
|
||||
}
|
||||
/>
|
||||
`;
|
||||
72
src/components/GradesView/EditModal/OverrideTable/index.jsx
Normal file
72
src/components/GradesView/EditModal/OverrideTable/index.jsx
Normal file
@@ -0,0 +1,72 @@
|
||||
/* eslint-disable react/sort-comp, react/button-has-type, import/no-named-as-default */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { Table } from '@edx/paragon';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { gradeOverrideHistoryColumns as columns } from 'data/constants/app';
|
||||
import selectors from 'data/selectors';
|
||||
|
||||
import messages from './messages';
|
||||
import ReasonInput from './ReasonInput';
|
||||
import AdjustedGradeInput from './AdjustedGradeInput';
|
||||
|
||||
/**
|
||||
* <OverrideTable />
|
||||
* Table containing previous grade override entries, and an "edit" row
|
||||
* with todays date, an AdjustedGradeInput and a ReasonInput
|
||||
*/
|
||||
export const OverrideTable = ({
|
||||
hide,
|
||||
gradeOverrides,
|
||||
todaysDate,
|
||||
}) => {
|
||||
if (hide) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Table
|
||||
columns={[
|
||||
{ label: <FormattedMessage {...messages.dateHeader} />, key: columns.date },
|
||||
{ label: <FormattedMessage {...messages.graderHeader} />, key: columns.grader },
|
||||
{ label: <FormattedMessage {...messages.reasonHeader} />, key: columns.reason },
|
||||
{
|
||||
label: <FormattedMessage {...messages.adjustedGradeHeader} />,
|
||||
key: columns.adjustedGrade,
|
||||
},
|
||||
]}
|
||||
data={[
|
||||
...gradeOverrides,
|
||||
{
|
||||
adjustedGrade: <AdjustedGradeInput />,
|
||||
date: todaysDate,
|
||||
reason: <ReasonInput />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
OverrideTable.defaultProps = {
|
||||
gradeOverrides: [],
|
||||
};
|
||||
OverrideTable.propTypes = {
|
||||
// redux
|
||||
gradeOverrides: PropTypes.arrayOf(PropTypes.shape({
|
||||
date: PropTypes.string,
|
||||
grader: PropTypes.string,
|
||||
reason: PropTypes.string,
|
||||
adjustedGrade: PropTypes.number,
|
||||
})),
|
||||
hide: PropTypes.bool.isRequired,
|
||||
todaysDate: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => ({
|
||||
hide: selectors.grades.hasOverrideErrors(state),
|
||||
gradeOverrides: selectors.grades.gradeOverrides(state),
|
||||
todaysDate: selectors.app.modalState.todaysDate(state),
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps)(OverrideTable);
|
||||
@@ -0,0 +1,26 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
adjustedGradeHeader: {
|
||||
id: 'gradebook.GradesView.EditModal.Overrides.adjustedGradeHeader',
|
||||
defaultMessage: 'Adjusted grade',
|
||||
description: 'Edit Modal Override Table Adjusted grade column header',
|
||||
},
|
||||
dateHeader: {
|
||||
id: 'gradebook.GradesView.EditModal.Overrides.dateHeader',
|
||||
defaultMessage: 'Date',
|
||||
description: 'Edit Modal Override Table Date column header',
|
||||
},
|
||||
graderHeader: {
|
||||
id: 'gradebook.GradesView.EditModal.Overrides.graderHeader',
|
||||
defaultMessage: 'Grader',
|
||||
description: 'Edit Modal Override Table Grader column header',
|
||||
},
|
||||
reasonHeader: {
|
||||
id: 'gradebook.GradesView.EditModal.Overrides.reasonHeader',
|
||||
defaultMessage: 'Reason',
|
||||
description: 'Edit Modal Override Table Reason column header',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
81
src/components/GradesView/EditModal/OverrideTable/test.jsx
Normal file
81
src/components/GradesView/EditModal/OverrideTable/test.jsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import selectors from 'data/selectors';
|
||||
|
||||
import {
|
||||
OverrideTable,
|
||||
mapStateToProps,
|
||||
} from '.';
|
||||
|
||||
jest.mock('@edx/paragon', () => ({ Table: () => 'Table' }));
|
||||
jest.mock('./ReasonInput', () => 'ReasonInput');
|
||||
jest.mock('./AdjustedGradeInput', () => 'AdjustedGradeInput');
|
||||
|
||||
jest.mock('data/selectors', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
app: {
|
||||
modalState: {
|
||||
todaysDate: jest.fn(state => ({ todaysDate: state })),
|
||||
},
|
||||
},
|
||||
grades: {
|
||||
hasOverrideErrors: jest.fn(state => ({ hasOverrideErrors: state })),
|
||||
gradeOverrides: jest.fn(state => ({ gradeOverrides: state })),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe('OverrideTable', () => {
|
||||
const props = {
|
||||
gradeOverrides: [
|
||||
{
|
||||
date: 'yesterday',
|
||||
grader: 'me',
|
||||
reason: 'you ate my sandwich',
|
||||
adjustedGrade: 0,
|
||||
},
|
||||
{
|
||||
date: 'today',
|
||||
grader: 'me',
|
||||
reason: 'you brought me a new sandwich',
|
||||
adjustedGrade: 20,
|
||||
},
|
||||
],
|
||||
hide: false,
|
||||
todaysDate: 'todaaaaaay',
|
||||
};
|
||||
|
||||
describe('Component', () => {
|
||||
describe('snapshots', () => {
|
||||
it('returns null if hide is true', () => {
|
||||
expect(shallow(<OverrideTable {...props} hide />)).toEqual({});
|
||||
});
|
||||
describe('basic snapshot', () => {
|
||||
test('shows a row for each entry and one editable row', () => {
|
||||
expect(shallow(<OverrideTable {...props} />)).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapStateToProps', () => {
|
||||
const testState = { I: 'wanna', be: 'the', very: 'best' };
|
||||
let mapped;
|
||||
beforeEach(() => {
|
||||
mapped = mapStateToProps(testState);
|
||||
});
|
||||
describe('modalState', () => {
|
||||
test('hide from grades.hasOverrideErrors', () => {
|
||||
expect(mapped.hide).toEqual(selectors.grades.hasOverrideErrors(testState));
|
||||
});
|
||||
test('gradeOverrides from grades.gradeOverrides', () => {
|
||||
expect(mapped.gradeOverrides).toEqual(selectors.grades.gradeOverrides(testState));
|
||||
});
|
||||
test('todaysData from app.modalState.todaysDate', () => {
|
||||
expect(mapped.todaysDate).toEqual(selectors.app.modalState.todaysDate(testState));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`HistoryHeader Component snapshot 1`] = `
|
||||
<div>
|
||||
<div
|
||||
className="grade-history-header grade-history-water"
|
||||
>
|
||||
Brita
|
||||
:
|
||||
</div>
|
||||
<div>
|
||||
hydration
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user