Compare commits

...

196 Commits

Author SHA1 Message Date
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
Simon Chen
223d9a00bd Merge pull request #76 from edx/schen/analytics_setup
Add segment library integration with Gradebook to track events
2019-01-16 16:10:41 -05:00
Simon Chen
8379f48e50 fix(analytics): Add segment integration into Gradebook
Gradebook should now have segment.io tracking
2019-01-16 13:41:35 -05:00
Jansen Kantor
9e1268e388 Merge pull request #75 from edx/jkantor/a11y-2
fix(a11y): add select aria-labels, row headers
2019-01-16 10:16:14 -05:00
jansenk
57e0f2254a fix(a11y): add select aria-labels, row headers
EDUCATOR-3858
2019-01-15 16:40:38 -05:00
Douglas Hall
2cc14191b4 Merge pull request #71 from edx/douglashall/frontend-component-footer
Move footer component to npm package
2019-01-10 14:32:19 -05:00
Douglas Hall
603dbeb823 fix(footer): move footer component to npm package 2019-01-09 16:38:01 -05:00
Zachary Hancock
55cb1f4140 Merge pull request #74 from edx/zhancock/openedx-meta
add metadata for openedx releases
2019-01-08 16:39:25 -05:00
Zach Hancock
55648a62ff add metadata for openedx releases 2019-01-08 15:28:30 -05:00
Zachary Hancock
62f9d24704 Merge pull request #73 from edx/zhancock/devstack-integration
move project run/setup to devstack
2019-01-08 11:19:19 -05:00
Zach Hancock
f036b0cf34 move project run/setup to devstack 2019-01-07 14:10:55 -05:00
Jansen Kantor
67493d1e9e Merge pull request #69 from edx/jkantor/change-message
changed role error message and don't show during loading
2019-01-03 10:59:37 -05:00
jansenk
e5bca7e526 changed role error message and don't show during loading 2019-01-02 16:35:06 -05:00
Jansen Kantor
52c5357ce7 Merge pull request #66 from edx/jkantor/change-pagination-buttons
implement gradebook pagination button feedback
2019-01-02 16:19:43 -05:00
jansenk
d469cc2de7 implement gradebook pagination button feedback
refactor buttons to a pure function component
change labels
disable rather than hide

EDUCATOR-3825
2019-01-02 16:11:01 -05:00
Jansen Kantor
86092f22b3 Merge pull request #65 from edx/jkantor/disable-student-groups
disable rather than hide empty groups and cohorts
2019-01-02 09:22:19 -05:00
Zachary Hancock
c8cb07228f Merge pull request #63 from edx/zhancock/edit-modal
Gradebook edit modal updates
2018-12-27 10:04:57 -05:00
Jansen Kantor
a1946e7bc4 Merge pull request #64 from edx/jkantor/roles-filter
request filtered roles
2018-12-21 15:08:09 -05:00
jansenk
01d80e0fff disable rather than hide empty groups and cohorts
EDUCATOR-3824
2018-12-21 14:23:19 -05:00
jansenk
e6da087e83 request filtered roles 2018-12-21 12:53:07 -05:00
Jansen Kantor
ac5eaed5cb Merge pull request #62 from edx/jkantor/staff
fix(auth) allow global staff to view gradebook
2018-12-21 11:46:02 -05:00
jansenk
88997ca242 fix(auth) allow global staff to view gradebook 2018-12-21 11:34:28 -05:00
Zach Hancock
d5daf9086f gradebook edit modal message 2018-12-21 10:09:35 -05:00
Simon Chen
8a01a60d63 Merge pull request #61 from edx/schen/default_select
fix(functionality): Make sure we default select radio button
2018-12-20 10:06:27 -05:00
Simon Chen
66cdcc7f2a fix(functionality): Make sure we default select radio button 2018-12-20 09:57:14 -05:00
Robert Raposa
0c73d66666 Merge pull request #59 from edx/robrap/footer-logo
Update footer logo.
2018-12-19 16:13:55 -05:00
albemarle
28e3e6d0e6 Merge pull request #60 from edx/home-link
add aria-label for edX Home
2018-12-19 15:29:57 -05:00
albemarle
6473bafa3d edx -> edX 2018-12-19 15:16:37 -05:00
Jansen Kantor
167901e665 Merge pull request #55 from edx/jkantor/role-error
render error message if user is not allowed to view gradebook
2018-12-19 15:15:34 -05:00
albemarle
50a0d6e579 add aria-label for edX Home 2018-12-19 15:09:58 -05:00
Robert Raposa
a284c286f5 Update footer logo.
ARCH-322
2018-12-19 14:04:31 -05:00
jansenk
dd967e703c render error message if user is not allowed to view gradebook 2018-12-19 14:00:27 -05:00
Richard I Reilly
725dc071e3 Merge pull request #58 from edx/rir/base-html-fixes
Add necessary meta tag to the base html file, language attribute for …
2018-12-19 13:37:45 -05:00
Rick Reilly
3da7730f23 Add necessary meta tag to the base html file, language attribute for a11y, and title 2018-12-19 11:20:33 -05:00
Richard I Reilly
bea36fb387 Merge pull request #57 from edx/kill-the-cats
Minor clean-up.
2018-12-18 18:29:09 -05:00
Robert Raposa
1408b0ae7e Minor clean-up. 2018-12-18 17:35:37 -05:00
Robert Raposa
7b817a4234 Merge pull request #56 from edx/dynamic-copyright
ARCH-321: Dynamic copyright in footer.
2018-12-18 17:13:42 -05:00
Richard I Reilly
a762c47d77 Merge pull request #37 from edx/add-footer
ARCH-308: Reimplement LMS footer in React in Gradebook.
2018-12-18 16:55:08 -05:00
Robert Raposa
aecb93c252 Dynamic copyright in footer.
ARCH-321
2018-12-18 16:51:27 -05:00
Robert Raposa
5a489b1bd5 Add footer matching LMS courses footer.
Note: There are still some follow-up tasks in ARCH-308
for analytics, i18n, etc. This gets the base functionality
in place.

ARCH-308
2018-12-18 14:19:01 -05:00
Simon Chen
5c642a1be5 Merge pull request #54 from edx/schen/alert
Update the color of the alert from red to yellow
2018-12-13 15:30:20 -05:00
Simon Chen
9a0e0e0ece Update the color of the alert from red to yellow 2018-12-13 14:50:38 -05:00
Alex Dusenbery
7486a342e2 Merge pull request #53 from edx/aed/show-attempted
Distinguish unattempted subsections.
2018-12-13 14:20:18 -05:00
Alex Dusenbery
fd807c54f8 Update README.md 2018-12-12 16:12:15 -05:00
Alex Dusenbery
9b894b502f Distinguish unattempted subsections. 2018-12-12 15:03:42 -05:00
Douglas Hall
afd9692f6b Merge pull request #51 from edx/douglashall/fix_prod_webpack_config
fix(webpack): use the appropriate css loader in prod webpack config
2018-12-12 10:56:04 -05:00
Douglas Hall
8f77dea222 fix(webpack): use the appropriate css loader in prod webpack config 2018-12-12 09:48:15 -05:00
Simon Chen
3bf3acaaec Merge pull request #50 from edx/schen/frozen_grades
fix(feat): Prevent editing if course grades are frozen
2018-12-11 13:45:23 -05:00
Simon Chen
f1ab3d0330 fix(feat): Prevent editing if course grades are frozen 2018-12-11 13:29:00 -05:00
Simon Chen
8d89bc16b1 Merge pull request #48 from edx/schen/update_wording
Update link wording
2018-12-10 15:29:01 -05:00
Alex Dusenbery
d93476c198 fix(UI): round numerator of grade ratios. 2018-12-10 14:26:19 -05:00
Simon Chen
b46d47286b Update link wording 2018-12-10 14:22:49 -05:00
Simon Chen
4aecfce14a Merge pull request #42 from edx/schen/test3
Add all the tests to reducers
2018-12-10 13:01:03 -05:00
Douglas Hall
14f7ad01b8 Merge pull request #46 from douglashall/douglashall/fix_logo
fix(header): fix edX logo in header
2018-12-10 12:29:39 -05:00
Douglas Hall
104cb30ef5 fix(header): fix edX logo in header 2018-12-10 12:16:58 -05:00
Simon Chen
e9bc4cebe4 Merge pull request #43 from edx/schen/fix_local
fix(auth): fix locally running gradebook auth refresh issue
2018-12-07 14:15:03 -05:00
Simon Chen
559180592c fix(auth): fix locally running gradebook auth refresh issue 2018-12-07 14:09:50 -05:00
Richard I Reilly
0f1f0ae89d Merge pull request #41 from edx/rir/header
Add super simple header
2018-12-07 14:04:14 -05:00
Simon Chen
a812ee3816 Add all the tests to reducers 2018-12-07 12:11:21 -05:00
Rick Reilly
2e725e0441 Add super simple header 2018-12-07 11:48:26 -05:00
Simon Chen
a1c2ccc539 Merge pull request #40 from edx/schen/tests2
Add more unit tests on actions and reducers
2018-12-07 10:59:02 -05:00
Simon Chen
a70ddd79f6 Add more unit tests on actions and reducers 2018-12-07 10:12:13 -05:00
Simon Chen
dd82054bbc Merge pull request #39 from edx/schen/setup-test
feat(test): Setup unit testing
2018-12-05 14:20:58 -05:00
Jansen Kantor
6a4bc67841 Merge pull request #38 from edx/encoding
fix(UI) specify utf8 to avoid incorrect character rendering
2018-12-05 14:15:51 -05:00
Simon Chen
adfefac85d feat(test): Setup unit testing 2018-12-05 13:52:33 -05:00
jkantor
c92144c436 fix(UI) specify utf8 to avoid incorrect character rendering 2018-12-05 13:35:28 -05:00
Jansen Kantor
ca0156ea4c Merge pull request #36 from edx/rounded-percents
fix(UI) rounded percentages to two decimal places
2018-12-05 13:33:38 -05:00
jkantor
61c4bc11bd fix(UI) rounded percentages to two decimal places 2018-12-05 10:53:55 -05:00
Jansen Kantor
db25a18f9d Merge pull request #35 from edx/updateMessage-filter
fix(UI) box should appear after editing grade
2018-12-04 14:01:05 -05:00
jkantor
0d7fa18acd fix(UI) box should appear after editing grade 2018-12-04 13:38:27 -05:00
Simon Chen
012bb3a1f3 Merge pull request #34 from edx/schen/EDUCATOR-3754
Add hardcoded page size on frontend
2018-12-04 09:58:43 -05:00
Simon Chen
de233e0285 fix(pagination): Add hardcoded page size on frontend 2018-12-03 16:04:41 -05:00
Richard I Reilly
ae7544cd53 Merge pull request #31 from edx/rir/spinner
Show a spinner when waiting for the grades call to come back
2018-12-03 15:04:09 -05:00
Rick Reilly
14df81b312 Show a spinner when waiting for the grades call to come back 2018-12-03 14:54:07 -05:00
Jansen Kantor
4706cfcd94 Merge pull request #30 from edx/retain_filter
fix(filter) filter should remain active after we edit a grade
2018-11-30 15:52:45 -05:00
jkantor
1f5a2469b2 fix(filter) filter should remain active after we edit a grade 2018-11-30 12:53:18 -05:00
Simon Chen
e31c670938 Merge pull request #29 from edx/schen/percent
fix(UI): Update the percent number view so it is actually percent
2018-11-30 12:07:47 -05:00
Simon Chen
db9f683297 fix(UI): Update the percent number view so it is actually percent with symbols 2018-11-30 11:55:02 -05:00
Richard I Reilly
7a43cdcaea Merge pull request #27 from edx/rir/dynamic-assignments
Make it so the assignment time column filter is dynamic and api driven
2018-11-30 11:08:59 -05:00
Rick Reilly
d5637a4550 Make it so the assignment time column filter is dynamic and api driven 2018-11-30 10:56:08 -05:00
72 changed files with 17335 additions and 7992 deletions

View File

@@ -1,3 +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

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
.DS_Store
.eslintcache
.idea
node_modules
npm-debug.log
coverage

View File

@@ -23,6 +23,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:

View File

@@ -1,29 +0,0 @@
# Copied from https://github.com/BretFisher/node-docker-good-defaults/blob/master/Dockerfile
FROM node:8.9.3
# Create app directory
RUN mkdir -p /edx/app
ARG NODE_ENV=production
ENV NODE_ENV $NODE_ENV
ARG PORT=80
ENV PORT $PORT
EXPOSE $PORT 1991
WORKDIR /edx
# Install app dependencies
# A wildcard is used to ensure both package.json AND package-lock.json are copied
# where available (npm@5+)
COPY package*.json ./
# If you are building your code for production
# RUN npm install --only=production
RUN npm install
ENV PATH /edx/app/node_modules/.bin:$PATH
WORKDIR /edx/app
COPY . /edx/app
ENTRYPOINT npm install && npm run start

149
LICENSE
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,32 +1,9 @@
shell: ## run a shell on the cookie-cutter container
docker exec -it edx.gradebook /bin/bash
build:
docker-compose build
up: ## bring up cookie-cutter container
docker-compose up
up-detached: ## bring up cookie-cutter container in detached mode
docker-compose up -d
logs: ## show logs for cookie-cutter container
docker-compose logs -f
down: ## stop and remove cookie-cutter container
docker-compose down
npm-install-%: ## install specified % npm package on the cookie-cutter container
docker exec npm install $* --save-dev
npm-install-%: ## install specified % npm package
npm install $* --save-dev
git add package.json
restart:
make down
make up
restart-detached:
make down
make up-detached
validate-no-uncommitted-package-lock-changes:
git diff --exit-code package-lock.json
test:
npm run test

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,25 +16,25 @@ 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
After cloning the repository, run `make up-detached` in the `gradebook` directory - this will build and start the `gradebook` web application in a docker container.
To install the project please refer to the [`edX Developer Stack`](https://github.com/edx/devstack) instructions.
The web application runs on port **1991**, so when you go to `http://localhost:1991` you should see the UI.
The web application runs on port **1994**, so when you go to `http://localhost:1994/course-v1:edX+DemoX+Demo_Course` you should see the UI (assuming you have such a Demo Course in your devstack). Note that you always have to provide a course id to actually see a gradebook.
If you don't, you can see the log messages for the docker container by executing `make logs` in the `gradebook` directory.
If you don't, you can see the log messages for the docker container by executing `make gradebook-logs` in the `devstack` directory.
Note that `make up-detached` executes the `npm run start` script which will hot-reload JavaScript and Sass files changes, so you should (:crossed_fingers:) not need to do anything (other than wait) when making changes.
Note that starting the container executes the `npm run start` script which will hot-reload JavaScript and Sass files changes, so you should (:crossed_fingers:) not need to do anything (other than wait) when making changes.
## Configuring for local use in edx-platform
Assuming you've got the UI running at `http://localhost:1991`, you can configure the LMS in edx-platform
Assuming you've got the UI running at `http://localhost:1994`, you can configure the LMS in edx-platform
to point to your local gradebook from the instructor dashboard by putting this settings in `lms/env/private.py`:
```
WRITABLE_GRADEBOOK_URL = 'http://localhost:1991'
WRITABLE_GRADEBOOK_URL = 'http://localhost:1994'
```
There are also several edx-platform waffle and feature flags you'll have to enable from the Django admin:
@@ -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`

BIN
assets/edx-footer.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

BIN
assets/edx-sm.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -4,6 +4,7 @@ const path = require('path');
module.exports = {
entry: {
segment: path.resolve(__dirname, '../src/segment.js'),
app: path.resolve(__dirname, '../src/index.jsx'),
},
output: {

View File

@@ -12,6 +12,7 @@ module.exports = Merge.smart(commonConfig, {
entry: [
// enable react's custom hot dev client so we get errors reported in the browser
require.resolve('react-dev-utils/webpackHotDevClient'),
path.resolve(__dirname, '../src/segment.js'),
path.resolve(__dirname, '../src/index.jsx'),
],
module: {
@@ -64,6 +65,28 @@ module.exports = Merge.smart(commonConfig, {
test: /\.(woff2?|ttf|svg|eot)(\?v=\d+\.\d+\.\d+)?$/,
loader: 'file-loader',
},
{
test: /\.(jpe?g|png|gif|ico)(\?v=\d+\.\d+\.\d+)?$/,
use: [
'file-loader',
{
loader: 'image-webpack-loader',
options: {
optimizationlevel: 7,
mozjpeg: {
progressive: true,
},
gifsicle: {
interlaced: false,
},
pngquant: {
quality: '65-90',
speed: 4,
},
},
},
],
},
],
},
// Specify additional processing or side-effects done on the Webpack output bundles as a whole.
@@ -75,12 +98,12 @@ module.exports = Merge.smart(commonConfig, {
}),
new webpack.EnvironmentPlugin({
NODE_ENV: 'development',
BASE_URL: 'localhost:1991',
BASE_URL: 'localhost:1994',
LMS_BASE_URL: 'http://localhost:18000',
LOGIN_URL: 'http://localhost:18000/login',
LOGOUT_URL: 'http://localhost:18000/login',
CSRF_TOKEN_API_PATH: '/csrf/api/v1/token',
REFRESH_ACCESS_TOKEN_ENDPOINT: 'http://localhost:18000/login',
REFRESH_ACCESS_TOKEN_ENDPOINT: 'http://localhost:18000/login_refresh',
DATA_API_BASE_URL: 'http://localhost:8000',
// LMS_CLIENT_ID should match the lms DOT client application id your LMS container
LMS_CLIENT_ID: 'login-service-client-id',
@@ -88,6 +111,24 @@ module.exports = Merge.smart(commonConfig, {
FEATURE_FLAGS: {},
ACCESS_TOKEN_COOKIE_NAME: 'edx-jwt-cookie-header-payload',
CSRF_COOKIE_NAME: 'csrftoken',
SITE_NAME: 'edX',
MARKETING_SITE_BASE_URL: 'http://localhost:18000',
SUPPORT_URL: 'http://localhost:18000/support',
CONTACT_URL: 'http://localhost:18000/contact',
OPEN_SOURCE_URL: 'http://localhost:18000/openedx',
TERMS_OF_SERVICE_URL: 'http://localhost:18000/terms-of-service',
PRIVACY_POLICY_URL: 'http://localhost:18000/privacy-policy',
FACEBOOK_URL: 'https://www.facebook.com',
TWITTER_URL: 'https://twitter.com',
YOU_TUBE_URL: 'https://www.youtube.com',
LINKED_IN_URL: 'https://www.linkedin.com',
REDDIT_URL: 'https://www.reddit.com',
APPLE_APP_STORE_URL: 'https://www.apple.com/ios/app-store/',
GOOGLE_PLAY_URL: 'https://play.google.com/store',
ENTERPRISE_MARKETING_URL: 'http://example.com',
ENTERPRISE_MARKETING_UTM_SOURCE: 'example.com',
ENTERPRISE_MARKETING_UTM_CAMPAIGN: 'example.com Referral',
ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM: 'Footer',
}),
// when the --hot option is not passed in as part of the command
// the HotModuleReplacementPlugin has to be specified in the Webpack configuration
@@ -98,7 +139,7 @@ module.exports = Merge.smart(commonConfig, {
// reloading.
devServer: {
host: '0.0.0.0',
port: 1991,
port: 1994,
historyApiFallback: true,
hot: true,
inline: true,

View File

@@ -4,8 +4,8 @@ const Merge = require('webpack-merge');
const commonConfig = require('./webpack.common.config.js');
const path = require('path');
const webpack = require('webpack');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = Merge.smart(commonConfig, {
mode: 'production',
@@ -38,29 +38,27 @@ module.exports = Merge.smart(commonConfig, {
// increases build time.
{
test: /(.scss|.css)$/,
use: ExtractTextPlugin.extract({
// creates style nodes from JS strings, only used if extracting fails
fallback: 'style-loader',
use: [
{
loader: 'css-loader', // translates CSS into CommonJS
options: {
sourceMap: true,
minimize: true,
},
use: [
MiniCssExtractPlugin.loader,
{
loader: 'css-loader', // translates CSS into CommonJS
options: {
sourceMap: true,
minimize: true,
},
{
loader: 'sass-loader', // compiles Sass to CSS
options: {
sourceMap: true,
includePaths: [
path.join(__dirname, '../node_modules'),
path.join(__dirname, '../src'),
],
},
},
'postcss-loader', // for autoprefixing, needs to be before the sass loader, not sure why
{
loader: 'sass-loader', // compiles Sass to CSS
options: {
sourceMap: true,
includePaths: [
path.join(__dirname, '../node_modules'),
path.join(__dirname, '../src'),
],
},
],
}),
},
],
},
// Webpack, by default, uses the url-loader for images and fonts that are required/included by
// files it processes, which just base64 encodes them and inlines them in the javascript
@@ -70,6 +68,28 @@ module.exports = Merge.smart(commonConfig, {
test: /\.(woff2?|ttf|svg|eot)(\?v=\d+\.\d+\.\d+)?$/,
loader: 'file-loader',
},
{
test: /\.(jpe?g|png|gif|ico)(\?v=\d+\.\d+\.\d+)?$/,
use: [
'file-loader',
{
loader: 'image-webpack-loader',
options: {
optimizationlevel: 7,
mozjpeg: {
progressive: true,
},
gifsicle: {
interlaced: false,
},
pngquant: {
quality: '65-90',
speed: 4,
},
},
},
],
},
],
},
// New in Webpack 4. Replaces CommonChunksPlugin. Extract common modules among all chunks to one
@@ -83,9 +103,8 @@ module.exports = Merge.smart(commonConfig, {
// Specify additional processing or side-effects done on the Webpack output bundles as a whole.
plugins: [
// Writes the extracted CSS from each entry to a file in the output directory.
new ExtractTextPlugin({
filename: '[name].min.css',
allChunks: true,
new MiniCssExtractPlugin({
filename: '[name].[chunkhash].css',
}),
// Generates an HTML file in the output directory.
new HtmlWebpackPlugin({
@@ -107,6 +126,24 @@ module.exports = Merge.smart(commonConfig, {
CSRF_COOKIE_NAME: 'csrftoken',
NEW_RELIC_APP_ID: null,
NEW_RELIC_LICENSE_KEY: null,
SITE_NAME: null,
MARKETING_SITE_BASE_URL: null,
SUPPORT_URL: null,
CONTACT_URL: null,
OPEN_SOURCE_URL: null,
TERMS_OF_SERVICE_URL: null,
PRIVACY_POLICY_URL: null,
FACEBOOK_URL: null,
TWITTER_URL: null,
YOU_TUBE_URL: null,
LINKED_IN_URL: null,
REDDIT_URL: null,
APPLE_APP_STORE_URL: null,
GOOGLE_PLAY_URL: null,
ENTERPRISE_MARKETING_URL: null,
ENTERPRISE_MARKETING_UTM_SOURCE: null,
ENTERPRISE_MARKETING_UTM_CAMPAIGN: null,
ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM: null,
}),
],
});

View File

@@ -1,20 +0,0 @@
version: "2"
services:
web:
build:
context: .
dockerfile: Dockerfile
args:
- NODE_ENV=development
container_name: edx.gradebook
image: edxops/front-end-cookie-cutter:latest
volumes:
- .:/edx/app:delegated
- notused:/edx/app/node_modules
ports:
- "1991:1991"
environment:
- NODE_ENV=development
volumes:
notused:

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.

9
openedx.yaml Normal file
View File

@@ -0,0 +1,9 @@
# This file describes this Open edX repo, as described in OEP-2:
# http://open-edx-proposals.readthedocs.io/en/latest/oeps/oep-0002.html#specification
nick: grbk
oeps: {}
owner: schenedx
supporting_teams:
- masters-devs
openedx-release: {ref: master}

20077
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,10 @@
{
"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",
@@ -15,72 +15,85 @@
"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.2.1",
"@edx/paragon": "^3.7.2",
"@edx/frontend-auth": "^4.0.0",
"@edx/frontend-component-footer": "^4.1.5",
"@edx/paragon": "^4.2.3",
"@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.5",
"email-prop-type": "^1.1.5",
"classnames": "^2.2.6",
"email-prop-type": "^1.1.7",
"font-awesome": "^4.7.0",
"history": "^4.7.2",
"prop-types": "^15.5.10",
"query-string": "^6.2.0",
"react": "^16.2.0",
"react-dom": "^16.2.0",
"react-redux": "^5.0.7",
"react-router": "^4.2.0",
"react-router-dom": "^4.2.2",
"history": "^4.10.1",
"prop-types": "^15.7.2",
"query-string": "^5.1.1",
"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.2",
"redux-beacon": "^2.1.0",
"redux-devtools-extension": "^2.13.8",
"redux-logger": "^3.0.6",
"redux-thunk": "^2.2.0",
"whatwg-fetch": "^2.0.3"
"redux-thunk": "^2.3.0",
"whatwg-fetch": "^2.0.4"
},
"devDependencies": {
"autoprefixer": "^9.6.1",
"axios-mock-adapter": "^1.17.0",
"babel-cli": "^6.26.0",
"babel-eslint": "^8.2.2",
"babel-jest": "^22.4.0",
"babel-loader": "^7.1.2",
"babel-eslint": "^8.2.6",
"babel-jest": "^22.4.4",
"babel-loader": "^7.1.5",
"babel-plugin-transform-class-properties": "^6.24.1",
"babel-plugin-transform-object-rest-spread": "^6.26.0",
"babel-preset-env": "^1.6.1",
"babel-preset-env": "^1.7.0",
"babel-preset-react": "^6.24.1",
"codecov": "^3.0.0",
"css-loader": "^0.28.9",
"enzyme": "^3.3.0",
"enzyme-adapter-react-16": "^1.1.1",
"es-check": "^2.0.2",
"eslint-config-edx": "^4.0.3",
"extract-text-webpack-plugin": "^4.0.0-beta.0",
"fetch-mock": "^6.3.0",
"codecov": "^3.6.1",
"css-loader": "^0.28.11",
"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",
"file-loader": "^1.1.9",
"html-webpack-harddisk-plugin": "^0.2.0",
"html-webpack-plugin": "^3.0.3",
"html-webpack-plugin": "^3.2.0",
"husky": "^0.14.3",
"identity-obj-proxy": "^3.0.0",
"jest": "^22.4.0",
"node-sass": "^4.7.2",
"react-dev-utils": "^5.0.0",
"react-test-renderer": "^16.2.0",
"redux-mock-store": "^1.5.1",
"image-webpack-loader": "^4.2.0",
"jest": "^22.4.4",
"mini-css-extract-plugin": "^0.4.0",
"node-sass": "^4.12.0",
"postcss-loader": "^3.0.0",
"react-dev-utils": "^5.0.3",
"react-test-renderer": "^16.10.1",
"redux-mock-store": "^1.5.3",
"sass-loader": "^6.0.6",
"semantic-release": "^15.10.7",
"style-loader": "^0.20.2",
"travis-deploy-once": "^5.0.9",
"webpack": "^4.1.0",
"webpack-cli": "^2.0.10",
"webpack-dev-server": "^3.1.0",
"webpack-merge": "^4.1.1"
"semantic-release": "^15.13.24",
"style-loader": "^0.20.3",
"travis-deploy-once": "^5.0.11",
"webpack": "^4.41.0",
"webpack-cli": "^3.3.9",
"webpack-dev-server": "^3.8.2",
"webpack-merge": "^4.2.2"
},
"jest": {
"setupFiles": [
@@ -101,6 +114,7 @@
],
"transformIgnorePatterns": [
"/node_modules/(?!(@edx/paragon)/).*/"
]
],
"testURL": "http://localhost"
}
}

View File

@@ -1,6 +1,10 @@
<!doctype html>
<html>
<head></head>
<html lang="en-us">
<head>
<title>Gradebook | edX</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
<div id="root"></div>
</body>

View File

@@ -4,6 +4,12 @@
$fa-font-path: "~font-awesome/fonts";
@import "~font-awesome/scss/font-awesome";
$input-focus-box-shadow: $input-box-shadow; // hack to get upgrade to paragon 4.0.0 to work
@import "~@edx/paragon/src/SearchField/SearchField";
@import "~@edx/paragon/src/Collapsible/Collapsible";
@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

@@ -1,18 +1,35 @@
.gradebook-container{
width: 500px;
@media only screen and (min-width: 640px) {
width: 630px;
.spinner-overlay {
position: fixed;
height: 100%;
width: 100%;
top: 0;
left: 0;
background-color: #999;
opacity: 0.5;
z-index: 99999;
display:flex;
align-items: flex-start;
justify-content: center;
padding: 200px;
}
.color-black {
color: black;
}
.gradebook-content {
// note that this width isn't well-abstracted from Drawer code.
// if we need to change it we may need to dig into those styles as well
width: 100vw;
.search-help-text {
margin-left: 20px;
}
@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;
}
}
.back-link{
float:right;
}
.student-filters{
display: flex;
.label{
@@ -22,34 +39,64 @@
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;
left: 0;
z-index: 1; // to float over the following children in the side-scrolling case
background: white;
}
.table tr td:nth-child(2) {
box-sizing: content-box;
padding-left: 170px;
}
.table tr th:nth-child(2) {
padding-left: 170px;
.table tbody th {
font-weight: normal;
}
.link-style {
@@ -61,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,77 +1,203 @@
import React from 'react';
import { Button, InputSelect, Modal, SearchField, StatusAlert, Table } from '@edx/paragon';
import PropTypes from 'prop-types';
import {
Button,
Collapsible,
Icon,
InputSelect,
InputText,
Modal,
SearchField,
StatefulButton,
StatusAlert,
Table,
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.getUserGrades(
this.props.match.params.courseId,
urlQuery.cohort,
urlQuery.track,
);
this.props.getTracks(this.props.match.params.courseId);
this.props.getCohorts(this.props.match.params.courseId);
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 = '';
if (subsection.attempted) {
adjustedGradePossible = subsection.score_possible;
}
this.setState({
modalModel: [{
username: userEntry.username,
currentGrade: `${subsection.score_earned}/${subsection.score_possible}`,
adjustedGrade: (
<span>
<input
style={{ width: '25px' }}
type="text"
onChange={event => this.setState({ updateVal: event.target.value })}
/> / {subsection.score_possible}
</span>
),
assignmentName: `${subsection.subsection_name}`,
}],
modalAssignmentName: `${subsection.subsection_name}`,
modalOpen: true,
updateModuleId: subsection.module_id,
updateUserId: userEntry.user_id,
updateUserName: userEntry.username,
todaysDate: formatDateForDisplay(new Date()),
adjustedGradePossible,
reasonForChange: '',
adjustedGradeValue: '',
});
}
handleAdjustedGradeClick = () => {
this.props.updateGrades(this.props.match.params.courseId, [
{
user_id: this.state.updateUserId,
usage_id: this.state.updateModuleId,
grade: {
earned_graded_override: this.state.updateVal,
},
},
]);
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.courseId, [
{
user_id: this.state.updateUserId,
usage_id: this.state.updateModuleId,
grade: {
earned_graded_override: this.state.adjustedGradeValue,
comment: this.state.reasonForChange,
},
},
],
this.state.filterValue,
this.props.selectedCohort,
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) => {
const mapped = entries.map(entry => ({
id: entry,
label: entry,
}));
mapped.unshift({ id: 0, label: 'All', value: '' });
return mapped;
};
mapCohortsEntries = (entries) => {
@@ -92,6 +218,58 @@ export default class Gradebook extends React.Component {
return mapped;
};
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) => {
const selectedTrackItem = this.props.tracks.find(x => x.name === event);
let selectedTrackSlug = null;
@@ -99,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) => {
@@ -114,14 +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 });
};
// 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) {
@@ -138,226 +370,697 @@ export default class Gradebook extends React.Component {
return 'Tracks';
};
roundGrade = percent => parseFloat((percent || 0).toFixed(DECIMAL_PRECISION));
formatter = {
percent: entries => entries.map((entry) => {
const results = { username: entry.username };
percent: (entries, areGradesFrozen) => entries.map((entry) => {
const learnerInformation = this.getLearnerInformation(entry);
const results = {
Username: (
<div><span className="wrap-text-in-cell">{learnerInformation}</span></div>
),
Email: (
<span className="wrap-text-in-cell">{entry.email}</span>
),
};
const assignments = entry.section_breakdown
.filter(section => section.is_graded)
.reduce((acc, subsection) => {
acc[subsection.label] = (
<button
className="btn btn-header link-style"
onClick={() => this.setNewModalState(entry, subsection)}
>
{subsection.percent}
</button>);
if (areGradesFrozen) {
acc[subsection.label] = `${this.roundGrade(subsection.percent * 100)} %`;
} else {
acc[subsection.label] = (
<button
className="btn btn-header link-style grade-button"
onClick={() => this.setNewModalState(entry, subsection)}
>
{this.roundGrade(subsection.percent * 100)}%
</button>);
}
return acc;
}, {});
const totals = { total: entry.percent * 100 };
const totals = { Total: `${this.roundGrade(entry.percent * 100)}%` };
return Object.assign(results, assignments, totals);
}),
absolute: entries => entries.map((entry) => {
const results = { username: entry.username };
absolute: (entries, areGradesFrozen) => entries.map((entry) => {
const learnerInformation = this.getLearnerInformation(entry);
const results = {
Username: (
<div><span className="wrap-text-in-cell">{learnerInformation}</span></div>
),
Email: (
<span className="wrap-text-in-cell">{entry.email}</span>
),
};
const assignments = entry.section_breakdown
.filter(section => section.is_graded)
.reduce((acc, subsection) => {
acc[subsection.label] = (
<button
className="btn btn-header link-style"
onClick={() => this.setNewModalState(entry, subsection)}
>
{subsection.score_earned}/{subsection.score_possible}
</button>);
const scoreEarned = this.roundGrade(subsection.score_earned);
const scorePossible = this.roundGrade(subsection.score_possible);
let label = `${scoreEarned}`;
if (subsection.attempted) {
label = `${scoreEarned}/${scorePossible}`;
}
if (areGradesFrozen) {
acc[subsection.label] = label;
} else {
acc[subsection.label] = (
<button
className="btn btn-header link-style"
onClick={() => this.setNewModalState(entry, subsection)}
>
{label}
</button>
);
}
return acc;
}, {});
const totals = { total: entry.percent * 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">
<div className="card gradebook-container">
<div className="card-body">
<Drawer
mainContent={toggleFilterDrawer => (
<div className="px-3 gradebook-content">
<a
href={this.lmsInstructorDashboardUrl(this.props.match.params.courseId)}
className="back-link"
href={this.lmsInstructorDashboardUrl(this.props.courseId)}
className="mb-3"
>
Back to Dashboard
<span aria-hidden="true">{'<< '}</span> {'Back to Dashboard'}
</a>
<h1>Gradebook</h1>
<h3> {this.props.match.params.courseId}</h3>
<hr />
<div className="d-flex justify-content-between" >
<h3> {this.props.courseId}</h3>
{this.props.areGradesFrozen &&
<div className="alert alert-warning" role="alert" >
The grades for this course are now frozen. Editing of grades is no longer allowed.
</div>
}
{(this.props.canUserViewGradebook === false) &&
<div className="alert alert-warning" role="alert" >
You are not authorized to view the gradebook for this course.
</div>
}
<Tabs labels={this.getActiveTabs()}>
<div>
<div>
Score View:
<span>
<input
id="score-view-percent"
className="ml-2 mr-1"
type="radio"
name="score-view"
value="percent"
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>
</div>
<div>
Category:
<span>
<input
id="category-all"
className="ml-2 mr-1"
type="radio"
name="category"
value="all"
onClick={() => this.props.filterColumns('all', this.props.grades[0])}
/>
<label className="mr-2" htmlFor="category-all">
All
</label>
</span>
<span>
<input
id="category-homework"
className="mr-1"
type="radio"
name="category"
value="homework"
onClick={() => this.props.filterColumns('hw', this.props.grades[0])}
/>
<label className="mr-2" htmlFor="category-homework">Homework</label>
</span>
<span>
<input
id="category-exam"
type="radio"
name="category"
value="exam"
className="ml-2 mr-1"
onClick={() => this.props.filterColumns('exam', this.props.grades[0])}
/>
<label htmlFor="category-exam">
Exam
</label>
</span>
</div>
{(this.props.tracks.length > 0 || this.props.cohorts.length > 0) &&
<div className="student-filters">
<span className="label">
Student Groups:
</span>
{this.props.tracks.length > 0 &&
<InputSelect
name="Tracks"
value={this.mapSelectedTrackEntry(this.props.selectedTrack)}
options={this.mapTracksEntries(this.props.tracks)}
onChange={this.updateTracks}
/>
}
{this.props.cohorts.length > 0 &&
<InputSelect
name="Cohorts"
value={this.mapSelectedCohortEntry(this.props.selectedCohort)}
options={this.mapCohortsEntries(this.props.cohorts)}
onChange={this.updateCohorts}
/>
}
<small className="form-text text-muted search-help-text">Search by username, email, or student key</small>
</div>
}
</div>
<div>
<div style={{ marginLeft: '10px', marginBottom: '10px' }}>
<a href={`${this.lmsInstructorDashboardUrl(this.props.match.params.courseId)}#view-data_download`}>Download Grade Report</a>
</div>
<SearchField
onSubmit={value => this.props.searchForUser(this.props.match.params.courseId, value, this.props.selectedCohort, this.props.selectedTrack)}
onChange={filterValue => this.setState({ filterValue })}
onClear={() => this.props.getUserGrades(this.props.match.params.courseId, this.props.selectedCohort, this.props.selectedTrack)}
value={this.state.filterValue}
<ConnectedFilterBadges
handleFilterBadgeClose={this.handleFilterBadgeClose}
/>
<div className="d-flex justify-content-end" style={{ marginTop : '20px'}}>
<Button
label="Previous"
buttonType="primary"
style={{ visibility: (!this.props.prevPage ? 'hidden' : 'visible') }}
onClick={() => this.props.getPrevNextGrades(this.props.prevPage, this.props.selectedCohort, this.props.selectedTrack)}
/>
<div style={{width: '10px'}} />
<Button
label="Next"
buttonType="primary"
style={{ visibility: (!this.props.nextPage ? 'hidden' : 'visible') }}
onClick={() => this.props.getPrevNextGrades(this.props.nextPage, this.props.selectedCohort, this.props.selectedTrack)}
<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>
{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}
/>
<div className="gbook">
<Table
columns={this.props.headings}
data={this.formatter[this.props.format](this.props.grades)}
tableSortable
defaultSortDirection="asc"
defaultSortedColumn="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">
<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>
<Modal
open={this.state.modalOpen}
title="Edit Grades"
body={(
<div>
<h3>{this.state.modalModel[0].assignmentName}</h3>
<Table
columns={[{ label: 'Username', key: 'username' }, { label: 'Current grade', key: 'currentGrade' }, { label: 'Adjusted grade', key: 'adjustedGrade' }]}
data={this.state.modalModel}
/>
</div>
)}
buttons={[
<Button
label="Edit 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">
<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">
<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

@@ -0,0 +1,25 @@
import React from 'react';
import { Hyperlink } from '@edx/paragon';
import EdxLogo from '../../../assets/edx-sm.png';
export default class Header extends React.Component {
renderLogo() {
return (
<img src={EdxLogo} alt="edX logo" height="30" width="60" />
);
}
render() {
return (
<div className="mb-3">
<header className="d-flex justify-content-center align-items-center p-3 border-bottom-blue">
<Hyperlink destination="https://www.edx.org">
{this.renderLogo()}
</Hyperlink>
<div />
</header>
</div>
);
}
}

View File

@@ -0,0 +1,36 @@
import renderer from 'react-test-renderer';
import PageButtons from '.';
const createInput = function createInput(prevPage, nextPage) {
return {
prevPage,
nextPage,
selectedTrack: 't',
selectedCohort: 'c',
getPrevNextGrades() {},
};
};
describe('PageButtons', () => {
const assertPageButtonsSnapshot = function assertPageButtonsSnapshot(input) {
const pb = renderer.create(PageButtons(input));
const tree = pb.toJSON();
expect(tree).toMatchSnapshot();
};
it('prev null, next null', () => {
assertPageButtonsSnapshot(createInput(null, null));
});
it('prev null, next not null', () => {
assertPageButtonsSnapshot(createInput(null, 'np'));
});
it('prev not null, next null', () => {
assertPageButtonsSnapshot(createInput('pp', null));
});
it('prev not null, next not null', () => {
assertPageButtonsSnapshot(createInput('pp', 'np'));
});
});

View File

@@ -0,0 +1,169 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PageButtons prev not null, next not null 1`] = `
<div
className="d-flex justify-content-center"
style={
Object {
"paddingBottom": "20px",
}
}
>
<button
className="btn btn-outline-primary"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
style={
Object {
"margin": "20px",
}
}
type="button"
>
Previous Page
</button>
<button
className="btn btn-outline-primary"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
style={
Object {
"margin": "20px",
}
}
type="button"
>
Next Page
</button>
</div>
`;
exports[`PageButtons prev not null, next null 1`] = `
<div
className="d-flex justify-content-center"
style={
Object {
"paddingBottom": "20px",
}
}
>
<button
className="btn btn-outline-primary"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
style={
Object {
"margin": "20px",
}
}
type="button"
>
Previous Page
</button>
<button
className="btn btn-outline-primary"
disabled={true}
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
style={
Object {
"margin": "20px",
}
}
type="button"
>
Next Page
</button>
</div>
`;
exports[`PageButtons prev null, next not null 1`] = `
<div
className="d-flex justify-content-center"
style={
Object {
"paddingBottom": "20px",
}
}
>
<button
className="btn btn-outline-primary"
disabled={true}
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
style={
Object {
"margin": "20px",
}
}
type="button"
>
Previous Page
</button>
<button
className="btn btn-outline-primary"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
style={
Object {
"margin": "20px",
}
}
type="button"
>
Next Page
</button>
</div>
`;
exports[`PageButtons prev null, next null 1`] = `
<div
className="d-flex justify-content-center"
style={
Object {
"paddingBottom": "20px",
}
}
>
<button
className="btn btn-outline-primary"
disabled={true}
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
style={
Object {
"margin": "20px",
}
}
type="button"
>
Previous Page
</button>
<button
className="btn btn-outline-primary"
disabled={true}
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
style={
Object {
"margin": "20px",
}
}
type="button"
>
Next Page
</button>
</div>
`;

View File

@@ -0,0 +1,79 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Button } from '@edx/paragon';
export default function PageButtons({
prevPage, nextPage, selectedTrack, selectedCohort, selectedAssignmentType,
getPrevNextGrades, match,
}) {
return (
<div
className="d-flex justify-content-center"
style={{ paddingBottom: '20px' }}
>
<Button
style={{ margin: '20px' }}
buttonType="outline-primary"
disabled={!prevPage}
onClick={() =>
getPrevNextGrades(
prevPage,
match.params.courseId,
selectedCohort,
selectedTrack,
selectedAssignmentType,
)}
>
Previous Page
</Button>
<Button
style={{ margin: '20px' }}
buttonType="outline-primary"
disabled={!nextPage}
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,63 +2,145 @@ 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 => (
function shouldShowSpinner(state) {
if (state.roles.canUserViewGradebook === true) {
return state.grades.showSpinner;
} else if (state.roles.canUserViewGradebook === false) {
return false;
} // canUserViewGradebook === null
return true;
}
const mapStateToProps = (state, ownProps) => (
{
courseId: ownProps.match.params.courseId,
grades: state.grades.results,
headings: state.grades.headings,
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.grades.selectedTrack,
selectedCohort: state.grades.selectedCohort,
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 = dispatch => (
{
getUserGrades: (courseId, cohort, track) => {
dispatch(fetchGrades(courseId, cohort, track));
},
searchForUser: (courseId, searchText, cohort, track) => {
dispatch(fetchMatchingUserGrades(courseId, searchText, cohort, track));
},
getPrevNextGrades : (endpoint, cohort, track) => {
dispatch(fetchPrevNextGrades(endpoint, cohort, track));
},
getCohorts: (courseId) => {
dispatch(fetchCohorts(courseId));
},
getTracks: (courseId) => {
dispatch(fetchTracks(courseId));
},
updateGrades: (courseId, updateData) => {
dispatch(updateGrades(courseId, updateData));
},
toggleFormat: (formatType) => {
dispatch(toggleGradeFormat(formatType));
},
filterColumns: (filterType, exampleUser) => {
dispatch(filterColumns(filterType, exampleUser));
},
updateBanner: (showSuccess) => {
dispatch(updateBanner(showSuccess));
},
}
);
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,

View File

@@ -0,0 +1,41 @@
import {
STARTED_FETCHING_ASSIGNMENT_TYPES,
GOT_ASSIGNMENT_TYPES,
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) => {
dispatch(startedFetchingAssignmentTypes());
return LmsApiService.fetchAssignmentTypes(courseId)
.then(response => response.data)
.then((data) => {
dispatch(gotAssignmentTypes(Object.keys(data.assignment_types)));
dispatch(gotGradesFrozen(data.grades_frozen));
dispatch(gotBulkManagementConfig(data.can_see_bulk_management));
})
.catch(() => {
dispatch(errorFetchingAssignmentTypes());
});
}
);
export {
fetchAssignmentTypes,
startedFetchingAssignmentTypes,
gotAssignmentTypes,
errorFetchingAssignmentTypes,
};

View File

@@ -0,0 +1,97 @@
import configureMockStore from 'redux-mock-store';
import MockAdapter from 'axios-mock-adapter';
import thunk from 'redux-thunk';
import apiClient from '../apiClient';
import { configuration } from '../../config';
import { fetchAssignmentTypes } from './assignmentTypes';
import {
STARTED_FETCHING_ASSIGNMENT_TYPES,
GOT_ASSIGNMENT_TYPES,
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(() => {
axiosMock.reset();
});
describe('fetchAssignmentTypes', () => {
const courseId = 'course-v1:edX+DemoX+Demo_Course';
const responseData = {
assignment_types: {
Exam: {
drop_count: 0,
min_count: 1,
short_label: 'Exam',
type: 'Exam',
weight: 0.25,
},
Homework: {
drop_count: 1,
min_count: 3,
short_label: 'Ex',
type: 'Homework',
weight: 0.75,
},
},
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();
axiosMock.onGet(`${configuration.LMS_BASE_URL}/api/grades/v1/gradebook/${courseId}/grading-info?graded_only=true`)
.replyOnce(200, JSON.stringify(responseData));
return store.dispatch(fetchAssignmentTypes(courseId)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it('dispatches failure action after fetching cohorts', () => {
const expectedActions = [
{ type: STARTED_FETCHING_ASSIGNMENT_TYPES },
{ type: ERROR_FETCHING_ASSIGNMENT_TYPES },
];
const store = mockStore();
axiosMock.onGet(`${configuration.LMS_BASE_URL}/api/grades/v1/gradebook/${courseId}/grading-info?graded_only=true`)
.replyOnce(500, JSON.stringify({}));
return store.dispatch(fetchAssignmentTypes(courseId)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it('dispatches frozen grade action with True value after fetching', () => {
const expectedActions = [
{ 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;
axiosMock.onGet(`${configuration.LMS_BASE_URL}/api/grades/v1/gradebook/${courseId}/grading-info?graded_only=true`)
.replyOnce(200, JSON.stringify(responseData));
return store.dispatch(fetchAssignmentTypes(courseId)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
});
});

View File

@@ -17,7 +17,7 @@ const fetchCohorts = courseId => (
.then((data) => {
dispatch(gotCohorts(data.cohorts));
})
.catch((error) => {
.catch(() => {
dispatch(errorFetchingCohorts());
});
}

View File

@@ -0,0 +1,76 @@
import configureMockStore from 'redux-mock-store';
import MockAdapter from 'axios-mock-adapter';
import thunk from 'redux-thunk';
import apiClient from '../apiClient';
import { configuration } from '../../config';
import { fetchCohorts } from './cohorts';
import {
STARTED_FETCHING_COHORTS,
GOT_COHORTS,
ERROR_FETCHING_COHORTS,
} from '../constants/actionTypes/cohorts';
const mockStore = configureMockStore([thunk]);
const axiosMock = new MockAdapter(apiClient);
apiClient.isAccessTokenExpired = jest.fn();
apiClient.isAccessTokenExpired.mockReturnValue(false);
describe('actions', () => {
afterEach(() => {
axiosMock.reset();
});
describe('fetchCohorts', () => {
const courseId = 'course-v1:edX+DemoX+Demo_Course';
it('dispatches success action after fetching cohorts', () => {
const responseData = {
cohorts: [
{
assignment_type: 'manual',
group_id: null,
id: 1,
name: 'default_group',
user_count: 2,
user_partition_id: null,
},
{
assignment_type: 'auto',
group_id: null,
id: 2,
name: 'auto_group',
user_count: 5,
user_partition_id: null,
}],
};
const expectedActions = [
{ type: STARTED_FETCHING_COHORTS },
{ type: GOT_COHORTS, cohorts: responseData.cohorts },
];
const store = mockStore();
axiosMock.onGet(`${configuration.LMS_BASE_URL}/courses/${courseId}/cohorts/`)
.replyOnce(200, JSON.stringify(responseData));
return store.dispatch(fetchCohorts(courseId)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it('dispatches failure action after fetching cohorts', () => {
const expectedActions = [
{ type: STARTED_FETCHING_COHORTS },
{ type: ERROR_FETCHING_COHORTS },
];
const store = mockStore();
axiosMock.onGet(`${configuration.LMS_BASE_URL}/courses/${courseId}/cohorts/`)
.replyOnce(500, JSON.stringify({}));
return store.dispatch(fetchCohorts(courseId)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
});
});

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,77 +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 apiClient from "../apiClient";
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) => (
dispatch => ({
type: FILTER_COLUMNS,
headings: headingMapper[filterType](dispatch, exampleUser),
}));
const filterAssignmentType = filterType => (
dispatch => dispatch({
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.all(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());
@@ -85,42 +180,70 @@ const fetchGrades = (courseId, cohort, track, showSuccess) => (
}
);
const fetchMatchingUserGrades = (courseId, searchText, cohort, track) => (
(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.all(dispatch, data.results[0]),
data.previous,
data.next,
));
dispatch(finishedFetchingGrades());
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.all(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(() => {
@@ -129,22 +252,72 @@ const fetchPrevNextGrades = (endpoint, cohort, track) => (
}
);
const updateGrades = (courseId, updateData) => (
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(fetchGrades(courseId, null, null, 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,
@@ -158,7 +331,13 @@ export {
gradeUpdateFailure,
updateGrades,
toggleGradeFormat,
sortGrades,
filterColumns,
updateBanner,
filterAssignmentType,
closeBanner,
submitFileUploadFormData,
fetchBulkUpgradeHistory,
doneViewingAssignment,
fetchGradeOverrideHistory,
updateGradesIfAssignmentGradeFiltersSet,
downloadBulkGradesReport,
downloadInterventionReport,
};

View File

@@ -0,0 +1,179 @@
import configureMockStore from 'redux-mock-store';
import MockAdapter from 'axios-mock-adapter';
import thunk from 'redux-thunk';
import apiClient from '../apiClient';
import { configuration } from '../../config';
import { fetchGrades } from './grades';
import {
STARTED_FETCHING_GRADES,
FINISHED_FETCHING_GRADES,
ERROR_FETCHING_GRADES,
GOT_GRADES,
} 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(() => {
axiosMock.reset();
});
describe('fetchGrades', () => {
const courseId = 'course-v1:edX+DemoX+Demo_Course';
const expectedCohort = 1;
const expectedTrack = 'verified';
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,
results: [
{
course_id: courseId,
email: 'user1@example.com',
username: 'user1',
user_id: 1,
percent: 0.5,
letter_grade: null,
section_breakdown: [
{
subsection_name: 'Demo Course Overview',
score_earned: 0,
score_possible: 0,
percent: 0,
displayed_value: '0.00',
grade_description: '(0.00/0.00)',
},
{
subsection_name: 'Example Week 1: Getting Started',
score_earned: 1,
score_possible: 1,
percent: 1,
displayed_value: '1.00',
grade_description: '(0.00/0.00)',
},
],
},
{
course_id: courseId,
email: 'user22@example.com',
username: 'user22',
user_id: 22,
percent: 0,
letter_grade: null,
section_breakdown: [
{
subsection_name: 'Demo Course Overview',
score_earned: 0,
score_possible: 0,
percent: 0,
displayed_value: '0.00',
grade_description: '(0.00/0.00)',
},
{
subsection_name: 'Example Week 1: Getting Started',
score_earned: 1,
score_possible: 1,
percent: 0,
displayed_value: '0.00',
grade_description: '(0.00/0.00)',
},
],
}],
};
it('dispatches success action after fetching grades', () => {
const expectedActions = [
{ type: STARTED_FETCHING_GRADES },
{
type: GOT_GRADES,
grades: responseData.results.sort(sortAlphaAsc),
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(responseData));
return store.dispatch(fetchGrades(
courseId,
expectedCohort,
expectedTrack,
expectedAssignmentType,
false,
)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it('dispatches failure action after fetching grades', () => {
const expectedActions = [
{ type: STARTED_FETCHING_GRADES },
{ type: ERROR_FETCHING_GRADES },
];
const store = mockStore();
axiosMock.onGet(fetchGradesURL)
.replyOnce(500, JSON.stringify({}));
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);
});
});
});
});

44
src/data/actions/roles.js Normal file
View File

@@ -0,0 +1,44 @@
import {
GOT_ROLES,
ERROR_FETCHING_ROLES,
} from '../constants/actionTypes/roles';
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'];
const gotRoles = (canUserViewGradebook, courseId) => ({
type: GOT_ROLES,
canUserViewGradebook,
courseId,
});
const errorFetchingRoles = () => ({ type: ERROR_FETCHING_ROLES });
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, cohort, track, assignmentType));
dispatch(fetchTracks(courseId));
dispatch(fetchCohorts(courseId));
dispatch(fetchAssignmentTypes(courseId));
}
})
.catch(() => {
dispatch(errorFetchingRoles());
}));
export {
getRoles,
errorFetchingRoles,
};

View File

@@ -0,0 +1,163 @@
import configureMockStore from 'redux-mock-store';
import MockAdapter from 'axios-mock-adapter';
import thunk from 'redux-thunk';
import apiClient from '../apiClient';
import { configuration } from '../../config';
import { getRoles } from './roles';
import {
GOT_ROLES,
ERROR_FETCHING_ROLES,
} from '../constants/actionTypes/roles';
import { STARTED_FETCHING_GRADES } from '../constants/actionTypes/grades';
import { STARTED_FETCHING_TRACKS } from '../constants/actionTypes/tracks';
import { STARTED_FETCHING_COHORTS } from '../constants/actionTypes/cohorts';
import { STARTED_FETCHING_ASSIGNMENT_TYPES } from '../constants/actionTypes/assignmentTypes';
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';
const rolesUrl = `${configuration.LMS_BASE_URL}/api/enrollment/v1/roles/?course_id=${encodeURIComponent(course1Id)}`;
function makeRoleListObj(roles, isGlobalStaff) {
return {
roles,
is_staff: isGlobalStaff,
};
}
function makeRoleObj(courseId, role) {
return {
course_id: courseId,
role,
};
}
const course1StaffRole = makeRoleObj(course1Id, 'staff');
const course1DummyRole = makeRoleObj(course1Id, 'dummy');
const course2StaffRole = makeRoleObj(course2Id, 'staff');
const course2DummyRole = makeRoleObj(course2Id, 'dummy');
const urlParams = { cohort: null, track: null };
describe('actions', () => {
afterEach(() => {
axiosMock.reset();
});
describe('getRoles', () => {
it('dispatches got_roles action and subsequent actions after fetching role that allows gradebook', () => {
const expectedActions = [
{ type: GOT_ROLES, canUserViewGradebook: true, courseId: course1Id },
{ type: STARTED_FETCHING_GRADES },
{ type: STARTED_FETCHING_TRACKS },
{ type: STARTED_FETCHING_COHORTS },
{ type: STARTED_FETCHING_ASSIGNMENT_TYPES },
];
const store = mockStore();
axiosMock.onGet(rolesUrl)
.replyOnce(
200,
JSON.stringify(makeRoleListObj([course1StaffRole, course2DummyRole], false)),
);
return store.dispatch(getRoles(course1Id, urlParams)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it('dispatches got_roles action and other actions after fetching irrelevent roles but user is global staff', () => {
const expectedActions = [
{ type: GOT_ROLES, canUserViewGradebook: true, courseId: course1Id },
{ type: STARTED_FETCHING_GRADES },
{ type: STARTED_FETCHING_TRACKS },
{ type: STARTED_FETCHING_COHORTS },
{ type: STARTED_FETCHING_ASSIGNMENT_TYPES },
];
const store = mockStore();
axiosMock.onGet(rolesUrl)
.replyOnce(
200,
JSON.stringify(makeRoleListObj([course1DummyRole, course2DummyRole], true)),
);
return store.dispatch(getRoles(course1Id, urlParams)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it('dispatches got_roles action and no other actions after fetching role that disallows gradebook', () => {
const expectedActions = [
{
type: GOT_ROLES, canUserViewGradebook: false, courseId: course1Id,
},
];
const store = mockStore();
axiosMock.onGet(rolesUrl)
.replyOnce(
200,
JSON.stringify(makeRoleListObj([course1DummyRole, course2StaffRole], false)),
);
return store.dispatch(getRoles(course1Id, urlParams)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it('dispatches got_roles action and no other actions after fetching empty roles', () => {
const expectedActions = [
{ type: GOT_ROLES, canUserViewGradebook: false, courseId: course1Id },
];
const store = mockStore();
axiosMock.onGet(rolesUrl)
.replyOnce(
200,
JSON.stringify(makeRoleListObj([], false)),
);
return store.dispatch(getRoles(course1Id, urlParams)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it('dispatches got_roles action and other actions after fetching empty roles but user is global staff', () => {
const expectedActions = [
{ type: GOT_ROLES, canUserViewGradebook: true, courseId: course1Id },
{ type: STARTED_FETCHING_GRADES },
{ type: STARTED_FETCHING_TRACKS },
{ type: STARTED_FETCHING_COHORTS },
{ type: STARTED_FETCHING_ASSIGNMENT_TYPES },
];
const store = mockStore();
axiosMock.onGet(rolesUrl)
.replyOnce(
200,
JSON.stringify(makeRoleListObj([], true)),
);
return store.dispatch(getRoles(course1Id, urlParams)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it('dispatches error action after getting an error when trying to get roles', () => {
const expectedActions = [
{ type: ERROR_FETCHING_ROLES },
];
const store = mockStore();
axiosMock.onGet(rolesUrl).replyOnce(400);
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,8 +18,11 @@ const fetchTracks = courseId => (
.then(response => response.data)
.then((data) => {
dispatch(gotTracks(data.course_modes));
if (hasMastersTrack(data.course_modes)) {
dispatch(fetchBulkUpgradeHistory(courseId));
}
})
.catch((error) => {
.catch(() => {
dispatch(errorFetchingTracks());
});
}

View File

@@ -0,0 +1,83 @@
import configureMockStore from 'redux-mock-store';
import MockAdapter from 'axios-mock-adapter';
import thunk from 'redux-thunk';
import apiClient from '../apiClient';
import { configuration } from '../../config';
import { fetchTracks } from './tracks';
import {
STARTED_FETCHING_TRACKS,
GOT_TRACKS,
ERROR_FETCHING_TRACKS,
} from '../constants/actionTypes/tracks';
const mockStore = configureMockStore([thunk]);
const axiosMock = new MockAdapter(apiClient);
apiClient.isAccessTokenExpired = jest.fn();
apiClient.isAccessTokenExpired.mockReturnValue(false);
describe('actions', () => {
afterEach(() => {
axiosMock.reset();
});
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 = {
course_modes: [
{
slug: 'audit',
name: 'Audit',
min_price: 0,
suggested_prices: '',
currency: 'usd',
expiration_datetime: null,
description: null,
sku: '68EFFFF',
bulk_sku: null,
},
{
slug: 'verified',
name: 'Verified Certificate',
min_price: 100,
suggested_prices: '',
currency: 'usd',
expiration_datetime: '2021-05-04T18:08:12.644361Z',
description: null,
sku: '8CF08E5',
bulk_sku: 'A5B6DBE',
}],
};
const expectedActions = [
{ type: STARTED_FETCHING_TRACKS },
{ type: GOT_TRACKS, tracks: responseData.course_modes },
];
const store = mockStore();
axiosMock.onGet(trackUrl)
.replyOnce(200, JSON.stringify(responseData));
return store.dispatch(fetchTracks(courseId)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it('dispatches failure action after fetching tracks', () => {
const expectedActions = [
{ type: STARTED_FETCHING_TRACKS },
{ type: ERROR_FETCHING_TRACKS },
];
const store = mockStore();
axiosMock.onGet(trackUrl)
.replyOnce(500, JSON.stringify({}));
return store.dispatch(fetchTracks(courseId)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
});
});

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,131 +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 = {
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 [];
},
hw: (dispatch, entry) => {
const results = [{
label: 'Username',
key: 'username',
columnSortable: true,
onSort: (direction) => {
this.setState({
grades: [...this.state.grades].sort(direction === 'desc' ? this.sortAlphaDesc : this.sortAlphaAsc),
});
},
}];
const assignmentHeadings = entry.section_breakdown
.filter(section => section.is_graded && section.label && section.category == 'Homework')
.map(s => ({
label: s.label,
key: s.label,
columnSortable: false,
onSort: (direction) => { this.sortNumerically(s.label, direction); },
}));
return results.concat(assignmentHeadings);
},
exam: (dispatch, entry) => {
const results = [{
label: 'Username',
key: 'username',
columnSortable: false,
onSort: (direction) => {
this.setState({
grades: [...this.state.grades].sort(direction === 'desc' ? this.sortAlphaDesc : this.sortAlphaAsc),
});
},
}];
const assignmentHeadings = entry.section_breakdown
.filter(section => section.is_graded && section.label && section.category == 'Exam')
.map(s => ({
label: s.label,
key: s.label,
columnSortable: false,
onSort: (direction) => { this.sortNumerically(s.label, direction); },
}));
return results.concat(assignmentHeadings);
},
};
export { headingMapper, gradeSortMap, sortAlphaAsc };
export { sortAlphaAsc, formatDateForDisplay };

View File

@@ -0,0 +1,12 @@
const STARTED_FETCHING_ASSIGNMENT_TYPES = 'STARTED_FETCHING_ASSIGNMENT_TYPES';
const GOT_ASSIGNMENT_TYPES = 'GOT_ASSIGNMENT_TYPES';
const ERROR_FETCHING_ASSIGNMENT_TYPES = 'ERROR_FETCHING_ASSIGNMENT_TYPES';
const GOT_ARE_GRADES_FROZEN = 'GOT_ARE_GRADES_FROZEN';
export {
STARTED_FETCHING_ASSIGNMENT_TYPES,
GOT_ASSIGNMENT_TYPES,
ERROR_FETCHING_ASSIGNMENT_TYPES,
GOT_ARE_GRADES_FROZEN,
};

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

@@ -0,0 +1,7 @@
const GOT_ROLES = 'GOT_ROLES';
const ERROR_FETCHING_ROLES = 'ERROR_FETCHING_ROLES';
export {
GOT_ROLES,
ERROR_FETCHING_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,48 @@
import {
STARTED_FETCHING_ASSIGNMENT_TYPES,
ERROR_FETCHING_ASSIGNMENT_TYPES,
GOT_ASSIGNMENT_TYPES,
GOT_ARE_GRADES_FROZEN,
} from '../constants/actionTypes/assignmentTypes';
const initialState = {
results: [],
startedFetching: false,
errorFetching: false,
};
const assignmentTypes = (state = initialState, action) => {
switch (action.type) {
case GOT_ASSIGNMENT_TYPES:
return {
...state,
results: action.assignmentTypes,
errorFetching: false,
finishedFetching: true,
};
case STARTED_FETCHING_ASSIGNMENT_TYPES:
return {
...state,
startedFetching: true,
};
case ERROR_FETCHING_ASSIGNMENT_TYPES:
return {
...state,
finishedFetching: true,
errorFetching: true,
};
case GOT_ARE_GRADES_FROZEN:
return {
...state,
areGradesFrozen: action.areGradesFrozen,
errorFetching: false,
finishedFetching: true,
};
default:
return state;
}
};
export default assignmentTypes;

View File

@@ -0,0 +1,68 @@
import assignmentTypes from './assignmentTypes';
import {
STARTED_FETCHING_ASSIGNMENT_TYPES,
ERROR_FETCHING_ASSIGNMENT_TYPES,
GOT_ASSIGNMENT_TYPES,
GOT_ARE_GRADES_FROZEN,
} from '../constants/actionTypes/assignmentTypes';
const initialState = {
results: [],
startedFetching: false,
errorFetching: false,
};
const assignmentTypesData = ['Exam', 'Homework'];
describe('assignmentTypes reducer', () => {
it('has initial state', () => {
expect(assignmentTypes(undefined, {})).toEqual(initialState);
});
it('updates fetch assignmentTypes request state', () => {
const expected = {
...initialState,
startedFetching: true,
};
expect(assignmentTypes(undefined, {
type: STARTED_FETCHING_ASSIGNMENT_TYPES,
})).toEqual(expected);
});
it('updates fetch assignmentTypes success state', () => {
const expected = {
...initialState,
results: assignmentTypesData,
errorFetching: false,
finishedFetching: true,
};
expect(assignmentTypes(undefined, {
type: GOT_ASSIGNMENT_TYPES,
assignmentTypes: assignmentTypesData,
})).toEqual(expected);
});
it('updates fetch assignmentTypes failure state', () => {
const expected = {
...initialState,
errorFetching: true,
finishedFetching: true,
};
expect(assignmentTypes(undefined, {
type: ERROR_FETCHING_ASSIGNMENT_TYPES,
})).toEqual(expected);
});
it('updates areGradesFrozen success state', () => {
const expected = {
...initialState,
errorFetching: false,
finishedFetching: true,
areGradesFrozen: true,
};
expect(assignmentTypes(undefined, {
type: GOT_ARE_GRADES_FROZEN,
areGradesFrozen: true,
})).toEqual(expected);
});
});

View File

@@ -17,6 +17,7 @@ const cohorts = (state = initialState, action) => {
return {
...state,
results: action.cohorts,
finishedFetching: true,
errorFetching: false,
};
case STARTED_FETCHING_COHORTS:

View File

@@ -0,0 +1,70 @@
import cohorts from './cohorts';
import {
STARTED_FETCHING_COHORTS,
ERROR_FETCHING_COHORTS,
GOT_COHORTS,
} from '../constants/actionTypes/cohorts';
const initialState = {
results: [],
startedFetching: false,
errorFetching: false,
};
const cohortsData = [
{
assignment_type: 'manual',
group_id: null,
id: 1,
name: 'default_group',
user_count: 2,
user_partition_id: null,
},
{
assignment_type: 'auto',
group_id: null,
id: 2,
name: 'auto_group',
user_count: 5,
user_partition_id: null,
}];
describe('cohorts reducer', () => {
it('has initial state', () => {
expect(cohorts(undefined, {})).toEqual(initialState);
});
it('updates fetch cohorts request state', () => {
const expected = {
...initialState,
startedFetching: true,
};
expect(cohorts(undefined, {
type: STARTED_FETCHING_COHORTS,
})).toEqual(expected);
});
it('updates fetch cohorts success state', () => {
const expected = {
...initialState,
results: cohortsData,
errorFetching: false,
finishedFetching: true,
};
expect(cohorts(undefined, {
type: GOT_COHORTS,
cohorts: cohortsData,
})).toEqual(expected);
});
it('updates fetch cohorts failure state', () => {
const expected = {
...initialState,
errorFetching: true,
finishedFetching: true,
};
expect(cohorts(undefined, {
type: ERROR_FETCHING_COHORTS,
})).toEqual(expected);
});
});

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,14 +3,29 @@ import {
ERROR_FETCHING_GRADES,
GOT_GRADES,
TOGGLE_GRADE_FORMAT,
FILTER_COLUMNS,
GRADE_UPDATE_SUCCESS,
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 +34,10 @@ const initialState = {
showSuccess: false,
prevPage: null,
nextPage: null,
showSpinner: true,
bulkManagement: {},
totalUsersCount: 0,
filteredUsersCount: 0,
};
const grades = (state = initialState, action) => {
@@ -30,16 +49,56 @@ 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,
startedFetching: true,
finishedFetching: false,
showSpinner: true,
};
case ERROR_FETCHING_GRADES:
return {
@@ -52,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

@@ -0,0 +1,164 @@
import grades, { initialGradesState as initialState } from './grades';
import {
STARTED_FETCHING_GRADES,
ERROR_FETCHING_GRADES,
GOT_GRADES,
TOGGLE_GRADE_FORMAT,
FILTER_BY_ASSIGNMENT_TYPE,
OPEN_BANNER,
} from '../constants/actionTypes/grades';
const courseId = 'course-v1:edX+DemoX+Demo_Course';
const headingsData = [
{ name: 'exam' },
{ name: 'homework2' },
];
const gradesData = [
{
course_id: courseId,
email: 'user1@example.com',
username: 'user1',
user_id: 1,
percent: 0.5,
letter_grade: null,
section_breakdown: [
{
subsection_name: 'Demo Course Overview',
score_earned: 0,
score_possible: 0,
percent: 0,
displayed_value: '0.00',
grade_description: '(0.00/0.00)',
},
{
subsection_name: 'Example Week 1: Getting Started',
score_earned: 1,
score_possible: 1,
percent: 1,
displayed_value: '1.00',
grade_description: '(0.00/0.00)',
},
],
},
{
course_id: courseId,
email: 'user22@example.com',
username: 'user22',
user_id: 22,
percent: 0,
letter_grade: null,
section_breakdown: [
{
subsection_name: 'Demo Course Overview',
score_earned: 0,
score_possible: 0,
percent: 0,
displayed_value: '0.00',
grade_description: '(0.00/0.00)',
},
{
subsection_name: 'Example Week 1: Getting Started',
score_earned: 1,
score_possible: 1,
percent: 0,
displayed_value: '0.00',
grade_description: '(0.00/0.00)',
},
],
}];
describe('grades reducer', () => {
it('has initial state', () => {
expect(grades(undefined, {})).toEqual(initialState);
});
it('updates fetch grades request state', () => {
const expected = {
...initialState,
startedFetching: true,
showSpinner: true,
};
expect(grades(undefined, {
type: STARTED_FETCHING_GRADES,
})).toEqual(expected);
});
it('updates fetch grades success state', () => {
const expectedPrev = 'testPrevUrl';
const expectedNext = 'testNextUrl';
const expectedTrack = 'verified';
const expectedCohortId = 2;
const expected = {
...initialState,
results: gradesData,
headings: headingsData,
errorFetching: false,
finishedFetching: true,
prevPage: expectedPrev,
nextPage: expectedNext,
showSpinner: false,
courseId,
totalUsersCount: 4,
filteredUsersCount: 2,
};
expect(grades(undefined, {
type: GOT_GRADES,
grades: gradesData,
headings: headingsData,
prev: expectedPrev,
next: expectedNext,
track: expectedTrack,
cohort: expectedCohortId,
showSpinner: true,
courseId,
totalUsersCount: 4,
filteredUsersCount: 2,
})).toEqual(expected);
});
it('updates toggle grade format state success', () => {
const formatTypeData = 'percent';
const expected = {
...initialState,
gradeFormat: formatTypeData,
};
expect(grades(undefined, {
type: TOGGLE_GRADE_FORMAT,
formatType: formatTypeData,
})).toEqual(expected);
});
it('updates filter columns state success', () => {
const expectedHeadings = headingsData;
const expected = {
...initialState,
headings: expectedHeadings,
};
expect(grades(undefined, {
type: FILTER_BY_ASSIGNMENT_TYPE,
headings: expectedHeadings,
})).toEqual(expected);
});
it('updates update_banner state success', () => {
const expectedShowSuccess = true;
const expected = {
...initialState,
showSuccess: expectedShowSuccess,
};
expect(grades(undefined, {
type: OPEN_BANNER,
})).toEqual(expected);
});
it('updates fetch grades failure state', () => {
const expected = {
...initialState,
errorFetching: true,
finishedFetching: true,
};
expect(grades(undefined, {
type: ERROR_FETCHING_GRADES,
})).toEqual(expected);
});
});

View File

@@ -3,11 +3,19 @@ import { combineReducers } from 'redux';
import cohorts from './cohorts';
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,
cohorts,
tracks,
assignmentTypes,
roles,
filters,
config,
});
export default rootReducer;

View File

@@ -0,0 +1,27 @@
import {
GOT_ROLES,
ERROR_FETCHING_ROLES,
} from '../constants/actionTypes/roles';
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;
}
};
export default roles;

View File

@@ -0,0 +1,47 @@
import roles from './roles';
import {
ERROR_FETCHING_ROLES,
GOT_ROLES,
} from '../constants/actionTypes/roles';
const initialState = {
canUserViewGradebook: null,
};
describe('tracks reducer', () => {
it('has initial state', () => {
expect(roles(undefined, {})).toEqual(initialState);
});
it('updates canUserViewGradebook to true', () => {
const expected = {
...initialState,
canUserViewGradebook: true,
};
expect(roles(undefined, {
type: GOT_ROLES,
canUserViewGradebook: true,
})).toEqual(expected);
});
it('updates canUserViewGradebook to false', () => {
const expected = {
...initialState,
canUserViewGradebook: false,
};
expect(roles(undefined, {
type: GOT_ROLES,
canUserViewGradebook: false,
})).toEqual(expected);
});
it('updates fetch roles failure state', () => {
const expected = {
...initialState,
canUserViewGradebook: false,
};
expect(roles(undefined, {
type: ERROR_FETCHING_ROLES,
})).toEqual(expected);
});
});

View File

@@ -18,6 +18,7 @@ const tracks = (state = initialState, action) => {
...state,
results: action.tracks,
errorFetching: false,
finishedFetching: true,
};
case STARTED_FETCHING_TRACKS:
return {

View File

@@ -0,0 +1,76 @@
import tracks from './tracks';
import {
STARTED_FETCHING_TRACKS,
ERROR_FETCHING_TRACKS,
GOT_TRACKS,
} from '../constants/actionTypes/tracks';
const initialState = {
results: [],
startedFetching: false,
errorFetching: false,
};
const tracksData = [
{
slug: 'audit',
name: 'Audit',
min_price: 0,
suggested_prices: '',
currency: 'usd',
expiration_datetime: null,
description: null,
sku: '68EFFFF',
bulk_sku: null,
},
{
slug: 'verified',
name: 'Verified Certificate',
min_price: 100,
suggested_prices: '',
currency: 'usd',
expiration_datetime: '2021-05-04T18:08:12.644361Z',
description: null,
sku: '8CF08E5',
bulk_sku: 'A5B6DBE',
}];
describe('tracks reducer', () => {
it('has initial state', () => {
expect(tracks(undefined, {})).toEqual(initialState);
});
it('updates fetch tracks request state', () => {
const expected = {
...initialState,
startedFetching: true,
};
expect(tracks(undefined, {
type: STARTED_FETCHING_TRACKS,
})).toEqual(expected);
});
it('updates fetch tracks success state', () => {
const expected = {
...initialState,
results: tracksData,
errorFetching: false,
finishedFetching: true,
};
expect(tracks(undefined, {
type: GOT_TRACKS,
tracks: tracksData,
})).toEqual(expected);
});
it('updates fetch tracks failure state', () => {
const expected = {
...initialState,
errorFetching: true,
finishedFetching: true,
};
expect(tracks(undefined, {
type: ERROR_FETCHING_TRACKS,
})).toEqual(expected);
});
});

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 = 25
static fetchGradebookData(courseId, searchText, cohort, track) {
let gradebookUrl = `${LmsApiService.baseUrl}/api/grades/v1/gradebook/${courseId}/`;
if (searchText || track || cohort) {
gradebookUrl += '?';
}
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"
}
}
]
@@ -44,9 +72,9 @@ class LmsApiService {
const gradebookUrl = `${LmsApiService.baseUrl}/api/grades/v1/gradebook/${courseId}/bulk-update`;
return apiClient.post(gradebookUrl, updateData);
}
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);
}
@@ -54,6 +82,58 @@ class LmsApiService {
const cohortsUrl = `${LmsApiService.baseUrl}/courses/${courseId}/cohorts/`;
return apiClient.get(cohortsUrl);
}
static fetchAssignmentTypes(courseId) {
const assignmentTypesUrl = `${LmsApiService.baseUrl}/api/grades/v1/gradebook/${courseId}/grading-info?graded_only=true`;
return apiClient.get(assignmentTypesUrl);
}
static fetchUserRoles(courseId) {
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

@@ -2,14 +2,99 @@ import { applyMiddleware, createStore } from 'redux';
import thunkMiddleware from 'redux-thunk';
import { composeWithDevTools } from 'redux-devtools-extension/logOnlyInProduction';
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, 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: '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: 'edx.gradebook.grades.grade_override.succeeded',
properties: {
category: trackingCategory,
label: action.courseId,
updatedGrades: action.payload.responseData,
},
})),
[GRADE_UPDATE_FAILURE]: trackEvent(action => ({
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());
const store = createStore(
reducers,
composeWithDevTools(applyMiddleware(thunkMiddleware, loggerMiddleware)),
composeWithDevTools(applyMiddleware(thunkMiddleware, loggerMiddleware, segmentMiddleware)),
);
export { trackingCategory };
export default store;

View File

@@ -3,24 +3,92 @@ import React from 'react';
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';
import Header from './components/Header';
import store from './data/store';
import FooterLogo from '../assets/edx-footer.png';
import './App.scss';
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>
<main>
<Switch>
<Route exact path="/:courseId" component={GradebookPage} />
</Switch>
</main>
</Router>
</Provider>
<IntlProvider>
<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}
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'));
},
);

7
src/postcss.config.js Normal file
View File

@@ -0,0 +1,7 @@
/* I'm here to allow autoprefixing in webpack.prod.config.js */
module.exports = {
plugins: [
require('autoprefixer')({ grid: true, browsers: ['>1%'] }),
],
};

85
src/segment.js Normal file
View File

@@ -0,0 +1,85 @@
// The code in this file is from Segment's website:
// https://segment.com/docs/sources/website/analytics.js/quickstart/
import { configuration } from './config';
(function () {
// Create a queue, but don't obliterate an existing one!
const analytics = window.analytics = window.analytics || [];
// If the real analytics.js is already on the page return.
if (analytics.initialize) return;
// If the snippet was invoked already show an error.
if (analytics.invoked) {
if (window.console && console.error) {
console.error('Segment snippet included twice.');
}
return;
}
// Invoked flag, to make sure the snippet
// is never invoked twice.
analytics.invoked = true;
// A list of the methods in Analytics.js to stub.
analytics.methods = [
'trackSubmit',
'trackClick',
'trackLink',
'trackForm',
'pageview',
'identify',
'reset',
'group',
'track',
'ready',
'alias',
'debug',
'page',
'once',
'off',
'on',
];
// Define a factory to create stubs. These are placeholders
// for methods in Analytics.js so that you never have to wait
// for it to load to actually record data. The `method` is
// stored as the first argument, so we can replay the data.
analytics.factory = function (method) {
return function () {
const args = Array.prototype.slice.call(arguments);
args.unshift(method);
analytics.push(args);
return analytics;
};
};
// For each of our methods, generate a queueing stub.
for (let i = 0; i < analytics.methods.length; i++) {
const key = analytics.methods[i];
analytics[key] = analytics.factory(key);
}
// Define a method to load Analytics.js from our CDN,
// and that will be sure to only ever load it once.
analytics.load = function (key, options) {
// Create an async script element based on your key.
const script = document.createElement('script');
script.type = 'text/javascript';
script.async = true;
script.src = `https://cdn.segment.com/analytics.js/v1/${
key}/analytics.min.js`;
// Insert our script next to the first script element.
const first = document.getElementsByTagName('script')[0];
first.parentNode.insertBefore(script, first);
analytics._loadOptions = options;
};
// Add a version to keep track of what's in the wild.
analytics.SNIPPET_VERSION = '4.1.0';
// Load Analytics.js with your key, which will automatically
// load the tools you've enabled for your account. Boosh!
analytics.load(configuration.SEGMENT_KEY);
}());

View File

@@ -4,3 +4,7 @@ import Enzyme from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
Enzyme.configure({ adapter: new Adapter() });
// These configuration values are usually set in webpack's EnvironmentPlugin however
// Jest does not use webpack so we need to set these so for testing
process.env.LMS_BASE_URL = 'http://localhost:18000';