Compare commits
313 Commits
jkantor/re
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9d9024628 | ||
|
|
4759819071 | ||
|
|
f89963a0e1 | ||
|
|
6daa3b3cec | ||
|
|
8cb2fc18ff | ||
|
|
65d82ece23 | ||
|
|
5087bbb0e9 | ||
|
|
81aa35edd6 | ||
|
|
45e81aff46 | ||
|
|
1e1ccda82e | ||
|
|
2af3703d68 | ||
|
|
17fa6e53b5 | ||
|
|
1c886d9c21 | ||
|
|
1e1250f93a | ||
|
|
cae75368bf | ||
|
|
ea2f1f9f6f | ||
|
|
a4f63de389 | ||
|
|
6e1688d627 | ||
|
|
e759221e5f | ||
|
|
02a3bda10a | ||
|
|
aa4ddfa977 | ||
|
|
2b88ef3144 | ||
|
|
cd6cb71eb5 | ||
|
|
c6d72bcf47 | ||
|
|
a0bdd0c012 | ||
|
|
73e1421a90 | ||
|
|
d38ec004cb | ||
|
|
03d4d403b7 | ||
|
|
95a0cafac4 | ||
|
|
4a221c9caa | ||
|
|
40d7167744 | ||
|
|
a2a3af4ea3 | ||
|
|
5aacd38010 | ||
|
|
a5aad38cff | ||
|
|
2456251790 | ||
|
|
34a657d212 | ||
|
|
99573f1d93 | ||
|
|
1f16468bee | ||
|
|
0225daf3d2 | ||
|
|
86ede70c41 | ||
|
|
afd688d198 | ||
|
|
8a82b60b22 | ||
|
|
b608be06fe | ||
|
|
00017e3be1 | ||
|
|
25f686a875 | ||
|
|
b88969c9bb | ||
|
|
427907fba2 | ||
|
|
a9608149db | ||
|
|
a4d1fb28aa | ||
|
|
42dbbee796 | ||
|
|
f0b6fc291e | ||
|
|
a9cceb1ef9 | ||
|
|
9921542f7e | ||
|
|
c31185acfd | ||
|
|
d5cdbb8047 | ||
|
|
941f27a2f4 | ||
|
|
5753412ede | ||
|
|
790de20613 | ||
|
|
6d67a807a4 | ||
|
|
490e3ea67e | ||
|
|
77c65b469f | ||
|
|
645a5447e7 | ||
|
|
0cfb9a5f85 | ||
|
|
90d011e45e | ||
|
|
cecfbf1830 | ||
|
|
46d59027b5 | ||
|
|
3b3384b6b5 | ||
|
|
4210e8a7f6 | ||
|
|
6d4c5e7702 | ||
|
|
dd3b128904 | ||
|
|
f720e0a849 | ||
|
|
8f7eb57dfd | ||
|
|
49da0175e8 | ||
|
|
6db15693d6 | ||
|
|
9859fa1a45 | ||
|
|
b2ebc800ad | ||
|
|
97eded4432 | ||
|
|
ae94cae1ef | ||
|
|
0448860bca | ||
|
|
d07312866b | ||
|
|
e458d6fb05 | ||
|
|
515307c923 | ||
|
|
e57cab41e5 | ||
|
|
957991d472 | ||
|
|
f72faa824c | ||
|
|
6d21cbb616 | ||
|
|
de445a97be | ||
|
|
570e843fd4 | ||
|
|
e803c818ab | ||
|
|
7a0483a896 | ||
|
|
201584a7ea | ||
|
|
c222bec9ec | ||
|
|
215662ba16 | ||
|
|
cf5e1a65bf | ||
|
|
e08dc1ddc3 | ||
|
|
3d2dd5006a | ||
|
|
694b1a75fc | ||
|
|
eff1ac0900 | ||
|
|
918463de91 | ||
|
|
a2d119aa43 | ||
|
|
ccb7865100 | ||
|
|
997d205ac6 | ||
|
|
13433e969f | ||
|
|
c21a81eb55 | ||
|
|
9675a6e9a9 | ||
|
|
d9a0a11936 | ||
|
|
13a19a274c | ||
|
|
f4edf956bb | ||
|
|
c3c328fddb | ||
|
|
75725c16f4 | ||
|
|
d59a4bf54d | ||
|
|
18cede45a6 | ||
|
|
c3823c39b0 | ||
|
|
ef8e20f2b3 | ||
|
|
d683e874b7 | ||
|
|
4e9270ab8e | ||
|
|
6450d8648b | ||
|
|
9c7c848df5 | ||
|
|
9ad10108ec | ||
|
|
338498543a | ||
|
|
788476193c | ||
|
|
cb658776c6 | ||
|
|
a4eff6991f | ||
|
|
f1c9140c8e | ||
|
|
ba9bd466a3 | ||
|
|
e44f5dde44 | ||
|
|
d46ce000bb | ||
|
|
bf3b37caa4 | ||
|
|
295048b4e9 | ||
|
|
1c70458590 | ||
|
|
247e9f3668 | ||
|
|
5e96dbf614 | ||
|
|
44197f673d | ||
|
|
11b62cce1d | ||
|
|
78d521cd95 | ||
|
|
10cac378b1 | ||
|
|
9a92e39b6c | ||
|
|
39bff6e276 | ||
|
|
3be81e02ea | ||
|
|
ffecce993e | ||
|
|
ae1702d182 | ||
|
|
67789481fb | ||
|
|
543cd623e1 | ||
|
|
ba31b713e2 | ||
|
|
84fe2c6628 | ||
|
|
b87447b543 | ||
|
|
541a661dcc | ||
|
|
abc68f4224 | ||
|
|
14a5d4f849 | ||
|
|
b0e173dbba | ||
|
|
e1c8b01531 | ||
|
|
182dc396d5 | ||
|
|
e191aa9717 | ||
|
|
401916471b | ||
|
|
cee43bddcb | ||
|
|
6be4aac16e | ||
|
|
85607d7e97 | ||
|
|
af1b82bc1a | ||
|
|
7923f77d8b | ||
|
|
b84186ab0c | ||
|
|
0c4675cfa2 | ||
|
|
607b47be24 | ||
|
|
f6b2902914 | ||
|
|
b682b91f0a | ||
|
|
fcb4248521 | ||
|
|
23dfed82d0 | ||
|
|
d0ab0eca8f | ||
|
|
c690dde838 | ||
|
|
011737b492 | ||
|
|
524116a601 | ||
|
|
db5414e97f | ||
|
|
456edd453e | ||
|
|
ecceda2343 | ||
|
|
f5706635e0 | ||
|
|
933f6c0a6f | ||
|
|
229033b742 | ||
|
|
da447b12ed | ||
|
|
c7b5979067 | ||
|
|
8bf130b099 | ||
|
|
9d442b0edb | ||
|
|
84cdacd4e8 | ||
|
|
4fcc3f863f | ||
|
|
79679c23f2 | ||
|
|
9b2436991b | ||
|
|
c95f2d6b22 | ||
|
|
4f43e65f03 | ||
|
|
50bf7d236a | ||
|
|
d2723e5bc1 | ||
|
|
03fa143fc1 | ||
|
|
075846f869 | ||
|
|
1208d27d92 | ||
|
|
e345716bd4 | ||
|
|
2121a63c83 | ||
|
|
47cab71b3c | ||
|
|
2d8af2ec00 | ||
|
|
d55abbe91e | ||
|
|
a75f365bdd | ||
|
|
bbb7e895a5 | ||
|
|
bf70fd1450 | ||
|
|
af2ece8290 | ||
|
|
620827d772 | ||
|
|
c6a4685bf5 | ||
|
|
8dd2237f9c | ||
|
|
97c58157f8 | ||
|
|
ce093efba4 | ||
|
|
799ef5b8a1 | ||
|
|
f956351cf7 | ||
|
|
7772e21c6a | ||
|
|
f07a96ce58 | ||
|
|
f64bc8d4a6 | ||
|
|
134dabb710 | ||
|
|
65c25f00b6 | ||
|
|
31748e246e | ||
|
|
650be29ef9 | ||
|
|
b713ab5748 | ||
|
|
5fe80b4a52 | ||
|
|
9e04813d06 | ||
|
|
a0e1a60d23 | ||
|
|
68c7944dd5 | ||
|
|
f4f6e5551f | ||
|
|
ee99bfdaa4 | ||
|
|
318ce349fc | ||
|
|
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 | ||
|
|
351bf48561 | ||
|
|
30c51668c4 |
69
.env
69
.env
@@ -1,33 +1,38 @@
|
||||
NODE_ENV='production',
|
||||
NODE_ENV='production'
|
||||
NODE_PATH=./src
|
||||
BASE_URL=null,
|
||||
LMS_BASE_URL=null,
|
||||
LOGIN_URL=null,
|
||||
LOGOUT_URL=null,
|
||||
CSRF_TOKEN_API_PATH=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,
|
||||
SITE_NAME=null,
|
||||
MARKETING_SITE_BASE_URL=null,
|
||||
SUPPORT_URL=null,
|
||||
CONTACT_URL=null,
|
||||
OPEN_SOURCE_URL=null,
|
||||
TERMS_OF_SERVICE_URL=null,
|
||||
PRIVACY_POLICY_URL=null,
|
||||
FACEBOOK_URL=null,
|
||||
TWITTER_URL=null,
|
||||
YOU_TUBE_URL=null,
|
||||
LINKED_IN_URL=null,
|
||||
REDDIT_URL=null,
|
||||
APPLE_APP_STORE_URL=null,
|
||||
GOOGLE_PLAY_URL=null,
|
||||
ENTERPRISE_MARKETING_URL=null,
|
||||
ENTERPRISE_MARKETING_UTM_SOURCE=null,
|
||||
ENTERPRISE_MARKETING_UTM_CAMPAIGN=null,
|
||||
ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM=null,
|
||||
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=''
|
||||
LANGUAGE_PREFERENCE_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=''
|
||||
APP_ID=''
|
||||
MFE_CONFIG_API_URL=''
|
||||
DISPLAY_FEEDBACK_WIDGET='true'
|
||||
# Fallback in local style files
|
||||
PARAGON_THEME_URLS={}
|
||||
|
||||
@@ -4,22 +4,26 @@ 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
|
||||
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'
|
||||
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
|
||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||
SITE_NAME='edX'
|
||||
|
||||
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=null
|
||||
SEGMENT_KEY=''
|
||||
FEATURE_FLAGS={}
|
||||
CSRF_COOKIE_NAME='csrftoken'
|
||||
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'
|
||||
@@ -33,3 +37,8 @@ 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'
|
||||
APP_ID=''
|
||||
MFE_CONFIG_API_URL=''
|
||||
DISPLAY_FEEDBACK_WIDGET='false'
|
||||
# Fallback in local style files
|
||||
PARAGON_THEME_URLS={}
|
||||
|
||||
@@ -3,3 +3,4 @@ dist/
|
||||
node_modules/
|
||||
src/postcss.config.js
|
||||
src/segment.js
|
||||
src/lightning.js
|
||||
|
||||
31
.eslintrc.js
31
.eslintrc.js
@@ -1,3 +1,30 @@
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
const { createConfig } = require('@openedx/frontend-build');
|
||||
|
||||
module.exports = createConfig('eslint');
|
||||
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': ['*'] } }],
|
||||
|
||||
// TOD: Remove this rule once we have a better way to handle this.
|
||||
'import/no-import-module-exports': 'off',
|
||||
'no-import-assign': 'off',
|
||||
'default-param-last': 'off',
|
||||
},
|
||||
overrides: [{
|
||||
files: ['*.test.js'], rules: { 'no-import-assign': 'off' },
|
||||
}],
|
||||
});
|
||||
|
||||
config.settings = {
|
||||
'import/resolver': {
|
||||
node: {
|
||||
paths: ['src', 'node_modules'],
|
||||
extensions: ['.js', '.jsx'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
|
||||
7
.github/dependabot.yml
vendored
Normal file
7
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Adding new check for github-actions
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
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: @openedx/content-aurora
|
||||
33
.github/renovate.json
vendored
Normal file
33
.github/renovate.json
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"extends": [
|
||||
"config:base",
|
||||
"schedule:weekly",
|
||||
":automergeLinters",
|
||||
":automergeMinor",
|
||||
":automergeTesters",
|
||||
":enableVulnerabilityAlerts",
|
||||
":rebaseStalePrs",
|
||||
":semanticCommits",
|
||||
":updateNotScheduled"
|
||||
],
|
||||
"packageRules": [
|
||||
{
|
||||
"matchDepTypes": [
|
||||
"devDependencies"
|
||||
],
|
||||
"matchUpdateTypes": [
|
||||
"lockFileMaintenance",
|
||||
"minor",
|
||||
"patch",
|
||||
"pin"
|
||||
],
|
||||
"automerge": true
|
||||
},
|
||||
{
|
||||
"matchPackagePatterns": ["@edx", "@openedx"],
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"automerge": true
|
||||
}
|
||||
],
|
||||
"timezone": "America/New_York"
|
||||
}
|
||||
19
.github/workflows/add-depr-ticket-to-depr-board.yml
vendored
Normal file
19
.github/workflows/add-depr-ticket-to-depr-board.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
# Run the workflow that adds new tickets that are either:
|
||||
# - labelled "DEPR"
|
||||
# - title starts with "[DEPR]"
|
||||
# - body starts with "Proposal Date" (this is the first template field)
|
||||
# to the org-wide DEPR project board
|
||||
|
||||
name: Add newly created DEPR issues to the DEPR project board
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
routeissue:
|
||||
uses: openedx/.github/.github/workflows/add-depr-ticket-to-depr-board.yml@master
|
||||
secrets:
|
||||
GITHUB_APP_ID: ${{ secrets.GRAPHQL_AUTH_APP_ID }}
|
||||
GITHUB_APP_PRIVATE_KEY: ${{ secrets.GRAPHQL_AUTH_APP_PEM }}
|
||||
SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }}
|
||||
20
.github/workflows/add-remove-label-on-comment.yml
vendored
Normal file
20
.github/workflows/add-remove-label-on-comment.yml
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
# This workflow runs when a comment is made on the ticket
|
||||
# If the comment starts with "label: " it tries to apply
|
||||
# the label indicated in rest of comment.
|
||||
# If the comment starts with "remove label: ", it tries
|
||||
# to remove the indicated label.
|
||||
# Note: Labels are allowed to have spaces and this script does
|
||||
# not parse spaces (as often a space is legitimate), so the command
|
||||
# "label: really long lots of words label" will apply the
|
||||
# label "really long lots of words label"
|
||||
|
||||
name: Allows for the adding and removing of labels via comment
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
add_remove_labels:
|
||||
uses: openedx/.github/.github/workflows/add-remove-label-on-comment.yml@master
|
||||
|
||||
60
.github/workflows/ci.yml
vendored
Normal file
60
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
name: node_js CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
branches:
|
||||
- '**'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Nodejs
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Unit Tests
|
||||
run: npm run test
|
||||
|
||||
- name: Validate Package Lock
|
||||
run: make validate-no-uncommitted-package-lock-changes
|
||||
|
||||
- name: Run Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Run Test
|
||||
run: npm run test
|
||||
|
||||
- name: Run Build
|
||||
run: npm run build
|
||||
|
||||
- name: Run Coverage
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: true
|
||||
|
||||
- name: Send failure notification
|
||||
if: ${{ failure() }}
|
||||
uses: dawidd6/action-send-mail@v6
|
||||
with:
|
||||
server_address: email-smtp.us-east-1.amazonaws.com
|
||||
server_port: 465
|
||||
username: ${{secrets.EDX_SMTP_USERNAME}}
|
||||
password: ${{secrets.EDX_SMTP_PASSWORD}}
|
||||
subject: CI workflow failed in ${{github.repository}}
|
||||
to: masters-grades@edx.org
|
||||
from: github-actions <github-actions@edx.org>
|
||||
body: CI workflow in ${{github.repository}} failed! For details see "github.com/${{
|
||||
github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
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: openedx/.github/.github/workflows/commitlint.yml@master
|
||||
13
.github/workflows/lockfileversion-check.yml
vendored
Normal file
13
.github/workflows/lockfileversion-check.yml
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
#check package-lock file version
|
||||
|
||||
name: Lockfile Version check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
version-check:
|
||||
uses: openedx/.github/.github/workflows/lockfileversion-check-v3.yml@master
|
||||
12
.github/workflows/self-assign-issue.yml
vendored
Normal file
12
.github/workflows/self-assign-issue.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
# This workflow runs when a comment is made on the ticket
|
||||
# If the comment starts with "assign me" it assigns the author to the
|
||||
# ticket (case insensitive)
|
||||
|
||||
name: Assign comment author to ticket if they say "assign me"
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
self_assign_by_comment:
|
||||
uses: openedx/.github/.github/workflows/self-assign-issue.yml@master
|
||||
12
.github/workflows/update-browserslist-db.yml
vendored
Normal file
12
.github/workflows/update-browserslist-db.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
name: Update Browserslist DB
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * 1'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
update-browserslist:
|
||||
uses: openedx/.github/.github/workflows/update-browserslist-db.yml@master
|
||||
|
||||
secrets:
|
||||
requirements_bot_github_token: ${{ secrets.requirements_bot_github_token }}
|
||||
13
.gitignore
vendored
13
.gitignore
vendored
@@ -1,11 +1,12 @@
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
.idea
|
||||
node_modules
|
||||
npm-debug.log
|
||||
coverage
|
||||
|
||||
dist/
|
||||
src/i18n/transifex_input.json
|
||||
temp/babel-plugin-react-intl
|
||||
|
||||
### pyenv ###
|
||||
.python-version
|
||||
@@ -14,3 +15,13 @@ dist/
|
||||
*~
|
||||
*.swo
|
||||
*.swp
|
||||
|
||||
### Development environments ###
|
||||
.idea
|
||||
.vscode
|
||||
|
||||
### transifex ###
|
||||
src/i18n/transifex_input.json
|
||||
temp
|
||||
|
||||
src/i18n/messages/
|
||||
@@ -1,7 +1,6 @@
|
||||
.eslintignore
|
||||
.eslintrc.json
|
||||
.gitignore
|
||||
.travis.yml
|
||||
docker-compose.yml
|
||||
Dockerfile
|
||||
Makefile
|
||||
|
||||
27
.releaserc
27
.releaserc
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"branch": "master",
|
||||
"tagFormat": "v${version}",
|
||||
"verifyConditions": [
|
||||
"@semantic-release/npm",
|
||||
{
|
||||
"path": "@semantic-release/github",
|
||||
"assets": {
|
||||
"path": "dist/*"
|
||||
}
|
||||
}
|
||||
],
|
||||
"analyzeCommits": "@semantic-release/commit-analyzer",
|
||||
"generateNotes": "@semantic-release/release-notes-generator",
|
||||
"prepare": "@semantic-release/npm",
|
||||
"publish": [
|
||||
"@semantic-release/npm",
|
||||
{
|
||||
"path": "@semantic-release/github",
|
||||
"assets": {
|
||||
"path": "dist/*"
|
||||
}
|
||||
}
|
||||
],
|
||||
"success": [],
|
||||
"fail": []
|
||||
}
|
||||
28
.travis.yml
28
.travis.yml
@@ -1,28 +0,0 @@
|
||||
language: node_js
|
||||
node_js: 12
|
||||
notifications:
|
||||
email:
|
||||
recipients:
|
||||
- masters-grades@edx.org
|
||||
on_success: never
|
||||
on_failure: always
|
||||
webhooks: https://www.travisbuddy.com/
|
||||
on_success: never
|
||||
before_install:
|
||||
- npm install -g greenkeeper-lockfile@1.14.0
|
||||
install:
|
||||
- npm ci
|
||||
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:
|
||||
- npm run travis-deploy-once "npm run semantic-release"
|
||||
- npm run coveralls
|
||||
env:
|
||||
global:
|
||||
- secure: bBLQZVw1aVUxB7GFNXGrdKeztyFrCCJusVgFcSuej9S4qmj9/jrVsEc9dEcH+BMS+b49+SvILoxzd6ZYLaRygQLzevnO1/dX596DeCKVK48PTTZRsNyafaSMCkxNKqEmRcA9hYL52xJJ5GpKo7ViWsFy8VFgUfZEJxQi8/lYbfQ1vlXRpo2LJfJh09v85roSXdQmajyGJ1Dz6elcwUX5B+BgXmIHizJXUMfFci61xTEZmgKtfeCiwFQA5pCvVMHBQhgySqT2N3eRESzRt2jAfAdcRKBYXS0rwKymdlL1ZF349Jm8xwtqm19Fwsut21181Lnn6FmccMWhQ7man3WH1xfT0ahmHNs1KJMyZcwRJd/gDfbd6iD3LB9Pt9hEQ00Qh/m7MYeahMxTEL9bp2TyILi8cTP91jeBUHCExCdv2jRrUQEnUS5vZUYRdM8CR2DLoLmNh3APndKzwgr5U8rh6RdhbQBJp97Hb/YYVrBiP2atLJAaYPY/xEQHK/YoXelQgiZ6wHBMV+tF/L0ZRn7KyVWdkbBKWfbEjRKbEJD9WD+V7HayMR81tm5CSqlrG8mTvSy2boIGiX14GV11ZEfMj5bjb6W41BW+QGqQerZvmwk/4ywe304X85PD0OBhIYPRzeLIi0Gt6lD1aOpVxgm4M03tdgYQzCPWRPq32CB+1IA=
|
||||
- secure: w1d/E+cc4+Bf017Jpp9YsKBzLSZw9sqKZGeM2tNrO6eJZbMJqfKTmfUrRw8BoLh1Z8YRkHF7RADDy3ln7XEdeAX3j9OoC3Cz0zN6iDX6TPcI461NuOIscJYb4tyFcuWm6FhgVlBAlo/BI3q+zqKwjfWuDaORpk6+haacCmvTe5V0vWhY+MYT7M+LfnKeKVzhI4magGt8jPTE21oziIFwCqCCjJc4+AmsWoWTzU0Q7Db0DZiJnLXFfXybLbkedAgJmcSgEGZCSpaZIOkX0/Lbazsz1Ky4KASfkrYT1Z5iKQ8TE3skmx1IIu+1egN8iBbdrY+NhvV24RkT+rpUvD7TBIHTrjQ5JYLe0kGjN70vG7YlKgjNSyTjkrEd7fCKpuIol3DVjBRz3tV5aCl0t/A8mIPqKyNI94MamWsExpqsxgcb9vBVno5caZvD8ZXNrGNqanB3MSoLGxZTLKif9u+AZfLnB3xtjaiJg3/BNoWaOBPlp/M6BvGIGHElwvLrAhUvl8wzrwJcQQWpmRMh0b6enr6Y7ox/mGGs7NBCT+CNKEsWeCfY4thZzgi6/GocXyqdTpXMkNSI1PDoPmi+vKafBd+7aAYbcUlJBTU6TAxyncln0tF2JF+ghTZ0v8nNzEQ9VmV4ddyoOHx6YnHvEcenWZGMROQnMCVifyDbaHpPbPI=
|
||||
57
Makefile
Executable file → Normal file
57
Makefile
Executable file → Normal file
@@ -1,9 +1,56 @@
|
||||
npm-install-%: ## install specified % npm package
|
||||
npm install $* --save-dev
|
||||
npm ci $* --save-dev
|
||||
git add package.json
|
||||
|
||||
validate-no-uncommitted-package-lock-changes:
|
||||
git diff --exit-code package-lock.json
|
||||
intl_imports = ./node_modules/.bin/intl-imports.js
|
||||
transifex_utils = ./node_modules/.bin/transifex-utils.js
|
||||
i18n = ./src/i18n
|
||||
transifex_input = $(i18n)/transifex_input.json
|
||||
|
||||
test:
|
||||
npm run test
|
||||
# This directory must match .babelrc .
|
||||
transifex_temp = ./temp/babel-plugin-formatjs
|
||||
|
||||
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
|
||||
|
||||
|
||||
# Experimental: OEP-58 Pulls translations using atlas
|
||||
pull_translations:
|
||||
rm -rf src/i18n/messages
|
||||
mkdir src/i18n/messages
|
||||
cd src/i18n/messages \
|
||||
&& atlas pull $(ATLAS_OPTIONS) \
|
||||
translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \
|
||||
translations/frontend-component-header/src/i18n/messages:frontend-component-header \
|
||||
translations/frontend-platform/src/i18n/messages:frontend-platform \
|
||||
translations/paragon/src/i18n/messages:paragon \
|
||||
translations/frontend-app-gradebook/src/i18n/messages:frontend-app-gradebook
|
||||
|
||||
$(intl_imports) frontend-platform paragon frontend-component-header frontend-component-footer frontend-app-gradebook
|
||||
|
||||
# This target is used by CI.
|
||||
validate-no-uncommitted-package-lock-changes:
|
||||
# Checking for package-lock.json changes...
|
||||
git diff --exit-code package-lock.json
|
||||
|
||||
79
README.md
79
README.md
@@ -1,79 +0,0 @@
|
||||
[](https://travis-ci.org/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
|
||||
|
||||
Please tag **@edx/educator-neem** on any PRs or issues.
|
||||
|
||||
## Introduction
|
||||
|
||||
The front-end of our editable Gradebook feature.
|
||||
|
||||
## Usage
|
||||
|
||||
To install gradebook into your project:
|
||||
```
|
||||
npm i --save @edx/frontend-app-gradebook
|
||||
```
|
||||
|
||||
## Running the UI Standalone
|
||||
|
||||
To install the project please refer to the [`edX Developer Stack`](https://github.com/edx/devstack) instructions.
|
||||
|
||||
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 gradebook-logs` in the `devstack` directory.
|
||||
|
||||
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 settings 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. You want to activate this flag for any course
|
||||
in which you'd like to enable the gradebook. Add a course override flag using a course id and the flag name
|
||||
``grades.writable_gradebook``. Make sure to check the ``enabled`` box. Alternatively, you could add this as a
|
||||
regular waffle flag to enable the gradebook for all courses.
|
||||
|
||||
## 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
|
||||
|
||||
* `config`
|
||||
* Directory for [`webpack`](https://webpack.js.org/) configurations
|
||||
* `public`
|
||||
* Entry point for the single-page application - `gradebook` has a single `index.html` file
|
||||
* `src`
|
||||
* `components`
|
||||
* Directory for presentational `React` components
|
||||
* `containers`
|
||||
* Directory for container `React` components
|
||||
* `data`
|
||||
* `actions`
|
||||
* Directory for `Redux` action creators
|
||||
* `constants`
|
||||
* `reducers`
|
||||
* Directory for `Redux` reducers
|
||||
|
||||
## Authentication with backend API services
|
||||
|
||||
See the [`@edx/frontend-auth`](https://github.com/edx/frontend-auth) repo for information about securing routes in your application that require user authentication.
|
||||
253
README.rst
Normal file
253
README.rst
Normal file
@@ -0,0 +1,253 @@
|
||||
frontend-app-gradebook
|
||||
#######################
|
||||
|
||||
|Build Status| |Codecov| |npm_version| |npm_downloads| |license| |semantic-release|
|
||||
|
||||
Purpose
|
||||
*******
|
||||
|
||||
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.
|
||||
|
||||
Jump to:
|
||||
|
||||
- `Should I use Gradebook in my course?`_
|
||||
- Quickstart_
|
||||
|
||||
For existing documentation see:
|
||||
|
||||
- Basic Usage: `Review Learner Grades (read-the-docs) <https://docs.openedx.org/en/latest/educators/how-tos/data/view_learner_grades.html>`_
|
||||
- Bulk Grade Management: `Override Learner Subsection Scores in Bulk (read-the-docs) <https://docs.openedx.org/en/latest/educators/how-tos/data/manage_learner_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.
|
||||
|
||||
Getting Started
|
||||
***************
|
||||
|
||||
Installation
|
||||
============
|
||||
|
||||
To install gradebook into your project:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
npm i --save @edx/frontend-app-gradebook
|
||||
|
||||
Quickstart
|
||||
==========
|
||||
|
||||
Cloning and Setup
|
||||
-----------------
|
||||
|
||||
1. Clone your new repo:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
git clone https://github.com/openedx/frontend-app-gradebook.git
|
||||
|
||||
2. Use the version of Node specified in ``.nvmrc``
|
||||
|
||||
3. Stop the Tutor devstack, if it's running:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
tutor dev stop
|
||||
|
||||
4. Next, we need to tell Tutor that we're going to be running this repo in development mode, and it should be excluded from the mfe container that otherwise runs every MFE. Run this:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
tutor mounts add /path/to/frontend-app-gradebook
|
||||
|
||||
5. Start Tutor in development mode. This command will start the LMS and Studio,
|
||||
and other required MFEs like ``authn`` and ``account``, but will not start the
|
||||
Gradebook MFE, which we're going to run on the host instead of in a container
|
||||
managed by Tutor. Run:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
tutor dev start lms cms mfe
|
||||
|
||||
Startup
|
||||
-------
|
||||
|
||||
1. Install npm dependencies:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
cd frontend-app-gradebook && npm install
|
||||
|
||||
2. Start the dev server:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
npm run dev
|
||||
|
||||
Running the UI Standalone
|
||||
==========================
|
||||
|
||||
To install the project please refer to the `MFE Development on Tutor <https://github.com/overhangio/tutor-mfe?tab=readme-ov-file#mfe-development>`_ instructions.
|
||||
|
||||
When not mounted, gradebook will run in the shared MFE container at http://apps.local.openedx.io/gradebook/course-v1:edX+DemoX+Demo_Course.
|
||||
|
||||
When mounted in the tutor ``gradebook`` container, or when running a local (host) webpack dev server, the web application runs on port **1994**, so when you go to http://apps.local.openedx.io:1994/gradebook/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.
|
||||
|
||||
(Note: This may not work in Tutor; these instructions are for the deprecated Devstack) You can see the log messages for the docker container by executing ``make gradebook-logs`` in the ``devstack`` directory.
|
||||
|
||||
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.
|
||||
|
||||
Plugins
|
||||
*******
|
||||
|
||||
This MFE can be customized using `Frontend Plugin Framework <https://github.com/openedx/frontend-plugin-framework>`_.
|
||||
|
||||
The parts of this MFE that can be customized in that manner are documented `here </src/plugin-slots>`_.
|
||||
|
||||
Running tests
|
||||
*************
|
||||
|
||||
Run:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
nvm use
|
||||
|
||||
npm ci
|
||||
|
||||
npm test
|
||||
|
||||
Directory Structure
|
||||
*******************
|
||||
|
||||
* ``config``
|
||||
|
||||
* Directory for `webpack <https://webpack.js.org/>`_ configurations
|
||||
|
||||
* ``public``
|
||||
|
||||
* Entry point for the single-page application - ``gradebook`` has a single ``index.html`` file
|
||||
|
||||
* ``src``
|
||||
|
||||
* ``components``
|
||||
|
||||
* Directory for presentational ``React`` components
|
||||
|
||||
* ``containers``
|
||||
|
||||
* Directory for container ``React`` components
|
||||
|
||||
* ``data``
|
||||
|
||||
* ``actions``
|
||||
|
||||
* Directory for ``Redux`` action creators
|
||||
|
||||
* ``constants``
|
||||
* ``reducers``
|
||||
|
||||
* Directory for ``Redux`` reducers
|
||||
|
||||
Authentication with backend API services
|
||||
*****************************************
|
||||
|
||||
See the `@edx/frontend-auth <https://github.com/edx-unsupported/frontend-auth>`_ repo for information about securing routes in your application that require user authentication.
|
||||
|
||||
License
|
||||
*******
|
||||
|
||||
The code in this repository is licensed under the AGPLv3 unless otherwise
|
||||
noted.
|
||||
|
||||
Please see `LICENSE <LICENSE>`_ for details.
|
||||
|
||||
Contributing
|
||||
************
|
||||
|
||||
Contributions are very welcome. Please read `How To Contribute`_ for details.
|
||||
|
||||
.. _How To Contribute: https://openedx.org/r/how-to-contribute
|
||||
|
||||
This project is currently accepting all types of contributions, bug fixes,
|
||||
security fixes, maintenance work, or new features. However, please make sure
|
||||
to have a discussion about your new feature idea with the maintainers prior to
|
||||
beginning development to maximize the chances of your change being accepted.
|
||||
You can start a conversation by creating a new issue on this repo summarizing
|
||||
your idea.
|
||||
|
||||
Getting Help
|
||||
************
|
||||
|
||||
If you're having trouble, we have discussion forums at
|
||||
https://discuss.openedx.org where you can connect with others in the community.
|
||||
|
||||
Our real-time conversations are on Slack. You can request a `Slack
|
||||
invitation`_, then join our `community Slack workspace`_. Because this is a
|
||||
frontend repository, the best place to discuss it would be in the
|
||||
`#wg-frontend channel`_.
|
||||
|
||||
For anything non-trivial, the best path is to open an issue in this repository
|
||||
with as many details about the issue you are facing as you can provide.
|
||||
|
||||
https://github.com/openedx/frontend-app-gradebook/issues
|
||||
|
||||
For more information about these options, see the `Getting Help`_ page.
|
||||
|
||||
.. _Slack invitation: https://openedx.org/slack
|
||||
.. _community Slack workspace: https://openedx.slack.com/
|
||||
.. _#wg-frontend channel: https://openedx.slack.com/archives/C04BM6YC7A6
|
||||
.. _Getting Help: https://openedx.org/community/connect
|
||||
|
||||
The Open edX Code of Conduct
|
||||
*****************************
|
||||
|
||||
All community members are expected to follow the `Open edX Code of Conduct`_.
|
||||
|
||||
.. _Open edX Code of Conduct: https://openedx.org/code-of-conduct/
|
||||
|
||||
Reporting Security Issues
|
||||
**************************
|
||||
|
||||
Please do not report security issues in public. Please email security@openedx.org.
|
||||
|
||||
.. |Build Status| image:: https://api.travis-ci.com/edx/frontend-app-gradebook.svg?branch=master
|
||||
:target: https://travis-ci.com/edx/frontend-app-gradebook
|
||||
.. |Codecov| image:: https://img.shields.io/codecov/c/gh/openedx/frontend-app-gradebook
|
||||
:target: https://app.codecov.io/gh/openedx/frontend-app-gradebook
|
||||
.. |npm_version| image:: https://img.shields.io/npm/v/@edx/frontend-app-gradebook.svg
|
||||
:target: @edx/frontend-app-gradebook
|
||||
.. |npm_downloads| image:: https://img.shields.io/npm/dt/@edx/frontend-app-gradebook.svg
|
||||
:target: @edx/frontend-app-gradebook
|
||||
.. |license| image:: https://img.shields.io/npm/l/@edx/frontend-app-gradebook.svg
|
||||
:target: @edx/frontend-app-gradebook
|
||||
.. |semantic-release| image:: https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg
|
||||
:target: https://github.com/semantic-release/semantic-release
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.0 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -1,3 +1,3 @@
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
const { createConfig } = require('@openedx/frontend-build');
|
||||
|
||||
module.exports = createConfig('babel');
|
||||
|
||||
14
catalog-info.yaml
Normal file
14
catalog-info.yaml
Normal file
@@ -0,0 +1,14 @@
|
||||
# This file records information about this repo. Its use is described in OEP-55:
|
||||
# https://open-edx-proposals.readthedocs.io/en/latest/processes/oep-0055-proc-project-maintainers.html
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
kind: Component
|
||||
metadata:
|
||||
name: "frontend-app-gradebook"
|
||||
description: "The frontend (MFE) for Open edX Gradebook"
|
||||
annotations:
|
||||
openedx.org/arch-interest-groups: ""
|
||||
openedx.org/release: "master"
|
||||
spec:
|
||||
owner: user:farhaanbukhsh
|
||||
type: 'website'
|
||||
lifecycle: 'experimental'
|
||||
@@ -1,77 +0,0 @@
|
||||
# Travis Configuration
|
||||
|
||||
Your project might have different build requirements - however, this project's `.travis.yml` configuration is supposed to represent a good starting point.
|
||||
|
||||
## Node JS Version
|
||||
|
||||
The minimum `Node` and `npm` versions that edX supports is `8.9.3` and `5.5.1`, respectively.
|
||||
|
||||
## Caching node_modules
|
||||
|
||||
While [the `Travis` blog](https://blog.travis-ci.com/2016-11-21-travis-ci-now-supports-yarn) recommends
|
||||
|
||||
```yaml
|
||||
cache:
|
||||
directories:
|
||||
- node_modules
|
||||
```
|
||||
|
||||
this causes issues when testing different versions of `Node` because [`node_modules` will store the compiled native modules](https://stackoverflow.com/a/42523517/5225575).
|
||||
|
||||
Caching the `~/.npm` directory avoids storing these native modules.
|
||||
|
||||
## Notifications
|
||||
|
||||
This project uses a service called [`TravisBuddy`](https://www.travisbuddy.com/), which provides Travis build context within a PR via webhooks (configured only to add feedback for build failures).
|
||||
|
||||

|
||||
|
||||
## Installing `greenkeeper-lockfile`
|
||||
|
||||
As explained in [the `Greenkeeper` documentation](https://greenkeeper.io/docs.html#greenkeeper-step-by-step), `Greenkeeper` is a service that keeps track of your project's dependencies, and will, for example, automatically open PRs with an updated `package.json` file when the latest version of a dependency is a major version ahead of the existing dependency version in your `package.json` file.
|
||||
|
||||
This automated updating is great, but `Greenkeeper` does not update your `package-lock.json` file, just your `package.json` file. This makes sense, as the only way to update the `package-lock.json` file would be to run `npm install` when building your project, using the latest `package.json`, and then committing the updated `package-lock.json` file.
|
||||
|
||||
This is essentially what you have to do manually when `Greenkeeper` opens a PR - `git checkout` the branch, `npm install` locally, `git commit` the `package-lock.json` changes, and then `git push` those changes to the `Greenkeeper` branch on `origin`. It's fun probably only the first time, and even then it gets old, fast.
|
||||
|
||||
What [`greenkeeper-lockfile`](https://github.com/greenkeeperio/greenkeeper-lockfile) does is that it automates the previous steps as part of the build process.
|
||||
|
||||
It will
|
||||
* Check that the branch is a `Greenkeeper` branch
|
||||
* Update the lockfile
|
||||
* Push a commit with the updated lockfile back to the Greenkeeper branch
|
||||
|
||||
This is why it's important to install `greenkeeper-lockfile` in the `before_install` step, and since it's used exclusively only in the Travis Build, why it's not part of the package's dependencies.
|
||||
|
||||
## Scripts
|
||||
|
||||
Most of the `script`s are self-explanatory - you probably want to fail a build if there are linting violations, or if any tests don't pass, or if it cannot compile your files.
|
||||
|
||||
However, there are a couple additional `script`s that might seem less self-explanatory.
|
||||
|
||||
### What the heck is `make validate-no-uncommitted-package-lock-changes`?
|
||||
|
||||
There are only two requirements for a good `make target` name
|
||||
|
||||
1. Definitely make it really verbose so people can't remember what it's called
|
||||
2. Definitely don't not use a double-negative
|
||||
|
||||
What `make validate-no-uncommitted-package-lock-changes` does is `git diff`s for any `package-lock.json` file changes in your project. It's important to remember that all build `script`s are executed in Travis *after* the `install` step (aka post-`npm install`).
|
||||
|
||||
This is important because `npm` uses the pinned dependencies in your `package-lock.json` file to build the `node_modules` directory. However, the dependencies defined within the `package.json` file can be modified manually, for example, to become misaligned with the dependencies defined within the `package-lock.json`. So when `npm install` executes, the `package-lock.json` file will be updated to mirror the modified `package.json` changes.
|
||||
|
||||
However, when these changes surface within a Travis build, this indicates differing dependency expectations between the committed `package.json` file and the `package-lock.json` file, which is a good reason to fail a build.
|
||||
|
||||
### What is this `npm run is-es5` check?
|
||||
|
||||
This project outputs production files to the `dist` folder. The `npm script`, `npm run is-es5`, checks the JavaScript files in the `dist` folder to make sure that they are `ES5`-compliant.
|
||||
|
||||
This check is important because `ES5` JavaScript has [greater browser compatibility](http://kangax.github.io/compat-table/es5/) than [`ES2015+`](http://kangax.github.io/compat-table/es6/) - particularly for `IE11`.
|
||||
|
||||
### `deploy` step
|
||||
|
||||
How your project deploys will probably differ between the cookie cutter and your own application.
|
||||
|
||||
For demonstrational purposes, the cookie cutter deploys to GitHub pages using [`Travis`'s GitHub pages configuration](https://docs.travis-ci.com/user/deployment/pages/).
|
||||
|
||||
Your application might deploy to an `S3` bucket or to `npm`.
|
||||
40
documentation/CI.md
Executable file
40
documentation/CI.md
Executable file
@@ -0,0 +1,40 @@
|
||||
# CI Configuration
|
||||
|
||||
Your project might have different build requirements - however, this project's `.github/ci.yml` configuration is supposed to represent a good starting point.
|
||||
|
||||
## Node JS Version
|
||||
|
||||
The minimum `Node` and `npm` versions that edX supports is `8.9.3` and `5.5.1`, respectively.
|
||||
|
||||
## Scripts
|
||||
|
||||
Most of the `script`s are self-explanatory - you probably want to fail a build if there are linting violations, or if any tests don't pass, or if it cannot compile your files.
|
||||
|
||||
However, there are a couple additional `script`s that might seem less self-explanatory.
|
||||
|
||||
### What the heck is `make validate-no-uncommitted-package-lock-changes`?
|
||||
|
||||
There are only two requirements for a good `make target` name
|
||||
|
||||
1. Definitely make it really verbose so people can't remember what it's called
|
||||
2. Definitely don't not use a double-negative
|
||||
|
||||
What `make validate-no-uncommitted-package-lock-changes` does is `git diff`s for any `package-lock.json` file changes in your project. It's important to remember that all build `script`s are executed in CI *after* the `install` step (aka post-`npm install`).
|
||||
|
||||
This is important because `npm` uses the pinned dependencies in your `package-lock.json` file to build the `node_modules` directory. However, the dependencies defined within the `package.json` file can be modified manually, for example, to become misaligned with the dependencies defined within the `package-lock.json`. So when `npm install` executes, the `package-lock.json` file will be updated to mirror the modified `package.json` changes.
|
||||
|
||||
However, when these changes surface within a CI build, this indicates differing dependency expectations between the committed `package.json` file and the `package-lock.json` file, which is a good reason to fail a build.
|
||||
|
||||
### What is this `npm run is-es5` check?
|
||||
|
||||
This project outputs production files to the `dist` folder. The `npm script`, `npm run is-es5`, checks the JavaScript files in the `dist` folder to make sure that they are `ES5`-compliant.
|
||||
|
||||
This check is important because `ES5` JavaScript has [greater browser compatibility](http://kangax.github.io/compat-table/es5/) than [`ES2015+`](http://kangax.github.io/compat-table/es6/) - particularly for `IE11`.
|
||||
|
||||
### `deploy` step
|
||||
|
||||
How your project deploys will probably differ between the cookie cutter and your own application.
|
||||
|
||||
For demonstrational purposes, the cookie cutter deploys to GitHub pages using [ GitHUb CI ].
|
||||
|
||||
Your application might deploy to an `S3` bucket or to `npm`.
|
||||
@@ -5,7 +5,7 @@ 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
|
||||
https://github.com/openedx/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,
|
||||
@@ -13,7 +13,7 @@ the course grade updates that occur during calls to this API are synchronous - t
|
||||
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
|
||||
https://github.com/openedx/edx-platform/blob/master/lms/djangoapps/grades/docs/decisions/0001-gradebook-api.rst
|
||||
|
||||
Decision
|
||||
========
|
||||
|
||||
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://docs.openedx.org/en/latest/educators/quickstarts/build_a_course.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://docs.openedx.org/en/latest/educators/how-tos/data/manage_learner_grades.html#review-how-grading-is-configured-for-your-course)
|
||||
- [Adding Exercises and Tools](https://docs.openedx.org/en/latest/educators/concepts/exercise_tools/about_problems_exercises_tools.html)
|
||||
- [Set the Assignment Type and Due Date for a Subsection](https://docs.openedx.org/en/latest/educators/how-tos/course_development/set_subsection_problem_date.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 - note: possibly outdated, edx.org-specific](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
|
||||
14
jest.config.js
Normal file
14
jest.config.js
Normal file
@@ -0,0 +1,14 @@
|
||||
const { createConfig } = require('@openedx/frontend-build');
|
||||
|
||||
module.exports = createConfig('jest', {
|
||||
setupFilesAfterEnv: [
|
||||
'<rootDir>/src/setupTest.js',
|
||||
],
|
||||
modulePaths: ['<rootDir>/src/'],
|
||||
coveragePathIgnorePatterns: [
|
||||
'src/segment.js',
|
||||
'src/postcss.config.js',
|
||||
'testUtils', // don't unit test jest mocking tools
|
||||
'testUtilsExtra', // don't unit test jest mocking tools
|
||||
],
|
||||
});
|
||||
13
openedx.yaml
13
openedx.yaml
@@ -1,13 +0,0 @@
|
||||
# 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
|
||||
|
||||
nick: grbk
|
||||
tags:
|
||||
- frontend-app
|
||||
- masters
|
||||
oeps:
|
||||
oep-2: true # Repository metadata
|
||||
openedx-release: {ref: master}
|
||||
owner:
|
||||
type: team
|
||||
team: edx/masters-devs-gta
|
||||
47670
package-lock.json
generated
47670
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
65
package.json
Executable file → Normal file
65
package.json
Executable file → Normal file
@@ -1,53 +1,60 @@
|
||||
{
|
||||
"name": "@edx/frontend-app-gradebook",
|
||||
"version": "1.4.14",
|
||||
"version": "1.6.3",
|
||||
"description": "edx editable gradebook-ui to manipulate grade overrides on subsections",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/edx/frontend-app-gradebook.git"
|
||||
"url": "git+https://github.com/openedx/frontend-app-gradebook.git"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "fedx-scripts webpack",
|
||||
"coveralls": "cat ./coverage/lcov.info | coveralls",
|
||||
"is-es5": "es-check es5 ./dist/*.js",
|
||||
"i18n_extract": "fedx-scripts formatjs extract",
|
||||
"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": "fedx-scripts webpack-dev-server --progress",
|
||||
"dev": "PUBLIC_PATH=/gradebook/ MFE_CONFIG_API_URL='http://localhost:8000/api/mfe_config/v1' fedx-scripts webpack-dev-server --progress --host apps.local.openedx.io",
|
||||
"test": "TZ=GMT fedx-scripts jest --coverage --passWithNoTests",
|
||||
"watch-tests": "jest --watch",
|
||||
"travis-deploy-once": "travis-deploy-once"
|
||||
"watch-tests": "jest --watch"
|
||||
},
|
||||
"author": "edX",
|
||||
"license": "AGPL-3.0",
|
||||
"homepage": "https://github.com/edx/frontend-app-gradebook#readme",
|
||||
"homepage": "https://github.com/openedx/frontend-app-gradebook#readme",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"browserslist": [
|
||||
"extends @edx/browserslist-config"
|
||||
],
|
||||
"dependencies": {
|
||||
"@edx/frontend-component-footer": "10.0.11",
|
||||
"@edx/frontend-platform": "1.6.1",
|
||||
"@edx/paragon": "10.0.1",
|
||||
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
|
||||
"@edx/frontend-component-footer": "^14.6.0",
|
||||
"@edx/frontend-component-header": "^6.6.1",
|
||||
"@edx/frontend-platform": "^8.3.7",
|
||||
"@edx/openedx-atlas": "^0.6.0",
|
||||
"@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",
|
||||
"@openedx/frontend-plugin-framework": "^1.6.0",
|
||||
"@openedx/paragon": "^23.4.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",
|
||||
"font-awesome": "4.7.0",
|
||||
"history": "4.10.1",
|
||||
"node-sass": "^4.14.1",
|
||||
"prop-types": "15.7.2",
|
||||
"prop-types": "15.8.1",
|
||||
"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": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-redux": "^7.2.9",
|
||||
"react-router": "6.15.0",
|
||||
"react-router-dom": "6.15.0",
|
||||
"react-router-redux": "^5.0.0-alpha.9",
|
||||
"redux": "4.0.5",
|
||||
"redux-beacon": "^2.1.0",
|
||||
@@ -55,23 +62,21 @@
|
||||
"redux-logger": "3.0.6",
|
||||
"redux-thunk": "2.3.0",
|
||||
"regenerator-runtime": "^0.13.7",
|
||||
"sass": "^1.49.0",
|
||||
"whatwg-fetch": "^2.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edx/frontend-build": "5.3.2",
|
||||
"axios": "0.19.2",
|
||||
"axios-mock-adapter": "^1.17.0",
|
||||
"codecov": "^3.6.1",
|
||||
"enzyme": "^3.10.0",
|
||||
"enzyme-adapter-react-16": "^1.14.0",
|
||||
"@edx/browserslist-config": "^1.1.1",
|
||||
"@openedx/frontend-build": "^14.6.2",
|
||||
"@testing-library/jest-dom": "^6.6.4",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"es-check": "^2.3.0",
|
||||
"fetch-mock": "^6.5.2",
|
||||
"husky": "2.7.0",
|
||||
"fetch-mock": "^12.2.0",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"react-dev-utils": "^5.0.3",
|
||||
"react-test-renderer": "^16.10.1",
|
||||
"redux-mock-store": "^1.5.3",
|
||||
"semantic-release": "^15.13.24",
|
||||
"travis-deploy-once": "^5.0.11"
|
||||
"jest": "^29.7.0",
|
||||
"react-dev-utils": "^12.0.1",
|
||||
"react-test-renderer": "^18.3.1",
|
||||
"redux-mock-store": "^1.5.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<!doctype html>
|
||||
<html lang="en-us">
|
||||
<head>
|
||||
<title>Gradebook | edX</title>
|
||||
<title>Gradebook | <%= process.env.SITE_NAME %></title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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>
|
||||
|
||||
32
src/App.jsx
Executable file
32
src/App.jsx
Executable file
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import { FooterSlot } from '@edx/frontend-component-footer';
|
||||
import Header from '@edx/frontend-component-header';
|
||||
|
||||
import store from 'data/store';
|
||||
import GradebookPage from 'containers/GradebookPage';
|
||||
import './App.scss';
|
||||
import Head from './head/Head';
|
||||
|
||||
const App = () => (
|
||||
<AppProvider store={store}>
|
||||
<Head />
|
||||
<div>
|
||||
<Header />
|
||||
<main>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/:courseId"
|
||||
element={<GradebookPage />}
|
||||
/>
|
||||
</Routes>
|
||||
</main>
|
||||
<FooterSlot />
|
||||
</div>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
export default App;
|
||||
14
src/App.scss
14
src/App.scss
@@ -1,13 +1,15 @@
|
||||
|
||||
@import "~@edx/paragon/scss/edx/theme.scss";
|
||||
// frontend-app-*/src/index.scss
|
||||
@use "@openedx/paragon/styles/css/core/custom-media-breakpoints.css" as paragonCustomMediaBreakpoints;
|
||||
|
||||
$fa-font-path: "~font-awesome/fonts";
|
||||
@import "~font-awesome/scss/font-awesome";
|
||||
|
||||
$input-focus-box-shadow: $input-box-shadow; // hack to get upgrade to paragon 4.0.0 to work
|
||||
$input-focus-box-shadow: var(--pgn-elevation-form-input-base); // hack to get upgrade to paragon 4.0.0 to work
|
||||
|
||||
@import "~@edx/frontend-component-header/dist/index";
|
||||
@import "~@edx/frontend-component-footer/dist/_footer";
|
||||
|
||||
@import "./components/Gradebook/gradebook";
|
||||
@import "./components/Drawer/Drawer";
|
||||
|
||||
@import "./components/GradesView/GradesView";
|
||||
@import "./components/BulkManagementHistoryView/BulkManagementHistoryView";
|
||||
@import "./components/WithSidebar/WithSidebar";
|
||||
@import "./components/GradebookFilters/GradebookFilters";
|
||||
|
||||
63
src/App.test.jsx
Normal file
63
src/App.test.jsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import App from './App';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
Routes: ({ children }) => children,
|
||||
Route: ({ element }) => element,
|
||||
}));
|
||||
|
||||
jest.mock('@edx/frontend-platform/react', () => ({
|
||||
AppProvider: ({ children }) => children,
|
||||
}));
|
||||
|
||||
jest.mock('@edx/frontend-component-header', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>Header</div>,
|
||||
}));
|
||||
|
||||
jest.mock('@edx/frontend-component-footer', () => ({
|
||||
FooterSlot: () => <div>Footer</div>,
|
||||
}));
|
||||
|
||||
jest.mock('./head/Head', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>Head</div>,
|
||||
}));
|
||||
|
||||
jest.mock('containers/GradebookPage', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>Gradebook</div>,
|
||||
}));
|
||||
|
||||
describe('App', () => {
|
||||
beforeEach(() => {
|
||||
render(<App />);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders Head component', () => {
|
||||
const head = screen.getByText('Head');
|
||||
expect(head).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Header component', () => {
|
||||
const header = screen.getByText('Header');
|
||||
expect(header).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Footer component', () => {
|
||||
const footer = screen.getByText('Footer');
|
||||
expect(footer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders main content wrapper', () => {
|
||||
const main = screen.getByRole('main');
|
||||
expect(main).toBeInTheDocument();
|
||||
const gradebook = screen.getByText('Gradebook');
|
||||
expect(gradebook).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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 '@openedx/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,51 @@
|
||||
import React from 'react';
|
||||
|
||||
import selectors from 'data/selectors';
|
||||
|
||||
import { BulkManagementAlerts, mapStateToProps } from './BulkManagementAlerts';
|
||||
import { renderWithIntl, screen } from '../../testUtilsExtra';
|
||||
|
||||
jest.mock('data/selectors', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
grades: {
|
||||
bulkImportError: (state) => ({ bulkImportError: state }),
|
||||
uploadSuccess: (state) => ({ uploadSuccess: state }),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const errorMessage = 'Oh noooooo';
|
||||
|
||||
describe('BulkManagementAlerts', () => {
|
||||
describe('component', () => {
|
||||
describe('states of the warnings', () => {
|
||||
test('no alert shown', () => {
|
||||
renderWithIntl(<BulkManagementAlerts bulkImportError="" uploadSuccess={false} />);
|
||||
expect(document.querySelectorAll('.alert').length).toEqual(0);
|
||||
});
|
||||
test('Just success alert shown', () => {
|
||||
renderWithIntl(<BulkManagementAlerts bulkImportError="" uploadSuccess />);
|
||||
expect(document.querySelectorAll('.alert-success').length).toEqual(1);
|
||||
});
|
||||
test('Just error alert shown', () => {
|
||||
renderWithIntl(<BulkManagementAlerts bulkImportError={errorMessage} uploadSuccess={false} />);
|
||||
expect(document.querySelectorAll('.alert-danger').length).toEqual(1);
|
||||
expect(screen.getByText(errorMessage)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
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%;
|
||||
}
|
||||
}
|
||||
61
src/components/BulkManagementHistoryView/HistoryTable.jsx
Normal file
61
src/components/BulkManagementHistoryView/HistoryTable.jsx
Normal file
@@ -0,0 +1,61 @@
|
||||
/* 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 { DataTable } from '@openedx/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,
|
||||
}) => (
|
||||
<DataTable
|
||||
data={bulkManagementHistory.map(mapHistoryRows)}
|
||||
hasFixedColumnWidths
|
||||
columns={bulkManagementColumns}
|
||||
className="table-striped"
|
||||
itemCount={bulkManagementHistory.length}
|
||||
/>
|
||||
);
|
||||
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);
|
||||
187
src/components/BulkManagementHistoryView/HistoryTable.test.jsx
Normal file
187
src/components/BulkManagementHistoryView/HistoryTable.test.jsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import React from 'react';
|
||||
import { render, screen, initializeMocks } from 'testUtilsExtra';
|
||||
import { DataTable } from '@openedx/paragon';
|
||||
|
||||
import selectors from 'data/selectors';
|
||||
import { bulkManagementColumns } from 'data/constants/app';
|
||||
|
||||
import { HistoryTable, mapHistoryRows, mapStateToProps } from './HistoryTable';
|
||||
import ResultsSummary from './ResultsSummary';
|
||||
|
||||
initializeMocks();
|
||||
|
||||
jest.mock('@openedx/paragon', () => ({
|
||||
...jest.requireActual('@openedx/paragon'),
|
||||
DataTable: jest.fn(() => <div data-testid="data-table">DataTable</div>),
|
||||
}));
|
||||
jest.mock('./ResultsSummary', () => jest.fn(() => <div data-testid="results-summary">ResultsSummary</div>));
|
||||
jest.mock('data/selectors', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
grades: {
|
||||
bulkManagementHistoryEntries: jest.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe('HistoryTable', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const mockBulkManagementHistory = [
|
||||
{
|
||||
originalFilename: 'test-file-1.csv',
|
||||
user: 'test-user-1',
|
||||
timeUploaded: '2025-01-01T10:00:00Z',
|
||||
resultsSummary: {
|
||||
rowId: 1,
|
||||
text: 'Download results 1',
|
||||
},
|
||||
},
|
||||
{
|
||||
originalFilename: 'test-file-2.csv',
|
||||
user: 'test-user-2',
|
||||
timeUploaded: '2025-01-02T10:00:00Z',
|
||||
resultsSummary: {
|
||||
rowId: 2,
|
||||
text: 'Download results 2',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('mapHistoryRows', () => {
|
||||
const mockRow = {
|
||||
resultsSummary: {
|
||||
rowId: 1,
|
||||
text: 'Download results',
|
||||
},
|
||||
originalFilename: 'test-file.csv',
|
||||
user: 'test-user',
|
||||
timeUploaded: '2025-01-01T10:00:00Z',
|
||||
};
|
||||
|
||||
it('transforms row data correctly', () => {
|
||||
const result = mapHistoryRows(mockRow);
|
||||
|
||||
expect(result).toHaveProperty('resultsSummary');
|
||||
expect(result).toHaveProperty('filename');
|
||||
expect(result).toHaveProperty('user');
|
||||
expect(result).toHaveProperty('timeUploaded');
|
||||
expect(result.timeUploaded).toBe('2025-01-01T10:00:00Z');
|
||||
});
|
||||
|
||||
it('wraps filename in span with correct class', () => {
|
||||
const result = mapHistoryRows(mockRow);
|
||||
render(<div>{result.filename}</div>);
|
||||
|
||||
const filenameSpan = screen.getByText('test-file.csv');
|
||||
expect(filenameSpan).toBeInTheDocument();
|
||||
expect(filenameSpan).toHaveClass('wrap-text-in-cell');
|
||||
});
|
||||
|
||||
it('wraps user in span with correct class', () => {
|
||||
const result = mapHistoryRows(mockRow);
|
||||
render(<div>{result.user}</div>);
|
||||
|
||||
const userSpan = screen.getByText('test-user');
|
||||
expect(userSpan).toBeInTheDocument();
|
||||
expect(userSpan).toHaveClass('wrap-text-in-cell');
|
||||
});
|
||||
|
||||
it('renders ResultsSummary component with correct props', () => {
|
||||
const result = mapHistoryRows(mockRow);
|
||||
render(<div>{result.resultsSummary}</div>);
|
||||
|
||||
expect(ResultsSummary).toHaveBeenCalledWith(mockRow.resultsSummary, {});
|
||||
expect(screen.getByTestId('results-summary')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('component', () => {
|
||||
it('renders DataTable with empty data when no history provided', () => {
|
||||
render(<HistoryTable />);
|
||||
|
||||
expect(DataTable).toHaveBeenCalledWith(
|
||||
{
|
||||
data: [],
|
||||
hasFixedColumnWidths: true,
|
||||
columns: bulkManagementColumns,
|
||||
className: 'table-striped',
|
||||
itemCount: 0,
|
||||
},
|
||||
{},
|
||||
);
|
||||
expect(screen.getByTestId('data-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders DataTable with mapped history data', () => {
|
||||
render(
|
||||
<HistoryTable bulkManagementHistory={mockBulkManagementHistory} />,
|
||||
);
|
||||
|
||||
expect(DataTable).toHaveBeenCalledWith(
|
||||
{
|
||||
data: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
filename: expect.any(Object),
|
||||
user: expect.any(Object),
|
||||
resultsSummary: expect.any(Object),
|
||||
timeUploaded: '2025-01-01T10:00:00Z',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
filename: expect.any(Object),
|
||||
user: expect.any(Object),
|
||||
resultsSummary: expect.any(Object),
|
||||
timeUploaded: '2025-01-02T10:00:00Z',
|
||||
}),
|
||||
]),
|
||||
hasFixedColumnWidths: true,
|
||||
columns: bulkManagementColumns,
|
||||
className: 'table-striped',
|
||||
itemCount: 2,
|
||||
},
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it('passes correct props to DataTable', () => {
|
||||
render(
|
||||
<HistoryTable bulkManagementHistory={mockBulkManagementHistory} />,
|
||||
);
|
||||
|
||||
const dataTableCall = DataTable.mock.calls[0][0];
|
||||
expect(dataTableCall.hasFixedColumnWidths).toBe(true);
|
||||
expect(dataTableCall.columns).toBe(bulkManagementColumns);
|
||||
expect(dataTableCall.className).toBe('table-striped');
|
||||
expect(dataTableCall.itemCount).toBe(mockBulkManagementHistory.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapStateToProps', () => {
|
||||
const mockState = { test: 'state' };
|
||||
const mockHistoryEntries = [
|
||||
{ originalFilename: 'file1.csv', user: 'user1' },
|
||||
{ originalFilename: 'file2.csv', user: 'user2' },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
selectors.grades.bulkManagementHistoryEntries.mockReturnValue(
|
||||
mockHistoryEntries,
|
||||
);
|
||||
});
|
||||
|
||||
it('maps bulkManagementHistory from selector', () => {
|
||||
const result = mapStateToProps(mockState);
|
||||
|
||||
expect(
|
||||
selectors.grades.bulkManagementHistoryEntries,
|
||||
).toHaveBeenCalledWith(mockState);
|
||||
expect(result.bulkManagementHistory).toBe(mockHistoryEntries);
|
||||
});
|
||||
});
|
||||
});
|
||||
36
src/components/BulkManagementHistoryView/ResultsSummary.jsx
Normal file
36
src/components/BulkManagementHistoryView/ResultsSummary.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 { Hyperlink, Icon } from '@openedx/paragon';
|
||||
import { Download } from '@openedx/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
|
||||
destination={lms.urls.bulkGradesUrlByRow(rowId)}
|
||||
target="_blank"
|
||||
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,35 @@
|
||||
import React from 'react';
|
||||
|
||||
import lms from 'data/services/lms';
|
||||
import { renderWithIntl, screen } from '../../testUtilsExtra';
|
||||
import ResultsSummary from './ResultsSummary';
|
||||
|
||||
jest.mock('data/services/lms', () => ({
|
||||
urls: {
|
||||
bulkGradesUrlByRow: jest.fn((rowId) => (`www.edx.org/${rowId}`)),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('ResultsSummary component', () => {
|
||||
const props = {
|
||||
rowId: 42,
|
||||
text: 'texty',
|
||||
};
|
||||
let link;
|
||||
beforeEach(() => {
|
||||
renderWithIntl(<ResultsSummary {...props} />);
|
||||
link = screen.getByRole('link', { name: props.text });
|
||||
});
|
||||
test('Hyperlink has target="_blank" and rel="noopener noreferrer"', () => {
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
test('Hyperlink has href to bulkGradesUrl', () => {
|
||||
expect(link).toHaveAttribute('href', lms.urls.bulkGradesUrlByRow(props.rowId));
|
||||
});
|
||||
test('displays Download Icon and text', () => {
|
||||
expect(link).toHaveTextContent(props.text);
|
||||
const icon = screen.getByRole('img', { hidden: true });
|
||||
expect(icon).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
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;
|
||||
25
src/components/BulkManagementHistoryView/index.test.jsx
Normal file
25
src/components/BulkManagementHistoryView/index.test.jsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { render, initializeMocks, screen } from 'testUtilsExtra';
|
||||
|
||||
import { BulkManagementHistoryView } from '.';
|
||||
import messages from './messages';
|
||||
|
||||
jest.mock('./BulkManagementAlerts', () => jest.fn(() => <div>BulkManagementAlerts</div>));
|
||||
jest.mock('./HistoryTable', () => jest.fn(() => <div>HistoryTable</div>));
|
||||
|
||||
initializeMocks();
|
||||
|
||||
describe('BulkManagementHistoryView', () => {
|
||||
describe('component', () => {
|
||||
beforeEach(() => {
|
||||
render(<BulkManagementHistoryView />);
|
||||
});
|
||||
describe('render alerts and heading', () => {
|
||||
it('heading - h4 loaded from messages', () => {
|
||||
expect(screen.getByText(messages.heading.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.getByText(messages.helpText.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.getByText('BulkManagementAlerts')).toBeInTheDocument();
|
||||
expect(screen.getByText('HistoryTable')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
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;
|
||||
@@ -1,48 +0,0 @@
|
||||
$drawer-width: 350px;
|
||||
|
||||
.drawer-contents {
|
||||
overflow-x: auto;
|
||||
transition: margin 300ms cubic-bezier(0.4,0,0.2,1);
|
||||
margin-left: 0;
|
||||
.drawer.open + & {
|
||||
margin-left: $drawer-width;
|
||||
}
|
||||
&.opened {
|
||||
width: calc(100vw - #{$drawer-width});
|
||||
}
|
||||
}
|
||||
|
||||
.drawer-contents {
|
||||
overflow-x: auto;
|
||||
transition: margin 300ms cubic-bezier(0.4,0,0.2,1);
|
||||
margin-left: 0;
|
||||
.drawer.open + & {
|
||||
margin-left: $drawer-width;
|
||||
}
|
||||
&.opened {
|
||||
width: calc(100vw - #{$drawer-width});
|
||||
}
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.drawer-container .collapsible {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.drawer {
|
||||
height: 100%;
|
||||
width: $drawer-width;
|
||||
position: absolute;
|
||||
transform: translateX(-$drawer-width);
|
||||
flex-direction: column;
|
||||
transition: transform 300ms cubic-bezier(0.4,0,0.2,1);
|
||||
&.open {
|
||||
transform: translateX(0%);
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { Button } from '@edx/paragon';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faTimes } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
export default class Drawer extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
open: props.initiallyOpen,
|
||||
transitioning: false,
|
||||
};
|
||||
}
|
||||
|
||||
close = () => {
|
||||
if (this.state.open) {
|
||||
this.toggleOpen();
|
||||
}
|
||||
};
|
||||
|
||||
toggleOpen = () => {
|
||||
this.setState({ transitioning: true });
|
||||
// defer the transition to the next repaint so we can be sure that
|
||||
// opening drawer is visible before it transitions
|
||||
// (the start state of the opening animation doesn't work if the element starts hidden)
|
||||
this.deferToNextRepaint(() => this.setState(prevState => ({ open: !prevState.open })));
|
||||
};
|
||||
|
||||
handleSlideDone = (e) => {
|
||||
if (e.currentTarget === e.target) {
|
||||
this.setState({ transitioning: false });
|
||||
}
|
||||
};
|
||||
|
||||
deferToNextRepaint(callback) {
|
||||
window.requestAnimationFrame(() => window.setTimeout(callback, 0));
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="d-flex drawer-container">
|
||||
<aside
|
||||
className={classNames(
|
||||
'drawer',
|
||||
{
|
||||
open: this.state.open,
|
||||
'd-none': !this.state.transitioning && !this.state.open,
|
||||
},
|
||||
)}
|
||||
onTransitionEnd={this.handleSlideDone}
|
||||
>
|
||||
<div className="drawer-header">
|
||||
<h2>{this.props.title}</h2>
|
||||
<Button
|
||||
className="p-1"
|
||||
onClick={this.close}
|
||||
aria-label="Close Filters"
|
||||
>
|
||||
<FontAwesomeIcon icon={faTimes} />
|
||||
</Button>
|
||||
</div>
|
||||
{this.props.children}
|
||||
</aside>
|
||||
<div
|
||||
className={classNames(
|
||||
'drawer-contents',
|
||||
'position-relative',
|
||||
!this.state.drawerTransitioning && this.state.drawerOpen && 'opened',
|
||||
)}
|
||||
>
|
||||
{this.props.mainContent(this.toggleOpen)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Drawer.propTypes = {
|
||||
initiallyOpen: PropTypes.bool.isRequired,
|
||||
children: PropTypes.node.isRequired,
|
||||
mainContent: PropTypes.func.isRequired,
|
||||
title: PropTypes.node.isRequired,
|
||||
};
|
||||
35
src/components/EdxHeader/EdxHeader.test.jsx
Normal file
35
src/components/EdxHeader/EdxHeader.test.jsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import Header from '.';
|
||||
|
||||
jest.mock('@edx/frontend-platform', () => ({
|
||||
...jest.requireActual('@edx/frontend-platform'),
|
||||
getConfig: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('Header', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('has edx link with logo url', () => {
|
||||
const url = 'www.ourLogo.url';
|
||||
const baseUrl = 'www.lms.url';
|
||||
getConfig.mockReturnValue({ LOGO_URL: url, LMS_BASE_URL: baseUrl });
|
||||
|
||||
render(
|
||||
<IntlProvider messages={{}} locale="en">
|
||||
<Header />
|
||||
</IntlProvider>,
|
||||
);
|
||||
|
||||
const link = screen.getByRole('link');
|
||||
const logo = screen.getByAltText('edX logo');
|
||||
|
||||
expect(link).toHaveAttribute('href', `${baseUrl}/dashboard`);
|
||||
expect(logo).toHaveAttribute('src', url);
|
||||
});
|
||||
});
|
||||
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 '@openedx/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;
|
||||
@@ -1,167 +0,0 @@
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import initialFilters from '../../data/constants/filters';
|
||||
|
||||
function FilterBadge({ name, value, onClick }) {
|
||||
return (
|
||||
<div>
|
||||
<span className="badge badge-info">
|
||||
<span>{`${name}: ${value}`}</span>
|
||||
<button type="button" className="btn-info" aria-label="Close" onClick={onClick}>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</span>
|
||||
<br />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RangeFilterBadge({
|
||||
displayName,
|
||||
filterName1,
|
||||
filterValue1,
|
||||
filterName2,
|
||||
filterValue2,
|
||||
handleBadgeClose,
|
||||
}) {
|
||||
return ((filterValue1 !== initialFilters[filterName1])
|
||||
|| (filterValue2 !== initialFilters[filterName2]))
|
||||
&& (
|
||||
<FilterBadge
|
||||
name={displayName}
|
||||
value={`${filterValue1} - ${filterValue2}`}
|
||||
onClick={handleBadgeClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
RangeFilterBadge.propTypes = {
|
||||
displayName: PropTypes.string.isRequired,
|
||||
filterName1: PropTypes.string.isRequired,
|
||||
filterValue1: PropTypes.string.isRequired,
|
||||
filterName2: PropTypes.string.isRequired,
|
||||
filterValue2: PropTypes.string.isRequired,
|
||||
handleBadgeClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
function SingleValueFilterBadge({
|
||||
displayName, filterName, filterValue, handleBadgeClose,
|
||||
}) {
|
||||
return (filterValue !== initialFilters[filterName])
|
||||
&& (
|
||||
<FilterBadge
|
||||
name={displayName}
|
||||
value={filterValue}
|
||||
onClick={handleBadgeClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
SingleValueFilterBadge.propTypes = {
|
||||
displayName: PropTypes.string.isRequired,
|
||||
filterName: PropTypes.string.isRequired,
|
||||
filterValue: PropTypes.string.isRequired,
|
||||
handleBadgeClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
function FilterBadges({
|
||||
assignment,
|
||||
assignmentType,
|
||||
track,
|
||||
cohort,
|
||||
assignmentGradeMin,
|
||||
assignmentGradeMax,
|
||||
courseGradeMin,
|
||||
courseGradeMax,
|
||||
handleFilterBadgeClose,
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<SingleValueFilterBadge
|
||||
displayName="Assignment Type"
|
||||
filterName="assignmentType"
|
||||
filterValue={assignmentType}
|
||||
handleBadgeClose={handleFilterBadgeClose(['assignmentType'])}
|
||||
/>
|
||||
<SingleValueFilterBadge
|
||||
displayName="Assignment"
|
||||
filterName="assignment"
|
||||
filterValue={assignment}
|
||||
handleBadgeClose={handleFilterBadgeClose(['assignment', 'assignmentGradeMax', 'assignmentGradeMin'])}
|
||||
/>
|
||||
<RangeFilterBadge
|
||||
displayName="Assignment Grade"
|
||||
filterName1="assignmentGradeMin"
|
||||
filterValue1={assignmentGradeMin}
|
||||
filterName2="assignmentGradeMax"
|
||||
filterValue2={assignmentGradeMax}
|
||||
handleBadgeClose={handleFilterBadgeClose(['assignmentGradeMin', 'assignmentGradeMax'])}
|
||||
/>
|
||||
<RangeFilterBadge
|
||||
displayName="Course Grade"
|
||||
filterName1="courseGradeMin"
|
||||
filterValue1={courseGradeMin}
|
||||
filterName2="courseGradeMax"
|
||||
filterValue2={courseGradeMax}
|
||||
handleBadgeClose={handleFilterBadgeClose(['courseGradeMin', 'courseGradeMax'])}
|
||||
/>
|
||||
<SingleValueFilterBadge
|
||||
displayName="Track"
|
||||
filterName="track"
|
||||
filterValue={track}
|
||||
handleBadgeClose={handleFilterBadgeClose(['track'])}
|
||||
/>
|
||||
<SingleValueFilterBadge
|
||||
displayName="Cohort"
|
||||
filterName="track"
|
||||
filterValue={cohort}
|
||||
handleBadgeClose={handleFilterBadgeClose(['cohort'])}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const mapStateToProps = state => (
|
||||
{
|
||||
assignment: (state.filters.assignment || {}).label,
|
||||
assignmentType: state.filters.assignmentType,
|
||||
track: state.filters.track,
|
||||
cohort: state.filters.cohort,
|
||||
assignmentGradeMin: state.filters.assignmentGradeMin,
|
||||
assignmentGradeMax: state.filters.assignmentGradeMax,
|
||||
courseGradeMin: state.filters.courseGradeMin,
|
||||
courseGradeMax: state.filters.courseGradeMax,
|
||||
}
|
||||
);
|
||||
|
||||
const ConnectedFilterBadges = connect(mapStateToProps)(FilterBadges);
|
||||
export default ConnectedFilterBadges;
|
||||
|
||||
FilterBadge.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
value: PropTypes.string.isRequired,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
FilterBadges.defaultProps = {
|
||||
assignment: initialFilters.assignmentType,
|
||||
assignmentType: initialFilters.assignmentType,
|
||||
track: initialFilters.track,
|
||||
cohort: initialFilters.cohort,
|
||||
assignmentGradeMin: initialFilters.assignmentGradeMin,
|
||||
assignmentGradeMax: initialFilters.assignmentGradeMax,
|
||||
courseGradeMin: initialFilters.courseGradeMin,
|
||||
courseGradeMax: initialFilters.courseGradeMax,
|
||||
};
|
||||
|
||||
FilterBadges.propTypes = {
|
||||
assignment: PropTypes.string,
|
||||
assignmentType: PropTypes.string,
|
||||
track: PropTypes.string,
|
||||
cohort: PropTypes.string,
|
||||
assignmentGradeMin: PropTypes.string,
|
||||
assignmentGradeMax: PropTypes.string,
|
||||
courseGradeMin: PropTypes.string,
|
||||
courseGradeMax: PropTypes.string,
|
||||
handleFilterBadgeClose: PropTypes.func.isRequired,
|
||||
};
|
||||
@@ -1,200 +0,0 @@
|
||||
/* eslint-disable react/sort-comp, react/button-has-type */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Collapsible,
|
||||
InputSelect,
|
||||
InputText,
|
||||
} from '@edx/paragon';
|
||||
import { selectableAssignmentLabels } from '../../data/selectors/filters';
|
||||
import {
|
||||
filterAssignmentType,
|
||||
fetchGrades,
|
||||
updateGradesIfAssignmentGradeFiltersSet,
|
||||
} from '../../data/actions/grades';
|
||||
import {
|
||||
updateAssignmentFilter,
|
||||
updateAssignmentLimits,
|
||||
} from '../../data/actions/filters';
|
||||
|
||||
export class Assignments extends React.Component {
|
||||
getAssignmentFilterOptions = () => [
|
||||
{ label: 'All', value: '' },
|
||||
...this.props.assignmentFilterOptions.map(({ label, subsectionLabel }) => ({
|
||||
label: `${label}: ${subsectionLabel}`,
|
||||
value: label,
|
||||
})),
|
||||
];
|
||||
|
||||
handleAssignmentFilterChange = (assignment) => {
|
||||
const selectedFilterOption = this.props.assignmentFilterOptions.find(assig => assig.label === assignment);
|
||||
const { type, id } = selectedFilterOption || {};
|
||||
const typedValue = { label: assignment, type, id };
|
||||
this.props.updateAssignmentFilter(typedValue);
|
||||
this.updateQueryParams({ assignment: id });
|
||||
this.props.updateGradesIfAssignmentGradeFiltersSet(
|
||||
this.props.courseId,
|
||||
this.props.selectedCohort,
|
||||
this.props.selectedTrack,
|
||||
this.props.selectedAssignmentType,
|
||||
);
|
||||
};
|
||||
|
||||
handleSubmitAssignmentGrade = (event) => {
|
||||
event.preventDefault();
|
||||
const {
|
||||
assignmentGradeMin,
|
||||
assignmentGradeMax,
|
||||
} = this.props;
|
||||
|
||||
this.props.updateAssignmentLimits(assignmentGradeMin, assignmentGradeMax);
|
||||
this.props.getUserGrades(
|
||||
this.props.courseId,
|
||||
this.props.selectedCohort,
|
||||
this.props.selectedTrack,
|
||||
this.props.selectedAssignmentType,
|
||||
);
|
||||
this.props.updateQueryParams({ assignmentGradeMin, assignmentGradeMax });
|
||||
};
|
||||
|
||||
mapAssignmentTypeEntries = (entries) => {
|
||||
const mapped = [
|
||||
{ id: 0, label: 'All', value: '' },
|
||||
...entries.map(entry => ({ id: entry, label: entry })),
|
||||
];
|
||||
return mapped;
|
||||
};
|
||||
|
||||
updateAssignmentTypes = (assignmentType) => {
|
||||
this.props.filterAssignmentType(assignmentType);
|
||||
this.updateQueryParams({ assignmentType });
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Collapsible title="Assignments" open className="filter-group mb-3">
|
||||
<div>
|
||||
<div className="student-filters">
|
||||
<span className="label">
|
||||
Assignment Types:
|
||||
</span>
|
||||
<InputSelect
|
||||
label="Assignment Types"
|
||||
name="assignment-types"
|
||||
aria-label="Assignment Types"
|
||||
value={this.props.selectedAssignmentType}
|
||||
options={this.mapAssignmentTypeEntries(this.props.assignmentTypes)}
|
||||
onChange={this.updateAssignmentTypes}
|
||||
disabled={this.props.assignmentFilterOptions.length === 0}
|
||||
/>
|
||||
</div>
|
||||
<div className="student-filters">
|
||||
<span className="label">
|
||||
Assignment:
|
||||
</span>
|
||||
<InputSelect
|
||||
label="Assignment"
|
||||
name="assignment"
|
||||
aria-label="Assignment"
|
||||
value={this.props.selectedAssignment}
|
||||
options={this.getAssignmentFilterOptions()}
|
||||
onChange={this.handleAssignmentFilterChange}
|
||||
disabled={this.props.assignmentFilterOptions.length === 0}
|
||||
/>
|
||||
</div>
|
||||
<p>Grade Range (0% - 100%)</p>
|
||||
<form className="d-fnlex justify-content-between align-items-center" onSubmit={this.handleSubmitAssignmentGrade}>
|
||||
<InputText
|
||||
label="Min Grade"
|
||||
name="assignmentGradeMin"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={this.props.assignmentGradeMin}
|
||||
disabled={!this.props.selectedAssignment}
|
||||
onChange={this.props.setAssignmentGradeMin}
|
||||
/>
|
||||
<span className="input-percent-label">%</span>
|
||||
<InputText
|
||||
label="Max Grade"
|
||||
name="assignmentGradeMax"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={this.props.assignmentGradeMax}
|
||||
disabled={!this.props.selectedAssignment}
|
||||
onChange={this.props.setAssignmentGradeMax}
|
||||
/>
|
||||
<span className="input-percent-label">%</span>
|
||||
<Button
|
||||
type="submit"
|
||||
className="btn-outline-secondary"
|
||||
name="assignmentGradeMinMax"
|
||||
disabled={!this.props.selectedAssignment}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Assignments.defaultProps = {
|
||||
assignmentTypes: [],
|
||||
assignmentFilterOptions: [],
|
||||
selectedAssignment: '',
|
||||
selectedAssignmentType: '',
|
||||
selectedCohort: null,
|
||||
selectedTrack: null,
|
||||
};
|
||||
|
||||
Assignments.propTypes = {
|
||||
assignmentGradeMin: PropTypes.string.isRequired,
|
||||
assignmentGradeMax: PropTypes.string.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
setAssignmentGradeMin: PropTypes.func.isRequired,
|
||||
setAssignmentGradeMax: PropTypes.func.isRequired,
|
||||
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,
|
||||
getUserGrades: PropTypes.func.isRequired,
|
||||
selectedAssignmentType: PropTypes.string,
|
||||
selectedAssignment: PropTypes.string,
|
||||
selectedCohort: PropTypes.string,
|
||||
selectedTrack: PropTypes.string,
|
||||
updateGradesIfAssignmentGradeFiltersSet: PropTypes.func.isRequired,
|
||||
updateAssignmentFilter: PropTypes.func.isRequired,
|
||||
updateAssignmentLimits: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => ({
|
||||
assignmentTypes: state.assignmentTypes.results,
|
||||
assignmentFilterOptions: selectableAssignmentLabels(state),
|
||||
selectedAssignment: (state.filters.assignment || {}).label,
|
||||
selectedAssignmentTypes: state.filters.assignmentType,
|
||||
selectedCohort: state.filters.cohort,
|
||||
selectedTrack: state.filters.track,
|
||||
});
|
||||
|
||||
export const mapDispatchToProps = {
|
||||
getUserGrades: fetchGrades,
|
||||
filterAssignmentType,
|
||||
updateAssignmentFilter,
|
||||
updateAssignmentLimits,
|
||||
updateGradesIfAssignmentGradeFiltersSet,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(Assignments);
|
||||
@@ -1,200 +0,0 @@
|
||||
/* eslint-disable react/sort-comp, react/button-has-type */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import {
|
||||
Button,
|
||||
StatusAlert,
|
||||
Table,
|
||||
Tab,
|
||||
} from '@edx/paragon';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faDownload } from '@fortawesome/free-solid-svg-icons';
|
||||
import { configuration } from '../../config';
|
||||
|
||||
import { submitFileUploadFormData } from '../../data/actions/grades';
|
||||
import { getBulkManagementHistory } from '../../data/selectors/grades';
|
||||
|
||||
export class BulkManagement extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.fileFormRef = React.createRef();
|
||||
this.fileInputRef = React.createRef();
|
||||
}
|
||||
|
||||
formatHistoryRow = (row) => {
|
||||
const {
|
||||
summaryOfRowsProcessed: {
|
||||
total,
|
||||
successfullyProcessed,
|
||||
failed,
|
||||
skipped,
|
||||
},
|
||||
unique_id: courseId,
|
||||
originalFilename,
|
||||
id,
|
||||
user: username,
|
||||
...rest
|
||||
} = row;
|
||||
const resultsText = [
|
||||
`${total} Students: ${successfullyProcessed} processed`,
|
||||
...(skipped > 0 ? [`${skipped} skipped`] : []),
|
||||
...(failed > 0 ? [`${failed} failed`] : []),
|
||||
].join(', ');
|
||||
const resultsSummary = (
|
||||
<a
|
||||
href={`${configuration.LMS_BASE_URL}/api/bulk_grades/course/${courseId}/?error_id=${id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<FontAwesomeIcon icon={faDownload} />
|
||||
{resultsText}
|
||||
</a>
|
||||
);
|
||||
const createWrappedCell = (text) => (<span className="wrap-text-in-cell">{text}</span>);
|
||||
const filename = createWrappedCell(originalFilename);
|
||||
const user = createWrappedCell(username);
|
||||
return {
|
||||
resultsSummary,
|
||||
filename,
|
||||
user,
|
||||
...rest,
|
||||
};
|
||||
};
|
||||
|
||||
handleClickImportGrades = () => {
|
||||
const fileInput = this.fileInputRef.current;
|
||||
if (fileInput) {
|
||||
fileInput.click();
|
||||
}
|
||||
};
|
||||
|
||||
handleFileInputChange = (event) => {
|
||||
const fileInput = event.target;
|
||||
const file = fileInput.files[0];
|
||||
const form = this.fileFormRef.current;
|
||||
if (file && form) {
|
||||
const formData = new FormData(form);
|
||||
this.props.submitFileUploadFormData(this.props.courseId, formData).then(() => {
|
||||
fileInput.value = null;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Tab eventKey="bulk_management" title="Bulk Management">
|
||||
<h4>Use this feature by downloading a CSV for bulk management,
|
||||
overriding grades locally, and coming back here to upload.
|
||||
</h4>
|
||||
<form ref={this.fileFormRef} action={this.props.gradeExportUrl} method="post">
|
||||
<StatusAlert
|
||||
alertType="danger"
|
||||
dialog={this.props.bulkImportError}
|
||||
open={this.props.bulkImportError}
|
||||
dismissible={false}
|
||||
/>
|
||||
<StatusAlert
|
||||
alertType="success"
|
||||
dialog="CSV processing. File uploads may take several minutes to complete"
|
||||
open={this.props.uploadSuccess}
|
||||
dismissible={false}
|
||||
/>
|
||||
<input
|
||||
className="d-none"
|
||||
type="file"
|
||||
name="csv"
|
||||
label="Upload Grade CSV"
|
||||
onChange={this.handleFileInputChange}
|
||||
ref={this.fileInputRef}
|
||||
/>
|
||||
</form>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={this.handleClickImportGrades}
|
||||
>
|
||||
Import Grades
|
||||
</Button>
|
||||
<p>
|
||||
Results appear in the table below.<br />
|
||||
Grade processing may take a few seconds.
|
||||
</p>
|
||||
<Table
|
||||
data={this.props.bulkManagementHistory.map(this.formatHistoryRow)}
|
||||
hasFixedColumnWidths
|
||||
columns={[
|
||||
{
|
||||
key: 'filename',
|
||||
label: 'Gradebook',
|
||||
columnSortable: false,
|
||||
width: 'col-5',
|
||||
},
|
||||
{
|
||||
key: 'resultsSummary',
|
||||
label: 'Download Summary',
|
||||
columnSortable: false,
|
||||
width: 'col',
|
||||
},
|
||||
{
|
||||
key: 'user',
|
||||
label: 'Who',
|
||||
columnSortable: false,
|
||||
width: 'col-1',
|
||||
},
|
||||
{
|
||||
key: 'timeUploaded',
|
||||
label: 'When',
|
||||
columnSortable: false,
|
||||
width: 'col',
|
||||
},
|
||||
]}
|
||||
className="table-striped"
|
||||
/>
|
||||
</Tab>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
BulkManagement.defaultProps = {
|
||||
bulkImportError: '',
|
||||
bulkManagementHistory: [],
|
||||
courseId: '',
|
||||
uploadSuccess: false,
|
||||
};
|
||||
|
||||
BulkManagement.propTypes = {
|
||||
courseId: PropTypes.string,
|
||||
gradeExportUrl: PropTypes.string.isRequired,
|
||||
|
||||
// redux
|
||||
bulkImportError: PropTypes.string,
|
||||
bulkManagementHistory: PropTypes.arrayOf(PropTypes.shape({
|
||||
originalFilename: PropTypes.string.isRequired,
|
||||
user: PropTypes.string.isRequired,
|
||||
timeUploaded: PropTypes.string.isRequired,
|
||||
summaryOfRowsProcessed: PropTypes.shape({
|
||||
total: PropTypes.number.isRequired,
|
||||
successfullyProcessed: PropTypes.number.isRequired,
|
||||
failed: PropTypes.number.isRequired,
|
||||
skipped: PropTypes.number.isRequired,
|
||||
}).isRequired,
|
||||
})),
|
||||
submitFileUploadFormData: PropTypes.func.isRequired,
|
||||
uploadSuccess: PropTypes.bool,
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => ({
|
||||
bulkImportError: state.grades.bulkManagement
|
||||
&& state.grades.bulkManagement.errorMessages
|
||||
? `Errors while processing: ${state.grades.bulkManagement.errorMessages.join(', ')}`
|
||||
: '',
|
||||
bulkManagementHistory: getBulkManagementHistory(state),
|
||||
uploadSuccess: !!(state.grades.bulkManagement && state.grades.bulkManagement.uploadSuccess),
|
||||
});
|
||||
|
||||
export const mapDispatchToProps = {
|
||||
submitFileUploadFormData,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(BulkManagement);
|
||||
@@ -1,90 +0,0 @@
|
||||
/* eslint-disable react/sort-comp, react/button-has-type */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { StatefulButton } from '@edx/paragon';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faDownload, faSpinner } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
import {
|
||||
downloadBulkGradesReport,
|
||||
downloadInterventionReport,
|
||||
} from '../../data/actions/grades';
|
||||
|
||||
export class BulkManagementControls extends React.Component {
|
||||
handleClickDownloadInterventions = () => {
|
||||
this.props.downloadInterventionReport(this.props.courseId);
|
||||
window.location = this.props.interventionExportUrl;
|
||||
};
|
||||
|
||||
// At present, we don't store label and value in google analytics. By setting the label
|
||||
// property of the below events, I want to verify that we can set the label of google anlatyics
|
||||
// The following properties of a google analytics event are:
|
||||
// category (used), name(used), lavel(not used), value(not used)
|
||||
handleClickExportGrades = () => {
|
||||
this.props.downloadBulkGradesReport(this.props.courseId);
|
||||
window.location = this.props.gradeExportUrl;
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<StatefulButton
|
||||
variant="outline-primary"
|
||||
onClick={this.handleClickExportGrades}
|
||||
state={this.props.showSpinner ? 'pending' : 'default'}
|
||||
labels={{
|
||||
default: 'Bulk Management',
|
||||
pending: 'Bulk Management',
|
||||
}}
|
||||
icons={{
|
||||
default: <FontAwesomeIcon className="mr-2" icon={faDownload} />,
|
||||
pending: <FontAwesomeIcon className="fa-spin mr-2" icon={faSpinner} />,
|
||||
}}
|
||||
disabledStates={['pending']}
|
||||
/>
|
||||
<StatefulButton
|
||||
variant="outline-primary"
|
||||
onClick={this.handleClickDownloadInterventions}
|
||||
state={this.props.showSpinner ? 'pending' : 'default'}
|
||||
className="ml-2"
|
||||
labels={{
|
||||
default: 'Interventions*',
|
||||
pending: 'Interventions*',
|
||||
}}
|
||||
icons={{
|
||||
default: <FontAwesomeIcon className="mr-2" icon={faDownload} />,
|
||||
pending: <FontAwesomeIcon className="fa-spin mr-2" icon={faSpinner} />,
|
||||
}}
|
||||
disabledStates={['pending']}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
BulkManagementControls.defaultProps = {
|
||||
courseId: '',
|
||||
showSpinner: false,
|
||||
};
|
||||
|
||||
BulkManagementControls.propTypes = {
|
||||
courseId: PropTypes.string,
|
||||
gradeExportUrl: PropTypes.string.isRequired,
|
||||
interventionExportUrl: PropTypes.string.isRequired,
|
||||
showSpinner: PropTypes.bool,
|
||||
|
||||
// redux
|
||||
downloadBulkGradesReport: PropTypes.func.isRequired,
|
||||
downloadInterventionReport: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export const mapStateToProps = () => ({ });
|
||||
|
||||
export const mapDispatchToProps = {
|
||||
downloadBulkGradesReport,
|
||||
downloadInterventionReport,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(BulkManagementControls);
|
||||
@@ -1,203 +0,0 @@
|
||||
/* eslint-disable react/sort-comp, react/button-has-type */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
StatusAlert,
|
||||
Table,
|
||||
} from '@edx/paragon';
|
||||
|
||||
import {
|
||||
doneViewingAssignment,
|
||||
updateGrades,
|
||||
} from '../../data/actions/grades';
|
||||
|
||||
const GRADE_OVERRIDE_HISTORY_COLUMNS = [{ label: 'Date', key: 'date' }, { label: 'Grader', key: 'grader' },
|
||||
{ label: 'Reason', key: 'reason' },
|
||||
{ label: 'Adjusted grade', key: 'adjustedGrade' }];
|
||||
|
||||
export class EditModal extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.overrideReasonInput = React.createRef();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.overrideReasonInput.current.focus();
|
||||
}
|
||||
|
||||
handleAdjustedGradeClick = () => {
|
||||
this.props.updateGrades(
|
||||
this.props.courseId, [
|
||||
{
|
||||
user_id: this.props.updateUserId,
|
||||
usage_id: this.props.updateModuleId,
|
||||
grade: {
|
||||
earned_graded_override: this.props.adjustedGradeValue,
|
||||
comment: this.props.reasonForChange,
|
||||
},
|
||||
},
|
||||
],
|
||||
this.props.filterValue,
|
||||
this.props.selectedCohort,
|
||||
this.props.selectedTrack,
|
||||
);
|
||||
|
||||
this.closeAssignmentModal();
|
||||
}
|
||||
|
||||
closeAssignmentModal = () => {
|
||||
this.props.doneViewingAssignment();
|
||||
this.props.setGradebookState({
|
||||
adjustedGradePossible: '',
|
||||
adjustedGradeValue: '',
|
||||
modalOpen: false,
|
||||
reasonForChange: '',
|
||||
updateModuleId: null,
|
||||
updateUserId: null,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Modal
|
||||
open={this.props.open}
|
||||
title="Edit Grades"
|
||||
closeText="Cancel"
|
||||
body={(
|
||||
<div>
|
||||
<div>
|
||||
<div className="grade-history-header grade-history-assignment">Assignment: </div>
|
||||
<div>{this.props.assignmentName}</div>
|
||||
<div className="grade-history-header grade-history-student">Student: </div>
|
||||
<div>{this.props.updateUserName}</div>
|
||||
<div className="grade-history-header grade-history-original-grade">Original Grade: </div>
|
||||
<div>{this.props.gradeOriginalEarnedGraded}</div>
|
||||
<div className="grade-history-header grade-history-current-grade">Current Grade: </div>
|
||||
<div>{this.props.gradeOverrideCurrentEarnedGradedOverride}</div>
|
||||
</div>
|
||||
<StatusAlert
|
||||
alertType="danger"
|
||||
dialog={this.props.gradeOverrideHistoryError}
|
||||
open={!!this.props.gradeOverrideHistoryError}
|
||||
dismissible={false}
|
||||
/>
|
||||
{!this.props.gradeOverrideHistoryError && (
|
||||
<Table
|
||||
columns={GRADE_OVERRIDE_HISTORY_COLUMNS}
|
||||
data={[...this.props.gradeOverrides, {
|
||||
date: this.props.todaysDate,
|
||||
reason: (<input
|
||||
type="text"
|
||||
name="reasonForChange"
|
||||
value={this.props.reasonForChange}
|
||||
onChange={this.props.setReasonForChange}
|
||||
ref={this.overrideReasonInput}
|
||||
/>),
|
||||
adjustedGrade: (
|
||||
<span>
|
||||
<input
|
||||
type="text"
|
||||
name="adjustedGradeValue"
|
||||
value={this.props.adjustedGradeValue}
|
||||
onChange={this.props.setAdjustedGradeValue}
|
||||
/>
|
||||
{(this.props.adjustedGradePossible || this.props.gradeOriginalPossibleGraded) && ' / '}
|
||||
{this.props.adjustedGradePossible || this.props.gradeOriginalPossibleGraded}
|
||||
</span>),
|
||||
}]}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div>Showing most recent actions (max 5). To see more, please contact
|
||||
support.
|
||||
</div>
|
||||
<div>Note: Once you save, your changes will be visible to students.</div>
|
||||
</div>
|
||||
)}
|
||||
buttons={[
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={this.handleAdjustedGradeClick}
|
||||
>
|
||||
Save Grade
|
||||
</Button>,
|
||||
]}
|
||||
onClose={this.closeAssignmentModal}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
EditModal.defaultProps = {
|
||||
adjustedGradeValue: null,
|
||||
courseId: '',
|
||||
gradeOverrideCurrentEarnedGradedOverride: null,
|
||||
gradeOverrideHistoryError: '',
|
||||
gradeOverrides: [],
|
||||
gradeOriginalEarnedGraded: null,
|
||||
gradeOriginalPossibleGraded: null,
|
||||
selectedCohort: null,
|
||||
selectedTrack: null,
|
||||
updateModuleId: '',
|
||||
updateUserId: '',
|
||||
updateUserName: '',
|
||||
};
|
||||
|
||||
EditModal.propTypes = {
|
||||
courseId: PropTypes.string,
|
||||
|
||||
// Gradebook State
|
||||
adjustedGradePossible: PropTypes.string.isRequired,
|
||||
// should pick one?
|
||||
adjustedGradeValue: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
|
||||
assignmentName: PropTypes.string.isRequired,
|
||||
filterValue: PropTypes.string.isRequired,
|
||||
open: PropTypes.bool.isRequired,
|
||||
reasonForChange: PropTypes.string.isRequired,
|
||||
todaysDate: PropTypes.string.isRequired,
|
||||
updateModuleId: PropTypes.string,
|
||||
updateUserId: PropTypes.number,
|
||||
updateUserName: PropTypes.string,
|
||||
|
||||
// Gradebook State Setters
|
||||
setAdjustedGradeValue: PropTypes.func.isRequired,
|
||||
setGradebookState: PropTypes.func.isRequired,
|
||||
setReasonForChange: PropTypes.func.isRequired,
|
||||
|
||||
// redux
|
||||
doneViewingAssignment: PropTypes.func.isRequired,
|
||||
gradeOverrideCurrentEarnedGradedOverride: PropTypes.number,
|
||||
gradeOverrideHistoryError: PropTypes.string,
|
||||
gradeOverrides: PropTypes.arrayOf(PropTypes.shape({
|
||||
date: PropTypes.string,
|
||||
grader: PropTypes.string,
|
||||
reason: PropTypes.string,
|
||||
adjustedGrade: PropTypes.number,
|
||||
})),
|
||||
gradeOriginalEarnedGraded: PropTypes.number,
|
||||
gradeOriginalPossibleGraded: PropTypes.number,
|
||||
selectedCohort: PropTypes.string,
|
||||
selectedTrack: PropTypes.string,
|
||||
updateGrades: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => ({
|
||||
gradeOverrides: state.grades.gradeOverrideHistoryResults,
|
||||
gradeOverrideCurrentEarnedGradedOverride: state.grades.gradeOverrideCurrentEarnedGradedOverride,
|
||||
gradeOverrideHistoryError: state.grades.gradeOverrideHistoryError,
|
||||
gradeOriginalEarnedGraded: state.grades.gradeOriginalEarnedGraded,
|
||||
grdaeOriginalPossibleGraded: state.grades.grdaeOriginalPossibleGraded,
|
||||
selectedCohort: state.filters.cohort,
|
||||
selectedTrack: state.filters.track,
|
||||
});
|
||||
|
||||
export const mapDispatchToProps = {
|
||||
doneViewingAssignment,
|
||||
updateGrades,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(EditModal);
|
||||
@@ -1,203 +0,0 @@
|
||||
/* eslint-disable react/sort-comp, react/button-has-type */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { Table } from '@edx/paragon';
|
||||
|
||||
import { formatDateForDisplay } from '../../data/actions/utils';
|
||||
import { getHeadings } from '../../data/selectors/grades';
|
||||
import { fetchGradeOverrideHistory } from '../../data/actions/grades';
|
||||
|
||||
const DECIMAL_PRECISION = 2;
|
||||
|
||||
export class GradebookTable extends React.Component {
|
||||
setNewModalState = (userEntry, subsection) => {
|
||||
this.props.fetchGradeOverrideHistory(
|
||||
subsection.module_id,
|
||||
userEntry.user_id,
|
||||
);
|
||||
|
||||
let adjustedGradePossible = '';
|
||||
if (subsection.attempted) {
|
||||
adjustedGradePossible = subsection.score_possible;
|
||||
}
|
||||
|
||||
this.props.setGradebookState({
|
||||
adjustedGradePossible,
|
||||
adjustedGradeValue: '',
|
||||
assignmentName: `${subsection.subsection_name}`,
|
||||
modalOpen: true,
|
||||
reasonForChange: '',
|
||||
todaysDate: formatDateForDisplay(new Date()),
|
||||
updateModuleId: subsection.module_id,
|
||||
updateUserId: userEntry.user_id,
|
||||
updateUserName: userEntry.username,
|
||||
});
|
||||
}
|
||||
|
||||
getLearnerInformation = entry => (
|
||||
<div>
|
||||
<div>{entry.username}</div>
|
||||
{entry.external_user_key && <div className="student-key">{entry.external_user_key}</div>}
|
||||
</div>
|
||||
)
|
||||
|
||||
roundGrade = percent => parseFloat((percent || 0).toFixed(DECIMAL_PRECISION));
|
||||
|
||||
formatter = {
|
||||
percent: (entries, areGradesFrozen) => entries.map((entry) => {
|
||||
const learnerInformation = this.getLearnerInformation(entry);
|
||||
const results = {
|
||||
Username: (
|
||||
<div><span className="wrap-text-in-cell">{learnerInformation}</span></div>
|
||||
),
|
||||
Email: (
|
||||
<span className="wrap-text-in-cell">{entry.email}</span>
|
||||
),
|
||||
};
|
||||
|
||||
const assignments = entry.section_breakdown
|
||||
.reduce((acc, subsection) => {
|
||||
if (areGradesFrozen) {
|
||||
acc[subsection.label] = `${this.roundGrade(subsection.percent * 100)} %`;
|
||||
} else {
|
||||
acc[subsection.label] = (
|
||||
<button
|
||||
className="btn btn-header link-style grade-button"
|
||||
onClick={() => this.setNewModalState(entry, subsection)}
|
||||
>
|
||||
{this.roundGrade(subsection.percent * 100)}%
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
const totals = { Total: `${this.roundGrade(entry.percent * 100)}%` };
|
||||
return Object.assign(results, assignments, totals);
|
||||
}),
|
||||
|
||||
absolute: (entries, areGradesFrozen) => entries.map((entry) => {
|
||||
const learnerInformation = this.getLearnerInformation(entry);
|
||||
const results = {
|
||||
Username: (
|
||||
<div><span className="wrap-text-in-cell">{learnerInformation}</span></div>
|
||||
),
|
||||
Email: (
|
||||
<span className="wrap-text-in-cell">{entry.email}</span>
|
||||
),
|
||||
};
|
||||
|
||||
const assignments = entry.section_breakdown
|
||||
.reduce((acc, subsection) => {
|
||||
const scoreEarned = this.roundGrade(subsection.score_earned);
|
||||
const scorePossible = this.roundGrade(subsection.score_possible);
|
||||
let label = `${scoreEarned}`;
|
||||
if (subsection.attempted) {
|
||||
label = `${scoreEarned}/${scorePossible}`;
|
||||
}
|
||||
if (areGradesFrozen) {
|
||||
acc[subsection.label] = label;
|
||||
} else {
|
||||
acc[subsection.label] = (
|
||||
<button
|
||||
className="btn btn-header link-style"
|
||||
onClick={() => this.setNewModalState(entry, subsection)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const totals = { Total: `${this.roundGrade(entry.percent * 100)}/100` };
|
||||
return Object.assign(results, assignments, totals);
|
||||
}),
|
||||
};
|
||||
|
||||
formatHeadings = () => {
|
||||
let headings = [...this.props.headings];
|
||||
|
||||
if (headings.length > 0) {
|
||||
const userInformationHeadingLabel = (
|
||||
<div>
|
||||
<div>Username</div>
|
||||
<div className="font-weight-normal student-key">Student Key*</div>
|
||||
</div>
|
||||
);
|
||||
const emailHeadingLabel = 'Email*';
|
||||
|
||||
headings = headings.map(heading => ({
|
||||
label: heading,
|
||||
key: heading,
|
||||
}));
|
||||
|
||||
// replace username heading label to include additional user data
|
||||
headings[0].label = userInformationHeadingLabel;
|
||||
headings[1].label = emailHeadingLabel;
|
||||
}
|
||||
|
||||
return headings;
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="gradebook-container">
|
||||
<div className="gbook">
|
||||
<Table
|
||||
columns={this.formatHeadings()}
|
||||
data={this.formatter[this.props.format](
|
||||
this.props.grades,
|
||||
this.props.areGradesFrozen,
|
||||
)}
|
||||
rowHeaderColumnKey="username"
|
||||
hasFixedColumnWidths
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
GradebookTable.defaultProps = {
|
||||
areGradesFrozen: false,
|
||||
grades: [],
|
||||
};
|
||||
|
||||
GradebookTable.propTypes = {
|
||||
setGradebookState: PropTypes.func.isRequired,
|
||||
// redux
|
||||
areGradesFrozen: PropTypes.bool,
|
||||
format: PropTypes.string.isRequired,
|
||||
grades: PropTypes.arrayOf(PropTypes.shape({
|
||||
percent: PropTypes.number,
|
||||
section_breakdown: PropTypes.arrayOf(PropTypes.shape({
|
||||
attempted: PropTypes.bool,
|
||||
category: PropTypes.string,
|
||||
label: PropTypes.string,
|
||||
module_id: PropTypes.string,
|
||||
percent: PropTypes.number,
|
||||
scoreEarned: PropTypes.number,
|
||||
scorePossible: PropTypes.number,
|
||||
subsection_name: PropTypes.string,
|
||||
})),
|
||||
user_id: PropTypes.number,
|
||||
user_name: PropTypes.string,
|
||||
})),
|
||||
headings: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
fetchGradeOverrideHistory: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => ({
|
||||
areGradesFrozen: state.assignmentTypes.areGradesFrozen,
|
||||
format: state.grades.gradeFormat,
|
||||
grades: state.grades.results,
|
||||
headings: getHeadings(state),
|
||||
});
|
||||
|
||||
export const mapDispatchToProps = {
|
||||
fetchGradeOverrideHistory,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(GradebookTable);
|
||||
@@ -1,171 +0,0 @@
|
||||
.spinner-overlay {
|
||||
position: fixed;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background-color: #999;
|
||||
opacity: 0.5;
|
||||
z-index: 99999;
|
||||
display:flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 200px;
|
||||
}
|
||||
|
||||
.color-black {
|
||||
color: black;
|
||||
}
|
||||
|
||||
.gradebook-content {
|
||||
// note that this width isn't well-abstracted from Drawer code.
|
||||
// if we need to change it we may need to dig into those styles as well
|
||||
width: 100vw;
|
||||
.search-help-text {
|
||||
margin-left: 20px;
|
||||
}
|
||||
h4 {
|
||||
font-weight: bold;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.student-filters{
|
||||
display: flex;
|
||||
.label{
|
||||
padding-top: 30px;
|
||||
}
|
||||
.form-group{
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
.grade-history-header{
|
||||
float: left;
|
||||
}
|
||||
|
||||
.grade-history-assignment{
|
||||
padding-right: 49px;
|
||||
}
|
||||
.grade-history-student{
|
||||
padding-right: 75px;
|
||||
}
|
||||
|
||||
.grade-history-original-grade{
|
||||
padding-right: 25px;
|
||||
}
|
||||
|
||||
.grade-history-current-grade{
|
||||
padding-right: 25px;
|
||||
}
|
||||
.gradebook-container {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
height: 600px;
|
||||
overflow-y: auto;
|
||||
word-break: break-word;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.gbook {
|
||||
width: 100%;
|
||||
|
||||
.grade-button {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.student-key {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
|
||||
.table thead tr {
|
||||
min-height: 60px;
|
||||
&:nth-child(1) {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background-color: white;
|
||||
th {
|
||||
background-color: white;
|
||||
border-bottom: 1px solid $gray_200;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
thead, tbody, tr, td, th {
|
||||
display: block;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.table tr th:first-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.table tr th:first-child,
|
||||
.table tr td:first-child {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 1; // to float over the following children in the side-scrolling case
|
||||
background: white;
|
||||
}
|
||||
|
||||
.table tr {
|
||||
th:nth-child(1),
|
||||
td:nth-child(1),
|
||||
th:nth-child(2),
|
||||
td:nth-child(2) {
|
||||
min-width: 240px;
|
||||
}
|
||||
th, td {
|
||||
min-width: 120px;
|
||||
}
|
||||
}
|
||||
.table tbody th {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.table {
|
||||
overflow-x: hidden;
|
||||
|
||||
height: 100%;
|
||||
|
||||
tbody {
|
||||
overflow-y: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
thead, tbody tr {
|
||||
display: table;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
th {
|
||||
vertical-align: top;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.link-style {
|
||||
color: #0075b4;
|
||||
&:hover, &:focus {
|
||||
color: #004368;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.input-percent-label {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.mb-85 {
|
||||
margin-bottom: 85px;
|
||||
}
|
||||
|
||||
.modal-dialog {
|
||||
max-width: 1000px;
|
||||
}
|
||||
|
||||
.wrap-text-in-cell {
|
||||
overflow-wrap: break-word;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
@@ -1,519 +0,0 @@
|
||||
/* eslint-disable react/sort-comp, react/button-has-type, import/no-named-as-default */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
Button,
|
||||
Collapsible,
|
||||
Icon,
|
||||
InputSelect,
|
||||
InputText,
|
||||
SearchField,
|
||||
StatusAlert,
|
||||
Tab,
|
||||
Tabs,
|
||||
} from '@edx/paragon';
|
||||
import queryString from 'query-string';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faFilter } from '@fortawesome/free-solid-svg-icons';
|
||||
import { configuration } from '../../config';
|
||||
import PageButtons from '../PageButtons';
|
||||
import Drawer from '../Drawer';
|
||||
import initialFilters from '../../data/constants/filters';
|
||||
import ConnectedFilterBadges from '../FilterBadges';
|
||||
|
||||
import Assignments from './Assignments';
|
||||
import BulkManagement from './BulkManagement';
|
||||
import BulkManagementControls from './BulkManagementControls';
|
||||
import EditModal from './EditModal';
|
||||
import GradebookTable from './GradebookTable';
|
||||
|
||||
export default class Gradebook extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
adjustedGradePossible: '',
|
||||
adjustedGradeValue: 0,
|
||||
assignmentGradeMin: '0',
|
||||
assignmentGradeMax: '100',
|
||||
assignmentName: '',
|
||||
courseGradeMin: '0',
|
||||
courseGradeMax: '100',
|
||||
filterValue: '',
|
||||
isMinCourseGradeFilterValid: true,
|
||||
isMaxCourseGradeFilterValid: true,
|
||||
modalOpen: false,
|
||||
reasonForChange: '',
|
||||
todaysDate: '',
|
||||
updateModuleId: null,
|
||||
updateUserId: null,
|
||||
};
|
||||
this.myRef = React.createRef();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const urlQuery = queryString.parse(this.props.location.search);
|
||||
this.props.initializeFilters(urlQuery);
|
||||
this.props.getRoles(this.props.courseId);
|
||||
|
||||
const newStateFields = {};
|
||||
['assignmentGradeMin', 'assignmentGradeMax', 'courseGradeMin', 'courseGradeMax'].forEach((attr) => {
|
||||
if (urlQuery[attr]) {
|
||||
newStateFields[attr] = urlQuery[attr];
|
||||
}
|
||||
});
|
||||
|
||||
this.setState(newStateFields);
|
||||
}
|
||||
|
||||
onChange(e) {
|
||||
this.setState({ [e.target.name]: e.target.value });
|
||||
}
|
||||
|
||||
getActiveTabs = () => {
|
||||
if (this.props.showBulkManagement) {
|
||||
return ['Grades', 'Bulk Management'];
|
||||
}
|
||||
return ['Grades'];
|
||||
};
|
||||
|
||||
getCourseGradeFilterAlertDialog = () => {
|
||||
let dialog = '';
|
||||
|
||||
if (!this.state.isMinCourseGradeFilterValid) {
|
||||
dialog += 'Minimum course grade value must be between 0 and 100. ';
|
||||
}
|
||||
if (!this.state.isMaxCourseGradeFilterValid) {
|
||||
dialog += 'Maximum course grade value must be between 0 and 100. ';
|
||||
}
|
||||
return dialog;
|
||||
};
|
||||
|
||||
updateQueryParams = (queryParams) => {
|
||||
const parsed = queryString.parse(this.props.location.search);
|
||||
Object.keys(queryParams).forEach((key) => {
|
||||
if (queryParams[key]) {
|
||||
parsed[key] = queryParams[key];
|
||||
} else {
|
||||
delete parsed[key];
|
||||
}
|
||||
});
|
||||
this.props.history.push(`?${queryString.stringify(parsed)}`);
|
||||
};
|
||||
|
||||
mapCohortsEntries = (entries) => {
|
||||
const mapped = entries.map(entry => ({
|
||||
id: entry.id,
|
||||
label: entry.name,
|
||||
}));
|
||||
mapped.unshift({ id: 0, label: 'Cohort-All' });
|
||||
return mapped;
|
||||
};
|
||||
|
||||
mapTracksEntries = (entries) => {
|
||||
const mapped = entries.map(entry => ({
|
||||
id: entry.slug,
|
||||
label: entry.name,
|
||||
}));
|
||||
mapped.unshift({ label: 'Track-All' });
|
||||
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.courseId,
|
||||
this.props.selectedCohort,
|
||||
selectedTrackSlug,
|
||||
this.props.selectedAssignmentType,
|
||||
);
|
||||
this.updateQueryParams({ track: selectedTrackSlug });
|
||||
};
|
||||
|
||||
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.courseId,
|
||||
selectedCohortId,
|
||||
this.props.selectedTrack,
|
||||
this.props.selectedAssignmentType,
|
||||
);
|
||||
this.updateQueryParams({ cohort: selectedCohortId });
|
||||
};
|
||||
|
||||
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';
|
||||
};
|
||||
|
||||
lmsInstructorDashboardUrl = courseId => `${configuration.LMS_BASE_URL}/courses/${courseId}/instructor`;
|
||||
|
||||
handleCourseGradeFilterChange = (type, value) => {
|
||||
const filterValue = value;
|
||||
|
||||
if (type === 'min') {
|
||||
this.setState({
|
||||
courseGradeMin: filterValue,
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
courseGradeMax: filterValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleCourseGradeFilterApplyButtonClick = () => {
|
||||
const { courseGradeMin, courseGradeMax } = this.state;
|
||||
const isMinValid = this.isGradeFilterValueInRange(courseGradeMin);
|
||||
const isMaxValid = this.isGradeFilterValueInRange(courseGradeMax);
|
||||
|
||||
this.setState({
|
||||
isMinCourseGradeFilterValid: isMinValid,
|
||||
isMaxCourseGradeFilterValid: isMaxValid,
|
||||
});
|
||||
|
||||
if (isMinValid && isMaxValid) {
|
||||
this.props.updateCourseGradeFilter(
|
||||
courseGradeMin,
|
||||
courseGradeMax,
|
||||
this.props.courseId,
|
||||
);
|
||||
this.props.getUserGrades(
|
||||
this.props.courseId,
|
||||
this.props.selectedCohort,
|
||||
this.props.selectedTrack,
|
||||
this.props.selectedAssignmentType,
|
||||
{
|
||||
courseGradeMin,
|
||||
courseGradeMax,
|
||||
},
|
||||
);
|
||||
this.updateQueryParams({ courseGradeMin, courseGradeMax });
|
||||
}
|
||||
}
|
||||
|
||||
isGradeFilterValueInRange = (value) => {
|
||||
const valueAsInt = parseInt(value, 10);
|
||||
return valueAsInt >= 0 && valueAsInt <= 100;
|
||||
};
|
||||
|
||||
handleFilterBadgeClose = filterNames => () => {
|
||||
this.props.resetFilters(filterNames);
|
||||
const queryParams = {};
|
||||
filterNames.forEach((filterName) => {
|
||||
queryParams[filterName] = false;
|
||||
});
|
||||
this.updateQueryParams(queryParams);
|
||||
const stateUpdate = {};
|
||||
const rangeStateFilters = ['assignmentGradeMin', 'assignmentGradeMax', 'courseGradeMin', 'courseGradeMax'];
|
||||
rangeStateFilters.forEach((filterName) => {
|
||||
if (filterNames.includes(filterName)) {
|
||||
stateUpdate[filterName] = initialFilters[filterName];
|
||||
}
|
||||
});
|
||||
this.setState(stateUpdate);
|
||||
this.props.getUserGrades(
|
||||
this.props.courseId,
|
||||
filterNames.includes('cohort') ? initialFilters.cohort : this.props.selectedCohort,
|
||||
filterNames.includes('track') ? initialFilters.track : this.props.selectedTrack,
|
||||
filterNames.includes('assignmentType') ? initialFilters.assignmentType : this.props.selectedAssignmentType,
|
||||
);
|
||||
}
|
||||
|
||||
createStateFieldSetter = (key) => (value) => this.setState({ [key]: value });
|
||||
|
||||
createStateFieldOnChange = (key) => ({ target }) => this.setState({ [key]: target.value });
|
||||
|
||||
createLimitedSetter = (...keys) => (values) => this.setState(
|
||||
keys.reduce(
|
||||
(obj, key) => (values[key] === undefined ? obj : { ...obj, [key]: values[key] }),
|
||||
{},
|
||||
),
|
||||
)
|
||||
|
||||
safeSetState = this.createLimitedSetter(
|
||||
'adjustedGradePossible',
|
||||
'adjustedGradeValue',
|
||||
'assignmnentName',
|
||||
'modalOpen',
|
||||
'reasonForChange',
|
||||
'todaysDate',
|
||||
'updateModuleId',
|
||||
'updateUserId',
|
||||
'updateUserName',
|
||||
);
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Drawer
|
||||
mainContent={toggleFilterDrawer => (
|
||||
<div className="px-3 gradebook-content">
|
||||
<a
|
||||
href={this.lmsInstructorDashboardUrl(this.props.courseId)}
|
||||
className="mb-3"
|
||||
>
|
||||
<span aria-hidden="true">{'<< '}</span> Back to Dashboard
|
||||
</a>
|
||||
<h1>Gradebook</h1>
|
||||
<h3> {this.props.courseId}</h3>
|
||||
{this.props.areGradesFrozen
|
||||
&& (
|
||||
<div className="alert alert-warning" role="alert">
|
||||
The grades for this course are now frozen. Editing of grades is no longer allowed.
|
||||
</div>
|
||||
)}
|
||||
{(this.props.canUserViewGradebook === false)
|
||||
&& (
|
||||
<div className="alert alert-warning" role="alert">
|
||||
You are not authorized to view the gradebook for this course.
|
||||
</div>
|
||||
)}
|
||||
<Tabs defaultActiveKey="grades">
|
||||
<Tab eventKey="grades" title="Grades">
|
||||
<h4>Step 1: Filter the Grade Report</h4>
|
||||
<div className="d-flex justify-content-between">
|
||||
{this.props.showSpinner && <div className="spinner-overlay"><Icon className="fa fa-spinner fa-spin fa-5x color-black" /></div>}
|
||||
<Button className="btn-primary align-self-start" onClick={toggleFilterDrawer}><FontAwesomeIcon icon={faFilter} /> Edit Filters</Button>
|
||||
<div>
|
||||
<SearchField
|
||||
onSubmit={value => this.props.searchForUser(
|
||||
this.props.courseId,
|
||||
value,
|
||||
this.props.selectedCohort,
|
||||
this.props.selectedTrack,
|
||||
this.props.selectedAssignmentType,
|
||||
)}
|
||||
inputLabel="Search for a learner"
|
||||
onChange={filterValue => this.setState({ filterValue })}
|
||||
onClear={() => this.props.getUserGrades(
|
||||
this.props.courseId,
|
||||
this.props.selectedCohort,
|
||||
this.props.selectedTrack,
|
||||
this.props.selectedAssignmentType,
|
||||
)}
|
||||
value={this.state.filterValue}
|
||||
/>
|
||||
<small className="form-text text-muted search-help-text">Search by username, email, or student key</small>
|
||||
</div>
|
||||
</div>
|
||||
<ConnectedFilterBadges
|
||||
handleFilterBadgeClose={this.handleFilterBadgeClose}
|
||||
/>
|
||||
<StatusAlert
|
||||
alertType="success"
|
||||
dialog="The grade has been successfully edited. You may see a slight delay before updates appear in the Gradebook."
|
||||
onClose={() => this.props.closeBanner()}
|
||||
open={this.props.showSuccess}
|
||||
/>
|
||||
<StatusAlert
|
||||
alertType="danger"
|
||||
dialog={this.getCourseGradeFilterAlertDialog()}
|
||||
dismissible={false}
|
||||
open={
|
||||
!this.state.isMinCourseGradeFilterValid
|
||||
|| !this.state.isMaxCourseGradeFilterValid
|
||||
}
|
||||
/>
|
||||
<h4>Step 2: View or Modify Individual Grades</h4>
|
||||
{this.props.totalUsersCount
|
||||
? (
|
||||
<div>
|
||||
Showing
|
||||
<span className="font-weight-bold"> {this.props.filteredUsersCount} </span>
|
||||
of
|
||||
<span className="font-weight-bold"> {this.props.totalUsersCount} </span>
|
||||
total learners
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
<div className="d-flex justify-content-between align-items-center mb-2">
|
||||
<InputSelect
|
||||
label="Score View:"
|
||||
name="ScoreView"
|
||||
value="percent"
|
||||
options={[{ label: 'Percent', value: 'percent' }, { label: 'Absolute', value: 'absolute' }]}
|
||||
onChange={this.props.toggleFormat}
|
||||
/>
|
||||
{this.props.showBulkManagement && (
|
||||
<BulkManagementControls
|
||||
courseId={this.props.courseId}
|
||||
gradeExportUrl={this.props.gradeExportUrl}
|
||||
interventionExportUrl={this.props.interventionExportUrl}
|
||||
showSpinner={this.props.showSpinner}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<GradebookTable setGradebookState={this.safeSetState} />
|
||||
{PageButtons(this.props)}
|
||||
<p>* available for learners in the Master's track only</p>
|
||||
<EditModal
|
||||
assignmentName={this.state.assignmentName}
|
||||
adjustedGradeValue={this.state.adjustedGradeValue}
|
||||
adjustedGradePossible={this.state.adjustedGradePossible}
|
||||
courseId={this.props.courseId}
|
||||
filterValue={this.state.filterValue}
|
||||
onChange={this.onChange}
|
||||
open={this.state.modalOpen}
|
||||
reasonForChange={this.state.reasonForChange}
|
||||
setAdjustedGradeValue={this.createStateFieldOnChange('adjustedGradeValue')}
|
||||
setGradebookState={this.safeSetState}
|
||||
setReasonForChange={this.createStateFieldOnChange('reasonForChange')}
|
||||
todaysDate={this.state.todaysDate}
|
||||
updateModuleId={this.state.updateModuleId}
|
||||
updateUserId={this.state.updateUserId}
|
||||
updateUserName={this.state.updateUserName}
|
||||
/>
|
||||
|
||||
</Tab>
|
||||
{this.props.showBulkManagement
|
||||
&& (
|
||||
<BulkManagement
|
||||
courseId={this.props.courseId}
|
||||
gradeExportUrl={this.props.gradeExportUrl}
|
||||
/>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
initiallyOpen={false}
|
||||
title={(
|
||||
<>
|
||||
<FontAwesomeIcon icon={faFilter} /> Filter By...
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<Assignments
|
||||
assignmentGradeMin={this.state.assignmentGradeMin}
|
||||
assignmentGradeMax={this.state.assignmentGradeMax}
|
||||
courseId={this.props.courseId}
|
||||
setAssignmentGradeMin={this.createStateFieldSetter('assignmentGradeMin')}
|
||||
setAssignmentGradeMax={this.createStateFieldSetter('assignmentGradeMax')}
|
||||
updateQueryParams={this.updateQueryParams}
|
||||
/>
|
||||
<Collapsible title="Overall Grade" open className="filter-group mb-3">
|
||||
<div className="d-flex justify-content-between align-items-center">
|
||||
<InputText
|
||||
value={this.state.courseGradeMin}
|
||||
name="minimum-grade"
|
||||
label="Min Grade"
|
||||
onChange={value => this.handleCourseGradeFilterChange('min', value)}
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
/>
|
||||
<span className="input-percent-label">%</span>
|
||||
<InputText
|
||||
value={this.state.courseGradeMax}
|
||||
name="max-grade"
|
||||
label="Max Grade"
|
||||
onChange={value => this.handleCourseGradeFilterChange('max', value)}
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
/>
|
||||
<span className="input-percent-label">%</span>
|
||||
<Button
|
||||
variant="outline-secondary"
|
||||
onClick={this.handleCourseGradeFilterApplyButtonClick}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</Collapsible>
|
||||
<Collapsible title="Student Groups" open className="filter-group mb-3">
|
||||
<InputSelect
|
||||
label="Tracks"
|
||||
name="Tracks"
|
||||
aria-label="Tracks"
|
||||
disabled={this.props.tracks.length === 0}
|
||||
value={this.mapSelectedTrackEntry(this.props.selectedTrack)}
|
||||
options={this.mapTracksEntries(this.props.tracks)}
|
||||
onChange={this.updateTracks}
|
||||
/>
|
||||
<InputSelect
|
||||
name="Cohorts"
|
||||
aria-label="Cohorts"
|
||||
label="Cohorts"
|
||||
disabled={this.props.cohorts.length === 0}
|
||||
value={this.mapSelectedCohortEntry(this.props.selectedCohort)}
|
||||
options={this.mapCohortsEntries(this.props.cohorts)}
|
||||
onChange={this.updateCohorts}
|
||||
/>
|
||||
</Collapsible>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Gradebook.defaultProps = {
|
||||
areGradesFrozen: false,
|
||||
canUserViewGradebook: false,
|
||||
cohorts: [],
|
||||
courseId: '',
|
||||
filteredUsersCount: null,
|
||||
location: {
|
||||
search: '',
|
||||
},
|
||||
selectedAssignmentType: '',
|
||||
selectedCohort: null,
|
||||
selectedTrack: null,
|
||||
showBulkManagement: false,
|
||||
showSpinner: false,
|
||||
totalUsersCount: null,
|
||||
tracks: [],
|
||||
};
|
||||
|
||||
Gradebook.propTypes = {
|
||||
areGradesFrozen: PropTypes.bool,
|
||||
canUserViewGradebook: PropTypes.bool,
|
||||
closeBanner: PropTypes.func.isRequired,
|
||||
cohorts: PropTypes.arrayOf(PropTypes.shape({
|
||||
name: PropTypes.string,
|
||||
id: PropTypes.number,
|
||||
})),
|
||||
courseId: PropTypes.string,
|
||||
filteredUsersCount: PropTypes.number,
|
||||
getRoles: PropTypes.func.isRequired,
|
||||
getUserGrades: PropTypes.func.isRequired,
|
||||
gradeExportUrl: PropTypes.string.isRequired,
|
||||
history: PropTypes.shape({
|
||||
push: PropTypes.func,
|
||||
}).isRequired,
|
||||
initializeFilters: PropTypes.func.isRequired,
|
||||
interventionExportUrl: PropTypes.string.isRequired,
|
||||
location: PropTypes.shape({
|
||||
search: PropTypes.string,
|
||||
}),
|
||||
resetFilters: PropTypes.func.isRequired,
|
||||
searchForUser: PropTypes.func.isRequired,
|
||||
selectedAssignmentType: PropTypes.string,
|
||||
selectedCohort: PropTypes.string,
|
||||
selectedTrack: PropTypes.string,
|
||||
showBulkManagement: PropTypes.bool,
|
||||
showSpinner: PropTypes.bool,
|
||||
showSuccess: PropTypes.bool.isRequired,
|
||||
toggleFormat: PropTypes.func.isRequired,
|
||||
totalUsersCount: PropTypes.number,
|
||||
tracks: PropTypes.arrayOf(PropTypes.shape({
|
||||
name: PropTypes.string,
|
||||
})),
|
||||
updateCourseGradeFilter: PropTypes.func.isRequired,
|
||||
};
|
||||
33
src/components/GradebookFilters/AssignmentFilter/hooks.js
Normal file
33
src/components/GradebookFilters/AssignmentFilter/hooks.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
selectors,
|
||||
actions,
|
||||
thunkActions,
|
||||
} from 'data/redux/hooks';
|
||||
|
||||
export const useAssignmentFilterData = ({
|
||||
updateQueryParams,
|
||||
}) => {
|
||||
const assignmentFilterOptions = selectors.filters.useSelectableAssignmentLabels();
|
||||
const selectedAssignmentLabel = selectors.filters.useSelectedAssignmentLabel() || '';
|
||||
|
||||
const updateAssignmentFilter = actions.filters.useUpdateAssignment();
|
||||
const conditionalFetch = thunkActions.grades.useFetchGradesIfAssignmentGradeFiltersSet();
|
||||
|
||||
const handleChange = ({ target: { value: assignment } }) => {
|
||||
const selectedFilterOption = assignmentFilterOptions.find(
|
||||
({ label }) => label === assignment,
|
||||
);
|
||||
const { type, id } = selectedFilterOption || {};
|
||||
updateAssignmentFilter({ label: assignment, type, id });
|
||||
updateQueryParams({ assignment: id });
|
||||
conditionalFetch();
|
||||
};
|
||||
|
||||
return {
|
||||
handleChange,
|
||||
selectedAssignmentLabel,
|
||||
assignmentFilterOptions,
|
||||
};
|
||||
};
|
||||
|
||||
export default useAssignmentFilterData;
|
||||
@@ -0,0 +1,88 @@
|
||||
import { selectors, actions, thunkActions } from 'data/redux/hooks';
|
||||
|
||||
import useAssignmentFilterData from './hooks';
|
||||
|
||||
jest.mock('data/redux/hooks', () => ({
|
||||
selectors: {
|
||||
filters: {
|
||||
useSelectableAssignmentLabels: jest.fn(),
|
||||
useSelectedAssignmentLabel: jest.fn(),
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
filters: { useUpdateAssignment: jest.fn() },
|
||||
},
|
||||
thunkActions: {
|
||||
grades: { useFetchGradesIfAssignmentGradeFiltersSet: jest.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
let out;
|
||||
const testKey = 'test-key';
|
||||
const event = { target: { value: testKey } };
|
||||
const testId = 'test-id';
|
||||
const testType = 'test-type';
|
||||
|
||||
const testLabel = { label: testKey, id: testId, type: testType };
|
||||
const selectableAssignmentLabels = [
|
||||
{ label: 'some' },
|
||||
{ label: 'test' },
|
||||
{ label: 'labels' },
|
||||
testLabel,
|
||||
];
|
||||
const selectedAssignmentLabel = 'test-assignment-label';
|
||||
selectors.filters.useSelectableAssignmentLabels.mockReturnValue(selectableAssignmentLabels);
|
||||
selectors.filters.useSelectedAssignmentLabel.mockReturnValue(selectedAssignmentLabel);
|
||||
|
||||
const updateAssignment = jest.fn();
|
||||
const fetch = jest.fn();
|
||||
actions.filters.useUpdateAssignment.mockReturnValue(updateAssignment);
|
||||
thunkActions.grades.useFetchGradesIfAssignmentGradeFiltersSet.mockReturnValue(fetch);
|
||||
|
||||
const updateQueryParams = jest.fn();
|
||||
|
||||
describe('useAssignmentFilterData hook', () => {
|
||||
beforeEach(() => {
|
||||
out = useAssignmentFilterData({ updateQueryParams });
|
||||
});
|
||||
describe('behavior', () => {
|
||||
it('initializes redux hooks', () => {
|
||||
expect(selectors.filters.useSelectableAssignmentLabels).toHaveBeenCalledWith();
|
||||
expect(selectors.filters.useSelectedAssignmentLabel).toHaveBeenCalledWith();
|
||||
expect(actions.filters.useUpdateAssignment).toHaveBeenCalledWith();
|
||||
expect(thunkActions.grades.useFetchGradesIfAssignmentGradeFiltersSet)
|
||||
.toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
describe('output', () => {
|
||||
describe('handleEvent', () => {
|
||||
beforeEach(() => {
|
||||
out.handleChange(event);
|
||||
});
|
||||
it('updates assignment filter with selected filter', () => {
|
||||
expect(updateAssignment).toHaveBeenCalledWith(testLabel);
|
||||
});
|
||||
it('updates queryParams', () => {
|
||||
expect(updateQueryParams).toHaveBeenCalledWith({ assignment: testId });
|
||||
});
|
||||
it('updates assignment filter with only label if no match', () => {
|
||||
out.handleChange({ target: { value: 'no-match' } });
|
||||
expect(updateAssignment).toHaveBeenCalledWith({ label: 'no-match' });
|
||||
});
|
||||
it('calls conditional fetch', () => {
|
||||
expect(fetch).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
it('passes selectedAssignmentLabel from hook', () => {
|
||||
expect(out.selectedAssignmentLabel).toEqual(selectedAssignmentLabel);
|
||||
});
|
||||
test('selectedAssignmentLabel is empty string if not set', () => {
|
||||
selectors.filters.useSelectedAssignmentLabel.mockReturnValue(undefined);
|
||||
out = useAssignmentFilterData({ updateQueryParams });
|
||||
expect(out.selectedAssignmentLabel).toEqual('');
|
||||
});
|
||||
it('passes assignmentFilterOptions from hook', () => {
|
||||
expect(out.assignmentFilterOptions).toEqual(selectableAssignmentLabels);
|
||||
});
|
||||
});
|
||||
});
|
||||
44
src/components/GradebookFilters/AssignmentFilter/index.jsx
Normal file
44
src/components/GradebookFilters/AssignmentFilter/index.jsx
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable react/sort-comp, react/button-has-type */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import messages from '../messages';
|
||||
import SelectGroup from '../SelectGroup';
|
||||
import useAssignmentFilterData from './hooks';
|
||||
|
||||
const AssignmentFilter = ({ updateQueryParams }) => {
|
||||
const {
|
||||
handleChange,
|
||||
selectedAssignmentLabel,
|
||||
assignmentFilterOptions,
|
||||
} = useAssignmentFilterData({ updateQueryParams });
|
||||
const { formatMessage } = useIntl();
|
||||
const filterOptions = assignmentFilterOptions.map(({ label, subsectionLabel }) => (
|
||||
<option key={label} value={label}>
|
||||
{label}: {subsectionLabel}
|
||||
</option>
|
||||
));
|
||||
return (
|
||||
<div className="student-filters">
|
||||
<SelectGroup
|
||||
id="assignment"
|
||||
label={formatMessage(messages.assignment)}
|
||||
value={selectedAssignmentLabel}
|
||||
onChange={handleChange}
|
||||
disabled={assignmentFilterOptions.length === 0}
|
||||
options={[
|
||||
<option key="0" value="">All</option>,
|
||||
...filterOptions,
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
AssignmentFilter.propTypes = {
|
||||
updateQueryParams: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default AssignmentFilter;
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import { render, screen, initializeMocks } from 'testUtilsExtra';
|
||||
|
||||
import useAssignmentFilterData from './hooks';
|
||||
import AssignmentFilter from '.';
|
||||
|
||||
jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() }));
|
||||
|
||||
const handleChange = jest.fn();
|
||||
const selectedAssignmentLabel = 'test-label';
|
||||
const assignmentFilterOptions = [
|
||||
{ label: 'label1', subsectionLabel: 'sLabel1' },
|
||||
{ label: 'label2', subsectionLabel: 'sLabel2' },
|
||||
{ label: 'label3', subsectionLabel: 'sLabel3' },
|
||||
{ label: 'label4', subsectionLabel: 'sLabel4' },
|
||||
];
|
||||
useAssignmentFilterData.mockReturnValue({
|
||||
handleChange,
|
||||
selectedAssignmentLabel,
|
||||
assignmentFilterOptions,
|
||||
});
|
||||
|
||||
const updateQueryParams = jest.fn();
|
||||
|
||||
describe('AssignmentFilter component', () => {
|
||||
beforeAll(() => {
|
||||
initializeMocks();
|
||||
render(<AssignmentFilter updateQueryParams={updateQueryParams} />);
|
||||
});
|
||||
describe('render', () => {
|
||||
test('filter options', () => {
|
||||
expect(screen.getByRole('combobox', { name: 'Assignment' })).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('option')).toHaveLength(assignmentFilterOptions.length + 1); // +1 for the default option
|
||||
expect(screen.getAllByRole('option')[assignmentFilterOptions.length]).toHaveTextContent(assignmentFilterOptions[assignmentFilterOptions.length - 1].label);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
/* eslint-disable react/sort-comp, react/button-has-type */
|
||||
import { selectors, actions, thunkActions } from 'data/redux/hooks';
|
||||
|
||||
const useAssignmentGradeFilterData = ({ updateQueryParams }) => {
|
||||
const localAssignmentLimits = selectors.app.useAssignmentGradeLimits();
|
||||
const selectedAssignment = selectors.filters.useSelectedAssignmentLabel();
|
||||
const fetchGrades = thunkActions.grades.useFetchGrades();
|
||||
const setFilter = actions.app.useSetLocalFilter();
|
||||
const updateAssignmentLimits = actions.filters.useUpdateAssignmentLimits();
|
||||
|
||||
const handleSubmit = () => {
|
||||
updateAssignmentLimits(localAssignmentLimits);
|
||||
fetchGrades();
|
||||
updateQueryParams(localAssignmentLimits);
|
||||
};
|
||||
|
||||
const handleSetMax = ({ target: { value } }) => {
|
||||
setFilter({ assignmentGradeMax: value });
|
||||
};
|
||||
|
||||
const handleSetMin = ({ target: { value } }) => {
|
||||
setFilter({ assignmentGradeMin: value });
|
||||
};
|
||||
|
||||
const { assignmentGradeMax, assignmentGradeMin } = localAssignmentLimits;
|
||||
return {
|
||||
assignmentGradeMin,
|
||||
assignmentGradeMax,
|
||||
selectedAssignment,
|
||||
handleSetMax,
|
||||
handleSetMin,
|
||||
handleSubmit,
|
||||
};
|
||||
};
|
||||
|
||||
export default useAssignmentGradeFilterData;
|
||||
@@ -0,0 +1,81 @@
|
||||
import { selectors, actions, thunkActions } from 'data/redux/hooks';
|
||||
|
||||
import useAssignmentGradeFilterData from './hooks';
|
||||
|
||||
jest.mock('data/redux/hooks', () => ({
|
||||
selectors: {
|
||||
app: { useAssignmentGradeLimits: jest.fn() },
|
||||
filters: { useSelectedAssignmentLabel: jest.fn() },
|
||||
},
|
||||
actions: {
|
||||
app: { useSetLocalFilter: jest.fn() },
|
||||
filters: { useUpdateAssignmentLimits: jest.fn() },
|
||||
},
|
||||
thunkActions: {
|
||||
grades: { useFetchGrades: jest.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
let out;
|
||||
|
||||
const assignmentGradeLimits = { assignmentGradeMax: 200, assignmentGradeMin: 3 };
|
||||
const selectedAssignmentLabel = 'test-assignment-label';
|
||||
selectors.app.useAssignmentGradeLimits.mockReturnValue(assignmentGradeLimits);
|
||||
selectors.filters.useSelectedAssignmentLabel.mockReturnValue(selectedAssignmentLabel);
|
||||
|
||||
const setLocalFilter = jest.fn();
|
||||
const updateAssignmentLimits = jest.fn();
|
||||
const fetch = jest.fn();
|
||||
actions.app.useSetLocalFilter.mockReturnValue(setLocalFilter);
|
||||
actions.filters.useUpdateAssignmentLimits.mockReturnValue(updateAssignmentLimits);
|
||||
thunkActions.grades.useFetchGrades.mockReturnValue(fetch);
|
||||
|
||||
const testValue = 42;
|
||||
|
||||
const updateQueryParams = jest.fn();
|
||||
|
||||
describe('useAssignmentFilterData hook', () => {
|
||||
beforeEach(() => {
|
||||
out = useAssignmentGradeFilterData({ updateQueryParams });
|
||||
});
|
||||
describe('behavior', () => {
|
||||
it('initializes redux hooks', () => {
|
||||
expect(selectors.app.useAssignmentGradeLimits).toHaveBeenCalledWith();
|
||||
expect(selectors.filters.useSelectedAssignmentLabel).toHaveBeenCalledWith();
|
||||
expect(actions.app.useSetLocalFilter).toHaveBeenCalledWith();
|
||||
expect(actions.filters.useUpdateAssignmentLimits).toHaveBeenCalledWith();
|
||||
expect(thunkActions.grades.useFetchGrades).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
describe('output', () => {
|
||||
describe('handleSubmit', () => {
|
||||
beforeEach(() => {
|
||||
out.handleSubmit();
|
||||
});
|
||||
it('updates assignment limits filter', () => {
|
||||
expect(updateAssignmentLimits).toHaveBeenCalledWith(assignmentGradeLimits);
|
||||
});
|
||||
it('updates queryParams', () => {
|
||||
expect(updateQueryParams).toHaveBeenCalledWith(assignmentGradeLimits);
|
||||
});
|
||||
it('calls conditional fetch', () => {
|
||||
expect(fetch).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
test('handleSetMax sets assignmentGradeMax', () => {
|
||||
out.handleSetMax({ target: { value: testValue } });
|
||||
expect(setLocalFilter).toHaveBeenCalledWith({ assignmentGradeMax: testValue });
|
||||
});
|
||||
test('handleSetMin sets assignmentGradeMin', () => {
|
||||
out.handleSetMin({ target: { value: testValue } });
|
||||
expect(setLocalFilter).toHaveBeenCalledWith({ assignmentGradeMin: testValue });
|
||||
});
|
||||
it('passes selectedAssignment from hook', () => {
|
||||
expect(out.selectedAssignment).toEqual(selectedAssignmentLabel);
|
||||
});
|
||||
it('passes assignmentGradeMin and assignmentGradeMax from hook', () => {
|
||||
expect(out.assignmentGradeMax).toEqual(assignmentGradeLimits.assignmentGradeMax);
|
||||
expect(out.assignmentGradeMin).toEqual(assignmentGradeLimits.assignmentGradeMin);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Button } from '@openedx/paragon';
|
||||
|
||||
import useAssignmentGradeFilterData from './hooks';
|
||||
import messages from '../messages';
|
||||
import PercentGroup from '../PercentGroup';
|
||||
|
||||
export const AssignmentGradeFilter = ({ updateQueryParams }) => {
|
||||
const {
|
||||
assignmentGradeMin,
|
||||
assignmentGradeMax,
|
||||
selectedAssignment,
|
||||
handleSetMax,
|
||||
handleSetMin,
|
||||
handleSubmit,
|
||||
} = useAssignmentGradeFilterData({ updateQueryParams });
|
||||
const { formatMessage } = useIntl();
|
||||
return (
|
||||
<div className="grade-filter-inputs">
|
||||
<PercentGroup
|
||||
id="assignmentGradeMin"
|
||||
label={formatMessage(messages.minGrade)}
|
||||
value={assignmentGradeMin}
|
||||
disabled={!selectedAssignment}
|
||||
onChange={handleSetMin}
|
||||
/>
|
||||
<PercentGroup
|
||||
id="assignmentGradeMax"
|
||||
label={formatMessage(messages.maxGrade)}
|
||||
value={assignmentGradeMax}
|
||||
disabled={!selectedAssignment}
|
||||
onChange={handleSetMax}
|
||||
/>
|
||||
<div className="grade-filter-action">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline-secondary"
|
||||
name="assignmentGradeMinMax"
|
||||
disabled={!selectedAssignment}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{formatMessage(messages.apply)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
AssignmentGradeFilter.propTypes = {
|
||||
updateQueryParams: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default AssignmentGradeFilter;
|
||||
@@ -0,0 +1,74 @@
|
||||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
import { screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import useAssignmentGradeFilterData from './hooks';
|
||||
import AssignmentFilter from '.';
|
||||
import { renderWithIntl } from '../../../testUtilsExtra';
|
||||
|
||||
jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() }));
|
||||
|
||||
const hookData = {
|
||||
handleSubmit: jest.fn(),
|
||||
handleSetMax: jest.fn(),
|
||||
handleSetMin: jest.fn(),
|
||||
selectedAssignment: 'test-assignment',
|
||||
assignmentGradeMax: 300,
|
||||
assignmentGradeMin: 23,
|
||||
};
|
||||
useAssignmentGradeFilterData.mockReturnValue(hookData);
|
||||
|
||||
const updateQueryParams = jest.fn();
|
||||
|
||||
describe('AssignmentFilter component', () => {
|
||||
describe('behavior', () => {
|
||||
it('initializes hooks', () => {
|
||||
renderWithIntl(<AssignmentFilter updateQueryParams={updateQueryParams} />);
|
||||
expect(useAssignmentGradeFilterData).toHaveBeenCalledWith({ updateQueryParams });
|
||||
});
|
||||
});
|
||||
describe('render', () => {
|
||||
describe('with selected assignment', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
renderWithIntl(<AssignmentFilter updateQueryParams={updateQueryParams} />);
|
||||
});
|
||||
it('renders a PercentGroup for both Max and Min filters', async () => {
|
||||
const user = userEvent.setup();
|
||||
const minGradeInput = screen.getByRole('spinbutton', { name: /Min Grade/i });
|
||||
const maxGradeInput = screen.getByRole('spinbutton', { name: /Max Grade/i });
|
||||
expect(minGradeInput).toBeInTheDocument();
|
||||
expect(maxGradeInput).toBeInTheDocument();
|
||||
expect(minGradeInput).toBeEnabled();
|
||||
expect(maxGradeInput).toBeEnabled();
|
||||
await user.type(minGradeInput, '25');
|
||||
expect(hookData.handleSetMin).toHaveBeenCalled();
|
||||
await user.type(maxGradeInput, '50');
|
||||
expect(hookData.handleSetMax).toHaveBeenCalled();
|
||||
});
|
||||
it('renders a submit button', async () => {
|
||||
const user = userEvent.setup();
|
||||
const submitButton = screen.getByRole('button', { name: /Apply/ });
|
||||
expect(submitButton).toBeInTheDocument();
|
||||
expect(submitButton).not.toHaveAttribute('disabled');
|
||||
await user.click(submitButton);
|
||||
expect(hookData.handleSubmit).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
describe('without selected assignment', () => {
|
||||
beforeEach(() => {
|
||||
useAssignmentGradeFilterData.mockReturnValueOnce({
|
||||
...hookData,
|
||||
selectedAssignment: null,
|
||||
});
|
||||
renderWithIntl(<AssignmentFilter updateQueryParams={updateQueryParams} />);
|
||||
});
|
||||
it('disables controls', () => {
|
||||
const minGrade = screen.getByRole('spinbutton', { name: /Min Grade/ });
|
||||
const maxGrade = screen.getByRole('spinbutton', { name: /Max Grade/ });
|
||||
expect(minGrade).toHaveAttribute('disabled');
|
||||
expect(maxGrade).toHaveAttribute('disabled');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { selectors, actions } from 'data/redux/hooks';
|
||||
|
||||
export const useAssignmentTypeFilterData = ({ updateQueryParams }) => {
|
||||
const assignmentTypes = selectors.assignmentTypes.useAllAssignmentTypes() || {};
|
||||
const assignmentFilterOptions = selectors.filters.useSelectableAssignmentLabels();
|
||||
const selectedAssignmentType = selectors.filters.useAssignmentType() || '';
|
||||
const filterAssignmentType = actions.filters.useUpdateAssignmentType();
|
||||
|
||||
const handleChange = (event) => {
|
||||
const assignmentType = event.target.value;
|
||||
filterAssignmentType(assignmentType);
|
||||
updateQueryParams({ assignmentType });
|
||||
};
|
||||
|
||||
return {
|
||||
assignmentTypes,
|
||||
handleChange,
|
||||
isDisabled: assignmentFilterOptions.length === 0,
|
||||
selectedAssignmentType,
|
||||
};
|
||||
};
|
||||
export default useAssignmentTypeFilterData;
|
||||
@@ -0,0 +1,92 @@
|
||||
import { selectors, actions } from 'data/redux/hooks';
|
||||
|
||||
import useAssignmentTypeFilterData from './hooks';
|
||||
|
||||
jest.mock('data/redux/hooks', () => ({
|
||||
selectors: {
|
||||
assignmentTypes: {
|
||||
useAllAssignmentTypes: jest.fn(),
|
||||
},
|
||||
filters: {
|
||||
useSelectableAssignmentLabels: jest.fn(),
|
||||
useAssignmentType: jest.fn(),
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
filters: { useUpdateAssignmentType: jest.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
let out;
|
||||
const testId = 'test-id';
|
||||
const testKey = 'test-key';
|
||||
|
||||
const testType = 'test-type';
|
||||
const allTypes = [testType, 'and', 'some', 'other', 'types'];
|
||||
selectors.assignmentTypes.useAllAssignmentTypes.mockReturnValue(allTypes);
|
||||
const event = { target: { value: testType } };
|
||||
|
||||
const testLabel = { label: testKey, id: testId, type: testType };
|
||||
const selectableAssignmentLabels = [
|
||||
{ label: 'some' },
|
||||
{ label: 'test' },
|
||||
{ label: 'labels' },
|
||||
testLabel,
|
||||
];
|
||||
selectors.filters.useSelectableAssignmentLabels.mockReturnValue(selectableAssignmentLabels);
|
||||
selectors.filters.useAssignmentType.mockReturnValue(testType);
|
||||
|
||||
const updateAssignmentType = jest.fn();
|
||||
actions.filters.useUpdateAssignmentType.mockReturnValue(updateAssignmentType);
|
||||
|
||||
const updateQueryParams = jest.fn();
|
||||
|
||||
describe('useAssignmentTypeFilterData hook', () => {
|
||||
beforeEach(() => {
|
||||
out = useAssignmentTypeFilterData({ updateQueryParams });
|
||||
});
|
||||
describe('behavior', () => {
|
||||
it('initializes redux hooks', () => {
|
||||
expect(selectors.assignmentTypes.useAllAssignmentTypes).toHaveBeenCalledWith();
|
||||
expect(selectors.filters.useSelectableAssignmentLabels).toHaveBeenCalledWith();
|
||||
expect(selectors.filters.useAssignmentType).toHaveBeenCalledWith();
|
||||
expect(actions.filters.useUpdateAssignmentType).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
describe('output', () => {
|
||||
describe('handleEvent', () => {
|
||||
beforeEach(() => {
|
||||
out.handleChange(event);
|
||||
});
|
||||
it('updates assignmentType filter with selected filter', () => {
|
||||
expect(updateAssignmentType).toHaveBeenCalledWith(testType);
|
||||
});
|
||||
it('updates queryParams', () => {
|
||||
expect(updateQueryParams).toHaveBeenCalledWith({ assignmentType: testType });
|
||||
});
|
||||
});
|
||||
describe('selectedAssignmentType', () => {
|
||||
it('returns selected assignmentType', () => {
|
||||
expect(out.selectedAssignmentType).toEqual(testType);
|
||||
});
|
||||
it('returns empty string if no assignmentType is selected', () => {
|
||||
selectors.filters.useAssignmentType.mockReturnValue(undefined);
|
||||
out = useAssignmentTypeFilterData({ updateQueryParams });
|
||||
expect(out.selectedAssignmentType).toEqual('');
|
||||
});
|
||||
});
|
||||
it('passes assignmentTypes from hook', () => {
|
||||
expect(out.assignmentTypes).toEqual(allTypes);
|
||||
});
|
||||
test('assignmentTypes is empty object if hook returns undefined', () => {
|
||||
selectors.assignmentTypes.useAllAssignmentTypes.mockReturnValue(undefined);
|
||||
out = useAssignmentTypeFilterData({ updateQueryParams });
|
||||
expect(out.assignmentTypes).toEqual({});
|
||||
});
|
||||
it('returns isDisabled if assigmentFilterOptions is empty', () => {
|
||||
expect(out.isDisabled).toEqual(false);
|
||||
selectors.assignmentTypes.useAllAssignmentTypes.mockReturnValue([]);
|
||||
out = useAssignmentTypeFilterData({ updateQueryParams });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
/* eslint-disable react/sort-comp, react/button-has-type */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import SelectGroup from '../SelectGroup';
|
||||
import messages from '../messages';
|
||||
import useAssignmentTypeFilterData from './hooks';
|
||||
|
||||
export const AssignmentTypeFilter = ({ updateQueryParams }) => {
|
||||
const {
|
||||
assignmentTypes,
|
||||
handleChange,
|
||||
isDisabled,
|
||||
selectedAssignmentType,
|
||||
} = useAssignmentTypeFilterData({ updateQueryParams });
|
||||
const { formatMessage } = useIntl();
|
||||
return (
|
||||
<div className="student-filters">
|
||||
<SelectGroup
|
||||
id="assignment-types"
|
||||
label={formatMessage(messages.assignmentTypes)}
|
||||
value={selectedAssignmentType}
|
||||
onChange={handleChange}
|
||||
disabled={isDisabled}
|
||||
options={[
|
||||
<option key="0" value="">All</option>,
|
||||
...assignmentTypes.map(entry => (
|
||||
<option key={entry} value={entry}>{entry}</option>
|
||||
)),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
AssignmentTypeFilter.propTypes = {
|
||||
updateQueryParams: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default AssignmentTypeFilter;
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import { screen } from '@testing-library/react';
|
||||
|
||||
import useAssignmentFilterTypeData from './hooks';
|
||||
import AssignmentFilterType from '.';
|
||||
import { renderWithIntl } from '../../../testUtilsExtra';
|
||||
|
||||
jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() }));
|
||||
|
||||
const handleChange = jest.fn();
|
||||
const testType = 'test-type';
|
||||
const assignmentTypes = [testType, 'type1', 'type2', 'type3'];
|
||||
useAssignmentFilterTypeData.mockReturnValue({
|
||||
handleChange,
|
||||
selectedAssignmentType: testType,
|
||||
assignmentTypes,
|
||||
isDisabled: true,
|
||||
});
|
||||
|
||||
const updateQueryParams = jest.fn();
|
||||
|
||||
describe('AssignmentFilterType component', () => {
|
||||
beforeAll(() => {
|
||||
renderWithIntl(<AssignmentFilterType updateQueryParams={updateQueryParams} />);
|
||||
});
|
||||
describe('render', () => {
|
||||
test('filter options', () => {
|
||||
const options = screen.getAllByRole('option');
|
||||
expect(options.length).toEqual(5); // 4 types + "All Types"
|
||||
expect(options[1]).toHaveTextContent(testType);
|
||||
});
|
||||
});
|
||||
});
|
||||
33
src/components/GradebookFilters/CourseGradeFilter/hooks.js
Normal file
33
src/components/GradebookFilters/CourseGradeFilter/hooks.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import { actions, selectors, thunkActions } from 'data/redux/hooks';
|
||||
|
||||
export const useCourseGradeFilterData = ({
|
||||
updateQueryParams,
|
||||
}) => {
|
||||
const isDisabled = !selectors.app.useAreCourseGradeFiltersValid();
|
||||
const localCourseLimits = selectors.app.useCourseGradeLimits();
|
||||
const fetchGrades = thunkActions.grades.useFetchGrades();
|
||||
const setLocalFilter = actions.app.useSetLocalFilter();
|
||||
const updateFilter = actions.filters.useUpdateCourseGradeLimits();
|
||||
|
||||
const handleApplyClick = () => {
|
||||
updateFilter(localCourseLimits);
|
||||
fetchGrades();
|
||||
updateQueryParams(localCourseLimits);
|
||||
};
|
||||
|
||||
const { courseGradeMin, courseGradeMax } = localCourseLimits;
|
||||
return {
|
||||
max: {
|
||||
value: courseGradeMax,
|
||||
onChange: (e) => setLocalFilter({ courseGradeMax: e.target.value }),
|
||||
},
|
||||
min: {
|
||||
value: courseGradeMin,
|
||||
onChange: (e) => setLocalFilter({ courseGradeMin: e.target.value }),
|
||||
},
|
||||
handleApplyClick,
|
||||
isDisabled,
|
||||
};
|
||||
};
|
||||
|
||||
export default useCourseGradeFilterData;
|
||||
@@ -0,0 +1,78 @@
|
||||
import { selectors, actions, thunkActions } from 'data/redux/hooks';
|
||||
|
||||
import useCourseTypeFilterData from './hooks';
|
||||
|
||||
jest.mock('data/redux/hooks', () => ({
|
||||
selectors: {
|
||||
app: {
|
||||
useAreCourseGradeFiltersValid: jest.fn(),
|
||||
useCourseGradeLimits: jest.fn(),
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
app: { useSetLocalFilter: jest.fn() },
|
||||
filters: { useUpdateCourseGradeLimits: jest.fn() },
|
||||
},
|
||||
thunkActions: {
|
||||
grades: { useFetchGrades: jest.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
let out;
|
||||
|
||||
const courseGradeLimits = { courseGradeMax: 120, courseGradeMin: 32 };
|
||||
selectors.app.useAreCourseGradeFiltersValid.mockReturnValue(true);
|
||||
selectors.app.useCourseGradeLimits.mockReturnValue(courseGradeLimits);
|
||||
|
||||
const setLocalFilter = jest.fn();
|
||||
actions.app.useSetLocalFilter.mockReturnValue(setLocalFilter);
|
||||
const updateCourseGradeLimits = jest.fn();
|
||||
actions.filters.useUpdateCourseGradeLimits.mockReturnValue(updateCourseGradeLimits);
|
||||
const fetch = jest.fn();
|
||||
thunkActions.grades.useFetchGrades.mockReturnValue(fetch);
|
||||
|
||||
const testValue = 55;
|
||||
|
||||
const updateQueryParams = jest.fn();
|
||||
|
||||
describe('useCourseTypeFilterData hook', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
out = useCourseTypeFilterData({ updateQueryParams });
|
||||
});
|
||||
describe('behavior', () => {
|
||||
it('initializes redux hooks', () => {
|
||||
expect(selectors.app.useAreCourseGradeFiltersValid).toHaveBeenCalledWith();
|
||||
expect(selectors.app.useCourseGradeLimits).toHaveBeenCalledWith();
|
||||
expect(actions.app.useSetLocalFilter).toHaveBeenCalledWith();
|
||||
expect(actions.filters.useUpdateCourseGradeLimits).toHaveBeenCalledWith();
|
||||
expect(thunkActions.grades.useFetchGrades).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
describe('output', () => {
|
||||
it('returns isDisabled if assigmentFilterOptions is empty', () => {
|
||||
expect(out.isDisabled).toEqual(false);
|
||||
selectors.app.useAreCourseGradeFiltersValid.mockReturnValue(false);
|
||||
out = useCourseTypeFilterData({ updateQueryParams });
|
||||
expect(out.isDisabled).toEqual(true);
|
||||
});
|
||||
test('min value and onChange', () => {
|
||||
const { courseGradeMin } = courseGradeLimits;
|
||||
expect(out.min.value).toEqual(courseGradeMin);
|
||||
out.min.onChange({ target: { value: testValue } });
|
||||
expect(setLocalFilter).toHaveBeenCalledWith({ courseGradeMin: testValue });
|
||||
});
|
||||
test('max value and onChange', () => {
|
||||
const { courseGradeMax } = courseGradeLimits;
|
||||
expect(out.max.value).toEqual(courseGradeMax);
|
||||
out.max.onChange({ target: { value: testValue } });
|
||||
expect(setLocalFilter).toHaveBeenCalledWith({ courseGradeMax: testValue });
|
||||
});
|
||||
it('updates filter, fetches grades, and updates query params on apply click', () => {
|
||||
out.handleApplyClick();
|
||||
expect(updateCourseGradeLimits).toHaveBeenCalledWith(courseGradeLimits);
|
||||
expect(fetch).toHaveBeenCalledWith();
|
||||
expect(updateQueryParams).toHaveBeenCalledWith(courseGradeLimits);
|
||||
});
|
||||
});
|
||||
});
|
||||
52
src/components/GradebookFilters/CourseGradeFilter/index.jsx
Normal file
52
src/components/GradebookFilters/CourseGradeFilter/index.jsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Button } from '@openedx/paragon';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import messages from '../messages';
|
||||
import PercentGroup from '../PercentGroup';
|
||||
import useCourseGradeFilterData from './hooks';
|
||||
|
||||
export const CourseGradeFilter = ({ updateQueryParams }) => {
|
||||
const {
|
||||
max,
|
||||
min,
|
||||
isDisabled,
|
||||
handleApplyClick,
|
||||
} = useCourseGradeFilterData({ updateQueryParams });
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grade-filter-inputs">
|
||||
<PercentGroup
|
||||
id="minimum-grade"
|
||||
label={formatMessage(messages.minGrade)}
|
||||
value={min.value}
|
||||
onChange={min.onChange}
|
||||
/>
|
||||
<PercentGroup
|
||||
id="maximum-grade"
|
||||
label={formatMessage(messages.maxGrade)}
|
||||
value={max.value}
|
||||
onChange={max.onChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="grade-filter-action">
|
||||
<Button
|
||||
variant="outline-secondary"
|
||||
onClick={handleApplyClick}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{formatMessage(messages.apply)}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
CourseGradeFilter.propTypes = {
|
||||
updateQueryParams: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default CourseGradeFilter;
|
||||
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import { screen } from '@testing-library/react';
|
||||
|
||||
import useCourseGradeFilterData from './hooks';
|
||||
import CourseFilter from '.';
|
||||
import { renderWithIntl } from '../../../testUtilsExtra';
|
||||
|
||||
jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() }));
|
||||
|
||||
const hookData = {
|
||||
handleChange: jest.fn(),
|
||||
max: {
|
||||
value: 300,
|
||||
onChange: jest.fn(),
|
||||
},
|
||||
min: {
|
||||
value: 23,
|
||||
onChange: jest.fn(),
|
||||
},
|
||||
selectedCourse: 'test-assignment',
|
||||
isDisabled: false,
|
||||
};
|
||||
useCourseGradeFilterData.mockReturnValue(hookData);
|
||||
|
||||
const updateQueryParams = jest.fn();
|
||||
|
||||
describe('CourseFilter component', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
describe('render', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
describe('with selected assignment', () => {
|
||||
beforeEach(() => {
|
||||
renderWithIntl(<CourseFilter updateQueryParams={updateQueryParams} />);
|
||||
});
|
||||
|
||||
it('renders a PercentGroup for both Max and Min filters', () => {
|
||||
expect(screen.getByRole('spinbutton', { name: 'Min Grade' })).toHaveValue(hookData.min.value);
|
||||
expect(screen.getByRole('spinbutton', { name: 'Max Grade' })).toHaveValue(hookData.max.value);
|
||||
});
|
||||
it('renders a submit button', () => {
|
||||
expect(screen.getByRole('button', { name: 'Apply' })).toBeInTheDocument();
|
||||
// Expect it to be enabled
|
||||
expect(screen.getByRole('button', { name: 'Apply' })).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
describe('if disabled', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
useCourseGradeFilterData.mockReturnValueOnce({ ...hookData, isDisabled: true });
|
||||
renderWithIntl(<CourseFilter updateQueryParams={updateQueryParams} />);
|
||||
});
|
||||
it('disables submit', () => {
|
||||
expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
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 '@openedx/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;
|
||||
35
src/components/GradebookFilters/PercentGroup.test.jsx
Normal file
35
src/components/GradebookFilters/PercentGroup.test.jsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { render, screen, initializeMocks } from 'testUtilsExtra';
|
||||
|
||||
import PercentGroup from './PercentGroup';
|
||||
|
||||
describe('PercentGroup', () => {
|
||||
let props = {
|
||||
id: 'group id',
|
||||
label: 'Group Label',
|
||||
value: 'group VALUE',
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
initializeMocks();
|
||||
props = {
|
||||
...props,
|
||||
onChange: jest.fn().mockName('props.onChange'),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Component', () => {
|
||||
test('is displayed', () => {
|
||||
render(<PercentGroup {...props} />);
|
||||
expect(screen.getByRole('spinbutton', { name: 'Group Label' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Group Label')).toBeVisible();
|
||||
expect(screen.getByText('%')).toBeVisible();
|
||||
});
|
||||
test('disabled', () => {
|
||||
render(<PercentGroup {...props} disabled />);
|
||||
expect(screen.getByRole('spinbutton', { name: 'Group Label' })).toBeDisabled();
|
||||
expect(screen.getByText('Group Label')).toBeVisible();
|
||||
expect(screen.getByText('%')).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
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 '@openedx/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;
|
||||
37
src/components/GradebookFilters/SelectGroup.test.jsx
Normal file
37
src/components/GradebookFilters/SelectGroup.test.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
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', () => {
|
||||
test('rendered with all options and label', () => {
|
||||
render(<SelectGroup {...props} />);
|
||||
expect(screen.getAllByRole('option')).toHaveLength(props.options.length);
|
||||
expect(screen.getByLabelText(props.label)).toBeInTheDocument();
|
||||
});
|
||||
test('disabled', () => {
|
||||
render(<SelectGroup {...props} disabled />);
|
||||
expect(screen.getByRole('combobox')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
46
src/components/GradebookFilters/StudentGroupsFilter/hooks.js
Normal file
46
src/components/GradebookFilters/StudentGroupsFilter/hooks.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import { actions, selectors, thunkActions } from 'data/redux/hooks';
|
||||
|
||||
export const useStudentGroupsFilterData = ({ updateQueryParams }) => {
|
||||
const selectedCohortEntry = selectors.root.useSelectedCohortEntry();
|
||||
const selectedTrackEntry = selectors.root.useSelectedTrackEntry();
|
||||
|
||||
const cohorts = selectors.cohorts.useAllCohorts();
|
||||
const tracks = selectors.tracks.useAllTracks();
|
||||
|
||||
const updateCohort = actions.filters.useUpdateCohort();
|
||||
const updateTrack = actions.filters.useUpdateTrack();
|
||||
|
||||
const fetchGrades = thunkActions.grades.useFetchGrades();
|
||||
|
||||
const handleUpdateTrack = (event) => {
|
||||
const selectedTrackItem = tracks.find(track => track.slug === event.target.value);
|
||||
const track = selectedTrackItem ? selectedTrackItem.slug.toString() : null;
|
||||
updateQueryParams({ track });
|
||||
updateTrack(track);
|
||||
fetchGrades();
|
||||
};
|
||||
|
||||
const handleUpdateCohort = (event) => {
|
||||
const selectedCohortItem = cohorts.find(cohort => cohort.id === parseInt(event.target.value, 10));
|
||||
const cohort = selectedCohortItem ? selectedCohortItem.id.toString() : null;
|
||||
// the param expected to be cohort_id
|
||||
updateQueryParams({ cohort });
|
||||
updateCohort(cohort);
|
||||
fetchGrades();
|
||||
};
|
||||
return {
|
||||
cohorts: {
|
||||
value: selectedCohortEntry?.id || '',
|
||||
isDisabled: cohorts.length === 0,
|
||||
handleChange: handleUpdateCohort,
|
||||
entries: cohorts.map(({ id: value, name }) => ({ value, name })),
|
||||
},
|
||||
tracks: {
|
||||
value: selectedTrackEntry?.slug || '',
|
||||
handleChange: handleUpdateTrack,
|
||||
entries: tracks.map(({ slug: value, name }) => ({ value, name })),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default useStudentGroupsFilterData;
|
||||
@@ -0,0 +1,141 @@
|
||||
import { selectors, actions, thunkActions } from 'data/redux/hooks';
|
||||
|
||||
import useAssignmentFilterData from './hooks';
|
||||
|
||||
jest.mock('data/redux/hooks', () => ({
|
||||
selectors: {
|
||||
root: {
|
||||
useSelectedCohortEntry: jest.fn(),
|
||||
useSelectedTrackEntry: jest.fn(),
|
||||
},
|
||||
cohorts: { useAllCohorts: jest.fn() },
|
||||
tracks: { useAllTracks: jest.fn() },
|
||||
},
|
||||
actions: {
|
||||
filters: {
|
||||
useUpdateCohort: jest.fn(),
|
||||
useUpdateTrack: jest.fn(),
|
||||
},
|
||||
},
|
||||
thunkActions: {
|
||||
grades: { useFetchGrades: jest.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
let out;
|
||||
|
||||
const testCohort = { name: 'cohort-name', id: 999 };
|
||||
selectors.root.useSelectedCohortEntry.mockReturnValue(testCohort);
|
||||
const testTrack = { name: 'track-name', slug: 8080 };
|
||||
selectors.root.useSelectedTrackEntry.mockReturnValue(testTrack);
|
||||
const allCohorts = [
|
||||
testCohort,
|
||||
{ name: 'cohort1', id: 11 },
|
||||
{ name: 'cohort2', id: 22 },
|
||||
{ name: 'cohort3', id: 33 },
|
||||
];
|
||||
selectors.cohorts.useAllCohorts.mockReturnValue(allCohorts);
|
||||
const allTracks = [
|
||||
testTrack,
|
||||
{ name: 'track1', slug: 111 },
|
||||
{ name: 'track2', slug: 222 },
|
||||
{ name: 'track3', slug: 333 },
|
||||
];
|
||||
selectors.tracks.useAllTracks.mockReturnValue(allTracks);
|
||||
|
||||
const updateCohort = jest.fn();
|
||||
actions.filters.useUpdateCohort.mockReturnValue(updateCohort);
|
||||
const updateTrack = jest.fn();
|
||||
actions.filters.useUpdateTrack.mockReturnValue(updateTrack);
|
||||
const fetch = jest.fn();
|
||||
thunkActions.grades.useFetchGrades.mockReturnValue(fetch);
|
||||
|
||||
const updateQueryParams = jest.fn();
|
||||
|
||||
describe('useAssignmentFilterData hook', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
out = useAssignmentFilterData({ updateQueryParams });
|
||||
});
|
||||
describe('behavior', () => {
|
||||
it('initializes redux hooks', () => {
|
||||
expect(selectors.root.useSelectedCohortEntry).toHaveBeenCalledWith();
|
||||
expect(selectors.root.useSelectedTrackEntry).toHaveBeenCalledWith();
|
||||
expect(selectors.cohorts.useAllCohorts).toHaveBeenCalledWith();
|
||||
expect(selectors.tracks.useAllTracks).toHaveBeenCalledWith();
|
||||
expect(actions.filters.useUpdateCohort).toHaveBeenCalledWith();
|
||||
expect(actions.filters.useUpdateTrack).toHaveBeenCalledWith();
|
||||
expect(thunkActions.grades.useFetchGrades).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
describe('output', () => {
|
||||
describe('cohorts', () => {
|
||||
test('value from hook', () => {
|
||||
expect(out.cohorts.value).toEqual(testCohort.id);
|
||||
});
|
||||
test('disabled iff no cohorts found', () => {
|
||||
expect(out.cohorts.isDisabled).toEqual(false);
|
||||
selectors.cohorts.useAllCohorts.mockReturnValueOnce([]);
|
||||
out = useAssignmentFilterData({ updateQueryParams });
|
||||
expect(out.cohorts.isDisabled).toEqual(true);
|
||||
});
|
||||
test('entries map id to value', () => {
|
||||
const { entries } = out.cohorts;
|
||||
expect(entries[0]).toEqual({ value: testCohort.id, name: testCohort.name });
|
||||
expect(entries[1]).toEqual({ value: allCohorts[1].id, name: allCohorts[1].name });
|
||||
expect(entries[2]).toEqual({ value: allCohorts[2].id, name: allCohorts[2].name });
|
||||
expect(entries[3]).toEqual({ value: allCohorts[3].id, name: allCohorts[3].name });
|
||||
});
|
||||
test('value defaults to empty string', () => {
|
||||
selectors.root.useSelectedCohortEntry.mockReturnValueOnce(null);
|
||||
out = useAssignmentFilterData({ updateQueryParams });
|
||||
expect(out.cohorts.value).toEqual('');
|
||||
});
|
||||
describe('handleEvent', () => {
|
||||
it('updates filter and query params and fetches grades', () => {
|
||||
out.cohorts.handleChange({ target: { value: testCohort.id } });
|
||||
expect(updateCohort).toHaveBeenCalledWith(testCohort.id.toString());
|
||||
expect(updateQueryParams).toHaveBeenCalledWith({ cohort: testCohort.id.toString() });
|
||||
expect(fetch).toHaveBeenCalled();
|
||||
});
|
||||
it('passes null if no matching track is found', () => {
|
||||
out.cohorts.handleChange({ target: { value: 'fake-name' } });
|
||||
expect(updateCohort).toHaveBeenCalledWith(null);
|
||||
expect(updateQueryParams).toHaveBeenCalledWith({ cohort: null });
|
||||
expect(fetch).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('tracks', () => {
|
||||
test('value from hook', () => {
|
||||
expect(out.tracks.value).toEqual(testTrack.slug);
|
||||
});
|
||||
test('entries map slug to value', () => {
|
||||
const { entries } = out.tracks;
|
||||
expect(entries[0]).toEqual({ value: testTrack.slug, name: testTrack.name });
|
||||
expect(entries[1]).toEqual({ value: allTracks[1].slug, name: allTracks[1].name });
|
||||
expect(entries[2]).toEqual({ value: allTracks[2].slug, name: allTracks[2].name });
|
||||
expect(entries[3]).toEqual({ value: allTracks[3].slug, name: allTracks[3].name });
|
||||
});
|
||||
test('value defaults to empty string', () => {
|
||||
selectors.root.useSelectedTrackEntry.mockReturnValueOnce(null);
|
||||
out = useAssignmentFilterData({ updateQueryParams });
|
||||
expect(out.tracks.value).toEqual('');
|
||||
});
|
||||
describe('handleEvent', () => {
|
||||
it('updates filter and query params and fetches grades', () => {
|
||||
out.tracks.handleChange({ target: { value: testTrack.slug } });
|
||||
expect(updateTrack).toHaveBeenCalledWith(testTrack.slug.toString());
|
||||
expect(updateQueryParams).toHaveBeenCalledWith({ track: testTrack.slug.toString() });
|
||||
expect(fetch).toHaveBeenCalled();
|
||||
});
|
||||
it('passes null if no matching track is found', () => {
|
||||
out.tracks.handleChange({ target: { value: 'fake-name' } });
|
||||
expect(updateTrack).toHaveBeenCalledWith(null);
|
||||
expect(updateQueryParams).toHaveBeenCalledWith({ track: null });
|
||||
expect(fetch).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
/* eslint-disable react/sort-comp, react/button-has-type, import/no-named-as-default */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import messages from '../messages';
|
||||
import SelectGroup from '../SelectGroup';
|
||||
import useStudentGroupsFilterData from './hooks';
|
||||
|
||||
const mapOptions = ({ value, name }) => (
|
||||
<option key={name} value={value}>{name}</option>
|
||||
);
|
||||
|
||||
export const StudentGroupsFilter = ({ updateQueryParams }) => {
|
||||
const { tracks, cohorts } = useStudentGroupsFilterData({ updateQueryParams });
|
||||
const { formatMessage } = useIntl();
|
||||
return (
|
||||
<>
|
||||
<SelectGroup
|
||||
id="Tracks"
|
||||
label={formatMessage(messages.tracks)}
|
||||
value={tracks.value}
|
||||
onChange={tracks.handleChange}
|
||||
options={[
|
||||
<option value={formatMessage(messages.trackAll)} key="0">
|
||||
{formatMessage(messages.trackAll)}
|
||||
</option>,
|
||||
...tracks.entries.map(mapOptions),
|
||||
]}
|
||||
/>
|
||||
<SelectGroup
|
||||
id="Cohorts"
|
||||
label={formatMessage(messages.cohorts)}
|
||||
value={cohorts.value}
|
||||
disabled={cohorts.isDisabled}
|
||||
onChange={cohorts.handleChange}
|
||||
options={[
|
||||
<option value={formatMessage(messages.cohortAll)} key="0">
|
||||
{formatMessage(messages.cohortAll)}
|
||||
</option>,
|
||||
...cohorts.entries.map(mapOptions),
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
StudentGroupsFilter.propTypes = {
|
||||
updateQueryParams: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default StudentGroupsFilter;
|
||||
@@ -0,0 +1,164 @@
|
||||
import React from 'react';
|
||||
import { render, screen, initializeMocks } from 'testUtilsExtra';
|
||||
|
||||
import SelectGroup from '../SelectGroup';
|
||||
import { StudentGroupsFilter } from './index';
|
||||
import useStudentGroupsFilterData from './hooks';
|
||||
|
||||
jest.mock('../SelectGroup', () => jest.fn(() => <div data-testid="select-group">SelectGroup</div>));
|
||||
jest.mock('./hooks', () => jest.fn());
|
||||
|
||||
initializeMocks();
|
||||
|
||||
describe('StudentGroupsFilter', () => {
|
||||
const mockUpdateQueryParams = jest.fn();
|
||||
|
||||
const mockTracksData = {
|
||||
value: 'test-track-value',
|
||||
entries: [
|
||||
{ value: 'track1', name: 'Track 1' },
|
||||
{ value: 'track2', name: 'Track 2' },
|
||||
],
|
||||
handleChange: jest.fn(),
|
||||
};
|
||||
|
||||
const mockCohortsData = {
|
||||
value: 'test-cohort-value',
|
||||
entries: [
|
||||
{ value: 'cohort1', name: 'Cohort 1' },
|
||||
{ value: 'cohort2', name: 'Cohort 2' },
|
||||
],
|
||||
handleChange: jest.fn(),
|
||||
isDisabled: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
useStudentGroupsFilterData.mockReturnValue({
|
||||
tracks: mockTracksData,
|
||||
cohorts: mockCohortsData,
|
||||
});
|
||||
});
|
||||
|
||||
it('calls useStudentGroupsFilterData hook with updateQueryParams', () => {
|
||||
render(<StudentGroupsFilter updateQueryParams={mockUpdateQueryParams} />);
|
||||
|
||||
expect(useStudentGroupsFilterData).toHaveBeenCalledWith({
|
||||
updateQueryParams: mockUpdateQueryParams,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders two SelectGroup components', () => {
|
||||
render(<StudentGroupsFilter updateQueryParams={mockUpdateQueryParams} />);
|
||||
|
||||
expect(SelectGroup).toHaveBeenCalledTimes(2);
|
||||
expect(screen.getAllByTestId('select-group')).toHaveLength(2);
|
||||
});
|
||||
|
||||
describe('tracks SelectGroup', () => {
|
||||
it('renders tracks SelectGroup with correct props', () => {
|
||||
render(<StudentGroupsFilter updateQueryParams={mockUpdateQueryParams} />);
|
||||
|
||||
const tracksCall = SelectGroup.mock.calls[0][0];
|
||||
expect(tracksCall.id).toBe('Tracks');
|
||||
expect(tracksCall.value).toBe(mockTracksData.value);
|
||||
expect(tracksCall.onChange).toBe(mockTracksData.handleChange);
|
||||
});
|
||||
|
||||
it('includes trackAll option in tracks SelectGroup', () => {
|
||||
render(<StudentGroupsFilter updateQueryParams={mockUpdateQueryParams} />);
|
||||
|
||||
const tracksCall = SelectGroup.mock.calls[0][0];
|
||||
const { options } = tracksCall;
|
||||
|
||||
expect(options).toHaveLength(3);
|
||||
expect(options[0].props.value).toBeDefined();
|
||||
expect(options[0].props.children).toBeDefined();
|
||||
});
|
||||
|
||||
it('includes track entries in tracks SelectGroup options', () => {
|
||||
render(<StudentGroupsFilter updateQueryParams={mockUpdateQueryParams} />);
|
||||
|
||||
const tracksCall = SelectGroup.mock.calls[0][0];
|
||||
const { options } = tracksCall;
|
||||
|
||||
expect(options[1].props.value).toBe('track1');
|
||||
expect(options[1].props.children).toBe('Track 1');
|
||||
expect(options[2].props.value).toBe('track2');
|
||||
expect(options[2].props.children).toBe('Track 2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cohorts SelectGroup', () => {
|
||||
it('renders cohorts SelectGroup with correct props', () => {
|
||||
render(<StudentGroupsFilter updateQueryParams={mockUpdateQueryParams} />);
|
||||
|
||||
const cohortsCall = SelectGroup.mock.calls[1][0];
|
||||
expect(cohortsCall.id).toBe('Cohorts');
|
||||
expect(cohortsCall.value).toBe(mockCohortsData.value);
|
||||
expect(cohortsCall.onChange).toBe(mockCohortsData.handleChange);
|
||||
expect(cohortsCall.disabled).toBe(mockCohortsData.isDisabled);
|
||||
});
|
||||
|
||||
it('includes cohortAll option in cohorts SelectGroup', () => {
|
||||
render(<StudentGroupsFilter updateQueryParams={mockUpdateQueryParams} />);
|
||||
|
||||
const cohortsCall = SelectGroup.mock.calls[1][0];
|
||||
const { options } = cohortsCall;
|
||||
|
||||
expect(options).toHaveLength(3);
|
||||
expect(options[0].props.value).toBeDefined();
|
||||
expect(options[0].props.children).toBeDefined();
|
||||
});
|
||||
|
||||
it('includes cohort entries in cohorts SelectGroup options', () => {
|
||||
render(<StudentGroupsFilter updateQueryParams={mockUpdateQueryParams} />);
|
||||
|
||||
const cohortsCall = SelectGroup.mock.calls[1][0];
|
||||
const { options } = cohortsCall;
|
||||
|
||||
expect(options[1].props.value).toBe('cohort1');
|
||||
expect(options[1].props.children).toBe('Cohort 1');
|
||||
expect(options[2].props.value).toBe('cohort2');
|
||||
expect(options[2].props.children).toBe('Cohort 2');
|
||||
});
|
||||
|
||||
it('passes disabled state to cohorts SelectGroup', () => {
|
||||
useStudentGroupsFilterData.mockReturnValue({
|
||||
tracks: mockTracksData,
|
||||
cohorts: { ...mockCohortsData, isDisabled: true },
|
||||
});
|
||||
|
||||
render(<StudentGroupsFilter updateQueryParams={mockUpdateQueryParams} />);
|
||||
|
||||
const cohortsCall = SelectGroup.mock.calls[1][0];
|
||||
expect(cohortsCall.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with empty entries', () => {
|
||||
it('handles empty tracks entries', () => {
|
||||
useStudentGroupsFilterData.mockReturnValue({
|
||||
tracks: { ...mockTracksData, entries: [] },
|
||||
cohorts: mockCohortsData,
|
||||
});
|
||||
|
||||
render(<StudentGroupsFilter updateQueryParams={mockUpdateQueryParams} />);
|
||||
|
||||
const tracksCall = SelectGroup.mock.calls[0][0];
|
||||
expect(tracksCall.options).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('handles empty cohorts entries', () => {
|
||||
useStudentGroupsFilterData.mockReturnValue({
|
||||
tracks: mockTracksData,
|
||||
cohorts: { ...mockCohortsData, entries: [] },
|
||||
});
|
||||
|
||||
render(<StudentGroupsFilter updateQueryParams={mockUpdateQueryParams} />);
|
||||
|
||||
const cohortsCall = SelectGroup.mock.calls[1][0];
|
||||
expect(cohortsCall.options).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
23
src/components/GradebookFilters/hooks.js
Normal file
23
src/components/GradebookFilters/hooks.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { actions, selectors, thunkActions } from 'data/redux/hooks';
|
||||
|
||||
export const useGradebookFiltersData = ({ updateQueryParams }) => {
|
||||
const includeCourseRoleMembers = selectors.filters.useIncludeCourseRoleMembers();
|
||||
const updateIncludeCourseRoleMembers = actions.filters.useUpdateIncludeCourseRoleMembers();
|
||||
const closeMenu = thunkActions.app.filterMenu.useCloseMenu();
|
||||
const fetchGrades = thunkActions.grades.useFetchGrades();
|
||||
|
||||
const handleIncludeTeamMembersChange = ({ target: { checked } }) => {
|
||||
updateIncludeCourseRoleMembers(checked);
|
||||
fetchGrades();
|
||||
updateQueryParams({ includeCourseRoleMembers: checked });
|
||||
};
|
||||
return {
|
||||
closeMenu,
|
||||
includeCourseTeamMembers: {
|
||||
handleChange: handleIncludeTeamMembersChange,
|
||||
value: includeCourseRoleMembers,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default useGradebookFiltersData;
|
||||
59
src/components/GradebookFilters/hooks.test.jsx
Normal file
59
src/components/GradebookFilters/hooks.test.jsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { actions, selectors, thunkActions } from 'data/redux/hooks';
|
||||
import * as hooks from './hooks';
|
||||
|
||||
jest.mock('data/redux/hooks', () => ({
|
||||
actions: {
|
||||
filters: { useUpdateIncludeCourseRoleMembers: jest.fn() },
|
||||
},
|
||||
selectors: {
|
||||
filters: { useIncludeCourseRoleMembers: jest.fn() },
|
||||
},
|
||||
thunkActions: {
|
||||
app: {
|
||||
filterMenu: { useCloseMenu: jest.fn() },
|
||||
},
|
||||
grades: { useFetchGrades: jest.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
selectors.filters.useIncludeCourseRoleMembers.mockReturnValue(true);
|
||||
const updateIncludeCourseRoleMembers = jest.fn();
|
||||
actions.filters.useUpdateIncludeCourseRoleMembers.mockReturnValue(updateIncludeCourseRoleMembers);
|
||||
const closeFilterMenu = jest.fn();
|
||||
thunkActions.app.filterMenu.useCloseMenu.mockReturnValue(closeFilterMenu);
|
||||
const fetchGrades = jest.fn();
|
||||
thunkActions.grades.useFetchGrades.mockReturnValue(fetchGrades);
|
||||
|
||||
const updateQueryParams = jest.fn();
|
||||
|
||||
let out;
|
||||
describe('GradebookFiltersData component hooks', () => {
|
||||
describe('useGradebookFiltersData', () => {
|
||||
beforeEach(() => {
|
||||
out = hooks.useGradebookFiltersData({ updateQueryParams });
|
||||
});
|
||||
describe('behavior', () => {
|
||||
it('initializes hooks', () => {
|
||||
expect(actions.filters.useUpdateIncludeCourseRoleMembers).toHaveBeenCalledWith();
|
||||
expect(selectors.filters.useIncludeCourseRoleMembers).toHaveBeenCalledWith();
|
||||
expect(thunkActions.app.filterMenu.useCloseMenu).toHaveBeenCalledWith();
|
||||
expect(thunkActions.grades.useFetchGrades).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
describe('output', () => {
|
||||
test('closeMenu', () => {
|
||||
expect(out.closeMenu).toEqual(closeFilterMenu);
|
||||
});
|
||||
test('includeCourseTeamMembers value', () => {
|
||||
expect(out.includeCourseTeamMembers.value).toEqual(true);
|
||||
});
|
||||
test('includeCourseTeamMembers handleChange', () => {
|
||||
const event = { target: { checked: false } };
|
||||
out.includeCourseTeamMembers.handleChange(event);
|
||||
expect(updateIncludeCourseRoleMembers).toHaveBeenCalledWith(false);
|
||||
expect(fetchGrades).toHaveBeenCalledWith();
|
||||
expect(updateQueryParams).toHaveBeenCalledWith({ includeCourseRoleMembers: false });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
89
src/components/GradebookFilters/index.jsx
Normal file
89
src/components/GradebookFilters/index.jsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
Icon,
|
||||
IconButton,
|
||||
Form,
|
||||
} from '@openedx/paragon';
|
||||
import { Close } from '@openedx/paragon/icons';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import messages from './messages';
|
||||
import AssignmentTypeFilter from './AssignmentTypeFilter';
|
||||
import AssignmentFilter from './AssignmentFilter';
|
||||
import AssignmentGradeFilter from './AssignmentGradeFilter';
|
||||
import CourseGradeFilter from './CourseGradeFilter';
|
||||
import StudentGroupsFilter from './StudentGroupsFilter';
|
||||
import useGradebookFiltersData from './hooks';
|
||||
|
||||
export const GradebookFilters = ({ updateQueryParams }) => {
|
||||
const {
|
||||
closeMenu,
|
||||
includeCourseTeamMembers,
|
||||
} = useGradebookFiltersData({ updateQueryParams });
|
||||
const { formatMessage } = useIntl();
|
||||
const collapsibleClassName = 'filter-group mb-3';
|
||||
return (
|
||||
<>
|
||||
<div className="filter-sidebar-header">
|
||||
<h2><Icon className="fa fa-filter" /></h2>
|
||||
<IconButton
|
||||
className="p-1"
|
||||
onClick={closeMenu}
|
||||
iconAs={Icon}
|
||||
src={Close}
|
||||
alt={formatMessage(messages.closeFilters)}
|
||||
aria-label={formatMessage(messages.closeFilters)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Collapsible
|
||||
title={formatMessage(messages.assignments)}
|
||||
defaultOpen
|
||||
className={collapsibleClassName}
|
||||
>
|
||||
<div>
|
||||
<AssignmentTypeFilter updateQueryParams={updateQueryParams} />
|
||||
<AssignmentFilter updateQueryParams={updateQueryParams} />
|
||||
<AssignmentGradeFilter updateQueryParams={updateQueryParams} />
|
||||
</div>
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible
|
||||
title={formatMessage(messages.overallGrade)}
|
||||
defaultOpen
|
||||
className={collapsibleClassName}
|
||||
>
|
||||
<CourseGradeFilter updateQueryParams={updateQueryParams} />
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible
|
||||
title={formatMessage(messages.studentGroups)}
|
||||
defaultOpen
|
||||
className={collapsibleClassName}
|
||||
>
|
||||
<StudentGroupsFilter updateQueryParams={updateQueryParams} />
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible
|
||||
title={formatMessage(messages.includeCourseTeamMembers)}
|
||||
defaultOpen
|
||||
className={collapsibleClassName}
|
||||
>
|
||||
<Form.Checkbox
|
||||
checked={includeCourseTeamMembers.value}
|
||||
onChange={includeCourseTeamMembers.handleChange}
|
||||
>
|
||||
{formatMessage(messages.includeCourseTeamMembers)}
|
||||
</Form.Checkbox>
|
||||
</Collapsible>
|
||||
</>
|
||||
);
|
||||
};
|
||||
GradebookFilters.propTypes = {
|
||||
updateQueryParams: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default GradebookFilters;
|
||||
30
src/components/GradebookFilters/index.test.jsx
Normal file
30
src/components/GradebookFilters/index.test.jsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import { render, screen, initializeMocks } from 'testUtilsExtra';
|
||||
|
||||
import GradebookFilters from '.';
|
||||
|
||||
const updateQueryParams = jest.fn();
|
||||
|
||||
initializeMocks();
|
||||
|
||||
describe('GradebookFilters', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
render(<GradebookFilters updateQueryParams={updateQueryParams} />);
|
||||
});
|
||||
describe('All filters render together', () => {
|
||||
test('Assignment filters', () => {
|
||||
expect(screen.getByRole('combobox', { name: 'Assignment Types' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('combobox', { name: 'Assignment' })).toBeInTheDocument();
|
||||
});
|
||||
test('CourseGrade filters', () => {
|
||||
expect(screen.getByRole('button', { name: 'Overall Grade' })).toBeInTheDocument();
|
||||
});
|
||||
test('StudentGroups filters', () => {
|
||||
expect(screen.getByRole('button', { name: 'Student Groups' })).toBeInTheDocument();
|
||||
});
|
||||
test('includeCourseTeamMembers', () => {
|
||||
expect(screen.getByRole('button', { name: 'Include Course Team Members' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
76
src/components/GradebookFilters/messages.js
Normal file
76
src/components/GradebookFilters/messages.js
Normal file
@@ -0,0 +1,76 @@
|
||||
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',
|
||||
},
|
||||
apply: {
|
||||
id: 'gradebook.GradebookFilters.apply',
|
||||
defaultMessage: 'Apply',
|
||||
description: 'Apply filter button text',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
35
src/components/GradebookHeader/hooks.js
Normal file
35
src/components/GradebookHeader/hooks.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import { views } from 'data/constants/app';
|
||||
import { actions, selectors } from 'data/redux/hooks';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
export const useGradebookHeaderData = () => {
|
||||
const activeView = selectors.app.useActiveView();
|
||||
const courseId = selectors.app.useCourseId();
|
||||
const areGradesFrozen = selectors.assignmentTypes.useAreGradesFrozen();
|
||||
const canUserViewGradebook = selectors.roles.useCanUserViewGradebook();
|
||||
const showBulkManagement = selectors.root.useShowBulkManagement();
|
||||
const setView = actions.app.useSetView();
|
||||
|
||||
const handleToggleViewClick = () => setView(
|
||||
activeView === views.grades
|
||||
? views.bulkManagementHistory
|
||||
: views.grades,
|
||||
);
|
||||
|
||||
const toggleViewMessage = activeView === views.grades
|
||||
? messages.toActivityLog
|
||||
: messages.toGradesView;
|
||||
|
||||
return {
|
||||
areGradesFrozen,
|
||||
canUserViewGradebook,
|
||||
courseId,
|
||||
showBulkManagement,
|
||||
|
||||
handleToggleViewClick,
|
||||
toggleViewMessage,
|
||||
};
|
||||
};
|
||||
|
||||
export default useGradebookHeaderData;
|
||||
90
src/components/GradebookHeader/hooks.test.js
Normal file
90
src/components/GradebookHeader/hooks.test.js
Normal file
@@ -0,0 +1,90 @@
|
||||
import { views } from 'data/constants/app';
|
||||
import { actions, selectors } from 'data/redux/hooks';
|
||||
|
||||
import messages from './messages';
|
||||
import useGradebookHeaderData from './hooks';
|
||||
|
||||
jest.mock('data/redux/hooks', () => ({
|
||||
actions: {
|
||||
app: {
|
||||
useSetView: jest.fn(),
|
||||
},
|
||||
},
|
||||
selectors: {
|
||||
app: {
|
||||
useActiveView: jest.fn(),
|
||||
useCourseId: jest.fn(),
|
||||
},
|
||||
assignmentTypes: {
|
||||
useAreGradesFrozen: jest.fn(),
|
||||
},
|
||||
roles: {
|
||||
useCanUserViewGradebook: jest.fn(),
|
||||
},
|
||||
root: {
|
||||
useShowBulkManagement: jest.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const activeView = 'test-active-view';
|
||||
selectors.app.useActiveView.mockReturnValue(activeView);
|
||||
const courseId = 'test-course-id';
|
||||
selectors.app.useCourseId.mockReturnValue(courseId);
|
||||
const areGradesFrozen = 'test-are-grades-frozen';
|
||||
selectors.assignmentTypes.useAreGradesFrozen.mockReturnValue(areGradesFrozen);
|
||||
const canUserViewGradebook = 'test-can-user-view-gradebook';
|
||||
selectors.roles.useCanUserViewGradebook.mockReturnValue(canUserViewGradebook);
|
||||
const showBulkManagement = 'test-show-bulk-management';
|
||||
selectors.root.useShowBulkManagement.mockReturnValue(showBulkManagement);
|
||||
|
||||
const setView = jest.fn();
|
||||
actions.app.useSetView.mockReturnValue(setView);
|
||||
|
||||
let out;
|
||||
describe('useGradebookHeaderData hooks', () => {
|
||||
describe('initialization', () => {
|
||||
it('initializes redux hooks', () => {
|
||||
out = useGradebookHeaderData();
|
||||
expect(selectors.app.useActiveView).toHaveBeenCalled();
|
||||
expect(selectors.app.useCourseId).toHaveBeenCalled();
|
||||
expect(selectors.assignmentTypes.useAreGradesFrozen).toHaveBeenCalled();
|
||||
expect(selectors.roles.useCanUserViewGradebook).toHaveBeenCalled();
|
||||
expect(selectors.root.useShowBulkManagement).toHaveBeenCalled();
|
||||
expect(actions.app.useSetView).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
describe('output', () => {
|
||||
test('redux fields', () => {
|
||||
out = useGradebookHeaderData();
|
||||
expect(out.areGradesFrozen).toEqual(areGradesFrozen);
|
||||
expect(out.canUserViewGradebook).toEqual(canUserViewGradebook);
|
||||
expect(out.courseId).toEqual(courseId);
|
||||
expect(out.showBulkManagement).toEqual(showBulkManagement);
|
||||
});
|
||||
describe('handleToggleViewClick', () => {
|
||||
it('calls setView with bulkManagemnetHistory message if grades view is active', () => {
|
||||
selectors.app.useActiveView.mockReturnValueOnce(views.grades);
|
||||
out = useGradebookHeaderData();
|
||||
out.handleToggleViewClick();
|
||||
expect(setView).toHaveBeenCalledWith(views.bulkManagementHistory);
|
||||
});
|
||||
it('calls setView with grades view if grades view is not active', () => {
|
||||
out = useGradebookHeaderData();
|
||||
out.handleToggleViewClick();
|
||||
expect(setView).toHaveBeenCalledWith(views.grades);
|
||||
});
|
||||
});
|
||||
describe('toggleViewMessage', () => {
|
||||
it('returns toActivityLog message if grades view is active', () => {
|
||||
selectors.app.useActiveView.mockReturnValueOnce(views.grades);
|
||||
out = useGradebookHeaderData();
|
||||
expect(out.toggleViewMessage).toEqual(messages.toActivityLog);
|
||||
});
|
||||
it('returns toGradesView message if grades view is not active', () => {
|
||||
out = useGradebookHeaderData();
|
||||
expect(out.toggleViewMessage).toEqual(messages.toGradesView);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
50
src/components/GradebookHeader/index.jsx
Normal file
50
src/components/GradebookHeader/index.jsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Button } from '@openedx/paragon';
|
||||
|
||||
import { instructorDashboardUrl } from 'data/services/lms/urls';
|
||||
import useGradebookHeaderData from './hooks';
|
||||
import messages from './messages';
|
||||
|
||||
export const GradebookHeader = () => {
|
||||
const { formatMessage } = useIntl();
|
||||
const {
|
||||
areGradesFrozen,
|
||||
canUserViewGradebook,
|
||||
courseId,
|
||||
handleToggleViewClick,
|
||||
showBulkManagement,
|
||||
toggleViewMessage,
|
||||
} = useGradebookHeaderData();
|
||||
const dashboardUrl = instructorDashboardUrl();
|
||||
return (
|
||||
<div className="gradebook-header">
|
||||
<a href={dashboardUrl} className="mb-3">
|
||||
<span aria-hidden="true">{'<< '}</span>
|
||||
{formatMessage(messages.backToDashboard)}
|
||||
</a>
|
||||
<h1>{formatMessage(messages.gradebook)}</h1>
|
||||
<div className="subtitle-row d-flex justify-content-between align-items-center">
|
||||
<h2 className="text-break">{courseId}</h2>
|
||||
{showBulkManagement && (
|
||||
<Button variant="tertiary" onClick={handleToggleViewClick}>
|
||||
{formatMessage(toggleViewMessage)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{areGradesFrozen && (
|
||||
<div className="alert alert-warning" role="alert">
|
||||
{formatMessage(messages.frozenWarning)}
|
||||
</div>
|
||||
)}
|
||||
{(canUserViewGradebook === false) && (
|
||||
<div className="alert alert-warning" role="alert">
|
||||
{formatMessage(messages.unauthorizedWarning)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GradebookHeader;
|
||||
300
src/components/GradebookHeader/index.test.jsx
Normal file
300
src/components/GradebookHeader/index.test.jsx
Normal file
@@ -0,0 +1,300 @@
|
||||
import React from 'react';
|
||||
import { render, screen, initializeMocks } from 'testUtilsExtra';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { instructorDashboardUrl } from 'data/services/lms/urls';
|
||||
|
||||
import { GradebookHeader } from './index';
|
||||
import useGradebookHeaderData from './hooks';
|
||||
import messages from './messages';
|
||||
|
||||
jest.mock('data/services/lms/urls', () => ({
|
||||
instructorDashboardUrl: jest.fn(),
|
||||
}));
|
||||
jest.mock('./hooks', () => jest.fn());
|
||||
|
||||
initializeMocks();
|
||||
|
||||
describe('GradebookHeader', () => {
|
||||
const mockHandleToggleViewClick = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
instructorDashboardUrl.mockReturnValue('https://example.com/dashboard');
|
||||
});
|
||||
|
||||
describe('basic rendering', () => {
|
||||
beforeEach(() => {
|
||||
useGradebookHeaderData.mockReturnValue({
|
||||
areGradesFrozen: false,
|
||||
canUserViewGradebook: true,
|
||||
courseId: 'course-v1:TestU+CS101+2024',
|
||||
handleToggleViewClick: mockHandleToggleViewClick,
|
||||
showBulkManagement: false,
|
||||
toggleViewMessage: messages.toActivityLog,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the main header container', () => {
|
||||
render(<GradebookHeader />);
|
||||
const header = screen.getByText('Gradebook').closest('.gradebook-header');
|
||||
expect(header).toHaveClass('gradebook-header');
|
||||
});
|
||||
|
||||
it('renders back to dashboard link', () => {
|
||||
render(<GradebookHeader />);
|
||||
const dashboardLink = screen.getByRole('link');
|
||||
expect(dashboardLink).toHaveAttribute(
|
||||
'href',
|
||||
'https://example.com/dashboard',
|
||||
);
|
||||
expect(dashboardLink).toHaveClass('mb-3');
|
||||
expect(dashboardLink).toHaveTextContent('Back to Dashboard');
|
||||
});
|
||||
|
||||
it('renders gradebook title', () => {
|
||||
render(<GradebookHeader />);
|
||||
const title = screen.getByRole('heading', { level: 1 });
|
||||
expect(title).toHaveTextContent('Gradebook');
|
||||
});
|
||||
|
||||
it('renders course ID subtitle', () => {
|
||||
render(<GradebookHeader />);
|
||||
const subtitle = screen.getByRole('heading', { level: 2 });
|
||||
expect(subtitle).toHaveTextContent('course-v1:TestU+CS101+2024');
|
||||
expect(subtitle).toHaveClass('text-break');
|
||||
});
|
||||
|
||||
it('renders subtitle row with correct classes', () => {
|
||||
render(<GradebookHeader />);
|
||||
const subtitleRow = screen.getByRole('heading', {
|
||||
level: 2,
|
||||
}).parentElement;
|
||||
expect(subtitleRow).toHaveClass(
|
||||
'subtitle-row',
|
||||
'd-flex',
|
||||
'justify-content-between',
|
||||
'align-items-center',
|
||||
);
|
||||
});
|
||||
|
||||
it('calls instructorDashboardUrl to get dashboard URL', () => {
|
||||
render(<GradebookHeader />);
|
||||
expect(instructorDashboardUrl).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls useGradebookHeaderData hook', () => {
|
||||
render(<GradebookHeader />);
|
||||
expect(useGradebookHeaderData).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulk management toggle button', () => {
|
||||
describe('when showBulkManagement is true', () => {
|
||||
beforeEach(() => {
|
||||
useGradebookHeaderData.mockReturnValue({
|
||||
areGradesFrozen: false,
|
||||
canUserViewGradebook: true,
|
||||
courseId: 'course-v1:TestU+CS101+2024',
|
||||
handleToggleViewClick: mockHandleToggleViewClick,
|
||||
showBulkManagement: true,
|
||||
toggleViewMessage: messages.toActivityLog,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders toggle view button', () => {
|
||||
render(<GradebookHeader />);
|
||||
expect(screen.getByRole('button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays correct button text from toggleViewMessage', () => {
|
||||
render(<GradebookHeader />);
|
||||
const toggleButton = screen.getByRole('button');
|
||||
expect(toggleButton).toHaveTextContent('View Bulk Management History');
|
||||
});
|
||||
|
||||
it('calls handleToggleViewClick when button is clicked', async () => {
|
||||
render(<GradebookHeader />);
|
||||
const user = userEvent.setup();
|
||||
const toggleButton = screen.getByRole('button');
|
||||
|
||||
await user.click(toggleButton);
|
||||
expect(mockHandleToggleViewClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('displays correct message from toggleViewMessage', () => {
|
||||
useGradebookHeaderData.mockReturnValue({
|
||||
areGradesFrozen: false,
|
||||
canUserViewGradebook: true,
|
||||
courseId: 'course-v1:TestU+CS101+2024',
|
||||
handleToggleViewClick: mockHandleToggleViewClick,
|
||||
showBulkManagement: true,
|
||||
toggleViewMessage: messages.toGradesView,
|
||||
});
|
||||
|
||||
render(<GradebookHeader />);
|
||||
const toggleButton = screen.getByRole('button');
|
||||
expect(toggleButton).toHaveTextContent('Return to Gradebook');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when showBulkManagement is false', () => {
|
||||
beforeEach(() => {
|
||||
useGradebookHeaderData.mockReturnValue({
|
||||
areGradesFrozen: false,
|
||||
canUserViewGradebook: true,
|
||||
courseId: 'course-v1:TestU+CS101+2024',
|
||||
handleToggleViewClick: mockHandleToggleViewClick,
|
||||
showBulkManagement: false,
|
||||
toggleViewMessage: messages.toActivityLog,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not render toggle view button', () => {
|
||||
render(<GradebookHeader />);
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('frozen grades warning', () => {
|
||||
describe('when areGradesFrozen is true', () => {
|
||||
beforeEach(() => {
|
||||
useGradebookHeaderData.mockReturnValue({
|
||||
areGradesFrozen: true,
|
||||
canUserViewGradebook: true,
|
||||
courseId: 'course-v1:TestU+CS101+2024',
|
||||
handleToggleViewClick: mockHandleToggleViewClick,
|
||||
showBulkManagement: false,
|
||||
toggleViewMessage: messages.toActivityLog,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders frozen warning alert', () => {
|
||||
render(<GradebookHeader />);
|
||||
const alert = screen.getByRole('alert');
|
||||
expect(alert).toHaveClass('alert', 'alert-warning');
|
||||
expect(alert).toHaveTextContent(
|
||||
'The grades for this course are now frozen. Editing of grades is no longer allowed.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when areGradesFrozen is false', () => {
|
||||
beforeEach(() => {
|
||||
useGradebookHeaderData.mockReturnValue({
|
||||
areGradesFrozen: false,
|
||||
canUserViewGradebook: true,
|
||||
courseId: 'course-v1:TestU+CS101+2024',
|
||||
handleToggleViewClick: mockHandleToggleViewClick,
|
||||
showBulkManagement: false,
|
||||
toggleViewMessage: messages.toActivityLog,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not render frozen warning alert', () => {
|
||||
render(<GradebookHeader />);
|
||||
expect(
|
||||
screen.queryByText(
|
||||
'The grades for this course are now frozen. Editing of grades is no longer allowed.',
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('unauthorized warning', () => {
|
||||
describe('when canUserViewGradebook is false', () => {
|
||||
beforeEach(() => {
|
||||
useGradebookHeaderData.mockReturnValue({
|
||||
areGradesFrozen: false,
|
||||
canUserViewGradebook: false,
|
||||
courseId: 'course-v1:TestU+CS101+2024',
|
||||
handleToggleViewClick: mockHandleToggleViewClick,
|
||||
showBulkManagement: false,
|
||||
toggleViewMessage: messages.toActivityLog,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders unauthorized warning alert', () => {
|
||||
render(<GradebookHeader />);
|
||||
const alert = screen.getByRole('alert');
|
||||
expect(alert).toHaveClass('alert', 'alert-warning');
|
||||
expect(alert).toHaveTextContent(
|
||||
'You are not authorized to view the gradebook for this course.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when canUserViewGradebook is true', () => {
|
||||
beforeEach(() => {
|
||||
useGradebookHeaderData.mockReturnValue({
|
||||
areGradesFrozen: false,
|
||||
canUserViewGradebook: true,
|
||||
courseId: 'course-v1:TestU+CS101+2024',
|
||||
handleToggleViewClick: mockHandleToggleViewClick,
|
||||
showBulkManagement: false,
|
||||
toggleViewMessage: messages.toActivityLog,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not render unauthorized warning alert', () => {
|
||||
render(<GradebookHeader />);
|
||||
expect(
|
||||
screen.queryByText(
|
||||
'You are not authorized to view the gradebook for this course.',
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple warnings', () => {
|
||||
it('renders both frozen and unauthorized warnings when both conditions are true', () => {
|
||||
useGradebookHeaderData.mockReturnValue({
|
||||
areGradesFrozen: true,
|
||||
canUserViewGradebook: false,
|
||||
courseId: 'course-v1:TestU+CS101+2024',
|
||||
handleToggleViewClick: mockHandleToggleViewClick,
|
||||
showBulkManagement: false,
|
||||
toggleViewMessage: messages.toActivityLog,
|
||||
});
|
||||
|
||||
render(<GradebookHeader />);
|
||||
const alerts = screen.getAllByRole('alert');
|
||||
expect(alerts).toHaveLength(2);
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
'The grades for this course are now frozen. Editing of grades is no longer allowed.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'You are not authorized to view the gradebook for this course.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('complete integration', () => {
|
||||
it('renders all elements when showBulkManagement is true', () => {
|
||||
useGradebookHeaderData.mockReturnValue({
|
||||
areGradesFrozen: false,
|
||||
canUserViewGradebook: true,
|
||||
courseId: 'course-v1:TestU+CS101+2024',
|
||||
handleToggleViewClick: mockHandleToggleViewClick,
|
||||
showBulkManagement: true,
|
||||
toggleViewMessage: messages.toActivityLog,
|
||||
});
|
||||
|
||||
render(<GradebookHeader />);
|
||||
|
||||
expect(screen.getByRole('link')).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { level: 2 })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
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;
|
||||
18
src/components/GradesView/BulkManagementControls/hooks.js
Normal file
18
src/components/GradesView/BulkManagementControls/hooks.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { actions, selectors } from 'data/redux/hooks';
|
||||
|
||||
export const useBulkManagementControlsData = () => {
|
||||
const gradeExportUrl = selectors.root.useGradeExportUrl();
|
||||
const showBulkManagement = selectors.root.useShowBulkManagement();
|
||||
const downloadBulkGradesReport = actions.grades.useDownloadBulkGradesReport();
|
||||
|
||||
const handleClickExportGrades = () => {
|
||||
downloadBulkGradesReport();
|
||||
window.location.assign(gradeExportUrl);
|
||||
};
|
||||
|
||||
return {
|
||||
show: showBulkManagement,
|
||||
handleClickExportGrades,
|
||||
};
|
||||
};
|
||||
export default useBulkManagementControlsData;
|
||||
@@ -0,0 +1,72 @@
|
||||
import { actions, selectors } from 'data/redux/hooks';
|
||||
|
||||
import useBulkManagementControlsData from './hooks';
|
||||
|
||||
jest.mock('data/redux/hooks', () => ({
|
||||
actions: {
|
||||
grades: {
|
||||
useDownloadBulkGradesReport: jest.fn(),
|
||||
},
|
||||
},
|
||||
selectors: {
|
||||
root: {
|
||||
useGradeExportUrl: jest.fn(),
|
||||
useShowBulkManagement: jest.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const downloadBulkGrades = jest.fn();
|
||||
actions.grades.useDownloadBulkGradesReport.mockReturnValue(downloadBulkGrades);
|
||||
const gradeExportUrl = 'test-grade-export-url';
|
||||
selectors.root.useGradeExportUrl.mockReturnValue(gradeExportUrl);
|
||||
selectors.root.useShowBulkManagement.mockReturnValue(true);
|
||||
|
||||
let hook;
|
||||
describe('useBulkManagementControlsData', () => {
|
||||
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();
|
||||
hook = useBulkManagementControlsData();
|
||||
});
|
||||
afterAll(() => {
|
||||
// restore `window.location` to the `jsdom` `Location` object
|
||||
window.location = oldWindowLocation;
|
||||
});
|
||||
describe('initialization', () => {
|
||||
it('initializes redux hooks', () => {
|
||||
expect(selectors.root.useGradeExportUrl).toHaveBeenCalledWith();
|
||||
expect(selectors.root.useShowBulkManagement).toHaveBeenCalledWith();
|
||||
expect(actions.grades.useDownloadBulkGradesReport).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
describe('output', () => {
|
||||
it('forwards show from showBulkManagement', () => {
|
||||
expect(hook.show).toEqual(true);
|
||||
selectors.root.useShowBulkManagement.mockReturnValue(false);
|
||||
hook = useBulkManagementControlsData();
|
||||
expect(hook.show).toEqual(false);
|
||||
});
|
||||
describe('handleClickExportGrades', () => {
|
||||
beforeEach(() => {
|
||||
hook.handleClickExportGrades();
|
||||
});
|
||||
it('downloads bulk grades report', () => {
|
||||
expect(downloadBulkGrades).toHaveBeenCalledWith();
|
||||
});
|
||||
it('sets window location to grade export url', () => {
|
||||
expect(window.location.assign).toHaveBeenCalledWith(gradeExportUrl);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
33
src/components/GradesView/BulkManagementControls/index.jsx
Normal file
33
src/components/GradesView/BulkManagementControls/index.jsx
Normal file
@@ -0,0 +1,33 @@
|
||||
/* eslint-disable react/sort-comp, react/button-has-type */
|
||||
import React from 'react';
|
||||
|
||||
import NetworkButton from 'components/NetworkButton';
|
||||
import ImportGradesButton from '../ImportGradesButton';
|
||||
|
||||
import useBulkManagementControlsData from './hooks';
|
||||
import messages from './messages';
|
||||
|
||||
/**
|
||||
* <BulkManagementControls />
|
||||
* Provides download buttons for Bulk Management and Intervention reports, only if
|
||||
* showBulkManagement is set in redus.
|
||||
*/
|
||||
export const BulkManagementControls = () => {
|
||||
const {
|
||||
show,
|
||||
handleClickExportGrades,
|
||||
} = useBulkManagementControlsData();
|
||||
|
||||
if (!show) { return null; }
|
||||
return (
|
||||
<div className="d-flex">
|
||||
<NetworkButton
|
||||
label={messages.downloadGradesBtn}
|
||||
onClick={handleClickExportGrades}
|
||||
/>
|
||||
<ImportGradesButton />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BulkManagementControls;
|
||||
160
src/components/GradesView/BulkManagementControls/index.test.jsx
Normal file
160
src/components/GradesView/BulkManagementControls/index.test.jsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import React from 'react';
|
||||
import { render, screen, initializeMocks } from 'testUtilsExtra';
|
||||
|
||||
import NetworkButton from 'components/NetworkButton';
|
||||
import ImportGradesButton from '../ImportGradesButton';
|
||||
|
||||
import { BulkManagementControls } from './index';
|
||||
import useBulkManagementControlsData from './hooks';
|
||||
import messages from './messages';
|
||||
|
||||
jest.mock('components/NetworkButton', () => jest.fn(() => <div data-testid="network-button">NetworkButton</div>));
|
||||
jest.mock('../ImportGradesButton', () => jest.fn(() => (
|
||||
<div data-testid="import-grades-button">ImportGradesButton</div>
|
||||
)));
|
||||
jest.mock('./hooks', () => jest.fn());
|
||||
|
||||
initializeMocks();
|
||||
|
||||
describe('BulkManagementControls', () => {
|
||||
const mockHandleClickExportGrades = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('when show is false', () => {
|
||||
beforeEach(() => {
|
||||
useBulkManagementControlsData.mockReturnValue({
|
||||
show: false,
|
||||
handleClickExportGrades: mockHandleClickExportGrades,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders nothing when show is false', () => {
|
||||
render(<BulkManagementControls />);
|
||||
expect(screen.queryByTestId('network-button')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('import-grades-button'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render NetworkButton when show is false', () => {
|
||||
render(<BulkManagementControls />);
|
||||
expect(NetworkButton).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not render ImportGradesButton when show is false', () => {
|
||||
render(<BulkManagementControls />);
|
||||
expect(ImportGradesButton).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when show is true', () => {
|
||||
beforeEach(() => {
|
||||
useBulkManagementControlsData.mockReturnValue({
|
||||
show: true,
|
||||
handleClickExportGrades: mockHandleClickExportGrades,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the container div with correct class when show is true', () => {
|
||||
render(<BulkManagementControls />);
|
||||
const containerDiv = screen.getByTestId('network-button').parentElement;
|
||||
expect(containerDiv).toHaveClass('d-flex');
|
||||
});
|
||||
|
||||
it('renders NetworkButton with correct props', () => {
|
||||
render(<BulkManagementControls />);
|
||||
|
||||
expect(NetworkButton).toHaveBeenCalledWith(
|
||||
{
|
||||
label: messages.downloadGradesBtn,
|
||||
onClick: mockHandleClickExportGrades,
|
||||
},
|
||||
{},
|
||||
);
|
||||
expect(screen.getByTestId('network-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders ImportGradesButton', () => {
|
||||
render(<BulkManagementControls />);
|
||||
|
||||
expect(ImportGradesButton).toHaveBeenCalledWith({}, {});
|
||||
expect(screen.getByTestId('import-grades-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls handleClickExportGrades when NetworkButton is clicked', () => {
|
||||
render(<BulkManagementControls />);
|
||||
|
||||
const networkButtonCall = NetworkButton.mock.calls[0][0];
|
||||
const { onClick } = networkButtonCall;
|
||||
|
||||
onClick();
|
||||
expect(mockHandleClickExportGrades).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('passes correct label to NetworkButton', () => {
|
||||
render(<BulkManagementControls />);
|
||||
|
||||
const networkButtonCall = NetworkButton.mock.calls[0][0];
|
||||
expect(networkButtonCall.label).toBe(messages.downloadGradesBtn);
|
||||
});
|
||||
|
||||
it('renders both buttons in the correct order', () => {
|
||||
render(<BulkManagementControls />);
|
||||
|
||||
expect(NetworkButton).toHaveBeenCalled();
|
||||
expect(ImportGradesButton).toHaveBeenCalled();
|
||||
|
||||
const networkButton = screen.getByTestId('network-button');
|
||||
const importButton = screen.getByTestId('import-grades-button');
|
||||
|
||||
expect(networkButton).toBeInTheDocument();
|
||||
expect(importButton).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hook integration', () => {
|
||||
it('calls useBulkManagementControlsData hook', () => {
|
||||
useBulkManagementControlsData.mockReturnValue({
|
||||
show: true,
|
||||
handleClickExportGrades: mockHandleClickExportGrades,
|
||||
});
|
||||
|
||||
render(<BulkManagementControls />);
|
||||
expect(useBulkManagementControlsData).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('uses the show value from hook to determine rendering', () => {
|
||||
useBulkManagementControlsData.mockReturnValue({
|
||||
show: false,
|
||||
handleClickExportGrades: mockHandleClickExportGrades,
|
||||
});
|
||||
|
||||
render(<BulkManagementControls />);
|
||||
expect(screen.queryByTestId('network-button')).not.toBeInTheDocument();
|
||||
|
||||
useBulkManagementControlsData.mockReturnValue({
|
||||
show: true,
|
||||
handleClickExportGrades: mockHandleClickExportGrades,
|
||||
});
|
||||
|
||||
render(<BulkManagementControls />);
|
||||
expect(screen.getByTestId('network-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('passes handleClickExportGrades from hook to NetworkButton', () => {
|
||||
const customHandler = jest.fn();
|
||||
useBulkManagementControlsData.mockReturnValue({
|
||||
show: true,
|
||||
handleClickExportGrades: customHandler,
|
||||
});
|
||||
|
||||
render(<BulkManagementControls />);
|
||||
|
||||
const networkButtonCall = NetworkButton.mock.calls[0][0];
|
||||
expect(networkButtonCall.onClick).toBe(customHandler);
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user