Compare commits

...

80 Commits

Author SHA1 Message Date
Nathan Sprenkle
807a57d947 Add tests for Redux selectors (#180)
* test: add selector tests

* refactor: remove unused typeOfSelectedAssignment

* chore: bump version to 1.4.26

Co-authored-by: Ben Warzeski <bwarzeski@edx.org>
2021-05-11 14:04:05 -04:00
Sarina Canelake
0c242ab6f0 Merge pull request #177 from pdpinch/patch-1
docs: correct link to bulk grade management
2021-05-07 16:01:08 -04:00
Nathan Sprenkle
ee2c573017 Clean up MFE/Redux usage (#179)
* refactor: clean up/standardize selector usage

* fix: fix eslint errors

* chore: bump version to 1.4.25

Co-authored-by: Ben Warzeski <bwarzeski@edx.org>
2021-05-07 12:44:00 -04:00
David Joy
4fdc541992 fix: use SITE_NAME env var for index.html title (#178)
We currently have a hard-coded “edX” string in Gradebook’s index.html file.  This replaces that string with the value of the SITE_NAME environment variable, which is set at build time from .env.development for dev, and from the build process at production build-time.
2021-05-04 13:40:08 -04:00
Peter Pinch
658b45136e docs: correct link to bulk grade management
a.k.a. Override Learner Subsection Scores in Bulk
2021-05-01 11:32:07 -04:00
Jansen Kantor
61fdb31316 feat: always show total as a pct, add tooltip (#170) 2021-04-29 09:54:00 -04:00
Jansen Kantor
93f45d0784 break out status alerts into their own component (#175)
* refactor: break out status alerts into their own component
2021-04-28 18:07:00 -04:00
Ben Warzeski
6c88291626 Merge pull request #176 from muselesscreator/assignment_tests
reafactor: separate and test GradebookFilters from Gradebook component
2021-04-28 17:50:24 -04:00
Ben Warzeski
621c297f1a version 1.4.22 2021-04-28 17:48:31 -04:00
Ben Warzeski
76b349e377 clean up snapshots and tests 2021-04-28 15:48:59 -04:00
Ben Warzeski
d88475aab5 fix: revert changes for eslint editor compatibility 2021-04-28 15:37:31 -04:00
Ben Warzeski
ddad9d9513 merge cleanup 2021-04-28 14:03:00 -04:00
Ben Warzeski
9f7e29ed76 remove redirected props 2021-04-28 13:50:13 -04:00
Ben Warzeski
539202f511 fix tracks select option 2021-04-28 13:50:13 -04:00
Ben Warzeski
c42a995b11 clean up tests and propTypes 2021-04-28 13:50:13 -04:00
Ben Warzeski
78644daf26 fix filters for includeCourseRoleMembers and cohorts 2021-04-28 13:50:12 -04:00
Ben Warzeski
7fd38dbcf1 remove moved methods 2021-04-28 13:50:12 -04:00
Ben Warzeski
62aad2aa2f add default value for courseRoleMembers flag 2021-04-28 13:50:12 -04:00
Ben Warzeski
12d32efe08 group filters under GradebookFilters sub-view 2021-04-28 13:50:10 -04:00
Ben Warzeski
c60358941e add tests for StudentGroupsFilters and CourseGradeFilters 2021-04-28 13:49:39 -04:00
Ben Warzeski
1345666e53 assignment filter tests 2021-04-28 13:49:14 -04:00
Ben Warzeski
c4bd8dc416 tell lint how to read module paths 2021-04-28 13:49:14 -04:00
Ben Warzeski
83986ea994 Assignment testing breakout pt1 2021-04-28 13:49:13 -04:00
Ben Warzeski
f891f90f77 update testing env 2021-04-28 13:49:11 -04:00
Ben Warzeski
313840fa10 fix: make gradebook filters update URL 2021-04-28 13:47:15 -04:00
Nathan Sprenkle
84a7531530 Split SearchControls into separate component (#174)
* Split SearchControls into separate component

* Add testing config

* Add webpack config

* Add snapshot tests for Search Controls

* bump version and update package-lock format
2021-04-28 13:27:02 -04:00
Nathan Sprenkle
27296449b4 Gradebook Test Plan (#171)
Add basic testing setup/instructions
2021-04-28 11:21:15 -04:00
Ben Warzeski
2b37919222 Merge pull request #173 from muselesscreator/fix_filters2
Fix filters2
2021-04-21 16:42:44 -04:00
Ben Warzeski
384d6cc296 fix: all filters now update queryParams 2021-04-21 15:34:26 -04:00
Ben Warzeski
a0943b3946 updateQueryParams fix for filters 2021-04-21 14:38:42 -04:00
Jansen Kantor
8bc1fc82f2 Add Show Course Staff option and exclude all course roles by default (#168)
* Show Course Role Members

* add option to hide FilterBadge value for boolean filters

* chore: bump package to 1.4.20

Co-authored-by: Nathan Sprenkle <nsprenkle@edx.org>
2021-04-16 11:06:45 -04:00
Jansen Kantor
1c26aa1d71 fix: typo preventing display of assignment name (#169)
* fix: typo preventing display of assignment name

* bump version
2021-03-23 10:08:06 -04:00
Michael Roytman
582b6cb1c5 Merge pull request #166 from edx/mroytman/update-openedx-yaml-file
update openedx.yaml to use current best practices
2021-02-02 16:12:12 -05:00
Michael Roytman
bc04f6d86f update openedx.yaml to use current best practices 2021-02-02 15:46:57 -05:00
Kyle McCormick
84f1efefb3 Allow special access to bulk management tools (#165)
Access is configured on a per-course-run basis
via the new setting:
`BULK_MANAGEMENT_SPECIAL_ACCESS_COURSE_IDS`

TNL-7901
2021-01-25 17:20:21 -05:00
Nathan Sprenkle
e9f01ea3a3 Remove duplicate labels from assignments filter (#164)
* Remove duplicate labels from assignments filter

* Bump to v1.4.18
2021-01-21 14:31:07 -05:00
Nathan Sprenkle
100fbc08bf Update README and add PR template (#160)
* Update README.md
* Add CODEOWNERS
* Add PR template
* Add VS Code .gitignore
2021-01-14 14:27:16 -05:00
Matthew Carter
500364dc99 Merge pull request #161 from muselesscreator/update_axios
update axios and frontend-platform
2021-01-06 14:19:39 -05:00
Ben Warzeski
609c0a8d3a update axios and frontend-platform 2021-01-05 15:31:34 -05:00
Jawayria
5f81624342 Merge pull request #147 from Jawayria/update-badge
Updated the build status badge to point to travis-ci.com
2020-12-17 12:44:30 +05:00
Ben Warzeski
647ecbab75 Merge pull request #159 from muselesscreator/v1.4.16
v1.4.16
2020-12-16 15:48:46 -05:00
Ben Warzeski
de539382bd v1.4.16 2020-12-16 15:45:11 -05:00
Ben Warzeski
cc01ab0a92 Bulk mgmt tab fix (#158)
* fix bulk management tab

* fix status alert prop

* shorthand fragment to make ci happy
2020-12-16 12:47:20 -05:00
Ben Warzeski
8881e62337 Merge pull request #155 from muselesscreator/rebrand
Rebrand
2020-12-16 12:14:18 -05:00
Ben Warzeski
92e7cc39cd Filter UI css/layout clean-up 2020-12-14 16:20:43 -05:00
Ben Warzeski
d6d09205f4 update theme to branding 2020-12-14 16:19:50 -05:00
Ben Warzeski
b2e4e330bf fix footer logo 2020-12-14 16:19:10 -05:00
Ben Warzeski
d10dc54116 update npm requirements 2020-12-14 16:16:17 -05:00
Matthew Carter
15d7dcfe85 Merge pull request #156 from edx/column_header_width
fix column header width to static value 
resolves https://openedx.atlassian.net/browse/EDUCATOR-4849
2020-12-10 16:24:26 -05:00
Ben Warzeski
6717663c07 fix column header width to static value 2020-12-10 10:30:12 -05:00
Ben Warzeski
ac229ebc85 Merge pull request #154 from muselesscreator/logo_update
logo update
2020-12-03 16:25:41 -05:00
Ben Warzeski
4c481721bc logo update 2020-12-02 08:15:35 -05:00
morenol
40f52b2dc9 Follow up to #149 (#152)
* fix: Update frontend-build and frontend-platform (#145)

* fix: Update frontend-build

* Upgrade frontend-platform

* Workaround for gradebook to use PUBLIC_PATH env in paths

* fix: error with undefined PUBLIC_PATH configuration
2020-12-01 15:58:10 -05:00
Matthew Carter
a25e446998 Merge pull request #143 from open-craft/fox/SE-3221-update-README
[SE-3221] Update README and provide screenshots of functionality.
2020-11-23 12:48:15 -05:00
Matthew Carter
326ae93ed7 Merge pull request #151 from edx/jkantor/semanitc-release-17.2.3
EDUCATOR-5470: upgrade semantic-release to 17.2.3
2020-11-20 09:45:33 -05:00
jansenk
95e9b51aca fix(security): Upgrade semantic-version to 17.2.3 2020-11-19 17:11:12 -05:00
Cory Lee
4d76329946 Merge pull request #149 from edx/revert-145-lmm/build
Revert "fix: Update frontend-build and frontend-platform"
2020-11-18 13:47:04 -05:00
Cory Lee
5c565bebb0 Revert "fix: Update frontend-build and frontend-platform (#145)"
This reverts commit 677521808b.
2020-11-18 13:46:38 -05:00
Jawayria
d1ca314565 Updated the build status badge to point to travis-ci.com 2020-11-18 21:31:44 +05:00
morenol
677521808b fix: Update frontend-build and frontend-platform (#145)
* fix: Update frontend-build

* Upgrade frontend-platform

* Workaround for gradebook to use PUBLIC_PATH env in paths
2020-11-10 16:45:21 -05:00
Ben Warzeski
139a0de6a6 Merge pull request #144 from muselesscreator/locked_header_and_components
Locked header and component split-out
2020-10-22 15:12:10 -04:00
Ben Warzeski
42b4a5b3dd fix event handlers 2020-10-22 10:57:26 -04:00
Ben Warzeski
9b9c703214 remove debug testing code 2020-10-22 10:06:16 -04:00
Ben Warzeski
d46f4da9d5 table update 2020-10-22 10:05:43 -04:00
Ben Warzeski
fa3826b452 merge fixes 2020-10-22 10:02:56 -04:00
Ben Warzeski
fd3eb71820 linting 2020-10-22 09:55:28 -04:00
Ben Warzeski
3dff787b37 rebase to master 2020-10-22 09:55:17 -04:00
Ben Warzeski
ac56ab766b component work 2020-10-22 09:51:04 -04:00
Ben Warzeski
dbe3dfa323 Merge pull request #146 from muselesscreator/locked_headers_alone
lock header row and column
2020-10-22 09:29:48 -04:00
Ben Warzeski
9e0c326dfc lock header row and column 2020-10-21 17:19:26 -04:00
Fox Danger Piacenti
351bf48561 Address feedback on README. 2020-09-15 12:58:50 -05:00
Fox Danger Piacenti
30c51668c4 Update README and provide screenshots of functionality. 2020-09-14 12:46:42 -05:00
Jansen Kantor
9bc86fc4f6 display grade override history error from lms (#142)
* display grade override history error from lms

* add error constants file

* quality

* code review

* fix stuff

* npm i -S regenerator-runtime
2020-09-08 17:14:21 -04:00
Ben Warzeski
aa39fcc7e0 Merge pull request #141 from muselesscreator/req_update_v2
Requirements Update
2020-09-02 09:57:26 -04:00
Ben Warzeski
f7fcaef03a remove old webpack config 2020-08-28 10:57:32 -04:00
Ben Warzeski
6f9c051ded bump version in package.json 2020-08-26 14:11:37 -04:00
Ben Warzeski
dabd923b10 linting 2020-08-26 14:11:02 -04:00
Ben Warzeski
da60ff9f1d p1 2020-08-26 14:11:02 -04:00
Ben Warzeski
f9d5987488 Merge pull request #140 from muselesscreator/table_cell_spacing
fix gradebook table cell spacing
2020-08-12 10:17:13 -04:00
Ben Warzeski
493d5df8fa fix gradebook table cell spacing 2020-08-11 15:07:58 -04:00
114 changed files with 23166 additions and 20124 deletions

View File

@@ -1,17 +0,0 @@
{
"presets": [
[
"env",
{
"targets": {
"browsers": ["last 2 versions", "ie 11"]
}
}
],
"babel-preset-react"
],
"plugins": [
"transform-object-rest-spread",
"transform-class-properties"
]
}

35
.env Normal file
View File

@@ -0,0 +1,35 @@
NODE_ENV='production',
NODE_PATH=./src
BASE_URL=null,
LMS_BASE_URL=null,
LOGIN_URL=null,
LOGOUT_URL=null,
CSRF_TOKEN_API_PATH=null,
REFRESH_ACCESS_TOKEN_ENDPOINT=null,
DATA_API_BASE_URL=null,
SEGMENT_KEY=null,
FEATURE_FLAGS={},
ACCESS_TOKEN_COOKIE_NAME=null,
CSRF_COOKIE_NAME='csrftoken',
NEW_RELIC_APP_ID=null,
NEW_RELIC_LICENSE_KEY=null,
SITE_NAME='',
MARKETING_SITE_BASE_URL=null,
SUPPORT_URL=null,
CONTACT_URL=null,
OPEN_SOURCE_URL=null,
TERMS_OF_SERVICE_URL=null,
PRIVACY_POLICY_URL=null,
FACEBOOK_URL=null,
TWITTER_URL=null,
YOU_TUBE_URL=null,
LINKED_IN_URL=null,
REDDIT_URL=null,
APPLE_APP_STORE_URL=null,
GOOGLE_PLAY_URL=null,
ENTERPRISE_MARKETING_URL=null,
ENTERPRISE_MARKETING_UTM_SOURCE=null,
ENTERPRISE_MARKETING_UTM_CAMPAIGN=null,
ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM=null,
BULK_MANAGEMENT_SPECIAL_ACCESS_COURSE_IDS=null,

42
.env.development Normal file
View File

@@ -0,0 +1,42 @@
NODE_ENV='development'
PORT=1994
BASE_URL='localhost:1994'
LMS_BASE_URL='http://localhost:18000'
LOGIN_URL='http://localhost:18000/login'
LOGOUT_URL='http://localhost:18000/login'
LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
LOGO_POWERED_BY_OPEN_EDX_URL_SVG=https://edx-cdn.org/v3/stage/open-edx-tag.svg
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
USER_INFO_COOKIE_NAME='edx-user-info'
SITE_NAME=localhost
DATA_API_BASE_URL='http://localhost:8000'
// LMS_CLIENT_ID should match the lms DOT client application id your LMS containe
LMS_CLIENT_ID='login-service-client-id'
SEGMENT_KEY=null
FEATURE_FLAGS={}
CSRF_COOKIE_NAME='csrftoken'
MARKETING_SITE_BASE_URL='http://localhost:18000'
SUPPORT_URL='http://localhost:18000/support'
CONTACT_URL='http://localhost:18000/contact'
OPEN_SOURCE_URL='http://localhost:18000/openedx'
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'
BULK_MANAGEMENT_SPECIAL_ACCESS_COURSE_IDS=null

View File

@@ -1,27 +0,0 @@
{
"extends": "eslint-config-edx",
"parser": "babel-eslint",
"rules": {
"import/no-extraneous-dependencies": [
"error",
{
"devDependencies": [
"config/*.js",
"**/*.test.jsx",
"**/*.test.js"
]
}
],
// https://github.com/evcohen/eslint-plugin-jsx-a11y/issues/340#issuecomment-338424908
"jsx-a11y/anchor-is-valid": [ "error", {
"components": [ "Link" ],
"specialLink": [ "to" ]
}],
// 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
}
}

14
.eslintrc.js Normal file
View File

@@ -0,0 +1,14 @@
const { createConfig } = require('@edx/frontend-build');
const config = createConfig('eslint');
config.settings = {
"import/resolver": {
node: {
paths: ["src", "node_modules"],
extensions: [".js", ".jsx"],
},
},
};
module.exports = config;

6
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1,6 @@
# Code owners for frontend-app-gradebook, editable gradebook micro-frontend (MFE)
# These owners will be the default owners for everything in
# the repo. Unless a later match takes precedence, they will
# be requested for review when someone opens a pull request.
* @edx/masters-devs-gta

29
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,29 @@
**TL;DR -** [ A short summary of what this PR does and why ]
JIRA: [JIRA-XXXX](https://openedx.atlassian.net/browse/JIRA-XXXX)
**What changed?**
- [ More in depth breakdown of changes ]
- [ Peripheral things that got changed ]
- [ etc... ]
**Developer Checklist**
- [ ] Test suites passing
- [ ] Documentation and test plan updated, if applicable
- [ ] Received code-owner approving review
- [ ] Bumped version number [package.json](../package.json)
**Testing Instructions**
[ How should a reviewer test this PR? ]
**Reviewer Checklist**
Collectively, these should be completed by reviewers of this PR:
- [ ] I've done a visual code review
- [ ] I've tested the new functionality
FYI: @edx/masters-devs-gta

7
.gitignore vendored
View File

@@ -1,6 +1,5 @@
.DS_Store
.eslintcache
.idea
node_modules
npm-debug.log
coverage
@@ -12,3 +11,9 @@ dist/
### Emacs ###
*~
*.swo
*.swp
### Development environments ###
.idea
.vscode

View File

@@ -1,18 +1,60 @@
[![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)
[![Build Status](https://api.travis-ci.com/edx/frontend-app-gradebook.svg?branch=master)](https://travis-ci.com/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
# Gradebook
Please tag **@edx/educator-neem** on any PRs or issues.
Gradebook allows course staff to view, filter, and override subsection grades for a course. Additionally for Masters courses, Gradebook enables bulk management of subsection grades.
## Introduction
Jump to:
The front-end of our editable Gradebook feature.
- [Should I use Gradebook in my course?](#should-i-use-gradebook-in-my-course)
- [Quickstart](#quickstart)
## Usage
For existing documentation see:
- Basic Usage: [Review Learner Grades (read-the-docs)](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/student_progress/course_grades.html#review-learner-grades-on-the-instructor-dashboard)
- Bulk Grade Management: [Override Learner Subsection Scores in Bulk (read-the-docs)](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/student_progress/course_grades.html#override-learner-subsection-scores-in-bulk)
## Should I use Gradebook in my course?
### What does this offer over the legacy gradebook?
![A screenshot of the grade listings](documentation/screenshots/grade-listings.png)
The micro-frontend offers a great deal more granularity when searching for problems, an easy interface for editing grades, an
audit trail for seeing who edited what grade and what reason they gave (if any) for doing so.
![Screenshot of the grade editing interface](documentation/screenshots/grade-editing.png)
UsageProblems can be filtered by student as in the traditional gradebook, but can also be filtered by scores to see who
scored within a certain range, and by assignment types (note: Not problem types, but categories like Exams or
Homework).
![Screenshot of the filtering options](documentation/screenshots/grade-filtering.png)
### What does the legacy gradebook offer that this project does not?
This project does not (yet, at least) create any graphs, which the traditional gradebook does. It also does not give
quick links to the problems for the instructor to visit. It expects the instructor to be familiar with the problems they
are grading and which unit they refer to.
The gradebook is expected to be much more performant for larger numbers of students as well. The Instructor Dashboard
link for the legacy gradebook reports that "this feature is available only to courses with a small number of enrolled
learners." However, this project comes with no such warning.
### Who should not change to this gradebook?
Groups whose instructors need not ever manually override grades do not need this project, but may not be any worse off
depending on their needs. Instructors that expect to review grades infrequently enough that not having a direct link
to the problem in question will have a worse UX than the legacy gradebook provides. Instructors that rely on the graphs
generated by the current gradebook might find the lack of autogenerated graphs to be frustrating.
## Quickstart
### Installation
To install gradebook into your project:
```
@@ -32,7 +74,7 @@ Note that starting the container executes the `npm run start` script which will
## Configuring for local use in edx-platform
Assuming you've got the UI running at `http://localhost:1994`, you can configure the LMS in edx-platform
to point to your local gradebook from the instructor dashboard by putting this settings in `lms/env/private.py`:
to point to your local gradebook from the instructor dashboard by putting this setting in `lms/env/private.py`:
```
WRITABLE_GRADEBOOK_URL = 'http://localhost:1994'
```
@@ -44,10 +86,15 @@ check the ``enabled`` and ``enabled for all courses`` boxes.
2. Waffle > Switches. Add the ``grades.assume_zero_grade_if_absent`` switch and make it active.
3. Waffle_utils > Waffle flag course overrides. You want to activate this flag for any course
in which you'd like to enable the gradebook. Add a course override flag using a course id and the flag name
``grades.writable_gradebook``. Make sure to check the ``enabled`` box. Alternatively, you could add this as a
regular waffle flag to enable the gradebook for all courses.
3. Waffle_utils > Waffle flag course overrides. Activate waffle flags for courses where you want to enable Gradebook functionality:
- Enable Gradebook by adding the ``grades.writable_gradebook`` add checking the ``enabled`` box.
- Enable Bulk Grade Management by adding the ``grades.bulk_management`` flag and checking the ``enabled`` box.
Alternatively, you could add these as regular waffle flags to enable the functionality for all courses.
**NOTE:** IF the above flags are not configured correctly, the gradebook may appear to work, but will return bogus
numbers for grades. If your gradebook isn't accepting your changes, or the changes aren't resulting in sane,
recalculated grade values, verify you've set all flags correctly.
## Running tests

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

3
babel.config.js Normal file
View File

@@ -0,0 +1,3 @@
const { createConfig } = require('@edx/frontend-build');
module.exports = createConfig('babel');

View File

@@ -1,16 +0,0 @@
// This is the common Webpack config. The dev and prod Webpack configs both
// inherit config defined here.
const path = require('path');
module.exports = {
entry: {
segment: path.resolve(__dirname, '../src/segment.js'),
app: path.resolve(__dirname, '../src/index.jsx'),
},
output: {
path: path.resolve(__dirname, '../dist'),
},
resolve: {
extensions: ['.js', '.jsx'],
},
};

View File

@@ -1,147 +0,0 @@
// This is the dev Webpack config. All settings here should prefer a fast build
// time at the expense of creating larger, unoptimized bundles.
const Merge = require('webpack-merge');
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const webpack = require('webpack');
const commonConfig = require('./webpack.common.config.js');
module.exports = Merge.smart(commonConfig, {
mode: 'development',
entry: [
// enable react's custom hot dev client so we get errors reported in the browser
require.resolve('react-dev-utils/webpackHotDevClient'),
path.resolve(__dirname, '../src/segment.js'),
path.resolve(__dirname, '../src/index.jsx'),
],
module: {
// Specify file-by-file rules to Webpack. Some file-types need a particular kind of loader.
rules: [
// The babel-loader transforms newer ES2015+ syntax to older ES5 for older browsers.
// Babel is configured with the .babelrc file at the root of the project.
{
test: /\.(js|jsx)$/,
include: [
path.resolve(__dirname, '../src'),
],
loader: 'babel-loader',
options: {
// Caches result of loader to the filesystem. Future builds will attempt to read from the
// cache to avoid needing to run the expensive recompilation process on each run.
cacheDirectory: true,
},
},
// We are not extracting CSS from the javascript bundles in development because extracting
// prevents hot-reloading from working, it increases build time, and we don't care about
// flash-of-unstyled-content issues in development.
{
test: /(.scss|.css)$/,
use: [
'style-loader', // creates style nodes from JS strings
{
loader: 'css-loader', // translates CSS into CommonJS
options: {
sourceMap: true,
},
},
{
loader: 'sass-loader', // compiles Sass to CSS
options: {
sourceMap: true,
includePaths: [
path.join(__dirname, '../node_modules'),
path.join(__dirname, '../src'),
],
},
},
],
},
// Webpack, by default, uses the url-loader for images and fonts that are required/included by
// files it processes, which just base64 encodes them and inlines them in the javascript
// bundles. This makes the javascript bundles ginormous and defeats caching so we will use the
// file-loader instead to copy the files directly to the output directory.
{
test: /\.(woff2?|ttf|svg|eot)(\?v=\d+\.\d+\.\d+)?$/,
loader: 'file-loader',
},
{
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.
plugins: [
// Generates an HTML file in the output directory.
new HtmlWebpackPlugin({
inject: true, // Appends script tags linking to the webpack bundles at the end of the body
template: path.resolve(__dirname, '../public/index.html'),
}),
new webpack.EnvironmentPlugin({
NODE_ENV: 'development',
BASE_URL: 'localhost: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',
DATA_API_BASE_URL: 'http://localhost:8000',
// LMS_CLIENT_ID should match the lms DOT client application id your LMS container
LMS_CLIENT_ID: 'login-service-client-id',
SEGMENT_KEY: null,
FEATURE_FLAGS: {},
ACCESS_TOKEN_COOKIE_NAME: 'edx-jwt-cookie-header-payload',
CSRF_COOKIE_NAME: 'csrftoken',
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
// https://webpack.js.org/configuration/dev-server/#devserver-hot
new webpack.HotModuleReplacementPlugin(),
],
// This configures webpack-dev-server which serves bundles from memory and provides live
// reloading.
devServer: {
host: '0.0.0.0',
port: 1994,
historyApiFallback: true,
hot: true,
inline: true,
},
});

View File

@@ -1,149 +0,0 @@
// This is the prod Webpack config. All settings here should prefer smaller,
// optimized bundles at the expense of a longer build time.
const Merge = require('webpack-merge');
const commonConfig = require('./webpack.common.config.js');
const path = require('path');
const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = Merge.smart(commonConfig, {
mode: 'production',
devtool: 'source-map',
output: {
filename: '[name].[chunkhash].js',
path: path.resolve(__dirname, '../dist'),
},
module: {
// Specify file-by-file rules to Webpack. Some file-types need a particular kind of loader.
rules: [
// The babel-loader transforms newer ES2015+ syntax to older ES5 for older browsers.
// Babel is configured with the .babelrc file at the root of the project.
{
test: /\.(js|jsx)$/,
include: [
path.resolve(__dirname, '../src'),
],
loader: 'babel-loader',
},
// Webpack, by default, includes all CSS in the javascript bundles. Unfortunately, that means:
// a) The CSS won't be cached by browsers separately (a javascript change will force CSS
// re-download). b) Since CSS is applied asyncronously, it causes an ugly
// flash-of-unstyled-content.
//
// To avoid these problems, we extract the CSS from the bundles into separate CSS files that
// can be included as <link> tags in the HTML <head> manually.
//
// We will not do this in development because it prevents hot-reloading from working and it
// increases build time.
{
test: /(.scss|.css)$/,
use: [
MiniCssExtractPlugin.loader,
{
loader: 'css-loader', // translates CSS into CommonJS
options: {
sourceMap: true,
minimize: true,
},
},
'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
// bundles. This makes the javascript bundles ginormous and defeats caching so we will use the
// file-loader instead to copy the files directly to the output directory.
{
test: /\.(woff2?|ttf|svg|eot)(\?v=\d+\.\d+\.\d+)?$/,
loader: 'file-loader',
},
{
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
// common chunk and extract the Webpack runtime to a single runtime chunk.
optimization: {
runtimeChunk: 'single',
splitChunks: {
chunks: 'all',
},
},
// Specify additional processing or side-effects done on the Webpack output bundles as a whole.
plugins: [
// Writes the extracted CSS from each entry to a file in the output directory.
new MiniCssExtractPlugin({
filename: '[name].[chunkhash].css',
}),
// Generates an HTML file in the output directory.
new HtmlWebpackPlugin({
inject: true, // Appends script tags linking to the webpack bundles at the end of the body
template: path.resolve(__dirname, '../public/index.html'),
}),
new webpack.EnvironmentPlugin({
NODE_ENV: 'production',
BASE_URL: null,
LMS_BASE_URL: null,
LOGIN_URL: null,
LOGOUT_URL: null,
CSRF_TOKEN_API_PATH: null,
REFRESH_ACCESS_TOKEN_ENDPOINT: null,
DATA_API_BASE_URL: null,
SEGMENT_KEY: null,
FEATURE_FLAGS: {},
ACCESS_TOKEN_COOKIE_NAME: null,
CSRF_COOKIE_NAME: 'csrftoken',
NEW_RELIC_APP_ID: null,
NEW_RELIC_LICENSE_KEY: null,
SITE_NAME: null,
MARKETING_SITE_BASE_URL: null,
SUPPORT_URL: null,
CONTACT_URL: null,
OPEN_SOURCE_URL: null,
TERMS_OF_SERVICE_URL: null,
PRIVACY_POLICY_URL: null,
FACEBOOK_URL: null,
TWITTER_URL: null,
YOU_TUBE_URL: null,
LINKED_IN_URL: null,
REDDIT_URL: null,
APPLE_APP_STORE_URL: null,
GOOGLE_PLAY_URL: null,
ENTERPRISE_MARKETING_URL: null,
ENTERPRISE_MARKETING_UTM_SOURCE: null,
ENTERPRISE_MARKETING_UTM_CAMPAIGN: null,
ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM: null,
}),
],
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 KiB

View File

@@ -0,0 +1,100 @@
# Test Plan
Designed to be a catalog of major Gradebook workflows to aid in testing. This should be kept up-to-date with new feature changes.
## Quickstart
Check that the items below are complete and continue to [Workflow Tests](#workflow-tests). Otherwise, followed the detailed setup in [test-setup.md](./test-setup.md).
- [ ] Course set up with graded content.
- [ ] Gradebook & feature toggles set up for course.
- [ ] Course has a Master's track for testing Master's-only features.
- [ ] Different types of students enrolled in course (e.g. Master's, TA's).
- [ ] Gradebook running.
## Workflow Tests
Visit a course as an instructor/staff then **Instructor** tab > **Student Admin** sub-tab > click **Show Gradebook**. Should navigate to `<root-url>:1994/{course-id}`.
Confirm the following workflows:
- [ ] Grades table results can be filtered from the "Filter" panel.
- The "Edit Filters" button renders for all courses.
- Click the "Edit Filters" button to open the "Filter" panel.
- [ ] Filter panel shows the sections: Assignments, Overall Grade, Student Groups, Include Course Team Members.
- **Note:** Filters are cumulative and act with other applied filters.
- Assignments pane
- [ ] Applying the "Assignment Types" filter limits the assignment columns show in the grades table to the selected assignment types.
- [ ] Applying an "Assignment" filter shows only the selected assignment column in the grades table.
- [ ] With an "Assignment" filter already selected, setting a "Min/Max Grade" filter shows only student rows with grades for the assignment within the filtered range.
- Overall Grade pane
- [ ] Applying a "Min/Max Grade" filter shows only students with Total Course Grades within the filtered range.
- Student Groups pane
- [ ] Applying a "Tracks" filter shows only student rows matching the selected track.
- [ ] Applying a "Cohorts" filter shows only student rows matching the selected cohort.
- Include Course Team Members pane
- By default, any user with a course role (e.g. staff, beta testers, TA's) are hidden from the grades table.
- [ ] Selecting "Include Course Team Members" shows course team members in the grades table.
- [ ] Deselecting "Include Course Team Members" shows only students without course roles in the grades table.
- [ ] Users can be searched/filtered using the Search box.
- The Search Box renders for all courses.
- [ ] Entering characters into the Search Box filters students on top of already applied filters.
- Note: characters can appear anywhere in a name or email, even though emails are only shown for masters-track students. It doesn't appear that search actually works for student keys.
- [ ] Grades table "Score View" allows selecting how scores are displayed.
- [ ] The "Score View" selector renders with the options: Absolute, Percent.
- [ ] Changing the "Score View" dropdown to "Percent" shows scores as percentages in the assignment columns (note that scores can be over 100%).
- [ ] Changing the "Score View" dropdown to "Absolute" shows scores as {awarded-points}/{possible-points} values, rounded to 2 decimal points.
- [ ] For unattempted problems score shows '0'.
- [ ] For attempted problems, score always shows an {awarded-points}/{possible-points} value.
- [ ] "Total Course Grade" always shows scores as percentages (including 0% for unattempted).
- [ ] Grades table displays correctly.
- [ ] The grades table shows with columns: Username, Email, {numbered-assignments}, Total.
- [ ] Usernames appear in the "Username" column.
- [ ] Student external keys (where applicable) also appear in the "Username" column.
- [ ] Student emails appear in the "Email" column only for masters-track students.
- [ ] Assignment scores show in their respective assignment columns.
- [ ] Total course grade shows in the "Total Course Grade" column.
- [ ] Grade overrides can be applied.
- [ ] Clicking on an assignment score in the grades table opens the "Edit Grades" modal.
- [ ] "Assignment name", "Student username", "Original grade", and "Current grade" display in the modal.
- [ ] A history of grade overrides including "Date", "Grader", "Reason", and "Adjusted Grade" shows (if the subsection was previously overridden).
- [ ] An entry with the current time appears in the table with areas to enter adjusted grades and reasons for adjusting.
- Enter an "Adjusted Grade" and "Reason" for the override.
- [ ] Modal can be navigated away from by clicking outside the modal, clicking the "x" button, or hitting "Cancel".
- [ ] Clicking "Save Grade" applies the override, shows the successful "grade has been edited" banner and updates score in grades table (may take a few seconds).
- [ ] Opening back up the "Edit Grades" modal shows the change as an entry in the override history table.
- [ ] *Masters only*: "Bulk Management" allows overriding grades in bulk.
- Open a non-masters-track course.
- [ ] Verify that the "Bulk Management" tab does not appear.
- [ ] Verify that the "Bulk Management" button does not appear.
- Open a masters-track course.
- [ ] Verify that the "Bulk Management" tab appears to the right of the "Grades" tab.
- [ ] Verify that the "Bulk Management" button appears.
- Click the "Bulk Management" button. This downloads existing student/assignment info.
- [ ] Open the downloaded CSV and verify that students and assignments in the file match applied filters/searches.
- Add values in the "new_override-{subsection-short-id}" columns for student grades to be overridden and save the CSV file.
- [ ] Clicking the "Bulk Management" tab shows the Bulk Management page.
- [ ] The bulk management history table appears with columns: "Gradebook", "Download Summary", "Who", "When".
- [ ] Previous bulk management imports (if applicable) appear in the table.
- Click the "Import Grades" button and select the modified CSV file.
- [ ] Verify that the "CSV processing" banner appears.
- Wait for processing to complete and reload the page. (Can take seconds to minutes depending on environment and size of the override.)
- Navigate back to the "Bulk Management" tab.
- [ ] Verify that a new entry appears in the results table indicating how many students were affected by the bulk grade change.
- Click the "Download Summary" link to see the summary of changes from the bulk grade changes.
- [ ] Verify that students are shown with modified subsections and actions: "No Action" for unchanged users, "Success" for successful overrides.
- [ ] *Masters only*: Interventions report shows student activity in the course.
- Open a non-masters-track course.
- [ ] Verify that the "Interventions" tab does not appear.
- [ ] Verify that the "Interventions" button does not appear.
- Open a masters-track course.
- [ ] Verify that the "Interventions" tab appears to the right of the "Grades" tab.
- [ ] Verify that the "Interventions" button appears.
- Click on the "Interventions" button to generate a CSV students and activity info.
- Open the interventions report and verify student info and activity info appear.

View File

@@ -0,0 +1,52 @@
# Test Setup
Instructions for setting up environments and data for testing Gradebook.
## Set up a course with graded content
A course with graded content is the first prerequisite to testing. Use an existing course (e.g. the DemoX Demonstration Course in Devstack) or see [Building and Running an edX Course > Developing Your Course](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/developing_course/index.html) for notes on how to develop a course from scratch.
Notably, the course needs a grading policy and subsections with scoreable content.
After creating subsections with content, they need to be configured with an "Assignment Type" to be included in grading.
Suggested resources:
- [Establishing a Grading Policy For Your Course](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/grading/index.html)
- [Adding Exercises and Tools](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/grading/index.html)
- [Set the Assignment Type and Due Date for a Subsection](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/developing_course/course_subsections.html#set-the-assignment-type-and-due-date-for-a-subsection)
## Enable Gradebook and feature toggles for course
See README.md #Quickstart for more detailed instructions.
As an admin user, visit Django Admin (`{lms-url}/admin`) to modify features.
- In Grades > Persistent Grades Enabled flag, click "Add persistent grades enabled flag"
- [ ] Enable the flag globally or for the course and click "Save"
- In Django-Waffle > Switches, click "Add switch"
- [ ] Set name to `grades.assume_zero_grade_if_absent`, select "Active", and click "Save"
- In Waffle_Utils > Waffle flag course overrides:
- [ ] Add a new flag called `grades.writeable_gradebook`, select "Force On", and enable it for your course
- [ ] Add a new flag called `grades.bulk_management`, select "Force On", and enable it for your course
## Create a Master's track for testing Master's-only features
[source](https://openedx.atlassian.net/wiki/spaces/MS/pages/1453818012/Add+a+learner+into+a+master+s+track)
Add a Master's track in your course:
- As an admin user, go to Django Admin (`{lms-url}/admin`) > Course Modes and add a new course mode
- Set the Mode to "Master's"
- Set any valid price and currency values
- Click "Save"
Enroll a student in the Master's track:
- As a staff/admin user, go to `{lms-url}/support/enrollment`
- Search for the username or email of student to enroll
- In the results table row matching the user/course, click the "Change Enrollment" button
- Select the "Master's" enrollment mode and click "Submit enrollment change"
## Setup different types of students in course
To fully test features the course should have at least:
- [ ] An audit-track student
- [ ] A master's-track student
- [ ] A staff member
- [ ] A non-staff user

11
jest.config.js Normal file
View File

@@ -0,0 +1,11 @@
const { createConfig } = require('@edx/frontend-build');
module.exports = createConfig('jest', {
setupFilesAfterEnv: [
'<rootDir>/src/setupTest.js',
],
modulePaths: ['<rootDir>/src/'],
snapshotSerializers: [
'enzyme-to-json/serializer',
],
});

View File

@@ -1,13 +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
tags:
- frontend-app
- masters
oeps:
oep-2: true # Repository metadata
openedx-release: {ref: master}
owner:
type: team
team: edx/masters-devs-gta

35728
package-lock.json generated Executable file → Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +1,21 @@
{
"name": "@edx/frontend-app-gradebook",
"version": "0.1.0",
"version": "1.4.26",
"description": "edx editable gradebook-ui to manipulate grade overrides on subsections",
"repository": {
"type": "git",
"url": "git+https://github.com/edx/frontend-app-gradebook.git"
},
"scripts": {
"build": "NODE_ENV=production BABEL_ENV=production webpack --config=config/webpack.prod.config.js",
"build": "fedx-scripts webpack",
"coveralls": "cat ./coverage/lcov.info | coveralls",
"is-es5": "es-check es5 ./dist/*.js",
"lint": "eslint --ext .js --ext .jsx .",
"precommit": "npm run lint",
"lint": "fedx-scripts eslint --ext .jsx,.js src/",
"lint-fix": "fedx-scripts eslint --fix --ext .jsx,.js src/",
"prepush": "npm run lint",
"semantic-release": "semantic-release",
"start": "NODE_ENV=development BABEL_ENV=development node_modules/.bin/webpack-dev-server --config=config/webpack.dev.config.js --progress",
"test": "jest --coverage --passWithNoTests",
"start": "fedx-scripts webpack-dev-server --progress",
"test": "TZ=GMT fedx-scripts jest --coverage --passWithNoTests",
"watch-tests": "jest --watch",
"travis-deploy-once": "travis-deploy-once"
},
@@ -25,96 +26,55 @@
"access": "public"
},
"dependencies": {
"@edx/edx-bootstrap": "^0.4.3",
"@edx/frontend-auth": "^4.0.0",
"@edx/frontend-component-footer": "^4.1.5",
"@edx/paragon": "^7.1.5",
"@edx/brand": "npm:@edx/brand-edx.org@^1.3.2",
"@edx/frontend-component-footer": "10.1.1",
"@edx/frontend-platform": "1.8.1",
"@edx/paragon": "14.6.1",
"@fortawesome/fontawesome-svg-core": "^1.2.25",
"@fortawesome/free-brands-svg-icons": "^5.11.2",
"@fortawesome/free-solid-svg-icons": "^5.11.2",
"@fortawesome/react-fontawesome": "^0.1.5",
"@redux-beacon/segment": "^1.0.0",
"babel-polyfill": "^6.26.0",
"classnames": "^2.2.6",
"core-js": "3.6.5",
"email-prop-type": "^1.1.7",
"font-awesome": "^4.7.0",
"history": "^4.10.1",
"prop-types": "^15.7.2",
"query-string": "^5.1.1",
"react": "^16.10.1",
"react-dom": "^16.10.1",
"enzyme": "^3.10.0",
"enzyme-to-json": "^3.6.2",
"font-awesome": "4.7.0",
"history": "4.10.1",
"node-sass": "^4.14.1",
"prop-types": "15.7.2",
"query-string": "6.13.0",
"react": "16.13.1",
"react-dom": "16.13.1",
"react-intl": "^2.9.0",
"react-redux": "^5.1.1",
"react-router": "^4.3.1",
"react-router-dom": "^4.3.1",
"react-router": "5.2.0",
"react-router-dom": "5.2.0",
"react-router-redux": "^5.0.0-alpha.9",
"redux": "^3.7.2",
"redux": "4.0.5",
"redux-beacon": "^2.1.0",
"redux-devtools-extension": "^2.13.8",
"redux-logger": "^3.0.6",
"redux-thunk": "^2.3.0",
"redux-devtools-extension": "2.13.8",
"redux-logger": "3.0.6",
"redux-thunk": "2.3.0",
"regenerator-runtime": "^0.13.7",
"whatwg-fetch": "^2.0.4"
},
"devDependencies": {
"autoprefixer": "^9.6.1",
"@edx/frontend-build": "5.5.2",
"axios": "0.21.1",
"axios-mock-adapter": "^1.17.0",
"babel-cli": "^6.26.0",
"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.7.0",
"babel-preset-react": "^6.24.1",
"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.2.0",
"husky": "^0.14.3",
"husky": "2.7.0",
"identity-obj-proxy": "^3.0.0",
"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",
"jest": "24.9.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.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": [
"./src/setupTest.js"
],
"moduleNameMapper": {
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/__mocks__/fileMock.js",
"\\.(css|scss)$": "identity-obj-proxy"
},
"collectCoverageFrom": [
"src/**/*.{js,jsx}"
],
"coveragePathIgnorePatterns": [
"/node_modules/",
"src/setupTest.js",
"src/index.js",
"/tests/"
],
"transformIgnorePatterns": [
"/node_modules/(?!(@edx/paragon)/).*/"
],
"testURL": "http://localhost"
"semantic-release": "^17.2.3",
"travis-deploy-once": "^5.0.11"
}
}

View File

@@ -1,9 +1,10 @@
<!doctype html>
<html lang="en-us">
<head>
<title>Gradebook | edX</title>
<title>Gradebook | <%= process.env.SITE_NAME %></title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="shortcut icon" href="<%=htmlWebpackPlugin.options.FAVICON_URL%>" type="image/x-icon" />
</head>
<body>
<div id="root"></div>

View File

@@ -1,13 +1,15 @@
@import "~@edx/paragon/scss/edx/theme.scss";
// frontend-app-*/src/index.scss
@import "~@edx/brand/paragon/fonts";
@import "~@edx/brand/paragon/variables";
@import "~@edx/paragon/scss/core/core";
@import "~@edx/brand/paragon/overrides";
$fa-font-path: "~font-awesome/fonts";
@import "~font-awesome/scss/font-awesome";
$input-focus-box-shadow: $input-box-shadow; // hack to get upgrade to paragon 4.0.0 to work
@import "~@edx/frontend-component-footer/src/lib/scss/site-footer";
@import "~@edx/frontend-component-footer/dist/_footer";
@import "./components/Gradebook/gradebook";
@import "./components/Drawer/Drawer";

View File

@@ -14,11 +14,6 @@ export default class Drawer extends React.Component {
};
}
deferToNextRepaint(callback) {
window.requestAnimationFrame(() =>
window.setTimeout(callback, 0));
}
close = () => {
if (this.state.open) {
this.toggleOpen();
@@ -39,6 +34,10 @@ export default class Drawer extends React.Component {
}
};
deferToNextRepaint(callback) {
window.requestAnimationFrame(() => window.setTimeout(callback, 0));
}
render() {
return (
<div className="d-flex drawer-container">

View File

@@ -1,15 +1,16 @@
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 }) {
function FilterBadge({
name, value, onClick, showValue,
}) {
return (
<div>
<span className="badge badge-info">
<span>{`${name}: ${value}`}</span>
<span>{name}{showValue && `: ${value}`}</span>
<button type="button" className="btn-info" aria-label="Close" onClick={onClick}>
<span aria-hidden="true">&times;</span>
</button>
@@ -19,6 +20,20 @@ function FilterBadge({ name, value, onClick }) {
);
}
FilterBadge.defaultProps = {
showValue: true,
};
FilterBadge.propTypes = {
name: PropTypes.string.isRequired,
value: PropTypes.oneOfType([
PropTypes.string,
PropTypes.bool,
]).isRequired,
onClick: PropTypes.func.isRequired,
showValue: PropTypes.bool,
};
function RangeFilterBadge({
displayName,
filterName1,
@@ -27,28 +42,54 @@ function RangeFilterBadge({
filterValue2,
handleBadgeClose,
}) {
return ((filterValue1 !== initialFilters[filterName1]) ||
(filterValue2 !== initialFilters[filterName2]))
&&
return ((filterValue1 !== initialFilters[filterName1])
|| (filterValue2 !== initialFilters[filterName2]))
&& (
<FilterBadge
name={displayName}
value={`${filterValue1} - ${filterValue2}`}
onClick={handleBadgeClose}
/>;
/>
);
}
RangeFilterBadge.propTypes = {
displayName: PropTypes.string.isRequired,
filterName1: PropTypes.string.isRequired,
filterValue1: PropTypes.string.isRequired,
filterName2: PropTypes.string.isRequired,
filterValue2: PropTypes.string.isRequired,
handleBadgeClose: PropTypes.func.isRequired,
};
function SingleValueFilterBadge({
displayName, filterName, filterValue, handleBadgeClose,
displayName, filterName, filterValue, handleBadgeClose, showValue,
}) {
return (filterValue !== initialFilters[filterName]) &&
return (filterValue !== initialFilters[filterName])
&& (
<FilterBadge
name={displayName}
value={filterValue}
onClick={handleBadgeClose}
/>;
showValue={showValue}
/>
);
}
SingleValueFilterBadge.defaultProps = {
showValue: true,
};
SingleValueFilterBadge.propTypes = {
displayName: PropTypes.string.isRequired,
filterName: PropTypes.string.isRequired,
filterValue: PropTypes.oneOfType([
PropTypes.string,
PropTypes.bool,
]).isRequired,
handleBadgeClose: PropTypes.func.isRequired,
showValue: PropTypes.bool,
};
function FilterBadges({
assignment,
assignmentType,
@@ -58,6 +99,7 @@ function FilterBadges({
assignmentGradeMax,
courseGradeMin,
courseGradeMax,
includeCourseRoleMembers,
handleFilterBadgeClose,
}) {
return (
@@ -98,10 +140,17 @@ function FilterBadges({
/>
<SingleValueFilterBadge
displayName="Cohort"
filterName="track"
filterName="cohort"
filterValue={cohort}
handleBadgeClose={handleFilterBadgeClose(['cohort'])}
/>
<SingleValueFilterBadge
displayName="Including Course Team Members"
filterName="includeCourseRoleMembers"
filterValue={includeCourseRoleMembers}
showValue={false}
handleBadgeClose={handleFilterBadgeClose(['includeCourseRoleMembers'])}
/>
</div>
);
}
@@ -116,18 +165,13 @@ const mapStateToProps = state => (
assignmentGradeMax: state.filters.assignmentGradeMax,
courseGradeMin: state.filters.courseGradeMin,
courseGradeMax: state.filters.courseGradeMax,
includeCourseRoleMembers: state.filters.includeCourseRoleMembers,
}
);
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,
@@ -137,6 +181,7 @@ FilterBadges.defaultProps = {
assignmentGradeMax: initialFilters.assignmentGradeMax,
courseGradeMin: initialFilters.courseGradeMin,
courseGradeMax: initialFilters.courseGradeMax,
includeCourseRoleMembers: initialFilters.includeCourseRoleMembers,
};
FilterBadges.propTypes = {
@@ -148,6 +193,6 @@ FilterBadges.propTypes = {
assignmentGradeMax: PropTypes.string,
courseGradeMin: PropTypes.string,
courseGradeMax: PropTypes.string,
includeCourseRoleMembers: PropTypes.bool,
handleFilterBadgeClose: PropTypes.func.isRequired,
};

View File

@@ -0,0 +1,199 @@
/* eslint-disable react/sort-comp, react/button-has-type */
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import {
Button,
StatusAlert,
Table,
} from '@edx/paragon';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faDownload } from '@fortawesome/free-solid-svg-icons';
import selectors from 'data/selectors';
import { configuration } from '../../config';
import { submitFileUploadFormData } from '../../data/actions/grades';
export class BulkManagement extends React.Component {
constructor(props) {
super(props);
this.fileFormRef = React.createRef();
this.fileInputRef = React.createRef();
}
formatHistoryRow = (row) => {
const {
summaryOfRowsProcessed: {
total,
successfullyProcessed,
failed,
skipped,
},
unique_id: courseId,
originalFilename,
id,
user: username,
...rest
} = row;
const resultsText = [
`${total} Students: ${successfullyProcessed} processed`,
...(skipped > 0 ? [`${skipped} skipped`] : []),
...(failed > 0 ? [`${failed} failed`] : []),
].join(', ');
const resultsSummary = (
<a
href={`${configuration.LMS_BASE_URL}/api/bulk_grades/course/${courseId}/?error_id=${id}`}
target="_blank"
rel="noopener noreferrer"
>
<FontAwesomeIcon icon={faDownload} />
{resultsText}
</a>
);
const createWrappedCell = (text) => (<span className="wrap-text-in-cell">{text}</span>);
const filename = createWrappedCell(originalFilename);
const user = createWrappedCell(username);
return {
resultsSummary,
filename,
user,
...rest,
};
};
handleClickImportGrades = () => {
const fileInput = this.fileInputRef.current;
if (fileInput) {
fileInput.click();
}
};
handleFileInputChange = (event) => {
const fileInput = event.target;
const file = fileInput.files[0];
const form = this.fileFormRef.current;
if (file && form) {
const formData = new FormData(form);
this.props.submitFileUploadFormData(this.props.courseId, formData).then(() => {
fileInput.value = null;
});
}
};
render() {
return (
<div>
<h4>Use this feature by downloading a CSV for bulk management,
overriding grades locally, and coming back here to upload.
</h4>
<form ref={this.fileFormRef} action={this.props.gradeExportUrl} method="post">
<StatusAlert
alertType="danger"
dialog={this.props.bulkImportError}
isOpen={this.props.bulkImportError}
dismissible={false}
/>
<StatusAlert
alertType="success"
dialog="CSV processing. File uploads may take several minutes to complete"
open={this.props.uploadSuccess}
dismissible={false}
/>
<input
className="d-none"
type="file"
name="csv"
label="Upload Grade CSV"
onChange={this.handleFileInputChange}
ref={this.fileInputRef}
/>
</form>
<Button
variant="primary"
onClick={this.handleClickImportGrades}
>
Import Grades
</Button>
<p>
Results appear in the table below.<br />
Grade processing may take a few seconds.
</p>
<Table
data={this.props.bulkManagementHistory.map(this.formatHistoryRow)}
hasFixedColumnWidths
columns={[
{
key: 'filename',
label: 'Gradebook',
columnSortable: false,
width: 'col-5',
},
{
key: 'resultsSummary',
label: 'Download Summary',
columnSortable: false,
width: 'col',
},
{
key: 'user',
label: 'Who',
columnSortable: false,
width: 'col-1',
},
{
key: 'timeUploaded',
label: 'When',
columnSortable: false,
width: 'col',
},
]}
className="table-striped"
/>
</div>
);
}
}
BulkManagement.defaultProps = {
bulkImportError: '',
bulkManagementHistory: [],
courseId: '',
uploadSuccess: false,
};
BulkManagement.propTypes = {
courseId: PropTypes.string,
gradeExportUrl: PropTypes.string.isRequired,
// redux
bulkImportError: PropTypes.string,
bulkManagementHistory: PropTypes.arrayOf(PropTypes.shape({
originalFilename: PropTypes.string.isRequired,
user: PropTypes.string.isRequired,
timeUploaded: PropTypes.string.isRequired,
summaryOfRowsProcessed: PropTypes.shape({
total: PropTypes.number.isRequired,
successfullyProcessed: PropTypes.number.isRequired,
failed: PropTypes.number.isRequired,
skipped: PropTypes.number.isRequired,
}).isRequired,
})),
submitFileUploadFormData: PropTypes.func.isRequired,
uploadSuccess: PropTypes.bool,
};
export const mapStateToProps = (state) => {
const { grades } = selectors;
return {
bulkImportError: grades.bulkImportError(state),
bulkManagementHistory: grades.bulkManagementHistoryEntries(state),
uploadSuccess: grades.uploadSuccess(state),
};
};
export const mapDispatchToProps = {
submitFileUploadFormData,
};
export default connect(mapStateToProps, mapDispatchToProps)(BulkManagement);

View File

@@ -0,0 +1,90 @@
/* eslint-disable react/sort-comp, react/button-has-type */
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { StatefulButton } from '@edx/paragon';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faDownload, faSpinner } from '@fortawesome/free-solid-svg-icons';
import {
downloadBulkGradesReport,
downloadInterventionReport,
} from '../../data/actions/grades';
export class BulkManagementControls extends React.Component {
handleClickDownloadInterventions = () => {
this.props.downloadInterventionReport(this.props.courseId);
window.location = this.props.interventionExportUrl;
};
// At present, we don't store label and value in google analytics. By setting the label
// property of the below events, I want to verify that we can set the label of google anlatyics
// The following properties of a google analytics event are:
// category (used), name(used), lavel(not used), value(not used)
handleClickExportGrades = () => {
this.props.downloadBulkGradesReport(this.props.courseId);
window.location = this.props.gradeExportUrl;
};
render() {
return (
<div>
<StatefulButton
variant="outline-primary"
onClick={this.handleClickExportGrades}
state={this.props.showSpinner ? 'pending' : 'default'}
labels={{
default: 'Bulk Management',
pending: 'Bulk Management',
}}
icons={{
default: <FontAwesomeIcon className="mr-2" icon={faDownload} />,
pending: <FontAwesomeIcon className="fa-spin mr-2" icon={faSpinner} />,
}}
disabledStates={['pending']}
/>
<StatefulButton
variant="outline-primary"
onClick={this.handleClickDownloadInterventions}
state={this.props.showSpinner ? 'pending' : 'default'}
className="ml-2"
labels={{
default: 'Interventions*',
pending: 'Interventions*',
}}
icons={{
default: <FontAwesomeIcon className="mr-2" icon={faDownload} />,
pending: <FontAwesomeIcon className="fa-spin mr-2" icon={faSpinner} />,
}}
disabledStates={['pending']}
/>
</div>
);
}
}
BulkManagementControls.defaultProps = {
courseId: '',
showSpinner: false,
};
BulkManagementControls.propTypes = {
courseId: PropTypes.string,
gradeExportUrl: PropTypes.string.isRequired,
interventionExportUrl: PropTypes.string.isRequired,
showSpinner: PropTypes.bool,
// redux
downloadBulkGradesReport: PropTypes.func.isRequired,
downloadInterventionReport: PropTypes.func.isRequired,
};
export const mapStateToProps = () => ({ });
export const mapDispatchToProps = {
downloadBulkGradesReport,
downloadInterventionReport,
};
export default connect(mapStateToProps, mapDispatchToProps)(BulkManagementControls);

View File

@@ -0,0 +1,210 @@
/* eslint-disable react/sort-comp, react/button-has-type */
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import {
Button,
Modal,
StatusAlert,
Table,
} from '@edx/paragon';
import selectors from 'data/selectors';
import {
doneViewingAssignment,
updateGrades,
} from '../../data/actions/grades';
const GRADE_OVERRIDE_HISTORY_COLUMNS = [
{ label: 'Date', key: 'date' },
{ label: 'Grader', key: 'grader' },
{ label: 'Reason', key: 'reason' },
{ label: 'Adjusted grade', key: 'adjustedGrade' },
];
export class EditModal extends React.Component {
constructor(props) {
super(props);
this.overrideReasonInput = React.createRef();
}
componentDidMount() {
this.overrideReasonInput.current.focus();
}
handleAdjustedGradeClick = () => {
this.props.updateGrades(
this.props.courseId, [
{
user_id: this.props.updateUserId,
usage_id: this.props.updateModuleId,
grade: {
earned_graded_override: this.props.adjustedGradeValue,
comment: this.props.reasonForChange,
},
},
],
this.props.filterValue,
this.props.selectedCohort,
this.props.selectedTrack,
);
this.closeAssignmentModal();
}
closeAssignmentModal = () => {
this.props.doneViewingAssignment();
this.props.setGradebookState({
adjustedGradePossible: '',
adjustedGradeValue: '',
modalOpen: false,
reasonForChange: '',
updateModuleId: null,
updateUserId: null,
});
};
render() {
return (
<Modal
open={this.props.open}
title="Edit Grades"
closeText="Cancel"
body={(
<div>
<div>
<div className="grade-history-header grade-history-assignment">Assignment: </div>
<div>{this.props.assignmentName}</div>
<div className="grade-history-header grade-history-student">Student: </div>
<div>{this.props.updateUserName}</div>
<div className="grade-history-header grade-history-original-grade">Original Grade: </div>
<div>{this.props.gradeOriginalEarnedGraded}</div>
<div className="grade-history-header grade-history-current-grade">Current Grade: </div>
<div>{this.props.gradeOverrideCurrentEarnedGradedOverride}</div>
</div>
<StatusAlert
alertType="danger"
dialog={this.props.gradeOverrideHistoryError}
open={!!this.props.gradeOverrideHistoryError}
dismissible={false}
/>
{!this.props.gradeOverrideHistoryError && (
<Table
columns={GRADE_OVERRIDE_HISTORY_COLUMNS}
data={[...this.props.gradeOverrides, {
date: this.props.todaysDate,
reason: (<input
type="text"
name="reasonForChange"
value={this.props.reasonForChange}
onChange={this.props.setReasonForChange}
ref={this.overrideReasonInput}
/>),
adjustedGrade: (
<span>
<input
type="text"
name="adjustedGradeValue"
value={this.props.adjustedGradeValue}
onChange={this.props.setAdjustedGradeValue}
/>
{(this.props.adjustedGradePossible || this.props.gradeOriginalPossibleGraded) && ' / '}
{this.props.adjustedGradePossible || this.props.gradeOriginalPossibleGraded}
</span>),
}]}
/>
)}
<div>Showing most recent actions (max 5). To see more, please contact
support.
</div>
<div>Note: Once you save, your changes will be visible to students.</div>
</div>
)}
buttons={[
<Button
variant="primary"
onClick={this.handleAdjustedGradeClick}
>
Save Grade
</Button>,
]}
onClose={this.closeAssignmentModal}
/>
);
}
}
EditModal.defaultProps = {
adjustedGradeValue: null,
courseId: '',
gradeOverrideCurrentEarnedGradedOverride: null,
gradeOverrideHistoryError: '',
gradeOverrides: [],
gradeOriginalEarnedGraded: null,
gradeOriginalPossibleGraded: null,
selectedCohort: null,
selectedTrack: null,
updateModuleId: '',
updateUserId: '',
updateUserName: '',
};
EditModal.propTypes = {
courseId: PropTypes.string,
// Gradebook State
adjustedGradePossible: PropTypes.string.isRequired,
// should pick one?
adjustedGradeValue: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
assignmentName: PropTypes.string.isRequired,
filterValue: PropTypes.string.isRequired,
open: PropTypes.bool.isRequired,
reasonForChange: PropTypes.string.isRequired,
todaysDate: PropTypes.string.isRequired,
updateModuleId: PropTypes.string,
updateUserId: PropTypes.number,
updateUserName: PropTypes.string,
// Gradebook State Setters
setAdjustedGradeValue: PropTypes.func.isRequired,
setGradebookState: PropTypes.func.isRequired,
setReasonForChange: PropTypes.func.isRequired,
// redux
doneViewingAssignment: PropTypes.func.isRequired,
gradeOverrideCurrentEarnedGradedOverride: PropTypes.number,
gradeOverrideHistoryError: PropTypes.string,
gradeOverrides: PropTypes.arrayOf(PropTypes.shape({
date: PropTypes.string,
grader: PropTypes.string,
reason: PropTypes.string,
adjustedGrade: PropTypes.number,
})),
gradeOriginalEarnedGraded: PropTypes.number,
gradeOriginalPossibleGraded: PropTypes.number,
selectedCohort: PropTypes.string,
selectedTrack: PropTypes.string,
updateGrades: PropTypes.func.isRequired,
};
export const mapStateToProps = (state) => {
const { filters, grades } = selectors;
return {
gradeOverrides: grades.gradeOverrides(state),
gradeOverrideCurrentEarnedGradedOverride: grades.gradeOverrideCurrentEarnedGradedOverride(state),
gradeOverrideHistoryError: grades.gradeOverrideHistoryError(state),
gradeOriginalEarnedGraded: grades.gradeOriginalEarnedGraded(state),
gradeOriginalPossibleGraded: grades.gradeOriginalPossibleGraded(state),
selectedCohort: filters.cohort(state),
selectedTrack: filters.track(state),
};
};
export const mapDispatchToProps = {
doneViewingAssignment,
updateGrades,
};
export default connect(mapStateToProps, mapDispatchToProps)(EditModal);

View File

@@ -0,0 +1,38 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AssignmentFilter Component snapshots basic snapshot 1`] = `
<div
className="student-filters"
>
<SelectGroup
disabled={false}
id="assignment"
label="Assignment"
onChange={[MockFunction handleChange]}
options={
Array [
<option
value=""
>
All
</option>,
<option
value="assgN1"
>
assgN1
:
subLabel1
</option>,
<option
value="assgN2"
>
assgN2
:
subLabel2
</option>,
]
}
value="assgN1"
/>
</div>
`;

View File

@@ -0,0 +1,104 @@
/* eslint-disable react/sort-comp, react/button-has-type */
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import * as gradesActions from 'data/actions/grades';
import * as filterActions from 'data/actions/filters';
import selectors from 'data/selectors';
import SelectGroup from '../SelectGroup';
export class AssignmentFilter extends React.Component {
constructor(props) {
super(props);
this.handleChange = this.handleChange.bind(this);
}
handleChange(event) {
const assignment = event.target.value;
const selectedFilterOption = this.props.assignmentFilterOptions.find(assig => assig.label === assignment);
const { type, id } = selectedFilterOption || {};
const typedValue = { label: assignment, type, id };
this.props.updateAssignmentFilter(typedValue);
this.props.updateQueryParams({ assignment: id });
this.props.updateGradesIfAssignmentGradeFiltersSet(
this.props.courseId,
this.props.selectedCohort,
this.props.selectedTrack,
this.props.selectedAssignmentType,
);
}
get options() {
const mapper = ({ label, subsectionLabel }) => (
<option key={label} value={label}>
{label}: {subsectionLabel}
</option>
);
return ([
<option key="0" value="">All</option>,
...this.props.assignmentFilterOptions.map(mapper),
]);
}
render() {
return (
<div className="student-filters">
<SelectGroup
id="assignment"
label="Assignment"
value={this.props.selectedAssignment}
onChange={this.handleChange}
disabled={this.props.assignmentFilterOptions.length === 0}
options={this.options}
/>
</div>
);
}
}
AssignmentFilter.defaultProps = {
assignmentFilterOptions: [],
selectedAssignment: '',
selectedAssignmentType: '',
selectedCohort: null,
selectedTrack: null,
};
AssignmentFilter.propTypes = {
courseId: PropTypes.string.isRequired,
updateQueryParams: PropTypes.func.isRequired,
// redux
assignmentFilterOptions: PropTypes.arrayOf(PropTypes.shape({
label: PropTypes.string,
subsectionLabel: PropTypes.string,
type: PropTypes.string,
id: PropTypes.string,
})),
selectedAssignmentType: PropTypes.string,
selectedAssignment: PropTypes.string,
selectedCohort: PropTypes.string,
selectedTrack: PropTypes.string,
updateGradesIfAssignmentGradeFiltersSet: PropTypes.func.isRequired,
updateAssignmentFilter: PropTypes.func.isRequired,
};
export const mapStateToProps = (state) => {
const { filters } = selectors;
return {
assignmentFilterOptions: filters.selectableAssignmentLabels(state),
selectedAssignment: filters.selectedAssignmentLabel(state),
selectedAssignmentType: filters.assignmentType(state),
selectedCohort: filters.cohort(state),
selectedTrack: filters.track(state),
};
};
export const mapDispatchToProps = {
updateAssignmentFilter: filterActions.updateAssignmentFilter,
updateGradesIfAssignmentGradeFiltersSet: gradesActions.updateGradesIfAssignmentGradeFiltersSet,
};
export default connect(mapStateToProps, mapDispatchToProps)(AssignmentFilter);

View File

@@ -0,0 +1,171 @@
import React from 'react';
import { mount, shallow } from 'enzyme';
import selectors from 'data/selectors';
import { updateAssignmentFilter } from 'data/actions/filters';
import { updateGradesIfAssignmentGradeFiltersSet } from 'data/actions/grades';
import {
AssignmentFilter,
mapStateToProps,
mapDispatchToProps,
} from '.';
jest.mock('data/selectors', () => ({
/** Mocking to use passed state for validation purposes */
filters: {
selectableAssignmentLabels: jest.fn(() => ([{
label: 'assigNment',
subsectionLabel: 'subsection',
type: 'assignMentType',
id: 'subsectionId',
}])),
selectedAssignmentLabel: jest.fn(() => 'assigNment'),
assignmentType: jest.fn(() => 'assignMentType'),
cohort: jest.fn(() => 'COhort'),
track: jest.fn(() => 'traCK'),
},
}));
describe('AssignmentFilter', () => {
let props = {
courseId: '12345',
assignmentFilterOptions: [
{
label: 'assgN1',
subsectionLabel: 'subLabel1',
type: 'assgn_Type1',
id: 'assgn_iD1',
},
{
label: 'assgN2',
subsectionLabel: 'subLabel2',
type: 'assgn_Type2',
id: 'assgn_iD2',
},
],
selectedAssignmentType: 'assgnFilterLabel1',
selectedAssignment: 'assgN1',
selectedCohort: 'a cohort',
selectedTrack: 'a track',
};
beforeEach(() => {
props = {
...props,
updateQueryParams: jest.fn(),
updateGradesIfAssignmentGradeFiltersSet: jest.fn(),
updateAssignmentFilter: jest.fn(),
};
});
describe('Component', () => {
describe('behavior', () => {
describe('handleChange', () => {
let el;
const newAssgn = 'assgN1';
const event = { target: { value: newAssgn } };
const selected = props.assignmentFilterOptions[0];
beforeEach(() => {
el = mount(<AssignmentFilter {...props} />);
el.instance().handleChange(event);
});
it('calls props.updateAssignmentFilter with selection', () => {
expect(props.updateAssignmentFilter).toHaveBeenCalledWith({
label: newAssgn,
type: selected.type,
id: selected.id,
});
});
it('calls props.updateQueryParams with selected assignment id',
() => {
expect(props.updateQueryParams).toHaveBeenCalledWith({
assignment: selected.id,
});
});
it('calls props.updateGradesIfAssignmentGradeFiltersSet', () => {
const method = props.updateGradesIfAssignmentGradeFiltersSet;
expect(method).toHaveBeenCalledWith(
props.courseId,
props.selectedCohort,
props.selectedTrack,
props.selectedAssignmentType,
);
});
});
});
describe('snapshots', () => {
test('basic snapshot', () => {
const el = shallow(<AssignmentFilter {...props} />);
el.instance().handleChange = jest.fn().mockName('handleChange');
expect(el.instance().render()).toMatchSnapshot();
});
});
});
describe('mapStateToProps', () => {
const state = {
filters: {
assignment: { label: 'assigNment' },
assignmentType: 'assignMentType',
cohort: 'COhort',
track: 'traCK',
},
};
describe('assignmentFilterOptions', () => {
it('is selected from filters.selectableAssignmentLabels', () => {
expect(
mapStateToProps(state).assignmentFilterOptions,
).toEqual(
selectors.filters.selectableAssignmentLabels(state),
);
});
});
describe('selectedAssignment', () => {
it('is selected from filters.selectedAssignmentLabel', () => {
expect(
mapStateToProps(state).selectedAssignment,
).toEqual(
selectors.filters.selectedAssignmentLabel(state),
);
});
});
describe('selectedAssignmentType', () => {
it('is selected from filters.assignmentType', () => {
expect(
mapStateToProps(state).selectedAssignmentType,
).toEqual(
selectors.filters.assignmentType(state),
);
});
});
describe('selectedCohort', () => {
it('is selected from filters.cohort', () => {
expect(
mapStateToProps(state).selectedCohort,
).toEqual(
selectors.filters.cohort(state),
);
});
});
describe('selectedTrack', () => {
it('is selected from filters.track', () => {
expect(
mapStateToProps(state).selectedTrack,
).toEqual(
selectors.filters.track(state),
);
});
});
});
describe('mapDispatchToProps', () => {
test('updateAssignmentFilter', () => {
expect(mapDispatchToProps.updateAssignmentFilter).toEqual(
updateAssignmentFilter,
);
});
test('updateGradesIfAsssignmentGradeFiltersSet', () => {
const prop = mapDispatchToProps.updateGradesIfAssignmentGradeFiltersSet;
expect(prop).toEqual(updateGradesIfAssignmentGradeFiltersSet);
});
});
});

View File

@@ -0,0 +1,71 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AssignmentGradeFilter Component snapshots buttons and groups disabled if no selected assignment 1`] = `
<div
className="grade-filter-inputs"
>
<PercentGroup
disabled={true}
id="assignmentGradeMin"
label="Min Grade"
onChange={[MockFunction handleSetMin]}
value="1"
/>
<PercentGroup
disabled={true}
id="assignmentGradeMax"
label="Max Grade"
onChange={[MockFunction handleSetMax]}
value="100"
/>
<div
className="grade-filter-action"
>
<Button
active={false}
disabled={true}
name="assignmentGradeMinMax"
onClick={[MockFunction handleSubmit]}
type="submit"
variant="outline-secondary"
>
Apply
</Button>
</div>
</div>
`;
exports[`AssignmentGradeFilter Component snapshots smoke test 1`] = `
<div
className="grade-filter-inputs"
>
<PercentGroup
disabled={false}
id="assignmentGradeMin"
label="Min Grade"
onChange={[MockFunction handleSetMin]}
value="1"
/>
<PercentGroup
disabled={false}
id="assignmentGradeMax"
label="Max Grade"
onChange={[MockFunction handleSetMax]}
value="100"
/>
<div
className="grade-filter-action"
>
<Button
active={false}
disabled={false}
name="assignmentGradeMinMax"
onClick={[MockFunction handleSubmit]}
type="submit"
variant="outline-secondary"
>
Apply
</Button>
</div>
</div>
`;

View File

@@ -0,0 +1,125 @@
/* eslint-disable react/sort-comp, react/button-has-type */
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { Button } from '@edx/paragon';
import * as gradesActions from 'data/actions/grades';
import * as filterActions from 'data/actions/filters';
import selectors from 'data/selectors';
import PercentGroup from '../PercentGroup';
export class AssignmentGradeFilter extends React.Component {
constructor(props) {
super(props);
this.handleSubmit = this.handleSubmit.bind(this);
this.handleSetMax = this.handleSetMax.bind(this);
this.handleSetMin = this.handleSetMin.bind(this);
}
handleSubmit() {
const {
assignmentGradeMin,
assignmentGradeMax,
} = this.props.filterValues;
this.props.updateAssignmentLimits(
assignmentGradeMin,
assignmentGradeMax,
);
this.props.getUserGrades(
this.props.courseId,
this.props.selectedCohort,
this.props.selectedTrack,
this.props.selectedAssignmentType,
);
this.props.updateQueryParams({
assignmentGradeMin,
assignmentGradeMax,
});
}
handleSetMax(event) {
this.props.setFilters({ assignmentGradeMax: event.target.value });
}
handleSetMin(event) {
this.props.setFilters({ assignmentGradeMin: event.target.value });
}
render() {
return (
<div className="grade-filter-inputs">
<PercentGroup
id="assignmentGradeMin"
label="Min Grade"
value={this.props.filterValues.assignmentGradeMin}
disabled={!this.props.selectedAssignment}
onChange={this.handleSetMin}
/>
<PercentGroup
id="assignmentGradeMax"
label="Max Grade"
value={this.props.filterValues.assignmentGradeMax}
disabled={!this.props.selectedAssignment}
onChange={this.handleSetMax}
/>
<div className="grade-filter-action">
<Button
type="submit"
variant="outline-secondary"
name="assignmentGradeMinMax"
disabled={!this.props.selectedAssignment}
onClick={this.handleSubmit}
>
Apply
</Button>
</div>
</div>
);
}
}
AssignmentGradeFilter.defaultProps = {
selectedAssignment: '',
selectedAssignmentType: '',
selectedCohort: null,
selectedTrack: null,
};
AssignmentGradeFilter.propTypes = {
courseId: PropTypes.string.isRequired,
filterValues: PropTypes.shape({
assignmentGradeMin: PropTypes.string.isRequired,
assignmentGradeMax: PropTypes.string.isRequired,
}).isRequired,
setFilters: PropTypes.func.isRequired,
updateQueryParams: PropTypes.func.isRequired,
// redux
getUserGrades: PropTypes.func.isRequired,
selectedAssignmentType: PropTypes.string,
selectedAssignment: PropTypes.string,
selectedCohort: PropTypes.string,
selectedTrack: PropTypes.string,
updateAssignmentLimits: PropTypes.func.isRequired,
};
export const mapStateToProps = (state) => {
const { filters } = selectors;
return {
selectedAssignment: filters.selectedAssignmentLabel(state),
selectedAssignmentType: filters.assignmentType(state),
selectedCohort: filters.cohort(state),
selectedTrack: filters.track(state),
};
};
export const mapDispatchToProps = {
getUserGrades: gradesActions.fetchGrades,
updateAssignmentLimits: filterActions.updateAssignmentLimits,
};
export default connect(mapStateToProps, mapDispatchToProps)(AssignmentGradeFilter);

View File

@@ -0,0 +1,174 @@
import React from 'react';
import { mount, shallow } from 'enzyme';
import { updateAssignmentLimits } from 'data/actions/filters';
import { fetchGrades } from 'data/actions/grades';
import {
AssignmentGradeFilter,
mapStateToProps,
mapDispatchToProps,
} from '.';
describe('AssignmentGradeFilter', () => {
let props = {
filterValues: {
assignmentGradeMin: '1',
assignmentGradeMax: '100',
},
courseId: '12345',
selectedAssignmentType: 'assgnFilterLabel1',
selectedAssignment: 'assgN1',
selectedCohort: 'a cohort',
selectedTrack: 'a track',
};
beforeEach(() => {
props = {
...props,
setFilters: jest.fn(),
updateQueryParams: jest.fn(),
getUserGrades: jest.fn(),
updateAssignmentLimits: jest.fn(),
};
});
describe('Component', () => {
describe('behavior', () => {
describe('handleSubmit', () => {
let el;
beforeEach(() => {
el = mount(<AssignmentGradeFilter {...props} />);
el.instance().handleSubmit();
});
it('calls props.updateAssignmentLimits with min and max', () => {
expect(props.updateAssignmentLimits).toHaveBeenCalledWith(
props.filterValues.assignmentGradeMin,
props.filterValues.assignmentGradeMax,
);
});
it('calls getUserGrades w/ selection', () => {
expect(props.getUserGrades).toHaveBeenCalledWith(
props.courseId,
props.selectedCohort,
props.selectedTrack,
props.selectedAssignmentType,
);
});
it('updates queryParams with assignment grade min and max', () => {
expect(props.updateQueryParams).toHaveBeenCalledWith({
assignmentGradeMin: props.filterValues.assignmentGradeMin,
assignmentGradeMax: props.filterValues.assignmentGradeMax,
});
});
});
describe('handleSetMin', () => {
it('calls setFilters for assignmentGradeMin', () => {
const testVal = 23;
const el = mount(<AssignmentGradeFilter {...props} />);
el.instance().handleSetMin({ target: { value: testVal } });
expect(props.setFilters).toHaveBeenCalledWith({
assignmentGradeMin: testVal,
});
});
});
describe('handleSetMax', () => {
it('calls setFilters for assignmentGradeMax', () => {
const testVal = 92;
const el = mount(<AssignmentGradeFilter {...props} />);
el.instance().handleSetMax({ target: { value: testVal } });
expect(props.setFilters).toHaveBeenCalledWith({
assignmentGradeMax: testVal,
});
});
});
});
describe('snapshots', () => {
let el;
const mockMethods = () => {
el.instance().handleSubmit = jest.fn().mockName('handleSubmit');
el.instance().handleSetMax = jest.fn().mockName('handleSetMax');
el.instance().handleSetMin = jest.fn().mockName('handleSetMin');
};
test('smoke test', () => {
el = shallow(<AssignmentGradeFilter {...props} />);
mockMethods(el);
expect(el.instance().render()).toMatchSnapshot();
});
test('buttons and groups disabled if no selected assignment', () => {
el = shallow(<AssignmentGradeFilter
{...props}
selectedAssignment={undefined}
/>);
mockMethods(el);
expect(el.instance().render()).toMatchSnapshot();
});
});
});
describe('mapStateToProps', () => {
const state = {
filters: {
assignment: { label: 'assigNment' },
assignmentType: 'assignMentType',
cohort: 'COhort',
track: 'traCK',
},
};
describe('selectedAsssignment', () => {
it('is undefined if no assignment is passed', () => {
expect(
mapStateToProps({ filters: {} }).selectedAssignment,
).toEqual(undefined);
});
it('returns the label of selected assignment if there is one', () => {
expect(
mapStateToProps(state).selectedAssignment,
).toEqual(
state.filters.assignment.label,
);
});
});
describe('selectedAssignmentType', () => {
it('is drawn from state.filters.assignmentType', () => {
expect(
mapStateToProps(state).selectedAssignmentType,
).toEqual(
state.filters.assignmentType,
);
});
});
describe('selectedCohort', () => {
it('is drawn from state.filters.cohort', () => {
expect(
mapStateToProps(state).selectedCohort,
).toEqual(
state.filters.cohort,
);
});
});
describe('selectedTrack', () => {
it('is drawn from state.filters.track', () => {
expect(
mapStateToProps(state).selectedTrack,
).toEqual(
state.filters.track,
);
});
});
});
describe('mapDispatchToProps', () => {
test('getUserGrades', () => {
expect(mapDispatchToProps.getUserGrades).toEqual(
fetchGrades,
);
});
test('updateAssignmentLimits', () => {
expect(
mapDispatchToProps.updateAssignmentLimits,
).toEqual(
updateAssignmentLimits,
);
});
});
});

View File

@@ -0,0 +1,67 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AssignmentTypeFilter Component snapshots SelectGroup disabled if no assignmentFilterOptions 1`] = `
<div
className="student-filters"
>
<SelectGroup
disabled={true}
id="assignment-types"
label="Assignment Types"
onChange={[MockFunction handleChange]}
options={
Array [
<option
value=""
>
All
</option>,
<option
value="assignMentType1"
>
assignMentType1
</option>,
<option
value="AssigNmentType2"
>
AssigNmentType2
</option>,
]
}
value="assigNmentType2"
/>
</div>
`;
exports[`AssignmentTypeFilter Component snapshots smoke test 1`] = `
<div
className="student-filters"
>
<SelectGroup
disabled={false}
id="assignment-types"
label="Assignment Types"
onChange={[MockFunction handleChange]}
options={
Array [
<option
value=""
>
All
</option>,
<option
value="assignMentType1"
>
assignMentType1
</option>,
<option
value="AssigNmentType2"
>
AssigNmentType2
</option>,
]
}
value="assigNmentType2"
/>
</div>
`;

View File

@@ -0,0 +1,78 @@
/* eslint-disable react/sort-comp, react/button-has-type */
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import * as gradesActions from 'data/actions/grades';
import selectors from 'data/selectors';
import SelectGroup from '../SelectGroup';
export class AssignmentTypeFilter extends React.Component {
constructor(props) {
super(props);
this.handleChange = this.handleChange.bind(this);
}
handleChange(event) {
const assignmentType = event.target.value;
this.props.filterAssignmentType(assignmentType);
this.props.updateQueryParams({ assignmentType });
}
get options() {
const mapper = (entry) => (
<option key={entry} value={entry}>{entry}</option>
);
return [
<option key="0" value="">All</option>,
...this.props.assignmentTypes.map(mapper),
];
}
render() {
return (
<div className="student-filters">
<SelectGroup
id="assignment-types"
label="Assignment Types"
value={this.props.selectedAssignmentType}
onChange={this.handleChange}
disabled={this.props.assignmentFilterOptions.length === 0}
options={this.options}
/>
</div>
);
}
}
AssignmentTypeFilter.defaultProps = {
assignmentTypes: [],
assignmentFilterOptions: [],
selectedAssignmentType: '',
};
AssignmentTypeFilter.propTypes = {
updateQueryParams: PropTypes.func.isRequired,
// redux
assignmentTypes: PropTypes.arrayOf(PropTypes.string),
assignmentFilterOptions: PropTypes.arrayOf(PropTypes.shape({
label: PropTypes.string,
subsectionLabel: PropTypes.string,
})),
filterAssignmentType: PropTypes.func.isRequired,
selectedAssignmentType: PropTypes.string,
};
export const mapStateToProps = (state) => ({
assignmentTypes: selectors.assignmentTypes.allAssignmentTypes(state),
assignmentFilterOptions: selectors.filters.selectableAssignmentLabels(state),
selectedAssignmentType: selectors.filters.assignmentType(state),
});
export const mapDispatchToProps = {
filterAssignmentType: gradesActions.filterAssignmentType,
};
export default connect(mapStateToProps, mapDispatchToProps)(AssignmentTypeFilter);

View File

@@ -0,0 +1,135 @@
import React from 'react';
import { shallow } from 'enzyme';
import selectors from 'data/selectors';
import { filterAssignmentType } from 'data/actions/grades';
import {
AssignmentTypeFilter,
mapStateToProps,
mapDispatchToProps,
} from '.';
jest.mock('data/selectors', () => ({
/** Mocking to use passed state for validation purposes */
assignmentTypes: {
allAssignmentTypes: jest.fn(() => (['assignment', 'labs'])),
},
filters: {
selectableAssignmentLabels: jest.fn(() => ([{
label: 'assigNment',
subsectionLabel: 'subsection',
type: 'assignMentType',
id: 'subsectionId',
}])),
assignmentType: jest.fn(() => 'assignMentType'),
},
}));
describe('AssignmentTypeFilter', () => {
let props = {
assignmentTypes: ['assignMentType1', 'AssigNmentType2'],
assignmentFilterOptions: [
{ label: 'filterLabel1', subsectionLabel: 'filterSubLabel2' },
{ label: 'filterLabel2', subsectionLabel: 'filterSubLabel1' },
],
selectedAssignmentType: 'assigNmentType2',
};
beforeEach(() => {
props = {
...props,
filterAssignmentType: jest.fn(),
updateQueryParams: jest.fn(),
};
});
describe('Component', () => {
describe('behavior', () => {
describe('handleChange', () => {
let el;
const newType = 'new Type';
const event = { target: { value: newType } };
beforeEach(() => {
el = shallow(<AssignmentTypeFilter {...props} />);
el.instance().handleChange(event);
});
it('calls props.filterAssignmentType with new type', () => {
expect(props.filterAssignmentType).toHaveBeenCalledWith(
newType,
);
});
it('updates queryParams with assignmentType', () => {
expect(props.updateQueryParams).toHaveBeenCalledWith({
assignmentType: newType,
});
});
});
});
describe('snapshots', () => {
let el;
const mockMethods = () => {
el.instance().handleChange = jest.fn().mockName('handleChange');
};
test('smoke test', () => {
el = shallow(<AssignmentTypeFilter {...props} />);
mockMethods(el);
expect(el.instance().render()).toMatchSnapshot();
});
test('SelectGroup disabled if no assignmentFilterOptions', () => {
el = shallow(<AssignmentTypeFilter
{...props}
assignmentFilterOptions={[]}
/>);
mockMethods(el);
expect(el.instance().render()).toMatchSnapshot();
});
});
});
describe('mapStateToProps', () => {
const state = {
assignmentTypes: {
results: ['assignMentType1', 'assignMentType2'],
},
filters: {
assignmentType: 'selectedAssignMent',
cohort: 'selectedCOHOrt',
track: 'SELectedTrack',
},
};
describe('assignmentTypes', () => {
it('is selected from assignmentTypes.allAssignmentTypes', () => {
expect(
mapStateToProps(state).assignmentTypes,
).toEqual(
selectors.assignmentTypes.allAssignmentTypes(state),
);
});
});
describe('assignmentFilterOptions', () => {
it('is selected from filters.selectableAssignmentLabels', () => {
expect(
mapStateToProps(state).assignmentFilterOptions,
).toEqual(
selectors.filters.selectableAssignmentLabels(state),
);
});
});
describe('selectedAssignmentType', () => {
it('is selected from filters.assignmentType', () => {
expect(
mapStateToProps(state).selectedAssignmentType,
).toEqual(
selectors.filters.assignmentType(state),
);
});
});
});
describe('mapDispatchToProps', () => {
test('filterAssignmentType', () => {
expect(mapDispatchToProps.filterAssignmentType).toEqual(
filterAssignmentType,
);
});
});
});

View File

@@ -0,0 +1,34 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CourseGradeFilter Component snapshots basic snapshot 1`] = `
<React.Fragment>
<div
className="grade-filter-inputs"
>
<PercentGroup
disabled={false}
id="minimum-grade"
label="Min Grade"
onChange={[MockFunction handleUpdateMin]}
value="5"
/>
<PercentGroup
disabled={false}
id="maximum-grade"
label="Max Grade"
onChange={[MockFunction handleUpdateMax]}
value="92"
/>
</div>
<div
className="grade-filter-action"
>
<Button
onClick={[MockFunction handleApplyClick]}
variant="outline-secondary"
>
Apply
</Button>
</div>
</React.Fragment>
`;

View File

@@ -0,0 +1,136 @@
/* eslint-disable react/sort-comp, react/button-has-type, import/no-named-as-default */
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import {
Button,
} from '@edx/paragon';
import { updateCourseGradeFilter } from 'data/actions/filters';
import { fetchGrades } from 'data/actions/grades';
import selectors from 'data/selectors';
import PercentGroup from '../PercentGroup';
export class CourseGradeFilter extends React.Component {
constructor(props) {
super(props);
this.handleApplyClick = this.handleApplyClick.bind(this);
this.handleUpdateMin = this.handleUpdateMin.bind(this);
this.handleUpdateMax = this.handleUpdateMax.bind(this);
this.updateCourseGradeFilters = this.updateCourseGradeFilters.bind(this);
}
handleApplyClick() {
const { courseGradeMin, courseGradeMax } = this.props.filterValues;
const isMinValid = this.isGradeFilterValueInRange(courseGradeMin);
const isMaxValid = this.isGradeFilterValueInRange(courseGradeMax);
this.props.setFilters({
isMinCourseGradeFilterValid: isMinValid,
isMaxCourseGradeFilterValid: isMaxValid,
});
if (isMinValid && isMaxValid) {
this.updateCourseGradeFilters();
}
}
updateCourseGradeFilters() {
const { courseGradeMin, courseGradeMax } = this.props.filterValues;
this.props.updateFilter(
courseGradeMin,
courseGradeMax,
this.props.courseId,
);
this.props.getUserGrades(
this.props.courseId,
this.props.selectedCohort,
this.props.selectedTrack,
this.props.selectedAssignmentType,
{ courseGradeMin, courseGradeMax },
);
this.props.updateQueryParams({ courseGradeMin, courseGradeMax });
}
handleUpdateMin(event) {
this.props.setFilters({ courseGradeMin: event.target.value });
}
handleUpdateMax(event) {
this.props.setFilters({ courseGradeMax: event.target.value });
}
isGradeFilterValueInRange = (value) => {
const valueAsInt = parseInt(value, 10);
return valueAsInt >= 0 && valueAsInt <= 100;
};
render() {
return (
<>
<div className="grade-filter-inputs">
<PercentGroup
id="minimum-grade"
label="Min Grade"
value={this.props.filterValues.courseGradeMin}
onChange={this.handleUpdateMin}
/>
<PercentGroup
id="maximum-grade"
label="Max Grade"
value={this.props.filterValues.courseGradeMax}
onChange={this.handleUpdateMax}
/>
</div>
<div className="grade-filter-action">
<Button
variant="outline-secondary"
onClick={this.handleApplyClick}
>
Apply
</Button>
</div>
</>
);
}
}
CourseGradeFilter.defaultProps = {
courseId: '',
selectedAssignmentType: '',
selectedCohort: null,
selectedTrack: null,
};
CourseGradeFilter.propTypes = {
courseId: PropTypes.string,
filterValues: PropTypes.shape({
courseGradeMin: PropTypes.string.isRequired,
courseGradeMax: PropTypes.string.isRequired,
}).isRequired,
setFilters: PropTypes.func.isRequired,
updateQueryParams: PropTypes.func.isRequired,
// Redux
getUserGrades: PropTypes.func.isRequired,
selectedAssignmentType: PropTypes.string,
selectedCohort: PropTypes.string,
selectedTrack: PropTypes.string,
updateFilter: PropTypes.func.isRequired,
};
export const mapStateToProps = (state) => {
const { filters } = selectors;
return {
selectedCohort: filters.cohort(state),
selectedTrack: filters.track(state),
selectedAssignmentType: filters.assignmentType(state),
};
};
export const mapDispatchToProps = {
updateFilter: updateCourseGradeFilter,
getUserGrades: fetchGrades,
};
export default connect(mapStateToProps, mapDispatchToProps)(CourseGradeFilter);

View File

@@ -0,0 +1,196 @@
/* eslint-disable import/no-named-as-default */
import React from 'react';
import { shallow } from 'enzyme';
import { updateCourseGradeFilter } from 'data/actions/filters';
import { fetchGrades } from 'data/actions/grades';
import {
CourseGradeFilter,
mapStateToProps,
mapDispatchToProps,
} from '.';
jest.mock('@edx/paragon', () => ({
Button: 'Button',
Collapsible: 'Collapsible',
}));
describe('CourseGradeFilter', () => {
let props = {
filterValues: {
courseGradeMin: '5',
courseGradeMax: '92',
},
courseId: '12345',
selectedAssignmentType: 'assignMent type 1',
selectedCohort: 'COHort',
selectedTrack: 'TracK',
};
beforeEach(() => {
props = {
...props,
getUserGrades: jest.fn(),
setFilters: jest.fn(),
updateQueryParams: jest.fn(),
updateFilter: jest.fn(),
};
});
describe('Component', () => {
describe('snapshots', () => {
test('basic snapshot', () => {
const el = shallow(<CourseGradeFilter {...props} />);
el.instance().handleUpdateMin = jest.fn().mockName(
'handleUpdateMin',
);
el.instance().handleUpdateMax = jest.fn().mockName(
'handleUpdateMax',
);
el.instance().handleApplyClick = jest.fn().mockName(
'handleApplyClick',
);
expect(el.instance().render()).toMatchSnapshot();
});
});
describe('behavior', () => {
let el;
const testVal = 'TESTvalue';
beforeEach(() => {
el = shallow(<CourseGradeFilter {...props} />);
});
describe('handleApplyClick', () => {
beforeEach(() => {
el.instance().updateCourseGradeFilters = jest.fn();
});
it('calls setFilters for isMin(Max)CourseGradeFilterValid', () => {
el.instance().isGradeFilterValueInRange = jest.fn().mockImplementation(v => v >= 50);
el.instance().handleApplyClick();
expect(props.setFilters).toHaveBeenCalledWith({
isMinCourseGradeFilterValid: false,
isMaxCourseGradeFilterValid: true,
});
});
it('calls updateCourseGradeFilters only if both min and max are valid', () => {
const isValid = jest.fn().mockImplementation(v => v >= 50);
el.instance().isGradeFilterValueInRange = isValid;
el.instance().handleApplyClick();
expect(el.instance().updateCourseGradeFilters).not.toHaveBeenCalled();
isValid.mockImplementation(v => v <= 50);
el.instance().handleApplyClick();
expect(el.instance().updateCourseGradeFilters).not.toHaveBeenCalled();
isValid.mockImplementation(v => v >= 0);
el.instance().handleApplyClick();
expect(el.instance().updateCourseGradeFilters).toHaveBeenCalled();
});
});
describe('updateCourseGradeFilters', () => {
beforeEach(() => {
el.instance().updateCourseGradeFilters();
});
it('calls props.updateFilter with selection', () => {
expect(props.updateFilter).toHaveBeenCalledWith(
props.filterValues.courseGradeMin,
props.filterValues.courseGradeMax,
props.courseId,
);
});
it('calls props.getUserGrades with selection', () => {
expect(props.getUserGrades).toHaveBeenCalledWith(
props.courseId,
props.selectedCohort,
props.selectedTrack,
props.selectedAssignmentType,
{
courseGradeMin: props.filterValues.courseGradeMin,
courseGradeMax: props.filterValues.courseGradeMax,
},
);
});
it('updates query params with courseGradeMin and courseGradeMax', () => {
expect(props.updateQueryParams).toHaveBeenCalledWith({
courseGradeMin: props.filterValues.courseGradeMin,
courseGradeMax: props.filterValues.courseGradeMax,
});
});
});
describe('handleUpdateMin', () => {
it('calls props.setCourseGradeMin with event value', () => {
el.instance().handleUpdateMin(
{ target: { value: testVal } },
);
expect(props.setFilters).toHaveBeenCalledWith({
courseGradeMin: testVal,
});
});
});
describe('handleUpdateMax', () => {
it('calls props.setCourseGradeMax with event value', () => {
el.instance().handleUpdateMax(
{ target: { value: testVal } },
);
expect(props.setFilters).toHaveBeenCalledWith({
courseGradeMax: testVal,
});
});
});
describe('isFilterValueInRange', () => {
it('returns true for values between 0 and 100', () => {
expect(el.instance().isGradeFilterValueInRange('0')).toEqual(true);
expect(el.instance().isGradeFilterValueInRange(1.1)).toEqual(true);
expect(el.instance().isGradeFilterValueInRange('43')).toEqual(true);
expect(el.instance().isGradeFilterValueInRange(98.6)).toEqual(true);
expect(el.instance().isGradeFilterValueInRange(100)).toEqual(true);
});
it('returns false for values below 0 and above 100', () => {
expect(el.instance().isGradeFilterValueInRange(-1)).toEqual(false);
expect(el.instance().isGradeFilterValueInRange(101)).toEqual(false);
});
});
});
});
describe('mapStateToProps', () => {
const state = {
filters: {
cohort: 'COHort',
track: 'TRacK',
assignmentType: 'TYPe',
},
};
describe('selectedAssignmentType', () => {
test('drawn from filters.assignmentType', () => {
expect(mapStateToProps(state).selectedAssignmentType).toEqual(
state.filters.assignmentType,
);
});
});
describe('selectedCohort', () => {
test('drawn from filters.cohort', () => {
expect(mapStateToProps(state).selectedCohort).toEqual(
state.filters.cohort,
);
});
});
describe('selectedTrack', () => {
test('drawn from filters.track', () => {
expect(mapStateToProps(state).selectedTrack).toEqual(
state.filters.track,
);
});
});
});
describe('mapDispatchToProps', () => {
describe('updateFilter', () => {
test('from updateCourseGradeFilter', () => {
expect(mapDispatchToProps.updateFilter).toEqual(updateCourseGradeFilter);
});
});
describe('getUserGrades', () => {
test('from fetchGrades', () => {
expect(mapDispatchToProps.getUserGrades).toEqual(fetchGrades);
});
});
});
});

View File

@@ -0,0 +1,39 @@
/* eslint-disable react/sort-comp */
import React from 'react';
import PropTypes from 'prop-types';
import { Form } from '@edx/paragon';
const PercentGroup = ({
id,
label,
value,
disabled,
onChange,
}) => (
<div className="percent-group">
<Form.Group controlId={id}>
<Form.Label>{label}</Form.Label>
<Form.Control
type="number"
min={0}
max={100}
step={1}
{...{ value, disabled, onChange }}
/>
</Form.Group>
<span className="input-percent-label">%</span>
</div>
);
PercentGroup.defaultProps = {
disabled: false,
};
PercentGroup.propTypes = {
id: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
value: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
disabled: PropTypes.bool,
};
export default PercentGroup;

View File

@@ -0,0 +1,33 @@
import React from 'react';
import { shallow } from 'enzyme';
import PercentGroup from './PercentGroup';
describe('PercentGroup', () => {
let props = {
id: 'group id',
label: 'Group Label',
value: 'group VALUE',
disabled: false,
};
beforeEach(() => {
props = {
...props,
onChange: jest.fn().mockName('props.onChange'),
};
});
describe('Component', () => {
describe('snapshots', () => {
test('basic snapshot', () => {
const el = shallow(<PercentGroup {...props} />);
expect(el).toMatchSnapshot();
});
test('disabled', () => {
const el = shallow(<PercentGroup {...props} disabled />);
expect(el).toMatchSnapshot();
});
});
});
});

View File

@@ -0,0 +1,36 @@
/* eslint-disable react/sort-comp, react/button-has-type */
import React from 'react';
import PropTypes from 'prop-types';
import { Form } from '@edx/paragon';
const SelectGroup = ({
id,
label,
value,
onChange,
disabled,
options,
}) => (
<div className="student-filters">
<Form.Group controlId={id}>
<Form.Label>{label}</Form.Label>
<Form.Control as="select" {...{ value, onChange, disabled }}>
{options}
</Form.Control>
</Form.Group>
</div>
);
SelectGroup.propTypes = {
id: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
value: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
disabled: PropTypes.bool,
options: PropTypes.arrayOf(PropTypes.node).isRequired,
};
SelectGroup.defaultProps = {
disabled: false,
};
export default SelectGroup;

View File

@@ -0,0 +1,38 @@
import React from 'react';
import { shallow } from 'enzyme';
import SelectGroup from './SelectGroup';
describe('SelectGroup', () => {
let props = {
id: 'group id',
label: 'Group Label',
value: 'group VALUE',
disabled: false,
options: [
<option value="opt1" key="opt1">Option 1</option>,
<option value="opt2" key="opt2">Option 2</option>,
<option value="opt3" key="opt3">Option 3</option>,
],
};
beforeEach(() => {
props = {
...props,
onChange: jest.fn().mockName('props.onChange'),
};
});
describe('Component', () => {
describe('snapshots', () => {
test('basic snapshot', () => {
const el = shallow(<SelectGroup {...props} />);
expect(el).toMatchSnapshot();
});
test('disabled', () => {
const el = shallow(<SelectGroup {...props} disabled />);
expect(el).toMatchSnapshot();
});
});
});
});

View File

@@ -0,0 +1,170 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`StudentGroupsFilter Component snapshots Cohorts group disabled if no cohorts 1`] = `
<React.Fragment>
<SelectGroup
disabled={false}
id="Tracks"
label="Tracks"
onChange={[Function]}
options={
Array [
<option
value="Track-All"
>
Track-All
</option>,
<option
value="TracK1"
>
TracK1
</option>,
<option
value="TracK2"
>
TracK2
</option>,
<option
value="TRACK3"
>
TRACK3
</option>,
]
}
value="TracK2"
/>
<SelectGroup
disabled={true}
id="Cohorts"
label="Cohorts"
onChange={[Function]}
options={
Array [
<option
value="Cohort-All"
>
Cohort-All
</option>,
]
}
value="Cohorts"
/>
</React.Fragment>
`;
exports[`StudentGroupsFilter Component snapshots basic snapshot 1`] = `
<React.Fragment>
<SelectGroup
disabled={false}
id="Tracks"
label="Tracks"
onChange={[MockFunction updateTracks]}
options={
Array [
<option
value="Track-All"
>
Track-All
</option>,
<option
value="TracK1"
>
TracK1
</option>,
<option
value="TracK2"
>
TracK2
</option>,
<option
value="TRACK3"
>
TRACK3
</option>,
]
}
value="TracK2"
/>
<SelectGroup
disabled={false}
id="Cohorts"
label="Cohorts"
onChange={[MockFunction updateCohorts]}
options={
Array [
<option
value="Cohort-All"
>
Cohort-All
</option>,
<option
value="cohorT1"
>
cohorT1
</option>,
<option
value="cohorT2"
>
cohorT2
</option>,
<option
value="cohorT3"
>
cohorT3
</option>,
]
}
value="cohorT3"
/>
</React.Fragment>
`;
exports[`StudentGroupsFilter Component snapshots mapCohortsEntries cohort options: [Cohort-All, <{slug, name}...>] 1`] = `
Array [
<option
value="Cohort-All"
>
Cohort-All
</option>,
<option
value="cohorT1"
>
cohorT1
</option>,
<option
value="cohorT2"
>
cohorT2
</option>,
<option
value="cohorT3"
>
cohorT3
</option>,
]
`;
exports[`StudentGroupsFilter Component snapshots mapTracksEntries cohort options: [Track-All, <{id, name}...>] 1`] = `
Array [
<option
value="Track-All"
>
Track-All
</option>,
<option
value="TracK1"
>
TracK1
</option>,
<option
value="TracK2"
>
TracK2
</option>,
<option
value="TRACK3"
>
TRACK3
</option>,
]
`;

View File

@@ -0,0 +1,153 @@
/* eslint-disable react/sort-comp, react/button-has-type, import/no-named-as-default */
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { fetchGrades } from 'data/actions/grades';
import selectors from 'data/selectors';
import SelectGroup from '../SelectGroup';
export class StudentGroupsFilter extends React.Component {
constructor(props) {
super(props);
this.updateCohorts = this.updateCohorts.bind(this);
this.updateTracks = this.updateTracks.bind(this);
}
mapCohortsEntries = () => {
const mapper = ({ id, name }) => (
<option key={id} value={name}>{name}</option>
);
return [
<option value="Cohort-All" key="0">Cohort-All</option>,
...this.props.cohorts.map(mapper),
];
};
mapTracksEntries = () => {
const mapper = ({ slug, name }) => (
<option key={slug} value={name}>{name}</option>
);
return [
<option value="Track-All" key="0">Track-All</option>,
...this.props.tracks.map(mapper),
];
};
mapSelectedCohortEntry = () => {
const selectedCohortEntry = this.props.cohorts.find(
(x) => x.id === parseInt(this.props.selectedCohort, 10),
);
return selectedCohortEntry ? selectedCohortEntry.name : 'Cohorts';
};
mapSelectedTrackEntry = () => {
const selectedTrackEntry = this.props.tracks.find(
({ slug }) => slug === this.props.selectedTrack,
);
return selectedTrackEntry ? selectedTrackEntry.name : 'Tracks';
};
selectedTrackSlugFromEvent(event) {
const selectedTrackItem = this.props.tracks.find(
({ name }) => name === event.target.value,
);
return selectedTrackItem ? selectedTrackItem.slug : null;
}
selectedCohortIdFromEvent(event) {
const selectedCohortItem = this.props.cohorts.find(
x => x.name === event.target.value,
);
return selectedCohortItem ? selectedCohortItem.id.toString() : null;
}
updateTracks(event) {
const selectedTrackSlug = this.selectedTrackSlugFromEvent(event);
this.props.getUserGrades(
this.props.courseId,
this.props.selectedCohort,
selectedTrackSlug,
this.props.selectedAssignmentType,
);
this.props.updateQueryParams({ track: selectedTrackSlug });
}
updateCohorts(event) {
const selectedCohortId = this.selectedCohortIdFromEvent(event);
this.props.getUserGrades(
this.props.courseId,
selectedCohortId,
this.props.selectedTrack,
this.props.selectedAssignmentType,
);
this.props.updateQueryParams({ cohort: selectedCohortId });
}
render() {
return (
<>
<SelectGroup
id="Tracks"
label="Tracks"
value={this.mapSelectedTrackEntry()}
onChange={this.updateTracks}
options={this.mapTracksEntries()}
/>
<SelectGroup
id="Cohorts"
label="Cohorts"
value={this.mapSelectedCohortEntry()}
disabled={this.props.cohorts.length === 0}
onChange={this.updateCohorts}
options={this.mapCohortsEntries()}
/>
</>
);
}
}
StudentGroupsFilter.defaultProps = {
cohorts: [],
courseId: '',
selectedAssignmentType: '',
selectedCohort: null,
selectedTrack: null,
tracks: [],
};
StudentGroupsFilter.propTypes = {
courseId: PropTypes.string,
updateQueryParams: PropTypes.func.isRequired,
// redux
cohorts: PropTypes.arrayOf(PropTypes.shape({
name: PropTypes.string,
id: PropTypes.number,
})),
getUserGrades: PropTypes.func.isRequired,
selectedAssignmentType: PropTypes.string,
selectedCohort: PropTypes.string,
selectedTrack: PropTypes.string,
tracks: PropTypes.arrayOf(PropTypes.shape({
name: PropTypes.string,
slug: PropTypes.string,
})),
};
export const mapStateToProps = (state) => {
const { filters, cohorts, tracks } = selectors;
return {
cohorts: cohorts.allCohorts(state),
selectedAssignmentType: filters.assignmentType(state),
selectedCohort: filters.cohort(state),
selectedTrack: filters.track(state),
tracks: tracks.allTracks(state),
};
};
export const mapDispatchToProps = {
getUserGrades: fetchGrades,
};
export default connect(mapStateToProps, mapDispatchToProps)(StudentGroupsFilter);

View File

@@ -0,0 +1,238 @@
/* eslint-disable import/no-named-as-default */
import React from 'react';
import { shallow } from 'enzyme';
import { fetchGrades } from 'data/actions/grades';
import {
StudentGroupsFilter,
mapStateToProps,
mapDispatchToProps,
} from '.';
describe('StudentGroupsFilter', () => {
let props = {
courseId: '12345',
cohorts: [
{ name: 'cohorT1', id: 8001 },
{ name: 'cohorT2', id: 8002 },
{ name: 'cohorT3', id: 8003 },
],
selectedAssignmentType: 'assignMent type 1',
selectedCohort: '8003',
selectedTrack: 'TracK2_slug',
tracks: [
{ name: 'TracK1', slug: 'TracK1_slug' },
{ name: 'TracK2', slug: 'TracK2_slug' },
{ name: 'TRACK3', slug: 'TRACK3_slug' },
],
};
beforeEach(() => {
props = {
...props,
getUserGrades: jest.fn(),
updateQueryParams: jest.fn(),
};
});
describe('Component', () => {
describe('snapshots', () => {
let el;
beforeEach(() => {
el = shallow(<StudentGroupsFilter {...props} />);
});
test('basic snapshot', () => {
el.instance().updateTracks = jest.fn().mockName(
'updateTracks',
);
el.instance().updateCohorts = jest.fn().mockName(
'updateCohorts',
);
expect(el.instance().render()).toMatchSnapshot();
});
test('Cohorts group disabled if no cohorts', () => {
el.setProps({ cohorts: [] });
expect(el.instance().render()).toMatchSnapshot();
});
describe('mapCohortsEntries', () => {
test('cohort options: [Cohort-All, <{slug, name}...>]', () => {
expect(el.instance().mapCohortsEntries()).toMatchSnapshot();
});
});
describe('mapTracksEntries', () => {
test('cohort options: [Track-All, <{id, name}...>]', () => {
expect(el.instance().mapTracksEntries()).toMatchSnapshot();
});
});
});
describe('behavior', () => {
let el;
beforeEach(() => {
el = shallow(<StudentGroupsFilter {...props} />);
});
describe('mapSelectedCohortEntry', () => {
it('returns the name of the cohort with the same numerical id', () => {
// Because selectedCohort is the id of cohorts[2]
expect(el.instance().mapSelectedCohortEntry()).toEqual(
props.cohorts[2].name,
);
});
it('returns "Cohorts" if no cohort is found', () => {
el.setProps({ selectedCohort: '999' });
expect(el.instance().mapSelectedCohortEntry()).toEqual(
'Cohorts',
);
});
});
describe('mapSelectedTrackEntry', () => {
it('returns the name of the track with the selected slug', () => {
// Because selectedTrack is the slug of tracks[1]
expect(el.instance().mapSelectedTrackEntry()).toEqual(
props.tracks[1].name,
);
});
it('returns "Tracks" if no track is found', () => {
el.setProps({ selectedTrack: 'FAKE' });
expect(el.instance().mapSelectedTrackEntry()).toEqual(
'Tracks',
);
});
});
describe('selectedCohortIdFromEvent', () => {
it('returns the id of the cohort with the name matching the event', () => {
expect(
el.instance().selectedCohortIdFromEvent(
{ target: { value: props.cohorts[1].name } },
),
).toEqual(props.cohorts[1].id.toString());
});
it('returns null if no matching cohort is found', () => {
expect(
el.instance().selectedCohortIdFromEvent(
{ target: { value: 'FAKE' } },
),
).toEqual(null);
});
});
describe('selectedTrackSlugFromEvent', () => {
it('returns the slug of the track with the name matching the event', () => {
expect(
el.instance().selectedTrackSlugFromEvent(
{ target: { value: props.tracks[1].name } },
),
).toEqual(props.tracks[1].slug);
});
it('returns null if no matching track is found', () => {
expect(
el.instance().selectedTrackSlugFromEvent(
{ target: { value: 'FAKE' } },
),
).toEqual(null);
});
});
describe('updateTracks', () => {
const selectedSlug = 'SLUG';
beforeEach(() => {
el = shallow(<StudentGroupsFilter {...props} />);
jest.spyOn(
el.instance(),
'selectedTrackSlugFromEvent',
).mockReturnValue(selectedSlug);
el.instance().updateTracks({ target: {} });
});
it('calls getUserGrades with selection', () => {
expect(props.getUserGrades).toHaveBeenCalledWith(
props.courseId,
props.selectedCohort,
selectedSlug,
props.selectedAssignmentType,
);
});
it('updates queryParams with track value', () => {
expect(props.updateQueryParams).toHaveBeenCalledWith({
track: selectedSlug,
});
});
});
describe('updateCohorts', () => {
const selectedId = 23;
beforeEach(() => {
el = shallow(<StudentGroupsFilter {...props} />);
jest.spyOn(
el.instance(),
'selectedCohortIdFromEvent',
).mockReturnValue(selectedId);
el.instance().updateCohorts({ target: {} });
});
it('calls getUserGrades with selection', () => {
expect(props.getUserGrades).toHaveBeenCalledWith(
props.courseId,
selectedId,
props.selectedTrack,
props.selectedAssignmentType,
);
});
it('updates queryParams with cohort value', () => {
expect(props.updateQueryParams).toHaveBeenCalledWith({
cohort: selectedId,
});
});
});
});
});
describe('mapStateToProps', () => {
const state = {
cohorts: { results: ['some', 'cohorts'] },
filters: {
cohort: 'COHort',
track: 'TRacK',
assignmentType: 'TYPe',
},
tracks: { results: ['a', 'few', 'tracks'] },
};
describe('cohorts', () => {
test('drawn from cohorts.results', () => {
expect(mapStateToProps(state).cohorts).toEqual(
state.cohorts.results,
);
});
});
describe('selectedAssignmentType', () => {
test('drawn from filters.assignmentType', () => {
expect(mapStateToProps(state).selectedAssignmentType).toEqual(
state.filters.assignmentType,
);
});
});
describe('selectedCohort', () => {
test('drawn from filters.cohort', () => {
expect(mapStateToProps(state).selectedCohort).toEqual(
state.filters.cohort,
);
});
});
describe('selectedTrack', () => {
test('drawn from filters.track', () => {
expect(mapStateToProps(state).selectedTrack).toEqual(
state.filters.track,
);
});
});
describe('tracks', () => {
test('drawn from tracks.results', () => {
expect(mapStateToProps(state).tracks).toEqual(
state.tracks.results,
);
});
});
});
describe('mapDispatchToProps', () => {
describe('getUserGrades', () => {
test('from fetchGrades', () => {
expect(mapDispatchToProps.getUserGrades).toEqual(fetchGrades);
});
});
});
});

View File

@@ -0,0 +1,71 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PercentGroup Component snapshots basic snapshot 1`] = `
<div
className="percent-group"
>
<FormGroup
as="div"
controlId="group id"
isInvalid={false}
isValid={false}
>
<FormLabel
isInline={false}
>
Group Label
</FormLabel>
<ForwardRef
as="input"
disabled={false}
max={100}
min={0}
onChange={[MockFunction props.onChange]}
plaintext={false}
step={1}
type="number"
value="group VALUE"
/>
</FormGroup>
<span
className="input-percent-label"
>
%
</span>
</div>
`;
exports[`PercentGroup Component snapshots disabled 1`] = `
<div
className="percent-group"
>
<FormGroup
as="div"
controlId="group id"
isInvalid={false}
isValid={false}
>
<FormLabel
isInline={false}
>
Group Label
</FormLabel>
<ForwardRef
as="input"
disabled={true}
max={100}
min={0}
onChange={[MockFunction props.onChange]}
plaintext={false}
step={1}
type="number"
value="group VALUE"
/>
</FormGroup>
<span
className="input-percent-label"
>
%
</span>
</div>
`;

View File

@@ -0,0 +1,91 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SelectGroup Component snapshots basic snapshot 1`] = `
<div
className="student-filters"
>
<FormGroup
as="div"
controlId="group id"
isInvalid={false}
isValid={false}
>
<FormLabel
isInline={false}
>
Group Label
</FormLabel>
<ForwardRef
as="select"
disabled={false}
onChange={[MockFunction props.onChange]}
plaintext={false}
value="group VALUE"
>
<option
key="opt1"
value="opt1"
>
Option 1
</option>
<option
key="opt2"
value="opt2"
>
Option 2
</option>
<option
key="opt3"
value="opt3"
>
Option 3
</option>
</ForwardRef>
</FormGroup>
</div>
`;
exports[`SelectGroup Component snapshots disabled 1`] = `
<div
className="student-filters"
>
<FormGroup
as="div"
controlId="group id"
isInvalid={false}
isValid={false}
>
<FormLabel
isInline={false}
>
Group Label
</FormLabel>
<ForwardRef
as="select"
disabled={true}
onChange={[MockFunction props.onChange]}
plaintext={false}
value="group VALUE"
>
<option
key="opt1"
value="opt1"
>
Option 1
</option>
<option
key="opt2"
value="opt2"
>
Option 2
</option>
<option
key="opt3"
value="opt3"
>
Option 3
</option>
</ForwardRef>
</FormGroup>
</div>
`;

View File

@@ -0,0 +1,75 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`GradebookFilters Component snapshots basic snapshot 1`] = `
<React.Fragment>
<Collapsible
className="filter-group mb-3"
defaultOpen={true}
title="Assignments"
>
<div>
<Connect(AssignmentTypeFilter)
updateQueryParams={[MockFunction]}
/>
<Connect(AssignmentFilter)
courseId="12345"
updateQueryParams={[MockFunction]}
/>
<Connect(AssignmentGradeFilter)
courseId="12345"
filterValues={
Object {
"assignmentGradeMax": "90",
"assignmentGradeMin": "10",
"courseGradeMax": "80",
"courseGradeMin": "20",
}
}
setFilters={[MockFunction]}
updateQueryParams={[MockFunction]}
/>
</div>
</Collapsible>
<Collapsible
className="filter-group mb-3"
defaultOpen={true}
title="Overall Grade"
>
<Connect(CourseGradeFilter)
courseId="12345"
filterValues={
Object {
"assignmentGradeMax": "90",
"assignmentGradeMin": "10",
"courseGradeMax": "80",
"courseGradeMin": "20",
}
}
setFilters={[MockFunction]}
updateQueryParams={[MockFunction]}
/>
</Collapsible>
<Collapsible
className="filter-group mb-3"
defaultOpen={true}
title="Student Groups"
>
<Connect(StudentGroupsFilter)
courseId="12345"
updateQueryParams={[MockFunction]}
/>
</Collapsible>
<Collapsible
className="filter-group mb-3"
defaultOpen={true}
title="Include Course Team Members"
>
<Checkbox
checked={true}
onChange={[MockFunction handleIncludeTeamMembersChange]}
>
Include Course Team Members
</Checkbox>
</Collapsible>
</React.Fragment>
`;

View File

@@ -0,0 +1,120 @@
/* eslint-disable react/sort-comp, import/no-named-as-default */
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { Collapsible, Form } from '@edx/paragon';
import * as filterActions from 'data/actions/filters';
import selectors from 'data/selectors';
import AssignmentTypeFilter from './AssignmentTypeFilter';
import AssignmentFilter from './AssignmentFilter';
import AssignmentGradeFilter from './AssignmentGradeFilter';
import CourseGradeFilter from './CourseGradeFilter';
import StudentGroupsFilter from './StudentGroupsFilter';
export class GradebookFilters extends React.Component {
constructor(props) {
super(props);
this.state = {
includeCourseRoleMembers: this.props.includeCourseRoleMembers,
};
this.handleIncludeTeamMembersChange = this.handleIncludeTeamMembersChange.bind(this);
}
handleIncludeTeamMembersChange(event) {
const includeCourseRoleMembers = event.target.checked;
this.setState({ includeCourseRoleMembers });
this.props.updateIncludeCourseRoleMembers(includeCourseRoleMembers);
this.props.updateQueryParams({ includeCourseRoleMembers });
}
collapsibleGroup = (title, content) => (
<Collapsible title={title} defaultOpen className="filter-group mb-3">
{content}
</Collapsible>
);
render() {
const {
courseId,
filterValues,
setFilters,
updateQueryParams,
} = this.props;
return (
<>
{this.collapsibleGroup('Assignments', (
<div>
<AssignmentTypeFilter
updateQueryParams={updateQueryParams}
/>
<AssignmentFilter
courseId={courseId}
updateQueryParams={updateQueryParams}
/>
<AssignmentGradeFilter
{...{
courseId,
filterValues,
setFilters,
updateQueryParams,
}}
/>
</div>
))}
{this.collapsibleGroup('Overall Grade', (
<CourseGradeFilter
{...{
filterValues,
setFilters,
courseId,
updateQueryParams,
}}
/>
))}
{this.collapsibleGroup('Student Groups', (
<StudentGroupsFilter
courseId={courseId}
updateQueryParams={updateQueryParams}
/>
))}
{this.collapsibleGroup('Include Course Team Members', (
<Form.Checkbox
checked={this.state.includeCourseRoleMembers}
onChange={this.handleIncludeTeamMembersChange}
>
Include Course Team Members
</Form.Checkbox>
))}
</>
);
}
}
GradebookFilters.defaultProps = {
includeCourseRoleMembers: false,
};
GradebookFilters.propTypes = {
courseId: PropTypes.string.isRequired,
filterValues: PropTypes.shape({
assignmentGradeMin: PropTypes.string,
assignmentGradeMax: PropTypes.string,
courseGradeMin: PropTypes.string,
courseGradeMax: PropTypes.string,
}).isRequired,
setFilters: PropTypes.func.isRequired,
includeCourseRoleMembers: PropTypes.bool,
updateIncludeCourseRoleMembers: PropTypes.func.isRequired,
updateQueryParams: PropTypes.func.isRequired,
};
export const mapStateToProps = (state) => ({
includeCourseRoleMembers: selectors.filters.includeCourseRoleMembers(state),
});
export const mapDispatchToProps = {
updateIncludeCourseRoleMembers: filterActions.updateIncludeCourseRoleMembers,
};
export default connect(mapStateToProps, mapDispatchToProps)(GradebookFilters);

View File

@@ -0,0 +1,105 @@
import React from 'react';
import { shallow } from 'enzyme';
import { updateIncludeCourseRoleMembers } from 'data/actions/filters';
import {
GradebookFilters,
mapStateToProps,
mapDispatchToProps,
} from '.';
jest.mock('@edx/paragon', () => ({
Collapsible: 'Collapsible',
Form: {
Checkbox: 'Checkbox',
},
}));
describe('GradebookFilters', () => {
let props = {
courseId: '12345',
filterValues: {
assignmentGradeMin: '10',
assignmentGradeMax: '90',
courseGradeMin: '20',
courseGradeMax: '80',
},
includeCourseRoleMembers: true,
};
beforeEach(() => {
props = {
...props,
updateQueryParams: jest.fn(),
updateIncludeCourseRoleMembers: jest.fn(),
setFilters: jest.fn(),
};
});
describe('Component', () => {
describe('behavior', () => {
describe('handleIncludeTeamMembersChange', () => {
let el;
beforeEach(() => {
el = shallow(<GradebookFilters {...props} />);
el.instance().setState = jest.fn();
});
it('calls setState with newVal', () => {
el.instance().handleIncludeTeamMembersChange(
{ target: { checked: true } },
);
expect(
el.instance().setState,
).toHaveBeenCalledWith({ includeCourseRoleMembers: true });
});
it('calls props.updateIncludeCourseRoleMembers with newVal', () => {
el.instance().handleIncludeTeamMembersChange(
{ target: { checked: false } },
);
expect(
props.updateIncludeCourseRoleMembers,
).toHaveBeenCalledWith(false);
});
it('calls props.updateQueryParams with newVal', () => {
el.instance().handleIncludeTeamMembersChange(
{ target: { checked: true } },
);
expect(
props.updateQueryParams,
).toHaveBeenCalledWith({ includeCourseRoleMembers: true });
});
});
});
describe('snapshots', () => {
test('basic snapshot', () => {
const el = shallow(<GradebookFilters {...props} />);
el.instance().handleIncludeTeamMembersChange = jest.fn().mockName(
'handleIncludeTeamMembersChange',
);
expect(el.instance().render()).toMatchSnapshot();
});
});
});
describe('mapStateToProps', () => {
const state = {
filters: {
includeCourseRoleMembers: 'plz do',
},
};
describe('includeCourseRoleMembers', () => {
it('is drawn from filters.includeCourseRoleMembers', () => {
expect(mapStateToProps(state).includeCourseRoleMembers).toEqual(
state.filters.includeCourseRoleMembers,
);
});
});
});
describe('mapDispatchToProps', () => {
test('updateIncludeCourseRoleMembers', () => {
expect(mapDispatchToProps.updateIncludeCourseRoleMembers).toEqual(
updateIncludeCourseRoleMembers,
);
});
});
});

View File

@@ -0,0 +1,234 @@
/* eslint-disable react/sort-comp, react/button-has-type */
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import {
Table, OverlayTrigger, Tooltip, Icon,
} from '@edx/paragon';
import { formatDateForDisplay } from '../../data/actions/utils';
import { fetchGradeOverrideHistory } from '../../data/actions/grades';
import { EMAIL_HEADING, TOTAL_COURSE_GRADE_HEADING, USERNAME_HEADING } from '../../data/constants/grades';
import selectors from '../../data/selectors';
const DECIMAL_PRECISION = 2;
export class GradebookTable extends React.Component {
setNewModalState = (userEntry, subsection) => {
this.props.fetchGradeOverrideHistory(
subsection.module_id,
userEntry.user_id,
);
let adjustedGradePossible = '';
if (subsection.attempted) {
adjustedGradePossible = subsection.score_possible;
}
this.props.setGradebookState({
adjustedGradePossible,
adjustedGradeValue: '',
assignmentName: `${subsection.subsection_name}`,
modalOpen: true,
reasonForChange: '',
todaysDate: formatDateForDisplay(new Date()),
updateModuleId: subsection.module_id,
updateUserId: userEntry.user_id,
updateUserName: userEntry.username,
});
}
getLearnerInformation = entry => (
<div>
<div>{entry.username}</div>
{entry.external_user_key && <div className="student-key">{entry.external_user_key}</div>}
</div>
)
roundGrade = percent => parseFloat((percent || 0).toFixed(DECIMAL_PRECISION));
formatter = {
percent: (entries, areGradesFrozen) => entries.map((entry) => {
const learnerInformation = this.getLearnerInformation(entry);
const results = {
[USERNAME_HEADING]: (
<div><span className="wrap-text-in-cell">{learnerInformation}</span></div>
),
[EMAIL_HEADING]: (
<span className="wrap-text-in-cell">{entry.email}</span>
),
};
const assignments = entry.section_breakdown
.reduce((acc, subsection) => {
if (areGradesFrozen) {
acc[subsection.label] = `${this.roundGrade(subsection.percent * 100)} %`;
} else {
acc[subsection.label] = (
<button
className="btn btn-header link-style grade-button"
onClick={() => this.setNewModalState(entry, subsection)}
>
{this.roundGrade(subsection.percent * 100)}%
</button>
);
}
return acc;
}, {});
const totals = { [TOTAL_COURSE_GRADE_HEADING]: `${this.roundGrade(entry.percent * 100)}%` };
return Object.assign(results, assignments, totals);
}),
absolute: (entries, areGradesFrozen) => entries.map((entry) => {
const learnerInformation = this.getLearnerInformation(entry);
const results = {
[USERNAME_HEADING]: (
<div><span className="wrap-text-in-cell">{learnerInformation}</span></div>
),
[EMAIL_HEADING]: (
<span className="wrap-text-in-cell">{entry.email}</span>
),
};
const assignments = entry.section_breakdown
.reduce((acc, subsection) => {
const scoreEarned = this.roundGrade(subsection.score_earned);
const scorePossible = this.roundGrade(subsection.score_possible);
let label = `${scoreEarned}`;
if (subsection.attempted) {
label = `${scoreEarned}/${scorePossible}`;
}
if (areGradesFrozen) {
acc[subsection.label] = label;
} else {
acc[subsection.label] = (
<button
className="btn btn-header link-style"
onClick={() => this.setNewModalState(entry, subsection)}
>
{label}
</button>
);
}
return acc;
}, {});
// Show this as a percent no matter what the other setting is. The data
// we're getting gives the final grade as a percentage so making it appear
// to be "out of" 100 is misleading.
const totals = { [TOTAL_COURSE_GRADE_HEADING]: `${this.roundGrade(entry.percent * 100)}%` };
return Object.assign(results, assignments, totals);
}),
};
formatHeadings = () => {
let headings = [...this.props.headings];
if (headings.length > 0) {
const headerLabelReplacements = {};
headerLabelReplacements[USERNAME_HEADING] = (
<div>
<div>Username</div>
<div className="font-weight-normal student-key">Student Key*</div>
</div>
);
headerLabelReplacements[EMAIL_HEADING] = 'Email*';
const totalGradePercentageMessage = 'Total Grade values are always displayed as a percentage.';
headerLabelReplacements[TOTAL_COURSE_GRADE_HEADING] = (
<div>
<OverlayTrigger
trigger={['hover', 'focus']}
key="left-basic"
placement="left"
overlay={(<Tooltip id="course-grade-tooltip">{totalGradePercentageMessage}</Tooltip>)}
>
<div>
{TOTAL_COURSE_GRADE_HEADING}
<div id="courseGradeTooltipIcon">
<Icon className="fa fa-info-circle" screenReaderText={totalGradePercentageMessage} />
</div>
</div>
</OverlayTrigger>
</div>
);
headings = headings.map(heading => {
const result = {
label: heading,
key: heading,
};
if (headerLabelReplacements[heading] !== undefined) {
result.label = headerLabelReplacements[heading];
}
return result;
});
}
return headings;
}
render() {
return (
<div className="gradebook-container">
<div className="gbook">
<Table
columns={this.formatHeadings()}
data={this.formatter[this.props.format](
this.props.grades,
this.props.areGradesFrozen,
)}
rowHeaderColumnKey="username"
hasFixedColumnWidths
/>
</div>
</div>
);
}
}
GradebookTable.defaultProps = {
areGradesFrozen: false,
grades: [],
};
GradebookTable.propTypes = {
setGradebookState: PropTypes.func.isRequired,
// redux
areGradesFrozen: PropTypes.bool,
format: PropTypes.string.isRequired,
grades: PropTypes.arrayOf(PropTypes.shape({
percent: PropTypes.number,
section_breakdown: PropTypes.arrayOf(PropTypes.shape({
attempted: PropTypes.bool,
category: PropTypes.string,
label: PropTypes.string,
module_id: PropTypes.string,
percent: PropTypes.number,
scoreEarned: PropTypes.number,
scorePossible: PropTypes.number,
subsection_name: PropTypes.string,
})),
user_id: PropTypes.number,
user_name: PropTypes.string,
})),
headings: PropTypes.arrayOf(PropTypes.string).isRequired,
fetchGradeOverrideHistory: PropTypes.func.isRequired,
};
export const mapStateToProps = (state) => {
const { assignmentTypes, grades, root } = selectors;
return {
areGradesFrozen: assignmentTypes.areGradesFrozen(state),
format: grades.gradeFormat(state),
grades: grades.allGrades(state),
headings: root.getHeadings(state),
};
};
export const mapDispatchToProps = {
fetchGradeOverrideHistory,
};
export default connect(mapStateToProps, mapDispatchToProps)(GradebookTable);

View File

@@ -0,0 +1,114 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { Button, Icon, SearchField } from '@edx/paragon';
import selectors from 'data/selectors';
import {
fetchGrades,
fetchMatchingUserGrades,
} from '../../data/actions/grades';
/**
* Controls for filtering the GradebookTable. Contains the "Edit Filters" button for opening the filter drawer
* as well as the search box for searching by username/email.
*/
export class SearchControls extends React.Component {
constructor(props) {
super(props);
this.onSubmit = this.onSubmit.bind(this);
this.onChange = this.onChange.bind(this);
this.onClear = this.onClear.bind(this);
}
/** Submitting searches for user matching the username/email in `value` */
onSubmit(value) {
this.props.searchForUser(
this.props.courseId,
value,
this.props.selectedCohort,
this.props.selectedTrack,
this.props.selectedAssignmentType,
);
}
/** Changing the search value stores the key in Gradebook. Currently unused */
onChange(filterValue) {
this.props.setFilterValue(filterValue);
}
/** Clearing the search box falls back to showing students with already applied filters */
onClear() {
this.props.getUserGrades(
this.props.courseId,
this.props.selectedCohort,
this.props.selectedTrack,
this.props.selectedAssignmentType,
);
}
render() {
return (
<>
<h4>Step 1: Filter the Grade Report</h4>
<div className="d-flex justify-content-between">
<Button
id="edit-filters-btn"
className="btn-primary align-self-start"
onClick={this.props.toggleFilterDrawer}
>
<Icon className="fa fa-filter" /> Edit Filters
</Button>
<div>
<SearchField
onSubmit={this.onSubmit}
inputLabel="Search for a learner"
onChange={this.onChange}
onClear={this.onClear}
value={this.props.filterValue}
/>
<small className="form-text text-muted search-help-text">Search by username, email, or student key</small>
</div>
</div>
</>
);
}
}
SearchControls.defaultProps = {
courseId: '',
filterValue: '',
selectedAssignmentType: '',
selectedCohort: null,
selectedTrack: null,
};
SearchControls.propTypes = {
courseId: PropTypes.string,
filterValue: PropTypes.string,
setFilterValue: PropTypes.func.isRequired,
toggleFilterDrawer: PropTypes.func.isRequired,
// From Redux
getUserGrades: PropTypes.func.isRequired,
searchForUser: PropTypes.func.isRequired,
selectedAssignmentType: PropTypes.string,
selectedCohort: PropTypes.string,
selectedTrack: PropTypes.string,
};
export const mapStateToProps = (state) => {
const { filters } = selectors;
return {
selectedAssignmentType: filters.assignmentType(state),
selectedTrack: filters.track(state),
selectedCohort: filters.cohort(state),
};
};
export const mapDispatchToProps = {
getUserGrades: fetchGrades,
searchForUser: fetchMatchingUserGrades,
};
export default connect(mapStateToProps, mapDispatchToProps)(SearchControls);

View File

@@ -0,0 +1,118 @@
import React from 'react';
import { shallow } from 'enzyme';
import {
fetchGrades,
fetchMatchingUserGrades,
} from '../../data/actions/grades';
import { mapDispatchToProps, mapStateToProps, SearchControls } from './SearchControls';
jest.mock('@edx/paragon', () => ({
Icon: 'Icon',
Button: 'Button',
SearchField: 'SearchField',
}));
describe('SearchControls', () => {
let props;
beforeEach(() => {
jest.resetAllMocks();
props = {
courseId: 'course-v1:edX+DEV101+T1',
filterValue: 'alice',
selectedAssignmentType: 'homework',
selectedCohort: 'spring term',
selectedTrack: 'masters',
getUserGrades: jest.fn(),
searchForUser: jest.fn(),
setFilterValue: jest.fn(),
toggleFilterDrawer: jest.fn().mockName('toggleFilterDrawer'),
};
});
const searchControls = (overriddenProps) => {
props = { ...props, ...overriddenProps };
return shallow(<SearchControls {...props} />);
};
describe('Component', () => {
describe('onSubmit', () => {
it('calls props.searchForUser with correct data', () => {
const wrapper = searchControls();
wrapper.instance().onSubmit('bob');
expect(props.searchForUser).toHaveBeenCalledWith(
props.courseId,
'bob',
props.selectedCohort,
props.selectedTrack,
props.selectedAssignmentType,
);
});
});
describe('onChange', () => {
it('saves the changed search value to Gradebook state', () => {
const wrapper = searchControls();
wrapper.instance().onChange('bob');
expect(props.setFilterValue).toHaveBeenCalledWith('bob');
});
});
describe('onClear', () => {
it('re-runs search with existing filters', () => {
const wrapper = searchControls();
wrapper.instance().onClear();
expect(props.getUserGrades).toHaveBeenCalledWith(
props.courseId,
props.selectedCohort,
props.selectedTrack,
props.selectedAssignmentType,
);
});
});
describe('mapStateToProps', () => {
const state = {
filters: {
assignmentType: 'labs',
track: 'honor',
cohort: 'fall term',
},
};
it('maps assignment type filter correctly', () => {
expect(mapStateToProps(state).selectedAssignmentType).toEqual(state.filters.assignmentType);
});
it('maps track filter correctly', () => {
expect(mapStateToProps(state).selectedTrack).toEqual(state.filters.track);
});
it('maps cohort filter correctly', () => {
expect(mapStateToProps(state).selectedCohort).toEqual(state.filters.cohort);
});
});
describe('mapDispatchToProps', () => {
test('getUserGrades', () => {
expect(mapDispatchToProps.getUserGrades).toEqual(fetchGrades);
});
test('searchForUser', () => {
expect(mapDispatchToProps.searchForUser).toEqual(fetchMatchingUserGrades);
});
});
describe('Snapshots', () => {
test('basic snapshot', () => {
const wrapper = searchControls();
wrapper.instance().onChange = jest.fn().mockName('onChange');
wrapper.instance().onClear = jest.fn().mockName('onClear');
wrapper.instance().onSubmit = jest.fn().mockName('onSubmit');
expect(wrapper.instance().render()).toMatchSnapshot();
});
});
});
});

View File

@@ -0,0 +1,72 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { StatusAlert } from '@edx/paragon';
import selectors from 'data/selectors';
import { closeBanner } from '../../data/actions/grades';
export const maxCourseGradeInvalidMessage = 'Maximum course grade value must be between 0 and 100. ';
export const minCourseGradeInvalidMessage = 'Minimum course grade value must be between 0 and 100. ';
export class StatusAlerts extends React.Component {
get isCourseGradeFilterAlertOpen() {
const r = !this.props.isMinCourseGradeFilterValid
|| !this.props.isMaxCourseGradeFilterValid;
return r;
}
get courseGradeFilterAlertDialogText() {
let dialogText = '';
if (!this.props.isMinCourseGradeFilterValid) {
dialogText += minCourseGradeInvalidMessage;
}
if (!this.props.isMaxCourseGradeFilterValid) {
dialogText += maxCourseGradeInvalidMessage;
}
return dialogText;
}
render() {
return (
<>
<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.handleCloseSuccessBanner}
open={this.props.showSuccessBanner}
/>
<StatusAlert
alertType="danger"
dialog={this.courseGradeFilterAlertDialogText}
dismissible={false}
open={this.isCourseGradeFilterAlertOpen}
/>
</>
);
}
}
StatusAlerts.defaultProps = {
isMinCourseGradeFilterValid: true,
isMaxCourseGradeFilterValid: true,
};
StatusAlerts.propTypes = {
isMinCourseGradeFilterValid: PropTypes.bool,
isMaxCourseGradeFilterValid: PropTypes.bool,
// redux
handleCloseSuccessBanner: PropTypes.func.isRequired,
showSuccessBanner: PropTypes.bool.isRequired,
};
export const mapStateToProps = (state) => ({
showSuccessBanner: selectors.grades.showSuccess(state),
});
export const mapDispatchToProps = {
handleCloseSuccessBanner: closeBanner,
};
export default connect(mapStateToProps, mapDispatchToProps)(StatusAlerts);

View File

@@ -0,0 +1,99 @@
import React from 'react';
import { shallow } from 'enzyme';
import {
StatusAlerts,
mapDispatchToProps,
mapStateToProps,
maxCourseGradeInvalidMessage,
minCourseGradeInvalidMessage,
} from './StatusAlerts';
import { closeBanner } from '../../data/actions/grades';
jest.mock('@edx/paragon', () => ({
StatusAlert: 'StatusAlert',
}));
describe('StatusAlerts', () => {
let props = {
showSuccessBanner: true,
isMaxCourseGradeFilterValid: true,
isMinCourseGradeFilterValid: true,
};
beforeEach(() => {
props = {
...props,
handleCloseSuccessBanner: jest.fn().mockName('handleCloseSuccessBanner'),
};
});
describe('snapshots', () => {
let el;
it('basic snapshot', () => {
el = shallow(<StatusAlerts {...props} />);
const courseGradeFilterAlertDialogText = 'the quiCk brown does somEthing or other';
jest.spyOn(
el.instance(),
'courseGradeFilterAlertDialogText',
'get',
).mockReturnValue(courseGradeFilterAlertDialogText);
expect(el.instance().render()).toMatchSnapshot();
});
});
describe('behavior', () => {
it.each([
[false, false],
[false, true],
[true, false],
[true, true],
])('min + max course grade validity', (isMinCourseGradeFilterValid, isMaxCourseGradeFilterValid) => {
props = {
...props,
isMinCourseGradeFilterValid,
isMaxCourseGradeFilterValid,
};
const el = shallow(<StatusAlerts {...props} />);
expect(
el.instance().isCourseGradeFilterAlertOpen,
).toEqual(
!isMinCourseGradeFilterValid || !isMaxCourseGradeFilterValid,
);
if (!isMaxCourseGradeFilterValid) {
expect(
el.instance().courseGradeFilterAlertDialogText,
).toEqual(
expect.stringContaining(maxCourseGradeInvalidMessage),
);
}
if (!isMinCourseGradeFilterValid) {
expect(
el.instance().courseGradeFilterAlertDialogText,
).toEqual(
expect.stringContaining(minCourseGradeInvalidMessage),
);
}
});
});
describe('mapStateToProps', () => {
it('showSuccessBanner', () => {
const arbitraryValue = 'AppleBananaCucumber';
const state = {
grades: {
showSuccess: arbitraryValue,
},
};
expect(mapStateToProps(state).showSuccessBanner).toBe(arbitraryValue);
});
});
describe('handleCloseSuccessBanner', () => {
test('handleCloseSuccessBanner', () => {
expect(
mapDispatchToProps.handleCloseSuccessBanner,
).toEqual(
closeBanner,
);
});
});
});

View File

@@ -0,0 +1,37 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SearchControls Component Snapshots basic snapshot 1`] = `
<React.Fragment>
<h4>
Step 1: Filter the Grade Report
</h4>
<div
className="d-flex justify-content-between"
>
<Button
className="btn-primary align-self-start"
id="edit-filters-btn"
onClick={[MockFunction toggleFilterDrawer]}
>
<Icon
className="fa fa-filter"
/>
Edit Filters
</Button>
<div>
<SearchField
inputLabel="Search for a learner"
onChange={[MockFunction onChange]}
onClear={[MockFunction onClear]}
onSubmit={[MockFunction onSubmit]}
value="alice"
/>
<small
className="form-text text-muted search-help-text"
>
Search by username, email, or student key
</small>
</div>
</div>
</React.Fragment>
`;

View File

@@ -0,0 +1,18 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`StatusAlerts snapshots basic snapshot 1`] = `
<React.Fragment>
<StatusAlert
alertType="success"
dialog="The grade has been successfully edited. You may see a slight delay before updates appear in the Gradebook."
onClose={[MockFunction handleCloseSuccessBanner]}
open={true}
/>
<StatusAlert
alertType="danger"
dialog="the quiCk brown does somEthing or other"
dismissible={false}
open={false}
/>
</React.Fragment>
`;

View File

@@ -31,13 +31,9 @@
}
.student-filters{
display: flex;
.label{
padding-top: 30px;
}
.form-group{
margin-left: 10px;
}
}
.grade-history-header{
float: left;
@@ -57,9 +53,17 @@
.grade-history-current-grade{
padding-right: 25px;
}
.gradebook-container {
width: 100%;
overflow-x: auto;
height: 600px;
overflow-y: auto;
word-break: break-word;
position: relative;
}
.gbook {
overflow-x: scroll;
width: 100%;
.grade-button {
text-decoration: underline;
@@ -69,18 +73,27 @@
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;
}
#courseGradeTooltipIcon {
float: right;
}
.table thead tr {
height: 60px;
min-height: 60px;
&:nth-child(1) {
position: sticky;
top: 0;
z-index: 10;
background-color: white;
th {
background-color: white;
border-bottom: 1px solid $gray_200;
}
}
}
thead, tbody, tr, td, th {
display: block;
word-break: break-word;
}
.table tr th:first-child {
@@ -89,16 +102,50 @@
.table tr th:first-child,
.table tr td:first-child {
position: sticky;
width: 160px;
left: 0;
z-index: 1; // to float over the following children in the side-scrolling case
background: white;
}
.table tr {
th:nth-child(1),
td:nth-child(1),
th:nth-child(2),
td:nth-child(2) {
width: 240px;
}
th:nth-last-of-type(1) {
width: 150px;
}
th, td {
width: 120px;
}
}
.table tbody th {
font-weight: normal;
}
.table {
overflow-x: hidden;
height: 100%;
tbody {
overflow-y: auto;
display: block;
}
thead, tbody tr {
display: table;
table-layout: fixed;
}
th {
vertical-align: top;
font-size: 14px;
}
}
.link-style {
color: #0075b4;
&:hover, &:focus {
@@ -106,10 +153,33 @@
text-decoration: underline;
}
}
}
.input-percent-label {
margin-top: 10px;
.form-group, .pgn__form-group {
label {
font-weight: bold;
}
}
.filter-group {
.grade-filter-inputs {
.percent-group {
display: inline-block;
.form-group, .pgn__form-group {
width: 115px;
display: inline-block;
}
.input-percent-label {
margin-top: 22px;
margin-left: 5px;
margin-right: 5px;
}
}
}
.grade-filter-action {
text-align: right;
}
}
.mb-85 {

View File

@@ -1,53 +1,49 @@
/* eslint-disable react/sort-comp, react/button-has-type, import/no-named-as-default */
import React from 'react';
import PropTypes from 'prop-types';
import {
Button,
Collapsible,
Icon,
InputSelect,
InputText,
Modal,
SearchField,
StatefulButton,
StatusAlert,
Table,
Tab,
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 { 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' }];
import BulkManagement from './BulkManagement';
import BulkManagementControls from './BulkManagementControls';
import EditModal from './EditModal';
import GradebookFilters from './GradebookFilters';
import GradebookTable from './GradebookTable';
import SearchControls from './SearchControls';
import StatusAlerts from './StatusAlerts';
export default class Gradebook extends React.Component {
constructor(props) {
super(props);
this.state = {
filterValue: '',
courseGradeMin: '0',
courseGradeMax: '100',
modalOpen: false,
adjustedGradePossible: '',
adjustedGradeValue: 0,
updateModuleId: null,
updateUserId: null,
reasonForChange: '',
assignmentGradeMin: '0',
assignmentGradeMax: '100',
assignmentName: '',
courseGradeMin: '0',
courseGradeMax: '100',
filterValue: '',
isMinCourseGradeFilterValid: true,
isMaxCourseGradeFilterValid: true,
modalOpen: false,
reasonForChange: '',
todaysDate: '',
updateModuleId: null,
updateUserId: null,
};
this.fileFormRef = React.createRef();
this.fileInputRef = React.createRef();
this.myRef = React.createRef();
}
@@ -55,7 +51,6 @@ export default class Gradebook extends React.Component {
const urlQuery = queryString.parse(this.props.location.search);
this.props.initializeFilters(urlQuery);
this.props.getRoles(this.props.courseId);
this.overrideReasonInput.focus();
const newStateFields = {};
['assignmentGradeMin', 'assignmentGradeMax', 'courseGradeMin', 'courseGradeMax'].forEach((attr) => {
@@ -71,37 +66,6 @@ export default class Gradebook extends React.Component {
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({
modalAssignmentName: `${subsection.subsection_name}`,
modalOpen: true,
updateModuleId: subsection.module_id,
updateUserId: userEntry.user_id,
updateUserName: userEntry.username,
todaysDate: formatDateForDisplay(new Date()),
adjustedGradePossible,
reasonForChange: '',
adjustedGradeValue: '',
});
}
getLearnerInformation = entry => (
<div>
<div>{entry.username}</div>
{entry.external_user_key && <div className="student-key">{entry.external_user_key}</div>}
</div>
)
getActiveTabs = () => {
if (this.props.showBulkManagement) {
return ['Grades', 'Bulk Management'];
@@ -109,76 +73,6 @@ export default class Gradebook extends React.Component {
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({
adjustedGradePossible: '',
adjustedGradeValue: '',
modalOpen: false,
reasonForChange: '',
updateModuleId: null,
updateUserId: null,
});
};
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);
Object.keys(queryParams).forEach((key) => {
@@ -191,332 +85,8 @@ export default class Gradebook extends React.Component {
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) => {
const mapped = entries.map(entry => ({
id: entry.id,
label: entry.name,
}));
mapped.unshift({ id: 0, label: 'Cohort-All' });
return mapped;
};
mapTracksEntries = (entries) => {
const mapped = entries.map(entry => ({
id: entry.slug,
label: entry.name,
}));
mapped.unshift({ label: 'Track-All' });
return mapped;
};
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;
if (selectedTrackItem) {
selectedTrackSlug = selectedTrackItem.slug;
}
this.props.getUserGrades(
this.props.courseId,
this.props.selectedCohort,
selectedTrackSlug,
this.props.selectedAssignmentType,
);
this.updateQueryParams({ track: selectedTrackSlug });
};
updateCohorts = (event) => {
const selectedCohortItem = this.props.cohorts.find(x => x.name === event);
let selectedCohortId = null;
if (selectedCohortItem) {
selectedCohortId = selectedCohortItem.id;
}
this.props.getUserGrades(
this.props.courseId,
selectedCohortId,
this.props.selectedTrack,
this.props.selectedAssignmentType,
);
this.updateQueryParams({ cohort: selectedCohortId });
};
// 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) {
return selectedCohortEntry.name;
}
return 'Cohorts';
};
mapSelectedTrackEntry = (entry) => {
const selectedTrackEntry = this.props.tracks.find(x => x.slug === entry);
if (selectedTrackEntry) {
return selectedTrackEntry.name;
}
return 'Tracks';
};
roundGrade = percent => parseFloat((percent || 0).toFixed(DECIMAL_PRECISION));
formatter = {
percent: (entries, areGradesFrozen) => entries.map((entry) => {
const learnerInformation = this.getLearnerInformation(entry);
const results = {
Username: (
<div><span className="wrap-text-in-cell">{learnerInformation}</span></div>
),
Email: (
<span className="wrap-text-in-cell">{entry.email}</span>
),
};
const assignments = entry.section_breakdown
.reduce((acc, subsection) => {
if (areGradesFrozen) {
acc[subsection.label] = `${this.roundGrade(subsection.percent * 100)} %`;
} else {
acc[subsection.label] = (
<button
className="btn btn-header link-style grade-button"
onClick={() => this.setNewModalState(entry, subsection)}
>
{this.roundGrade(subsection.percent * 100)}%
</button>);
}
return acc;
}, {});
const totals = { Total: `${this.roundGrade(entry.percent * 100)}%` };
return Object.assign(results, assignments, totals);
}),
absolute: (entries, areGradesFrozen) => entries.map((entry) => {
const learnerInformation = this.getLearnerInformation(entry);
const results = {
Username: (
<div><span className="wrap-text-in-cell">{learnerInformation}</span></div>
),
Email: (
<span className="wrap-text-in-cell">{entry.email}</span>
),
};
const assignments = entry.section_breakdown
.reduce((acc, subsection) => {
const scoreEarned = this.roundGrade(subsection.score_earned);
const scorePossible = this.roundGrade(subsection.score_possible);
let label = `${scoreEarned}`;
if (subsection.attempted) {
label = `${scoreEarned}/${scorePossible}`;
}
if (areGradesFrozen) {
acc[subsection.label] = label;
} else {
acc[subsection.label] = (
<button
className="btn btn-header link-style"
onClick={() => this.setNewModalState(entry, subsection)}
>
{label}
</button>
);
}
return acc;
}, {});
const totals = { Total: `${this.roundGrade(entry.percent * 100)}/100` };
return Object.assign(results, assignments, totals);
}),
};
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 = {};
@@ -540,6 +110,46 @@ export default class Gradebook extends React.Component {
);
}
createStateFieldSetter = (key) => (value) => this.setState({ [key]: value });
createStateFieldOnChange = (key) => ({ target }) => this.setState({ [key]: target.value });
createLimitedSetter = (...keys) => (values) => this.setState(
keys.reduce(
(obj, key) => (values[key] === undefined ? obj : { ...obj, [key]: values[key] }),
{},
),
)
safeSetState = this.createLimitedSetter(
'adjustedGradePossible',
'adjustedGradeValue',
'assignmentName',
'filterValue',
'modalOpen',
'reasonForChange',
'todaysDate',
'updateModuleId',
'updateUserId',
'updateUserName',
);
setFilters = this.createLimitedSetter(
'assignmentGradeMin',
'assignmentGradeMax',
'courseGradeMin',
'courseGradeMax',
'isMinCourseGradeFilterValid',
'isMaxCourseGradeFilterValid',
);
filterValues = () => ({
assignmentGradeMin: this.state.assignmentGradeMin,
assignmentGradeMax: this.state.assignmentGradeMax,
courseGradeMin: this.state.courseGradeMin,
courseGradeMax: this.state.courseGradeMax,
});
render() {
return (
<Drawer
@@ -549,81 +159,54 @@ export default class Gradebook extends React.Component {
href={this.lmsInstructorDashboardUrl(this.props.courseId)}
className="mb-3"
>
<span aria-hidden="true">{'<< '}</span> {'Back to Dashboard'}
<span aria-hidden="true">{'<< '}</span> Back to Dashboard
</a>
<h1>Gradebook</h1>
<h3> {this.props.courseId}</h3>
{this.props.areGradesFrozen &&
<div className="alert alert-warning" role="alert" >
{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" >
)}
{(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>
<h4>Step 1: Filter the Grade Report</h4>
<div className="d-flex justify-content-between" >
{this.props.showSpinner && <div className="spinner-overlay"><Icon className="fa fa-spinner fa-spin fa-5x color-black" /></div>}
<Button className="btn-primary align-self-start" onClick={toggleFilterDrawer}><FontAwesomeIcon icon={faFilter} /> Edit Filters</Button>
<div>
<SearchField
onSubmit={value =>
this.props.searchForUser(
this.props.courseId,
value,
this.props.selectedCohort,
this.props.selectedTrack,
this.props.selectedAssignmentType,
)
}
inputLabel="Search for a learner"
onChange={filterValue => this.setState({ filterValue })}
onClear={() =>
this.props.getUserGrades(
this.props.courseId,
this.props.selectedCohort,
this.props.selectedTrack,
this.props.selectedAssignmentType,
)
}
value={this.state.filterValue}
/>
<small className="form-text text-muted search-help-text">Search by username, email, or student key</small>
)}
<Tabs defaultActiveKey="grades">
<Tab eventKey="grades" title="Grades">
{this.props.showSpinner && (
<div className="spinner-overlay">
<Icon className="fa fa-spinner fa-spin fa-5x color-black" />
</div>
</div>
)}
<SearchControls
courseId={this.props.courseId}
filterValue={this.state.filterValue}
setFilterValue={this.createStateFieldSetter('filterValue')}
toggleFilterDrawer={toggleFilterDrawer}
/>
<ConnectedFilterBadges
handleFilterBadgeClose={this.handleFilterBadgeClose}
/>
<StatusAlert
alertType="success"
dialog="The grade has been successfully edited. You may see a slight delay before updates appear in the Gradebook."
onClose={() => this.props.closeBanner()}
open={this.props.showSuccess}
/>
<StatusAlert
alertType="danger"
dialog={this.getCourseGradeFilterAlertDialog()}
dismissible={false}
open={
!this.state.isMinCourseGradeFilterValid ||
!this.state.isMaxCourseGradeFilterValid
}
<StatusAlerts
isMinCourseGradeFilterValid={this.state.isMinCourseGradeFilterValid}
isMaxCourseGradeFilterValid={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
}
{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:"
@@ -633,310 +216,61 @@ export default class Gradebook extends React.Component {
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>
<BulkManagementControls
courseId={this.props.courseId}
gradeExportUrl={this.props.gradeExportUrl}
interventionExportUrl={this.props.interventionExportUrl}
showSpinner={this.props.showSpinner}
/>
)}
</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>
<GradebookTable setGradebookState={this.safeSetState} />
{PageButtons(this.props)}
<p>* available for learners in the Master&apos;s track only</p>
<Modal
<EditModal
assignmentName={this.state.assignmentName}
adjustedGradeValue={this.state.adjustedGradeValue}
adjustedGradePossible={this.state.adjustedGradePossible}
courseId={this.props.courseId}
filterValue={this.state.filterValue}
onChange={this.onChange}
open={this.state.modalOpen}
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}
reasonForChange={this.state.reasonForChange}
setAdjustedGradeValue={this.createStateFieldOnChange('adjustedGradeValue')}
setGradebookState={this.safeSetState}
setReasonForChange={this.createStateFieldOnChange('reasonForChange')}
todaysDate={this.state.todaysDate}
updateModuleId={this.state.updateModuleId}
updateUserId={this.state.updateUserId}
updateUserName={this.state.updateUserName}
/>
</div>
{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"
</Tab>
{this.props.showBulkManagement
&& (
<Tab eventKey="bulk_management" title="Bulk Management">
<BulkManagement
courseId={this.props.courseId}
gradeExportUrl={this.props.gradeExportUrl}
/>
</div>)}
</Tab>
)}
</Tabs>
</div>
)}
initiallyOpen={false}
title={
<React.Fragment>
title={(
<>
<FontAwesomeIcon icon={faFilter} /> Filter By...
</React.Fragment>
}
</>
)}
>
<Collapsible title="Assignments" isOpen className="filter-group mb-3">
<div>
<div className="student-filters">
<span className="label">
Assignment Types:
</span>
<InputSelect
name="assignment-types"
aria-label="Assignment Types"
value={this.props.selectedAssignmentType}
options={this.mapAssignmentTypeEntries(this.props.assignmentTypes)}
onChange={this.updateAssignmentTypes}
disabled={this.props.assignmentFilterOptions.length === 0}
/>
</div>
<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>
</Collapsible>
<Collapsible title="Overall Grade" isOpen className="filter-group mb-3">
<div className="d-flex justify-content-between align-items-center">
<InputText
value={this.state.courseGradeMin}
name="minimum-grade"
label="Min Grade"
onChange={value => this.handleCourseGradeFilterChange('min', value)}
type="number"
min={0}
max={100}
/>
<span className="input-percent-label">%</span>
<InputText
value={this.state.courseGradeMax}
name="max-grade"
label="Max Grade"
onChange={value => this.handleCourseGradeFilterChange('max', value)}
type="number"
min={0}
max={100}
/>
<span className="input-percent-label">%</span>
<Button
buttonType="outline-secondary"
onClick={this.handleCourseGradeFilterApplyButtonClick}
>
Apply
</Button>
</div>
</Collapsible>
<Collapsible title="Student Groups" isOpen className="filter-group mb-3">
<InputSelect
name="Tracks"
aria-label="Tracks"
disabled={this.props.tracks.length === 0}
value={this.mapSelectedTrackEntry(this.props.selectedTrack)}
options={this.mapTracksEntries(this.props.tracks)}
onChange={this.updateTracks}
/>
<InputSelect
name="Cohorts"
aria-label="Cohorts"
disabled={this.props.cohorts.length === 0}
value={this.mapSelectedCohortEntry(this.props.selectedCohort)}
options={this.mapCohortsEntries(this.props.cohorts)}
onChange={this.updateCohorts}
/>
</Collapsible>
<GradebookFilters
setFilters={this.setFilters}
filterValues={this.filterValues()}
updateQueryParams={this.updateQueryParams}
courseId={this.props.courseId}
/>
</Drawer>
);
}
@@ -944,123 +278,42 @@ export default class Gradebook extends React.Component {
Gradebook.defaultProps = {
areGradesFrozen: false,
assignmentTypes: [],
assignmentFilterOptions: [],
canUserViewGradebook: false,
cohorts: [],
grades: [],
gradeOverrides: [],
gradeOverrideCurrentEarnedGradedOverride: null,
gradeOriginalEarnedGraded: null,
gradeOriginalPossibleGraded: null,
courseId: '',
filteredUsersCount: null,
location: {
search: '',
},
courseId: '',
selectedAssignmentType: '',
selectedCohort: null,
selectedTrack: null,
selectedAssignmentType: '',
selectedAssignment: '',
showSpinner: false,
tracks: [],
bulkImportError: '',
uploadSuccess: false,
showBulkManagement: false,
bulkManagementHistory: [],
errorFetchingGradeOverrideHistory: false,
showSpinner: 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,
courseId: PropTypes.string,
filteredUsersCount: PropTypes.number,
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,
gradeExportUrl: PropTypes.string.isRequired,
history: PropTypes.shape({
push: PropTypes.func,
}).isRequired,
initializeFilters: PropTypes.func.isRequired,
interventionExportUrl: PropTypes.string.isRequired,
location: PropTypes.shape({
search: PropTypes.string,
}),
courseId: PropTypes.string,
searchForUser: PropTypes.func.isRequired,
resetFilters: 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,
})),
showSpinner: PropTypes.bool,
toggleFormat: PropTypes.func.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

@@ -1,12 +1,11 @@
import React from 'react';
import { Hyperlink } from '@edx/paragon';
import EdxLogo from '../../../assets/edx-sm.png';
import { getConfig } from '@edx/frontend-platform';
export default class Header extends React.Component {
renderLogo() {
return (
<img src={EdxLogo} alt="edX logo" height="30" width="60" />
<img src={getConfig().LOGO_URL} alt="edX logo" height="30" width="60" />
);
}

View File

@@ -12,9 +12,7 @@ exports[`PageButtons prev not null, next not null 1`] = `
<button
className="btn btn-outline-primary"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
style={
Object {
"margin": "20px",
@@ -27,9 +25,7 @@ exports[`PageButtons prev not null, next not null 1`] = `
<button
className="btn btn-outline-primary"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
style={
Object {
"margin": "20px",
@@ -54,9 +50,7 @@ exports[`PageButtons prev not null, next null 1`] = `
<button
className="btn btn-outline-primary"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
style={
Object {
"margin": "20px",
@@ -69,9 +63,7 @@ exports[`PageButtons prev not null, next null 1`] = `
<button
className="btn btn-outline-primary"
disabled={true}
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
style={
Object {
"margin": "20px",
@@ -96,9 +88,7 @@ exports[`PageButtons prev null, next not null 1`] = `
<button
className="btn btn-outline-primary"
disabled={true}
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
style={
Object {
"margin": "20px",
@@ -111,9 +101,7 @@ exports[`PageButtons prev null, next not null 1`] = `
<button
className="btn btn-outline-primary"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
style={
Object {
"margin": "20px",
@@ -138,9 +126,7 @@ exports[`PageButtons prev null, next null 1`] = `
<button
className="btn btn-outline-primary"
disabled={true}
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
style={
Object {
"margin": "20px",
@@ -153,9 +139,7 @@ exports[`PageButtons prev null, next null 1`] = `
<button
className="btn btn-outline-primary"
disabled={true}
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
style={
Object {
"margin": "20px",

View File

@@ -2,7 +2,6 @@ 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,
@@ -14,31 +13,29 @@ export default function PageButtons({
>
<Button
style={{ margin: '20px' }}
buttonType="outline-primary"
variant="outline-primary"
disabled={!prevPage}
onClick={() =>
getPrevNextGrades(
prevPage,
match.params.courseId,
selectedCohort,
selectedTrack,
selectedAssignmentType,
)}
onClick={() => getPrevNextGrades(
prevPage,
match.params.courseId,
selectedCohort,
selectedTrack,
selectedAssignmentType,
)}
>
Previous Page
</Button>
<Button
style={{ margin: '20px' }}
buttonType="outline-primary"
variant="outline-primary"
disabled={!nextPage}
onClick={() =>
getPrevNextGrades(
nextPage,
match.params.courseId,
selectedCohort,
selectedTrack,
selectedAssignmentType,
)}
onClick={() => getPrevNextGrades(
nextPage,
match.params.courseId,
selectedCohort,
selectedTrack,
selectedAssignmentType,
)}
>
Next Page
</Button>
@@ -76,4 +73,3 @@ PageButtons.propTypes = {
name: PropTypes.string,
}),
};

View File

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

View File

@@ -38,4 +38,3 @@ export {
gotAssignmentTypes,
errorFetchingAssignmentTypes,
};

View File

@@ -1,8 +1,9 @@
import axios from 'axios';
import configureMockStore from 'redux-mock-store';
import MockAdapter from 'axios-mock-adapter';
import thunk from 'redux-thunk';
import apiClient from '../apiClient';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { configuration } from '../../config';
import { fetchAssignmentTypes } from './assignmentTypes';
import {
@@ -14,9 +15,12 @@ import {
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);
jest.mock('@edx/frontend-platform/auth');
const axiosMock = new MockAdapter(axios);
getAuthenticatedHttpClient.mockReturnValue(axios);
axios.isAccessTokenExpired = jest.fn();
axios.isAccessTokenExpired.mockReturnValue(false);
describe('actions', () => {
afterEach(() => {

View File

@@ -1,8 +1,9 @@
import axios from 'axios';
import configureMockStore from 'redux-mock-store';
import MockAdapter from 'axios-mock-adapter';
import thunk from 'redux-thunk';
import apiClient from '../apiClient';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { configuration } from '../../config';
import { fetchCohorts } from './cohorts';
import {
@@ -12,9 +13,12 @@ import {
} from '../constants/actionTypes/cohorts';
const mockStore = configureMockStore([thunk]);
const axiosMock = new MockAdapter(apiClient);
apiClient.isAccessTokenExpired = jest.fn();
apiClient.isAccessTokenExpired.mockReturnValue(false);
jest.mock('@edx/frontend-platform/auth');
const axiosMock = new MockAdapter(axios);
getAuthenticatedHttpClient.mockReturnValue(axios);
axios.isAccessTokenExpired = jest.fn();
axios.isAccessTokenExpired.mockReturnValue(false);
describe('actions', () => {
afterEach(() => {

View File

@@ -1,5 +1,16 @@
import filterSelectors from 'data/selectors/filters';
import initialFilters from '../constants/filters';
import { INITIALIZE_FILTERS, RESET_FILTERS, UPDATE_ASSIGNMENT_FILTER, UPDATE_ASSIGNMENT_LIMITS, UPDATE_COURSE_GRADE_LIMITS } from '../constants/actionTypes/filters';
import {
INITIALIZE_FILTERS,
RESET_FILTERS,
UPDATE_ASSIGNMENT_FILTER,
UPDATE_ASSIGNMENT_LIMITS,
UPDATE_COURSE_GRADE_LIMITS,
UPDATE_INCLUDE_COURSE_ROLE_MEMBERS,
} from '../constants/actionTypes/filters';
import { fetchGrades } from './grades';
const { allFilters } = filterSelectors;
const initializeFilters = ({
assignment = initialFilters.assignment,
@@ -10,6 +21,7 @@ const initializeFilters = ({
assignmentGradeMax = initialFilters.assignmentGradeMax,
courseGradeMin = initialFilters.courseGradeMin,
courseGradeMax = initialFilters.assignmentGradeMax,
includeCourseRoleMembers = initialFilters.includeCourseRoleMembers,
}) => ({
type: INITIALIZE_FILTERS,
data: {
@@ -21,6 +33,7 @@ const initializeFilters = ({
assignmentGradeMax,
courseGradeMin,
courseGradeMax,
includeCourseRoleMembers: Boolean(includeCourseRoleMembers),
},
});
@@ -48,7 +61,21 @@ const updateCourseGradeFilter = (courseGradeMin, courseGradeMax, courseId) => ({
},
});
const updateIncludeCourseRoleMembersFilter = (includeCourseRoleMembers) => ({
type: UPDATE_INCLUDE_COURSE_ROLE_MEMBERS,
data: {
includeCourseRoleMembers,
},
});
const updateIncludeCourseRoleMembers = includeCourseRoleMembers => (dispatch, getState) => {
dispatch(updateIncludeCourseRoleMembersFilter(includeCourseRoleMembers));
const state = getState();
const { cohort, track, assignmentType } = allFilters(state);
dispatch(fetchGrades(state.grades.courseId, cohort, track, assignmentType));
};
export {
initializeFilters, resetFilters, updateAssignmentFilter,
updateAssignmentLimits, updateCourseGradeFilter,
updateAssignmentLimits, updateCourseGradeFilter, updateIncludeCourseRoleMembers,
};

View File

@@ -1,3 +1,6 @@
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import gradesSelectors from 'data/selectors/grades';
import filtersSelectors from 'data/selectors/filters';
import {
STARTED_FETCHING_GRADES,
FINISHED_FETCHING_GRADES,
@@ -23,11 +26,17 @@ import {
BULK_GRADE_REPORT_DOWNLOADED,
INTERVENTION_REPORT_DOWNLOADED,
} from '../constants/actionTypes/grades';
import GRADE_OVERRIDE_HISTORY_ERROR_DEFAULT_MSG from '../constants/errors';
import LmsApiService from '../services/LmsApiService';
import { sortAlphaAsc, formatDateForDisplay } from './utils';
import { formatMaxAssignmentGrade, formatMinAssignmentGrade, formatMaxCourseGrade, formatMinCourseGrade } from '../selectors/grades';
import { getFilters } from '../selectors/filters';
import apiClient from '../apiClient';
const {
formatMaxAssignmentGrade,
formatMinAssignmentGrade,
formatMaxCourseGrade,
formatMinCourseGrade,
} = gradesSelectors;
const { allFilters } = filtersSelectors;
const defaultAssignmentFilter = 'All';
@@ -40,7 +49,10 @@ 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 errorFetchingGradeOverrideHistory = () => ({ type: ERROR_FETCHING_GRADE_OVERRIDE_HISTORY });
const errorFetchingGradeOverrideHistory = errorMessage => ({
type: ERROR_FETCHING_GRADE_OVERRIDE_HISTORY,
errorMessage,
});
const gotGrades = ({
grades, cohort, track, assignmentType, headings, prev,
@@ -108,7 +120,6 @@ const uploadOverrideFailure = (courseId, error) => ({
payload: { error },
});
const toggleGradeFormat = formatType => ({ type: TOGGLE_GRADE_FORMAT, formatType });
const filterAssignmentType = filterType => (
@@ -136,7 +147,8 @@ const fetchGrades = (
assignmentGradeMin: assignmentMin,
courseGradeMin,
courseGradeMax,
} = getFilters(getState());
includeCourseRoleMembers,
} = allFilters(getState());
const { id: assignmentId } = assignment || {};
const assignmentGradeMax = formatMaxAssignmentGrade(assignmentMax, { assignmentId });
const assignmentGradeMin = formatMinAssignmentGrade(assignmentMin, { assignmentId });
@@ -153,6 +165,7 @@ const fetchGrades = (
assignmentGradeMin,
courseGradeMin: courseGradeMinFormatted,
courseGradeMax: courseGradeMaxFormatted,
includeCourseRoleMembers,
},
)
@@ -191,27 +204,31 @@ const doneViewingAssignment = () => dispatch => dispatch({
type: DONE_VIEWING_ASSIGNMENT,
});
const fetchGradeOverrideHistory = (subsectionId, userId) => (
dispatch =>
LmsApiService.fetchGradeOverrideHistory(subsectionId, userId)
.then(response => response.data)
.then((data) => {
dispatch => LmsApiService.fetchGradeOverrideHistory(subsectionId, userId)
.then(response => response.data)
.then((data) => {
if (data.success) {
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,
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,
originalGradeEarnedGraded: data.original_grade
? data.original_grade.earned_graded : null,
originalGradePossibleGraded: data.original_grade
? data.original_grade.possible_graded : null,
}));
})
.catch(() => {
dispatch(errorFetchingGradeOverrideHistory());
})
} else {
dispatch(errorFetchingGradeOverrideHistory(data.error_message));
}
})
.catch(() => {
dispatch(errorFetchingGradeOverrideHistory(GRADE_OVERRIDE_HISTORY_ERROR_DEFAULT_MSG));
})
);
const fetchMatchingUserGrades = (
@@ -230,7 +247,7 @@ const fetchMatchingUserGrades = (
const fetchPrevNextGrades = (endpoint, courseId, cohort, track, assignmentType) => (
(dispatch) => {
dispatch(startedFetchingGrades());
return apiClient.get(endpoint)
return getAuthenticatedHttpClient().get(endpoint)
.then(response => response.data)
.then((data) => {
dispatch(gotGrades({
@@ -293,11 +310,10 @@ const submitFileUploadFormData = (courseId, formData) => (
);
const fetchBulkUpgradeHistory = courseId => (
dispatch =>
// todo add loading effect
LmsApiService.fetchGradeBulkOperationHistory(courseId).then((response) => {
dispatch(gotBulkHistory(response));
}).catch(() => dispatch(bulkHistoryError()))
// todo add loading effect
dispatch => LmsApiService.fetchGradeBulkOperationHistory(courseId).then(
(response) => { dispatch(gotBulkHistory(response)); },
).catch(() => dispatch(bulkHistoryError()))
);
const updateGradesIfAssignmentGradeFiltersSet = (

View File

@@ -1,23 +1,30 @@
import axios from 'axios';
import configureMockStore from 'redux-mock-store';
import MockAdapter from 'axios-mock-adapter';
import thunk from 'redux-thunk';
import apiClient from '../apiClient';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { configuration } from '../../config';
import { fetchGrades } from './grades';
import { fetchGrades, fetchGradeOverrideHistory } from './grades';
import {
STARTED_FETCHING_GRADES,
FINISHED_FETCHING_GRADES,
ERROR_FETCHING_GRADES,
GOT_GRADES,
GOT_GRADE_OVERRIDE_HISTORY,
ERROR_FETCHING_GRADE_OVERRIDE_HISTORY,
} from '../constants/actionTypes/grades';
import GRADE_OVERRIDE_HISTORY_ERROR_DEFAULT_MSG from '../constants/errors';
import { sortAlphaAsc } from './utils';
import LmsApiService from '../services/LmsApiService';
const mockStore = configureMockStore([thunk]);
const axiosMock = new MockAdapter(apiClient);
apiClient.isAccessTokenExpired = jest.fn();
apiClient.isAccessTokenExpired.mockReturnValue(false);
jest.mock('@edx/frontend-platform/auth');
const axiosMock = new MockAdapter(axios);
getAuthenticatedHttpClient.mockReturnValue(axios);
axios.isAccessTokenExpired = jest.fn();
axios.isAccessTokenExpired.mockReturnValue(false);
describe('actions', () => {
afterEach(() => {
@@ -29,7 +36,7 @@ describe('actions', () => {
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 fetchGradesURL = `${configuration.LMS_BASE_URL}/api/grades/v1/gradebook/${courseId}/?page_size=25&cohort_id=${expectedCohort}&enrollment_mode=${expectedTrack}&excluded_course_roles=all`;
const responseData = {
next: `${fetchGradesURL}&cursor=2344fda`,
previous: null,
@@ -176,4 +183,92 @@ describe('actions', () => {
});
});
});
describe('fetchGradeOverridHistory', () => {
const subsectionId = 'subsectionId-11111';
const userId = 'user-id-11111';
const fetchOverridesURL = `${LmsApiService.baseUrl}/api/grades/v1/subsection/${subsectionId}/?user_id=${userId}&history_record_limit=5`;
const originalGrade = {
earned_all: 1.0,
possible_all: 12.0,
earned_graded: 3.0,
possible_graded: 8.0,
};
const override = {
earned_all_override: 13.0,
possible_all_override: 13.0,
earned_graded_override: 10.0,
possible_graded_override: 10.0,
};
it('dispatches success action after successfully getting override info', () => {
const responseData = {
success: true,
original_grade: originalGrade,
history: [],
override,
};
axiosMock.onGet(fetchOverridesURL)
.replyOnce(200, JSON.stringify(responseData));
const expectedActions = [
{
type: GOT_GRADE_OVERRIDE_HISTORY,
overrideHistory: [],
currentEarnedAllOverride: override.earned_all_override,
currentPossibleAllOverride: override.possible_all_override,
currentEarnedGradedOverride: override.earned_graded_override,
currentPossibleGradedOverride: override.possible_graded_override,
originalGradeEarnedAll: originalGrade.earned_all,
originalGradePossibleAll: originalGrade.possible_all,
originalGradeEarnedGraded: originalGrade.earned_graded,
originalGradePossibleGraded: originalGrade.possible_graded,
},
];
const store = mockStore();
return store.dispatch(fetchGradeOverrideHistory(subsectionId, userId)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
describe('dispatches failure action with expected message', () => {
test('on failure response', () => {
const responseData = {
success: false,
error_message: 'There was an error!!!!!!!!!',
};
axiosMock.onGet(fetchOverridesURL).replyOnce(200, JSON.stringify(responseData));
const expectedActions = [{
type: ERROR_FETCHING_GRADE_OVERRIDE_HISTORY,
errorMessage: responseData.error_message,
}];
const store = mockStore();
return store.dispatch(fetchGradeOverrideHistory(subsectionId, userId)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
test('on 500 error', () => {
axiosMock.onGet(fetchOverridesURL).replyOnce(500);
const expectedActions = [{
type: ERROR_FETCHING_GRADE_OVERRIDE_HISTORY,
errorMessage: GRADE_OVERRIDE_HISTORY_ERROR_DEFAULT_MSG,
}];
const store = mockStore();
return store.dispatch(fetchGradeOverrideHistory(subsectionId, userId)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
});
});
});

View File

@@ -1,3 +1,4 @@
import filtersSelectors from 'data/selectors/filters';
import {
GOT_ROLES,
ERROR_FETCHING_ROLES,
@@ -6,9 +7,10 @@ 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 { allFilters } = filtersSelectors;
const allowedRoles = ['staff', 'instructor', 'support'];
const gotRoles = (canUserViewGradebook, courseId) => ({
@@ -26,7 +28,7 @@ const getRoles = courseId => (
|| (response.roles.some(role => (role.course_id === courseId)
&& allowedRoles.includes(role.role)));
dispatch(gotRoles(canUserViewGradebook, courseId));
const { cohort, track, assignmentType } = getFilters(getState());
const { cohort, track, assignmentType } = allFilters(getState());
if (canUserViewGradebook) {
dispatch(fetchGrades(courseId, cohort, track, assignmentType));
dispatch(fetchTracks(courseId));

View File

@@ -1,8 +1,9 @@
import axios from 'axios';
import configureMockStore from 'redux-mock-store';
import MockAdapter from 'axios-mock-adapter';
import thunk from 'redux-thunk';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import apiClient from '../apiClient';
import { configuration } from '../../config';
import { getRoles } from './roles';
import {
@@ -14,11 +15,13 @@ 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);
jest.mock('@edx/frontend-platform/auth');
const axiosMock = new MockAdapter(axios);
getAuthenticatedHttpClient.mockReturnValue(axios);
axios.isAccessTokenExpired = jest.fn();
axios.isAccessTokenExpired.mockReturnValue(false);
const course1Id = 'course-v1:edX+DemoX+Demo_Course';
const course2Id = 'course-v1:edX+DemoX+Demo_Course_2';

View File

@@ -1,24 +1,27 @@
/* eslint-disable camelcase */
import tracksSelectors from 'data/selectors/tracks';
import {
STARTED_FETCHING_TRACKS,
GOT_TRACKS,
ERROR_FETCHING_TRACKS,
} from '../constants/actionTypes/tracks';
import { hasMastersTrack } from '../selectors/tracks';
import { fetchBulkUpgradeHistory } from './grades';
import LmsApiService from '../services/LmsApiService';
const { hasMastersTrack } = tracksSelectors;
const startedFetchingTracks = () => ({ type: STARTED_FETCHING_TRACKS });
const errorFetchingTracks = () => ({ type: ERROR_FETCHING_TRACKS });
const gotTracks = tracks => ({ type: GOT_TRACKS, tracks });
const gotTracks = (tracks) => ({ type: GOT_TRACKS, tracks });
const fetchTracks = courseId => (
const fetchTracks = (courseId) => (
(dispatch) => {
dispatch(startedFetchingTracks());
return LmsApiService.fetchTracks(courseId)
.then(response => response.data)
.then((data) => {
dispatch(gotTracks(data.course_modes));
if (hasMastersTrack(data.course_modes)) {
.then(({ data }) => data)
.then(({ course_modes }) => {
dispatch(gotTracks(course_modes));
if (hasMastersTrack(course_modes)) {
dispatch(fetchBulkUpgradeHistory(courseId));
}
})

View File

@@ -1,8 +1,9 @@
import axios from 'axios';
import configureMockStore from 'redux-mock-store';
import MockAdapter from 'axios-mock-adapter';
import thunk from 'redux-thunk';
import apiClient from '../apiClient';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { configuration } from '../../config';
import { fetchTracks } from './tracks';
import {
@@ -12,9 +13,12 @@ import {
} from '../constants/actionTypes/tracks';
const mockStore = configureMockStore([thunk]);
const axiosMock = new MockAdapter(apiClient);
apiClient.isAccessTokenExpired = jest.fn();
apiClient.isAccessTokenExpired.mockReturnValue(false);
jest.mock('@edx/frontend-platform/auth');
const axiosMock = new MockAdapter(axios);
getAuthenticatedHttpClient.mockReturnValue(axios);
axios.isAccessTokenExpired = jest.fn();
axios.isAccessTokenExpired.mockReturnValue(false);
describe('actions', () => {
afterEach(() => {

View File

@@ -27,4 +27,3 @@ const sortAlphaAsc = (gradeRowA, gradeRowB) => {
};
export { sortAlphaAsc, formatDateForDisplay };

View File

@@ -1,15 +0,0 @@
import { getAuthenticatedAPIClient } from '@edx/frontend-auth';
import { configuration } from '../config';
const apiClient = getAuthenticatedAPIClient({
appBaseUrl: configuration.BASE_URL,
loginUrl: configuration.LOGIN_URL,
logoutUrl: configuration.LOGOUT_URL,
csrfTokenApiPath: process.env.CSRF_TOKEN_API_PATH,
refreshAccessTokenEndpoint: configuration.REFRESH_ACCESS_TOKEN_ENDPOINT,
accessTokenCookieName: configuration.ACCESS_TOKEN_COOKIE_NAME,
csrfCookieName: configuration.CSRF_COOKIE_NAME,
});
export default apiClient;

View File

@@ -9,4 +9,3 @@ export {
ERROR_FETCHING_ASSIGNMENT_TYPES,
GOT_ARE_GRADES_FROZEN,
};

View File

@@ -3,7 +3,8 @@ 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';
const UPDATE_INCLUDE_COURSE_ROLE_MEMBERS = 'UPDATE_INCLUDE_COURSE_ROLE_MEMBERS';
export {
INITIALIZE_FILTERS, RESET_FILTERS, UPDATE_ASSIGNMENT_FILTER,
UPDATE_ASSIGNMENT_LIMITS, UPDATE_COURSE_GRADE_LIMITS,
UPDATE_ASSIGNMENT_LIMITS, UPDATE_COURSE_GRADE_LIMITS, UPDATE_INCLUDE_COURSE_ROLE_MEMBERS,
};

View File

@@ -0,0 +1,3 @@
const GRADE_OVERRIDE_HISTORY_ERROR_DEFAULT_MSG = 'Error retrieving grade override history.';
export default GRADE_OVERRIDE_HISTORY_ERROR_DEFAULT_MSG;

View File

@@ -7,6 +7,7 @@ const initialFilters = {
assignmentGradeMax: '100',
courseGradeMin: '0',
courseGradeMax: '100',
includeCourseRoleMembers: false,
};
export default initialFilters;

View File

@@ -0,0 +1,5 @@
const EMAIL_HEADING = 'Email';
const TOTAL_COURSE_GRADE_HEADING = 'Total Grade (%)';
const USERNAME_HEADING = 'Username';
export { EMAIL_HEADING, TOTAL_COURSE_GRADE_HEADING, USERNAME_HEADING };

View File

@@ -11,7 +11,6 @@ const initialState = {
errorFetching: false,
};
const assignmentTypes = (state = initialState, action) => {
switch (action.type) {
case GOT_ASSIGNMENT_TYPES:
@@ -45,4 +44,3 @@ const assignmentTypes = (state = initialState, action) => {
};
export default assignmentTypes;

View File

@@ -10,7 +10,6 @@ const initialState = {
errorFetching: false,
};
const cohorts = (state = initialState, action) => {
switch (action.type) {
case GOT_COHORTS:
@@ -37,4 +36,3 @@ const cohorts = (state = initialState, action) => {
};
export default cohorts;

View File

@@ -1,11 +1,16 @@
import filterSelectors from 'data/selectors/filters';
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 {
INITIALIZE_FILTERS,
UPDATE_ASSIGNMENT_FILTER,
UPDATE_ASSIGNMENT_LIMITS,
UPDATE_COURSE_GRADE_LIMITS,
RESET_FILTERS,
UPDATE_INCLUDE_COURSE_ROLE_MEMBERS,
} from '../constants/actionTypes/filters';
import initialFilters from '../constants/filters';
import { getAssignmentsFromResultsSubstate, chooseRelevantAssignmentData } from '../selectors/filters';
const { getAssignmentsFromResultsSubstate, chooseRelevantAssignmentData } = filterSelectors;
const initialState = {};
const reducer = (state = initialState, action) => {
@@ -15,8 +20,8 @@ const reducer = (state = initialState, action) => {
...state,
assignmentType: action.filterType,
assignment: (
action.filterType !== '' &&
(state.assignment || {}).type !== action.filterType)
action.filterType !== ''
&& (state.assignment || {}).type !== action.filterType)
? '' : state.assignment,
};
case INITIALIZE_FILTERS:
@@ -68,6 +73,11 @@ const reducer = (state = initialState, action) => {
courseGradeMin: action.data.courseGradeMin,
courseGradeMax: action.data.courseGradeMax,
};
case UPDATE_INCLUDE_COURSE_ROLE_MEMBERS:
return {
...state,
includeCourseRoleMembers: action.data.includeCourseRoleMembers,
};
default:
return state;
}

View File

@@ -30,6 +30,7 @@ const initialState = {
startedFetching: false,
finishedFetching: false,
errorFetching: false,
overrideHistoryError: '',
gradeFormat: 'percent',
showSuccess: false,
prevPage: null,
@@ -83,14 +84,14 @@ const grades = (state = initialState, action) => {
gradeOriginalPossibleAll: action.originalGradePossibleAll,
gradeOriginalEarnedGraded: action.originalGradeEarnedGraded,
gradeOriginalPossibleGraded: action.originalGradePossibleGraded,
errorFetchingOverrideHistory: false,
overrideHistoryError: '',
};
case ERROR_FETCHING_GRADE_OVERRIDE_HISTORY:
return {
...state,
finishedFetchingOverrideHistory: true,
errorFetchingOverrideHistory: true,
overrideHistoryError: action.errorMessage,
};
case STARTED_FETCHING_GRADES:

View File

@@ -6,6 +6,7 @@ import {
TOGGLE_GRADE_FORMAT,
FILTER_BY_ASSIGNMENT_TYPE,
OPEN_BANNER,
ERROR_FETCHING_GRADE_OVERRIDE_HISTORY,
} from '../constants/actionTypes/grades';
const courseId = 'course-v1:edX+DemoX+Demo_Course';
@@ -161,4 +162,17 @@ describe('grades reducer', () => {
type: ERROR_FETCHING_GRADES,
})).toEqual(expected);
});
it('updates fetch grade override history failure state', () => {
const errorMessage = 'This is the error message';
const expected = {
...initialState,
finishedFetchingOverrideHistory: true,
overrideHistoryError: errorMessage,
};
expect(grades(undefined, {
type: ERROR_FETCHING_GRADE_OVERRIDE_HISTORY,
errorMessage,
})).toEqual(expected);
});
});

View File

@@ -10,7 +10,6 @@ const initialState = {
errorFetching: false,
};
const tracks = (state = initialState, action) => {
switch (action.type) {
case GOT_TRACKS:
@@ -37,4 +36,3 @@ const tracks = (state = initialState, action) => {
};
export default tracks;

View File

@@ -0,0 +1,6 @@
const selectors = {
areGradesFrozen: ({ assignmentTypes }) => assignmentTypes.areGradesFrozen,
allAssignmentTypes: ({ assignmentTypes }) => assignmentTypes.results,
};
export default selectors;

View File

@@ -0,0 +1,17 @@
import selectors from './assignmentTypes';
describe('areGradesFrozen', () => {
it('selects areGradesFrozen from state', () => {
const testValue = 'THX 1138';
const areGradesFrozen = selectors.areGradesFrozen({ assignmentTypes: { areGradesFrozen: testValue } });
expect(areGradesFrozen).toEqual(testValue);
});
});
describe('allAssignmentTypes', () => {
it('returns assignment types', () => {
const testAssignmentTypes = ['assignment', 'labs'];
const allAssignmentTypes = selectors.allAssignmentTypes({ assignmentTypes: { results: testAssignmentTypes } });
expect(allAssignmentTypes).toEqual(testAssignmentTypes);
});
});

View File

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

View File

@@ -0,0 +1,49 @@
import selectors from './cohorts';
const testCohorts = [
{
id: '1',
name: 'Cohort 1',
},
{
id: '9000',
name: 'Cohort 9000',
},
];
const nonMatchingId = '9001';
const testState = { cohorts: { results: testCohorts } };
describe('getCohortById', () => {
it('returns cohort when a match is found', () => {
const cohort = selectors.getCohortById(testState, testCohorts[0].id);
expect(cohort).toEqual(testCohorts[0]);
});
it('returns undefined when no match is found', () => {
const cohort = selectors.getCohortById(testState, nonMatchingId);
expect(cohort).toEqual(undefined);
});
});
describe('getCohortNameById', () => {
it('returns a cohort name when cohort matching ID is found', () => {
const cohortName = selectors.getCohortNameById(testState, testCohorts[1].id);
expect(cohortName).toEqual(testCohorts[1].name);
});
it('returns undefined when no matching cohort is found', () => {
const cohortName = selectors.getCohortNameById(testState, nonMatchingId);
expect(cohortName).toEqual(undefined);
});
});
describe('allCohorts', () => {
it('selects cohorts from state', () => {
const allCohorts = selectors.allCohorts(testState);
expect(allCohorts).toEqual(testCohorts);
});
it('returns an empty array when no cohort results present in state', () => {
const allCohorts = selectors.allCohorts({ cohorts: {} });
expect(allCohorts).toEqual([]);
});
});

View File

@@ -1,41 +1,62 @@
const getFilters = state => state.filters || {};
import simpleSelectorFactory from '../utils';
const getAssignmentsFromResultsSubstate = results =>
(results[0] || {}).section_breakdown || [];
const allFilters = (state) => state.filters || {};
const getAssignmentsFromResultsSubstate = (results) => (
(results[0] || {}).section_breakdown || []
);
const selectableAssignments = (state) => {
const selectedAssignmentType = getFilters(state).assignmentType;
const selectedAssignmentType = allFilters(state).assignmentType;
const needToFilter = selectedAssignmentType && selectedAssignmentType !== 'All';
const allAssignments = getAssignmentsFromResultsSubstate(state.grades.results);
if (needToFilter) {
return allAssignments.filter(assignment => assignment.category === selectedAssignmentType);
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 chooseRelevantAssignmentData = ({
label,
subsection_name: subsectionLabel,
category,
module_id: id,
}) => ({
label, subsectionLabel, category, id,
});
const selectableAssignmentLabels = state =>
selectableAssignments(state).map(chooseRelevantAssignmentData);
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;
};
const simpleSelectors = simpleSelectorFactory(
({ filters }) => filters,
[
'assignment',
'assignmentGradeMax',
'assignmentGradeMin',
'assignmentType',
'cohort',
'courseGradeMax',
'courseGradeMin',
'track',
'includeCourseRoleMembers',
],
);
const selectedAssignmentId = (state) => (simpleSelectors.assignment(state) || {}).id;
const selectedAssignmentLabel = (state) => (simpleSelectors.assignment(state) || {}).label;
export {
const selectors = {
...simpleSelectors,
selectedAssignmentId,
selectedAssignmentLabel,
selectableAssignmentLabels,
selectableAssignments,
getFilters,
typeOfSelectedAssignment,
allFilters,
chooseRelevantAssignmentData,
getAssignmentsFromResultsSubstate,
};
export default selectors;

View File

@@ -0,0 +1,137 @@
import selectors from './filters';
const selectedAssignmentInfo = {
category: 'Homework',
id: 'block-v1:edX+type@sequential+block@abcde',
label: 'HW 01',
subsectionLabel: 'Example Week 1: Getting Started',
};
const filters = {
assignment: selectedAssignmentInfo,
assignmentGradeMax: '100',
assignmentGradeMin: '0',
assignmentType: 'Homework',
cohort: 'Spring Term',
courseGradeMax: '100',
courseGradeMin: '0',
includeCourseRoleMembers: false,
track: 'masters',
};
const noFilters = {
assignment: undefined,
assignmentGradeMax: '100',
assignmentGradeMin: '0',
assignmentType: 'All',
cohort: '',
courseGradeMax: '100',
courseGradeMin: '0',
includeCourseRoleMembers: false,
track: '',
};
const sectionBreakdowns = [
{
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)',
module_id: 'block-v1:edX+type@sequential+block@abcde',
label: 'HW 01',
category: 'Homework',
},
{
subsection_name: 'Example Week 2: Going Deeper',
score_earned: 1,
score_possible: 42,
percent: 0,
displayed_value: '0.02',
grade_description: '(0.00/0.00)',
module_id: 'block-v1:edX+type@sequential+block@bcdef',
label: 'LAB 01',
category: 'Labs',
},
];
const gradesData = { results: [{ section_breakdown: sectionBreakdowns }] };
const testState = {
filters,
grades: gradesData,
};
describe('allFilters', () => {
it('selects all filters from state', () => {
const allFilters = selectors.allFilters(testState);
expect(allFilters).toEqual(filters);
});
it('returns an empty object when no filters are in state', () => {
const allFilters = selectors.allFilters({});
expect(allFilters).toEqual({});
});
});
describe('selectedAssignmentId', () => {
it('gets filtered assignment ID when available', () => {
const assignmentId = selectors.selectedAssignmentId(testState);
expect(assignmentId).toEqual(filters.assignment.id);
});
it('returns undefined when assignment ID unavailable', () => {
const assignmentId = selectors.selectedAssignmentId({ filters: { assignment: undefined } });
expect(assignmentId).toEqual(undefined);
});
});
describe('selectedAssignmentLabel', () => {
it('gets filtered assignment label when available', () => {
const assignmentLabel = selectors.selectedAssignmentLabel(testState);
expect(assignmentLabel).toEqual(filters.assignment.label);
});
it('returns undefined when assignment label is unavailable', () => {
const assignmentLabel = selectors.selectedAssignmentLabel({ filters: { assignment: undefined } });
expect(assignmentLabel).toEqual(undefined);
});
});
describe('selectableAssignmentLabels', () => {
it('gets assignment data for sections matching selected type filters', () => {
const selectableAssignmentLabels = selectors.selectableAssignmentLabels(testState);
expect(selectableAssignmentLabels).toEqual([filters.assignment]);
});
});
describe('selectableAssignments', () => {
it('returns all graded assignments when no assignment type filtering is applied', () => {
const selectableAssignments = selectors.selectableAssignments({ grades: gradesData, filters: noFilters });
expect(selectableAssignments).toEqual(sectionBreakdowns);
});
it('gets assignments of the selected category when assignment type filtering is applied', () => {
const selectableAssignments = selectors.selectableAssignments(testState);
expect(selectableAssignments).toEqual([sectionBreakdowns[0]]);
});
});
describe('chooseRelevantAssignmentData', () => {
it('maps label, subsection, category, and ID from assignment data', () => {
const assignmentData = selectors.chooseRelevantAssignmentData(sectionBreakdowns[0]);
expect(assignmentData).toEqual(selectedAssignmentInfo);
});
});
describe('getAssignmentsFromResultsSubstate', () => {
it('gets section breakdowns from state', () => {
const assignments = selectors.getAssignmentsFromResultsSubstate(gradesData.results);
expect(assignments).toEqual(sectionBreakdowns);
});
it('returns an empty array when results are not supplied', () => {
const assignments = selectors.getAssignmentsFromResultsSubstate([]);
expect(assignments).toEqual([]);
});
it('returns an empty array when section breakdowns are not supplied', () => {
const assignments = selectors.getAssignmentsFromResultsSubstate([{}]);
expect(assignments).toEqual([]);
});
});

View File

@@ -1,5 +1,6 @@
import { formatDateForDisplay } from '../actions/utils';
import { getFilters } from './filters';
import simpleSelectorFactory from '../utils';
import { EMAIL_HEADING, TOTAL_COURSE_GRADE_HEADING, USERNAME_HEADING } from '../constants/grades';
const getRowsProcessed = (data) => {
const {
@@ -15,28 +16,25 @@ const getRowsProcessed = (data) => {
};
};
const transformHistoryEntry = (historyRow) => {
const {
modified,
original_filename: originalFilename,
data,
...rest
} = historyRow;
const transformHistoryEntry = ({
modified,
original_filename: originalFilename,
data,
...rest
}) => ({
timeUploaded: formatDateForDisplay(new Date(modified)),
originalFilename,
summaryOfRowsProcessed: getRowsProcessed(data),
...rest,
});
const timeUploaded = formatDateForDisplay(new Date(modified));
const summaryOfRowsProcessed = getRowsProcessed(data);
const bulkManagementHistory = ({ grades: { bulkManagement } }) => (
(bulkManagement.history || [])
);
return {
timeUploaded,
originalFilename,
summaryOfRowsProcessed,
...rest,
};
};
const getBulkManagementHistoryFromState = state =>
state.grades.bulkManagement.history || [];
const getBulkManagementHistory = state =>
getBulkManagementHistoryFromState(state).map(transformHistoryEntry);
const bulkManagementHistoryEntries = (state) => (
bulkManagementHistory(state).map(transformHistoryEntry)
);
const headingMapper = (category, label = 'All') => {
const filters = {
@@ -54,13 +52,13 @@ const headingMapper = (category, label = 'All') => {
return (entry) => {
if (entry) {
const results = ['Username', 'Email'];
const results = [USERNAME_HEADING, EMAIL_HEADING];
const assignmentHeadings = entry
.filter(filters[filter])
.map(s => s.label);
const totals = ['Total'];
const totals = [TOTAL_COURSE_GRADE_HEADING];
return results.concat(assignmentHeadings).concat(totals);
}
@@ -68,27 +66,16 @@ const headingMapper = (category, label = 'All') => {
};
};
const getExampleSectionBreakdown = state => (state.grades.results[0] || {}).section_breakdown || [];
const getExampleSectionBreakdown = ({ grades }) => (
(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 composeFilters = (...predicates) => (percentGrade, options = {}) => predicates.reduce((accum, predicate) => {
if (predicate(percentGrade, options)) {
return null;
}
return accum;
}, percentGrade);
const percentGradeIsMax = percentGrade => (
percentGrade === '100'
@@ -104,20 +91,56 @@ const assignmentIdIsDefined = (percentGrade, { assignmentId }) => (
const formatMaxCourseGrade = composeFilters(percentGradeIsMax);
const formatMinCourseGrade = composeFilters(percentGradeIsMin);
const formatMaxAssignmentGrade = composeFilters(
percentGradeIsMax,
assignmentIdIsDefined,
);
const formatMinAssignmentGrade = composeFilters(
percentGradeIsMin,
assignmentIdIsDefined,
);
export {
getBulkManagementHistory,
getHeadings,
const simpleSelectors = simpleSelectorFactory(
({ grades }) => grades,
[
'filteredUsersCount',
'totalUsersCount',
'gradeFormat',
'showSpinner',
'gradeOverrideCurrentEarnedGradedOverride',
'gradeOverrideHistoryError',
'gradeOriginalEarnedGraded',
'gradeOriginalPossibleGraded',
'showSuccess',
],
);
const allGrades = ({ grades: { results } }) => results;
const uploadSuccess = ({ grades: { bulkManagement } }) => (!!bulkManagement && bulkManagement.uploadSuccess);
const bulkImportError = ({ grades: { bulkManagement } }) => (
(!!bulkManagement && bulkManagement.errorMessages)
? `Errors while processing: ${bulkManagement.errorMessages.join(', ')}`
: ''
);
const gradeOverrides = ({ grades }) => grades.gradeOverrideHistoryResults;
const selectors = {
bulkImportError,
formatMinAssignmentGrade,
formatMaxAssignmentGrade,
formatMaxCourseGrade,
formatMinCourseGrade,
getExampleSectionBreakdown,
headingMapper,
...simpleSelectors,
allGrades,
uploadSuccess,
bulkManagementHistoryEntries,
gradeOverrides,
};
export default selectors;

View File

@@ -1,4 +1,4 @@
import { getBulkManagementHistory } from './grades';
import selectors from './grades';
const genericHistoryRow = {
id: 5,
@@ -15,14 +15,243 @@ const genericHistoryRow = {
},
};
describe('getBulkManagementHistory', () => {
const genericResultsRows = [
{
attempted: true,
category: 'Homework',
label: 'HW 01',
module_id: 'block-v1:edX+Term+type@sequential+block@1',
percent: 1,
score_earned: 1,
score_possible: 1,
subsection_name: 'Week 1',
},
{
attempted: true,
category: 'Homework',
label: 'HW 02',
module_id: 'block-v1:edX+Term+type@sequential+block@2',
percent: 1,
score_earned: 1,
score_possible: 1,
subsection_name: 'Week 2',
},
{
attempted: false,
category: 'Lab',
label: 'Lab 01',
module_id: 'block-v1:edX+Term+type@sequential+block@3',
percent: 0,
score_earned: 0,
score_possible: 0,
subsection_name: 'Week 3',
},
];
describe('bulkImportError', () => {
it('returns an empty string when bulkManagement not run', () => {
const result = selectors.bulkImportError({ grades: { bulkManagement: null } });
expect(result).toEqual('');
});
it('returns an empty string when bulkManagement runs without error', () => {
const result = selectors.bulkImportError({ grades: { bulkManagement: { uploadSuccess: true } } });
expect(result).toEqual('');
});
it('returns error string when bulkManagement encounters an error', () => {
const errorMessages = ['error1', 'also error2'];
const expectedErrorString = `Errors while processing: ${errorMessages[0]}, ${errorMessages[1]}`;
const result = selectors.bulkImportError({ grades: { bulkManagement: { errorMessages } } });
expect(result).toEqual(expectedErrorString);
});
});
describe('grade formatters', () => {
const selectedAssignment = { assignmentId: 'block-v1:edX+type@sequential+block@abcde' };
describe('formatMinAssignmentGrade', () => {
const defaultGrade = '0';
const modifiedGrade = '1';
it('passes numbers through when grade is not default (0) and assignment is supplied', () => {
const formattedMinAssignmentGrade = selectors.formatMinAssignmentGrade(modifiedGrade, selectedAssignment);
expect(formattedMinAssignmentGrade).toEqual(modifiedGrade);
});
it('ignores grade when unmodified from default (0)', () => {
const formattedMinAssignmentGrade = selectors.formatMinAssignmentGrade(defaultGrade, selectedAssignment);
expect(formattedMinAssignmentGrade).toEqual(null);
});
it('ignores grade when an assignment is not supplied', () => {
const formattedMinAssignmentGrade = selectors.formatMinAssignmentGrade(modifiedGrade, {});
expect(formattedMinAssignmentGrade).toEqual(null);
});
});
describe('formatMaxAssignmentGrade', () => {
const defaultGrade = '100';
const modifiedGrade = '99';
it('passes numbers through when grade is not default (100) and assignment is supplied', () => {
const formattedMaxAssignmentGrade = selectors.formatMaxAssignmentGrade(modifiedGrade, selectedAssignment);
expect(formattedMaxAssignmentGrade).toEqual(modifiedGrade);
});
it('ignores grade when unmodified from default (100)', () => {
const formattedMaxAssignmentGrade = selectors.formatMaxAssignmentGrade(defaultGrade, selectedAssignment);
expect(formattedMaxAssignmentGrade).toEqual(null);
});
it('ignores grade when an assignment is not supplied', () => {
const formattedMaxAssignmentGrade = selectors.formatMaxAssignmentGrade(modifiedGrade, {});
expect(formattedMaxAssignmentGrade).toEqual(null);
});
});
describe('formatMinCourseGrade', () => {
const defaultGrade = '0';
const modifiedGrade = '37';
it('passes numbers through when grade is not default (0) and assignment is supplied', () => {
const formattedMinGrade = selectors.formatMinCourseGrade(modifiedGrade, selectedAssignment);
expect(formattedMinGrade).toEqual(modifiedGrade);
});
it('ignores grade when unmodified from default (0)', () => {
const formattedMinGrade = selectors.formatMinCourseGrade(defaultGrade, selectedAssignment);
expect(formattedMinGrade).toEqual(null);
});
});
describe('formatMaxCourseGrade', () => {
const defaultGrade = '100';
const modifiedGrade = '42';
it('passes numbers through when grade is not default (100) and assignment is supplied', () => {
const formattedMaxGrade = selectors.formatMaxCourseGrade(modifiedGrade, selectedAssignment);
expect(formattedMaxGrade).toEqual(modifiedGrade);
});
it('ignores unmodified grades', () => {
const formattedMaxGrade = selectors.formatMaxCourseGrade(defaultGrade, selectedAssignment);
expect(formattedMaxGrade).toEqual(null);
});
});
});
describe('getExampleSectionBreakdown', () => {
const gradesData = {
next: null,
previous: null,
results: [
{
section_breakdown: [
{
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)',
},
],
},
],
};
it('returns an empty array when results are unavailable', () => {
const result = selectors.getExampleSectionBreakdown({ grades: { results: [{}] } });
expect(result).toEqual([]);
});
it('returns an empty array when breakdowns are unavailable', () => {
const result = selectors.getExampleSectionBreakdown({ grades: { results: [{ foo: 'bar' }] } });
expect(result).toEqual([]);
});
it('gets section breakdown when available', () => {
const result = selectors.getExampleSectionBreakdown({ grades: gradesData });
expect(result).toEqual([{
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)',
}]);
});
});
describe('headingMapper', () => {
const expectedHeaders = (subsectionLabels) => (['Username', 'Email', ...subsectionLabels, 'Total Grade (%)']);
it('creates headers for all assignments when no filtering is applied', () => {
const allSubsectionLabels = ['HW 01', 'HW 02', 'Lab 01'];
const headingMapper = selectors.headingMapper('All');
const headers = headingMapper(genericResultsRows);
expect(headers).toEqual(expectedHeaders(allSubsectionLabels));
});
it('creates headers for only matching assignment types when type filter is applied', () => {
const homeworkHeaders = ['HW 01', 'HW 02'];
const headingMapper = selectors.headingMapper('Homework');
const headers = headingMapper(genericResultsRows);
expect(headers).toEqual(expectedHeaders(homeworkHeaders));
});
it('creates headers for only matching assignment when label filter is applied', () => {
const homeworkHeader = ['HW 02'];
const headingMapper = selectors.headingMapper('Homework', 'HW 02');
const headers = headingMapper(genericResultsRows);
expect(headers).toEqual(expectedHeaders(homeworkHeader));
});
it('returns an empty array when no entries are passed', () => {
const headingMapper = selectors.headingMapper('All');
const headers = headingMapper(undefined);
expect(headers).toEqual([]);
});
});
describe('simpleSelectors', () => {
const simpleSelectorState = {
grades: {
filteredUsersCount: 9000,
totalUsersCount: 9001,
gradeFormat: 'percent',
showSpinner: false,
gradeOverrideCurrentEarnedGradedOverride: null,
gradeOverrideHistoryError: null,
gradeOriginalEarnedGraded: null,
gradeOriginalPossibleGraded: null,
showSuccess: false,
},
};
it('selects simple data by name from grades state', () => {
const expectedFilteredUsers = 9000;
const expectedTotalUsers = 9001;
const expectedGradeFormat = 'percent';
// the selector factory is already tested, this just exercises some of these mappings
expect(selectors.filteredUsersCount(simpleSelectorState)).toEqual(expectedFilteredUsers);
expect(selectors.totalUsersCount(simpleSelectorState)).toEqual(expectedTotalUsers);
expect(selectors.gradeFormat(simpleSelectorState)).toEqual(expectedGradeFormat);
});
});
describe('uploadSuccess', () => {
it('shows an upload success when bulk management data returned and completed successfully', () => {
const uploadSuccess = selectors.uploadSuccess({ grades: { bulkManagement: { uploadSuccess: true } } });
expect(uploadSuccess).toEqual(true);
});
it('returns false when bulk management data not returned', () => {
const uploadSuccess = selectors.uploadSuccess({ grades: {} });
expect(uploadSuccess).toEqual(false);
});
});
describe('bulkManagementHistoryEntries', () => {
it('handles history being as-yet unloaded', () => {
const result = getBulkManagementHistory({ grades: { bulkManagement: {} } });
const result = selectors.bulkManagementHistoryEntries({ grades: { bulkManagement: {} } });
expect(result).toEqual([]);
});
it('formats dates for us', () => {
const result = getBulkManagementHistory({
const result = selectors.bulkManagementHistoryEntries({
grades: {
bulkManagement: {
history: [
@@ -37,7 +266,7 @@ describe('getBulkManagementHistory', () => {
});
const exerciseGetRowsProcessed = (input, expectation) => {
const result = getBulkManagementHistory({
const result = selectors.bulkManagementHistoryEntries({
grades: {
bulkManagement: {
history: [
@@ -50,7 +279,7 @@ describe('getBulkManagementHistory', () => {
expect(summaryOfRowsProcessed).toEqual(expect.objectContaining(expectation));
};
it('calculates skippage', () => {
it('calculates skipped rows', () => {
exerciseGetRowsProcessed({
total_rows: 100,
processed_rows: 10,

View File

@@ -0,0 +1,71 @@
import LmsApiService from 'data/services/LmsApiService';
import assignmentTypes from './assignmentTypes';
import cohorts from './cohorts';
import filters from './filters';
import grades from './grades';
import roles from './roles';
import special from './special';
import tracks from './tracks';
const lmsApiServiceArgs = (state) => ({
cohort: cohorts.getCohortNameById(state, filters.cohort(state)),
assignment: filters.selectedAssignmentId(state),
assignmentType: filters.assignmentType(state),
assignmentGradeMin: grades.formatMinAssignmentGrade(
filters.assignmentGradeMin(state),
{ assignmentId: filters.selectedAssignmentId(state) },
),
assignmentGradeMax: grades.formatMaxAssignmentGrade(
filters.assignmentGradeMax(state),
{ assignmentId: filters.selectedAssignmentId(state) },
),
courseGradeMin: grades.formatMinCourseGrade(filters.courseGradeMin(state)),
courseGradeMax: grades.formatMaxCourseGrade(filters.courseGradeMax(state)),
});
const gradeExportUrl = (state, { courseId }) => (
LmsApiService.getGradeExportCsvUrl(courseId, {
...lmsApiServiceArgs(state),
excludeCourseRoles: filters.includeCourseRoleMembers(state) ? '' : 'all',
})
);
const interventionExportUrl = (state, { courseId }) => (
LmsApiService.getInterventionExportCsvUrl(
courseId,
lmsApiServiceArgs(state),
)
);
const showBulkManagement = (state, { courseId }) => (
special.hasSpecialBulkManagementAccess(courseId)
|| (tracks.stateHasMastersTrack(state) && state.config.bulkManagementAvailable)
);
const shouldShowSpinner = (state) => {
const canView = roles.canUserViewGradebook(state);
return canView && grades.showSpinner(state);
};
const getHeadings = (state) => grades.headingMapper(
filters.assignmentType(state) || 'All',
filters.selectedAssignmentLabel(state) || 'All',
)(grades.getExampleSectionBreakdown(state));
export default {
root: {
getHeadings,
gradeExportUrl,
interventionExportUrl,
showBulkManagement,
shouldShowSpinner,
},
assignmentTypes,
cohorts,
filters,
grades,
roles,
special,
tracks,
};

Some files were not shown because too many files have changed in this diff Show More