Compare commits

..

127 Commits

Author SHA1 Message Date
Kyle McCormick
f7a13d58f2 Fix logout URL 2020-04-09 12:34:22 -04:00
Kyle McCormick
6728242671 Temporary: Absolute /gradebook/ React route 2020-04-08 17:20:47 -04:00
Kyle McCormick
beb2c3cbae Make React routes relative 2020-04-08 17:17:47 -04:00
Kyle McCormick
973c6e9a0a Update config for devstack 2020-04-08 16:53:16 -04:00
Kyle McCormick
09cfee335f Add dev-build command 2020-04-08 16:37:28 -04:00
Simon Chen
ee274a2fb0 Merge pull request #136 from edx/schen/gradebook_node12
Upgrade node build version from 8 to 12
2019-12-06 11:39:06 -05:00
Alex Dusenbery
baa26f3b20 Update travis email recipients. 2019-12-06 11:03:01 -05:00
Simon Chen
0f7ffcbec1 Upgrade node build version from 8 to 12 2019-12-06 10:55:01 -05:00
Thomas Tracy
f5616f77fd Remove commented scss imports 2019-10-21 14:55:57 -04:00
Thomas Tracy
ecf5ef4664 Upgrade gradebook from paragon 4 to 7 2019-10-21 14:55:57 -04:00
Adam Butterworth
480ea1915c fix: remove youtube logo from footer
Related ticket: PROD-816
2019-10-16 14:44:27 -04:00
Jansen Kantor
a646b7b77c add FilterBadges (#133)
* added FilterBadges
2019-10-16 11:47:04 -04:00
Andytr1
a2503d7067 Merge pull request #132 from edx/andytr1/use-label-for-events
Update event properties to use label for google analytics
2019-10-08 10:21:51 -04:00
atesker
7413c2100f use label 2019-10-08 08:56:15 -04:00
Andytr1
8733ab5e1f Merge pull request #131 from edx/andytr1/add-constants-for-analytics
added action constants and comments
2019-10-07 12:54:55 -04:00
atesker
8dc5836daa added event constants 2019-10-07 12:33:12 -04:00
Andytr1
993809b5b0 Merge pull request #129 from edx/andytr1/analytics-move-events-from-frontend-and-add-courseid
EDUCATOR-4715 working on moving analytics events to redux
2019-10-07 10:34:49 -04:00
atesker
9665859ea2 working on moving analytics events to redux
EDUCATOR-4715 - make analytics use redux hooks
2019-10-04 15:24:36 -04:00
Andytr1
386bbea84b Merge pull request #128 from edx/andytr1/analytics-course-id-final-fix
EDUCATOR-4685 final fix for using label with course id
2019-10-04 11:30:41 -04:00
atesker
ee8e56a792 EDUCATOR-4685 final fix for using label with course id 2019-10-04 10:00:24 -04:00
Matt Hughes
80aa4f10a0 Merge pull request #127 from edx/matthugs/option-c-EDUCATOR-4686-trivial-copy-change
Set better expectations around file upload processing time
2019-10-03 11:50:15 -04:00
Alex Dusenbery
2d0bf8c322 upgrade nose-sass (amongst others) 2019-10-03 11:03:59 -04:00
Matt Hughes
eaa2db5019 Set better expectations around file upload processing time
JIRA:EDUCATOR-4686
2019-10-03 10:16:00 -04:00
Andytr1
de6b2f1219 Merge pull request #125 from edx/andytr1/analytics-add-course-id
EDUCATOR-4685 add course id to label
2019-10-03 09:47:38 -04:00
Matt Hughes
12db9448d8 Merge pull request #124 from edx/andytr1/display-max-possible-score-for-not-started-work
EDUCATOR-4365 - display max grade for not started assignments
2019-10-02 15:30:00 -04:00
atesker
52da367d18 EDUCATOR-4685 add course id to label
different location of label
2019-10-02 14:46:23 -04:00
Matt Hughes
e8cbc66e6f clear out redux state related to the modal consistently
... as we already do with component state for these sorts of fields
2019-10-02 13:51:43 -04:00
Matt Hughes
bc39443d94 improving state management of these modal fields 2019-10-02 13:17:23 -04:00
Matt Hughes
15cdd7a256 handle the two distinct data sources for this information properly
JIRA:EDUCATOR-4711
2019-10-02 12:19:12 -04:00
atesker
81601ca6ea EDUCATOR-4365 - display max grade for not started assignments 2019-10-01 12:07:26 -04:00
Andytr1
c773c35bbc Merge pull request #123 from edx/andytr1/intervention-fix-cohort-filter-4627
EDUCATOR-4627
2019-09-12 11:39:36 -04:00
atesker
b4e77b2b3f EDUCATOR-4627 2019-09-12 11:13:04 -04:00
Matt Hughes
56b90cd223 Merge pull request #122 from edx/matthugs/some-more-ui-updates
some more ui updates
2019-09-11 10:02:38 -04:00
Matt Hughes
7fa24969ac Limit the amount of space the uname & email columns can take up
This is meant to alleviate concerns about the table not showing grade information
while the drawer is open and few assignment columns are
visible (e.g. when the "specific assignment" filter is set).
2019-09-10 18:08:21 -04:00
Matt Hughes
924dcda088 Add disclaimer about masters-specific features
JIRA:EDUCATOR-4637
2019-09-10 17:01:02 -04:00
Andytr1
18f9ffcfde Merge pull request #120 from edx/andytr1/grade-events-updated
updated event names
2019-09-10 16:42:19 -04:00
atesker
1e7871795c updated event names
added comments

added more detailed comments

added more detailed comments
2019-09-10 16:37:36 -04:00
Matt Hughes
cd309c47e1 Merge pull request #121 from edx/matthugs/gradebook-styling-issues
Gradebook styling issues
2019-09-10 14:59:49 -04:00
Matt Hughes
5d9d2af578 Break table text line for long usernames as well
... as for file names
2019-09-10 14:15:16 -04:00
Matt Hughes
c5306296c9 Fix IE & Firefox styling issue
negative margins don't play consistently with flexbox cross-browser

JIRA:EDUCATOR-4622
2019-09-10 14:15:16 -04:00
Jansen Kantor
49e3fd23d7 Merge pull request #119 from edx/jkantor/copypasta
remove incorrectly copied block
2019-09-09 12:05:10 -04:00
Matt Hughes
7bdd1533c3 Merge pull request #117 from edx/matthugs/push-aside-drawer-for-filters
Push-aside drawer for filters
2019-09-09 10:57:31 -04:00
jansenk
22cf805960 remove incorrectly copied block 2019-09-09 10:22:05 -04:00
Matt Hughes
be84c91981 Move filters to push-aside drawer
- makes gradebook full-width (to accommodate push-aside)
- while the drawer is open the rest of the page is left-right
  scrollable

JIRA:EDUCATOR-4579
2019-09-09 09:43:59 -04:00
Nimisha Asthagiri
4434857ada Merge pull request #116 from edx/arch/rename-repo
Rename gradebook to frontend-app-gradebook
2019-09-06 14:23:51 -04:00
Andytr1
7764f78386 Merge pull request #118 from edx/andytr1/new-events-in-gradebook
EDUCATOR-4524 - new events
2019-09-06 12:45:41 -04:00
atesker
b4a5288b4b EDUCATOR-4524 - new events 2019-09-06 12:05:54 -04:00
Nimisha Asthagiri
caea72b92e Rename gradebook to frontend-app-gradebook 2019-09-06 07:19:06 -04:00
Kyle McCormick
93128ac728 Update openedx.yaml for Masters squad reorg (#115) 2019-09-05 09:01:15 -04:00
Andytr1
05bd0d740c Merge pull request #114 from edx/andytr1/change-message-after-grade-edit
EDUCATOR-4591 - update save message
2019-09-03 11:21:09 -04:00
atesker
f041d6b16d EDUCATOR-4591 - update save message 2019-09-03 10:23:25 -04:00
Matt Hughes
b90df949b9 Merge pull request #113 from edx/matthugs/add-configurability-of-bulk-management-features
Add support for waffling off bulk management
2019-08-20 16:32:46 -04:00
Matt Hughes
9c1f5ed239 Add support for waffling off bulk management
even for masters courses
2019-08-20 15:20:27 -04:00
Matt Hughes
f38cd91fb1 Merge pull request #112 from edx/matthugs/add-intervention-filters
add intervention filters
2019-08-19 10:18:49 -04:00
Matt Hughes
588528abf6 Moved interventions download button to main view 2019-08-16 15:20:54 -04:00
Matt Hughes
d62b9ca520 Move the score view input closer to the portion of the UI it affects 2019-08-16 14:05:54 -04:00
Matt Hughes
ec08ef4bcb Apply filters to interventions report
JIRA:EDUCATOR-4537
2019-08-16 14:02:06 -04:00
Michael Roytman
d5b3edc850 Merge pull request #111 from edx/mroytman/EDUCATOR-4432-course-level-grade-filter
mroytman/educator 4432 course level grade filter
2019-08-15 06:54:44 -04:00
Michael Roytman
4f7d764d20 add course level grade filters to the gradebook 2019-08-15 06:27:09 -04:00
Matt Hughes
2dacc2c037 Merge pull request #110 from edx/matthugs/add-assig-grade-filter
Add assignment grade filter
2019-08-13 15:05:54 -04:00
Matt Hughes
88fa2e60f4 bug fix with a corner case I'd missed
switching back to "all" for the assignment with assignment grade
filters set caused the lms api to be misused
2019-08-13 13:45:18 -04:00
Matt Hughes
2822e36b7b Michael's code review comments 2019-08-13 11:28:22 -04:00
Matt Hughes
f46fe3f8ee correct poor choice of abbreviation 2019-08-13 10:52:02 -04:00
Matt Hughes
e787d3a697 Add filters for assignment grades
When assignment grade filters are set, changing which assignment is
chosen will also cause the grades to be refreshed from the
server (since the subset of learners being viewed may be different).

JIRA:EDUCATOR-4541
2019-08-12 15:01:54 -04:00
Matt Hughes
d77737f132 Merge pull request #108 from edx/matthugs/add-filter-for-specific-assignment
add filter for specific assignment
2019-08-02 10:35:17 -04:00
Matt Hughes
9ed5c6cb34 Add new filter for assignments
JIRA:EDUCATOR-4514

- Filter applies both to gradebook view and to CSV export used for bulk
  management.
- Fixes a bug with the cohort filter being applied to bulk management
  CSVs
- Sets us up to add new filters more easily
- New filter interoperates with existing assignment type filter to
  limit options
2019-08-02 10:29:38 -04:00
Michael Roytman
5b16a5dbb2 Merge pull request #109 from edx/mroytman/EDUCATOR-4509-pull-out-bulk-management
pull out bulk management tools to main tab and remove redundant funct…
2019-08-01 14:21:19 -04:00
Michael Roytman
2c9c505b32 pull out bulk management tools to main tab and remove redundant functionality across tabs 2019-08-01 14:13:42 -04:00
Andytr1
9425e06f8f Merge pull request #107 from edx/andytr1/interventions-ui-actual
EDUCATOR-4532 add button to download interventions
2019-07-26 14:47:14 -04:00
atesker
e7134d8f68 EDUCATOR-4532 add button to download interventions 2019-07-26 08:35:24 -04:00
Dave St.Germain
e547d67f19 Merge pull request #106 from edx/mroytman/EDUCATOR-4434-gradebook-user-counts
add selection counts to gradebook
2019-07-25 09:54:28 -04:00
Michael Roytman
8b1d5aacf2 add selection counts to gradebook 2019-07-24 16:31:47 -04:00
Matt Hughes
5a243e1b5f Merge pull request #105 from edx/matthugs/EDUCATOR-4435
Add download of row-by-row status CSV echo for bulk operation history table
2019-07-24 14:33:37 -04:00
Matt Hughes
a957e16424 Adding tabular history of grade bulk management CSV uploads
JIRA:EDUCATOR-4435
2019-07-24 14:16:10 -04:00
Andytr1
0d12eaf979 Merge pull request #104 from edx/andytr1/fix-studentkey-display
fix studen key label
2019-07-17 13:25:00 -04:00
atesker
3e2787b0e0 fix studen key label
put height back in

fix trailing space
2019-07-17 13:19:37 -04:00
Andytr1
35c632b716 Merge pull request #103 from edx/andytr1/gradebook-remove-edit-by-name
removed username and fake max score
2019-07-16 14:29:42 -04:00
atesker
801e2d6d59 removed username and fake max score
display correct overrides

code review cleanup2
2019-07-16 14:23:50 -04:00
Andytr1
7bd81d7a2b Merge pull request #102 from edx/andytr1/gradebook-remove-edit-by-name
removed username and fake max score
2019-07-15 14:48:36 -04:00
atesker
2b8ecf2c78 removed username and fake max score 2019-07-15 14:22:24 -04:00
Andytr1
de13ac5093 Merge pull request #101 from edx/andytr1/gradebook-fix-override-display
show correct override
2019-07-12 16:36:01 -04:00
atesker
ade906896c show correct override 2019-07-12 16:01:53 -04:00
Andytr1
43181e39cc Merge pull request #100 from edx/andytr1/gradebook-fix-error-with-empty-override
bug fix on empty override
2019-07-12 10:26:26 -04:00
atesker
f60a9647b4 bug fix on empty override 2019-07-11 17:50:33 -04:00
Andytr1
7dc1ffdd42 Merge pull request #99 from edx/andytr1/gradebook-show-override-history
EDUCATOR-4353 - ui - show override history
2019-07-10 15:29:16 -04:00
atesker
2913f1d965 EDUCATOR-4353 - ui - show override history
updated unit tests

EDUCATOR-4353 - major improvements to service etc

remove unused

unit tests fix

code review continued

update unit test after merge

code review - showing error dialog
2019-07-10 14:07:58 -04:00
Matt Hughes
7f85fb12e3 Merge pull request #98 from edx/matthugs/add-table-for-bulk-management-operation-history
Add table corresponding to what new history endpoint supports
2019-07-10 10:08:54 -04:00
Matt Hughes
90f66d3759 added an action for errors (currently unhandled) 2019-07-02 12:19:48 -04:00
Matt Hughes
fb09bee8d9 addressed another todo starting a selector module for grades 2019-07-02 11:37:54 -04:00
Matt Hughes
e31495b00b address some TODOs & fix a unit test's expectation 2019-07-02 11:34:26 -04:00
Matt Hughes
0557f29e0b Add table corresponding to what new history endpoint supports 2019-07-01 17:28:28 -04:00
Matt Hughes
e08b13f218 Merge pull request #95 from edx/matthugs/grade-management-shell-modal
Add grade management tab to Gradebook for courses containing a Masters track
2019-06-24 15:17:08 -04:00
Matt Hughes
dee42eee7e Added bulk management tab for CSV export/import
The bulk management tab will only be shown for masters courses,
i.e. those containing a masters track

JIRA:EDUCATOR-4343
JIRA:EDUCATOR-4431
2019-06-24 13:51:11 -04:00
Andytr1
1a0fe945a5 Merge pull request #97 from edx/mroytman/add-email-student-key
mroytman/add email student key
2019-06-24 13:26:11 -04:00
Michael Roytman
704144f9d1 first attempt at including email and student key 2019-06-13 08:27:23 -04:00
Kyle McCormick
a1ab29702f Switch to AGPL 2019-05-20 16:32:37 -04:00
Adam Stankiewicz
f619308ce5 Merge pull request #92 from edx/astankiewicz/fix-forward-intlprovider
fix forward for including intlprovider
2019-05-16 15:59:22 -04:00
Adam Stankiewicz
c235da8cc0 fix forward for including intlprovider 2019-05-16 12:52:51 -07:00
Adam Stankiewicz
777e0696ee Merge pull request #91 from edx/astankiewicz/footer-update
Upgrade @edx/frontend-component-footer, update 'edX for Business' & social links
2019-05-16 14:45:39 -04:00
Adam Stankiewicz
95b8ba146e version bump footer component 2019-05-16 11:03:11 -07:00
Adam Stankiewicz
8463a28c7d Change to example.com 2019-05-16 08:11:26 -07:00
Adam Stankiewicz
bdee388dbd Adding local env config variables 2019-05-16 07:55:33 -07:00
Adam Stankiewicz
e0633dc816 Upgrade @edx/frontend-component-footer and update 'edX for Business' and social links 2019-05-15 12:46:09 -04:00
Simon Chen
668620a5f0 Merge pull request #90 from edx/kdmccormick/supporting-team
Add masters-neem as a supporting team
2019-05-13 11:23:47 -04:00
Kyle McCormick
cfbb02a54c Add masters-neem as a supporting team 2019-05-13 10:59:27 -04:00
Kyle McCormick
b22b44bbf5 Change repo owner from edx/educator-neem to schenedx
Per recent change to OEP-2 https://github.com/edx/open-edx-proposals/pull/112/files
2019-05-13 10:22:44 -04:00
Douglas Hall
80d9e32fba Merge pull request #88 from edx/douglashall/upgrade_auth
Upgrade frontend-auth to 4.0.0
2019-04-11 13:48:24 -04:00
Douglas Hall
ef8975df86 Upgrade frontend-auth to 4.0.0 2019-04-11 13:37:23 -04:00
Richard I Reilly
09e482e893 Merge pull request #87 from edx/rir/remove-sorting
Remove remove all unneeded sorting
2019-02-04 10:45:29 -05:00
Rick Reilly
d4421d47fc Remove remove all unneeded sorting 2019-02-04 10:39:49 -05:00
Kyle McCormick
0ef8e773cc Merge pull request #85 from edx/kdmccormick/page-size
EDUCATOR-3936 Increase users per page from 10 to 25
2019-01-25 11:46:38 -05:00
Kyle McCormick
1dac20b866 EDUCATOR-3936 Increase users per page from 10 to 25 2019-01-25 11:39:38 -05:00
Zachary Hancock
ed2d715ce0 Merge pull request #84 from edx/zhancock/assignment-type-filter
persist assignment type filter
2019-01-24 16:47:22 -05:00
Zach Hancock
c82c49ea59 persist assignment type filter 2019-01-24 16:42:57 -05:00
Richard I Reilly
a9f8aec5f9 Merge pull request #86 from edx/rir/search-affordance
Cosmetic changes to give search more affordance
2019-01-24 16:20:52 -05:00
Rick Reilly
a63e9a5347 Cosmetic changes to give search more affordance 2019-01-24 16:06:53 -05:00
Richard I Reilly
2581812118 Merge pull request #82 from edx/rir/cleanup
Remove the 'is_graded' filter. The api will ensure all subsection gra…
2019-01-23 14:23:55 -05:00
Rick Reilly
c4fe803a95 Remove the 'is_graded' filter. The api will ensure all subsection grades we get are 'is_graded=true' 2019-01-23 13:34:50 -05:00
Richard I Reilly
93be5329ca Merge pull request #79 from edx/rir/lint
fix(lint): Fix all eslint issues and prop validation
2019-01-23 12:21:33 -05:00
Rick Reilly
80ba7e7152 fix(lint): Fix all eslint issues and prop validation 2019-01-23 12:18:32 -05:00
Alex Dusenbery
f88526aa3a Include expired course modes when fetching data from course enrollment API. 2019-01-23 10:16:07 -05:00
Simon Chen
c0f08eee58 Merge pull request #80 from edx/schen/improve_analytics
fix(analytics): Add the proper labels to analytics for gradebook
2019-01-22 13:43:29 -05:00
Simon Chen
ef62ea35dc fix(analytics): Add the proper labels to analytics for gradebook 2019-01-22 13:25:41 -05:00
Simon Chen
34eaa31776 Merge pull request #78 from edx/schen/EDUCATOR-3925
fix(bug): make sure gradebook rounding handle null input
2019-01-17 14:44:23 -05:00
Simon Chen
a7316e6824 fix(bug): make sure gradebook rounding handle null input 2019-01-17 14:37:21 -05:00
Alex Dusenbery
c0ab04f20c Merge pull request #77 from edx/aed/adrs
Add ADRs about API usage and UX.
2019-01-17 11:19:24 -05:00
Alex Dusenbery
ed72e7c203 Add ADRs about API usage and UX. 2019-01-16 17:00:21 -05:00
52 changed files with 8070 additions and 2782 deletions

View File

@@ -1,4 +1,5 @@
coverage/*
dist/
node_modules/
src/postcss.config.js
src/segment.js

View File

@@ -16,7 +16,10 @@
"jsx-a11y/anchor-is-valid": [ "error", {
"components": [ "Link" ],
"specialLink": [ "to" ]
}]
}],
// https://github.com/yannickcr/eslint-plugin-react/issues/1754#issuecomment-378838053
// tl;dr: this rule is no longer going to cause any user-facing visual weirdness, its original motivation
"react/no-did-mount-set-state": "off"
},
"env": {
"jest": true

View File

@@ -1,21 +1,14 @@
language: node_js
node_js:
- lts/*
cache:
directories:
- "~/.npm"
node_js: 12
notifications:
email:
recipients:
- adusenbery@edx.org
- rreilly@edx.org
- schen@edx.org
- masters-grades@edx.org
on_success: never
on_failure: always
webhooks: https://www.travisbuddy.com/
on_success: never
before_install:
- npm install -g npm@latest
- npm install -g greenkeeper-lockfile@1.14.0
install:
- npm ci
@@ -23,6 +16,7 @@ before_script: greenkeeper-lockfile-update
after_script: greenkeeper-lockfile-upload
script:
- make validate-no-uncommitted-package-lock-changes
- npm run lint
- npm run test
- npm run build
after_success:

149
LICENSE
View File

@@ -1,23 +1,21 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
@@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
@@ -72,7 +60,7 @@ modification follow.
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
@@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
@@ -635,40 +633,29 @@ the "copyright" line and a pointer to where the full notice is found.
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
GNU Affero General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<http://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.

View File

@@ -1,7 +1,7 @@
[![Build Status](https://api.travis-ci.org/edx/gradebook.svg?branch=master)](https://travis-ci.org/edx/gradebook) [![Coveralls](https://img.shields.io/coveralls/edx/gradebook.svg?branch=master)](https://coveralls.io/github/edx/gradebook)
[![npm_version](https://img.shields.io/npm/v/@edx/gradebook.svg)](@edx/gradebook)
[![npm_downloads](https://img.shields.io/npm/dt/@edx/gradebook.svg)](@edx/gradebook)
[![license](https://img.shields.io/npm/l/@edx/gradebook.svg)](@edx/gradebook)
[![Build Status](https://api.travis-ci.org/edx/frontend-app-gradebook.svg?branch=master)](https://travis-ci.org/edx/frontend-app-gradebook) [![Coveralls](https://img.shields.io/coveralls/edx/frontend-app-gradebook.svg?branch=master)](https://coveralls.io/github/edx/frontend-app-gradebook)
[![npm_version](https://img.shields.io/npm/v/@edx/frontend-app-gradebook.svg)](@edx/frontend-app-gradebook)
[![npm_downloads](https://img.shields.io/npm/dt/@edx/frontend-app-gradebook.svg)](@edx/frontend-app-gradebook)
[![license](https://img.shields.io/npm/l/@edx/frontend-app-gradebook.svg)](@edx/frontend-app-gradebook)
[![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release)
# gradebook
@@ -16,7 +16,7 @@ The front-end of our editable Gradebook feature.
To install gradebook into your project:
```
npm i --save @edx/gradebook
npm i --save @edx/frontend-app-gradebook
```
## Running the UI Standalone
@@ -49,6 +49,13 @@ in which you'd like to enable the gradebook. Add a course override flag using a
``grades.writable_gradebook``. Make sure to check the ``enabled`` box. Alternatively, you could add this as a
regular waffle flag to enable the gradebook for all courses.
## Running tests
1. Assuming that you're operating in the context of the edX devstack,
run `gradebook-shell` from your devstack directory. This will start a bash shell inside your
running gradebook container.
2. Run `make test` (which executes `npm run test`). This will run all of the gradebook tests.
## Directory Structure
* `config`

View File

@@ -98,10 +98,10 @@ module.exports = Merge.smart(commonConfig, {
}),
new webpack.EnvironmentPlugin({
NODE_ENV: 'development',
BASE_URL: 'localhost:1994',
BASE_URL: 'localhost:19000/gradebook',
LMS_BASE_URL: 'http://localhost:18000',
LOGIN_URL: 'http://localhost:18000/login',
LOGOUT_URL: 'http://localhost:18000/login',
LOGOUT_URL: 'http://localhost:18000/logout',
CSRF_TOKEN_API_PATH: '/csrf/api/v1/token',
REFRESH_ACCESS_TOKEN_ENDPOINT: 'http://localhost:18000/login_refresh',
DATA_API_BASE_URL: 'http://localhost:8000',
@@ -122,10 +122,13 @@ module.exports = Merge.smart(commonConfig, {
TWITTER_URL: 'https://twitter.com',
YOU_TUBE_URL: 'https://www.youtube.com',
LINKED_IN_URL: 'https://www.linkedin.com',
GOOGLE_PLUS_URL: 'https://plus.google.com',
REDDIT_URL: 'https://www.reddit.com',
APPLE_APP_STORE_URL: 'https://www.apple.com/ios/app-store/',
GOOGLE_PLAY_URL: 'https://play.google.com/store',
ENTERPRISE_MARKETING_URL: 'http://example.com',
ENTERPRISE_MARKETING_UTM_SOURCE: 'example.com',
ENTERPRISE_MARKETING_UTM_CAMPAIGN: 'example.com Referral',
ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM: 'Footer',
}),
// when the --hot option is not passed in as part of the command
// the HotModuleReplacementPlugin has to be specified in the Webpack configuration

View File

@@ -47,7 +47,7 @@ module.exports = Merge.smart(commonConfig, {
minimize: true,
},
},
'postcss-loader',
'postcss-loader', // for autoprefixing, needs to be before the sass loader, not sure why
{
loader: 'sass-loader', // compiles Sass to CSS
options: {
@@ -137,10 +137,13 @@ module.exports = Merge.smart(commonConfig, {
TWITTER_URL: null,
YOU_TUBE_URL: null,
LINKED_IN_URL: null,
GOOGLE_PLUS_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,
}),
],
});

View File

@@ -0,0 +1,46 @@
Usage of the bulk-update API
============================
Context
=======
The LMS Grades API exposes a set of Gradebook-related endpoints:
https://github.com/edx/edx-platform/blob/master/lms/djangoapps/grades/api/v1/gradebook_views.py
The ``bulk-update`` endpoint defined therein allows for the creation/modification of subsection
grades for multiple users and sections in a single request. This allows clients of the API to limit
the number of network requests made and to more easily manage client-side data. Moreover,
the course grade updates that occur during calls to this API are synchronous - the entire update operation
is completed before a response is given to the client.
For decisions made about the implementation of this API, see:
https://github.com/edx/edx-platform/blob/master/lms/djangoapps/grades/docs/decisions/0001-gradebook-api.rst
Decision
========
The Gradebook front-end will post data about a single subsection and user in a single request
to the ``bulk-update`` API. That is, we currently need only the "update" aspect of this
endpoint, and not the "bulk" aspect, for satisfying the requirements of the current UX.
Status
======
Accepted (circa December 2018)
Consequences
============
This is a scenario in which the implementation of the API is coupled to the
UX that depends on the API. Because the course grade update is synchronous, it means
the API response can contain the updated subsection and course grade data. Because
a response from the API contains this data, the UI can operate in a very familiar way:
- A user clicks a button to submit a request with grade update data to the update endpoint.
- On the server, the subsection and course grades are modified.
- In the meantime, the client-side user looks at a spinner.
- A response is returned with updated data and the spinner goes away.
- Updated data is displayed to the user, along with a message indicative of the update.
If the update becomes asynchronous, the user experience outlined above has to change.
Because a single call to this endpoint updates grades data for only a single user,
the endpoint does not necessarily have to utilize an asynchronous operation at this time.

View File

@@ -3,6 +3,7 @@
nick: grbk
oeps: {}
owner: edx/educator-neem
owner: schenedx
supporting_teams:
- masters-devs
openedx-release: {ref: master}
track-pulls: true

7335
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,14 @@
{
"name": "@edx/gradebook",
"name": "@edx/frontend-app-gradebook",
"version": "0.1.0",
"description": "edx editable gradebook-ui to manipulate grade overrides on subsections",
"repository": {
"type": "git",
"url": "git+https://github.com/edx/gradebook.git"
"url": "git+https://github.com/edx/frontend-app-gradebook.git"
},
"scripts": {
"build": "NODE_ENV=production BABEL_ENV=production webpack --config=config/webpack.prod.config.js",
"dev-build": "NODE_ENV=development BABEL_ENV=development webpack --config=config/webpack.dev.config.js",
"coveralls": "cat ./coverage/lcov.info | coveralls",
"is-es5": "es-check es5 ./dist/*.js",
"lint": "eslint --ext .js --ext .jsx .",
@@ -15,43 +16,49 @@
"semantic-release": "semantic-release",
"start": "NODE_ENV=development BABEL_ENV=development node_modules/.bin/webpack-dev-server --config=config/webpack.dev.config.js --progress",
"test": "jest --coverage --passWithNoTests",
"watch-tests": "jest --watch",
"travis-deploy-once": "travis-deploy-once"
},
"author": "edX",
"license": "AGPL-3.0",
"homepage": "https://github.com/edx/gradebook#readme",
"homepage": "https://github.com/edx/frontend-app-gradebook#readme",
"publishConfig": {
"access": "public"
},
"dependencies": {
"@edx/edx-bootstrap": "^0.4.3",
"@edx/frontend-auth": "^1.3.0",
"@edx/frontend-component-footer": "^1.0.0",
"@edx/paragon": "^3.8.3",
"@edx/frontend-auth": "^4.0.0",
"@edx/frontend-component-footer": "^4.1.5",
"@edx/paragon": "^7.1.5",
"@fortawesome/fontawesome-svg-core": "^1.2.25",
"@fortawesome/free-brands-svg-icons": "^5.11.2",
"@fortawesome/free-solid-svg-icons": "^5.11.2",
"@fortawesome/react-fontawesome": "^0.1.5",
"@redux-beacon/segment": "^1.0.0",
"babel-polyfill": "^6.26.0",
"classnames": "^2.2.6",
"email-prop-type": "^1.1.7",
"font-awesome": "^4.7.0",
"history": "^4.7.2",
"prop-types": "^15.6.2",
"history": "^4.10.1",
"prop-types": "^15.7.2",
"query-string": "^5.1.1",
"react": "^16.7.0",
"react-dom": "^16.7.0",
"react": "^16.10.1",
"react-dom": "^16.10.1",
"react-intl": "^2.9.0",
"react-redux": "^5.1.1",
"react-router": "^4.3.1",
"react-router-dom": "^4.3.1",
"react-router-redux": "^5.0.0-alpha.9",
"redux": "^3.7.2",
"redux-devtools-extension": "^2.13.7",
"redux-beacon": "^2.0.3",
"redux-beacon": "^2.1.0",
"redux-devtools-extension": "^2.13.8",
"redux-logger": "^3.0.6",
"redux-thunk": "^2.3.0",
"whatwg-fetch": "^2.0.4"
},
"devDependencies": {
"autoprefixer": "^9.4.5",
"axios-mock-adapter": "^1.16.0",
"autoprefixer": "^9.6.1",
"axios-mock-adapter": "^1.17.0",
"babel-cli": "^6.26.0",
"babel-eslint": "^8.2.6",
"babel-jest": "^22.4.4",
@@ -60,10 +67,10 @@
"babel-plugin-transform-object-rest-spread": "^6.26.0",
"babel-preset-env": "^1.7.0",
"babel-preset-react": "^6.24.1",
"codecov": "^3.1.0",
"codecov": "^3.6.1",
"css-loader": "^0.28.11",
"enzyme": "^3.8.0",
"enzyme-adapter-react-16": "^1.7.1",
"enzyme": "^3.10.0",
"enzyme-adapter-react-16": "^1.14.0",
"es-check": "^2.3.0",
"eslint-config-edx": "^4.0.4",
"fetch-mock": "^6.5.2",
@@ -75,19 +82,19 @@
"image-webpack-loader": "^4.2.0",
"jest": "^22.4.4",
"mini-css-extract-plugin": "^0.4.0",
"node-sass": "^4.11.0",
"node-sass": "^4.12.0",
"postcss-loader": "^3.0.0",
"react-dev-utils": "^5.0.3",
"react-test-renderer": "^16.7.0",
"react-test-renderer": "^16.10.1",
"redux-mock-store": "^1.5.3",
"sass-loader": "^6.0.6",
"semantic-release": "^15.13.3",
"semantic-release": "^15.13.24",
"style-loader": "^0.20.3",
"travis-deploy-once": "^5.0.11",
"webpack": "^4.28.4",
"webpack-cli": "^3.2.1",
"webpack-dev-server": "^3.1.14",
"webpack-merge": "^4.2.1"
"webpack": "^4.41.0",
"webpack-cli": "^3.3.9",
"webpack-dev-server": "^3.8.2",
"webpack-merge": "^4.2.2"
},
"jest": {
"setupFiles": [

View File

@@ -1,11 +1,13 @@
@import "~@edx/edx-bootstrap/sass/edx/theme";
@import "~bootstrap/scss/bootstrap";
@import "~@edx/paragon/scss/edx/theme.scss";
$fa-font-path: "~font-awesome/fonts";
@import "~font-awesome/scss/font-awesome";
@import "~@edx/paragon/src/SearchField/SearchField";
$input-focus-box-shadow: $input-box-shadow; // hack to get upgrade to paragon 4.0.0 to work
@import "~@edx/frontend-component-footer/src/lib/scss/site-footer";
@import "./components/Gradebook/gradebook";
@import "./components/Drawer/Drawer";

View File

@@ -0,0 +1,48 @@
$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%);
}
}

View File

@@ -0,0 +1,86 @@
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,
};
}
deferToNextRepaint(callback) {
window.requestAnimationFrame(() =>
window.setTimeout(callback, 0));
}
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 });
}
};
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,
};

View File

@@ -0,0 +1,153 @@
import { connect } from 'react-redux';
import React from 'react';
import PropTypes from 'prop-types';
import initialFilters from '../../data/constants/filters';
function FilterBadge({ name, value, onClick }) {
return (
<div>
<span className="badge badge-info">
<span>{`${name}: ${value}`}</span>
<button type="button" className="btn-info" aria-label="Close" onClick={onClick}>
<span aria-hidden="true">&times;</span>
</button>
</span>
<br />
</div>
);
}
function RangeFilterBadge({
displayName,
filterName1,
filterValue1,
filterName2,
filterValue2,
handleBadgeClose,
}) {
return ((filterValue1 !== initialFilters[filterName1]) ||
(filterValue2 !== initialFilters[filterName2]))
&&
<FilterBadge
name={displayName}
value={`${filterValue1} - ${filterValue2}`}
onClick={handleBadgeClose}
/>;
}
function SingleValueFilterBadge({
displayName, filterName, filterValue, handleBadgeClose,
}) {
return (filterValue !== initialFilters[filterName]) &&
<FilterBadge
name={displayName}
value={filterValue}
onClick={handleBadgeClose}
/>;
}
function FilterBadges({
assignment,
assignmentType,
track,
cohort,
assignmentGradeMin,
assignmentGradeMax,
courseGradeMin,
courseGradeMax,
handleFilterBadgeClose,
}) {
return (
<div>
<SingleValueFilterBadge
displayName="Assignment Type"
filterName="assignmentType"
filterValue={assignmentType}
handleBadgeClose={handleFilterBadgeClose(['assignmentType'])}
/>
<SingleValueFilterBadge
displayName="Assignment"
filterName="assignment"
filterValue={assignment}
handleBadgeClose={handleFilterBadgeClose(['assignment', 'assignmentGradeMax', 'assignmentGradeMin'])}
/>
<RangeFilterBadge
displayName="Assignment Grade"
filterName1="assignmentGradeMin"
filterValue1={assignmentGradeMin}
filterName2="assignmentGradeMax"
filterValue2={assignmentGradeMax}
handleBadgeClose={handleFilterBadgeClose(['assignmentGradeMin', 'assignmentGradeMax'])}
/>
<RangeFilterBadge
displayName="Course Grade"
filterName1="courseGradeMin"
filterValue1={courseGradeMin}
filterName2="courseGradeMax"
filterValue2={courseGradeMax}
handleBadgeClose={handleFilterBadgeClose(['courseGradeMin', 'courseGradeMax'])}
/>
<SingleValueFilterBadge
displayName="Track"
filterName="track"
filterValue={track}
handleBadgeClose={handleFilterBadgeClose(['track'])}
/>
<SingleValueFilterBadge
displayName="Cohort"
filterName="track"
filterValue={cohort}
handleBadgeClose={handleFilterBadgeClose(['cohort'])}
/>
</div>
);
}
const mapStateToProps = state => (
{
assignment: (state.filters.assignment || {}).label,
assignmentType: state.filters.assignmentType,
track: state.filters.track,
cohort: state.filters.cohort,
assignmentGradeMin: state.filters.assignmentGradeMin,
assignmentGradeMax: state.filters.assignmentGradeMax,
courseGradeMin: state.filters.courseGradeMin,
courseGradeMax: state.filters.courseGradeMax,
}
);
const ConnectedFilterBadges = connect(mapStateToProps)(FilterBadges);
export default ConnectedFilterBadges;
FilterBadge.propTypes = {
name: PropTypes.string.isRequired,
value: PropTypes.string.isRequired,
onClick: PropTypes.func.isRequired,
};
FilterBadges.defaultProps = {
assignment: initialFilters.assignmentType,
assignmentType: initialFilters.assignmentType,
track: initialFilters.track,
cohort: initialFilters.cohort,
assignmentGradeMin: initialFilters.assignmentGradeMin,
assignmentGradeMax: initialFilters.assignmentGradeMax,
courseGradeMin: initialFilters.courseGradeMin,
courseGradeMax: initialFilters.courseGradeMax,
};
FilterBadges.propTypes = {
assignment: PropTypes.string,
assignmentType: PropTypes.string,
track: PropTypes.string,
cohort: PropTypes.string,
assignmentGradeMin: PropTypes.string,
assignmentGradeMax: PropTypes.string,
courseGradeMin: PropTypes.string,
courseGradeMax: PropTypes.string,
handleFilterBadgeClose: PropTypes.func.isRequired,
};

View File

@@ -3,6 +3,7 @@
height: 100%;
width: 100%;
top: 0;
left: 0;
background-color: #999;
opacity: 0.5;
z-index: 99999;
@@ -16,16 +17,16 @@
color: black;
}
.gradebook-container{
width: 500px;
@media only screen and (min-width: 640px) {
width: 630px;
.gradebook-content {
// note that this width isn't well-abstracted from Drawer code.
// if we need to change it we may need to dig into those styles as well
width: 100vw;
.search-help-text {
margin-left: 20px;
}
@media only screen and (min-width: 992px) {
width: 900px;
}
@media only screen and (min-width: 1200px) {
width: 1024px;
h4 {
font-weight: bold;
margin-top: 2rem;
}
}
@@ -38,34 +39,60 @@
margin-left: 10px;
}
}
.grade-history-header{
float: left;
}
.grade-history-assignment{
padding-right: 49px;
}
.grade-history-student{
padding-right: 75px;
}
.grade-history-original-grade{
padding-right: 25px;
}
.grade-history-current-grade{
padding-right: 25px;
}
.gbook {
overflow-x: scroll;
.grade-button {
text-decoration: underline;
}
.student-key {
font-size: 14px;
}
.table {
padding-left: 244px;
// prevents the table from shrinking to a width where "Final 01" breaks to two lines
min-width: 731px;
th {
vertical-align: top;
font-size: 14px;
}
}
.table thead tr {
height: 60px;
}
.table tr th:first-child {
position: absolute;
width: 160px;
height:50px;
display: block;
background-color: #fff;
border-bottom: none;
border-bottom: none;
}
.table tr th:first-child,
.table tr td:first-child {
position: absolute;
position: sticky;
width: 160px;
height:50px;
display: block;
background-color: #fff;
}
.table tr td:nth-child(2) {
box-sizing: content-box;
padding-left: 170px;
}
.table tr th:nth-child(2) {
padding-left: 170px;
left: 0;
z-index: 1; // to float over the following children in the side-scrolling case
background: white;
}
.table tbody th {
@@ -81,3 +108,19 @@
}
}
.input-percent-label {
margin-top: 10px;
}
.mb-85 {
margin-bottom: 85px;
}
.modal-dialog {
max-width: 1000px;
}
.wrap-text-in-cell {
overflow-wrap: break-word;
word-wrap: break-word;
}

View File

@@ -1,73 +1,146 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
Button,
Collapsible,
Icon,
InputSelect,
InputText,
Modal,
SearchField,
StatefulButton,
StatusAlert,
Table,
Icon,
Tabs,
} from '@edx/paragon';
import queryString from 'query-string';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faDownload, faSpinner, faFilter } from '@fortawesome/free-solid-svg-icons';
import { configuration } from '../../config';
import PageButtons from '../PageButtons';
import Drawer from '../Drawer';
import { formatDateForDisplay } from '../../data/actions/utils';
import initialFilters from '../../data/constants/filters';
import ConnectedFilterBadges from '../FilterBadges';
const DECIMAL_PRECISION = 2;
const GRADE_OVERRIDE_HISTORY_COLUMNS = [{ label: 'Date', key: 'date' }, { label: 'Grader', key: 'grader' },
{ label: 'Reason', key: 'reason' },
{ label: 'Adjusted grade', key: 'adjustedGrade' }];
export default class Gradebook extends React.Component {
constructor(props) {
super(props);
this.state = {
filterValue: '',
courseGradeMin: '0',
courseGradeMax: '100',
modalOpen: false,
modalModel: [{}],
updateVal: 0,
adjustedGradeValue: 0,
updateModuleId: null,
updateUserId: null,
reasonForChange: '',
assignmentGradeMin: '0',
assignmentGradeMax: '100',
isMinCourseGradeFilterValid: true,
isMaxCourseGradeFilterValid: true,
};
this.fileFormRef = React.createRef();
this.fileInputRef = React.createRef();
this.myRef = React.createRef();
}
componentDidMount() {
const urlQuery = queryString.parse(this.props.location.search);
this.props.getRoles(this.props.match.params.courseId, urlQuery);
this.props.initializeFilters(urlQuery);
this.props.getRoles(this.props.courseId);
this.overrideReasonInput.focus();
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 });
}
setNewModalState = (userEntry, subsection) => {
this.props.fetchGradeOverrideHistory(
subsection.module_id,
userEntry.user_id,
);
let adjustedGradePossible = '';
let currentGradePossible = '';
if (subsection.attempted) {
adjustedGradePossible = ` / ${subsection.score_possible}`;
currentGradePossible = `/${subsection.score_possible}`;
adjustedGradePossible = subsection.score_possible;
}
this.setState({
modalModel: [{
username: userEntry.username,
currentGrade: `${subsection.score_earned}${currentGradePossible}`,
adjustedGrade: (
<span>
<input
style={{ width: '25px' }}
type="text"
onChange={event => this.setState({ updateVal: event.target.value })}
/>{adjustedGradePossible}
</span>
),
assignmentName: `${subsection.subsection_name}`,
}],
modalAssignmentName: `${subsection.subsection_name}`,
modalOpen: true,
updateModuleId: subsection.module_id,
updateUserId: userEntry.user_id,
updateUserName: userEntry.username,
todaysDate: formatDateForDisplay(new Date()),
adjustedGradePossible,
reasonForChange: '',
adjustedGradeValue: '',
});
}
getLearnerInformation = entry => (
<div>
<div>{entry.username}</div>
{entry.external_user_key && <div className="student-key">{entry.external_user_key}</div>}
</div>
)
getActiveTabs = () => {
if (this.props.showBulkManagement) {
return ['Grades', 'Bulk Management'];
}
return ['Grades'];
};
getAssignmentFilterOptions = () => [
{ label: 'All', value: '' },
...this.props.assignmentFilterOptions.map((assignment) => {
const { label, subsectionLabel } = assignment;
return {
label: `${label}: ${subsectionLabel}`,
value: label,
};
}),
];
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;
};
handleAdjustedGradeClick = () => {
this.props.updateGrades(
this.props.match.params.courseId, [
this.props.courseId, [
{
user_id: this.state.updateUserId,
usage_id: this.state.updateModuleId,
grade: {
earned_graded_override: this.state.updateVal,
earned_graded_override: this.state.adjustedGradeValue,
comment: this.state.reasonForChange,
},
},
],
@@ -76,18 +149,46 @@ export default class Gradebook extends React.Component {
this.props.selectedTrack,
);
this.closeAssignmentModal();
}
closeAssignmentModal = () => {
this.props.doneViewingAssignment();
this.setState({
modalModel: [{}],
adjustedGradePossible: '',
adjustedGradeValue: '',
modalOpen: false,
reasonForChange: '',
updateModuleId: null,
updateUserId: null,
});
}
};
updateQueryParams = (queryKey, queryValue) => {
handleAssignmentFilterChange = (assignment) => {
const selectedFilterOption = this.props.assignmentFilterOptions.find(assig =>
assig.label === assignment);
const { type, id } = selectedFilterOption || {};
const typedValue = { label: assignment, type, id };
this.props.updateAssignmentFilter(typedValue);
this.updateQueryParams({ assignment: id });
this.props.updateGradesIfAssignmentGradeFiltersSet(
this.props.courseId,
this.props.selectedCohort,
this.props.selectedTrack,
this.props.selectedAssignmentType,
);
};
updateQueryParams = (queryParams) => {
const parsed = queryString.parse(this.props.location.search);
parsed[queryKey] = queryValue;
return `?${queryString.stringify(parsed)}`;
Object.keys(queryParams).forEach((key) => {
if (queryParams[key]) {
parsed[key] = queryParams[key];
} else {
delete parsed[key];
}
});
this.props.history.push(`?${queryString.stringify(parsed)}`);
};
mapAssignmentTypeEntries = (entries) => {
@@ -95,7 +196,7 @@ export default class Gradebook extends React.Component {
id: entry,
label: entry,
}));
mapped.unshift({ id: 0, label: 'All' });
mapped.unshift({ id: 0, label: 'All', value: '' });
return mapped;
};
@@ -117,8 +218,56 @@ export default class Gradebook extends React.Component {
return mapped;
};
updateAssignmentTypes = (event) => {
this.props.filterColumns(event, this.props.grades[0]);
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 filename = (
<span className="wrap-text-in-cell">
{originalFilename}
</span>
);
const user = (
<span className="wrap-text-in-cell">
{username}
</span>
);
return {
resultsSummary,
filename,
user,
...rest,
};
};
updateAssignmentTypes = (assignmentType) => {
this.props.filterAssignmentType(assignmentType);
this.updateQueryParams({ assignmentType });
}
updateTracks = (event) => {
@@ -128,12 +277,12 @@ export default class Gradebook extends React.Component {
selectedTrackSlug = selectedTrackItem.slug;
}
this.props.getUserGrades(
this.props.match.params.courseId,
this.props.courseId,
this.props.selectedCohort,
selectedTrackSlug,
this.props.selectedAssignmentType,
);
const updatedQueryStrings = this.updateQueryParams('track', selectedTrackSlug);
this.props.history.push(updatedQueryStrings);
this.updateQueryParams({ track: selectedTrackSlug });
};
updateCohorts = (event) => {
@@ -143,23 +292,68 @@ export default class Gradebook extends React.Component {
selectedCohortId = selectedCohortItem.id;
}
this.props.getUserGrades(
this.props.match.params.courseId,
this.props.courseId,
selectedCohortId,
this.props.selectedTrack,
this.props.selectedAssignmentType,
);
const updatedQueryStrings = this.updateQueryParams('cohort', selectedCohortId);
this.props.history.push(updatedQueryStrings);
this.updateQueryParams({ cohort: selectedCohortId });
};
mapSelectedAssignmentTypeEntry = (entry) => {
const selectedAssignmentTypeEntry = this.props.assignmentTypes
.find(x => x.id === parseInt(entry, 10));
if (selectedAssignmentTypeEntry) {
return selectedAssignmentTypeEntry.name;
}
return 'All';
// 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;
};
handleClickDownloadInterventions = () => {
this.props.downloadInterventionReport(this.props.courseId);
window.location = this.props.interventionExportUrl;
};
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;
});
}
};
handleSubmitAssignmentGrade = (event) => {
event.preventDefault();
const {
assignmentGradeMin,
assignmentGradeMax,
} = this.state;
this.props.updateAssignmentLimits(assignmentGradeMin, assignmentGradeMax);
this.props.getUserGrades(
this.props.courseId,
this.props.selectedCohort,
this.props.selectedTrack,
this.props.selectedAssignmentType,
);
this.updateQueryParams({ assignmentGradeMin, assignmentGradeMax });
};
handleMinAssigGradeChange = assignmentGradeMin => this.setState({ assignmentGradeMin });
handleMaxAssigGradeChange = assignmentGradeMax => this.setState({ assignmentGradeMax });
mapSelectedCohortEntry = (entry) => {
const selectedCohortEntry = this.props.cohorts.find(x => x.id === parseInt(entry, 10));
if (selectedCohortEntry) {
@@ -176,20 +370,28 @@ export default class Gradebook extends React.Component {
return 'Tracks';
};
roundGrade = percent => parseFloat(percent.toFixed(DECIMAL_PRECISION));
roundGrade = percent => parseFloat((percent || 0).toFixed(DECIMAL_PRECISION));
formatter = {
percent: (entries, areGradesFrozen) => entries.map((entry) => {
const results = { username: entry.username };
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
.filter(section => section.is_graded)
.reduce((acc, subsection) => {
if (areGradesFrozen) {
acc[subsection.label] = `${this.roundGrade(subsection.percent * 100)} %`;
} else {
acc[subsection.label] = (
<button
className="btn btn-header link-style"
className="btn btn-header link-style grade-button"
onClick={() => this.setNewModalState(entry, subsection)}
>
{this.roundGrade(subsection.percent * 100)}%
@@ -197,14 +399,22 @@ export default class Gradebook extends React.Component {
}
return acc;
}, {});
const totals = { total: `${this.roundGrade(entry.percent * 100)}%` };
const totals = { Total: `${this.roundGrade(entry.percent * 100)}%` };
return Object.assign(results, assignments, totals);
}),
absolute: (entries, areGradesFrozen) => entries.map((entry) => {
const results = { username: entry.username };
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
.filter(section => section.is_graded)
.reduce((acc, subsection) => {
const scoreEarned = this.roundGrade(subsection.score_earned);
const scorePossible = this.roundGrade(subsection.score_possible);
@@ -227,166 +437,630 @@ export default class Gradebook extends React.Component {
return acc;
}, {});
const totals = { total: `${this.roundGrade(entry.percent * 100)}/100` };
const totals = { Total: `${this.roundGrade(entry.percent * 100)}/100` };
return Object.assign(results, assignments, totals);
}),
};
lmsInstructorDashboardUrl = courseId => `${configuration.LMS_BASE_URL}/courses/${courseId}/instructor`;
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, width: 'col' }));
// replace username heading label to include additional user data
headings[0].label = userInformationHeadingLabel;
headings[0].width = 'col-2';
headings[1].label = emailHeadingLabel;
headings[1].width = 'col-2';
}
return headings;
}
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,
);
}
render() {
return (
<div className="d-flex justify-content-center">
{ this.props.showSpinner && <div className="spinner-overlay"><Icon className={['fa', 'fa-spinner', 'fa-spin', 'fa-5x', 'color-black']} /></div>}
<div className="gradebook-container">
<div>
<Drawer
mainContent={toggleFilterDrawer => (
<div className="px-3 gradebook-content">
<a
href={this.lmsInstructorDashboardUrl(this.props.match.params.courseId)}
href={this.lmsInstructorDashboardUrl(this.props.courseId)}
className="mb-3"
>
<span aria-hidden="true">{'<< '}</span> {'Back to Dashboard'}
</a>
<h1>Gradebook</h1>
<h3> {this.props.match.params.courseId}</h3>
{ this.props.areGradesFrozen &&
<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) &&
{(this.props.canUserViewGradebook === false) &&
<div className="alert alert-warning" role="alert" >
You are not authorized to view the gradebook for this course.
</div>
}
<hr />
<div className="d-flex justify-content-between" >
<Tabs labels={this.getActiveTabs()}>
<div>
<div role="radiogroup" aria-labelledby="score-view-group-label">
<span id="score-view-group-label">Score View:</span>
<span>
<input
id="score-view-percent"
className="ml-2 mr-1"
type="radio"
name="score-view"
value="percent"
defaultChecked
onClick={() => this.props.toggleFormat('percent')}
<h4>Step 1: Filter the Grade Report</h4>
<div className="d-flex justify-content-between" >
{this.props.showSpinner && <div className="spinner-overlay"><Icon className="fa fa-spinner fa-spin fa-5x color-black" /></div>}
<Button className="btn-primary align-self-start" onClick={toggleFilterDrawer}><FontAwesomeIcon icon={faFilter} /> Edit Filters</Button>
<div>
<SearchField
onSubmit={value =>
this.props.searchForUser(
this.props.courseId,
value,
this.props.selectedCohort,
this.props.selectedTrack,
this.props.selectedAssignmentType,
)
}
inputLabel="Search for a learner"
onChange={filterValue => this.setState({ filterValue })}
onClear={() =>
this.props.getUserGrades(
this.props.courseId,
this.props.selectedCohort,
this.props.selectedTrack,
this.props.selectedAssignmentType,
)
}
value={this.state.filterValue}
/>
<label className="mr-2" htmlFor="score-view-percent">Percent</label>
</span>
<span>
<input
id="score-view-absolute"
type="radio"
name="score-view"
value="absolute"
className="mr-1"
onClick={() => this.props.toggleFormat('absolute')}
/>
<label htmlFor="score-view-absolute">Absolute</label>
</span>
<small className="form-text text-muted search-help-text">Search by username, email, or student key</small>
</div>
</div>
{ this.props.assignmnetTypes.length > 0 &&
<div className="student-filters">
<span className="label">
Assignment Types:
</span>
<InputSelect
name="assignment-types"
ariaLabel="Assignment Types"
value={this.mapSelectedTrackEntry(this.props.selectedAssignmentType)}
options={this.mapAssignmentTypeEntries(this.props.assignmnetTypes)}
onChange={this.updateAssignmentTypes}
<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 && (
<div>
<StatefulButton
buttonType="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
buttonType="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>
)}
</div>
<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 className="student-filters">
<span className="label">
Student Groups:
</span>
<InputSelect
name="Tracks"
ariaLabel="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"
ariaLabel="Cohorts"
disabled={this.props.cohorts.length === 0}
value={this.mapSelectedCohortEntry(this.props.selectedCohort)}
options={this.mapCohortsEntries(this.props.cohorts)}
onChange={this.updateCohorts}
/>
</div>
</div>
<div>
<div style={{ marginLeft: '10px', marginBottom: '10px' }}>
<a href={`${this.lmsInstructorDashboardUrl(this.props.match.params.courseId)}#view-data_download`}>Generate Grade Report</a>
</div>
<SearchField
onSubmit={value => this.props.searchForUser(this.props.match.params.courseId, value, this.props.selectedCohort, this.props.selectedTrack)}
onChange={filterValue => this.setState({ filterValue })}
onClear={() => this.props.getUserGrades(this.props.match.params.courseId, this.props.selectedCohort, this.props.selectedTrack)}
value={this.state.filterValue}
{PageButtons(this.props)}
<p>* available for learners in the Master&apos;s track only</p>
<Modal
open={this.state.modalOpen}
title="Edit Grades"
closeText="Cancel"
body={(
<div>
<div>
<div className="grade-history-header grade-history-assignment">Assignment: </div> <div>{this.state.modalAssignmentName}</div>
<div className="grade-history-header grade-history-student">Student: </div> <div>{this.state.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="Error retrieving grade override history."
open={this.props.errorFetchingGradeOverrideHistory}
dismissible={false}
/>
{!this.props.errorFetchingGradeOverrideHistory && (
<Table
columns={GRADE_OVERRIDE_HISTORY_COLUMNS}
data={[...this.props.gradeOverrides, {
date: this.state.todaysDate,
reason: (<input
type="text"
name="reasonForChange"
value={this.state.reasonForChange}
onChange={value => this.onChange(value)}
ref={(input) => { this.overrideReasonInput = input; }}
/>),
adjustedGrade: (
<span>
<input
type="text"
name="adjustedGradeValue"
value={this.state.adjustedGradeValue}
onChange={value => this.onChange(value)}
/>
{(this.state.adjustedGradePossible
|| this.props.gradeOriginalPossibleGraded)
&& ' / '}
{this.state.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
buttonType="primary"
onClick={this.handleAdjustedGradeClick}
>
Save Grade
</Button>,
]}
onClose={this.closeAssignmentModal}
/>
</div>
</div>
<br />
<StatusAlert
alertType="success"
dialog="The grade has been successfully edited."
onClose={() => this.props.updateBanner(false)}
open={this.props.showSuccess}
/>
{PageButtons(this.props)}
<div className="gbook">
<Table
columns={this.props.headings}
data={this.formatter[this.props.format](this.props.grades, this.props.areGradesFrozen)}
tableSortable
defaultSortDirection="asc"
defaultSortedColumn="username"
rowHeaderColumnKey="username"
{this.props.showBulkManagement && (
<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}
open={this.props.bulkImportError}
dismissible={false}
/>
<StatusAlert
alertType="success"
dialog="CSV processing. File uploads may take several minutes to complete"
open={this.props.uploadSuccess}
dismissible={false}
/>
<input
className="d-none"
type="file"
name="csv"
label="Upload Grade CSV"
onChange={this.handleFileInputChange}
ref={this.fileInputRef}
/>
</form>
<Button
buttonType="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>)}
</Tabs>
</div>
)}
initiallyOpen={false}
title={
<React.Fragment>
<FontAwesomeIcon icon={faFilter} /> Filter By...
</React.Fragment>
}
>
<Collapsible title="Assignments" isOpen className="filter-group mb-3">
<div>
<div className="student-filters">
<span className="label">
Assignment Types:
</span>
<InputSelect
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>
{PageButtons(this.props)}
<Modal
open={this.state.modalOpen}
title="Edit Grades"
closeText="Cancel"
body={(
<div>
<h3>{this.state.modalModel[0].assignmentName}</h3>
<Table
columns={[{ label: 'Username', key: 'username' }, { label: 'Current grade', key: 'currentGrade' }, { label: 'Adjusted grade', key: 'adjustedGrade' }]}
data={this.state.modalModel}
/>
<div>Note: Once you save, your changes will be visible to students.</div>
</div>
)}
buttons={[
<Button
label="Save Grade"
buttonType="primary"
onClick={this.handleAdjustedGradeClick}
/>,
]}
onClose={() => this.setState({
modalOpen: false,
modalModel: [{}],
updateVal: 0,
updateModuleId: null,
updateUserId: null,
})}
/>
<div className="student-filters">
<span className="label">
Assignment:
</span>
<InputSelect
name="assignment"
aria-label="Assignment"
value={this.props.selectedAssignment}
options={this.getAssignmentFilterOptions()}
onChange={this.handleAssignmentFilterChange}
disabled={this.props.assignmentFilterOptions.length === 0}
/>
</div>
<p>Grade Range (0% - 100%)</p>
<form className="d-flex justify-content-between align-items-center" onSubmit={this.handleSubmitAssignmentGrade}>
<InputText
label="Min Grade"
name="assignmentGradeMin"
type="number"
min={0}
max={100}
step={1}
value={this.state.assignmentGradeMin}
disabled={!this.props.selectedAssignment}
onChange={this.handleMinAssigGradeChange}
/>
<span className="input-percent-label">%</span>
<InputText
label="Max Grade"
name="assignmentGradeMax"
type="number"
min={0}
max={100}
step={1}
value={this.state.assignmentGradeMax}
disabled={!this.props.selectedAssignment}
onChange={this.handleMaxAssigGradeChange}
/>
<span className="input-percent-label">%</span>
<Button
type="submit"
className="btn-outline-secondary"
name="assignmentGradeMinMax"
disabled={!this.props.selectedAssignment}
>
Apply
</Button>
</form>
</div>
</div>
</div>
</Collapsible>
<Collapsible title="Overall Grade" isOpen className="filter-group mb-3">
<div className="d-flex justify-content-between align-items-center">
<InputText
value={this.state.courseGradeMin}
name="minimum-grade"
label="Min Grade"
onChange={value => this.handleCourseGradeFilterChange('min', value)}
type="number"
min={0}
max={100}
/>
<span className="input-percent-label">%</span>
<InputText
value={this.state.courseGradeMax}
name="max-grade"
label="Max Grade"
onChange={value => this.handleCourseGradeFilterChange('max', value)}
type="number"
min={0}
max={100}
/>
<span className="input-percent-label">%</span>
<Button
buttonType="outline-secondary"
onClick={this.handleCourseGradeFilterApplyButtonClick}
>
Apply
</Button>
</div>
</Collapsible>
<Collapsible title="Student Groups" isOpen className="filter-group mb-3">
<InputSelect
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"
disabled={this.props.cohorts.length === 0}
value={this.mapSelectedCohortEntry(this.props.selectedCohort)}
options={this.mapCohortsEntries(this.props.cohorts)}
onChange={this.updateCohorts}
/>
</Collapsible>
</Drawer>
);
}
}
Gradebook.defaultProps = {
areGradesFrozen: false,
assignmentTypes: [],
assignmentFilterOptions: [],
canUserViewGradebook: false,
cohorts: [],
grades: [],
gradeOverrides: [],
gradeOverrideCurrentEarnedGradedOverride: null,
gradeOriginalEarnedGraded: null,
gradeOriginalPossibleGraded: null,
location: {
search: '',
},
courseId: '',
selectedCohort: null,
selectedTrack: null,
selectedAssignmentType: '',
selectedAssignment: '',
showSpinner: false,
tracks: [],
bulkImportError: '',
uploadSuccess: false,
showBulkManagement: false,
bulkManagementHistory: [],
errorFetchingGradeOverrideHistory: false,
totalUsersCount: null,
filteredUsersCount: null,
};
Gradebook.propTypes = {
areGradesFrozen: PropTypes.bool,
assignmentTypes: PropTypes.arrayOf(PropTypes.string),
assignmentFilterOptions: PropTypes.arrayOf(PropTypes.shape({
label: PropTypes.string,
subsectionLabel: PropTypes.string,
})),
canUserViewGradebook: PropTypes.bool,
cohorts: PropTypes.arrayOf(PropTypes.shape({
name: PropTypes.string,
id: PropTypes.number,
})),
filterAssignmentType: PropTypes.func.isRequired,
updateAssignmentFilter: PropTypes.func.isRequired,
updateAssignmentLimits: PropTypes.func.isRequired,
format: PropTypes.string.isRequired,
getRoles: PropTypes.func.isRequired,
getUserGrades: PropTypes.func.isRequired,
fetchGradeOverrideHistory: PropTypes.func.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,
})),
gradeOverrides: PropTypes.arrayOf(PropTypes.shape({
date: PropTypes.string,
grader: PropTypes.string,
reason: PropTypes.string,
adjustedGrade: PropTypes.number,
})),
gradeOverrideCurrentEarnedGradedOverride: PropTypes.number,
gradeOriginalEarnedGraded: PropTypes.number,
gradeOriginalPossibleGraded: PropTypes.number,
doneViewingAssignment: PropTypes.func.isRequired,
headings: PropTypes.arrayOf(PropTypes.string).isRequired,
history: PropTypes.shape({
push: PropTypes.func,
}).isRequired,
location: PropTypes.shape({
search: PropTypes.string,
}),
courseId: PropTypes.string,
searchForUser: PropTypes.func.isRequired,
selectedAssignmentType: PropTypes.string,
selectedAssignment: PropTypes.string,
selectedCohort: PropTypes.string,
selectedTrack: PropTypes.string,
resetFilters: PropTypes.func.isRequired,
showSpinner: PropTypes.bool,
showSuccess: PropTypes.bool.isRequired,
toggleFormat: PropTypes.func.isRequired,
tracks: PropTypes.arrayOf(PropTypes.shape({
name: PropTypes.string,
})),
closeBanner: PropTypes.func.isRequired,
updateGrades: PropTypes.func.isRequired,
gradeExportUrl: PropTypes.string.isRequired,
interventionExportUrl: PropTypes.string.isRequired,
submitFileUploadFormData: PropTypes.func.isRequired,
bulkImportError: PropTypes.string,
uploadSuccess: PropTypes.bool,
errorFetchingGradeOverrideHistory: PropTypes.bool,
showBulkManagement: PropTypes.bool,
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,
})),
totalUsersCount: PropTypes.number,
filteredUsersCount: PropTypes.number,
initializeFilters: PropTypes.func.isRequired,
updateGradesIfAssignmentGradeFiltersSet: PropTypes.func.isRequired,
updateCourseGradeFilter: PropTypes.func.isRequired,
downloadBulkGradesReport: PropTypes.func.isRequired,
downloadInterventionReport: PropTypes.func.isRequired,
};

View File

@@ -4,13 +4,6 @@ import { Hyperlink } from '@edx/paragon';
import EdxLogo from '../../../assets/edx-sm.png';
export default class Header extends React.Component {
constructor(props) {
super(props);
this.state = {
mobileNavOpen: false,
};
}
renderLogo() {
return (
<img src={EdxLogo} alt="edX logo" height="30" width="60" />
@@ -21,7 +14,9 @@ export default class Header extends React.Component {
return (
<div className="mb-3">
<header className="d-flex justify-content-center align-items-center p-3 border-bottom-blue">
<Hyperlink content={this.renderLogo()} destination="https://www.edx.org" />
<Hyperlink destination="https://www.edx.org">
{this.renderLogo()}
</Hyperlink>
<div />
</header>
</div>

View File

@@ -10,7 +10,7 @@ exports[`PageButtons prev not null, next not null 1`] = `
}
>
<button
className="btn btn-primary"
className="btn btn-outline-primary"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
@@ -25,7 +25,7 @@ exports[`PageButtons prev not null, next not null 1`] = `
Previous Page
</button>
<button
className="btn btn-primary"
className="btn btn-outline-primary"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
@@ -52,7 +52,7 @@ exports[`PageButtons prev not null, next null 1`] = `
}
>
<button
className="btn btn-primary"
className="btn btn-outline-primary"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
@@ -67,7 +67,7 @@ exports[`PageButtons prev not null, next null 1`] = `
Previous Page
</button>
<button
className="btn btn-primary"
className="btn btn-outline-primary"
disabled={true}
onBlur={[Function]}
onClick={[Function]}
@@ -94,7 +94,7 @@ exports[`PageButtons prev null, next not null 1`] = `
}
>
<button
className="btn btn-primary"
className="btn btn-outline-primary"
disabled={true}
onBlur={[Function]}
onClick={[Function]}
@@ -109,7 +109,7 @@ exports[`PageButtons prev null, next not null 1`] = `
Previous Page
</button>
<button
className="btn btn-primary"
className="btn btn-outline-primary"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
@@ -136,7 +136,7 @@ exports[`PageButtons prev null, next null 1`] = `
}
>
<button
className="btn btn-primary"
className="btn btn-outline-primary"
disabled={true}
onBlur={[Function]}
onClick={[Function]}
@@ -151,7 +151,7 @@ exports[`PageButtons prev null, next null 1`] = `
Previous Page
</button>
<button
className="btn btn-primary"
className="btn btn-outline-primary"
disabled={true}
onBlur={[Function]}
onClick={[Function]}

View File

@@ -1,30 +1,79 @@
import React from 'react';
import {
Button,
} from '@edx/paragon';
import PropTypes from 'prop-types';
import { Button } from '@edx/paragon';
export default function PageButtons({prevPage, nextPage, selectedTrack, selectedCohort, getPrevNextGrades}) {
export default function PageButtons({
prevPage, nextPage, selectedTrack, selectedCohort, selectedAssignmentType,
getPrevNextGrades, match,
}) {
return (
<div
className="d-flex justify-content-center"
style={{ paddingBottom: '20px' }}
>
<Button
label="Previous Page"
style={{ margin: '20px' }}
buttonType="primary"
buttonType="outline-primary"
disabled={!prevPage}
onClick={() => getPrevNextGrades(prevPage, selectedCohort, selectedTrack)}
/>
onClick={() =>
getPrevNextGrades(
prevPage,
match.params.courseId,
selectedCohort,
selectedTrack,
selectedAssignmentType,
)}
>
Previous Page
</Button>
<Button
label="Next Page"
style={{ margin: '20px' }}
buttonType="primary"
buttonType="outline-primary"
disabled={!nextPage}
onClick={() => getPrevNextGrades(nextPage, selectedCohort, selectedTrack)}
/>
onClick={() =>
getPrevNextGrades(
nextPage,
match.params.courseId,
selectedCohort,
selectedTrack,
selectedAssignmentType,
)}
>
Next Page
</Button>
</div>
);
}
PageButtons.defaultProps = {
match: {
params: {
courseId: '',
},
},
nextPage: '',
prevPage: '',
selectedCohort: null,
selectedTrack: null,
selectedAssignmentType: null,
};
PageButtons.propTypes = {
getPrevNextGrades: PropTypes.func.isRequired,
match: PropTypes.shape({
params: PropTypes.shape({
courseId: PropTypes.string,
}),
}),
nextPage: PropTypes.string,
prevPage: PropTypes.string,
selectedAssignmentType: PropTypes.string,
selectedCohort: PropTypes.shape({
name: PropTypes.string,
}),
selectedTrack: PropTypes.shape({
name: PropTypes.string,
}),
};

View File

@@ -2,86 +2,146 @@ import { connect } from 'react-redux';
import Gradebook from '../../components/Gradebook';
import {
closeBanner,
doneViewingAssignment,
fetchGradeOverrideHistory,
fetchGrades,
fetchMatchingUserGrades,
fetchPrevNextGrades,
updateGrades,
filterAssignmentType,
submitFileUploadFormData,
toggleGradeFormat,
filterColumns,
updateBanner,
updateGrades,
updateGradesIfAssignmentGradeFiltersSet,
downloadBulkGradesReport,
downloadInterventionReport,
} from '../../data/actions/grades';
import { fetchCohorts } from '../../data/actions/cohorts';
import { fetchTracks } from '../../data/actions/tracks';
import { initializeFilters, resetFilters, updateAssignmentFilter, updateAssignmentLimits, updateCourseGradeFilter } from '../../data/actions/filters';
import stateHasMastersTrack from '../../data/selectors/tracks';
import {
getBulkManagementHistory,
getHeadings,
formatMinAssignmentGrade,
formatMaxAssignmentGrade,
formatMinCourseGrade,
formatMaxCourseGrade,
} from '../../data/selectors/grades';
import { selectableAssignmentLabels } from '../../data/selectors/filters';
import { getCohortNameById } from '../../data/selectors/cohorts';
import { fetchAssignmentTypes } from '../../data/actions/assignmentTypes';
import { getRoles } from '../../data/actions/roles';
import LmsApiService from '../../data/services/LmsApiService';
const mapStateToProps = state => (
{
grades: state.grades.results,
headings: state.grades.headings,
tracks: state.tracks.results,
cohorts: state.cohorts.results,
selectedTrack: state.grades.selectedTrack,
selectedCohort: state.grades.selectedCohort,
format: state.grades.gradeFormat,
showSuccess: state.grades.showSuccess,
prevPage: state.grades.prevPage,
nextPage: state.grades.nextPage,
assignmnetTypes: state.assignmentTypes.results,
areGradesFrozen: state.assignmentTypes.areGradesFrozen,
showSpinner: shouldShowSpinner(state),
canUserViewGradebook: state.roles.canUserViewGradebook
}
);
function shouldShowSpinner (state) {
if (state.roles.canUserViewGradebook === true){
function shouldShowSpinner(state) {
if (state.roles.canUserViewGradebook === true) {
return state.grades.showSpinner;
} else if (state.roles.canUserViewGradebook === false){
} else if (state.roles.canUserViewGradebook === false) {
return false;
} else { // canUserViewGradebook === null
return true;
}
} // canUserViewGradebook === null
return true;
}
const mapDispatchToProps = dispatch => (
const mapStateToProps = (state, ownProps) => (
{
getUserGrades: (courseId, cohort, track) => {
dispatch(fetchGrades(courseId, cohort, track));
},
searchForUser: (courseId, searchText, cohort, track) => {
dispatch(fetchMatchingUserGrades(courseId, searchText, cohort, track, false));
},
getPrevNextGrades: (endpoint, cohort, track) => {
dispatch(fetchPrevNextGrades(endpoint, cohort, track));
},
getCohorts: (courseId) => {
dispatch(fetchCohorts(courseId));
},
getTracks: (courseId) => {
dispatch(fetchTracks(courseId));
},
getAssignmentTypes: (courseId) => {
dispatch(fetchAssignmentTypes(courseId));
},
updateGrades: (courseId, updateData, searchText, cohort, track) => {
dispatch(updateGrades(courseId, updateData, searchText, cohort, track));
},
toggleFormat: (formatType) => {
dispatch(toggleGradeFormat(formatType));
},
filterColumns: (filterType, exampleUser) => {
dispatch(filterColumns(filterType, exampleUser));
},
updateBanner: (showSuccess) => {
dispatch(updateBanner(showSuccess));
},
getRoles: (matchParams, urlQuery) => {
dispatch(getRoles(matchParams, urlQuery));
},
courseId: ownProps.match.params.courseId,
grades: state.grades.results,
gradeOverrides: state.grades.gradeOverrideHistoryResults,
gradeOverrideCurrentEarnedAllOverride: state.grades.gradeOverrideCurrentEarnedAllOverride,
gradeOverrideCurrentPossibleAllOverride: state.grades.gradeOverrideCurrentPossibleAllOverride,
gradeOverrideCurrentEarnedGradedOverride: state.grades.gradeOverrideCurrentEarnedGradedOverride,
gradeOverrideCurrentPossibleGradedOverride:
state.grades.gradeOverrideCurrentPossibleGradedOverride,
gradeOriginalEarnedGraded: state.grades.gradeOriginalEarnedGraded,
gradeOriginalPossibleGraded: state.grades.gradeOriginalPossibleGraded,
headings: getHeadings(state),
tracks: state.tracks.results,
cohorts: state.cohorts.results,
selectedTrack: state.filters.track,
selectedCohort: state.filters.cohort,
selectedAssignmentType: state.filters.assignmentType,
selectedAssignment: (state.filters.assignment || {}).label,
format: state.grades.gradeFormat,
showSuccess: state.grades.showSuccess,
errorFetchingGradeOverrideHistory: state.grades.errorFetchingOverrideHistory,
prevPage: state.grades.prevPage,
nextPage: state.grades.nextPage,
assignmentTypes: state.assignmentTypes.results,
assignmentFilterOptions: selectableAssignmentLabels(state),
areGradesFrozen: state.assignmentTypes.areGradesFrozen,
showSpinner: shouldShowSpinner(state),
canUserViewGradebook: state.roles.canUserViewGradebook,
gradeExportUrl: LmsApiService.getGradeExportCsvUrl(ownProps.match.params.courseId, {
cohort: getCohortNameById(state, state.filters.cohort),
track: state.filters.track,
assignment: (state.filters.assignment || {}).id,
assignmentType: state.filters.assignmentType,
assignmentGradeMin: formatMinAssignmentGrade(
state.filters.assignmentGradeMin,
{ assignmentId: (state.filters.assignment || {}).id },
),
assignmentGradeMax: formatMaxAssignmentGrade(
state.filters.assignmentGradeMax,
{ assignmentId: (state.filters.assignment || {}).id },
),
courseGradeMin: formatMinCourseGrade(state.filters.courseGradeMin),
courseGradeMax: formatMaxCourseGrade(state.filters.courseGradeMax),
}),
interventionExportUrl:
LmsApiService.getInterventionExportCsvUrl(ownProps.match.params.courseId, {
cohort: getCohortNameById(state, state.filters.cohort),
assignment: (state.filters.assignment || {}).id,
assignmentType: state.filters.assignmentType,
assignmentGradeMin: formatMinAssignmentGrade(
state.filters.assignmentGradeMin,
{ assignmentId: (state.filters.assignment || {}).id },
),
assignmentGradeMax: formatMaxAssignmentGrade(
state.filters.assignmentGradeMax,
{ assignmentId: (state.filters.assignment || {}).id },
),
courseGradeMin: formatMinCourseGrade(state.filters.courseGradeMin),
courseGradeMax: formatMaxCourseGrade(state.filters.courseGradeMax),
}),
bulkImportError: state.grades.bulkManagement &&
state.grades.bulkManagement.errorMessages ?
`Errors while processing: ${state.grades.bulkManagement.errorMessages.join(', ')}` :
'',
uploadSuccess: !!(state.grades.bulkManagement &&
state.grades.bulkManagement.uploadSuccess),
showBulkManagement: stateHasMastersTrack(state) && state.config.bulkManagementAvailable,
bulkManagementHistory: getBulkManagementHistory(state),
totalUsersCount: state.grades.totalUsersCount,
filteredUsersCount: state.grades.filteredUsersCount,
}
);
const mapDispatchToProps = {
doneViewingAssignment,
getUserGrades: fetchGrades,
fetchGradeOverrideHistory,
searchForUser: fetchMatchingUserGrades,
getPrevNextGrades: fetchPrevNextGrades,
getCohorts: fetchCohorts,
getTracks: fetchTracks,
getAssignmentTypes: fetchAssignmentTypes,
updateGrades,
toggleFormat: toggleGradeFormat,
filterAssignmentType,
closeBanner,
getRoles,
submitFileUploadFormData,
initializeFilters,
resetFilters,
updateAssignmentFilter,
updateAssignmentLimits,
updateGradesIfAssignmentGradeFiltersSet,
updateCourseGradeFilter,
downloadBulkGradesReport,
downloadInterventionReport,
};
const GradebookPage = connect(
mapStateToProps,
mapDispatchToProps,

View File

@@ -4,12 +4,17 @@ import {
ERROR_FETCHING_ASSIGNMENT_TYPES,
GOT_ARE_GRADES_FROZEN,
} from '../constants/actionTypes/assignmentTypes';
import GOT_BULK_MANAGEMENT_CONFIG from '../constants/actionTypes/config';
import LmsApiService from '../services/LmsApiService';
const startedFetchingAssignmentTypes = () => ({ type: STARTED_FETCHING_ASSIGNMENT_TYPES });
const errorFetchingAssignmentTypes = () => ({ type: ERROR_FETCHING_ASSIGNMENT_TYPES });
const gotAssignmentTypes = assignmentTypes => ({ type: GOT_ASSIGNMENT_TYPES, assignmentTypes });
const gotGradesFrozen = areGradesFrozen => ({ type: GOT_ARE_GRADES_FROZEN, areGradesFrozen });
const gotBulkManagementConfig = bulkManagementEnabled => ({
type: GOT_BULK_MANAGEMENT_CONFIG,
data: bulkManagementEnabled,
});
const fetchAssignmentTypes = courseId => (
(dispatch) => {
@@ -19,6 +24,7 @@ const fetchAssignmentTypes = courseId => (
.then((data) => {
dispatch(gotAssignmentTypes(Object.keys(data.assignment_types)));
dispatch(gotGradesFrozen(data.grades_frozen));
dispatch(gotBulkManagementConfig(data.can_see_bulk_management));
})
.catch(() => {
dispatch(errorFetchingAssignmentTypes());

View File

@@ -11,9 +11,12 @@ import {
ERROR_FETCHING_ASSIGNMENT_TYPES,
GOT_ARE_GRADES_FROZEN,
} from '../constants/actionTypes/assignmentTypes';
import GOT_BULK_MANAGEMENT_CONFIG from '../constants/actionTypes/config';
const mockStore = configureMockStore([thunk]);
const axiosMock = new MockAdapter(apiClient);
apiClient.isAccessTokenExpired = jest.fn();
apiClient.isAccessTokenExpired.mockReturnValue(false);
describe('actions', () => {
afterEach(() => {
@@ -40,12 +43,14 @@ describe('actions', () => {
},
},
grades_frozen: false,
can_see_bulk_management: true,
};
it('dispatches success action after fetching fetchAssignmentTypes', () => {
const expectedActions = [
{ type: STARTED_FETCHING_ASSIGNMENT_TYPES },
{ type: GOT_ASSIGNMENT_TYPES, assignmentTypes: Object.keys(responseData.assignment_types) },
{ type: GOT_ARE_GRADES_FROZEN, areGradesFrozen: responseData.grades_frozen },
{ type: GOT_BULK_MANAGEMENT_CONFIG, data: true },
];
const store = mockStore();
@@ -77,6 +82,7 @@ describe('actions', () => {
{ type: STARTED_FETCHING_ASSIGNMENT_TYPES },
{ type: GOT_ASSIGNMENT_TYPES, assignmentTypes: Object.keys(responseData.assignment_types) },
{ type: GOT_ARE_GRADES_FROZEN, areGradesFrozen: true },
{ type: GOT_BULK_MANAGEMENT_CONFIG, data: true },
];
const store = mockStore();
responseData.grades_frozen = true;

View File

@@ -13,6 +13,8 @@ import {
const mockStore = configureMockStore([thunk]);
const axiosMock = new MockAdapter(apiClient);
apiClient.isAccessTokenExpired = jest.fn();
apiClient.isAccessTokenExpired.mockReturnValue(false);
describe('actions', () => {
afterEach(() => {

View File

@@ -0,0 +1,54 @@
import initialFilters from '../constants/filters';
import { INITIALIZE_FILTERS, RESET_FILTERS, UPDATE_ASSIGNMENT_FILTER, UPDATE_ASSIGNMENT_LIMITS, UPDATE_COURSE_GRADE_LIMITS } from '../constants/actionTypes/filters';
const initializeFilters = ({
assignment = initialFilters.assignment,
assignmentType = initialFilters.assignmentType,
track = initialFilters.track,
cohort = initialFilters.cohort,
assignmentGradeMin = initialFilters.assignmentGradeMin,
assignmentGradeMax = initialFilters.assignmentGradeMax,
courseGradeMin = initialFilters.courseGradeMin,
courseGradeMax = initialFilters.assignmentGradeMax,
}) => ({
type: INITIALIZE_FILTERS,
data: {
assignment: { id: assignment },
assignmentType,
track,
cohort,
assignmentGradeMin,
assignmentGradeMax,
courseGradeMin,
courseGradeMax,
},
});
const resetFilters = filterNames => ({
type: RESET_FILTERS,
filterNames,
});
const updateAssignmentFilter = assignment => ({
type: UPDATE_ASSIGNMENT_FILTER,
data: assignment,
});
const updateAssignmentLimits = (minGrade, maxGrade) => ({
type: UPDATE_ASSIGNMENT_LIMITS,
data: { minGrade, maxGrade },
});
const updateCourseGradeFilter = (courseGradeMin, courseGradeMax, courseId) => ({
type: UPDATE_COURSE_GRADE_LIMITS,
data: {
courseGradeMin,
courseGradeMax,
courseId,
},
});
export {
initializeFilters, resetFilters, updateAssignmentFilter,
updateAssignmentLimits, updateCourseGradeFilter,
};

View File

@@ -7,79 +7,172 @@ import {
GRADE_UPDATE_SUCCESS,
GRADE_UPDATE_FAILURE,
TOGGLE_GRADE_FORMAT,
SORT_GRADES,
FILTER_COLUMNS,
UPDATE_BANNER,
FILTER_BY_ASSIGNMENT_TYPE,
OPEN_BANNER,
CLOSE_BANNER,
START_UPLOAD,
UPLOAD_COMPLETE,
UPLOAD_ERR,
GOT_BULK_HISTORY,
BULK_HISTORY_ERR,
GOT_GRADE_OVERRIDE_HISTORY,
DONE_VIEWING_ASSIGNMENT,
ERROR_FETCHING_GRADE_OVERRIDE_HISTORY,
UPLOAD_OVERRIDE,
UPLOAD_OVERRIDE_ERROR,
BULK_GRADE_REPORT_DOWNLOADED,
INTERVENTION_REPORT_DOWNLOADED,
} from '../constants/actionTypes/grades';
import LmsApiService from '../services/LmsApiService';
import store from '../store';
import { headingMapper, gradeSortMap, sortAlphaAsc } from './utils';
import { sortAlphaAsc, formatDateForDisplay } from './utils';
import { formatMaxAssignmentGrade, formatMinAssignmentGrade, formatMaxCourseGrade, formatMinCourseGrade } from '../selectors/grades';
import { getFilters } from '../selectors/filters';
import apiClient from '../apiClient';
const defaultAssignmentFilter = 'All';
const sortGrades = (columnName, direction) => {
const sortFn = gradeSortMap(columnName, direction);
const { results } = store.getState().grades;
results.sort(sortFn);
/* have to make a copy of results or React wont know there was
* a change and wont trigger a re-render
*/
return ({ type: SORT_GRADES, results: [...results] });
};
const startedCsvUpload = () => ({ type: START_UPLOAD });
const finishedCsvUpload = () => ({ type: UPLOAD_COMPLETE });
const csvUploadError = data => ({ type: UPLOAD_ERR, data });
const gotBulkHistory = data => ({ type: GOT_BULK_HISTORY, data });
const bulkHistoryError = () => ({ type: BULK_HISTORY_ERR });
const startedFetchingGrades = () => ({ type: STARTED_FETCHING_GRADES });
const finishedFetchingGrades = () => ({ type: FINISHED_FETCHING_GRADES });
const errorFetchingGrades = () => ({ type: ERROR_FETCHING_GRADES });
const gotGrades = (grades, cohort, track, headings, prev, next) => ({
const errorFetchingGradeOverrideHistory = () => ({ type: ERROR_FETCHING_GRADE_OVERRIDE_HISTORY });
const gotGrades = ({
grades, cohort, track, assignmentType, headings, prev,
next, courseId, totalUsersCount, filteredUsersCount,
}) => ({
type: GOT_GRADES,
grades,
cohort,
track,
assignmentType,
headings,
prev,
next,
courseId,
totalUsersCount,
filteredUsersCount,
});
const gotGradeOverrideHistory = ({
overrideHistory, currentEarnedAllOverride, currentPossibleAllOverride,
currentEarnedGradedOverride, currentPossibleGradedOverride,
originalGradeEarnedAll, originalGradePossibleAll, originalGradeEarnedGraded,
originalGradePossibleGraded,
}) => ({
type: GOT_GRADE_OVERRIDE_HISTORY,
overrideHistory,
currentEarnedAllOverride,
currentPossibleAllOverride,
currentEarnedGradedOverride,
currentPossibleGradedOverride,
originalGradeEarnedAll,
originalGradePossibleAll,
originalGradeEarnedGraded,
originalGradePossibleGraded,
});
const gradeUpdateRequest = () => ({ type: GRADE_UPDATE_REQUEST });
const gradeUpdateSuccess = responseData => ({
const gradeUpdateSuccess = (courseId, responseData) => ({
type: GRADE_UPDATE_SUCCESS,
courseId,
payload: { responseData },
});
const gradeUpdateFailure = error => ({
const gradeUpdateFailure = (courseId, error) => ({
type: GRADE_UPDATE_FAILURE,
courseId,
payload: { error },
});
const uploadOverrideSuccess = courseId => ({
type: UPLOAD_OVERRIDE,
courseId,
});
// This action for google analytics only. Doesn't change redux state.
const downloadBulkGradesReport = courseId => ({
type: BULK_GRADE_REPORT_DOWNLOADED,
courseId,
});
// This action for google analytics only. Doesn't change redux state.
const downloadInterventionReport = courseId => ({
type: INTERVENTION_REPORT_DOWNLOADED,
courseId,
});
const uploadOverrideFailure = (courseId, error) => ({
type: UPLOAD_OVERRIDE_ERROR,
courseId,
payload: { error },
});
const toggleGradeFormat = formatType => ({ type: TOGGLE_GRADE_FORMAT, formatType });
const filterColumns = (filterType, exampleUser) => (
const filterAssignmentType = filterType => (
dispatch => dispatch({
type: FILTER_COLUMNS,
headings: headingMapper(filterType)(dispatch, exampleUser),
type: FILTER_BY_ASSIGNMENT_TYPE,
filterType,
})
);
const updateBanner = showSuccess => ({ type: UPDATE_BANNER, showSuccess });
const openBanner = () => ({ type: OPEN_BANNER });
const closeBanner = () => ({ type: CLOSE_BANNER });
const fetchGrades = (courseId, cohort, track, showSuccess) => (
(dispatch) => {
const fetchGrades = (
courseId,
cohort,
track,
assignmentType,
options = {},
) => (
(dispatch, getState) => {
dispatch(startedFetchingGrades());
return LmsApiService.fetchGradebookData(courseId, null, cohort, track)
const {
assignment,
assignmentGradeMax: assignmentMax,
assignmentGradeMin: assignmentMin,
courseGradeMin,
courseGradeMax,
} = getFilters(getState());
const { id: assignmentId } = assignment || {};
const assignmentGradeMax = formatMaxAssignmentGrade(assignmentMax, { assignmentId });
const assignmentGradeMin = formatMinAssignmentGrade(assignmentMin, { assignmentId });
const courseGradeMinFormatted = formatMinCourseGrade(courseGradeMin);
const courseGradeMaxFormatted = formatMaxCourseGrade(courseGradeMax);
return LmsApiService.fetchGradebookData(
courseId,
options.searchText || null,
cohort,
track,
{
assignment: assignmentId,
assignmentGradeMax,
assignmentGradeMin,
courseGradeMin: courseGradeMinFormatted,
courseGradeMax: courseGradeMaxFormatted,
},
)
.then(response => response.data)
.then((data) => {
dispatch(gotGrades(
data.results.sort(sortAlphaAsc),
dispatch(gotGrades({
grades: data.results.sort(sortAlphaAsc),
cohort,
track,
headingMapper(defaultAssignmentFilter)(dispatch, data.results[0]),
data.previous,
data.next,
));
assignmentType,
prev: data.previous,
next: data.next,
courseId,
totalUsersCount: data.total_users_count,
filteredUsersCount: data.filtered_users_count,
}));
dispatch(finishedFetchingGrades());
dispatch(updateBanner(!!showSuccess));
if (options.showSuccess) {
dispatch(openBanner());
}
})
.catch(() => {
dispatch(errorFetchingGrades());
@@ -87,43 +180,70 @@ const fetchGrades = (courseId, cohort, track, showSuccess) => (
}
);
const fetchMatchingUserGrades = (courseId, searchText, cohort, track, showSuccess) => (
(dispatch) => {
dispatch(startedFetchingGrades());
return LmsApiService.fetchGradebookData(courseId, searchText, cohort, track)
const formatGradeOverrideForDisplay = historyArray => historyArray.map(item => ({
date: formatDateForDisplay(new Date(item.history_date)),
grader: item.history_user,
reason: item.override_reason,
adjustedGrade: item.earned_graded_override,
}));
const doneViewingAssignment = () => dispatch => dispatch({
type: DONE_VIEWING_ASSIGNMENT,
});
const fetchGradeOverrideHistory = (subsectionId, userId) => (
dispatch =>
LmsApiService.fetchGradeOverrideHistory(subsectionId, userId)
.then(response => response.data)
.then((data) => {
dispatch(gotGrades(
data.results.sort(sortAlphaAsc),
cohort,
track,
headingMapper(defaultAssignmentFilter)(dispatch, data.results[0]),
data.previous,
data.next,
));
dispatch(finishedFetchingGrades());
dispatch(updateBanner(showSuccess));
dispatch(gotGradeOverrideHistory({
overrideHistory: formatGradeOverrideForDisplay(data.history),
currentEarnedAllOverride: data.override ? data.override.earned_all_override : null,
currentPossibleAllOverride: data.override ? data.override.possible_all_override : null,
currentEarnedGradedOverride: data.override ? data.override.earned_graded_override : null,
currentPossibleGradedOverride: data.override ?
data.override.possible_graded_override : null,
originalGradeEarnedAll: data.original_grade ? data.original_grade.earned_all : null,
originalGradePossibleAll: data.original_grade ? data.original_grade.possible_all : null,
originalGradeEarnedGraded: data.original_grade ? data.original_grade.earned_graded : null,
originalGradePossibleGraded: data.original_grade ?
data.original_grade.possible_graded : null,
}));
})
.catch(() => {
dispatch(errorFetchingGrades());
});
}
dispatch(errorFetchingGradeOverrideHistory());
})
);
const fetchPrevNextGrades = (endpoint, cohort, track) => (
const fetchMatchingUserGrades = (
courseId,
searchText,
cohort,
track,
assignmentType,
showSuccess,
options = {},
) => {
const newOptions = { ...options, searchText, showSuccess };
return fetchGrades(courseId, cohort, track, assignmentType, newOptions);
};
const fetchPrevNextGrades = (endpoint, courseId, cohort, track, assignmentType) => (
(dispatch) => {
dispatch(startedFetchingGrades());
return apiClient.get(endpoint)
.then(response => response.data)
.then((data) => {
dispatch(gotGrades(
data.results.sort(sortAlphaAsc),
dispatch(gotGrades({
grades: data.results.sort(sortAlphaAsc),
cohort,
track,
headingMapper(defaultAssignmentFilter)(dispatch, data.results[0]),
data.previous,
data.next,
));
assignmentType,
prev: data.previous,
next: data.next,
courseId,
totalUsersCount: data.total_users_count,
filteredUsersCount: data.filtered_users_count,
}));
dispatch(finishedFetchingGrades());
})
.catch(() => {
@@ -132,22 +252,72 @@ const fetchPrevNextGrades = (endpoint, cohort, track) => (
}
);
const updateGrades = (courseId, updateData, searchText, cohort, track) => (
(dispatch) => {
dispatch(gradeUpdateRequest());
return LmsApiService.updateGradebookData(courseId, updateData)
.then(response => response.data)
.then((data) => {
dispatch(gradeUpdateSuccess(data));
dispatch(fetchMatchingUserGrades(courseId, searchText, cohort, track, true));
dispatch(gradeUpdateSuccess(courseId, data));
dispatch(fetchMatchingUserGrades(
courseId,
searchText,
cohort,
track,
defaultAssignmentFilter,
true,
{ searchText },
));
})
.catch((error) => {
dispatch(gradeUpdateFailure(error));
dispatch(gradeUpdateFailure(courseId, error));
});
}
);
const submitFileUploadFormData = (courseId, formData) => (
(dispatch) => {
dispatch(startedCsvUpload());
return LmsApiService.uploadGradeCsv(courseId, formData).then(() => {
dispatch(finishedCsvUpload());
dispatch(uploadOverrideSuccess(courseId));
}).catch((err) => {
dispatch(uploadOverrideFailure(courseId, err));
if (err.status === 200 && err.data.error_messages.length) {
const { error_messages: errorMessages, saved, total } = err.data;
return dispatch(csvUploadError({ errorMessages, saved, total }));
}
return dispatch(csvUploadError({ errorMessages: ['Unknown error.'] }));
});
}
);
const fetchBulkUpgradeHistory = courseId => (
dispatch =>
// todo add loading effect
LmsApiService.fetchGradeBulkOperationHistory(courseId).then((response) => {
dispatch(gotBulkHistory(response));
}).catch(() => dispatch(bulkHistoryError()))
);
const updateGradesIfAssignmentGradeFiltersSet = (
courseId,
cohort,
track,
assignmentType,
) => (dispatch, getState) => {
const { filters } = getState();
const hasAssignmentGradeFiltersSet = filters.assignmentGradeMax || filters.assignmentGradeMin;
if (hasAssignmentGradeFiltersSet) {
dispatch(fetchGrades(
courseId,
cohort,
track,
assignmentType,
));
}
};
export {
startedFetchingGrades,
finishedFetchingGrades,
@@ -161,7 +331,13 @@ export {
gradeUpdateFailure,
updateGrades,
toggleGradeFormat,
sortGrades,
filterColumns,
updateBanner,
filterAssignmentType,
closeBanner,
submitFileUploadFormData,
fetchBulkUpgradeHistory,
doneViewingAssignment,
fetchGradeOverrideHistory,
updateGradesIfAssignmentGradeFiltersSet,
downloadBulkGradesReport,
downloadInterventionReport,
};

View File

@@ -10,13 +10,14 @@ import {
FINISHED_FETCHING_GRADES,
ERROR_FETCHING_GRADES,
GOT_GRADES,
UPDATE_BANNER,
} from '../constants/actionTypes/grades';
import { sortAlphaAsc } from './utils';
const mockStore = configureMockStore([thunk]);
const axiosMock = new MockAdapter(apiClient);
apiClient.isAccessTokenExpired = jest.fn();
apiClient.isAccessTokenExpired.mockReturnValue(false);
describe('actions', () => {
afterEach(() => {
@@ -27,7 +28,8 @@ describe('actions', () => {
const courseId = 'course-v1:edX+DemoX+Demo_Course';
const expectedCohort = 1;
const expectedTrack = 'verified';
const fetchGradesURL = `${configuration.LMS_BASE_URL}/api/grades/v1/gradebook/${courseId}/?page_size=10&cohort_id=${expectedCohort}&enrollment_mode=${expectedTrack}`;
const expectedAssignmentType = 'Exam';
const fetchGradesURL = `${configuration.LMS_BASE_URL}/api/grades/v1/gradebook/${courseId}/?page_size=25&cohort_id=${expectedCohort}&enrollment_mode=${expectedTrack}`;
const responseData = {
next: `${fetchGradesURL}&cursor=2344fda`,
previous: null,
@@ -94,32 +96,25 @@ describe('actions', () => {
grades: responseData.results.sort(sortAlphaAsc),
cohort: expectedCohort,
track: expectedTrack,
headings: [
{
columnSortable: true,
key: 'username',
label: 'Username',
onSort: expect.anything(),
},
{
columnSortable: true,
key: 'total',
label: 'Total',
onSort: expect.anything(),
},
],
assignmentType: expectedAssignmentType,
prev: responseData.previous,
next: responseData.next,
courseId,
},
{ type: FINISHED_FETCHING_GRADES },
{ type: UPDATE_BANNER, showSuccess: false },
];
const store = mockStore();
axiosMock.onGet(fetchGradesURL)
.replyOnce(200, JSON.stringify(responseData));
return store.dispatch(fetchGrades(courseId, expectedCohort, expectedTrack, false)).then(() => {
return store.dispatch(fetchGrades(
courseId,
expectedCohort,
expectedTrack,
expectedAssignmentType,
false,
)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
@@ -134,7 +129,49 @@ describe('actions', () => {
axiosMock.onGet(fetchGradesURL)
.replyOnce(500, JSON.stringify({}));
return store.dispatch(fetchGrades(courseId, expectedCohort, expectedTrack, false)).then(() => {
return store.dispatch(fetchGrades(
courseId,
expectedCohort,
expectedTrack,
expectedAssignmentType,
false,
)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it('dispatches success action on empty response after fetching grades', () => {
const emptyResponseData = {
next: responseData.next,
previous: responseData.previous,
results: [],
};
const expectedActions = [
{ type: STARTED_FETCHING_GRADES },
{
type: GOT_GRADES,
grades: [],
cohort: expectedCohort,
track: expectedTrack,
assignmentType: expectedAssignmentType,
prev: responseData.previous,
next: responseData.next,
courseId,
},
{ type: FINISHED_FETCHING_GRADES },
];
const store = mockStore();
axiosMock.onGet(fetchGradesURL)
.replyOnce(200, JSON.stringify(emptyResponseData));
return store.dispatch(fetchGrades(
courseId,
expectedCohort,
expectedTrack,
expectedAssignmentType,
false,
)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});

View File

@@ -6,6 +6,7 @@ import { fetchGrades } from './grades';
import { fetchTracks } from './tracks';
import { fetchCohorts } from './cohorts';
import { fetchAssignmentTypes } from './assignmentTypes';
import { getFilters } from '../selectors/filters';
import LmsApiService from '../services/LmsApiService';
const allowedRoles = ['staff', 'instructor', 'support'];
@@ -17,16 +18,17 @@ const gotRoles = (canUserViewGradebook, courseId) => ({
});
const errorFetchingRoles = () => ({ type: ERROR_FETCHING_ROLES });
const getRoles = (courseId, urlQuery) => (
dispatch => LmsApiService.fetchUserRoles(courseId)
const getRoles = courseId => (
(dispatch, getState) => LmsApiService.fetchUserRoles(courseId)
.then(response => response.data)
.then((response) => {
const canUserViewGradebook = response.is_staff
|| (response.roles.some(role => (role.course_id === courseId)
&& allowedRoles.includes(role.role)));
dispatch(gotRoles(canUserViewGradebook, courseId));
const { cohort, track, assignmentType } = getFilters(getState());
if (canUserViewGradebook) {
dispatch(fetchGrades(courseId, urlQuery.cohort, urlQuery.track));
dispatch(fetchGrades(courseId, cohort, track, assignmentType));
dispatch(fetchTracks(courseId));
dispatch(fetchCohorts(courseId));
dispatch(fetchAssignmentTypes(courseId));

View File

@@ -17,6 +17,8 @@ import { STARTED_FETCHING_ASSIGNMENT_TYPES } from '../constants/actionTypes/assi
const mockStore = configureMockStore([thunk]);
const axiosMock = new MockAdapter(apiClient);
apiClient.isAccessTokenExpired = jest.fn();
apiClient.isAccessTokenExpired.mockReturnValue(false);
const course1Id = 'course-v1:edX+DemoX+Demo_Course';
const course2Id = 'course-v1:edX+DemoX+Demo_Course_2';
@@ -57,7 +59,10 @@ describe('actions', () => {
];
const store = mockStore();
axiosMock.onGet(rolesUrl)
.replyOnce(200, JSON.stringify(makeRoleListObj([course1StaffRole, course2DummyRole], false)));
.replyOnce(
200,
JSON.stringify(makeRoleListObj([course1StaffRole, course2DummyRole], false)),
);
return store.dispatch(getRoles(course1Id, urlParams)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
@@ -75,7 +80,10 @@ describe('actions', () => {
const store = mockStore();
axiosMock.onGet(rolesUrl)
.replyOnce(200, JSON.stringify(makeRoleListObj([course1DummyRole, course2DummyRole], true)));
.replyOnce(
200,
JSON.stringify(makeRoleListObj([course1DummyRole, course2DummyRole], true)),
);
return store.dispatch(getRoles(course1Id, urlParams)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
@@ -91,7 +99,10 @@ describe('actions', () => {
const store = mockStore();
axiosMock.onGet(rolesUrl)
.replyOnce(200, JSON.stringify(makeRoleListObj([course1DummyRole, course2StaffRole], false)));
.replyOnce(
200,
JSON.stringify(makeRoleListObj([course1DummyRole, course2StaffRole], false)),
);
return store.dispatch(getRoles(course1Id, urlParams)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
@@ -105,7 +116,10 @@ describe('actions', () => {
const store = mockStore();
axiosMock.onGet(rolesUrl)
.replyOnce(200, JSON.stringify(makeRoleListObj([], false)));
.replyOnce(
200,
JSON.stringify(makeRoleListObj([], false)),
);
return store.dispatch(getRoles(course1Id, urlParams)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
@@ -123,7 +137,10 @@ describe('actions', () => {
const store = mockStore();
axiosMock.onGet(rolesUrl)
.replyOnce(200, JSON.stringify(makeRoleListObj([], true)));
.replyOnce(
200,
JSON.stringify(makeRoleListObj([], true)),
);
return store.dispatch(getRoles(course1Id, urlParams)).then(() => {
expect(store.getActions()).toEqual(expectedActions);

View File

@@ -3,6 +3,8 @@ import {
GOT_TRACKS,
ERROR_FETCHING_TRACKS,
} from '../constants/actionTypes/tracks';
import { hasMastersTrack } from '../selectors/tracks';
import { fetchBulkUpgradeHistory } from './grades';
import LmsApiService from '../services/LmsApiService';
const startedFetchingTracks = () => ({ type: STARTED_FETCHING_TRACKS });
@@ -16,6 +18,9 @@ const fetchTracks = courseId => (
.then(response => response.data)
.then((data) => {
dispatch(gotTracks(data.course_modes));
if (hasMastersTrack(data.course_modes)) {
dispatch(fetchBulkUpgradeHistory(courseId));
}
})
.catch(() => {
dispatch(errorFetchingTracks());

View File

@@ -13,6 +13,8 @@ import {
const mockStore = configureMockStore([thunk]);
const axiosMock = new MockAdapter(apiClient);
apiClient.isAccessTokenExpired = jest.fn();
apiClient.isAccessTokenExpired.mockReturnValue(false);
describe('actions', () => {
afterEach(() => {
@@ -21,6 +23,7 @@ describe('actions', () => {
describe('fetchTracks', () => {
const courseId = 'course-v1:edX+DemoX+Demo_Course';
const trackUrl = `${configuration.LMS_BASE_URL}/api/enrollment/v1/course/${courseId}?include_expired=1`;
it('dispatches success action after fetching tracks', () => {
const responseData = {
@@ -54,7 +57,7 @@ describe('actions', () => {
];
const store = mockStore();
axiosMock.onGet(`${configuration.LMS_BASE_URL}/api/enrollment/v1/course/${courseId}`)
axiosMock.onGet(trackUrl)
.replyOnce(200, JSON.stringify(responseData));
return store.dispatch(fetchTracks(courseId)).then(() => {
@@ -69,7 +72,7 @@ describe('actions', () => {
];
const store = mockStore();
axiosMock.onGet(`${configuration.LMS_BASE_URL}/api/enrollment/v1/course/${courseId}`)
axiosMock.onGet(trackUrl)
.replyOnce(500, JSON.stringify({}));
return store.dispatch(fetchTracks(courseId)).then(() => {

View File

@@ -1,4 +1,18 @@
import { sortGrades } from './grades';
const formatDateForDisplay = (inputDate) => {
const options = {
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: 'UTC',
};
const timeOptions = {
hour: '2-digit',
minute: '2-digit',
timeZone: 'UTC',
timeZoneName: 'short',
};
return `${inputDate.toLocaleDateString('en-US', options)} at ${inputDate.toLocaleTimeString('en-US', timeOptions)}`;
};
const sortAlphaAsc = (gradeRowA, gradeRowB) => {
const a = gradeRowA.username.toUpperCase();
@@ -12,114 +26,5 @@ const sortAlphaAsc = (gradeRowA, gradeRowB) => {
return 0;
};
const sortAlphaDesc = (gradeRowA, gradeRowB) => {
const a = gradeRowA.username.toUpperCase();
const b = gradeRowB.username.toUpperCase();
if (a < b) {
return 1;
}
if (a > b) {
return -1;
}
return 0;
};
const sortNumerically = (colKey, direction) => {
function getPercents(gradeRowA, gradeRowB) {
if (colKey !== 'total') {
return {
a: gradeRowA.section_breakdown.find(x => x.label === colKey).percent,
b: gradeRowB.section_breakdown.find(x => x.label === colKey).percent,
};
}
return {
a: gradeRowA.percent,
b: gradeRowB.percent,
};
}
function sortNumAsc(gradeRowA, gradeRowB) {
const { a, b } = getPercents(gradeRowA, gradeRowB);
return a - b;
}
function sortNumDesc(gradeRowA, gradeRowB) {
const { a, b } = getPercents(gradeRowA, gradeRowB);
return b - a;
}
return direction === 'desc' ? sortNumDesc : sortNumAsc;
};
function gradeSortMap(columnName, direction) {
if (columnName === 'username' && direction === 'desc') {
return sortAlphaDesc;
} else if (columnName === 'username') {
return sortAlphaAsc;
}
return sortNumerically(columnName, direction);
}
const headingMapper = (filterKey) => {
function all(dispatch, entry) {
if (entry) {
const results = [{
label: 'Username',
key: 'username',
columnSortable: true,
onSort: (direction) => { dispatch(sortGrades('username', direction)); },
}];
const assignmentHeadings = entry.section_breakdown
.filter(section => section.is_graded && section.label)
.map(s => ({
label: s.label,
key: s.label,
columnSortable: true,
onSort: direction => dispatch(sortGrades(s.label, direction)),
}));
const totals = [{
label: 'Total',
key: 'total',
columnSortable: true,
onSort: direction => dispatch(sortGrades('total', direction)),
}];
return results.concat(assignmentHeadings).concat(totals);
}
return [];
}
function some(dispatch, entry) {
const results = [{
label: 'Username',
key: 'username',
columnSortable: true,
onSort: (direction) => { dispatch(sortGrades('username', direction)); },
}];
const assignmentHeadings = entry.section_breakdown
.filter(section => section.is_graded && section.label && section.category === filterKey)
.map(s => ({
label: s.label,
key: s.label,
columnSortable: false,
onSort: (direction) => { this.sortNumerically(s.label, direction); },
}));
const totals = [{
label: 'Total',
key: 'total',
columnSortable: true,
onSort: direction => dispatch(sortGrades('total', direction)),
}];
return results.concat(assignmentHeadings).concat(totals);
}
return filterKey === 'All' ? all : some;
};
export { headingMapper, gradeSortMap, sortAlphaAsc };
export { sortAlphaAsc, formatDateForDisplay };

View File

@@ -0,0 +1,3 @@
const GOT_BULK_MANAGEMENT_CONFIG = 'GOT_BULK_MANAGEMENT_CONFIG';
export default GOT_BULK_MANAGEMENT_CONFIG;

View File

@@ -0,0 +1,9 @@
const INITIALIZE_FILTERS = 'INITIALIZE_FILTERS';
const RESET_FILTERS = 'RESET_FILTERS';
const UPDATE_ASSIGNMENT_FILTER = 'UPDATE_ASSIGNMENT_FILTER';
const UPDATE_ASSIGNMENT_LIMITS = 'UPDATE_ASSIGNMENT_LIMITS';
const UPDATE_COURSE_GRADE_LIMITS = 'UPDATE_COURSE_GRADE_LIMITS';
export {
INITIALIZE_FILTERS, RESET_FILTERS, UPDATE_ASSIGNMENT_FILTER,
UPDATE_ASSIGNMENT_LIMITS, UPDATE_COURSE_GRADE_LIMITS,
};

View File

@@ -2,15 +2,32 @@ const STARTED_FETCHING_GRADES = 'STARTED_FETCHING_GRADES';
const FINISHED_FETCHING_GRADES = 'FINISHED_FETCHING_GRADES';
const ERROR_FETCHING_GRADES = 'ERROR_FETCHING_GRADES';
const GOT_GRADES = 'GOT_GRADES';
const DONE_VIEWING_ASSIGNMENT = 'DONE_VIEWING_ASSIGNMENT';
const GOT_GRADE_OVERRIDE_HISTORY = 'GOT_GRADE_OVERRIDE_HISTORY';
const ERROR_FETCHING_GRADE_OVERRIDE_HISTORY = 'ERROR_FETCHING_GRADE_OVERRIDE_HISTORY';
const FILTER_SELECTED = 'FILTER_SELECTED';
const GRADE_OVERRIDE = 'GRADE_OVERRIDE';
const REPORT_DOWNLOADED = 'REPORT_DOWNLOADED';
const UPLOAD_OVERRIDE = 'UPLOAD_OVERRIDE';
const UPLOAD_OVERRIDE_ERROR = 'UPLOAD_OVERRIDE_ERROR';
const GRADE_UPDATE_REQUEST = 'GRADE_UPDATE_REQUEST';
const GRADE_UPDATE_SUCCESS = 'GRADE_UPDATE_SUCCESS';
const GRADE_UPDATE_FAILURE = 'GRADE_UPDATE_FAILURE';
const TOGGLE_GRADE_FORMAT = 'TOGGLE_GRADE_FORMAT';
const SORT_GRADES = 'SORT_GRADES';
const FILTER_COLUMNS = 'FILTER_COLUMNS';
const UPDATE_BANNER = 'UPDATE_BANNER';
const FILTER_BY_ASSIGNMENT_TYPE = 'FILTER_BY_ASSIGNMENT_TYPE';
const CLOSE_BANNER = 'CLOSE_BANNER';
const OPEN_BANNER = 'OPEN_BANNER';
const START_UPLOAD = 'START_UPLOAD';
const UPLOAD_COMPLETE = 'UPLOAD_COMPLETE';
const UPLOAD_ERR = 'UPLOAD_ERR';
const GOT_BULK_HISTORY = 'GOT_BULK_HISTORY';
const BULK_HISTORY_ERR = 'BULK_HISTORY_ERR';
const BULK_GRADE_REPORT_DOWNLOADED = 'BULK_GRADE_REPORT_DOWNLOADED';
const INTERVENTION_REPORT_DOWNLOADED = 'INTERVENTION_REPORT_DOWNLOADED';
export {
STARTED_FETCHING_GRADES,
@@ -21,7 +38,22 @@ export {
GRADE_UPDATE_SUCCESS,
GRADE_UPDATE_FAILURE,
TOGGLE_GRADE_FORMAT,
SORT_GRADES,
FILTER_COLUMNS,
UPDATE_BANNER,
FILTER_BY_ASSIGNMENT_TYPE,
OPEN_BANNER,
CLOSE_BANNER,
START_UPLOAD,
UPLOAD_COMPLETE,
UPLOAD_ERR,
GOT_BULK_HISTORY,
BULK_HISTORY_ERR,
DONE_VIEWING_ASSIGNMENT,
GOT_GRADE_OVERRIDE_HISTORY,
ERROR_FETCHING_GRADE_OVERRIDE_HISTORY,
FILTER_SELECTED,
GRADE_OVERRIDE,
REPORT_DOWNLOADED,
UPLOAD_OVERRIDE,
UPLOAD_OVERRIDE_ERROR,
BULK_GRADE_REPORT_DOWNLOADED,
INTERVENTION_REPORT_DOWNLOADED,
};

View File

@@ -1,5 +1,5 @@
const GOT_ROLES = 'GOT_ROLES';
const ERROR_FETCHING_ROLES = 'ERROR_FETCHING_ROLES'
const ERROR_FETCHING_ROLES = 'ERROR_FETCHING_ROLES';
export {
GOT_ROLES,

View File

@@ -0,0 +1,12 @@
const initialFilters = {
assignment: '',
assignmentType: '',
track: '',
cohort: '',
assignmentGradeMin: '0',
assignmentGradeMax: '100',
courseGradeMin: '0',
courseGradeMax: '100',
};
export default initialFilters;

View File

@@ -0,0 +1,15 @@
import GOT_BULK_MANAGEMENT_CONFIG from '../constants/actionTypes/config';
const reducer = (state = {}, action) => {
switch (action.type) {
case GOT_BULK_MANAGEMENT_CONFIG:
return {
...state,
bulkManagementAvailable: action.data,
};
default:
return state;
}
};
export default reducer;

View File

@@ -0,0 +1,76 @@
import { GOT_GRADES, FILTER_BY_ASSIGNMENT_TYPE } from '../constants/actionTypes/grades';
import { INITIALIZE_FILTERS, UPDATE_ASSIGNMENT_FILTER, UPDATE_ASSIGNMENT_LIMITS, UPDATE_COURSE_GRADE_LIMITS, RESET_FILTERS } from '../constants/actionTypes/filters';
import initialFilters from '../constants/filters';
import { getAssignmentsFromResultsSubstate, chooseRelevantAssignmentData } from '../selectors/filters';
const initialState = {};
const reducer = (state = initialState, action) => {
switch (action.type) {
case FILTER_BY_ASSIGNMENT_TYPE:
return {
...state,
assignmentType: action.filterType,
assignment: (
action.filterType !== '' &&
(state.assignment || {}).type !== action.filterType)
? '' : state.assignment,
};
case INITIALIZE_FILTERS:
return {
...state,
...action.data,
};
case GOT_GRADES: {
const { assignment } = state;
const { id, type } = assignment || {};
if (!type) {
const relevantAssignment = getAssignmentsFromResultsSubstate(action.grades)
.map(chooseRelevantAssignmentData)
.find(assig => assig.id === id);
return {
...state,
track: action.track,
cohort: action.cohort,
assignment: relevantAssignment,
};
}
return {
...state,
track: action.track,
cohort: action.cohort,
};
}
case RESET_FILTERS: {
const result = { ...state };
action.filterNames.forEach((filterName) => {
result[filterName] = initialFilters[filterName];
});
return result;
}
case UPDATE_ASSIGNMENT_FILTER:
return {
...state,
assignment: action.data,
};
case UPDATE_ASSIGNMENT_LIMITS:
return {
...state,
assignmentGradeMin: action.data.minGrade,
assignmentGradeMax: action.data.maxGrade,
};
case UPDATE_COURSE_GRADE_LIMITS:
return {
...state,
courseGradeMin: action.data.courseGradeMin,
courseGradeMax: action.data.courseGradeMax,
};
default:
return state;
}
};
export default reducer;

View File

@@ -3,13 +3,29 @@ import {
ERROR_FETCHING_GRADES,
GOT_GRADES,
TOGGLE_GRADE_FORMAT,
FILTER_COLUMNS,
UPDATE_BANNER,
SORT_GRADES,
FILTER_BY_ASSIGNMENT_TYPE,
OPEN_BANNER,
CLOSE_BANNER,
START_UPLOAD,
UPLOAD_COMPLETE,
UPLOAD_ERR,
GOT_BULK_HISTORY,
DONE_VIEWING_ASSIGNMENT,
GOT_GRADE_OVERRIDE_HISTORY,
ERROR_FETCHING_GRADE_OVERRIDE_HISTORY,
} from '../constants/actionTypes/grades';
const initialState = {
results: [],
gradeOverrideHistoryResults: [],
gradeOverrideCurrentEarnedAllOverride: null,
gradeOverrideCurrentPossibleAllOverride: null,
gradeOverrideCurrentEarnedGradedOverride: null,
gradeOverrideCurrentPossibleGradedOverride: null,
gradeOriginalEarnedAll: null,
gradeOriginalPossibleAll: null,
gradeOriginalEarnedGraded: null,
gradeOriginalPossibleGraded: null,
headings: [],
startedFetching: false,
finishedFetching: false,
@@ -19,6 +35,9 @@ const initialState = {
prevPage: null,
nextPage: null,
showSpinner: true,
bulkManagement: {},
totalUsersCount: 0,
filteredUsersCount: 0,
};
const grades = (state = initialState, action) => {
@@ -30,12 +49,50 @@ const grades = (state = initialState, action) => {
headings: action.headings,
finishedFetching: true,
errorFetching: false,
selectedTrack: action.track,
selectedCohort: action.cohort,
prevPage: action.prev,
nextPage: action.next,
showSpinner: false,
courseId: action.courseId,
totalUsersCount: action.totalUsersCount,
filteredUsersCount: action.filteredUsersCount,
};
case DONE_VIEWING_ASSIGNMENT: {
const {
gradeOverrideHistoryResults,
gradeOverrideCurrentEarnedAllOverride,
gradeOverrideCurrentPossibleAllOverride,
gradeOverrideCurrentEarnedGradedOverride,
gradeOverrideCurrentPossibleGradedOverride,
gradeOriginalEarnedAll,
gradeOriginalPossibleAll,
gradeOriginalEarnedGraded,
gradeOriginalPossibleGraded,
...rest
} = state;
return rest;
}
case GOT_GRADE_OVERRIDE_HISTORY:
return {
...state,
gradeOverrideHistoryResults: action.overrideHistory,
gradeOverrideCurrentEarnedAllOverride: action.currentEarnedAllOverride,
gradeOverrideCurrentPossibleAllOverride: action.currentPossibleAllOverride,
gradeOverrideCurrentEarnedGradedOverride: action.currentEarnedGradedOverride,
gradeOverrideCurrentPossibleGradedOverride: action.currentPossibleGradedOverride,
gradeOriginalEarnedAll: action.originalGradeEarnedAll,
gradeOriginalPossibleAll: action.originalGradePossibleAll,
gradeOriginalEarnedGraded: action.originalGradeEarnedGraded,
gradeOriginalPossibleGraded: action.originalGradePossibleGraded,
errorFetchingOverrideHistory: false,
};
case ERROR_FETCHING_GRADE_OVERRIDE_HISTORY:
return {
...state,
finishedFetchingOverrideHistory: true,
errorFetchingOverrideHistory: true,
};
case STARTED_FETCHING_GRADES:
return {
...state,
@@ -54,24 +111,57 @@ const grades = (state = initialState, action) => {
...state,
gradeFormat: action.formatType,
};
case FILTER_COLUMNS:
case FILTER_BY_ASSIGNMENT_TYPE:
return {
...state,
selectedAssignmentType: action.filterType,
headings: action.headings,
};
case UPDATE_BANNER:
case OPEN_BANNER:
return {
...state,
showSuccess: action.showSuccess,
showSuccess: true,
};
case SORT_GRADES:
case CLOSE_BANNER:
return {
...state,
results: action.results,
showSuccess: false,
};
case START_UPLOAD: {
const { errorMessages, uploadSuccess, ...rest } = state.bulkManagement;
return {
...state,
showSpinner: true,
bulkManagement: rest,
};
}
case UPLOAD_COMPLETE:
return {
...state,
showSpinner: false,
bulkManagement: { uploadSuccess: true, ...state.bulkManagement },
};
case UPLOAD_ERR:
return {
...state,
showSpinner: false,
bulkManagement: {
...state.bulkManagement,
...action.data,
},
};
case GOT_BULK_HISTORY:
return {
...state,
bulkManagement: {
...state.bulkManagement,
history: action.data,
},
};
default:
return state;
}
};
export { initialState as initialGradesState };
export default grades;

View File

@@ -1,27 +1,13 @@
import grades from './grades';
import grades, { initialGradesState as initialState } from './grades';
import {
STARTED_FETCHING_GRADES,
ERROR_FETCHING_GRADES,
GOT_GRADES,
TOGGLE_GRADE_FORMAT,
FILTER_COLUMNS,
UPDATE_BANNER,
SORT_GRADES,
FILTER_BY_ASSIGNMENT_TYPE,
OPEN_BANNER,
} from '../constants/actionTypes/grades';
const initialState = {
results: [],
headings: [],
startedFetching: false,
finishedFetching: false,
errorFetching: false,
gradeFormat: 'percent',
showSuccess: false,
prevPage: null,
nextPage: null,
showSpinner: true,
};
const courseId = 'course-v1:edX+DemoX+Demo_Course';
const headingsData = [
{ name: 'exam' },
@@ -108,11 +94,12 @@ describe('grades reducer', () => {
headings: headingsData,
errorFetching: false,
finishedFetching: true,
selectedTrack: expectedTrack,
selectedCohort: expectedCohortId,
prevPage: expectedPrev,
nextPage: expectedNext,
showSpinner: false,
courseId,
totalUsersCount: 4,
filteredUsersCount: 2,
};
expect(grades(undefined, {
type: GOT_GRADES,
@@ -123,6 +110,9 @@ describe('grades reducer', () => {
track: expectedTrack,
cohort: expectedCohortId,
showSpinner: true,
courseId,
totalUsersCount: 4,
filteredUsersCount: 2,
})).toEqual(expected);
});
@@ -145,7 +135,7 @@ describe('grades reducer', () => {
headings: expectedHeadings,
};
expect(grades(undefined, {
type: FILTER_COLUMNS,
type: FILTER_BY_ASSIGNMENT_TYPE,
headings: expectedHeadings,
})).toEqual(expected);
});
@@ -157,19 +147,7 @@ describe('grades reducer', () => {
showSuccess: expectedShowSuccess,
};
expect(grades(undefined, {
type: UPDATE_BANNER,
showSuccess: expectedShowSuccess,
})).toEqual(expected);
});
it('updates sort grades state success', () => {
const expected = {
...initialState,
results: gradesData,
};
expect(grades(undefined, {
type: SORT_GRADES,
results: gradesData,
type: OPEN_BANNER,
})).toEqual(expected);
});

View File

@@ -5,6 +5,8 @@ import grades from './grades';
import tracks from './tracks';
import assignmentTypes from './assignmentTypes';
import roles from './roles';
import filters from './filters';
import config from './config';
const rootReducer = combineReducers({
grades,
@@ -12,6 +14,8 @@ const rootReducer = combineReducers({
tracks,
assignmentTypes,
roles,
filters,
config,
});
export default rootReducer;

View File

@@ -1,26 +1,27 @@
import {
GOT_ROLES,
ERROR_FETCHING_ROLES,
} from '../constants/actionTypes/roles';
GOT_ROLES,
ERROR_FETCHING_ROLES,
} from '../constants/actionTypes/roles';
const initialState = {
canUserViewGradebook: null,
};
const initialState = {
canUserViewGradebook: null,
};
const roles = (state = initialState, action) => {
switch (action.type) {
case GOT_ROLES:
return {
...state,
canUserViewGradebook: action.canUserViewGradebook,
};
case ERROR_FETCHING_ROLES:
return {
...state,
canUserViewGradebook: false,
};
default:
return state;
}};
const roles = (state = initialState, action) => {
switch (action.type) {
case GOT_ROLES:
return {
...state,
canUserViewGradebook: action.canUserViewGradebook,
};
case ERROR_FETCHING_ROLES:
return {
...state,
canUserViewGradebook: false,
};
default:
return state;
}
};
export default roles;
export default roles;

View File

@@ -16,7 +16,7 @@ describe('tracks reducer', () => {
it('updates canUserViewGradebook to true', () => {
const expected = {
...initialState,
canUserViewGradebook: true
canUserViewGradebook: true,
};
expect(roles(undefined, {
type: GOT_ROLES,
@@ -27,7 +27,7 @@ describe('tracks reducer', () => {
it('updates canUserViewGradebook to false', () => {
const expected = {
...initialState,
canUserViewGradebook: false
canUserViewGradebook: false,
};
expect(roles(undefined, {
type: GOT_ROLES,

View File

@@ -0,0 +1,12 @@
const getCohorts = state => state.cohorts.results || [];
const getCohortById = (state, selectedCohortId) => {
const cohort = getCohorts(state).find(coh => coh.id === selectedCohortId);
return cohort;
};
const getCohortNameById = (state, selectedCohortId) =>
(getCohortById(state, selectedCohortId) || {}).name;
export { getCohortById, getCohortNameById, getCohorts };

View File

@@ -0,0 +1,41 @@
const getFilters = state => state.filters || {};
const getAssignmentsFromResultsSubstate = results =>
(results[0] || {}).section_breakdown || [];
const selectableAssignments = (state) => {
const selectedAssignmentType = getFilters(state).assignmentType;
const needToFilter = selectedAssignmentType && selectedAssignmentType !== 'All';
const allAssignments = getAssignmentsFromResultsSubstate(state.grades.results);
if (needToFilter) {
return allAssignments.filter(assignment => assignment.category === selectedAssignmentType);
}
return allAssignments;
};
const chooseRelevantAssignmentData = assignment => ({
label: assignment.label,
subsectionLabel: assignment.subsection_name,
type: assignment.category,
id: assignment.module_id,
});
const selectableAssignmentLabels = state =>
selectableAssignments(state).map(chooseRelevantAssignmentData);
const typeOfSelectedAssignment = (state) => {
const selectedAssignmentLabel = getFilters(state).assignment;
const sectionBreakdown = (state.grades.results[0] || {}).section_breakdown || [];
const selectedAssignment = sectionBreakdown.find(section =>
section.label === selectedAssignmentLabel);
return selectedAssignment && selectedAssignment.category;
};
export {
selectableAssignmentLabels,
selectableAssignments,
getFilters,
typeOfSelectedAssignment,
chooseRelevantAssignmentData,
getAssignmentsFromResultsSubstate,
};

View File

@@ -0,0 +1,123 @@
import { formatDateForDisplay } from '../actions/utils';
import { getFilters } from './filters';
const getRowsProcessed = (data) => {
const {
processed_rows: processed,
saved_rows: saved,
total_rows: total,
} = data;
return {
total,
successfullyProcessed: saved,
failed: processed - saved,
skipped: total - processed,
};
};
const transformHistoryEntry = (historyRow) => {
const {
modified,
original_filename: originalFilename,
data,
...rest
} = historyRow;
const timeUploaded = formatDateForDisplay(new Date(modified));
const summaryOfRowsProcessed = getRowsProcessed(data);
return {
timeUploaded,
originalFilename,
summaryOfRowsProcessed,
...rest,
};
};
const getBulkManagementHistoryFromState = state =>
state.grades.bulkManagement.history || [];
const getBulkManagementHistory = state =>
getBulkManagementHistoryFromState(state).map(transformHistoryEntry);
const headingMapper = (category, label = 'All') => {
const filters = {
all: section => section.label,
byCategory: section => section.label && section.category === category,
byLabel: section => section.label && section.label === label,
};
let filter;
if (label === 'All') {
filter = category === 'All' ? 'all' : 'byCategory';
} else {
filter = 'byLabel';
}
return (entry) => {
if (entry) {
const results = ['Username', 'Email'];
const assignmentHeadings = entry
.filter(filters[filter])
.map(s => s.label);
const totals = ['Total'];
return results.concat(assignmentHeadings).concat(totals);
}
return [];
};
};
const getExampleSectionBreakdown = state => (state.grades.results[0] || {}).section_breakdown || [];
const getHeadings = (state) => {
const filters = getFilters(state) || {};
const {
assignmentType: selectedAssignmentType,
assignment: selectedAssignment,
} = filters;
const assignments = getExampleSectionBreakdown(state);
const type = selectedAssignmentType || 'All';
const assignment = (selectedAssignment || {}).label || 'All';
return headingMapper(type, assignment)(assignments);
};
const composeFilters = (...predicates) => (percentGrade, options = {}) =>
predicates.reduce((accum, predicate) => {
if (predicate(percentGrade, options)) {
return null;
}
return accum;
}, percentGrade);
const percentGradeIsMax = percentGrade => (
percentGrade === '100'
);
const percentGradeIsMin = percentGrade => (
percentGrade === '0'
);
const assignmentIdIsDefined = (percentGrade, { assignmentId }) => (
!assignmentId
);
const formatMaxCourseGrade = composeFilters(percentGradeIsMax);
const formatMinCourseGrade = composeFilters(percentGradeIsMin);
const formatMaxAssignmentGrade = composeFilters(
percentGradeIsMax,
assignmentIdIsDefined,
);
const formatMinAssignmentGrade = composeFilters(
percentGradeIsMin,
assignmentIdIsDefined,
);
export {
getBulkManagementHistory,
getHeadings,
formatMinAssignmentGrade,
formatMaxAssignmentGrade,
formatMaxCourseGrade,
formatMinCourseGrade,
};

View File

@@ -0,0 +1,72 @@
import { getBulkManagementHistory } from './grades';
const genericHistoryRow = {
id: 5,
class_name: 'bulk_grades.api.GradeCSVProcessor',
unique_id: 'course-v1:google+goog101+2018_spring',
operation: 'commit',
user: 'edx',
modified: '2019-07-16T20:25:46.700802Z',
original_filename: '',
data: {
total_rows: 5,
processed_rows: 3,
saved_rows: 3,
},
};
describe('getBulkManagementHistory', () => {
it('handles history being as-yet unloaded', () => {
const result = getBulkManagementHistory({ grades: { bulkManagement: {} } });
expect(result).toEqual([]);
});
it('formats dates for us', () => {
const result = getBulkManagementHistory({
grades: {
bulkManagement: {
history: [
genericHistoryRow,
],
},
},
});
const [{ timeUploaded }] = result;
expect(timeUploaded).not.toMatch(/Z$/);
expect(timeUploaded).toContain(' at ');
});
const exerciseGetRowsProcessed = (input, expectation) => {
const result = getBulkManagementHistory({
grades: {
bulkManagement: {
history: [
{ ...genericHistoryRow, data: input },
],
},
},
});
const [{ summaryOfRowsProcessed }] = result;
expect(summaryOfRowsProcessed).toEqual(expect.objectContaining(expectation));
};
it('calculates skippage', () => {
exerciseGetRowsProcessed({
total_rows: 100,
processed_rows: 10,
saved_rows: 10,
}, {
skipped: 90,
});
});
it('calculates failures', () => {
exerciseGetRowsProcessed({
total_rows: 10,
processed_rows: 100,
saved_rows: 10,
}, {
failed: 90,
});
});
});

View File

@@ -0,0 +1,13 @@
const compose = (...fns) => {
const [firstFunc, ...rest] = fns.reverse();
return (...args) =>
rest.reduce((accum, fn) => fn(accum), firstFunc(...args));
};
const getTracks = state => state.tracks.results || [];
const trackIsMasters = track => track.slug === 'masters';
const hasMastersTrack = tracks => tracks.some(trackIsMasters);
const stateHasMastersTrack = compose(hasMastersTrack, getTracks);
export { hasMastersTrack, trackIsMasters };
export default stateHasMastersTrack;

View File

@@ -3,21 +3,45 @@ import { configuration } from '../../config';
class LmsApiService {
static baseUrl = configuration.LMS_BASE_URL;
static pageSize = 10
static pageSize = 25
static fetchGradebookData(courseId, searchText, cohort, track) {
let gradebookUrl = `${LmsApiService.baseUrl}/api/grades/v1/gradebook/${courseId}/`;
gradebookUrl += `?page_size=${LmsApiService.pageSize}&`;
static fetchGradebookData(courseId, searchText, cohort, track, options = {}) {
const queryParams = {};
queryParams.page_size = LmsApiService.pageSize;
if (searchText) {
gradebookUrl += `username_contains=${searchText}&`;
queryParams.user_contains = searchText;
}
if (cohort) {
gradebookUrl += `cohort_id=${cohort}&`;
queryParams.cohort_id = cohort;
}
if (track) {
gradebookUrl += `enrollment_mode=${track}`;
queryParams.enrollment_mode = track;
}
if (options.assignmentGradeMax || options.assignmentGradeMin) {
if (!options.assignment) {
throw new Error('Gradebook LMS API requires assignment to be set to filter by min/max assig. grade');
}
queryParams.assignment = options.assignment;
if (options.assignmentGradeMin) {
queryParams.assignment_grade_min = options.assignmentGradeMin;
}
if (options.assignmentGradeMax) {
queryParams.assignment_grade_max = options.assignmentGradeMax;
}
}
if (options.courseGradeMin) {
queryParams.course_grade_min = options.courseGradeMin;
}
if (options.courseGradeMax) {
queryParams.course_grade_max = options.courseGradeMax;
}
const queryParamString = Object.keys(queryParams)
.map(attr => `${attr}=${encodeURIComponent(queryParams[attr])}`)
.join('&');
const gradebookUrl = `${LmsApiService.baseUrl}/api/grades/v1/gradebook/${courseId}/?${queryParamString}`;
return apiClient.get(gradebookUrl);
}
@@ -25,7 +49,10 @@ class LmsApiService {
/*
updateData is expected to be a list of objects with the keys 'user_id' (an integer),
'usage_id' (a string) and 'grade', which is an object with the keys:
'earned_all_override', 'possible_all_override', 'earned_graded_override', and 'possible_graded_override',
'earned_all_override',
'possible_all_override',
'earned_graded_override',
and 'possible_graded_override',
each of which should be an integer.
Example:
[
@@ -36,7 +63,8 @@ class LmsApiService {
"earned_all_override": 11,
"possible_all_override": 11,
"earned_graded_override": 11,
"possible_graded_override": 11
"possible_graded_override": 11,
"comment": "reason for override"
}
}
]
@@ -46,7 +74,7 @@ class LmsApiService {
}
static fetchTracks(courseId) {
const trackUrl = `${LmsApiService.baseUrl}/api/enrollment/v1/course/${courseId}`;
const trackUrl = `${LmsApiService.baseUrl}/api/enrollment/v1/course/${courseId}?include_expired=1`;
return apiClient.get(trackUrl);
}
@@ -64,6 +92,48 @@ class LmsApiService {
const rolesUrl = `${LmsApiService.baseUrl}/api/enrollment/v1/roles/?course_id=${encodeURIComponent(courseId)}`;
return apiClient.get(rolesUrl);
}
static getGradeExportCsvUrl(courseId, options = {}) {
const queryParams = ['track', 'cohort', 'assignment', 'assignmentType', 'assignmentGradeMax',
'assignmentGradeMin', 'courseGradeMin', 'courseGradeMax']
.filter(opt => options[opt] &&
options[opt] !== 'All')
.map(opt => `${opt}=${encodeURIComponent(options[opt])}`)
.join('&');
return `${LmsApiService.baseUrl}/api/bulk_grades/course/${courseId}/?${queryParams}`;
}
static getInterventionExportCsvUrl(courseId, options = {}) {
const queryParams = ['track', 'cohort', 'assignment', 'assignmentType', 'assignmentGradeMax',
'assignmentGradeMin', 'courseGradeMin', 'courseGradeMax']
.filter(opt => options[opt] &&
options[opt] !== 'All')
.map(opt => `${opt}=${encodeURIComponent(options[opt])}`)
.join('&');
return `${LmsApiService.baseUrl}/api/bulk_grades/course/${courseId}/intervention?${queryParams}`;
}
static getGradeImportCsvUrl = LmsApiService.getGradeExportCsvUrl;
static uploadGradeCsv(courseId, formData) {
const fileUploadUrl = LmsApiService.getGradeImportCsvUrl(courseId);
return apiClient.post(fileUploadUrl, formData).then((result) => {
if (result.status === 200 && !result.data.error_messages.length) {
return result.data;
}
return Promise.reject(result);
});
}
static fetchGradeBulkOperationHistory(courseId) {
const url = `${LmsApiService.baseUrl}/api/bulk_grades/course/${courseId}/history/`;
return apiClient.get(url).then(response => response.data).catch(() => Promise.reject(Error('unhandled response error')));
}
static fetchGradeOverrideHistory(subsectionId, userId) {
const historyUrl = `${LmsApiService.baseUrl}/api/grades/v1/subsection/${subsectionId}/?user_id=${userId}&history_record_limit=5`;
return apiClient.get(historyUrl);
}
}
export default LmsApiService;

View File

@@ -5,37 +5,87 @@ import { createLogger } from 'redux-logger';
import { createMiddleware } from 'redux-beacon';
import Segment, { trackEvent, trackPageView } from '@redux-beacon/segment';
import { GOT_ROLES } from './constants/actionTypes/roles';
import { GOT_GRADES, GRADE_UPDATE_SUCCESS, GRADE_UPDATE_FAILURE } from './constants/actionTypes/grades';
import {
GOT_GRADES, GRADE_UPDATE_SUCCESS, GRADE_UPDATE_FAILURE, UPLOAD_OVERRIDE,
UPLOAD_OVERRIDE_ERROR, BULK_GRADE_REPORT_DOWNLOADED, INTERVENTION_REPORT_DOWNLOADED,
} from './constants/actionTypes/grades';
import { UPDATE_COURSE_GRADE_LIMITS } from './constants/actionTypes/filters';
import reducers from './reducers';
const loggerMiddleware = createLogger();
const trackingCategory = 'gradebook';
const eventsMap = {
[GOT_ROLES]: trackPageView(action => ({
category: trackingCategory,
page: action.courseId,
})),
[GOT_GRADES]: trackEvent(action => ({
name: 'Grades displayed or paginated',
name: 'edx.gradebook.grades.displayed',
properties: {
category: trackingCategory,
label: action.courseId,
track: action.track,
cohort: action.cohort,
assignmentType: action.assignmentType,
prev: action.prev,
next: action.next,
},
})),
[GRADE_UPDATE_SUCCESS]: trackEvent(action => ({
name: 'Grades Updated',
name: 'edx.gradebook.grades.grade_override.succeeded',
properties: {
category: trackingCategory,
label: action.courseId,
updatedGrades: action.payload.responseData,
},
})),
[GRADE_UPDATE_FAILURE]: trackEvent(action => ({
name: 'Grades Fail to Update',
name: 'edx.gradebook.grades.grade_override.failed',
properties: {
category: trackingCategory,
label: action.courseId,
error: action.payload.error,
},
})),
[UPLOAD_OVERRIDE]: trackEvent(action => ({
name: 'edx.gradebook.grades.upload.grades_overrides.succeeded',
properties: {
category: trackingCategory,
label: action.courseId,
},
})),
[UPLOAD_OVERRIDE_ERROR]: trackEvent(action => ({
name: 'edx.gradebook.grades.upload.grades_overrides.failed',
properties: {
category: trackingCategory,
label: action.courseId,
error: action.payload.error,
},
})),
[UPDATE_COURSE_GRADE_LIMITS]: trackEvent(action => ({
name: 'edx.gradebook.grades.filter_applied',
label: action.courseId,
properties: {
category: trackingCategory,
label: action.courseId,
},
})),
[BULK_GRADE_REPORT_DOWNLOADED]: trackEvent(action => ({
name: 'edx.gradebook.reports.grade_export.downloaded',
properties: {
category: trackingCategory,
label: action.courseId,
},
})),
[INTERVENTION_REPORT_DOWNLOADED]: trackEvent(action => ({
name: 'edx.gradebook.reports.intervention.downloaded',
properties: {
category: trackingCategory,
label: action.courseId,
},
})),
};
const segmentMiddleware = createMiddleware(eventsMap, Segment());
@@ -46,4 +96,5 @@ const store = createStore(
composeWithDevTools(applyMiddleware(thunkMiddleware, loggerMiddleware, segmentMiddleware)),
);
export { trackingCategory };
export default store;

View File

@@ -4,6 +4,15 @@ import ReactDOM from 'react-dom';
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
import { Provider } from 'react-redux';
import SiteFooter from '@edx/frontend-component-footer';
import { IntlProvider } from 'react-intl';
import {
faFacebookSquare,
faTwitterSquare,
faLinkedin,
faRedditSquare,
} from '@fortawesome/free-brands-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import apiClient from './data/apiClient';
import GradebookPage from './containers/GradebookPage';
@@ -12,41 +21,74 @@ import store from './data/store';
import FooterLogo from '../assets/edx-footer.png';
import './App.scss';
var courseId = window.location.pathname.substring(1);
const socialLinks = [
{
title: 'Facebook',
url: process.env.FACEBOOK_URL,
icon: <FontAwesomeIcon icon={faFacebookSquare} className="social-icon" size="2x" />,
screenReaderText: 'Like edX on Facebook',
},
{
title: 'Twitter',
url: process.env.TWITTER_URL,
icon: <FontAwesomeIcon icon={faTwitterSquare} className="social-icon" size="2x" />,
screenReaderText: 'Follow edX on Twitter',
},
{
title: 'LinkedIn',
url: process.env.LINKED_IN_URL,
icon: <FontAwesomeIcon icon={faLinkedin} className="social-icon" size="2x" />,
screenReaderText: 'Follow edX on LinkedIn',
},
{
title: 'Reddit',
url: process.env.REDDIT_URL,
icon: <FontAwesomeIcon icon={faRedditSquare} className="social-icon" size="2x" />,
screenReaderText: 'Subscribe to the edX subreddit',
},
];
const App = () => (
<Provider store={store}>
<Router>
<div>
<Header />
<main>
<Switch>
<Route exact path="/:courseId" component={GradebookPage} />
</Switch>
</main>
<SiteFooter
siteName={process.env.SITE_NAME}
siteLogo={FooterLogo}
marketingSiteBaseUrl={process.env.MARKETING_SITE_BASE_URL}
supportUrl={process.env.SUPPORT_URL}
contactUrl={process.env.CONTACT_URL}
openSourceUrl={process.env.OPEN_SOURCE_URL}
termsOfServiceUrl={process.env.TERMS_OF_SERVICE_URL}
privacyPolicyUrl={process.env.PRIVACY_POLICY_URL}
facebookUrl={process.env.FACEBOOK_URL}
twitterUrl={process.env.TWITTER_URL}
youTubeUrl={process.env.YOU_TUBE_URL}
linkedInUrl={process.env.LINKED_IN_URL}
googlePlusUrl={process.env.GOOGLE_PLUS_URL}
redditUrl={process.env.REDDIT_URL}
appleAppStoreUrl={process.env.APPLE_APP_STORE_URL}
googlePlayUrl={process.env.GOOGLE_PLAY_URL}
/>
</div>
</Router>
</Provider>
<IntlProvider>
<Provider store={store}>
<Router>
<div>
<Header />
<main>
<Switch>
<Route exact path="/gradebook/:courseId" component={GradebookPage} />
</Switch>
</main>
<SiteFooter
siteName={process.env.SITE_NAME}
siteLogo={FooterLogo}
marketingSiteBaseUrl={process.env.MARKETING_SITE_BASE_URL}
supportUrl={process.env.SUPPORT_URL}
contactUrl={process.env.CONTACT_URL}
openSourceUrl={process.env.OPEN_SOURCE_URL}
termsOfServiceUrl={process.env.TERMS_OF_SERVICE_URL}
privacyPolicyUrl={process.env.PRIVACY_POLICY_URL}
appleAppStoreUrl={process.env.APPLE_APP_STORE_URL}
googlePlayUrl={process.env.GOOGLE_PLAY_URL}
socialLinks={socialLinks}
enterpriseMarketingLink={{
url: process.env.ENTERPRISE_MARKETING_URL,
queryParams: {
utm_source: process.env.ENTERPRISE_MARKETING_UTM_SOURCE,
utm_campaign: process.env.ENTERPRISE_MARKETING_UTM_CAMPAIGN,
utm_medium: process.env.ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM,
},
}}
/>
</div>
</Router>
</Provider>
</IntlProvider>
);
if (apiClient.ensurePublicOrAuthencationAndCookies(window.location.pathname)) {
ReactDOM.render(<App />, document.getElementById('root'));
}
apiClient.ensurePublicOrAuthenticationAndCookies(
window.location.pathname,
() => {
ReactDOM.render(<App />, document.getElementById('root'));
},
);