Compare commits
263 Commits
v1.4.21
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad009ef7ab | ||
|
|
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 |
71
.env
71
.env
@@ -1,35 +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,
|
||||
|
||||
BULK_MANAGEMENT_SPECIAL_ACCESS_COURSE_IDS=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={}
|
||||
|
||||
@@ -7,24 +7,23 @@ LOGOUT_URL='http://localhost:18000/login'
|
||||
LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
|
||||
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
|
||||
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
|
||||
LOGO_POWERED_BY_OPEN_EDX_URL_SVG=https://edx-cdn.org/v3/stage/open-edx-tag.svg
|
||||
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
|
||||
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||
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'
|
||||
@@ -38,5 +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'
|
||||
|
||||
BULK_MANAGEMENT_SPECIAL_ACCESS_COURSE_IDS=null
|
||||
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;
|
||||
|
||||
6
.github/CODEOWNERS
vendored
6
.github/CODEOWNERS
vendored
@@ -1,6 +0,0 @@
|
||||
# Code owners for frontend-app-gradebook, editable gradebook micro-frontend (MFE)
|
||||
|
||||
# These owners will be the default owners for everything in
|
||||
# the repo. Unless a later match takes precedence, they will
|
||||
# be requested for review when someone opens a pull request.
|
||||
* @edx/masters-devs-gta
|
||||
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"
|
||||
2
.github/pull_request_template.md
vendored
2
.github/pull_request_template.md
vendored
@@ -26,4 +26,4 @@ Collectively, these should be completed by reviewers of this PR:
|
||||
- [ ] I've tested the new functionality
|
||||
|
||||
|
||||
FYI: @edx/masters-devs-gta
|
||||
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@v6
|
||||
|
||||
- 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 }}
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -5,6 +5,8 @@ npm-debug.log
|
||||
coverage
|
||||
|
||||
dist/
|
||||
src/i18n/transifex_input.json
|
||||
temp/babel-plugin-react-intl
|
||||
|
||||
### pyenv ###
|
||||
.python-version
|
||||
@@ -17,3 +19,9 @@ dist/
|
||||
### 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
|
||||
|
||||
147
README.md
147
README.md
@@ -1,10 +1,13 @@
|
||||
[](https://travis-ci.com/edx/frontend-app-gradebook) [](https://coveralls.io/github/edx/frontend-app-gradebook)
|
||||
# frontend-app-gradebook
|
||||
|
||||
[](https://travis-ci.com/edx/frontend-app-gradebook)
|
||||
[](https://app.codecov.io/gh/openedx/frontend-app-gradebook)
|
||||
[](@edx/frontend-app-gradebook)
|
||||
[](@edx/frontend-app-gradebook)
|
||||
[](@edx/frontend-app-gradebook)
|
||||
[](https://github.com/semantic-release/semantic-release)
|
||||
|
||||
# Gradebook
|
||||
# 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.
|
||||
|
||||
@@ -15,26 +18,20 @@ Jump to:
|
||||
|
||||
For existing documentation see:
|
||||
|
||||
- Basic Usage: [Review Learner Grades (read-the-docs)](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/student_progress/course_grades.html#review-learner-grades-on-the-instructor-dashboard)
|
||||
- Bulk Grade Management: [Override Learner Subsection Scores in Bulk (read-the-docs)](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/student_progress/course_grades.html#review-learner-grades-on-the-instructor-dashboard)
|
||||
- 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
|
||||
@@ -52,7 +49,8 @@ depending on their needs. Instructors that expect to review grades infrequently
|
||||
to the problem in question will have a worse UX than the legacy gradebook provides. Instructors that rely on the graphs
|
||||
generated by the current gradebook might find the lack of autogenerated graphs to be frustrating.
|
||||
|
||||
## Quickstart
|
||||
## Getting Started
|
||||
|
||||
|
||||
### Installation
|
||||
|
||||
@@ -61,47 +59,66 @@ To install gradebook into your project:
|
||||
npm i --save @edx/frontend-app-gradebook
|
||||
```
|
||||
|
||||
Cloning and Startup
|
||||
===================
|
||||
|
||||
1. Clone your new repo:
|
||||
|
||||
``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:
|
||||
|
||||
``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:
|
||||
|
||||
``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:
|
||||
|
||||
``tutor dev start lms cms mfe``
|
||||
|
||||
## Startup
|
||||
|
||||
1. Install npm dependencies:
|
||||
|
||||
``cd frontend-app-gradebook && npm install``
|
||||
|
||||
2. Start the dev server:
|
||||
|
||||
``npm run dev``
|
||||
|
||||
## Running the UI Standalone
|
||||
|
||||
To install the project please refer to the [`edX Developer Stack`](https://github.com/edx/devstack) instructions.
|
||||
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.
|
||||
|
||||
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.
|
||||
When not mounted, gradebook will run in the shared MFE container at http://apps.local.openedx.io/gradebook/course-v1:edX+DemoX+Demo_Course.
|
||||
|
||||
If you don't, you can see the log messages for the docker container by executing `make gradebook-logs` in the `devstack` directory.
|
||||
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.
|
||||
|
||||
## Configuring for local use in edx-platform
|
||||
## Plugins
|
||||
This MFE can be customized using [Frontend Plugin Framework](https://github.com/openedx/frontend-plugin-framework).
|
||||
|
||||
Assuming you've got the UI running at `http://localhost:1994`, you can configure the LMS in edx-platform
|
||||
to point to your local gradebook from the instructor dashboard by putting this setting in `lms/env/private.py`:
|
||||
```
|
||||
WRITABLE_GRADEBOOK_URL = 'http://localhost:1994'
|
||||
```
|
||||
|
||||
There are also several edx-platform waffle and feature flags you'll have to enable from the Django admin:
|
||||
|
||||
1. Grades > Persistent grades enabled flag. Add this flag if it doesn't exist,
|
||||
check the ``enabled`` and ``enabled for all courses`` boxes.
|
||||
|
||||
2. Waffle > Switches. Add the ``grades.assume_zero_grade_if_absent`` switch and make it active.
|
||||
|
||||
3. Waffle_utils > Waffle flag course overrides. Activate waffle flags for courses where you want to enable Gradebook functionality:
|
||||
- Enable Gradebook by adding the ``grades.writable_gradebook`` add checking the ``enabled`` box.
|
||||
- Enable Bulk Grade Management by adding the ``grades.bulk_management`` flag and checking the ``enabled`` box.
|
||||
|
||||
Alternatively, you could add these as regular waffle flags to enable the functionality for all courses.
|
||||
|
||||
**NOTE:** IF the above flags are not configured correctly, the gradebook may appear to work, but will return bogus
|
||||
numbers for grades. If your gradebook isn't accepting your changes, or the changes aren't resulting in sane,
|
||||
recalculated grade values, verify you've set all flags correctly.
|
||||
The parts of this MFE that can be customized in that manner are documented [here](/src/plugin-slots).
|
||||
|
||||
## 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.
|
||||
Run:
|
||||
|
||||
``nvm use``
|
||||
|
||||
``npm ci``
|
||||
|
||||
``npm test``
|
||||
|
||||
## Directory Structure
|
||||
|
||||
@@ -123,4 +140,50 @@ running gradebook container.
|
||||
|
||||
## 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.
|
||||
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.
|
||||
|
||||
Contributing
|
||||
============
|
||||
|
||||
Contributions are very welcome. Please read [How To Contribute](https://docs.openedx.org/en/latest/developers/references/developer_guide/process/index.html) for details.
|
||||
|
||||
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](https://openedx.org/slack), then join our
|
||||
[community Slack workspace](https://openedx.slack.com/) Because this is a
|
||||
frontend repository, the best place to discuss it would be in the
|
||||
[#wg-frontend channel](https://openedx.slack.com/archives/C04BM6YC7A6).
|
||||
|
||||
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](https://openedx.org/community/connect) page.
|
||||
|
||||
The Open edX Code of Conduct
|
||||
============================
|
||||
|
||||
All community members are expected to follow the [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.
|
||||
|
||||
@@ -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
|
||||
========
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 254 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 240 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 319 KiB |
@@ -68,33 +68,40 @@ Confirm the following workflows:
|
||||
- [ ] 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.
|
||||
|
||||
- [ ] *Masters only*: "Bulk Management" allows overriding grades in bulk.
|
||||
- [ ] *Master's (or selectively-enabled) only*: "Bulk Management" allows overriding grades in bulk.
|
||||
- Open a non-masters-track course.
|
||||
- [ ] Verify that the "Bulk Management" tab does not appear.
|
||||
- [ ] 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" tab appears to the right of the "Grades" tab.
|
||||
- [ ] Verify that the "Bulk Management" button appears.
|
||||
- Click the "Bulk Management" button. This downloads existing student/assignment info.
|
||||
- [ ] 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.
|
||||
- Add values in the "new_override-{subsection-short-id}" columns for student grades to be overridden and save the CSV file.
|
||||
- [ ] Clicking the "Bulk Management" tab shows the Bulk Management page.
|
||||
- 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.)
|
||||
- Navigate back to the "Bulk Management" tab.
|
||||
- [ ] 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 "Interventions" tab does not appear.
|
||||
- [ ] Verify that the "Interventions" button does not appear.
|
||||
- [ ] 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 "Interventions" tab appears to the right of the "Grades" tab.
|
||||
- [ ] Verify that the "Interventions" button appears.
|
||||
- Click on the "Interventions" button to generate a CSV students and activity info.
|
||||
- [ ] 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.
|
||||
|
||||
@@ -4,17 +4,17 @@ Instructions for setting up environments and data for testing Gradebook.
|
||||
|
||||
## Set up a course with graded content
|
||||
|
||||
A course with graded content is the first prerequisite to testing. Use an existing course (e.g. the DemoX Demonstration Course in Devstack) or see [Building and Running an edX Course > Developing Your Course](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/developing_course/index.html) for notes on how to develop a course from scratch.
|
||||
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://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/grading/index.html)
|
||||
- [Adding Exercises and Tools](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/grading/index.html)
|
||||
- [Set the Assignment Type and Due Date for a Subsection](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/developing_course/course_subsections.html#set-the-assignment-type-and-due-date-for-a-subsection)
|
||||
- [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 and feature toggles for course
|
||||
## Enable Gradebook for course
|
||||
|
||||
See README.md #Quickstart for more detailed instructions.
|
||||
|
||||
@@ -25,11 +25,17 @@ As an admin user, visit Django Admin (`{lms-url}/admin`) to modify features.
|
||||
- [ ] 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
|
||||
- [ ] Add a new flag called `grades.bulk_management`, select "Force On", and enable it for your course
|
||||
|
||||
## Enable Bulk Management
|
||||
|
||||
Bulk Management is an added feature to allow modifying grades in bulk via CSV upload. Bulk Management is default enabled for Master's track courses but can be selectively enabled for other courses with a waffle flag following the steps below.
|
||||
|
||||
- In Waffle_Utils > Waffle flag course overrides:
|
||||
- [ ] Add a new flag called `grades.bulk_management`, select "Force On", and enable it for your course.
|
||||
|
||||
## Create a Master's track for testing Master's-only features
|
||||
|
||||
[source](https://openedx.atlassian.net/wiki/spaces/MS/pages/1453818012/Add+a+learner+into+a+master+s+track)
|
||||
[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
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
const { createConfig } = require('@openedx/frontend-build');
|
||||
|
||||
module.exports = createConfig('jest', {
|
||||
setupFilesAfterEnv: [
|
||||
'<rootDir>/src/setupTest.js',
|
||||
],
|
||||
modulePaths: ['<rootDir>/src/'],
|
||||
snapshotSerializers: [
|
||||
'enzyme-to-json/serializer',
|
||||
coveragePathIgnorePatterns: [
|
||||
'src/segment.js',
|
||||
'src/postcss.config.js',
|
||||
'testUtils', // don't unit test jest mocking tools
|
||||
'testUtilsExtra', // don't unit test jest mocking tools
|
||||
],
|
||||
});
|
||||
|
||||
@@ -1,9 +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
|
||||
|
||||
tags:
|
||||
- frontend-app
|
||||
- masters
|
||||
oeps:
|
||||
oep-2: true # Repository metadata
|
||||
openedx-release: {ref: master}
|
||||
80104
package-lock.json
generated
80104
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
68
package.json
Executable file → Normal file
68
package.json
Executable file → Normal file
@@ -1,55 +1,60 @@
|
||||
{
|
||||
"name": "@edx/frontend-app-gradebook",
|
||||
"version": "1.4.21",
|
||||
"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/brand": "npm:@edx/brand-edx.org@^1.3.2",
|
||||
"@edx/frontend-component-footer": "10.1.1",
|
||||
"@edx/frontend-platform": "1.8.1",
|
||||
"@edx/paragon": "12.4.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",
|
||||
"enzyme-to-json": "^3.6.2",
|
||||
"font-awesome": "4.7.0",
|
||||
"history": "4.10.1",
|
||||
"node-sass": "^4.14.1",
|
||||
"prop-types": "15.7.2",
|
||||
"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",
|
||||
@@ -57,24 +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.5.2",
|
||||
"axios": "0.21.1",
|
||||
"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",
|
||||
"jest": "24.9.0",
|
||||
"react-dev-utils": "^5.0.3",
|
||||
"react-test-renderer": "^16.10.1",
|
||||
"redux-mock-store": "^1.5.3",
|
||||
"semantic-release": "^17.2.3",
|
||||
"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,9 @@
|
||||
<!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>
|
||||
|
||||
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,15 +1,15 @@
|
||||
// frontend-app-*/src/index.scss
|
||||
@import "~@edx/brand/paragon/fonts";
|
||||
@import "~@edx/brand/paragon/variables";
|
||||
@import "~@edx/paragon/scss/core/core";
|
||||
@import "~@edx/brand/paragon/overrides";
|
||||
@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,198 +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, showValue,
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<span className="badge badge-info">
|
||||
<span>{name}{showValue && `: ${value}`}</span>
|
||||
<button type="button" className="btn-info" aria-label="Close" onClick={onClick}>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</span>
|
||||
<br />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
FilterBadge.defaultProps = {
|
||||
showValue: true,
|
||||
};
|
||||
|
||||
FilterBadge.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
value: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.bool,
|
||||
]).isRequired,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
showValue: PropTypes.bool,
|
||||
};
|
||||
|
||||
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, showValue,
|
||||
}) {
|
||||
return (filterValue !== initialFilters[filterName])
|
||||
&& (
|
||||
<FilterBadge
|
||||
name={displayName}
|
||||
value={filterValue}
|
||||
onClick={handleBadgeClose}
|
||||
showValue={showValue}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
SingleValueFilterBadge.defaultProps = {
|
||||
showValue: true,
|
||||
};
|
||||
|
||||
SingleValueFilterBadge.propTypes = {
|
||||
displayName: PropTypes.string.isRequired,
|
||||
filterName: PropTypes.string.isRequired,
|
||||
filterValue: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.bool,
|
||||
]).isRequired,
|
||||
handleBadgeClose: PropTypes.func.isRequired,
|
||||
showValue: PropTypes.bool,
|
||||
};
|
||||
|
||||
function FilterBadges({
|
||||
assignment,
|
||||
assignmentType,
|
||||
track,
|
||||
cohort,
|
||||
assignmentGradeMin,
|
||||
assignmentGradeMax,
|
||||
courseGradeMin,
|
||||
courseGradeMax,
|
||||
includeCourseRoleMembers,
|
||||
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="cohort"
|
||||
filterValue={cohort}
|
||||
handleBadgeClose={handleFilterBadgeClose(['cohort'])}
|
||||
/>
|
||||
<SingleValueFilterBadge
|
||||
displayName="Including Course Team Members"
|
||||
filterName="includeCourseRoleMembers"
|
||||
filterValue={includeCourseRoleMembers}
|
||||
showValue={false}
|
||||
handleBadgeClose={handleFilterBadgeClose(['includeCourseRoleMembers'])}
|
||||
/>
|
||||
</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,
|
||||
includeCourseRoleMembers: state.filters.includeCourseRoleMembers,
|
||||
}
|
||||
);
|
||||
|
||||
const ConnectedFilterBadges = connect(mapStateToProps)(FilterBadges);
|
||||
export default ConnectedFilterBadges;
|
||||
|
||||
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,
|
||||
includeCourseRoleMembers: initialFilters.includeCourseRoleMembers,
|
||||
};
|
||||
|
||||
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,
|
||||
includeCourseRoleMembers: PropTypes.bool,
|
||||
handleFilterBadgeClose: PropTypes.func.isRequired,
|
||||
};
|
||||
@@ -1,199 +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.props.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.props.updateQueryParams({ assignmentType });
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Collapsible title="Assignments" defaultOpen className="filter-group mb-3">
|
||||
<div>
|
||||
<div className="student-filters">
|
||||
<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">
|
||||
<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>
|
||||
<form className="grade-filter-inputs" onSubmit={this.handleSubmitAssignmentGrade}>
|
||||
<div className="percent-group">
|
||||
<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>
|
||||
</div>
|
||||
<div className="percent-group">
|
||||
<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>
|
||||
</div>
|
||||
<div className="grade-filter-action">
|
||||
<Button
|
||||
type="submit"
|
||||
className="btn-outline-secondary"
|
||||
name="assignmentGradeMinMax"
|
||||
disabled={!this.props.selectedAssignment}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</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,199 +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,
|
||||
} 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 (
|
||||
<div>
|
||||
<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}
|
||||
isOpen={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"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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,110 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { Button, Icon, SearchField } from '@edx/paragon';
|
||||
|
||||
import {
|
||||
fetchGrades,
|
||||
fetchMatchingUserGrades,
|
||||
} from '../../data/actions/grades';
|
||||
|
||||
/**
|
||||
* Controls for filtering the GradebookTable. Contains the "Edit Filters" button for opening the filter drawer
|
||||
* as well as the search box for searching by username/email.
|
||||
*/
|
||||
export class SearchControls extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.onSubmit = this.onSubmit.bind(this);
|
||||
this.onChange = this.onChange.bind(this);
|
||||
this.onClear = this.onClear.bind(this);
|
||||
}
|
||||
|
||||
/** Submitting searches for user matching the username/email in `value` */
|
||||
onSubmit(value) {
|
||||
this.props.searchForUser(
|
||||
this.props.courseId,
|
||||
value,
|
||||
this.props.selectedCohort,
|
||||
this.props.selectedTrack,
|
||||
this.props.selectedAssignmentType,
|
||||
);
|
||||
}
|
||||
|
||||
/** Changing the search value stores the key in Gradebook. Currently unused */
|
||||
onChange(filterValue) {
|
||||
this.props.setFilterValue(filterValue);
|
||||
}
|
||||
|
||||
/** Clearing the search box falls back to showing students with already applied filters */
|
||||
onClear() {
|
||||
this.props.getUserGrades(
|
||||
this.props.courseId,
|
||||
this.props.selectedCohort,
|
||||
this.props.selectedTrack,
|
||||
this.props.selectedAssignmentType,
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<>
|
||||
<h4>Step 1: Filter the Grade Report</h4>
|
||||
<div className="d-flex justify-content-between">
|
||||
<Button
|
||||
id="edit-filters-btn"
|
||||
className="btn-primary align-self-start"
|
||||
onClick={this.props.toggleFilterDrawer}
|
||||
>
|
||||
<Icon className="fa fa-filter" /> Edit Filters
|
||||
</Button>
|
||||
<div>
|
||||
<SearchField
|
||||
onSubmit={this.onSubmit}
|
||||
inputLabel="Search for a learner"
|
||||
onChange={this.onChange}
|
||||
onClear={this.onClear}
|
||||
value={this.props.filterValue}
|
||||
/>
|
||||
<small className="form-text text-muted search-help-text">Search by username, email, or student key</small>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SearchControls.defaultProps = {
|
||||
courseId: '',
|
||||
filterValue: '',
|
||||
selectedAssignmentType: '',
|
||||
selectedCohort: null,
|
||||
selectedTrack: null,
|
||||
};
|
||||
|
||||
SearchControls.propTypes = {
|
||||
courseId: PropTypes.string,
|
||||
filterValue: PropTypes.string,
|
||||
setFilterValue: PropTypes.func.isRequired,
|
||||
toggleFilterDrawer: PropTypes.func.isRequired,
|
||||
// From Redux
|
||||
getUserGrades: PropTypes.func.isRequired,
|
||||
searchForUser: PropTypes.func.isRequired,
|
||||
selectedAssignmentType: PropTypes.string,
|
||||
selectedCohort: PropTypes.string,
|
||||
selectedTrack: PropTypes.string,
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => ({
|
||||
selectedAssignmentType: state.filters.assignmentType,
|
||||
selectedTrack: state.filters.track,
|
||||
selectedCohort: state.filters.cohort,
|
||||
});
|
||||
|
||||
export const mapDispatchToProps = {
|
||||
getUserGrades: fetchGrades,
|
||||
searchForUser: fetchMatchingUserGrades,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(SearchControls);
|
||||
@@ -1,118 +0,0 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import {
|
||||
fetchGrades,
|
||||
fetchMatchingUserGrades,
|
||||
} from '../../data/actions/grades';
|
||||
import { mapDispatchToProps, mapStateToProps, SearchControls } from './SearchControls';
|
||||
|
||||
jest.mock('@edx/paragon', () => ({
|
||||
Icon: 'Icon',
|
||||
Button: 'Button',
|
||||
SearchField: 'SearchField',
|
||||
}));
|
||||
|
||||
describe('SearchControls', () => {
|
||||
let props;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
props = {
|
||||
courseId: 'course-v1:edX+DEV101+T1',
|
||||
filterValue: 'alice',
|
||||
selectedAssignmentType: 'homework',
|
||||
selectedCohort: 'spring term',
|
||||
selectedTrack: 'masters',
|
||||
getUserGrades: jest.fn(),
|
||||
searchForUser: jest.fn(),
|
||||
setFilterValue: jest.fn(),
|
||||
toggleFilterDrawer: jest.fn().mockName('toggleFilterDrawer'),
|
||||
};
|
||||
});
|
||||
|
||||
const searchControls = (overriddenProps) => {
|
||||
props = { ...props, ...overriddenProps };
|
||||
return shallow(<SearchControls {...props} />);
|
||||
};
|
||||
|
||||
describe('Component', () => {
|
||||
describe('onSubmit', () => {
|
||||
it('calls props.searchForUser with correct data', () => {
|
||||
const wrapper = searchControls();
|
||||
wrapper.instance().onSubmit('bob');
|
||||
|
||||
expect(props.searchForUser).toHaveBeenCalledWith(
|
||||
props.courseId,
|
||||
'bob',
|
||||
props.selectedCohort,
|
||||
props.selectedTrack,
|
||||
props.selectedAssignmentType,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onChange', () => {
|
||||
it('saves the changed search value to Gradebook state', () => {
|
||||
const wrapper = searchControls();
|
||||
wrapper.instance().onChange('bob');
|
||||
expect(props.setFilterValue).toHaveBeenCalledWith('bob');
|
||||
});
|
||||
});
|
||||
|
||||
describe('onClear', () => {
|
||||
it('re-runs search with existing filters', () => {
|
||||
const wrapper = searchControls();
|
||||
wrapper.instance().onClear();
|
||||
expect(props.getUserGrades).toHaveBeenCalledWith(
|
||||
props.courseId,
|
||||
props.selectedCohort,
|
||||
props.selectedTrack,
|
||||
props.selectedAssignmentType,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapStateToProps', () => {
|
||||
const state = {
|
||||
filters: {
|
||||
assignmentType: 'labs',
|
||||
track: 'honor',
|
||||
cohort: 'fall term',
|
||||
},
|
||||
};
|
||||
|
||||
it('maps assignment type filter correctly', () => {
|
||||
expect(mapStateToProps(state).selectedAssignmentType).toEqual(state.filters.assignmentType);
|
||||
});
|
||||
|
||||
it('maps track filter correctly', () => {
|
||||
expect(mapStateToProps(state).selectedTrack).toEqual(state.filters.track);
|
||||
});
|
||||
|
||||
it('maps cohort filter correctly', () => {
|
||||
expect(mapStateToProps(state).selectedCohort).toEqual(state.filters.cohort);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapDispatchToProps', () => {
|
||||
test('getUserGrades', () => {
|
||||
expect(mapDispatchToProps.getUserGrades).toEqual(fetchGrades);
|
||||
});
|
||||
|
||||
test('searchForUser', () => {
|
||||
expect(mapDispatchToProps.searchForUser).toEqual(fetchMatchingUserGrades);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Snapshots', () => {
|
||||
test('basic snapshot', () => {
|
||||
const wrapper = searchControls();
|
||||
wrapper.instance().onChange = jest.fn().mockName('onChange');
|
||||
wrapper.instance().onClear = jest.fn().mockName('onClear');
|
||||
wrapper.instance().onSubmit = jest.fn().mockName('onSubmit');
|
||||
expect(wrapper.instance().render()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,37 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`SearchControls Component Snapshots basic snapshot 1`] = `
|
||||
<React.Fragment>
|
||||
<h4>
|
||||
Step 1: Filter the Grade Report
|
||||
</h4>
|
||||
<div
|
||||
className="d-flex justify-content-between"
|
||||
>
|
||||
<Button
|
||||
className="btn-primary align-self-start"
|
||||
id="edit-filters-btn"
|
||||
onClick={[MockFunction toggleFilterDrawer]}
|
||||
>
|
||||
<Icon
|
||||
className="fa fa-filter"
|
||||
/>
|
||||
Edit Filters
|
||||
</Button>
|
||||
<div>
|
||||
<SearchField
|
||||
inputLabel="Search for a learner"
|
||||
onChange={[MockFunction onChange]}
|
||||
onClear={[MockFunction onClear]}
|
||||
onSubmit={[MockFunction onSubmit]}
|
||||
value="alice"
|
||||
/>
|
||||
<small
|
||||
className="form-text text-muted search-help-text"
|
||||
>
|
||||
Search by username, email, or student key
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
`;
|
||||
@@ -1,530 +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,
|
||||
CheckBox,
|
||||
Icon,
|
||||
InputSelect,
|
||||
InputText,
|
||||
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';
|
||||
import SearchControls from './SearchControls';
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
handleIncludeTeamMembersChange = (includeCourseRoleMembers) => {
|
||||
this.props.updateIncludeCourseRoleMembers(includeCourseRoleMembers);
|
||||
this.updateQueryParams({ includeCourseRoleMembers });
|
||||
};
|
||||
|
||||
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',
|
||||
'assignmentName',
|
||||
'filterValue',
|
||||
'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">
|
||||
{this.props.showSpinner && (
|
||||
<div className="spinner-overlay">
|
||||
<Icon className="fa fa-spinner fa-spin fa-5x color-black" />
|
||||
</div>
|
||||
)}
|
||||
<SearchControls
|
||||
courseId={this.props.courseId}
|
||||
filterValue={this.state.filterValue}
|
||||
setFilterValue={this.createStateFieldSetter('filterValue')}
|
||||
toggleFilterDrawer={toggleFilterDrawer}
|
||||
/>
|
||||
<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
|
||||
&& (
|
||||
<Tab eventKey="bulk_management" title="Bulk Management">
|
||||
<BulkManagement
|
||||
courseId={this.props.courseId}
|
||||
gradeExportUrl={this.props.gradeExportUrl}
|
||||
/>
|
||||
</Tab>
|
||||
)}
|
||||
</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" defaultOpen className="filter-group mb-3">
|
||||
<div className="grade-filter-inputs">
|
||||
<div className="percent-group">
|
||||
<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>
|
||||
</div>
|
||||
<div className="percent-group">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grade-filter-action">
|
||||
<Button
|
||||
variant="outline-secondary"
|
||||
onClick={this.handleCourseGradeFilterApplyButtonClick}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</Collapsible>
|
||||
<Collapsible title="Student Groups" defaultOpen 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>
|
||||
<Collapsible title="Include Course Team Members" className="filter-group mb-3">
|
||||
<CheckBox
|
||||
name="include-course-team-members"
|
||||
aria-label="Include Course Team Members"
|
||||
label="Include Course Team Members"
|
||||
checked={this.props.includeCourseRoleMembers}
|
||||
onChange={this.handleIncludeTeamMembersChange}
|
||||
/>
|
||||
</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: [],
|
||||
includeCourseRoleMembers: false,
|
||||
};
|
||||
|
||||
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,
|
||||
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,
|
||||
includeCourseRoleMembers: PropTypes.bool,
|
||||
updateIncludeCourseRoleMembers: 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;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user