Compare commits
5 Commits
v1.4.29
...
kdmccormic
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7a13d58f2 | ||
|
|
6728242671 | ||
|
|
beb2c3cbae | ||
|
|
973c6e9a0a | ||
|
|
09cfee335f |
17
.babelrc
Executable file
17
.babelrc
Executable file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"presets": [
|
||||||
|
[
|
||||||
|
"env",
|
||||||
|
{
|
||||||
|
"targets": {
|
||||||
|
"browsers": ["last 2 versions", "ie 11"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"babel-preset-react"
|
||||||
|
],
|
||||||
|
"plugins": [
|
||||||
|
"transform-object-rest-spread",
|
||||||
|
"transform-class-properties"
|
||||||
|
]
|
||||||
|
}
|
||||||
35
.env
35
.env
@@ -1,35 +0,0 @@
|
|||||||
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,
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
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
|
|
||||||
27
.eslintrc
Executable file
27
.eslintrc
Executable file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"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
14
.eslintrc.js
@@ -1,14 +0,0 @@
|
|||||||
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
6
.github/CODEOWNERS
vendored
@@ -1,6 +0,0 @@
|
|||||||
# Code owners for frontend-app-gradebook, editable gradebook micro-frontend (MFE)
|
|
||||||
|
|
||||||
# These owners will be the default owners for everything in
|
|
||||||
# the repo. Unless a later match takes precedence, they will
|
|
||||||
# be requested for review when someone opens a pull request.
|
|
||||||
* @edx/masters-devs-gta
|
|
||||||
29
.github/pull_request_template.md
vendored
29
.github/pull_request_template.md
vendored
@@ -1,29 +0,0 @@
|
|||||||
**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
7
.gitignore
vendored
@@ -1,5 +1,6 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.eslintcache
|
.eslintcache
|
||||||
|
.idea
|
||||||
node_modules
|
node_modules
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
coverage
|
coverage
|
||||||
@@ -11,9 +12,3 @@ dist/
|
|||||||
|
|
||||||
### Emacs ###
|
### Emacs ###
|
||||||
*~
|
*~
|
||||||
*.swo
|
|
||||||
*.swp
|
|
||||||
|
|
||||||
### Development environments ###
|
|
||||||
.idea
|
|
||||||
.vscode
|
|
||||||
|
|||||||
69
README.md
69
README.md
@@ -1,60 +1,18 @@
|
|||||||
[](https://travis-ci.com/edx/frontend-app-gradebook) [](https://coveralls.io/github/edx/frontend-app-gradebook)
|
[](https://travis-ci.org/edx/frontend-app-gradebook) [](https://coveralls.io/github/edx/frontend-app-gradebook)
|
||||||
[](@edx/frontend-app-gradebook)
|
[](@edx/frontend-app-gradebook)
|
||||||
[](@edx/frontend-app-gradebook)
|
[](@edx/frontend-app-gradebook)
|
||||||
[](@edx/frontend-app-gradebook)
|
[](@edx/frontend-app-gradebook)
|
||||||
[](https://github.com/semantic-release/semantic-release)
|
[](https://github.com/semantic-release/semantic-release)
|
||||||
|
|
||||||
# Gradebook
|
# gradebook
|
||||||
|
|
||||||
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.
|
Please tag **@edx/educator-neem** on any PRs or issues.
|
||||||
|
|
||||||
Jump to:
|
## Introduction
|
||||||
|
|
||||||
- [Should I use Gradebook in my course?](#should-i-use-gradebook-in-my-course)
|
The front-end of our editable Gradebook feature.
|
||||||
- [Quickstart](#quickstart)
|
|
||||||
|
|
||||||
For existing documentation see:
|
## Usage
|
||||||
|
|
||||||
- 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?
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
The micro-frontend offers a great deal more granularity when searching for problems, an easy interface for editing grades, an
|
|
||||||
audit trail for seeing who edited what grade and what reason they gave (if any) for doing so.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
UsageProblems can be filtered by student as in the traditional gradebook, but can also be filtered by scores to see who
|
|
||||||
scored within a certain range, and by assignment types (note: Not problem types, but categories like ‘Exams’ or
|
|
||||||
‘Homework’).
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
### What does the legacy gradebook offer that this project does not?
|
|
||||||
|
|
||||||
This project does not (yet, at least) create any graphs, which the traditional gradebook does. It also does not give
|
|
||||||
quick links to the problems for the instructor to visit. It expects the instructor to be familiar with the problems they
|
|
||||||
are grading and which unit they refer to.
|
|
||||||
|
|
||||||
The gradebook is expected to be much more performant for larger numbers of students as well. The Instructor Dashboard
|
|
||||||
link for the legacy gradebook reports that "this feature is available only to courses with a small number of enrolled
|
|
||||||
learners." However, this project comes with no such warning.
|
|
||||||
|
|
||||||
### Who should not change to this gradebook?
|
|
||||||
|
|
||||||
Groups whose instructors need not ever manually override grades do not need this project, but may not be any worse off
|
|
||||||
depending on their needs. Instructors that expect to review grades infrequently enough that not having a direct link
|
|
||||||
to the problem in question will have a worse UX than the legacy gradebook provides. Instructors that rely on the graphs
|
|
||||||
generated by the current gradebook might find the lack of autogenerated graphs to be frustrating.
|
|
||||||
|
|
||||||
## Quickstart
|
|
||||||
|
|
||||||
### Installation
|
|
||||||
|
|
||||||
To install gradebook into your project:
|
To install gradebook into your project:
|
||||||
```
|
```
|
||||||
@@ -74,7 +32,7 @@ Note that starting the container executes the `npm run start` script which will
|
|||||||
## Configuring for local use in edx-platform
|
## 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
|
Assuming you've got the UI running at `http://localhost:1994`, you can configure the LMS in edx-platform
|
||||||
to point to your local gradebook from the instructor dashboard by putting this setting in `lms/env/private.py`:
|
to point to your local gradebook from the instructor dashboard by putting this settings in `lms/env/private.py`:
|
||||||
```
|
```
|
||||||
WRITABLE_GRADEBOOK_URL = 'http://localhost:1994'
|
WRITABLE_GRADEBOOK_URL = 'http://localhost:1994'
|
||||||
```
|
```
|
||||||
@@ -86,15 +44,10 @@ 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.
|
2. Waffle > Switches. Add the ``grades.assume_zero_grade_if_absent`` switch and make it active.
|
||||||
|
|
||||||
3. Waffle_utils > Waffle flag course overrides. Activate waffle flags for courses where you want to enable Gradebook functionality:
|
3. Waffle_utils > Waffle flag course overrides. You want to activate this flag for any course
|
||||||
- Enable Gradebook by adding the ``grades.writable_gradebook`` add checking the ``enabled`` box.
|
in which you'd like to enable the gradebook. Add a course override flag using a course id and the flag name
|
||||||
- Enable Bulk Grade Management by adding the ``grades.bulk_management`` flag and checking the ``enabled`` box.
|
``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.
|
||||||
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
|
## Running tests
|
||||||
|
|
||||||
|
|||||||
BIN
assets/edx-footer.png
Normal file
BIN
assets/edx-footer.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
BIN
assets/edx-sm.png
Normal file
BIN
assets/edx-sm.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
@@ -1,3 +0,0 @@
|
|||||||
const { createConfig } = require('@edx/frontend-build');
|
|
||||||
|
|
||||||
module.exports = createConfig('babel');
|
|
||||||
16
config/webpack.common.config.js
Executable file
16
config/webpack.common.config.js
Executable file
@@ -0,0 +1,16 @@
|
|||||||
|
// 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'],
|
||||||
|
},
|
||||||
|
};
|
||||||
147
config/webpack.dev.config.js
Executable file
147
config/webpack.dev.config.js
Executable file
@@ -0,0 +1,147 @@
|
|||||||
|
// 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:19000/gradebook',
|
||||||
|
LMS_BASE_URL: 'http://localhost:18000',
|
||||||
|
LOGIN_URL: 'http://localhost:18000/login',
|
||||||
|
LOGOUT_URL: 'http://localhost:18000/logout',
|
||||||
|
CSRF_TOKEN_API_PATH: '/csrf/api/v1/token',
|
||||||
|
REFRESH_ACCESS_TOKEN_ENDPOINT: 'http://localhost:18000/login_refresh',
|
||||||
|
DATA_API_BASE_URL: 'http://localhost:8000',
|
||||||
|
// 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
149
config/webpack.prod.config.js
Executable file
149
config/webpack.prod.config.js
Executable file
@@ -0,0 +1,149 @@
|
|||||||
|
// 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.
|
Before Width: | Height: | Size: 254 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 240 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 319 KiB |
@@ -1,100 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
const { createConfig } = require('@edx/frontend-build');
|
|
||||||
|
|
||||||
module.exports = createConfig('jest', {
|
|
||||||
setupFilesAfterEnv: [
|
|
||||||
'<rootDir>/src/setupTest.js',
|
|
||||||
],
|
|
||||||
modulePaths: ['<rootDir>/src/'],
|
|
||||||
snapshotSerializers: [
|
|
||||||
'enzyme-to-json/serializer',
|
|
||||||
],
|
|
||||||
});
|
|
||||||
10
openedx.yaml
10
openedx.yaml
@@ -1,9 +1,9 @@
|
|||||||
# This file describes this Open edX repo, as described in OEP-2:
|
# 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
|
# http://open-edx-proposals.readthedocs.io/en/latest/oeps/oep-0002.html#specification
|
||||||
|
|
||||||
tags:
|
nick: grbk
|
||||||
- frontend-app
|
oeps: {}
|
||||||
- masters
|
owner: schenedx
|
||||||
oeps:
|
supporting_teams:
|
||||||
oep-2: true # Repository metadata
|
- masters-devs
|
||||||
openedx-release: {ref: master}
|
openedx-release: {ref: master}
|
||||||
|
|||||||
35974
package-lock.json
generated
Normal file → Executable file
35974
package-lock.json
generated
Normal file → Executable file
File diff suppressed because it is too large
Load Diff
111
package.json
111
package.json
@@ -1,21 +1,21 @@
|
|||||||
{
|
{
|
||||||
"name": "@edx/frontend-app-gradebook",
|
"name": "@edx/frontend-app-gradebook",
|
||||||
"version": "1.4.29",
|
"version": "0.1.0",
|
||||||
"description": "edx editable gradebook-ui to manipulate grade overrides on subsections",
|
"description": "edx editable gradebook-ui to manipulate grade overrides on subsections",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://github.com/edx/frontend-app-gradebook.git"
|
"url": "git+https://github.com/edx/frontend-app-gradebook.git"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "fedx-scripts webpack",
|
"build": "NODE_ENV=production BABEL_ENV=production webpack --config=config/webpack.prod.config.js",
|
||||||
|
"dev-build": "NODE_ENV=development BABEL_ENV=development webpack --config=config/webpack.dev.config.js",
|
||||||
"coveralls": "cat ./coverage/lcov.info | coveralls",
|
"coveralls": "cat ./coverage/lcov.info | coveralls",
|
||||||
"is-es5": "es-check es5 ./dist/*.js",
|
"is-es5": "es-check es5 ./dist/*.js",
|
||||||
"lint": "fedx-scripts eslint --ext .jsx,.js src/",
|
"lint": "eslint --ext .js --ext .jsx .",
|
||||||
"lint-fix": "fedx-scripts eslint --fix --ext .jsx,.js src/",
|
"precommit": "npm run lint",
|
||||||
"prepush": "npm run lint",
|
|
||||||
"semantic-release": "semantic-release",
|
"semantic-release": "semantic-release",
|
||||||
"start": "fedx-scripts webpack-dev-server --progress",
|
"start": "NODE_ENV=development BABEL_ENV=development node_modules/.bin/webpack-dev-server --config=config/webpack.dev.config.js --progress",
|
||||||
"test": "TZ=GMT fedx-scripts jest --coverage --passWithNoTests",
|
"test": "jest --coverage --passWithNoTests",
|
||||||
"watch-tests": "jest --watch",
|
"watch-tests": "jest --watch",
|
||||||
"travis-deploy-once": "travis-deploy-once"
|
"travis-deploy-once": "travis-deploy-once"
|
||||||
},
|
},
|
||||||
@@ -26,57 +26,96 @@
|
|||||||
"access": "public"
|
"access": "public"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@edx/brand": "npm:@edx/brand-edx.org@^1.3.2",
|
"@edx/edx-bootstrap": "^0.4.3",
|
||||||
"@edx/frontend-component-footer": "10.1.1",
|
"@edx/frontend-auth": "^4.0.0",
|
||||||
"@edx/frontend-platform": "1.8.1",
|
"@edx/frontend-component-footer": "^4.1.5",
|
||||||
"@edx/paragon": "14.6.1",
|
"@edx/paragon": "^7.1.5",
|
||||||
"@fortawesome/fontawesome-svg-core": "^1.2.25",
|
"@fortawesome/fontawesome-svg-core": "^1.2.25",
|
||||||
"@fortawesome/free-brands-svg-icons": "^5.11.2",
|
"@fortawesome/free-brands-svg-icons": "^5.11.2",
|
||||||
"@fortawesome/free-solid-svg-icons": "^5.11.2",
|
"@fortawesome/free-solid-svg-icons": "^5.11.2",
|
||||||
"@fortawesome/react-fontawesome": "^0.1.5",
|
"@fortawesome/react-fontawesome": "^0.1.5",
|
||||||
"@redux-beacon/segment": "^1.0.0",
|
"@redux-beacon/segment": "^1.0.0",
|
||||||
"@reduxjs/toolkit": "^1.5.1",
|
"babel-polyfill": "^6.26.0",
|
||||||
"classnames": "^2.2.6",
|
"classnames": "^2.2.6",
|
||||||
"core-js": "3.6.5",
|
|
||||||
"email-prop-type": "^1.1.7",
|
"email-prop-type": "^1.1.7",
|
||||||
"enzyme": "^3.10.0",
|
"font-awesome": "^4.7.0",
|
||||||
"enzyme-to-json": "^3.6.2",
|
"history": "^4.10.1",
|
||||||
"font-awesome": "4.7.0",
|
"prop-types": "^15.7.2",
|
||||||
"history": "4.10.1",
|
"query-string": "^5.1.1",
|
||||||
"node-sass": "^4.14.1",
|
"react": "^16.10.1",
|
||||||
"prop-types": "15.7.2",
|
"react-dom": "^16.10.1",
|
||||||
"query-string": "6.13.0",
|
|
||||||
"react": "16.13.1",
|
|
||||||
"react-dom": "16.13.1",
|
|
||||||
"react-intl": "^2.9.0",
|
"react-intl": "^2.9.0",
|
||||||
"react-redux": "^5.1.1",
|
"react-redux": "^5.1.1",
|
||||||
"react-router": "5.2.0",
|
"react-router": "^4.3.1",
|
||||||
"react-router-dom": "5.2.0",
|
"react-router-dom": "^4.3.1",
|
||||||
"react-router-redux": "^5.0.0-alpha.9",
|
"react-router-redux": "^5.0.0-alpha.9",
|
||||||
"redux": "4.0.5",
|
"redux": "^3.7.2",
|
||||||
"redux-beacon": "^2.1.0",
|
"redux-beacon": "^2.1.0",
|
||||||
"redux-devtools-extension": "2.13.8",
|
"redux-devtools-extension": "^2.13.8",
|
||||||
"redux-logger": "3.0.6",
|
"redux-logger": "^3.0.6",
|
||||||
"redux-thunk": "2.3.0",
|
"redux-thunk": "^2.3.0",
|
||||||
"regenerator-runtime": "^0.13.7",
|
|
||||||
"util": "^0.12.3",
|
|
||||||
"whatwg-fetch": "^2.0.4"
|
"whatwg-fetch": "^2.0.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@edx/frontend-build": "5.5.2",
|
"autoprefixer": "^9.6.1",
|
||||||
"axios": "0.21.1",
|
|
||||||
"axios-mock-adapter": "^1.17.0",
|
"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",
|
"codecov": "^3.6.1",
|
||||||
|
"css-loader": "^0.28.11",
|
||||||
|
"enzyme": "^3.10.0",
|
||||||
"enzyme-adapter-react-16": "^1.14.0",
|
"enzyme-adapter-react-16": "^1.14.0",
|
||||||
"es-check": "^2.3.0",
|
"es-check": "^2.3.0",
|
||||||
|
"eslint-config-edx": "^4.0.4",
|
||||||
"fetch-mock": "^6.5.2",
|
"fetch-mock": "^6.5.2",
|
||||||
"husky": "2.7.0",
|
"file-loader": "^1.1.9",
|
||||||
|
"html-webpack-harddisk-plugin": "^0.2.0",
|
||||||
|
"html-webpack-plugin": "^3.2.0",
|
||||||
|
"husky": "^0.14.3",
|
||||||
"identity-obj-proxy": "^3.0.0",
|
"identity-obj-proxy": "^3.0.0",
|
||||||
"jest": "24.9.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",
|
||||||
"react-dev-utils": "^5.0.3",
|
"react-dev-utils": "^5.0.3",
|
||||||
"react-test-renderer": "^16.10.1",
|
"react-test-renderer": "^16.10.1",
|
||||||
"redux-mock-store": "^1.5.3",
|
"redux-mock-store": "^1.5.3",
|
||||||
"semantic-release": "^17.2.3",
|
"sass-loader": "^6.0.6",
|
||||||
"travis-deploy-once": "^5.0.11"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en-us">
|
<html lang="en-us">
|
||||||
<head>
|
<head>
|
||||||
<title>Gradebook | <%= process.env.SITE_NAME %></title>
|
<title>Gradebook | edX</title>
|
||||||
<meta charset="utf-8">
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
10
src/App.scss
10
src/App.scss
@@ -1,15 +1,13 @@
|
|||||||
// frontend-app-*/src/index.scss
|
|
||||||
@import "~@edx/brand/paragon/fonts";
|
@import "~@edx/paragon/scss/edx/theme.scss";
|
||||||
@import "~@edx/brand/paragon/variables";
|
|
||||||
@import "~@edx/paragon/scss/core/core";
|
|
||||||
@import "~@edx/brand/paragon/overrides";
|
|
||||||
|
|
||||||
$fa-font-path: "~font-awesome/fonts";
|
$fa-font-path: "~font-awesome/fonts";
|
||||||
@import "~font-awesome/scss/font-awesome";
|
@import "~font-awesome/scss/font-awesome";
|
||||||
|
|
||||||
$input-focus-box-shadow: $input-box-shadow; // hack to get upgrade to paragon 4.0.0 to work
|
$input-focus-box-shadow: $input-box-shadow; // hack to get upgrade to paragon 4.0.0 to work
|
||||||
|
|
||||||
@import "~@edx/frontend-component-footer/dist/_footer";
|
@import "~@edx/frontend-component-footer/src/lib/scss/site-footer";
|
||||||
|
|
||||||
@import "./components/Gradebook/gradebook";
|
@import "./components/Gradebook/gradebook";
|
||||||
@import "./components/Drawer/Drawer";
|
@import "./components/Drawer/Drawer";
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ export default class Drawer extends React.Component {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deferToNextRepaint(callback) {
|
||||||
|
window.requestAnimationFrame(() =>
|
||||||
|
window.setTimeout(callback, 0));
|
||||||
|
}
|
||||||
|
|
||||||
close = () => {
|
close = () => {
|
||||||
if (this.state.open) {
|
if (this.state.open) {
|
||||||
this.toggleOpen();
|
this.toggleOpen();
|
||||||
@@ -34,10 +39,6 @@ export default class Drawer extends React.Component {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
deferToNextRepaint(callback) {
|
|
||||||
window.requestAnimationFrame(() => window.setTimeout(callback, 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<div className="d-flex drawer-container">
|
<div className="d-flex drawer-container">
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
|
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import initialFilters from '../../data/constants/filters';
|
import initialFilters from '../../data/constants/filters';
|
||||||
|
|
||||||
function FilterBadge({
|
function FilterBadge({ name, value, onClick }) {
|
||||||
name, value, onClick, showValue,
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<span className="badge badge-info">
|
<span className="badge badge-info">
|
||||||
<span>{name}{showValue && `: ${value}`}</span>
|
<span>{`${name}: ${value}`}</span>
|
||||||
<button type="button" className="btn-info" aria-label="Close" onClick={onClick}>
|
<button type="button" className="btn-info" aria-label="Close" onClick={onClick}>
|
||||||
<span aria-hidden="true">×</span>
|
<span aria-hidden="true">×</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -20,20 +19,6 @@ function FilterBadge({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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({
|
function RangeFilterBadge({
|
||||||
displayName,
|
displayName,
|
||||||
filterName1,
|
filterName1,
|
||||||
@@ -42,54 +27,28 @@ function RangeFilterBadge({
|
|||||||
filterValue2,
|
filterValue2,
|
||||||
handleBadgeClose,
|
handleBadgeClose,
|
||||||
}) {
|
}) {
|
||||||
return ((filterValue1 !== initialFilters[filterName1])
|
return ((filterValue1 !== initialFilters[filterName1]) ||
|
||||||
|| (filterValue2 !== initialFilters[filterName2]))
|
(filterValue2 !== initialFilters[filterName2]))
|
||||||
&& (
|
&&
|
||||||
<FilterBadge
|
<FilterBadge
|
||||||
name={displayName}
|
name={displayName}
|
||||||
value={`${filterValue1} - ${filterValue2}`}
|
value={`${filterValue1} - ${filterValue2}`}
|
||||||
onClick={handleBadgeClose}
|
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({
|
function SingleValueFilterBadge({
|
||||||
displayName, filterName, filterValue, handleBadgeClose, showValue,
|
displayName, filterName, filterValue, handleBadgeClose,
|
||||||
}) {
|
}) {
|
||||||
return (filterValue !== initialFilters[filterName])
|
return (filterValue !== initialFilters[filterName]) &&
|
||||||
&& (
|
|
||||||
<FilterBadge
|
<FilterBadge
|
||||||
name={displayName}
|
name={displayName}
|
||||||
value={filterValue}
|
value={filterValue}
|
||||||
onClick={handleBadgeClose}
|
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({
|
function FilterBadges({
|
||||||
assignment,
|
assignment,
|
||||||
assignmentType,
|
assignmentType,
|
||||||
@@ -99,7 +58,6 @@ function FilterBadges({
|
|||||||
assignmentGradeMax,
|
assignmentGradeMax,
|
||||||
courseGradeMin,
|
courseGradeMin,
|
||||||
courseGradeMax,
|
courseGradeMax,
|
||||||
includeCourseRoleMembers,
|
|
||||||
handleFilterBadgeClose,
|
handleFilterBadgeClose,
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -140,17 +98,10 @@ function FilterBadges({
|
|||||||
/>
|
/>
|
||||||
<SingleValueFilterBadge
|
<SingleValueFilterBadge
|
||||||
displayName="Cohort"
|
displayName="Cohort"
|
||||||
filterName="cohort"
|
filterName="track"
|
||||||
filterValue={cohort}
|
filterValue={cohort}
|
||||||
handleBadgeClose={handleFilterBadgeClose(['cohort'])}
|
handleBadgeClose={handleFilterBadgeClose(['cohort'])}
|
||||||
/>
|
/>
|
||||||
<SingleValueFilterBadge
|
|
||||||
displayName="Including Course Team Members"
|
|
||||||
filterName="includeCourseRoleMembers"
|
|
||||||
filterValue={includeCourseRoleMembers}
|
|
||||||
showValue={false}
|
|
||||||
handleBadgeClose={handleFilterBadgeClose(['includeCourseRoleMembers'])}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -165,13 +116,18 @@ const mapStateToProps = state => (
|
|||||||
assignmentGradeMax: state.filters.assignmentGradeMax,
|
assignmentGradeMax: state.filters.assignmentGradeMax,
|
||||||
courseGradeMin: state.filters.courseGradeMin,
|
courseGradeMin: state.filters.courseGradeMin,
|
||||||
courseGradeMax: state.filters.courseGradeMax,
|
courseGradeMax: state.filters.courseGradeMax,
|
||||||
includeCourseRoleMembers: state.filters.includeCourseRoleMembers,
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const ConnectedFilterBadges = connect(mapStateToProps)(FilterBadges);
|
const ConnectedFilterBadges = connect(mapStateToProps)(FilterBadges);
|
||||||
export default ConnectedFilterBadges;
|
export default ConnectedFilterBadges;
|
||||||
|
|
||||||
|
FilterBadge.propTypes = {
|
||||||
|
name: PropTypes.string.isRequired,
|
||||||
|
value: PropTypes.string.isRequired,
|
||||||
|
onClick: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
FilterBadges.defaultProps = {
|
FilterBadges.defaultProps = {
|
||||||
assignment: initialFilters.assignmentType,
|
assignment: initialFilters.assignmentType,
|
||||||
assignmentType: initialFilters.assignmentType,
|
assignmentType: initialFilters.assignmentType,
|
||||||
@@ -181,7 +137,6 @@ FilterBadges.defaultProps = {
|
|||||||
assignmentGradeMax: initialFilters.assignmentGradeMax,
|
assignmentGradeMax: initialFilters.assignmentGradeMax,
|
||||||
courseGradeMin: initialFilters.courseGradeMin,
|
courseGradeMin: initialFilters.courseGradeMin,
|
||||||
courseGradeMax: initialFilters.courseGradeMax,
|
courseGradeMax: initialFilters.courseGradeMax,
|
||||||
includeCourseRoleMembers: initialFilters.includeCourseRoleMembers,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
FilterBadges.propTypes = {
|
FilterBadges.propTypes = {
|
||||||
@@ -193,6 +148,6 @@ FilterBadges.propTypes = {
|
|||||||
assignmentGradeMax: PropTypes.string,
|
assignmentGradeMax: PropTypes.string,
|
||||||
courseGradeMin: PropTypes.string,
|
courseGradeMin: PropTypes.string,
|
||||||
courseGradeMax: PropTypes.string,
|
courseGradeMax: PropTypes.string,
|
||||||
includeCourseRoleMembers: PropTypes.bool,
|
|
||||||
handleFilterBadgeClose: PropTypes.func.isRequired,
|
handleFilterBadgeClose: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,199 +0,0 @@
|
|||||||
/* eslint-disable react/sort-comp, react/button-has-type */
|
|
||||||
import React from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
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 thunkActions from 'data/thunkActions';
|
|
||||||
import { configuration } from 'config';
|
|
||||||
|
|
||||||
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}
|
|
||||||
open={!!this.props.bulkImportError}
|
|
||||||
dismissible={false}
|
|
||||||
/>
|
|
||||||
<StatusAlert
|
|
||||||
alertType="success"
|
|
||||||
dialog="CSV processing. File uploads may take several minutes to complete"
|
|
||||||
open={this.props.uploadSuccess}
|
|
||||||
dismissible={false}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
className="d-none"
|
|
||||||
type="file"
|
|
||||||
name="csv"
|
|
||||||
label="Upload Grade CSV"
|
|
||||||
onChange={this.handleFileInputChange}
|
|
||||||
ref={this.fileInputRef}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
onClick={this.handleClickImportGrades}
|
|
||||||
>
|
|
||||||
Import Grades
|
|
||||||
</Button>
|
|
||||||
<p>
|
|
||||||
Results appear in the table below.<br />
|
|
||||||
Grade processing may take a few seconds.
|
|
||||||
</p>
|
|
||||||
<Table
|
|
||||||
data={this.props.bulkManagementHistory.map(this.formatHistoryRow)}
|
|
||||||
hasFixedColumnWidths
|
|
||||||
columns={[
|
|
||||||
{
|
|
||||||
key: 'filename',
|
|
||||||
label: 'Gradebook',
|
|
||||||
columnSortable: false,
|
|
||||||
width: 'col-5',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'resultsSummary',
|
|
||||||
label: 'Download Summary',
|
|
||||||
columnSortable: false,
|
|
||||||
width: 'col',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'user',
|
|
||||||
label: 'Who',
|
|
||||||
columnSortable: false,
|
|
||||||
width: 'col-1',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'timeUploaded',
|
|
||||||
label: 'When',
|
|
||||||
columnSortable: false,
|
|
||||||
width: 'col',
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
className="table-striped"
|
|
||||||
/>
|
|
||||||
</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: thunkActions.grades.submitFileUploadFormData,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(BulkManagement);
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
/* eslint-disable react/sort-comp, react/button-has-type */
|
|
||||||
import React from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import { StatefulButton } from '@edx/paragon';
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import { faDownload, faSpinner } from '@fortawesome/free-solid-svg-icons';
|
|
||||||
|
|
||||||
import actions from 'data/actions';
|
|
||||||
|
|
||||||
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: actions.grades.downloadReport.bulkGrades,
|
|
||||||
downloadInterventionReport: actions.grades.downloadReport.intervention,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(BulkManagementControls);
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
/* eslint-disable react/sort-comp, react/button-has-type */
|
|
||||||
import React from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Modal,
|
|
||||||
StatusAlert,
|
|
||||||
Table,
|
|
||||||
} from '@edx/paragon';
|
|
||||||
|
|
||||||
import selectors from 'data/selectors';
|
|
||||||
import actions from 'data/actions';
|
|
||||||
import thunkActions from 'data/thunkActions';
|
|
||||||
|
|
||||||
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: actions.grades.doneViewingAssignment,
|
|
||||||
updateGrades: thunkActions.grades.updateGrades,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(EditModal);
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
// 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>
|
|
||||||
`;
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
/* eslint-disable react/sort-comp, react/button-has-type */
|
|
||||||
import React from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import selectors from 'data/selectors';
|
|
||||||
import actions from 'data/actions';
|
|
||||||
import thunkActions from 'data/thunkActions';
|
|
||||||
|
|
||||||
import SelectGroup from '../SelectGroup';
|
|
||||||
|
|
||||||
const { updateGradesIfAssignmentGradeFiltersSet } = thunkActions.grades;
|
|
||||||
|
|
||||||
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: actions.filters.update.assignment,
|
|
||||||
updateGradesIfAssignmentGradeFiltersSet,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(AssignmentFilter);
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { mount, shallow } from 'enzyme';
|
|
||||||
|
|
||||||
import selectors from 'data/selectors';
|
|
||||||
import actions from 'data/actions';
|
|
||||||
import { updateGradesIfAssignmentGradeFiltersSet } from 'data/thunkActions/grades';
|
|
||||||
import {
|
|
||||||
AssignmentFilter,
|
|
||||||
mapStateToProps,
|
|
||||||
mapDispatchToProps,
|
|
||||||
} from '.';
|
|
||||||
|
|
||||||
jest.mock('data/thunkActions/grades', () => ({
|
|
||||||
updateGradesIfAssignmentGradeFiltersSet: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
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(
|
|
||||||
actions.filters.update.assignment,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
test('updateGradesIfAsssignmentGradeFiltersSet', () => {
|
|
||||||
const prop = mapDispatchToProps.updateGradesIfAssignmentGradeFiltersSet;
|
|
||||||
expect(prop).toEqual(updateGradesIfAssignmentGradeFiltersSet);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
// 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>
|
|
||||||
`;
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
/* eslint-disable react/sort-comp, react/button-has-type */
|
|
||||||
import React from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import { Button } from '@edx/paragon';
|
|
||||||
|
|
||||||
import selectors from 'data/selectors';
|
|
||||||
import actions from 'data/actions';
|
|
||||||
import thunkActions from 'data/thunkActions';
|
|
||||||
|
|
||||||
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({
|
|
||||||
maxGrade: assignmentGradeMax,
|
|
||||||
minGrade: assignmentGradeMin,
|
|
||||||
});
|
|
||||||
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: thunkActions.grades.fetchGrades,
|
|
||||||
updateAssignmentLimits: actions.filters.update.assignmentLimits,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(AssignmentGradeFilter);
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { mount, shallow } from 'enzyme';
|
|
||||||
|
|
||||||
import actions from 'data/actions';
|
|
||||||
import { fetchGrades } from 'data/thunkActions/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({
|
|
||||||
maxGrade: props.filterValues.assignmentGradeMax,
|
|
||||||
minGrade: props.filterValues.assignmentGradeMin,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
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(
|
|
||||||
actions.filters.update.assignmentLimits,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
// 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>
|
|
||||||
`;
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
/* eslint-disable react/sort-comp, react/button-has-type */
|
|
||||||
import React from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import selectors from 'data/selectors';
|
|
||||||
import actions from 'data/actions';
|
|
||||||
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: actions.filters.update.assignmentType,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(AssignmentTypeFilter);
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { shallow } from 'enzyme';
|
|
||||||
|
|
||||||
import selectors from 'data/selectors';
|
|
||||||
import actions from 'data/actions';
|
|
||||||
|
|
||||||
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(
|
|
||||||
actions.filters.update.assignmentType,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
// 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>
|
|
||||||
`;
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
/* 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 selectors from 'data/selectors';
|
|
||||||
import actions from 'data/actions';
|
|
||||||
import thunkActions from 'data/thunkActions';
|
|
||||||
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,
|
|
||||||
courseId: 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: actions.filters.update.courseGradeLimits,
|
|
||||||
getUserGrades: thunkActions.grades.fetchGrades,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(CourseGradeFilter);
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
/* eslint-disable import/no-named-as-default */
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { shallow } from 'enzyme';
|
|
||||||
|
|
||||||
import actions from 'data/actions';
|
|
||||||
import { fetchGrades } from 'data/thunkActions/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({
|
|
||||||
courseGradeMin: props.filterValues.courseGradeMin,
|
|
||||||
courseGradeMax: props.filterValues.courseGradeMax,
|
|
||||||
courseId: 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(actions.filters.update.courseGradeLimits);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
describe('getUserGrades', () => {
|
|
||||||
test('from fetchGrades', () => {
|
|
||||||
expect(mapDispatchToProps.getUserGrades).toEqual(fetchGrades);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
/* 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;
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
/* 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;
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
// 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>,
|
|
||||||
]
|
|
||||||
`;
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
/* 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 selectors from 'data/selectors';
|
|
||||||
import thunkActions from 'data/thunkActions';
|
|
||||||
|
|
||||||
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: thunkActions.grades.fetchGrades,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(StudentGroupsFilter);
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
/* eslint-disable import/no-named-as-default */
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { shallow } from 'enzyme';
|
|
||||||
|
|
||||||
import { fetchGrades } from 'data/thunkActions/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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
// 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>
|
|
||||||
`;
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
// 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>
|
|
||||||
`;
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
// 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>
|
|
||||||
`;
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
/* 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 actions from 'data/actions';
|
|
||||||
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: actions.filters.update.includeCourseRoleMembers,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(GradebookFilters);
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { shallow } from 'enzyme';
|
|
||||||
|
|
||||||
import actions from 'data/actions';
|
|
||||||
|
|
||||||
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(
|
|
||||||
actions.filters.update.includeCourseRoleMembers,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,234 +0,0 @@
|
|||||||
/* eslint-disable react/sort-comp, react/button-has-type */
|
|
||||||
import React from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import {
|
|
||||||
Table, OverlayTrigger, Tooltip, Icon,
|
|
||||||
} from '@edx/paragon';
|
|
||||||
|
|
||||||
import { EMAIL_HEADING, TOTAL_COURSE_GRADE_HEADING, USERNAME_HEADING } from 'data/constants/grades';
|
|
||||||
import selectors from 'data/selectors';
|
|
||||||
import { formatDateForDisplay } from 'data/actions/utils';
|
|
||||||
import thunkActions from 'data/thunkActions';
|
|
||||||
|
|
||||||
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: thunkActions.grades.fetchGradeOverrideHistory,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(GradebookTable);
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
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 thunkActions from 'data/thunkActions';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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: thunkActions.grades.fetchGrades,
|
|
||||||
searchForUser: thunkActions.grades.fetchMatchingUserGrades,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(SearchControls);
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { shallow } from 'enzyme';
|
|
||||||
|
|
||||||
import {
|
|
||||||
fetchGrades,
|
|
||||||
fetchMatchingUserGrades,
|
|
||||||
} from '../../data/thunkActions/grades';
|
|
||||||
import { mapDispatchToProps, mapStateToProps, SearchControls } from './SearchControls';
|
|
||||||
|
|
||||||
jest.mock('@edx/paragon', () => ({
|
|
||||||
Icon: 'Icon',
|
|
||||||
Button: 'Button',
|
|
||||||
SearchField: 'SearchField',
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('SearchControls', () => {
|
|
||||||
let props;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.resetAllMocks();
|
|
||||||
props = {
|
|
||||||
courseId: 'course-v1:edX+DEV101+T1',
|
|
||||||
filterValue: 'alice',
|
|
||||||
selectedAssignmentType: 'homework',
|
|
||||||
selectedCohort: 'spring term',
|
|
||||||
selectedTrack: 'masters',
|
|
||||||
getUserGrades: jest.fn(),
|
|
||||||
searchForUser: jest.fn(),
|
|
||||||
setFilterValue: jest.fn(),
|
|
||||||
toggleFilterDrawer: jest.fn().mockName('toggleFilterDrawer'),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const searchControls = (overriddenProps) => {
|
|
||||||
props = { ...props, ...overriddenProps };
|
|
||||||
return shallow(<SearchControls {...props} />);
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('Component', () => {
|
|
||||||
describe('onSubmit', () => {
|
|
||||||
it('calls props.searchForUser with correct data', () => {
|
|
||||||
const wrapper = searchControls();
|
|
||||||
wrapper.instance().onSubmit('bob');
|
|
||||||
|
|
||||||
expect(props.searchForUser).toHaveBeenCalledWith(
|
|
||||||
props.courseId,
|
|
||||||
'bob',
|
|
||||||
props.selectedCohort,
|
|
||||||
props.selectedTrack,
|
|
||||||
props.selectedAssignmentType,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('onChange', () => {
|
|
||||||
it('saves the changed search value to Gradebook state', () => {
|
|
||||||
const wrapper = searchControls();
|
|
||||||
wrapper.instance().onChange('bob');
|
|
||||||
expect(props.setFilterValue).toHaveBeenCalledWith('bob');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('onClear', () => {
|
|
||||||
it('re-runs search with existing filters', () => {
|
|
||||||
const wrapper = searchControls();
|
|
||||||
wrapper.instance().onClear();
|
|
||||||
expect(props.getUserGrades).toHaveBeenCalledWith(
|
|
||||||
props.courseId,
|
|
||||||
props.selectedCohort,
|
|
||||||
props.selectedTrack,
|
|
||||||
props.selectedAssignmentType,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('mapStateToProps', () => {
|
|
||||||
const state = {
|
|
||||||
filters: {
|
|
||||||
assignmentType: 'labs',
|
|
||||||
track: 'honor',
|
|
||||||
cohort: 'fall term',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
it('maps assignment type filter correctly', () => {
|
|
||||||
expect(mapStateToProps(state).selectedAssignmentType).toEqual(state.filters.assignmentType);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('maps track filter correctly', () => {
|
|
||||||
expect(mapStateToProps(state).selectedTrack).toEqual(state.filters.track);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('maps cohort filter correctly', () => {
|
|
||||||
expect(mapStateToProps(state).selectedCohort).toEqual(state.filters.cohort);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('mapDispatchToProps', () => {
|
|
||||||
test('getUserGrades', () => {
|
|
||||||
expect(mapDispatchToProps.getUserGrades).toEqual(fetchGrades);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('searchForUser', () => {
|
|
||||||
expect(mapDispatchToProps.searchForUser).toEqual(fetchMatchingUserGrades);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Snapshots', () => {
|
|
||||||
test('basic snapshot', () => {
|
|
||||||
const wrapper = searchControls();
|
|
||||||
wrapper.instance().onChange = jest.fn().mockName('onChange');
|
|
||||||
wrapper.instance().onClear = jest.fn().mockName('onClear');
|
|
||||||
wrapper.instance().onSubmit = jest.fn().mockName('onSubmit');
|
|
||||||
expect(wrapper.instance().render()).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
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 actions from 'data/actions';
|
|
||||||
|
|
||||||
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: actions.grades.banner.close,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(StatusAlerts);
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { shallow } from 'enzyme';
|
|
||||||
|
|
||||||
import actions from 'data/actions';
|
|
||||||
import {
|
|
||||||
StatusAlerts,
|
|
||||||
mapDispatchToProps,
|
|
||||||
mapStateToProps,
|
|
||||||
maxCourseGradeInvalidMessage,
|
|
||||||
minCourseGradeInvalidMessage,
|
|
||||||
} from './StatusAlerts';
|
|
||||||
|
|
||||||
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(
|
|
||||||
actions.grades.banner.close,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
|
||||||
|
|
||||||
exports[`SearchControls Component Snapshots basic snapshot 1`] = `
|
|
||||||
<React.Fragment>
|
|
||||||
<h4>
|
|
||||||
Step 1: Filter the Grade Report
|
|
||||||
</h4>
|
|
||||||
<div
|
|
||||||
className="d-flex justify-content-between"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
className="btn-primary align-self-start"
|
|
||||||
id="edit-filters-btn"
|
|
||||||
onClick={[MockFunction toggleFilterDrawer]}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
className="fa fa-filter"
|
|
||||||
/>
|
|
||||||
Edit Filters
|
|
||||||
</Button>
|
|
||||||
<div>
|
|
||||||
<SearchField
|
|
||||||
inputLabel="Search for a learner"
|
|
||||||
onChange={[MockFunction onChange]}
|
|
||||||
onClear={[MockFunction onClear]}
|
|
||||||
onSubmit={[MockFunction onSubmit]}
|
|
||||||
value="alice"
|
|
||||||
/>
|
|
||||||
<small
|
|
||||||
className="form-text text-muted search-help-text"
|
|
||||||
>
|
|
||||||
Search by username, email, or student key
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</React.Fragment>
|
|
||||||
`;
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
// 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>
|
|
||||||
`;
|
|
||||||
@@ -31,9 +31,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.student-filters{
|
.student-filters{
|
||||||
|
display: flex;
|
||||||
.label{
|
.label{
|
||||||
padding-top: 30px;
|
padding-top: 30px;
|
||||||
}
|
}
|
||||||
|
.form-group{
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.grade-history-header{
|
.grade-history-header{
|
||||||
float: left;
|
float: left;
|
||||||
@@ -53,17 +57,9 @@
|
|||||||
.grade-history-current-grade{
|
.grade-history-current-grade{
|
||||||
padding-right: 25px;
|
padding-right: 25px;
|
||||||
}
|
}
|
||||||
.gradebook-container {
|
|
||||||
width: 100%;
|
|
||||||
overflow-x: auto;
|
|
||||||
height: 600px;
|
|
||||||
overflow-y: auto;
|
|
||||||
word-break: break-word;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gbook {
|
.gbook {
|
||||||
width: 100%;
|
overflow-x: scroll;
|
||||||
|
|
||||||
.grade-button {
|
.grade-button {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
@@ -73,27 +69,18 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#courseGradeTooltipIcon {
|
.table {
|
||||||
float: right;
|
padding-left: 244px;
|
||||||
}
|
// prevents the table from shrinking to a width where "Final 01" breaks to two lines
|
||||||
|
min-width: 731px;
|
||||||
.table thead tr {
|
th {
|
||||||
min-height: 60px;
|
vertical-align: top;
|
||||||
&:nth-child(1) {
|
font-size: 14px;
|
||||||
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 {
|
.table thead tr {
|
||||||
display: block;
|
height: 60px;
|
||||||
word-break: break-word;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.table tr th:first-child {
|
.table tr th:first-child {
|
||||||
@@ -102,50 +89,16 @@
|
|||||||
.table tr th:first-child,
|
.table tr th:first-child,
|
||||||
.table tr td:first-child {
|
.table tr td:first-child {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
|
width: 160px;
|
||||||
left: 0;
|
left: 0;
|
||||||
z-index: 1; // to float over the following children in the side-scrolling case
|
z-index: 1; // to float over the following children in the side-scrolling case
|
||||||
background: white;
|
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 {
|
.table tbody th {
|
||||||
font-weight: normal;
|
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 {
|
.link-style {
|
||||||
color: #0075b4;
|
color: #0075b4;
|
||||||
&:hover, &:focus {
|
&:hover, &:focus {
|
||||||
@@ -153,33 +106,10 @@
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group, .pgn__form-group {
|
.input-percent-label {
|
||||||
label {
|
margin-top: 10px;
|
||||||
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 {
|
.mb-85 {
|
||||||
|
|||||||
@@ -1,49 +1,53 @@
|
|||||||
/* eslint-disable react/sort-comp, react/button-has-type, import/no-named-as-default */
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import {
|
import {
|
||||||
|
Button,
|
||||||
|
Collapsible,
|
||||||
Icon,
|
Icon,
|
||||||
InputSelect,
|
InputSelect,
|
||||||
Tab,
|
InputText,
|
||||||
|
Modal,
|
||||||
|
SearchField,
|
||||||
|
StatefulButton,
|
||||||
|
StatusAlert,
|
||||||
|
Table,
|
||||||
Tabs,
|
Tabs,
|
||||||
} from '@edx/paragon';
|
} from '@edx/paragon';
|
||||||
import queryString from 'query-string';
|
import queryString from 'query-string';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faFilter } from '@fortawesome/free-solid-svg-icons';
|
import { faDownload, faSpinner, faFilter } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { configuration } from '../../config';
|
import { configuration } from '../../config';
|
||||||
import PageButtons from '../PageButtons';
|
import PageButtons from '../PageButtons';
|
||||||
import Drawer from '../Drawer';
|
import Drawer from '../Drawer';
|
||||||
|
import { formatDateForDisplay } from '../../data/actions/utils';
|
||||||
import initialFilters from '../../data/constants/filters';
|
import initialFilters from '../../data/constants/filters';
|
||||||
import ConnectedFilterBadges from '../FilterBadges';
|
import ConnectedFilterBadges from '../FilterBadges';
|
||||||
|
|
||||||
import BulkManagement from './BulkManagement';
|
|
||||||
import BulkManagementControls from './BulkManagementControls';
|
const DECIMAL_PRECISION = 2;
|
||||||
import EditModal from './EditModal';
|
const GRADE_OVERRIDE_HISTORY_COLUMNS = [{ label: 'Date', key: 'date' }, { label: 'Grader', key: 'grader' },
|
||||||
import GradebookFilters from './GradebookFilters';
|
{ label: 'Reason', key: 'reason' },
|
||||||
import GradebookTable from './GradebookTable';
|
{ label: 'Adjusted grade', key: 'adjustedGrade' }];
|
||||||
import SearchControls from './SearchControls';
|
|
||||||
import StatusAlerts from './StatusAlerts';
|
|
||||||
|
|
||||||
export default class Gradebook extends React.Component {
|
export default class Gradebook extends React.Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
adjustedGradePossible: '',
|
filterValue: '',
|
||||||
adjustedGradeValue: 0,
|
|
||||||
assignmentGradeMin: '0',
|
|
||||||
assignmentGradeMax: '100',
|
|
||||||
assignmentName: '',
|
|
||||||
courseGradeMin: '0',
|
courseGradeMin: '0',
|
||||||
courseGradeMax: '100',
|
courseGradeMax: '100',
|
||||||
filterValue: '',
|
|
||||||
isMinCourseGradeFilterValid: true,
|
|
||||||
isMaxCourseGradeFilterValid: true,
|
|
||||||
modalOpen: false,
|
modalOpen: false,
|
||||||
reasonForChange: '',
|
adjustedGradeValue: 0,
|
||||||
todaysDate: '',
|
|
||||||
updateModuleId: null,
|
updateModuleId: null,
|
||||||
updateUserId: null,
|
updateUserId: null,
|
||||||
|
reasonForChange: '',
|
||||||
|
assignmentGradeMin: '0',
|
||||||
|
assignmentGradeMax: '100',
|
||||||
|
isMinCourseGradeFilterValid: true,
|
||||||
|
isMaxCourseGradeFilterValid: true,
|
||||||
};
|
};
|
||||||
|
this.fileFormRef = React.createRef();
|
||||||
|
this.fileInputRef = React.createRef();
|
||||||
this.myRef = React.createRef();
|
this.myRef = React.createRef();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +55,7 @@ export default class Gradebook extends React.Component {
|
|||||||
const urlQuery = queryString.parse(this.props.location.search);
|
const urlQuery = queryString.parse(this.props.location.search);
|
||||||
this.props.initializeFilters(urlQuery);
|
this.props.initializeFilters(urlQuery);
|
||||||
this.props.getRoles(this.props.courseId);
|
this.props.getRoles(this.props.courseId);
|
||||||
|
this.overrideReasonInput.focus();
|
||||||
|
|
||||||
const newStateFields = {};
|
const newStateFields = {};
|
||||||
['assignmentGradeMin', 'assignmentGradeMax', 'courseGradeMin', 'courseGradeMax'].forEach((attr) => {
|
['assignmentGradeMin', 'assignmentGradeMax', 'courseGradeMin', 'courseGradeMax'].forEach((attr) => {
|
||||||
@@ -66,6 +71,37 @@ export default class Gradebook extends React.Component {
|
|||||||
this.setState({ [e.target.name]: e.target.value });
|
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 = () => {
|
getActiveTabs = () => {
|
||||||
if (this.props.showBulkManagement) {
|
if (this.props.showBulkManagement) {
|
||||||
return ['Grades', 'Bulk Management'];
|
return ['Grades', 'Bulk Management'];
|
||||||
@@ -73,6 +109,76 @@ export default class Gradebook extends React.Component {
|
|||||||
return ['Grades'];
|
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) => {
|
updateQueryParams = (queryParams) => {
|
||||||
const parsed = queryString.parse(this.props.location.search);
|
const parsed = queryString.parse(this.props.location.search);
|
||||||
Object.keys(queryParams).forEach((key) => {
|
Object.keys(queryParams).forEach((key) => {
|
||||||
@@ -85,8 +191,332 @@ export default class Gradebook extends React.Component {
|
|||||||
this.props.history.push(`?${queryString.stringify(parsed)}`);
|
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`;
|
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 => () => {
|
handleFilterBadgeClose = filterNames => () => {
|
||||||
this.props.resetFilters(filterNames);
|
this.props.resetFilters(filterNames);
|
||||||
const queryParams = {};
|
const queryParams = {};
|
||||||
@@ -110,46 +540,6 @@ 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() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<Drawer
|
<Drawer
|
||||||
@@ -159,54 +549,81 @@ export default class Gradebook extends React.Component {
|
|||||||
href={this.lmsInstructorDashboardUrl(this.props.courseId)}
|
href={this.lmsInstructorDashboardUrl(this.props.courseId)}
|
||||||
className="mb-3"
|
className="mb-3"
|
||||||
>
|
>
|
||||||
<span aria-hidden="true">{'<< '}</span> Back to Dashboard
|
<span aria-hidden="true">{'<< '}</span> {'Back to Dashboard'}
|
||||||
</a>
|
</a>
|
||||||
<h1>Gradebook</h1>
|
<h1>Gradebook</h1>
|
||||||
<h3> {this.props.courseId}</h3>
|
<h3> {this.props.courseId}</h3>
|
||||||
{this.props.areGradesFrozen
|
{this.props.areGradesFrozen &&
|
||||||
&& (
|
<div className="alert alert-warning" role="alert" >
|
||||||
<div className="alert alert-warning" role="alert">
|
|
||||||
The grades for this course are now frozen. Editing of grades is no longer allowed.
|
The grades for this course are now frozen. Editing of grades is no longer allowed.
|
||||||
</div>
|
</div>
|
||||||
)}
|
}
|
||||||
{(this.props.canUserViewGradebook === false)
|
{(this.props.canUserViewGradebook === false) &&
|
||||||
&& (
|
<div className="alert alert-warning" role="alert" >
|
||||||
<div className="alert alert-warning" role="alert">
|
|
||||||
You are not authorized to view the gradebook for this course.
|
You are not authorized to view the gradebook for this course.
|
||||||
</div>
|
</div>
|
||||||
)}
|
}
|
||||||
<Tabs defaultActiveKey="grades">
|
<Tabs labels={this.getActiveTabs()}>
|
||||||
<Tab eventKey="grades" title="Grades">
|
<div>
|
||||||
{this.props.showSpinner && (
|
<h4>Step 1: Filter the Grade Report</h4>
|
||||||
<div className="spinner-overlay">
|
<div className="d-flex justify-content-between" >
|
||||||
<Icon className="fa fa-spinner fa-spin fa-5x color-black" />
|
{this.props.showSpinner && <div className="spinner-overlay"><Icon className="fa fa-spinner fa-spin fa-5x color-black" /></div>}
|
||||||
|
<Button className="btn-primary align-self-start" onClick={toggleFilterDrawer}><FontAwesomeIcon icon={faFilter} /> Edit Filters</Button>
|
||||||
|
<div>
|
||||||
|
<SearchField
|
||||||
|
onSubmit={value =>
|
||||||
|
this.props.searchForUser(
|
||||||
|
this.props.courseId,
|
||||||
|
value,
|
||||||
|
this.props.selectedCohort,
|
||||||
|
this.props.selectedTrack,
|
||||||
|
this.props.selectedAssignmentType,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
inputLabel="Search for a learner"
|
||||||
|
onChange={filterValue => this.setState({ filterValue })}
|
||||||
|
onClear={() =>
|
||||||
|
this.props.getUserGrades(
|
||||||
|
this.props.courseId,
|
||||||
|
this.props.selectedCohort,
|
||||||
|
this.props.selectedTrack,
|
||||||
|
this.props.selectedAssignmentType,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
value={this.state.filterValue}
|
||||||
|
/>
|
||||||
|
<small className="form-text text-muted search-help-text">Search by username, email, or student key</small>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
<SearchControls
|
|
||||||
courseId={this.props.courseId}
|
|
||||||
filterValue={this.state.filterValue}
|
|
||||||
setFilterValue={this.createStateFieldSetter('filterValue')}
|
|
||||||
toggleFilterDrawer={toggleFilterDrawer}
|
|
||||||
/>
|
|
||||||
<ConnectedFilterBadges
|
<ConnectedFilterBadges
|
||||||
handleFilterBadgeClose={this.handleFilterBadgeClose}
|
handleFilterBadgeClose={this.handleFilterBadgeClose}
|
||||||
/>
|
/>
|
||||||
<StatusAlerts
|
<StatusAlert
|
||||||
isMinCourseGradeFilterValid={this.state.isMinCourseGradeFilterValid}
|
alertType="success"
|
||||||
isMaxCourseGradeFilterValid={this.state.isMaxCourseGradeFilterValid}
|
dialog="The grade has been successfully edited. You may see a slight delay before updates appear in the Gradebook."
|
||||||
|
onClose={() => this.props.closeBanner()}
|
||||||
|
open={this.props.showSuccess}
|
||||||
|
/>
|
||||||
|
<StatusAlert
|
||||||
|
alertType="danger"
|
||||||
|
dialog={this.getCourseGradeFilterAlertDialog()}
|
||||||
|
dismissible={false}
|
||||||
|
open={
|
||||||
|
!this.state.isMinCourseGradeFilterValid ||
|
||||||
|
!this.state.isMaxCourseGradeFilterValid
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<h4>Step 2: View or Modify Individual Grades</h4>
|
<h4>Step 2: View or Modify Individual Grades</h4>
|
||||||
{this.props.totalUsersCount
|
{this.props.totalUsersCount ?
|
||||||
? (
|
<div>
|
||||||
<div>
|
Showing
|
||||||
Showing
|
<span className="font-weight-bold"> {this.props.filteredUsersCount} </span>
|
||||||
<span className="font-weight-bold"> {this.props.filteredUsersCount} </span>
|
of
|
||||||
of
|
<span className="font-weight-bold"> {this.props.totalUsersCount} </span>
|
||||||
<span className="font-weight-bold"> {this.props.totalUsersCount} </span>
|
total learners
|
||||||
total learners
|
</div> :
|
||||||
</div>
|
null
|
||||||
)
|
}
|
||||||
: null}
|
|
||||||
<div className="d-flex justify-content-between align-items-center mb-2">
|
<div className="d-flex justify-content-between align-items-center mb-2">
|
||||||
<InputSelect
|
<InputSelect
|
||||||
label="Score View:"
|
label="Score View:"
|
||||||
@@ -216,61 +633,310 @@ export default class Gradebook extends React.Component {
|
|||||||
onChange={this.props.toggleFormat}
|
onChange={this.props.toggleFormat}
|
||||||
/>
|
/>
|
||||||
{this.props.showBulkManagement && (
|
{this.props.showBulkManagement && (
|
||||||
<BulkManagementControls
|
<div>
|
||||||
courseId={this.props.courseId}
|
<StatefulButton
|
||||||
gradeExportUrl={this.props.gradeExportUrl}
|
buttonType="outline-primary"
|
||||||
interventionExportUrl={this.props.interventionExportUrl}
|
onClick={this.handleClickExportGrades}
|
||||||
showSpinner={this.props.showSpinner}
|
state={this.props.showSpinner ? 'pending' : 'default'}
|
||||||
/>
|
labels={{
|
||||||
|
default: 'Bulk Management',
|
||||||
|
pending: 'Bulk Management',
|
||||||
|
}}
|
||||||
|
icons={{
|
||||||
|
default: <FontAwesomeIcon className="mr-2" icon={faDownload} />,
|
||||||
|
pending: <FontAwesomeIcon className="fa-spin mr-2" icon={faSpinner} />,
|
||||||
|
}}
|
||||||
|
disabledStates={['pending']}
|
||||||
|
/>
|
||||||
|
<StatefulButton
|
||||||
|
buttonType="outline-primary"
|
||||||
|
onClick={this.handleClickDownloadInterventions}
|
||||||
|
state={this.props.showSpinner ? 'pending' : 'default'}
|
||||||
|
className="ml-2"
|
||||||
|
labels={{
|
||||||
|
default: 'Interventions*',
|
||||||
|
pending: 'Interventions*',
|
||||||
|
}}
|
||||||
|
icons={{
|
||||||
|
default: <FontAwesomeIcon className="mr-2" icon={faDownload} />,
|
||||||
|
pending: <FontAwesomeIcon className="fa-spin mr-2" icon={faSpinner} />,
|
||||||
|
}}
|
||||||
|
disabledStates={['pending']}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<GradebookTable setGradebookState={this.safeSetState} />
|
<div className="gradebook-container">
|
||||||
|
<div className="gbook">
|
||||||
|
<Table
|
||||||
|
columns={this.formatHeadings()}
|
||||||
|
data={this.formatter[this.props.format](
|
||||||
|
this.props.grades,
|
||||||
|
this.props.areGradesFrozen,
|
||||||
|
)}
|
||||||
|
rowHeaderColumnKey="username"
|
||||||
|
hasFixedColumnWidths
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{PageButtons(this.props)}
|
{PageButtons(this.props)}
|
||||||
<p>* available for learners in the Master's track only</p>
|
<p>* available for learners in the Master's track only</p>
|
||||||
<EditModal
|
<Modal
|
||||||
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}
|
open={this.state.modalOpen}
|
||||||
reasonForChange={this.state.reasonForChange}
|
title="Edit Grades"
|
||||||
setAdjustedGradeValue={this.createStateFieldOnChange('adjustedGradeValue')}
|
closeText="Cancel"
|
||||||
setGradebookState={this.safeSetState}
|
body={(
|
||||||
setReasonForChange={this.createStateFieldOnChange('reasonForChange')}
|
<div>
|
||||||
todaysDate={this.state.todaysDate}
|
<div>
|
||||||
updateModuleId={this.state.updateModuleId}
|
<div className="grade-history-header grade-history-assignment">Assignment: </div> <div>{this.state.modalAssignmentName}</div>
|
||||||
updateUserId={this.state.updateUserId}
|
<div className="grade-history-header grade-history-student">Student: </div> <div>{this.state.updateUserName}</div>
|
||||||
updateUserName={this.state.updateUserName}
|
<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>),
|
||||||
|
}]}
|
||||||
|
/>)}
|
||||||
|
|
||||||
</Tab>
|
<div>Showing most recent actions (max 5). To see more, please contact
|
||||||
{this.props.showBulkManagement
|
support.
|
||||||
&& (
|
</div>
|
||||||
<Tab eventKey="bulk_management" title="Bulk Management">
|
<div>Note: Once you save, your changes will be visible to students.</div>
|
||||||
<BulkManagement
|
</div>
|
||||||
courseId={this.props.courseId}
|
)}
|
||||||
gradeExportUrl={this.props.gradeExportUrl}
|
buttons={[
|
||||||
|
<Button
|
||||||
|
buttonType="primary"
|
||||||
|
onClick={this.handleAdjustedGradeClick}
|
||||||
|
>
|
||||||
|
Save Grade
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
onClose={this.closeAssignmentModal}
|
||||||
|
/>
|
||||||
|
</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>
|
</div>)}
|
||||||
)}
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
initiallyOpen={false}
|
initiallyOpen={false}
|
||||||
title={(
|
title={
|
||||||
<>
|
<React.Fragment>
|
||||||
<FontAwesomeIcon icon={faFilter} /> Filter By...
|
<FontAwesomeIcon icon={faFilter} /> Filter By...
|
||||||
</>
|
</React.Fragment>
|
||||||
)}
|
}
|
||||||
>
|
>
|
||||||
<GradebookFilters
|
<Collapsible title="Assignments" isOpen className="filter-group mb-3">
|
||||||
setFilters={this.setFilters}
|
<div>
|
||||||
filterValues={this.filterValues()}
|
<div className="student-filters">
|
||||||
updateQueryParams={this.updateQueryParams}
|
<span className="label">
|
||||||
courseId={this.props.courseId}
|
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>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -278,42 +944,123 @@ export default class Gradebook extends React.Component {
|
|||||||
|
|
||||||
Gradebook.defaultProps = {
|
Gradebook.defaultProps = {
|
||||||
areGradesFrozen: false,
|
areGradesFrozen: false,
|
||||||
|
assignmentTypes: [],
|
||||||
|
assignmentFilterOptions: [],
|
||||||
canUserViewGradebook: false,
|
canUserViewGradebook: false,
|
||||||
courseId: '',
|
cohorts: [],
|
||||||
filteredUsersCount: null,
|
grades: [],
|
||||||
|
gradeOverrides: [],
|
||||||
|
gradeOverrideCurrentEarnedGradedOverride: null,
|
||||||
|
gradeOriginalEarnedGraded: null,
|
||||||
|
gradeOriginalPossibleGraded: null,
|
||||||
location: {
|
location: {
|
||||||
search: '',
|
search: '',
|
||||||
},
|
},
|
||||||
selectedAssignmentType: '',
|
courseId: '',
|
||||||
selectedCohort: null,
|
selectedCohort: null,
|
||||||
selectedTrack: null,
|
selectedTrack: null,
|
||||||
showBulkManagement: false,
|
selectedAssignmentType: '',
|
||||||
|
selectedAssignment: '',
|
||||||
showSpinner: false,
|
showSpinner: false,
|
||||||
|
tracks: [],
|
||||||
|
bulkImportError: '',
|
||||||
|
uploadSuccess: false,
|
||||||
|
showBulkManagement: false,
|
||||||
|
bulkManagementHistory: [],
|
||||||
|
errorFetchingGradeOverrideHistory: false,
|
||||||
totalUsersCount: null,
|
totalUsersCount: null,
|
||||||
|
filteredUsersCount: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
Gradebook.propTypes = {
|
Gradebook.propTypes = {
|
||||||
areGradesFrozen: PropTypes.bool,
|
areGradesFrozen: PropTypes.bool,
|
||||||
|
assignmentTypes: PropTypes.arrayOf(PropTypes.string),
|
||||||
|
assignmentFilterOptions: PropTypes.arrayOf(PropTypes.shape({
|
||||||
|
label: PropTypes.string,
|
||||||
|
subsectionLabel: PropTypes.string,
|
||||||
|
})),
|
||||||
canUserViewGradebook: PropTypes.bool,
|
canUserViewGradebook: PropTypes.bool,
|
||||||
courseId: PropTypes.string,
|
cohorts: PropTypes.arrayOf(PropTypes.shape({
|
||||||
filteredUsersCount: PropTypes.number,
|
name: PropTypes.string,
|
||||||
|
id: PropTypes.number,
|
||||||
|
})),
|
||||||
|
filterAssignmentType: PropTypes.func.isRequired,
|
||||||
|
updateAssignmentFilter: PropTypes.func.isRequired,
|
||||||
|
updateAssignmentLimits: PropTypes.func.isRequired,
|
||||||
|
format: PropTypes.string.isRequired,
|
||||||
getRoles: PropTypes.func.isRequired,
|
getRoles: PropTypes.func.isRequired,
|
||||||
getUserGrades: PropTypes.func.isRequired,
|
getUserGrades: PropTypes.func.isRequired,
|
||||||
gradeExportUrl: PropTypes.string.isRequired,
|
fetchGradeOverrideHistory: PropTypes.func.isRequired,
|
||||||
|
grades: PropTypes.arrayOf(PropTypes.shape({
|
||||||
|
percent: PropTypes.number,
|
||||||
|
section_breakdown: PropTypes.arrayOf(PropTypes.shape({
|
||||||
|
attempted: PropTypes.bool,
|
||||||
|
category: PropTypes.string,
|
||||||
|
label: PropTypes.string,
|
||||||
|
module_id: PropTypes.string,
|
||||||
|
percent: PropTypes.number,
|
||||||
|
scoreEarned: PropTypes.number,
|
||||||
|
scorePossible: PropTypes.number,
|
||||||
|
subsection_name: PropTypes.string,
|
||||||
|
})),
|
||||||
|
user_id: PropTypes.number,
|
||||||
|
user_name: PropTypes.string,
|
||||||
|
})),
|
||||||
|
gradeOverrides: PropTypes.arrayOf(PropTypes.shape({
|
||||||
|
date: PropTypes.string,
|
||||||
|
grader: PropTypes.string,
|
||||||
|
reason: PropTypes.string,
|
||||||
|
adjustedGrade: PropTypes.number,
|
||||||
|
})),
|
||||||
|
gradeOverrideCurrentEarnedGradedOverride: PropTypes.number,
|
||||||
|
gradeOriginalEarnedGraded: PropTypes.number,
|
||||||
|
gradeOriginalPossibleGraded: PropTypes.number,
|
||||||
|
doneViewingAssignment: PropTypes.func.isRequired,
|
||||||
|
headings: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||||
history: PropTypes.shape({
|
history: PropTypes.shape({
|
||||||
push: PropTypes.func,
|
push: PropTypes.func,
|
||||||
}).isRequired,
|
}).isRequired,
|
||||||
initializeFilters: PropTypes.func.isRequired,
|
|
||||||
interventionExportUrl: PropTypes.string.isRequired,
|
|
||||||
location: PropTypes.shape({
|
location: PropTypes.shape({
|
||||||
search: PropTypes.string,
|
search: PropTypes.string,
|
||||||
}),
|
}),
|
||||||
resetFilters: PropTypes.func.isRequired,
|
courseId: PropTypes.string,
|
||||||
|
searchForUser: PropTypes.func.isRequired,
|
||||||
selectedAssignmentType: PropTypes.string,
|
selectedAssignmentType: PropTypes.string,
|
||||||
|
selectedAssignment: PropTypes.string,
|
||||||
selectedCohort: PropTypes.string,
|
selectedCohort: PropTypes.string,
|
||||||
selectedTrack: PropTypes.string,
|
selectedTrack: PropTypes.string,
|
||||||
showBulkManagement: PropTypes.bool,
|
resetFilters: PropTypes.func.isRequired,
|
||||||
showSpinner: PropTypes.bool,
|
showSpinner: PropTypes.bool,
|
||||||
|
showSuccess: PropTypes.bool.isRequired,
|
||||||
toggleFormat: PropTypes.func.isRequired,
|
toggleFormat: PropTypes.func.isRequired,
|
||||||
|
tracks: PropTypes.arrayOf(PropTypes.shape({
|
||||||
|
name: PropTypes.string,
|
||||||
|
})),
|
||||||
|
closeBanner: PropTypes.func.isRequired,
|
||||||
|
updateGrades: PropTypes.func.isRequired,
|
||||||
|
gradeExportUrl: PropTypes.string.isRequired,
|
||||||
|
interventionExportUrl: PropTypes.string.isRequired,
|
||||||
|
submitFileUploadFormData: PropTypes.func.isRequired,
|
||||||
|
bulkImportError: PropTypes.string,
|
||||||
|
uploadSuccess: PropTypes.bool,
|
||||||
|
errorFetchingGradeOverrideHistory: PropTypes.bool,
|
||||||
|
showBulkManagement: PropTypes.bool,
|
||||||
|
bulkManagementHistory: PropTypes.arrayOf(PropTypes.shape({
|
||||||
|
originalFilename: PropTypes.string.isRequired,
|
||||||
|
user: PropTypes.string.isRequired,
|
||||||
|
timeUploaded: PropTypes.string.isRequired,
|
||||||
|
summaryOfRowsProcessed: PropTypes.shape({
|
||||||
|
total: PropTypes.number.isRequired,
|
||||||
|
successfullyProcessed: PropTypes.number.isRequired,
|
||||||
|
failed: PropTypes.number.isRequired,
|
||||||
|
skipped: PropTypes.number.isRequired,
|
||||||
|
}).isRequired,
|
||||||
|
})),
|
||||||
totalUsersCount: PropTypes.number,
|
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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Hyperlink } from '@edx/paragon';
|
import { Hyperlink } from '@edx/paragon';
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
|
||||||
|
import EdxLogo from '../../../assets/edx-sm.png';
|
||||||
|
|
||||||
export default class Header extends React.Component {
|
export default class Header extends React.Component {
|
||||||
renderLogo() {
|
renderLogo() {
|
||||||
return (
|
return (
|
||||||
<img src={getConfig().LOGO_URL} alt="edX logo" height="30" width="60" />
|
<img src={EdxLogo} alt="edX logo" height="30" width="60" />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ exports[`PageButtons prev not null, next not null 1`] = `
|
|||||||
<button
|
<button
|
||||||
className="btn btn-outline-primary"
|
className="btn btn-outline-primary"
|
||||||
disabled={false}
|
disabled={false}
|
||||||
|
onBlur={[Function]}
|
||||||
onClick={[Function]}
|
onClick={[Function]}
|
||||||
|
onKeyDown={[Function]}
|
||||||
style={
|
style={
|
||||||
Object {
|
Object {
|
||||||
"margin": "20px",
|
"margin": "20px",
|
||||||
@@ -25,7 +27,9 @@ exports[`PageButtons prev not null, next not null 1`] = `
|
|||||||
<button
|
<button
|
||||||
className="btn btn-outline-primary"
|
className="btn btn-outline-primary"
|
||||||
disabled={false}
|
disabled={false}
|
||||||
|
onBlur={[Function]}
|
||||||
onClick={[Function]}
|
onClick={[Function]}
|
||||||
|
onKeyDown={[Function]}
|
||||||
style={
|
style={
|
||||||
Object {
|
Object {
|
||||||
"margin": "20px",
|
"margin": "20px",
|
||||||
@@ -50,7 +54,9 @@ exports[`PageButtons prev not null, next null 1`] = `
|
|||||||
<button
|
<button
|
||||||
className="btn btn-outline-primary"
|
className="btn btn-outline-primary"
|
||||||
disabled={false}
|
disabled={false}
|
||||||
|
onBlur={[Function]}
|
||||||
onClick={[Function]}
|
onClick={[Function]}
|
||||||
|
onKeyDown={[Function]}
|
||||||
style={
|
style={
|
||||||
Object {
|
Object {
|
||||||
"margin": "20px",
|
"margin": "20px",
|
||||||
@@ -63,7 +69,9 @@ exports[`PageButtons prev not null, next null 1`] = `
|
|||||||
<button
|
<button
|
||||||
className="btn btn-outline-primary"
|
className="btn btn-outline-primary"
|
||||||
disabled={true}
|
disabled={true}
|
||||||
|
onBlur={[Function]}
|
||||||
onClick={[Function]}
|
onClick={[Function]}
|
||||||
|
onKeyDown={[Function]}
|
||||||
style={
|
style={
|
||||||
Object {
|
Object {
|
||||||
"margin": "20px",
|
"margin": "20px",
|
||||||
@@ -88,7 +96,9 @@ exports[`PageButtons prev null, next not null 1`] = `
|
|||||||
<button
|
<button
|
||||||
className="btn btn-outline-primary"
|
className="btn btn-outline-primary"
|
||||||
disabled={true}
|
disabled={true}
|
||||||
|
onBlur={[Function]}
|
||||||
onClick={[Function]}
|
onClick={[Function]}
|
||||||
|
onKeyDown={[Function]}
|
||||||
style={
|
style={
|
||||||
Object {
|
Object {
|
||||||
"margin": "20px",
|
"margin": "20px",
|
||||||
@@ -101,7 +111,9 @@ exports[`PageButtons prev null, next not null 1`] = `
|
|||||||
<button
|
<button
|
||||||
className="btn btn-outline-primary"
|
className="btn btn-outline-primary"
|
||||||
disabled={false}
|
disabled={false}
|
||||||
|
onBlur={[Function]}
|
||||||
onClick={[Function]}
|
onClick={[Function]}
|
||||||
|
onKeyDown={[Function]}
|
||||||
style={
|
style={
|
||||||
Object {
|
Object {
|
||||||
"margin": "20px",
|
"margin": "20px",
|
||||||
@@ -126,7 +138,9 @@ exports[`PageButtons prev null, next null 1`] = `
|
|||||||
<button
|
<button
|
||||||
className="btn btn-outline-primary"
|
className="btn btn-outline-primary"
|
||||||
disabled={true}
|
disabled={true}
|
||||||
|
onBlur={[Function]}
|
||||||
onClick={[Function]}
|
onClick={[Function]}
|
||||||
|
onKeyDown={[Function]}
|
||||||
style={
|
style={
|
||||||
Object {
|
Object {
|
||||||
"margin": "20px",
|
"margin": "20px",
|
||||||
@@ -139,7 +153,9 @@ exports[`PageButtons prev null, next null 1`] = `
|
|||||||
<button
|
<button
|
||||||
className="btn btn-outline-primary"
|
className="btn btn-outline-primary"
|
||||||
disabled={true}
|
disabled={true}
|
||||||
|
onBlur={[Function]}
|
||||||
onClick={[Function]}
|
onClick={[Function]}
|
||||||
|
onKeyDown={[Function]}
|
||||||
style={
|
style={
|
||||||
Object {
|
Object {
|
||||||
"margin": "20px",
|
"margin": "20px",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React from 'react';
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { Button } from '@edx/paragon';
|
import { Button } from '@edx/paragon';
|
||||||
|
|
||||||
|
|
||||||
export default function PageButtons({
|
export default function PageButtons({
|
||||||
prevPage, nextPage, selectedTrack, selectedCohort, selectedAssignmentType,
|
prevPage, nextPage, selectedTrack, selectedCohort, selectedAssignmentType,
|
||||||
getPrevNextGrades, match,
|
getPrevNextGrades, match,
|
||||||
@@ -13,29 +14,31 @@ export default function PageButtons({
|
|||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
style={{ margin: '20px' }}
|
style={{ margin: '20px' }}
|
||||||
variant="outline-primary"
|
buttonType="outline-primary"
|
||||||
disabled={!prevPage}
|
disabled={!prevPage}
|
||||||
onClick={() => getPrevNextGrades(
|
onClick={() =>
|
||||||
prevPage,
|
getPrevNextGrades(
|
||||||
match.params.courseId,
|
prevPage,
|
||||||
selectedCohort,
|
match.params.courseId,
|
||||||
selectedTrack,
|
selectedCohort,
|
||||||
selectedAssignmentType,
|
selectedTrack,
|
||||||
)}
|
selectedAssignmentType,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
Previous Page
|
Previous Page
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
style={{ margin: '20px' }}
|
style={{ margin: '20px' }}
|
||||||
variant="outline-primary"
|
buttonType="outline-primary"
|
||||||
disabled={!nextPage}
|
disabled={!nextPage}
|
||||||
onClick={() => getPrevNextGrades(
|
onClick={() =>
|
||||||
nextPage,
|
getPrevNextGrades(
|
||||||
match.params.courseId,
|
nextPage,
|
||||||
selectedCohort,
|
match.params.courseId,
|
||||||
selectedTrack,
|
selectedCohort,
|
||||||
selectedAssignmentType,
|
selectedTrack,
|
||||||
)}
|
selectedAssignmentType,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
Next Page
|
Next Page
|
||||||
</Button>
|
</Button>
|
||||||
@@ -73,3 +76,4 @@ PageButtons.propTypes = {
|
|||||||
name: PropTypes.string,
|
name: PropTypes.string,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,67 +1,145 @@
|
|||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
import thunkActions from 'data/thunkActions';
|
import Gradebook from '../../components/Gradebook';
|
||||||
import actions from 'data/actions';
|
import {
|
||||||
import selectors from 'data/selectors';
|
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';
|
||||||
|
|
||||||
import Gradebook from 'components/Gradebook';
|
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) => {
|
const mapStateToProps = (state, ownProps) => (
|
||||||
const {
|
{
|
||||||
root,
|
courseId: ownProps.match.params.courseId,
|
||||||
assignmentTypes,
|
grades: state.grades.results,
|
||||||
filters,
|
gradeOverrides: state.grades.gradeOverrideHistoryResults,
|
||||||
grades,
|
gradeOverrideCurrentEarnedAllOverride: state.grades.gradeOverrideCurrentEarnedAllOverride,
|
||||||
roles,
|
gradeOverrideCurrentPossibleAllOverride: state.grades.gradeOverrideCurrentPossibleAllOverride,
|
||||||
} = selectors;
|
gradeOverrideCurrentEarnedGradedOverride: state.grades.gradeOverrideCurrentEarnedGradedOverride,
|
||||||
|
gradeOverrideCurrentPossibleGradedOverride:
|
||||||
const { courseId } = ownProps.match.params;
|
state.grades.gradeOverrideCurrentPossibleGradedOverride,
|
||||||
return {
|
gradeOriginalEarnedGraded: state.grades.gradeOriginalEarnedGraded,
|
||||||
courseId,
|
gradeOriginalPossibleGraded: state.grades.gradeOriginalPossibleGraded,
|
||||||
areGradesFrozen: assignmentTypes.areGradesFrozen(state),
|
headings: getHeadings(state),
|
||||||
assignmentTypes: assignmentTypes.allAssignmentTypes(state),
|
tracks: state.tracks.results,
|
||||||
assignmentFilterOptions: filters.selectableAssignmentLabels(state),
|
cohorts: state.cohorts.results,
|
||||||
bulkImportError: grades.bulkImportError(state),
|
selectedTrack: state.filters.track,
|
||||||
bulkManagementHistory: grades.bulkManagementHistoryEntries(state),
|
selectedCohort: state.filters.cohort,
|
||||||
canUserViewGradebook: roles.canUserViewGradebook(state),
|
selectedAssignmentType: state.filters.assignmentType,
|
||||||
filteredUsersCount: grades.filteredUsersCount(state),
|
selectedAssignment: (state.filters.assignment || {}).label,
|
||||||
format: grades.gradeFormat(state),
|
format: state.grades.gradeFormat,
|
||||||
gradeExportUrl: root.gradeExportUrl(state, { courseId }),
|
showSuccess: state.grades.showSuccess,
|
||||||
grades: grades.allGrades(state),
|
errorFetchingGradeOverrideHistory: state.grades.errorFetchingOverrideHistory,
|
||||||
headings: root.getHeadings(state),
|
|
||||||
interventionExportUrl: root.interventionExportUrl(state, { courseId }),
|
|
||||||
nextPage: state.grades.nextPage,
|
|
||||||
prevPage: state.grades.prevPage,
|
prevPage: state.grades.prevPage,
|
||||||
selectedTrack: filters.track(state),
|
nextPage: state.grades.nextPage,
|
||||||
selectedCohort: filters.cohort(state),
|
assignmentTypes: state.assignmentTypes.results,
|
||||||
selectedAssignmentType: filters.assignmentType(state),
|
assignmentFilterOptions: selectableAssignmentLabels(state),
|
||||||
selectedAssignment: filters.selectedAssignmentLabel(state),
|
areGradesFrozen: state.assignmentTypes.areGradesFrozen,
|
||||||
showBulkManagement: root.showBulkManagement(state, { courseId }),
|
showSpinner: shouldShowSpinner(state),
|
||||||
showSpinner: root.shouldShowSpinner(state),
|
canUserViewGradebook: state.roles.canUserViewGradebook,
|
||||||
totalUsersCount: grades.totalUsersCount(state),
|
gradeExportUrl: LmsApiService.getGradeExportCsvUrl(ownProps.match.params.courseId, {
|
||||||
uploadSuccess: grades.uploadSuccess(state),
|
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 = {
|
const mapDispatchToProps = {
|
||||||
downloadBulkGradesReport: actions.grades.downloadReport.bulkGrades,
|
doneViewingAssignment,
|
||||||
downloadInterventionReport: actions.grades.downloadReport.intervention,
|
getUserGrades: fetchGrades,
|
||||||
toggleFormat: actions.grades.toggleGradeFormat,
|
fetchGradeOverrideHistory,
|
||||||
|
searchForUser: fetchMatchingUserGrades,
|
||||||
filterAssignmentType: actions.filters.update.assignmentType,
|
getPrevNextGrades: fetchPrevNextGrades,
|
||||||
initializeFilters: actions.filters.initialize,
|
getCohorts: fetchCohorts,
|
||||||
resetFilters: actions.filters.reset,
|
getTracks: fetchTracks,
|
||||||
updateAssignmentFilter: actions.filters.update.assignment,
|
getAssignmentTypes: fetchAssignmentTypes,
|
||||||
updateAssignmentLimits: actions.filters.update.assignmentLimits,
|
updateGrades,
|
||||||
|
toggleFormat: toggleGradeFormat,
|
||||||
fetchGradeOverrideHistory: thunkActions.grades.fetchGradeOverrideHistory,
|
filterAssignmentType,
|
||||||
getAssignmentTypes: thunkActions.assignmentTypes.fetchAssignmentTypes,
|
closeBanner,
|
||||||
getCohorts: thunkActions.cohorts.fetchCohorts,
|
getRoles,
|
||||||
getPrevNextGrades: thunkActions.grades.fetchPrevNextGrades,
|
submitFileUploadFormData,
|
||||||
getRoles: thunkActions.roles.fetchRoles,
|
initializeFilters,
|
||||||
getTracks: thunkActions.tracks.fetchTracks,
|
resetFilters,
|
||||||
getUserGrades: thunkActions.grades.fetchGrades,
|
updateAssignmentFilter,
|
||||||
submitFileUploadFormData: thunkActions.grades.submitFileUploadFormData,
|
updateAssignmentLimits,
|
||||||
|
updateGradesIfAssignmentGradeFiltersSet,
|
||||||
|
updateCourseGradeFilter,
|
||||||
|
downloadBulkGradesReport,
|
||||||
|
downloadInterventionReport,
|
||||||
};
|
};
|
||||||
|
|
||||||
const GradebookPage = connect(
|
const GradebookPage = connect(
|
||||||
|
|||||||
@@ -1,17 +1,41 @@
|
|||||||
import { StrictDict } from 'utils';
|
import {
|
||||||
import { createActionFactory } from './utils';
|
STARTED_FETCHING_ASSIGNMENT_TYPES,
|
||||||
|
GOT_ASSIGNMENT_TYPES,
|
||||||
|
ERROR_FETCHING_ASSIGNMENT_TYPES,
|
||||||
|
GOT_ARE_GRADES_FROZEN,
|
||||||
|
} from '../constants/actionTypes/assignmentTypes';
|
||||||
|
import GOT_BULK_MANAGEMENT_CONFIG from '../constants/actionTypes/config';
|
||||||
|
import LmsApiService from '../services/LmsApiService';
|
||||||
|
|
||||||
export const dataKey = 'assignmentTypes';
|
const startedFetchingAssignmentTypes = () => ({ type: STARTED_FETCHING_ASSIGNMENT_TYPES });
|
||||||
const createAction = createActionFactory(dataKey);
|
const errorFetchingAssignmentTypes = () => ({ type: ERROR_FETCHING_ASSIGNMENT_TYPES });
|
||||||
|
const gotAssignmentTypes = assignmentTypes => ({ type: GOT_ASSIGNMENT_TYPES, assignmentTypes });
|
||||||
const fetching = {
|
const gotGradesFrozen = areGradesFrozen => ({ type: GOT_ARE_GRADES_FROZEN, areGradesFrozen });
|
||||||
error: createAction('fetching/error'),
|
const gotBulkManagementConfig = bulkManagementEnabled => ({
|
||||||
started: createAction('fetching/started'),
|
type: GOT_BULK_MANAGEMENT_CONFIG,
|
||||||
received: createAction('fetching/received'),
|
data: bulkManagementEnabled,
|
||||||
};
|
|
||||||
const gotGradesFrozen = createAction('gotGradesFrozen');
|
|
||||||
|
|
||||||
export default StrictDict({
|
|
||||||
fetching: StrictDict(fetching),
|
|
||||||
gotGradesFrozen,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const fetchAssignmentTypes = courseId => (
|
||||||
|
(dispatch) => {
|
||||||
|
dispatch(startedFetchingAssignmentTypes());
|
||||||
|
return LmsApiService.fetchAssignmentTypes(courseId)
|
||||||
|
.then(response => response.data)
|
||||||
|
.then((data) => {
|
||||||
|
dispatch(gotAssignmentTypes(Object.keys(data.assignment_types)));
|
||||||
|
dispatch(gotGradesFrozen(data.grades_frozen));
|
||||||
|
dispatch(gotBulkManagementConfig(data.can_see_bulk_management));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
dispatch(errorFetchingAssignmentTypes());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export {
|
||||||
|
fetchAssignmentTypes,
|
||||||
|
startedFetchingAssignmentTypes,
|
||||||
|
gotAssignmentTypes,
|
||||||
|
errorFetchingAssignmentTypes,
|
||||||
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,97 @@
|
|||||||
import actions, { dataKey } from './assignmentTypes';
|
import configureMockStore from 'redux-mock-store';
|
||||||
import { testAction, testActionTypes } from './testUtils';
|
import MockAdapter from 'axios-mock-adapter';
|
||||||
|
import thunk from 'redux-thunk';
|
||||||
|
|
||||||
|
import apiClient from '../apiClient';
|
||||||
|
import { configuration } from '../../config';
|
||||||
|
import { fetchAssignmentTypes } from './assignmentTypes';
|
||||||
|
import {
|
||||||
|
STARTED_FETCHING_ASSIGNMENT_TYPES,
|
||||||
|
GOT_ASSIGNMENT_TYPES,
|
||||||
|
ERROR_FETCHING_ASSIGNMENT_TYPES,
|
||||||
|
GOT_ARE_GRADES_FROZEN,
|
||||||
|
} from '../constants/actionTypes/assignmentTypes';
|
||||||
|
import GOT_BULK_MANAGEMENT_CONFIG from '../constants/actionTypes/config';
|
||||||
|
|
||||||
|
const mockStore = configureMockStore([thunk]);
|
||||||
|
const axiosMock = new MockAdapter(apiClient);
|
||||||
|
apiClient.isAccessTokenExpired = jest.fn();
|
||||||
|
apiClient.isAccessTokenExpired.mockReturnValue(false);
|
||||||
|
|
||||||
describe('actions', () => {
|
describe('actions', () => {
|
||||||
describe('action types', () => {
|
afterEach(() => {
|
||||||
const actionTypes = [
|
axiosMock.reset();
|
||||||
actions.fetching.error,
|
|
||||||
actions.fetching.started,
|
|
||||||
actions.fetching.received,
|
|
||||||
actions.gotGradesFrozen,
|
|
||||||
].map(action => action.toString());
|
|
||||||
testActionTypes(actionTypes, dataKey);
|
|
||||||
});
|
});
|
||||||
describe('actions provided', () => {
|
|
||||||
describe('fetching actions', () => {
|
describe('fetchAssignmentTypes', () => {
|
||||||
test('error action', () => testAction(actions.fetching.error));
|
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||||
test('started action', () => testAction(actions.fetching.started));
|
const responseData = {
|
||||||
test('received action', () => testAction(actions.fetching.received));
|
assignment_types: {
|
||||||
|
Exam: {
|
||||||
|
drop_count: 0,
|
||||||
|
min_count: 1,
|
||||||
|
short_label: 'Exam',
|
||||||
|
type: 'Exam',
|
||||||
|
weight: 0.25,
|
||||||
|
},
|
||||||
|
Homework: {
|
||||||
|
drop_count: 1,
|
||||||
|
min_count: 3,
|
||||||
|
short_label: 'Ex',
|
||||||
|
type: 'Homework',
|
||||||
|
weight: 0.75,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
grades_frozen: false,
|
||||||
|
can_see_bulk_management: true,
|
||||||
|
};
|
||||||
|
it('dispatches success action after fetching fetchAssignmentTypes', () => {
|
||||||
|
const expectedActions = [
|
||||||
|
{ type: STARTED_FETCHING_ASSIGNMENT_TYPES },
|
||||||
|
{ type: GOT_ASSIGNMENT_TYPES, assignmentTypes: Object.keys(responseData.assignment_types) },
|
||||||
|
{ type: GOT_ARE_GRADES_FROZEN, areGradesFrozen: responseData.grades_frozen },
|
||||||
|
{ type: GOT_BULK_MANAGEMENT_CONFIG, data: true },
|
||||||
|
];
|
||||||
|
const store = mockStore();
|
||||||
|
|
||||||
|
axiosMock.onGet(`${configuration.LMS_BASE_URL}/api/grades/v1/gradebook/${courseId}/grading-info?graded_only=true`)
|
||||||
|
.replyOnce(200, JSON.stringify(responseData));
|
||||||
|
|
||||||
|
return store.dispatch(fetchAssignmentTypes(courseId)).then(() => {
|
||||||
|
expect(store.getActions()).toEqual(expectedActions);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches failure action after fetching cohorts', () => {
|
||||||
|
const expectedActions = [
|
||||||
|
{ type: STARTED_FETCHING_ASSIGNMENT_TYPES },
|
||||||
|
{ type: ERROR_FETCHING_ASSIGNMENT_TYPES },
|
||||||
|
];
|
||||||
|
const store = mockStore();
|
||||||
|
|
||||||
|
axiosMock.onGet(`${configuration.LMS_BASE_URL}/api/grades/v1/gradebook/${courseId}/grading-info?graded_only=true`)
|
||||||
|
.replyOnce(500, JSON.stringify({}));
|
||||||
|
|
||||||
|
return store.dispatch(fetchAssignmentTypes(courseId)).then(() => {
|
||||||
|
expect(store.getActions()).toEqual(expectedActions);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches frozen grade action with True value after fetching', () => {
|
||||||
|
const expectedActions = [
|
||||||
|
{ type: STARTED_FETCHING_ASSIGNMENT_TYPES },
|
||||||
|
{ type: GOT_ASSIGNMENT_TYPES, assignmentTypes: Object.keys(responseData.assignment_types) },
|
||||||
|
{ type: GOT_ARE_GRADES_FROZEN, areGradesFrozen: true },
|
||||||
|
{ type: GOT_BULK_MANAGEMENT_CONFIG, data: true },
|
||||||
|
];
|
||||||
|
const store = mockStore();
|
||||||
|
responseData.grades_frozen = true;
|
||||||
|
axiosMock.onGet(`${configuration.LMS_BASE_URL}/api/grades/v1/gradebook/${courseId}/grading-info?graded_only=true`)
|
||||||
|
.replyOnce(200, JSON.stringify(responseData));
|
||||||
|
|
||||||
|
return store.dispatch(fetchAssignmentTypes(courseId)).then(() => {
|
||||||
|
expect(store.getActions()).toEqual(expectedActions);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
test('gotGradesFrozen action', () => testAction(actions.gotGradesFrozen));
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,15 +1,31 @@
|
|||||||
import { StrictDict } from 'utils';
|
import {
|
||||||
import { createActionFactory } from './utils';
|
STARTED_FETCHING_COHORTS,
|
||||||
|
GOT_COHORTS,
|
||||||
|
ERROR_FETCHING_COHORTS,
|
||||||
|
} from '../constants/actionTypes/cohorts';
|
||||||
|
import LmsApiService from '../services/LmsApiService';
|
||||||
|
|
||||||
export const dataKey = 'cohorts';
|
const startedFetchingCohorts = () => ({ type: STARTED_FETCHING_COHORTS });
|
||||||
const createAction = createActionFactory(dataKey);
|
const errorFetchingCohorts = () => ({ type: ERROR_FETCHING_COHORTS });
|
||||||
|
const gotCohorts = cohorts => ({ type: GOT_COHORTS, cohorts });
|
||||||
|
|
||||||
const fetching = {
|
const fetchCohorts = courseId => (
|
||||||
started: createAction('fetching/started'),
|
(dispatch) => {
|
||||||
error: createAction('fetching/error'),
|
dispatch(startedFetchingCohorts());
|
||||||
received: createAction('fetching/received'),
|
return LmsApiService.fetchCohorts(courseId)
|
||||||
|
.then(response => response.data)
|
||||||
|
.then((data) => {
|
||||||
|
dispatch(gotCohorts(data.cohorts));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
dispatch(errorFetchingCohorts());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export {
|
||||||
|
fetchCohorts,
|
||||||
|
startedFetchingCohorts,
|
||||||
|
gotCohorts,
|
||||||
|
errorFetchingCohorts,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default StrictDict({
|
|
||||||
fetching: StrictDict(fetching),
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,20 +1,76 @@
|
|||||||
import actions, { dataKey } from './cohorts';
|
import configureMockStore from 'redux-mock-store';
|
||||||
import { testAction, testActionTypes } from './testUtils';
|
import MockAdapter from 'axios-mock-adapter';
|
||||||
|
import thunk from 'redux-thunk';
|
||||||
|
|
||||||
describe('actions.cohorts', () => {
|
import apiClient from '../apiClient';
|
||||||
describe('action types', () => {
|
import { configuration } from '../../config';
|
||||||
const actionTypes = [
|
import { fetchCohorts } from './cohorts';
|
||||||
actions.fetching.error,
|
import {
|
||||||
actions.fetching.started,
|
STARTED_FETCHING_COHORTS,
|
||||||
actions.fetching.received,
|
GOT_COHORTS,
|
||||||
].map(action => action.toString());
|
ERROR_FETCHING_COHORTS,
|
||||||
testActionTypes(actionTypes, dataKey);
|
} from '../constants/actionTypes/cohorts';
|
||||||
|
|
||||||
|
const mockStore = configureMockStore([thunk]);
|
||||||
|
const axiosMock = new MockAdapter(apiClient);
|
||||||
|
apiClient.isAccessTokenExpired = jest.fn();
|
||||||
|
apiClient.isAccessTokenExpired.mockReturnValue(false);
|
||||||
|
|
||||||
|
describe('actions', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
axiosMock.reset();
|
||||||
});
|
});
|
||||||
describe('actions provided', () => {
|
|
||||||
describe('fecthing actions', () => {
|
describe('fetchCohorts', () => {
|
||||||
test('error action', () => testAction(actions.fetching.error));
|
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||||
test('started action', () => testAction(actions.fetching.started));
|
|
||||||
test('received action', () => testAction(actions.fetching.received));
|
it('dispatches success action after fetching cohorts', () => {
|
||||||
|
const responseData = {
|
||||||
|
cohorts: [
|
||||||
|
{
|
||||||
|
assignment_type: 'manual',
|
||||||
|
group_id: null,
|
||||||
|
id: 1,
|
||||||
|
name: 'default_group',
|
||||||
|
user_count: 2,
|
||||||
|
user_partition_id: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
assignment_type: 'auto',
|
||||||
|
group_id: null,
|
||||||
|
id: 2,
|
||||||
|
name: 'auto_group',
|
||||||
|
user_count: 5,
|
||||||
|
user_partition_id: null,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
const expectedActions = [
|
||||||
|
{ type: STARTED_FETCHING_COHORTS },
|
||||||
|
{ type: GOT_COHORTS, cohorts: responseData.cohorts },
|
||||||
|
];
|
||||||
|
const store = mockStore();
|
||||||
|
|
||||||
|
axiosMock.onGet(`${configuration.LMS_BASE_URL}/courses/${courseId}/cohorts/`)
|
||||||
|
.replyOnce(200, JSON.stringify(responseData));
|
||||||
|
|
||||||
|
return store.dispatch(fetchCohorts(courseId)).then(() => {
|
||||||
|
expect(store.getActions()).toEqual(expectedActions);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches failure action after fetching cohorts', () => {
|
||||||
|
const expectedActions = [
|
||||||
|
{ type: STARTED_FETCHING_COHORTS },
|
||||||
|
{ type: ERROR_FETCHING_COHORTS },
|
||||||
|
];
|
||||||
|
const store = mockStore();
|
||||||
|
|
||||||
|
axiosMock.onGet(`${configuration.LMS_BASE_URL}/courses/${courseId}/cohorts/`)
|
||||||
|
.replyOnce(500, JSON.stringify({}));
|
||||||
|
|
||||||
|
return store.dispatch(fetchCohorts(courseId)).then(() => {
|
||||||
|
expect(store.getActions()).toEqual(expectedActions);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
import { StrictDict } from 'utils';
|
|
||||||
import { createActionFactory } from './utils';
|
|
||||||
|
|
||||||
export const dataKey = 'config';
|
|
||||||
const createAction = createActionFactory(dataKey);
|
|
||||||
|
|
||||||
const gotBulkManagementConfig = createAction('gotBulkManagement');
|
|
||||||
|
|
||||||
export default StrictDict({
|
|
||||||
gotBulkManagementConfig,
|
|
||||||
});
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import actions, { dataKey } from './config';
|
|
||||||
import { testAction, testActionTypes } from './testUtils';
|
|
||||||
|
|
||||||
describe('actions.cohorts', () => {
|
|
||||||
describe('action types', () => {
|
|
||||||
const actionTypes = [
|
|
||||||
actions.gotBulkManagementConfig,
|
|
||||||
].map(action => action.toString());
|
|
||||||
testActionTypes(actionTypes, dataKey);
|
|
||||||
});
|
|
||||||
describe('actions provided', () => {
|
|
||||||
test('gotBulkManagementConfig action', () => testAction(actions.gotBulkManagementConfig));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,11 +1,7 @@
|
|||||||
import { StrictDict } from 'utils';
|
|
||||||
import initialFilters from '../constants/filters';
|
import initialFilters from '../constants/filters';
|
||||||
import { createActionFactory } from './utils';
|
import { INITIALIZE_FILTERS, RESET_FILTERS, UPDATE_ASSIGNMENT_FILTER, UPDATE_ASSIGNMENT_LIMITS, UPDATE_COURSE_GRADE_LIMITS } from '../constants/actionTypes/filters';
|
||||||
|
|
||||||
export const dataKey = 'filters';
|
const initializeFilters = ({
|
||||||
const createAction = createActionFactory(dataKey);
|
|
||||||
|
|
||||||
const initialize = createAction('initialize', ({
|
|
||||||
assignment = initialFilters.assignment,
|
assignment = initialFilters.assignment,
|
||||||
assignmentType = initialFilters.assignmentType,
|
assignmentType = initialFilters.assignmentType,
|
||||||
track = initialFilters.track,
|
track = initialFilters.track,
|
||||||
@@ -14,9 +10,9 @@ const initialize = createAction('initialize', ({
|
|||||||
assignmentGradeMax = initialFilters.assignmentGradeMax,
|
assignmentGradeMax = initialFilters.assignmentGradeMax,
|
||||||
courseGradeMin = initialFilters.courseGradeMin,
|
courseGradeMin = initialFilters.courseGradeMin,
|
||||||
courseGradeMax = initialFilters.assignmentGradeMax,
|
courseGradeMax = initialFilters.assignmentGradeMax,
|
||||||
includeCourseRoleMembers = initialFilters.includeCourseRoleMembers,
|
|
||||||
}) => ({
|
}) => ({
|
||||||
payload: {
|
type: INITIALIZE_FILTERS,
|
||||||
|
data: {
|
||||||
assignment: { id: assignment },
|
assignment: { id: assignment },
|
||||||
assignmentType,
|
assignmentType,
|
||||||
track,
|
track,
|
||||||
@@ -25,21 +21,34 @@ const initialize = createAction('initialize', ({
|
|||||||
assignmentGradeMax,
|
assignmentGradeMax,
|
||||||
courseGradeMin,
|
courseGradeMin,
|
||||||
courseGradeMax,
|
courseGradeMax,
|
||||||
includeCourseRoleMembers: Boolean(includeCourseRoleMembers),
|
|
||||||
},
|
},
|
||||||
}));
|
|
||||||
|
|
||||||
const reset = createAction('reset');
|
|
||||||
const update = StrictDict({
|
|
||||||
assignment: createAction('update/assignment'),
|
|
||||||
assignmentType: createAction('update/assignmentType'),
|
|
||||||
assignmentLimits: createAction('update/assignmentLimits'),
|
|
||||||
courseGradeLimits: createAction('update/courseGradeLimits'),
|
|
||||||
includeCourseRoleMembers: createAction('update/includeCourseRoleMembers'),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default StrictDict({
|
const resetFilters = filterNames => ({
|
||||||
initialize,
|
type: RESET_FILTERS,
|
||||||
reset,
|
filterNames,
|
||||||
update: StrictDict(update),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const updateAssignmentFilter = assignment => ({
|
||||||
|
type: UPDATE_ASSIGNMENT_FILTER,
|
||||||
|
data: assignment,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateAssignmentLimits = (minGrade, maxGrade) => ({
|
||||||
|
type: UPDATE_ASSIGNMENT_LIMITS,
|
||||||
|
data: { minGrade, maxGrade },
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateCourseGradeFilter = (courseGradeMin, courseGradeMax, courseId) => ({
|
||||||
|
type: UPDATE_COURSE_GRADE_LIMITS,
|
||||||
|
data: {
|
||||||
|
courseGradeMin,
|
||||||
|
courseGradeMax,
|
||||||
|
courseId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export {
|
||||||
|
initializeFilters, resetFilters, updateAssignmentFilter,
|
||||||
|
updateAssignmentLimits, updateCourseGradeFilter,
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
import actions, { dataKey } from './filters';
|
|
||||||
import initialFilters from '../constants/filters';
|
|
||||||
import { testAction, testActionTypes } from './testUtils';
|
|
||||||
|
|
||||||
describe('actions.filters', () => {
|
|
||||||
describe('action types', () => {
|
|
||||||
const actionTypes = [
|
|
||||||
actions.initialize,
|
|
||||||
actions.reset,
|
|
||||||
actions.update.assignment,
|
|
||||||
actions.update.assignmentType,
|
|
||||||
actions.update.assignmentLimits,
|
|
||||||
actions.update.courseGradeLimits,
|
|
||||||
actions.update.includeCourseRoleMembers,
|
|
||||||
].map(action => action.toString());
|
|
||||||
testActionTypes(actionTypes, dataKey);
|
|
||||||
});
|
|
||||||
describe('actions provided', () => {
|
|
||||||
describe('initialize action', () => {
|
|
||||||
it('sets initialFilters values for missing args', () => {
|
|
||||||
testAction(actions.initialize, {}, {
|
|
||||||
assignment: { id: initialFilters.assignment },
|
|
||||||
assignmentType: initialFilters.assignmentType,
|
|
||||||
cohort: initialFilters.cohort,
|
|
||||||
track: initialFilters.track,
|
|
||||||
assignmentGradeMin: initialFilters.assignmentGradeMin,
|
|
||||||
assignmentGradeMax: initialFilters.assignmentGradeMax,
|
|
||||||
courseGradeMin: initialFilters.courseGradeMin,
|
|
||||||
courseGradeMax: initialFilters.courseGradeMax,
|
|
||||||
includeCourseRoleMembers: initialFilters.includeCourseRoleMembers,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
it('loads filters from args', () => {
|
|
||||||
const expected = {
|
|
||||||
assignment: { id: 'assIGNmentId' },
|
|
||||||
assignmentType: 'aType',
|
|
||||||
track: 'masters',
|
|
||||||
cohort: 3,
|
|
||||||
assignmentGradeMin: 23,
|
|
||||||
assignmentGradeMax: 98,
|
|
||||||
courseGradeMin: 11,
|
|
||||||
courseGradeMax: 87,
|
|
||||||
includeCourseRoleMembers: true,
|
|
||||||
};
|
|
||||||
const args = { ...expected, assignment: expected.assignment.id, also: 'other stuff' };
|
|
||||||
testAction(actions.initialize, args, expected);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
test('reset action', () => testAction(actions.reset));
|
|
||||||
describe('update actions', () => {
|
|
||||||
test('update.assignment action', () => (
|
|
||||||
testAction(actions.update.assignment)
|
|
||||||
));
|
|
||||||
test('update.assignmentType action', () => (
|
|
||||||
testAction(actions.update.assignmentType)
|
|
||||||
));
|
|
||||||
test('update.assignmentLimits action', () => (
|
|
||||||
testAction(actions.update.assignmentLimits)
|
|
||||||
));
|
|
||||||
test('update.courseGradeLimits action', () => (
|
|
||||||
testAction(actions.update.courseGradeLimits)
|
|
||||||
));
|
|
||||||
test('update.includeCourseRoleMembers action', () => (
|
|
||||||
testAction(actions.update.includeCourseRoleMembers)
|
|
||||||
));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,103 +1,343 @@
|
|||||||
import { StrictDict } from 'utils';
|
import {
|
||||||
import { createActionFactory } from './utils';
|
STARTED_FETCHING_GRADES,
|
||||||
|
FINISHED_FETCHING_GRADES,
|
||||||
|
ERROR_FETCHING_GRADES,
|
||||||
|
GOT_GRADES,
|
||||||
|
GRADE_UPDATE_REQUEST,
|
||||||
|
GRADE_UPDATE_SUCCESS,
|
||||||
|
GRADE_UPDATE_FAILURE,
|
||||||
|
TOGGLE_GRADE_FORMAT,
|
||||||
|
FILTER_BY_ASSIGNMENT_TYPE,
|
||||||
|
OPEN_BANNER,
|
||||||
|
CLOSE_BANNER,
|
||||||
|
START_UPLOAD,
|
||||||
|
UPLOAD_COMPLETE,
|
||||||
|
UPLOAD_ERR,
|
||||||
|
GOT_BULK_HISTORY,
|
||||||
|
BULK_HISTORY_ERR,
|
||||||
|
GOT_GRADE_OVERRIDE_HISTORY,
|
||||||
|
DONE_VIEWING_ASSIGNMENT,
|
||||||
|
ERROR_FETCHING_GRADE_OVERRIDE_HISTORY,
|
||||||
|
UPLOAD_OVERRIDE,
|
||||||
|
UPLOAD_OVERRIDE_ERROR,
|
||||||
|
BULK_GRADE_REPORT_DOWNLOADED,
|
||||||
|
INTERVENTION_REPORT_DOWNLOADED,
|
||||||
|
} from '../constants/actionTypes/grades';
|
||||||
|
import LmsApiService from '../services/LmsApiService';
|
||||||
|
import { sortAlphaAsc, formatDateForDisplay } from './utils';
|
||||||
|
import { formatMaxAssignmentGrade, formatMinAssignmentGrade, formatMaxCourseGrade, formatMinCourseGrade } from '../selectors/grades';
|
||||||
|
import { getFilters } from '../selectors/filters';
|
||||||
|
import apiClient from '../apiClient';
|
||||||
|
|
||||||
export const dataKey = 'grades';
|
const defaultAssignmentFilter = 'All';
|
||||||
const createAction = createActionFactory(dataKey);
|
|
||||||
|
|
||||||
const banner = {
|
const startedCsvUpload = () => ({ type: START_UPLOAD });
|
||||||
open: createAction('banner/open'),
|
const finishedCsvUpload = () => ({ type: UPLOAD_COMPLETE });
|
||||||
close: createAction('banner/close'),
|
const csvUploadError = data => ({ type: UPLOAD_ERR, data });
|
||||||
};
|
const gotBulkHistory = data => ({ type: GOT_BULK_HISTORY, data });
|
||||||
|
const bulkHistoryError = () => ({ type: BULK_HISTORY_ERR });
|
||||||
|
|
||||||
const bulkHistory = {
|
const startedFetchingGrades = () => ({ type: STARTED_FETCHING_GRADES });
|
||||||
received: createAction('bulkHistory/received'),
|
const finishedFetchingGrades = () => ({ type: FINISHED_FETCHING_GRADES });
|
||||||
error: createAction('bulkHistory/error'),
|
const errorFetchingGrades = () => ({ type: ERROR_FETCHING_GRADES });
|
||||||
};
|
const errorFetchingGradeOverrideHistory = () => ({ type: ERROR_FETCHING_GRADE_OVERRIDE_HISTORY });
|
||||||
|
|
||||||
const csvUpload = {
|
const gotGrades = ({
|
||||||
started: createAction('csvUpload/started'),
|
grades, cohort, track, assignmentType, headings, prev,
|
||||||
finished: createAction('csvUpload/finished'),
|
next, courseId, totalUsersCount, filteredUsersCount,
|
||||||
error: createAction('csvUpload/error'),
|
}) => ({
|
||||||
};
|
type: GOT_GRADES,
|
||||||
|
grades,
|
||||||
const doneViewingAssignment = createAction('doneViewingAssignment');
|
cohort,
|
||||||
|
track,
|
||||||
// for segment tracking
|
assignmentType,
|
||||||
const downloadReport = {
|
headings,
|
||||||
bulkGrades: createAction('downloadReport/bulkGrades'),
|
prev,
|
||||||
intervention: createAction('downloadReport/intervention'),
|
next,
|
||||||
};
|
courseId,
|
||||||
|
totalUsersCount,
|
||||||
const fetching = {
|
filteredUsersCount,
|
||||||
started: createAction('fetching/started'),
|
|
||||||
finished: createAction('fetching/finished'),
|
|
||||||
error: createAction('fetching/error'),
|
|
||||||
// for segment tracking
|
|
||||||
received: createAction(
|
|
||||||
'fetching/received',
|
|
||||||
(data) => ({
|
|
||||||
payload: {
|
|
||||||
grades: data.grades,
|
|
||||||
cohort: data.cohort,
|
|
||||||
track: data.track,
|
|
||||||
assignmentType: data.assignmentType,
|
|
||||||
headings: data.headings,
|
|
||||||
prev: data.prev,
|
|
||||||
next: data.next,
|
|
||||||
courseId: data.courseId,
|
|
||||||
totalUsersCount: data.totalUsersCount,
|
|
||||||
filteredUsersCount: data.filteredUsersCount,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
const overrideHistory = {
|
|
||||||
error: createAction('overrideHistory/errorFetching'),
|
|
||||||
received: createAction(
|
|
||||||
'overrideHistory/received',
|
|
||||||
(data) => ({
|
|
||||||
payload: {
|
|
||||||
overrideHistory: data.overrideHistory,
|
|
||||||
currentEarnedAllOverride: data.currentEarnedAllOverride,
|
|
||||||
currentPossibleAllOverride: data.currentPossibleAllOverride,
|
|
||||||
currentEarnedGradedOverride: data.currentEarnedGradedOverride,
|
|
||||||
currentPossibleGradedOverride: data.currentPossibleGradedOverride,
|
|
||||||
originalGradeEarnedAll: data.originalGradeEarnedAll,
|
|
||||||
originalGradePossibleAll: data.originalGradePossibleAll,
|
|
||||||
originalGradeEarnedGraded: data.originalGradeEarnedGraded,
|
|
||||||
originalGradePossibleGraded: data.originalGradePossibleGraded,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleGradeFormat = createAction('toggleGradeFormat');
|
|
||||||
|
|
||||||
const update = {
|
|
||||||
request: createAction('update/request'),
|
|
||||||
success: createAction('update/success'),
|
|
||||||
failure: createAction('update/failure', (courseId, error) => ({
|
|
||||||
payload: { courseId, error },
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
|
|
||||||
const uploadOverride = {
|
|
||||||
success: createAction('uploadOverride/success'),
|
|
||||||
failure: createAction('uploadOverride/failure', (courseId, error) => ({
|
|
||||||
payload: { courseId, error },
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
|
|
||||||
export default StrictDict({
|
|
||||||
banner: StrictDict(banner),
|
|
||||||
bulkHistory: StrictDict(bulkHistory),
|
|
||||||
csvUpload: StrictDict(csvUpload),
|
|
||||||
doneViewingAssignment,
|
|
||||||
downloadReport: StrictDict(downloadReport),
|
|
||||||
fetching: StrictDict(fetching),
|
|
||||||
overrideHistory: StrictDict(overrideHistory),
|
|
||||||
toggleGradeFormat,
|
|
||||||
update: StrictDict(update),
|
|
||||||
uploadOverride: StrictDict(uploadOverride),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const gotGradeOverrideHistory = ({
|
||||||
|
overrideHistory, currentEarnedAllOverride, currentPossibleAllOverride,
|
||||||
|
currentEarnedGradedOverride, currentPossibleGradedOverride,
|
||||||
|
originalGradeEarnedAll, originalGradePossibleAll, originalGradeEarnedGraded,
|
||||||
|
originalGradePossibleGraded,
|
||||||
|
}) => ({
|
||||||
|
type: GOT_GRADE_OVERRIDE_HISTORY,
|
||||||
|
overrideHistory,
|
||||||
|
currentEarnedAllOverride,
|
||||||
|
currentPossibleAllOverride,
|
||||||
|
currentEarnedGradedOverride,
|
||||||
|
currentPossibleGradedOverride,
|
||||||
|
originalGradeEarnedAll,
|
||||||
|
originalGradePossibleAll,
|
||||||
|
originalGradeEarnedGraded,
|
||||||
|
originalGradePossibleGraded,
|
||||||
|
});
|
||||||
|
|
||||||
|
const gradeUpdateRequest = () => ({ type: GRADE_UPDATE_REQUEST });
|
||||||
|
const gradeUpdateSuccess = (courseId, responseData) => ({
|
||||||
|
type: GRADE_UPDATE_SUCCESS,
|
||||||
|
courseId,
|
||||||
|
payload: { responseData },
|
||||||
|
});
|
||||||
|
const gradeUpdateFailure = (courseId, error) => ({
|
||||||
|
type: GRADE_UPDATE_FAILURE,
|
||||||
|
courseId,
|
||||||
|
payload: { error },
|
||||||
|
});
|
||||||
|
const uploadOverrideSuccess = courseId => ({
|
||||||
|
type: UPLOAD_OVERRIDE,
|
||||||
|
courseId,
|
||||||
|
});
|
||||||
|
// This action for google analytics only. Doesn't change redux state.
|
||||||
|
const downloadBulkGradesReport = courseId => ({
|
||||||
|
type: BULK_GRADE_REPORT_DOWNLOADED,
|
||||||
|
courseId,
|
||||||
|
});
|
||||||
|
// This action for google analytics only. Doesn't change redux state.
|
||||||
|
const downloadInterventionReport = courseId => ({
|
||||||
|
type: INTERVENTION_REPORT_DOWNLOADED,
|
||||||
|
courseId,
|
||||||
|
});
|
||||||
|
const uploadOverrideFailure = (courseId, error) => ({
|
||||||
|
type: UPLOAD_OVERRIDE_ERROR,
|
||||||
|
courseId,
|
||||||
|
payload: { error },
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const toggleGradeFormat = formatType => ({ type: TOGGLE_GRADE_FORMAT, formatType });
|
||||||
|
|
||||||
|
const filterAssignmentType = filterType => (
|
||||||
|
dispatch => dispatch({
|
||||||
|
type: FILTER_BY_ASSIGNMENT_TYPE,
|
||||||
|
filterType,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const openBanner = () => ({ type: OPEN_BANNER });
|
||||||
|
const closeBanner = () => ({ type: CLOSE_BANNER });
|
||||||
|
|
||||||
|
const fetchGrades = (
|
||||||
|
courseId,
|
||||||
|
cohort,
|
||||||
|
track,
|
||||||
|
assignmentType,
|
||||||
|
options = {},
|
||||||
|
) => (
|
||||||
|
(dispatch, getState) => {
|
||||||
|
dispatch(startedFetchingGrades());
|
||||||
|
const {
|
||||||
|
assignment,
|
||||||
|
assignmentGradeMax: assignmentMax,
|
||||||
|
assignmentGradeMin: assignmentMin,
|
||||||
|
courseGradeMin,
|
||||||
|
courseGradeMax,
|
||||||
|
} = getFilters(getState());
|
||||||
|
const { id: assignmentId } = assignment || {};
|
||||||
|
const assignmentGradeMax = formatMaxAssignmentGrade(assignmentMax, { assignmentId });
|
||||||
|
const assignmentGradeMin = formatMinAssignmentGrade(assignmentMin, { assignmentId });
|
||||||
|
const courseGradeMinFormatted = formatMinCourseGrade(courseGradeMin);
|
||||||
|
const courseGradeMaxFormatted = formatMaxCourseGrade(courseGradeMax);
|
||||||
|
return LmsApiService.fetchGradebookData(
|
||||||
|
courseId,
|
||||||
|
options.searchText || null,
|
||||||
|
cohort,
|
||||||
|
track,
|
||||||
|
{
|
||||||
|
assignment: assignmentId,
|
||||||
|
assignmentGradeMax,
|
||||||
|
assignmentGradeMin,
|
||||||
|
courseGradeMin: courseGradeMinFormatted,
|
||||||
|
courseGradeMax: courseGradeMaxFormatted,
|
||||||
|
},
|
||||||
|
|
||||||
|
)
|
||||||
|
.then(response => response.data)
|
||||||
|
.then((data) => {
|
||||||
|
dispatch(gotGrades({
|
||||||
|
grades: data.results.sort(sortAlphaAsc),
|
||||||
|
cohort,
|
||||||
|
track,
|
||||||
|
assignmentType,
|
||||||
|
prev: data.previous,
|
||||||
|
next: data.next,
|
||||||
|
courseId,
|
||||||
|
totalUsersCount: data.total_users_count,
|
||||||
|
filteredUsersCount: data.filtered_users_count,
|
||||||
|
}));
|
||||||
|
dispatch(finishedFetchingGrades());
|
||||||
|
if (options.showSuccess) {
|
||||||
|
dispatch(openBanner());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
dispatch(errorFetchingGrades());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const formatGradeOverrideForDisplay = historyArray => historyArray.map(item => ({
|
||||||
|
date: formatDateForDisplay(new Date(item.history_date)),
|
||||||
|
grader: item.history_user,
|
||||||
|
reason: item.override_reason,
|
||||||
|
adjustedGrade: item.earned_graded_override,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const doneViewingAssignment = () => dispatch => dispatch({
|
||||||
|
type: DONE_VIEWING_ASSIGNMENT,
|
||||||
|
});
|
||||||
|
const fetchGradeOverrideHistory = (subsectionId, userId) => (
|
||||||
|
dispatch =>
|
||||||
|
LmsApiService.fetchGradeOverrideHistory(subsectionId, userId)
|
||||||
|
.then(response => response.data)
|
||||||
|
.then((data) => {
|
||||||
|
dispatch(gotGradeOverrideHistory({
|
||||||
|
overrideHistory: formatGradeOverrideForDisplay(data.history),
|
||||||
|
currentEarnedAllOverride: data.override ? data.override.earned_all_override : null,
|
||||||
|
currentPossibleAllOverride: data.override ? data.override.possible_all_override : null,
|
||||||
|
currentEarnedGradedOverride: data.override ? data.override.earned_graded_override : null,
|
||||||
|
currentPossibleGradedOverride: data.override ?
|
||||||
|
data.override.possible_graded_override : null,
|
||||||
|
originalGradeEarnedAll: data.original_grade ? data.original_grade.earned_all : null,
|
||||||
|
originalGradePossibleAll: data.original_grade ? data.original_grade.possible_all : null,
|
||||||
|
originalGradeEarnedGraded: data.original_grade ? data.original_grade.earned_graded : null,
|
||||||
|
originalGradePossibleGraded: data.original_grade ?
|
||||||
|
data.original_grade.possible_graded : null,
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
dispatch(errorFetchingGradeOverrideHistory());
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetchMatchingUserGrades = (
|
||||||
|
courseId,
|
||||||
|
searchText,
|
||||||
|
cohort,
|
||||||
|
track,
|
||||||
|
assignmentType,
|
||||||
|
showSuccess,
|
||||||
|
options = {},
|
||||||
|
) => {
|
||||||
|
const newOptions = { ...options, searchText, showSuccess };
|
||||||
|
return fetchGrades(courseId, cohort, track, assignmentType, newOptions);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchPrevNextGrades = (endpoint, courseId, cohort, track, assignmentType) => (
|
||||||
|
(dispatch) => {
|
||||||
|
dispatch(startedFetchingGrades());
|
||||||
|
return apiClient.get(endpoint)
|
||||||
|
.then(response => response.data)
|
||||||
|
.then((data) => {
|
||||||
|
dispatch(gotGrades({
|
||||||
|
grades: data.results.sort(sortAlphaAsc),
|
||||||
|
cohort,
|
||||||
|
track,
|
||||||
|
assignmentType,
|
||||||
|
prev: data.previous,
|
||||||
|
next: data.next,
|
||||||
|
courseId,
|
||||||
|
totalUsersCount: data.total_users_count,
|
||||||
|
filteredUsersCount: data.filtered_users_count,
|
||||||
|
}));
|
||||||
|
dispatch(finishedFetchingGrades());
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
dispatch(errorFetchingGrades());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateGrades = (courseId, updateData, searchText, cohort, track) => (
|
||||||
|
(dispatch) => {
|
||||||
|
dispatch(gradeUpdateRequest());
|
||||||
|
return LmsApiService.updateGradebookData(courseId, updateData)
|
||||||
|
.then(response => response.data)
|
||||||
|
.then((data) => {
|
||||||
|
dispatch(gradeUpdateSuccess(courseId, data));
|
||||||
|
dispatch(fetchMatchingUserGrades(
|
||||||
|
courseId,
|
||||||
|
searchText,
|
||||||
|
cohort,
|
||||||
|
track,
|
||||||
|
defaultAssignmentFilter,
|
||||||
|
true,
|
||||||
|
{ searchText },
|
||||||
|
));
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
dispatch(gradeUpdateFailure(courseId, error));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const submitFileUploadFormData = (courseId, formData) => (
|
||||||
|
(dispatch) => {
|
||||||
|
dispatch(startedCsvUpload());
|
||||||
|
return LmsApiService.uploadGradeCsv(courseId, formData).then(() => {
|
||||||
|
dispatch(finishedCsvUpload());
|
||||||
|
dispatch(uploadOverrideSuccess(courseId));
|
||||||
|
}).catch((err) => {
|
||||||
|
dispatch(uploadOverrideFailure(courseId, err));
|
||||||
|
if (err.status === 200 && err.data.error_messages.length) {
|
||||||
|
const { error_messages: errorMessages, saved, total } = err.data;
|
||||||
|
return dispatch(csvUploadError({ errorMessages, saved, total }));
|
||||||
|
}
|
||||||
|
return dispatch(csvUploadError({ errorMessages: ['Unknown error.'] }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetchBulkUpgradeHistory = courseId => (
|
||||||
|
dispatch =>
|
||||||
|
// todo add loading effect
|
||||||
|
LmsApiService.fetchGradeBulkOperationHistory(courseId).then((response) => {
|
||||||
|
dispatch(gotBulkHistory(response));
|
||||||
|
}).catch(() => dispatch(bulkHistoryError()))
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateGradesIfAssignmentGradeFiltersSet = (
|
||||||
|
courseId,
|
||||||
|
cohort,
|
||||||
|
track,
|
||||||
|
assignmentType,
|
||||||
|
) => (dispatch, getState) => {
|
||||||
|
const { filters } = getState();
|
||||||
|
const hasAssignmentGradeFiltersSet = filters.assignmentGradeMax || filters.assignmentGradeMin;
|
||||||
|
if (hasAssignmentGradeFiltersSet) {
|
||||||
|
dispatch(fetchGrades(
|
||||||
|
courseId,
|
||||||
|
cohort,
|
||||||
|
track,
|
||||||
|
assignmentType,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
startedFetchingGrades,
|
||||||
|
finishedFetchingGrades,
|
||||||
|
errorFetchingGrades,
|
||||||
|
gotGrades,
|
||||||
|
fetchGrades,
|
||||||
|
fetchMatchingUserGrades,
|
||||||
|
fetchPrevNextGrades,
|
||||||
|
gradeUpdateRequest,
|
||||||
|
gradeUpdateSuccess,
|
||||||
|
gradeUpdateFailure,
|
||||||
|
updateGrades,
|
||||||
|
toggleGradeFormat,
|
||||||
|
filterAssignmentType,
|
||||||
|
closeBanner,
|
||||||
|
submitFileUploadFormData,
|
||||||
|
fetchBulkUpgradeHistory,
|
||||||
|
doneViewingAssignment,
|
||||||
|
fetchGradeOverrideHistory,
|
||||||
|
updateGradesIfAssignmentGradeFiltersSet,
|
||||||
|
downloadBulkGradesReport,
|
||||||
|
downloadInterventionReport,
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,115 +1,179 @@
|
|||||||
import actions, { dataKey } from './grades';
|
import configureMockStore from 'redux-mock-store';
|
||||||
import { testAction, testActionTypes } from './testUtils';
|
import MockAdapter from 'axios-mock-adapter';
|
||||||
|
import thunk from 'redux-thunk';
|
||||||
|
|
||||||
describe('actions.grades', () => {
|
import apiClient from '../apiClient';
|
||||||
describe('action types', () => {
|
import { configuration } from '../../config';
|
||||||
const actionTypes = [
|
import { fetchGrades } from './grades';
|
||||||
actions.banner.open,
|
import {
|
||||||
actions.banner.close,
|
STARTED_FETCHING_GRADES,
|
||||||
actions.bulkHistory.received,
|
FINISHED_FETCHING_GRADES,
|
||||||
actions.bulkHistory.error,
|
ERROR_FETCHING_GRADES,
|
||||||
actions.csvUpload.started,
|
GOT_GRADES,
|
||||||
actions.csvUpload.finished,
|
} from '../constants/actionTypes/grades';
|
||||||
actions.csvUpload.error,
|
import { sortAlphaAsc } from './utils';
|
||||||
actions.doneViewingAssignment,
|
|
||||||
actions.downloadReport.bulkGrades,
|
|
||||||
actions.downloadReport.intervention,
|
const mockStore = configureMockStore([thunk]);
|
||||||
actions.fetching.started,
|
const axiosMock = new MockAdapter(apiClient);
|
||||||
actions.fetching.finished,
|
apiClient.isAccessTokenExpired = jest.fn();
|
||||||
actions.fetching.error,
|
apiClient.isAccessTokenExpired.mockReturnValue(false);
|
||||||
actions.fetching.received,
|
|
||||||
actions.overrideHistory.error,
|
describe('actions', () => {
|
||||||
actions.overrideHistory.received,
|
afterEach(() => {
|
||||||
actions.toggleGradeFormat,
|
axiosMock.reset();
|
||||||
actions.update.request,
|
|
||||||
actions.update.success,
|
|
||||||
actions.update.failure,
|
|
||||||
actions.uploadOverride.success,
|
|
||||||
actions.uploadOverride.failure,
|
|
||||||
].map(action => action.toString());
|
|
||||||
testActionTypes(actionTypes, dataKey);
|
|
||||||
});
|
});
|
||||||
describe('actions provided', () => {
|
|
||||||
describe('banner', () => {
|
describe('fetchGrades', () => {
|
||||||
test('open action', () => testAction(actions.banner.open));
|
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||||
test('close action', () => testAction(actions.banner.close));
|
const expectedCohort = 1;
|
||||||
});
|
const expectedTrack = 'verified';
|
||||||
describe('bulkHistory', () => {
|
const expectedAssignmentType = 'Exam';
|
||||||
test('received action', () => testAction(actions.bulkHistory.received));
|
const fetchGradesURL = `${configuration.LMS_BASE_URL}/api/grades/v1/gradebook/${courseId}/?page_size=25&cohort_id=${expectedCohort}&enrollment_mode=${expectedTrack}`;
|
||||||
test('error action', () => testAction(actions.bulkHistory.error));
|
const responseData = {
|
||||||
});
|
next: `${fetchGradesURL}&cursor=2344fda`,
|
||||||
describe('csvUpload', () => {
|
previous: null,
|
||||||
test('started action', () => testAction(actions.csvUpload.started));
|
results: [
|
||||||
test('finished action', () => testAction(actions.csvUpload.finished));
|
{
|
||||||
test('error action', () => testAction(actions.csvUpload.error));
|
course_id: courseId,
|
||||||
});
|
email: 'user1@example.com',
|
||||||
test('doneViewingAssignment', () => testAction(actions.doneViewingAssignment));
|
username: 'user1',
|
||||||
describe('downloadReport', () => {
|
user_id: 1,
|
||||||
test('bulkGrades action', () => testAction(actions.downloadReport.bulkGrades));
|
percent: 0.5,
|
||||||
test('intervention action', () => testAction(actions.downloadReport.intervention));
|
letter_grade: null,
|
||||||
});
|
section_breakdown: [
|
||||||
describe('fetching', () => {
|
{
|
||||||
test('started action', () => testAction(actions.fetching.started));
|
subsection_name: 'Demo Course Overview',
|
||||||
test('finished action', () => testAction(actions.fetching.finished));
|
score_earned: 0,
|
||||||
test('error action', () => testAction(actions.fetching.error));
|
score_possible: 0,
|
||||||
describe('received', () => {
|
percent: 0,
|
||||||
it('loads grades data from data', () => {
|
displayed_value: '0.00',
|
||||||
const data = {
|
grade_description: '(0.00/0.00)',
|
||||||
grades: ['some', 'grades'],
|
},
|
||||||
cohort: 2,
|
{
|
||||||
track: 'summoners',
|
subsection_name: 'Example Week 1: Getting Started',
|
||||||
assignmentType: 'potion',
|
score_earned: 1,
|
||||||
headings: ['H', 'E', 'a', 'd', 'Ing', 'sssss'],
|
score_possible: 1,
|
||||||
prev: 'prEEEV',
|
percent: 1,
|
||||||
next: 'NEEEExt',
|
displayed_value: '1.00',
|
||||||
courseId: 'fake ID',
|
grade_description: '(0.00/0.00)',
|
||||||
totalUsersCount: 2,
|
},
|
||||||
filteredUsersCount: 999,
|
],
|
||||||
};
|
},
|
||||||
testAction(actions.fetching.received, { ...data, other: 'fields' }, data);
|
{
|
||||||
});
|
course_id: courseId,
|
||||||
|
email: 'user22@example.com',
|
||||||
|
username: 'user22',
|
||||||
|
user_id: 22,
|
||||||
|
percent: 0,
|
||||||
|
letter_grade: null,
|
||||||
|
section_breakdown: [
|
||||||
|
{
|
||||||
|
subsection_name: 'Demo Course Overview',
|
||||||
|
score_earned: 0,
|
||||||
|
score_possible: 0,
|
||||||
|
percent: 0,
|
||||||
|
displayed_value: '0.00',
|
||||||
|
grade_description: '(0.00/0.00)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
subsection_name: 'Example Week 1: Getting Started',
|
||||||
|
score_earned: 1,
|
||||||
|
score_possible: 1,
|
||||||
|
percent: 0,
|
||||||
|
displayed_value: '0.00',
|
||||||
|
grade_description: '(0.00/0.00)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
it('dispatches success action after fetching grades', () => {
|
||||||
|
const expectedActions = [
|
||||||
|
{ type: STARTED_FETCHING_GRADES },
|
||||||
|
{
|
||||||
|
type: GOT_GRADES,
|
||||||
|
grades: responseData.results.sort(sortAlphaAsc),
|
||||||
|
cohort: expectedCohort,
|
||||||
|
track: expectedTrack,
|
||||||
|
assignmentType: expectedAssignmentType,
|
||||||
|
prev: responseData.previous,
|
||||||
|
next: responseData.next,
|
||||||
|
courseId,
|
||||||
|
},
|
||||||
|
{ type: FINISHED_FETCHING_GRADES },
|
||||||
|
];
|
||||||
|
const store = mockStore();
|
||||||
|
|
||||||
|
axiosMock.onGet(fetchGradesURL)
|
||||||
|
.replyOnce(200, JSON.stringify(responseData));
|
||||||
|
|
||||||
|
return store.dispatch(fetchGrades(
|
||||||
|
courseId,
|
||||||
|
expectedCohort,
|
||||||
|
expectedTrack,
|
||||||
|
expectedAssignmentType,
|
||||||
|
false,
|
||||||
|
)).then(() => {
|
||||||
|
expect(store.getActions()).toEqual(expectedActions);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
describe('overrideHistory', () => {
|
|
||||||
test('error action', () => testAction(actions.overrideHistory.error));
|
it('dispatches failure action after fetching grades', () => {
|
||||||
describe('received', () => {
|
const expectedActions = [
|
||||||
it('loads override history from data', () => {
|
{ type: STARTED_FETCHING_GRADES },
|
||||||
const data = {
|
{ type: ERROR_FETCHING_GRADES },
|
||||||
overrideHistory: 'some History',
|
];
|
||||||
currentEarnedAllOverride: 123,
|
const store = mockStore();
|
||||||
currentPossibleAllOverride: 243,
|
|
||||||
currentEarnedGradedOverride: 1236,
|
axiosMock.onGet(fetchGradesURL)
|
||||||
currentPossibleGradedOverride: 52,
|
.replyOnce(500, JSON.stringify({}));
|
||||||
originalGradeEarnedAll: 323,
|
|
||||||
originalGradePossibleAll: 6223,
|
return store.dispatch(fetchGrades(
|
||||||
originalGradeEarnedGraded: 1232,
|
courseId,
|
||||||
originalGradePossibleGraded: 512,
|
expectedCohort,
|
||||||
};
|
expectedTrack,
|
||||||
testAction(actions.overrideHistory.received, { ...data, other: 'fields' }, data);
|
expectedAssignmentType,
|
||||||
});
|
false,
|
||||||
|
)).then(() => {
|
||||||
|
expect(store.getActions()).toEqual(expectedActions);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
test('toggleGradeFormat', () => testAction(actions.toggleGradeFormat));
|
|
||||||
describe('update', () => {
|
it('dispatches success action on empty response after fetching grades', () => {
|
||||||
const courseId = 'fake ID';
|
const emptyResponseData = {
|
||||||
const error = 'Try Again??';
|
next: responseData.next,
|
||||||
test('request action', () => testAction(actions.update.request));
|
previous: responseData.previous,
|
||||||
test('success action', () => testAction(actions.update.success));
|
results: [],
|
||||||
test('failure action', () => testAction(
|
};
|
||||||
actions.update.failure,
|
const expectedActions = [
|
||||||
[courseId, error],
|
{ type: STARTED_FETCHING_GRADES },
|
||||||
{ courseId, error },
|
{
|
||||||
));
|
type: GOT_GRADES,
|
||||||
});
|
grades: [],
|
||||||
describe('uploadOverride', () => {
|
cohort: expectedCohort,
|
||||||
const courseId = 'fake ID';
|
track: expectedTrack,
|
||||||
const error = 'Try Again??';
|
assignmentType: expectedAssignmentType,
|
||||||
test('success action', () => testAction(actions.uploadOverride.success));
|
prev: responseData.previous,
|
||||||
test('failure action', () => testAction(
|
next: responseData.next,
|
||||||
actions.uploadOverride.failure,
|
courseId,
|
||||||
[courseId, error],
|
},
|
||||||
{ courseId, error },
|
{ type: FINISHED_FETCHING_GRADES },
|
||||||
));
|
];
|
||||||
|
const store = mockStore();
|
||||||
|
|
||||||
|
axiosMock.onGet(fetchGradesURL)
|
||||||
|
.replyOnce(200, JSON.stringify(emptyResponseData));
|
||||||
|
|
||||||
|
return store.dispatch(fetchGrades(
|
||||||
|
courseId,
|
||||||
|
expectedCohort,
|
||||||
|
expectedTrack,
|
||||||
|
expectedAssignmentType,
|
||||||
|
false,
|
||||||
|
)).then(() => {
|
||||||
|
expect(store.getActions()).toEqual(expectedActions);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
import { StrictDict } from 'utils';
|
|
||||||
|
|
||||||
import assignmentTypes from './assignmentTypes';
|
|
||||||
import cohorts from './cohorts';
|
|
||||||
import config from './config';
|
|
||||||
import filters from './filters';
|
|
||||||
import grades from './grades';
|
|
||||||
import roles from './roles';
|
|
||||||
import tracks from './tracks';
|
|
||||||
|
|
||||||
export default StrictDict({
|
|
||||||
assignmentTypes,
|
|
||||||
cohorts,
|
|
||||||
config,
|
|
||||||
filters,
|
|
||||||
grades,
|
|
||||||
roles,
|
|
||||||
tracks,
|
|
||||||
});
|
|
||||||
@@ -1,14 +1,44 @@
|
|||||||
import { StrictDict } from 'utils';
|
import {
|
||||||
import { createActionFactory } from './utils';
|
GOT_ROLES,
|
||||||
|
ERROR_FETCHING_ROLES,
|
||||||
|
} from '../constants/actionTypes/roles';
|
||||||
|
import { fetchGrades } from './grades';
|
||||||
|
import { fetchTracks } from './tracks';
|
||||||
|
import { fetchCohorts } from './cohorts';
|
||||||
|
import { fetchAssignmentTypes } from './assignmentTypes';
|
||||||
|
import { getFilters } from '../selectors/filters';
|
||||||
|
import LmsApiService from '../services/LmsApiService';
|
||||||
|
|
||||||
export const dataKey = 'roles';
|
const allowedRoles = ['staff', 'instructor', 'support'];
|
||||||
const createAction = createActionFactory(dataKey);
|
|
||||||
|
|
||||||
const fetching = {
|
const gotRoles = (canUserViewGradebook, courseId) => ({
|
||||||
error: createAction('fetching/error'),
|
type: GOT_ROLES,
|
||||||
received: createAction('fetching/received'),
|
canUserViewGradebook,
|
||||||
};
|
courseId,
|
||||||
|
|
||||||
export default StrictDict({
|
|
||||||
fetching: StrictDict(fetching),
|
|
||||||
});
|
});
|
||||||
|
const errorFetchingRoles = () => ({ type: ERROR_FETCHING_ROLES });
|
||||||
|
|
||||||
|
const getRoles = courseId => (
|
||||||
|
(dispatch, getState) => LmsApiService.fetchUserRoles(courseId)
|
||||||
|
.then(response => response.data)
|
||||||
|
.then((response) => {
|
||||||
|
const canUserViewGradebook = response.is_staff
|
||||||
|
|| (response.roles.some(role => (role.course_id === courseId)
|
||||||
|
&& allowedRoles.includes(role.role)));
|
||||||
|
dispatch(gotRoles(canUserViewGradebook, courseId));
|
||||||
|
const { cohort, track, assignmentType } = getFilters(getState());
|
||||||
|
if (canUserViewGradebook) {
|
||||||
|
dispatch(fetchGrades(courseId, cohort, track, assignmentType));
|
||||||
|
dispatch(fetchTracks(courseId));
|
||||||
|
dispatch(fetchCohorts(courseId));
|
||||||
|
dispatch(fetchAssignmentTypes(courseId));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
dispatch(errorFetchingRoles());
|
||||||
|
}));
|
||||||
|
|
||||||
|
export {
|
||||||
|
getRoles,
|
||||||
|
errorFetchingRoles,
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,18 +1,163 @@
|
|||||||
import actions, { dataKey } from './roles';
|
import configureMockStore from 'redux-mock-store';
|
||||||
import { testAction, testActionTypes } from './testUtils';
|
import MockAdapter from 'axios-mock-adapter';
|
||||||
|
import thunk from 'redux-thunk';
|
||||||
|
|
||||||
describe('actions.roles', () => {
|
import apiClient from '../apiClient';
|
||||||
describe('action types', () => {
|
import { configuration } from '../../config';
|
||||||
const actionTypes = [
|
import { getRoles } from './roles';
|
||||||
actions.fetching.error,
|
import {
|
||||||
actions.fetching.received,
|
GOT_ROLES,
|
||||||
].map(action => action.toString());
|
ERROR_FETCHING_ROLES,
|
||||||
testActionTypes(actionTypes, dataKey);
|
} from '../constants/actionTypes/roles';
|
||||||
|
import { STARTED_FETCHING_GRADES } from '../constants/actionTypes/grades';
|
||||||
|
import { STARTED_FETCHING_TRACKS } from '../constants/actionTypes/tracks';
|
||||||
|
import { STARTED_FETCHING_COHORTS } from '../constants/actionTypes/cohorts';
|
||||||
|
import { STARTED_FETCHING_ASSIGNMENT_TYPES } from '../constants/actionTypes/assignmentTypes';
|
||||||
|
|
||||||
|
|
||||||
|
const mockStore = configureMockStore([thunk]);
|
||||||
|
const axiosMock = new MockAdapter(apiClient);
|
||||||
|
apiClient.isAccessTokenExpired = jest.fn();
|
||||||
|
apiClient.isAccessTokenExpired.mockReturnValue(false);
|
||||||
|
|
||||||
|
const course1Id = 'course-v1:edX+DemoX+Demo_Course';
|
||||||
|
const course2Id = 'course-v1:edX+DemoX+Demo_Course_2';
|
||||||
|
const rolesUrl = `${configuration.LMS_BASE_URL}/api/enrollment/v1/roles/?course_id=${encodeURIComponent(course1Id)}`;
|
||||||
|
|
||||||
|
function makeRoleListObj(roles, isGlobalStaff) {
|
||||||
|
return {
|
||||||
|
roles,
|
||||||
|
is_staff: isGlobalStaff,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function makeRoleObj(courseId, role) {
|
||||||
|
return {
|
||||||
|
course_id: courseId,
|
||||||
|
role,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const course1StaffRole = makeRoleObj(course1Id, 'staff');
|
||||||
|
const course1DummyRole = makeRoleObj(course1Id, 'dummy');
|
||||||
|
const course2StaffRole = makeRoleObj(course2Id, 'staff');
|
||||||
|
const course2DummyRole = makeRoleObj(course2Id, 'dummy');
|
||||||
|
const urlParams = { cohort: null, track: null };
|
||||||
|
|
||||||
|
describe('actions', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
axiosMock.reset();
|
||||||
});
|
});
|
||||||
describe('actions provided', () => {
|
|
||||||
describe('fecthing actions', () => {
|
describe('getRoles', () => {
|
||||||
test('error action', () => testAction(actions.fetching.error));
|
it('dispatches got_roles action and subsequent actions after fetching role that allows gradebook', () => {
|
||||||
test('received action', () => testAction(actions.fetching.received));
|
const expectedActions = [
|
||||||
|
{ type: GOT_ROLES, canUserViewGradebook: true, courseId: course1Id },
|
||||||
|
{ type: STARTED_FETCHING_GRADES },
|
||||||
|
{ type: STARTED_FETCHING_TRACKS },
|
||||||
|
{ type: STARTED_FETCHING_COHORTS },
|
||||||
|
{ type: STARTED_FETCHING_ASSIGNMENT_TYPES },
|
||||||
|
];
|
||||||
|
const store = mockStore();
|
||||||
|
axiosMock.onGet(rolesUrl)
|
||||||
|
.replyOnce(
|
||||||
|
200,
|
||||||
|
JSON.stringify(makeRoleListObj([course1StaffRole, course2DummyRole], false)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return store.dispatch(getRoles(course1Id, urlParams)).then(() => {
|
||||||
|
expect(store.getActions()).toEqual(expectedActions);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches got_roles action and other actions after fetching irrelevent roles but user is global staff', () => {
|
||||||
|
const expectedActions = [
|
||||||
|
{ type: GOT_ROLES, canUserViewGradebook: true, courseId: course1Id },
|
||||||
|
{ type: STARTED_FETCHING_GRADES },
|
||||||
|
{ type: STARTED_FETCHING_TRACKS },
|
||||||
|
{ type: STARTED_FETCHING_COHORTS },
|
||||||
|
{ type: STARTED_FETCHING_ASSIGNMENT_TYPES },
|
||||||
|
];
|
||||||
|
const store = mockStore();
|
||||||
|
|
||||||
|
axiosMock.onGet(rolesUrl)
|
||||||
|
.replyOnce(
|
||||||
|
200,
|
||||||
|
JSON.stringify(makeRoleListObj([course1DummyRole, course2DummyRole], true)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return store.dispatch(getRoles(course1Id, urlParams)).then(() => {
|
||||||
|
expect(store.getActions()).toEqual(expectedActions);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches got_roles action and no other actions after fetching role that disallows gradebook', () => {
|
||||||
|
const expectedActions = [
|
||||||
|
{
|
||||||
|
type: GOT_ROLES, canUserViewGradebook: false, courseId: course1Id,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const store = mockStore();
|
||||||
|
|
||||||
|
axiosMock.onGet(rolesUrl)
|
||||||
|
.replyOnce(
|
||||||
|
200,
|
||||||
|
JSON.stringify(makeRoleListObj([course1DummyRole, course2StaffRole], false)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return store.dispatch(getRoles(course1Id, urlParams)).then(() => {
|
||||||
|
expect(store.getActions()).toEqual(expectedActions);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches got_roles action and no other actions after fetching empty roles', () => {
|
||||||
|
const expectedActions = [
|
||||||
|
{ type: GOT_ROLES, canUserViewGradebook: false, courseId: course1Id },
|
||||||
|
];
|
||||||
|
const store = mockStore();
|
||||||
|
|
||||||
|
axiosMock.onGet(rolesUrl)
|
||||||
|
.replyOnce(
|
||||||
|
200,
|
||||||
|
JSON.stringify(makeRoleListObj([], false)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return store.dispatch(getRoles(course1Id, urlParams)).then(() => {
|
||||||
|
expect(store.getActions()).toEqual(expectedActions);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches got_roles action and other actions after fetching empty roles but user is global staff', () => {
|
||||||
|
const expectedActions = [
|
||||||
|
{ type: GOT_ROLES, canUserViewGradebook: true, courseId: course1Id },
|
||||||
|
{ type: STARTED_FETCHING_GRADES },
|
||||||
|
{ type: STARTED_FETCHING_TRACKS },
|
||||||
|
{ type: STARTED_FETCHING_COHORTS },
|
||||||
|
{ type: STARTED_FETCHING_ASSIGNMENT_TYPES },
|
||||||
|
];
|
||||||
|
const store = mockStore();
|
||||||
|
|
||||||
|
axiosMock.onGet(rolesUrl)
|
||||||
|
.replyOnce(
|
||||||
|
200,
|
||||||
|
JSON.stringify(makeRoleListObj([], true)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return store.dispatch(getRoles(course1Id, urlParams)).then(() => {
|
||||||
|
expect(store.getActions()).toEqual(expectedActions);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches error action after getting an error when trying to get roles', () => {
|
||||||
|
const expectedActions = [
|
||||||
|
{ type: ERROR_FETCHING_ROLES },
|
||||||
|
];
|
||||||
|
const store = mockStore();
|
||||||
|
|
||||||
|
axiosMock.onGet(rolesUrl).replyOnce(400);
|
||||||
|
|
||||||
|
return store.dispatch(getRoles(course1Id, urlParams)).then(() => {
|
||||||
|
expect(store.getActions()).toEqual(expectedActions);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
/**
|
|
||||||
* testActionTypes(actionTypes, dataKey)
|
|
||||||
* Takes a list of actionTypes and a module dataKey, and verifies that
|
|
||||||
* * all actionTypes are unique
|
|
||||||
* * all actionTypes begin with the dataKey
|
|
||||||
* @param {string[]} actionTypes - list of action types
|
|
||||||
* @param {string} dataKey - module data key
|
|
||||||
*/
|
|
||||||
export const testActionTypes = (actionTypes, dataKey) => {
|
|
||||||
test('all types are unique', () => {
|
|
||||||
expect(actionTypes.length).toEqual((new Set(actionTypes)).size);
|
|
||||||
});
|
|
||||||
test('all types begin with the module dataKey', () => {
|
|
||||||
actionTypes.forEach(type => {
|
|
||||||
expect(type.startsWith(dataKey)).toEqual(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* testAction(action, args, expectedPayload)
|
|
||||||
* Multi-purpose action creator test function.
|
|
||||||
* If args/expectedPayload are passed, verifies that it produces the expected output when called
|
|
||||||
* with the given args.
|
|
||||||
* If none are passed, (for action creators with basic definition) it tests against a default
|
|
||||||
* test payload.
|
|
||||||
* @param {object} action - action creator object/method
|
|
||||||
* @param {[object]} args - optional payload argument
|
|
||||||
* @param {[object]} expectedPayload - optional expected payload.
|
|
||||||
*/
|
|
||||||
export const testAction = (action, args, expectedPayload) => {
|
|
||||||
const type = action.toString();
|
|
||||||
if (args) {
|
|
||||||
if (Array.isArray(args)) {
|
|
||||||
expect(action(...args)).toEqual({ type, payload: expectedPayload });
|
|
||||||
} else {
|
|
||||||
expect(action(args)).toEqual({ type, payload: expectedPayload });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const payload = { test: 'PAYload' };
|
|
||||||
expect(action(payload)).toEqual({ type, payload });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default {
|
|
||||||
testAction,
|
|
||||||
testActionTypes,
|
|
||||||
};
|
|
||||||
@@ -1,15 +1,36 @@
|
|||||||
import { StrictDict } from 'utils';
|
import {
|
||||||
import { createActionFactory } from './utils';
|
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';
|
||||||
|
|
||||||
export const dataKey = 'tracks';
|
const startedFetchingTracks = () => ({ type: STARTED_FETCHING_TRACKS });
|
||||||
const createAction = createActionFactory(dataKey);
|
const errorFetchingTracks = () => ({ type: ERROR_FETCHING_TRACKS });
|
||||||
|
const gotTracks = tracks => ({ type: GOT_TRACKS, tracks });
|
||||||
|
|
||||||
const fetching = {
|
const fetchTracks = courseId => (
|
||||||
started: createAction('fetching/started'),
|
(dispatch) => {
|
||||||
error: createAction('fetching/error'),
|
dispatch(startedFetchingTracks());
|
||||||
received: createAction('fetching/received'),
|
return LmsApiService.fetchTracks(courseId)
|
||||||
|
.then(response => response.data)
|
||||||
|
.then((data) => {
|
||||||
|
dispatch(gotTracks(data.course_modes));
|
||||||
|
if (hasMastersTrack(data.course_modes)) {
|
||||||
|
dispatch(fetchBulkUpgradeHistory(courseId));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
dispatch(errorFetchingTracks());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export {
|
||||||
|
fetchTracks,
|
||||||
|
startedFetchingTracks,
|
||||||
|
gotTracks,
|
||||||
|
errorFetchingTracks,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default StrictDict({
|
|
||||||
fetching: StrictDict(fetching),
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,20 +1,83 @@
|
|||||||
import actions, { dataKey } from './tracks';
|
import configureMockStore from 'redux-mock-store';
|
||||||
import { testAction, testActionTypes } from './testUtils';
|
import MockAdapter from 'axios-mock-adapter';
|
||||||
|
import thunk from 'redux-thunk';
|
||||||
|
|
||||||
describe('actions.tracks', () => {
|
import apiClient from '../apiClient';
|
||||||
describe('action types', () => {
|
import { configuration } from '../../config';
|
||||||
const actionTypes = [
|
import { fetchTracks } from './tracks';
|
||||||
actions.fetching.error,
|
import {
|
||||||
actions.fetching.started,
|
STARTED_FETCHING_TRACKS,
|
||||||
actions.fetching.received,
|
GOT_TRACKS,
|
||||||
].map(action => action.toString());
|
ERROR_FETCHING_TRACKS,
|
||||||
testActionTypes(actionTypes, dataKey);
|
} from '../constants/actionTypes/tracks';
|
||||||
|
|
||||||
|
const mockStore = configureMockStore([thunk]);
|
||||||
|
const axiosMock = new MockAdapter(apiClient);
|
||||||
|
apiClient.isAccessTokenExpired = jest.fn();
|
||||||
|
apiClient.isAccessTokenExpired.mockReturnValue(false);
|
||||||
|
|
||||||
|
describe('actions', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
axiosMock.reset();
|
||||||
});
|
});
|
||||||
describe('actions provided', () => {
|
|
||||||
describe('fecthing actions', () => {
|
describe('fetchTracks', () => {
|
||||||
test('error action', () => testAction(actions.fetching.error));
|
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||||
test('started action', () => testAction(actions.fetching.started));
|
const trackUrl = `${configuration.LMS_BASE_URL}/api/enrollment/v1/course/${courseId}?include_expired=1`;
|
||||||
test('received action', () => testAction(actions.fetching.received));
|
|
||||||
|
it('dispatches success action after fetching tracks', () => {
|
||||||
|
const responseData = {
|
||||||
|
course_modes: [
|
||||||
|
{
|
||||||
|
slug: 'audit',
|
||||||
|
name: 'Audit',
|
||||||
|
min_price: 0,
|
||||||
|
suggested_prices: '',
|
||||||
|
currency: 'usd',
|
||||||
|
expiration_datetime: null,
|
||||||
|
description: null,
|
||||||
|
sku: '68EFFFF',
|
||||||
|
bulk_sku: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'verified',
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
min_price: 100,
|
||||||
|
suggested_prices: '',
|
||||||
|
currency: 'usd',
|
||||||
|
expiration_datetime: '2021-05-04T18:08:12.644361Z',
|
||||||
|
description: null,
|
||||||
|
sku: '8CF08E5',
|
||||||
|
bulk_sku: 'A5B6DBE',
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
const expectedActions = [
|
||||||
|
{ type: STARTED_FETCHING_TRACKS },
|
||||||
|
{ type: GOT_TRACKS, tracks: responseData.course_modes },
|
||||||
|
];
|
||||||
|
const store = mockStore();
|
||||||
|
|
||||||
|
axiosMock.onGet(trackUrl)
|
||||||
|
.replyOnce(200, JSON.stringify(responseData));
|
||||||
|
|
||||||
|
return store.dispatch(fetchTracks(courseId)).then(() => {
|
||||||
|
expect(store.getActions()).toEqual(expectedActions);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches failure action after fetching tracks', () => {
|
||||||
|
const expectedActions = [
|
||||||
|
{ type: STARTED_FETCHING_TRACKS },
|
||||||
|
{ type: ERROR_FETCHING_TRACKS },
|
||||||
|
];
|
||||||
|
const store = mockStore();
|
||||||
|
|
||||||
|
axiosMock.onGet(trackUrl)
|
||||||
|
.replyOnce(500, JSON.stringify({}));
|
||||||
|
|
||||||
|
return store.dispatch(fetchTracks(courseId)).then(() => {
|
||||||
|
expect(store.getActions()).toEqual(expectedActions);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { createAction } from '@reduxjs/toolkit';
|
|
||||||
|
|
||||||
const formatDateForDisplay = (inputDate) => {
|
const formatDateForDisplay = (inputDate) => {
|
||||||
const options = {
|
const options = {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
@@ -28,12 +26,5 @@ const sortAlphaAsc = (gradeRowA, gradeRowB) => {
|
|||||||
return 0;
|
return 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const createActionFactory = (dataKey) => (actionKey, ...args) => (
|
export { sortAlphaAsc, formatDateForDisplay };
|
||||||
createAction(`${dataKey}/${actionKey}`, ...args)
|
|
||||||
);
|
|
||||||
|
|
||||||
export {
|
|
||||||
createActionFactory,
|
|
||||||
sortAlphaAsc,
|
|
||||||
formatDateForDisplay,
|
|
||||||
};
|
|
||||||
|
|||||||
15
src/data/apiClient.js
Normal file
15
src/data/apiClient.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
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;
|
||||||
12
src/data/constants/actionTypes/assignmentTypes.js
Normal file
12
src/data/constants/actionTypes/assignmentTypes.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
const STARTED_FETCHING_ASSIGNMENT_TYPES = 'STARTED_FETCHING_ASSIGNMENT_TYPES';
|
||||||
|
const GOT_ASSIGNMENT_TYPES = 'GOT_ASSIGNMENT_TYPES';
|
||||||
|
const ERROR_FETCHING_ASSIGNMENT_TYPES = 'ERROR_FETCHING_ASSIGNMENT_TYPES';
|
||||||
|
const GOT_ARE_GRADES_FROZEN = 'GOT_ARE_GRADES_FROZEN';
|
||||||
|
|
||||||
|
export {
|
||||||
|
STARTED_FETCHING_ASSIGNMENT_TYPES,
|
||||||
|
GOT_ASSIGNMENT_TYPES,
|
||||||
|
ERROR_FETCHING_ASSIGNMENT_TYPES,
|
||||||
|
GOT_ARE_GRADES_FROZEN,
|
||||||
|
};
|
||||||
|
|
||||||
9
src/data/constants/actionTypes/cohorts.js
Normal file
9
src/data/constants/actionTypes/cohorts.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
const STARTED_FETCHING_COHORTS = 'STARTED_FETCHING_COHORTS';
|
||||||
|
const GOT_COHORTS = 'GOT_COHORTS';
|
||||||
|
const ERROR_FETCHING_COHORTS = 'ERROR_FETCHING_COHORTS';
|
||||||
|
|
||||||
|
export {
|
||||||
|
STARTED_FETCHING_COHORTS,
|
||||||
|
GOT_COHORTS,
|
||||||
|
ERROR_FETCHING_COHORTS,
|
||||||
|
};
|
||||||
3
src/data/constants/actionTypes/config.js
Normal file
3
src/data/constants/actionTypes/config.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
const GOT_BULK_MANAGEMENT_CONFIG = 'GOT_BULK_MANAGEMENT_CONFIG';
|
||||||
|
|
||||||
|
export default GOT_BULK_MANAGEMENT_CONFIG;
|
||||||
9
src/data/constants/actionTypes/filters.js
Normal file
9
src/data/constants/actionTypes/filters.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
const INITIALIZE_FILTERS = 'INITIALIZE_FILTERS';
|
||||||
|
const RESET_FILTERS = 'RESET_FILTERS';
|
||||||
|
const UPDATE_ASSIGNMENT_FILTER = 'UPDATE_ASSIGNMENT_FILTER';
|
||||||
|
const UPDATE_ASSIGNMENT_LIMITS = 'UPDATE_ASSIGNMENT_LIMITS';
|
||||||
|
const UPDATE_COURSE_GRADE_LIMITS = 'UPDATE_COURSE_GRADE_LIMITS';
|
||||||
|
export {
|
||||||
|
INITIALIZE_FILTERS, RESET_FILTERS, UPDATE_ASSIGNMENT_FILTER,
|
||||||
|
UPDATE_ASSIGNMENT_LIMITS, UPDATE_COURSE_GRADE_LIMITS,
|
||||||
|
};
|
||||||
59
src/data/constants/actionTypes/grades.js
Normal file
59
src/data/constants/actionTypes/grades.js
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
const STARTED_FETCHING_GRADES = 'STARTED_FETCHING_GRADES';
|
||||||
|
const FINISHED_FETCHING_GRADES = 'FINISHED_FETCHING_GRADES';
|
||||||
|
const ERROR_FETCHING_GRADES = 'ERROR_FETCHING_GRADES';
|
||||||
|
const GOT_GRADES = 'GOT_GRADES';
|
||||||
|
const DONE_VIEWING_ASSIGNMENT = 'DONE_VIEWING_ASSIGNMENT';
|
||||||
|
const GOT_GRADE_OVERRIDE_HISTORY = 'GOT_GRADE_OVERRIDE_HISTORY';
|
||||||
|
const ERROR_FETCHING_GRADE_OVERRIDE_HISTORY = 'ERROR_FETCHING_GRADE_OVERRIDE_HISTORY';
|
||||||
|
|
||||||
|
const FILTER_SELECTED = 'FILTER_SELECTED';
|
||||||
|
const GRADE_OVERRIDE = 'GRADE_OVERRIDE';
|
||||||
|
const REPORT_DOWNLOADED = 'REPORT_DOWNLOADED';
|
||||||
|
const UPLOAD_OVERRIDE = 'UPLOAD_OVERRIDE';
|
||||||
|
const UPLOAD_OVERRIDE_ERROR = 'UPLOAD_OVERRIDE_ERROR';
|
||||||
|
|
||||||
|
const GRADE_UPDATE_REQUEST = 'GRADE_UPDATE_REQUEST';
|
||||||
|
const GRADE_UPDATE_SUCCESS = 'GRADE_UPDATE_SUCCESS';
|
||||||
|
const GRADE_UPDATE_FAILURE = 'GRADE_UPDATE_FAILURE';
|
||||||
|
|
||||||
|
const TOGGLE_GRADE_FORMAT = 'TOGGLE_GRADE_FORMAT';
|
||||||
|
const FILTER_BY_ASSIGNMENT_TYPE = 'FILTER_BY_ASSIGNMENT_TYPE';
|
||||||
|
const CLOSE_BANNER = 'CLOSE_BANNER';
|
||||||
|
const OPEN_BANNER = 'OPEN_BANNER';
|
||||||
|
|
||||||
|
const START_UPLOAD = 'START_UPLOAD';
|
||||||
|
const UPLOAD_COMPLETE = 'UPLOAD_COMPLETE';
|
||||||
|
const UPLOAD_ERR = 'UPLOAD_ERR';
|
||||||
|
const GOT_BULK_HISTORY = 'GOT_BULK_HISTORY';
|
||||||
|
const BULK_HISTORY_ERR = 'BULK_HISTORY_ERR';
|
||||||
|
const BULK_GRADE_REPORT_DOWNLOADED = 'BULK_GRADE_REPORT_DOWNLOADED';
|
||||||
|
const INTERVENTION_REPORT_DOWNLOADED = 'INTERVENTION_REPORT_DOWNLOADED';
|
||||||
|
|
||||||
|
export {
|
||||||
|
STARTED_FETCHING_GRADES,
|
||||||
|
FINISHED_FETCHING_GRADES,
|
||||||
|
ERROR_FETCHING_GRADES,
|
||||||
|
GOT_GRADES,
|
||||||
|
GRADE_UPDATE_REQUEST,
|
||||||
|
GRADE_UPDATE_SUCCESS,
|
||||||
|
GRADE_UPDATE_FAILURE,
|
||||||
|
TOGGLE_GRADE_FORMAT,
|
||||||
|
FILTER_BY_ASSIGNMENT_TYPE,
|
||||||
|
OPEN_BANNER,
|
||||||
|
CLOSE_BANNER,
|
||||||
|
START_UPLOAD,
|
||||||
|
UPLOAD_COMPLETE,
|
||||||
|
UPLOAD_ERR,
|
||||||
|
GOT_BULK_HISTORY,
|
||||||
|
BULK_HISTORY_ERR,
|
||||||
|
DONE_VIEWING_ASSIGNMENT,
|
||||||
|
GOT_GRADE_OVERRIDE_HISTORY,
|
||||||
|
ERROR_FETCHING_GRADE_OVERRIDE_HISTORY,
|
||||||
|
FILTER_SELECTED,
|
||||||
|
GRADE_OVERRIDE,
|
||||||
|
REPORT_DOWNLOADED,
|
||||||
|
UPLOAD_OVERRIDE,
|
||||||
|
UPLOAD_OVERRIDE_ERROR,
|
||||||
|
BULK_GRADE_REPORT_DOWNLOADED,
|
||||||
|
INTERVENTION_REPORT_DOWNLOADED,
|
||||||
|
};
|
||||||
7
src/data/constants/actionTypes/roles.js
Normal file
7
src/data/constants/actionTypes/roles.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
const GOT_ROLES = 'GOT_ROLES';
|
||||||
|
const ERROR_FETCHING_ROLES = 'ERROR_FETCHING_ROLES';
|
||||||
|
|
||||||
|
export {
|
||||||
|
GOT_ROLES,
|
||||||
|
ERROR_FETCHING_ROLES,
|
||||||
|
};
|
||||||
9
src/data/constants/actionTypes/tracks.js
Normal file
9
src/data/constants/actionTypes/tracks.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
const STARTED_FETCHING_TRACKS = 'STARTED_FETCHING_TRACKS';
|
||||||
|
const GOT_TRACKS = 'GOT_TRACKS';
|
||||||
|
const ERROR_FETCHING_TRACKS = 'ERROR_FETCHING_TRACKS';
|
||||||
|
|
||||||
|
export {
|
||||||
|
STARTED_FETCHING_TRACKS,
|
||||||
|
GOT_TRACKS,
|
||||||
|
ERROR_FETCHING_TRACKS,
|
||||||
|
};
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
const GRADE_OVERRIDE_HISTORY_ERROR_DEFAULT_MSG = 'Error retrieving grade override history.';
|
|
||||||
|
|
||||||
export default GRADE_OVERRIDE_HISTORY_ERROR_DEFAULT_MSG;
|
|
||||||
@@ -7,7 +7,6 @@ const initialFilters = {
|
|||||||
assignmentGradeMax: '100',
|
assignmentGradeMax: '100',
|
||||||
courseGradeMin: '0',
|
courseGradeMin: '0',
|
||||||
courseGradeMax: '100',
|
courseGradeMax: '100',
|
||||||
includeCourseRoleMembers: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default initialFilters;
|
export default initialFilters;
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
const EMAIL_HEADING = 'Email';
|
|
||||||
const TOTAL_COURSE_GRADE_HEADING = 'Total Grade (%)';
|
|
||||||
const USERNAME_HEADING = 'Username';
|
|
||||||
|
|
||||||
export { EMAIL_HEADING, TOTAL_COURSE_GRADE_HEADING, USERNAME_HEADING };
|
|
||||||
@@ -1,4 +1,9 @@
|
|||||||
import actions from '../actions/assignmentTypes';
|
import {
|
||||||
|
STARTED_FETCHING_ASSIGNMENT_TYPES,
|
||||||
|
ERROR_FETCHING_ASSIGNMENT_TYPES,
|
||||||
|
GOT_ASSIGNMENT_TYPES,
|
||||||
|
GOT_ARE_GRADES_FROZEN,
|
||||||
|
} from '../constants/actionTypes/assignmentTypes';
|
||||||
|
|
||||||
const initialState = {
|
const initialState = {
|
||||||
results: [],
|
results: [],
|
||||||
@@ -6,30 +11,31 @@ const initialState = {
|
|||||||
errorFetching: false,
|
errorFetching: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const assignmentTypes = (state = initialState, { type, payload }) => {
|
|
||||||
switch (type) {
|
const assignmentTypes = (state = initialState, action) => {
|
||||||
case actions.fetching.started.toString():
|
switch (action.type) {
|
||||||
|
case GOT_ASSIGNMENT_TYPES:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
results: action.assignmentTypes,
|
||||||
|
errorFetching: false,
|
||||||
|
finishedFetching: true,
|
||||||
|
};
|
||||||
|
case STARTED_FETCHING_ASSIGNMENT_TYPES:
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
startedFetching: true,
|
startedFetching: true,
|
||||||
};
|
};
|
||||||
case actions.fetching.received.toString():
|
case ERROR_FETCHING_ASSIGNMENT_TYPES:
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
results: payload,
|
|
||||||
errorFetching: false,
|
|
||||||
finishedFetching: true,
|
|
||||||
};
|
|
||||||
case actions.fetching.error.toString():
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
finishedFetching: true,
|
finishedFetching: true,
|
||||||
errorFetching: true,
|
errorFetching: true,
|
||||||
};
|
};
|
||||||
case actions.gotGradesFrozen.toString():
|
case GOT_ARE_GRADES_FROZEN:
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
areGradesFrozen: payload,
|
areGradesFrozen: action.areGradesFrozen,
|
||||||
errorFetching: false,
|
errorFetching: false,
|
||||||
finishedFetching: true,
|
finishedFetching: true,
|
||||||
};
|
};
|
||||||
@@ -38,5 +44,5 @@ const assignmentTypes = (state = initialState, { type, payload }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export { initialState };
|
|
||||||
export default assignmentTypes;
|
export default assignmentTypes;
|
||||||
|
|
||||||
|
|||||||
@@ -1,71 +1,68 @@
|
|||||||
import assignmentTypes, { initialState } from './assignmentTypes';
|
import assignmentTypes from './assignmentTypes';
|
||||||
import actions from '../actions/assignmentTypes';
|
import {
|
||||||
|
STARTED_FETCHING_ASSIGNMENT_TYPES,
|
||||||
|
ERROR_FETCHING_ASSIGNMENT_TYPES,
|
||||||
|
GOT_ASSIGNMENT_TYPES,
|
||||||
|
GOT_ARE_GRADES_FROZEN,
|
||||||
|
} from '../constants/actionTypes/assignmentTypes';
|
||||||
|
|
||||||
const testingState = {
|
const initialState = {
|
||||||
...initialState,
|
results: [],
|
||||||
results: ['Exam', 'Homework'],
|
startedFetching: false,
|
||||||
arbitraryField: 'arbitrary',
|
errorFetching: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const assignmentTypesData = ['Exam', 'Homework'];
|
||||||
|
|
||||||
describe('assignmentTypes reducer', () => {
|
describe('assignmentTypes reducer', () => {
|
||||||
it('has initial state', () => {
|
it('has initial state', () => {
|
||||||
expect(
|
expect(assignmentTypes(undefined, {})).toEqual(initialState);
|
||||||
assignmentTypes(undefined, {}),
|
|
||||||
).toEqual(initialState);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('handling actions.fetching.started', () => {
|
it('updates fetch assignmentTypes request state', () => {
|
||||||
it('sets startedFetching=true', () => {
|
const expected = {
|
||||||
const expected = {
|
...initialState,
|
||||||
...testingState,
|
startedFetching: true,
|
||||||
startedFetching: true,
|
};
|
||||||
};
|
expect(assignmentTypes(undefined, {
|
||||||
expect(
|
type: STARTED_FETCHING_ASSIGNMENT_TYPES,
|
||||||
assignmentTypes(testingState, actions.fetching.started()),
|
})).toEqual(expected);
|
||||||
).toEqual(expected);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('handling actions.fetching.received', () => {
|
it('updates fetch assignmentTypes success state', () => {
|
||||||
it('loads the results and sets finishedFetching=true and errorFetching=false', () => {
|
const expected = {
|
||||||
const expectedResults = ['Exam'];
|
...initialState,
|
||||||
const expected = {
|
results: assignmentTypesData,
|
||||||
...testingState,
|
errorFetching: false,
|
||||||
results: expectedResults,
|
finishedFetching: true,
|
||||||
errorFetching: false,
|
};
|
||||||
finishedFetching: true,
|
expect(assignmentTypes(undefined, {
|
||||||
};
|
type: GOT_ASSIGNMENT_TYPES,
|
||||||
expect(
|
assignmentTypes: assignmentTypesData,
|
||||||
assignmentTypes(testingState, actions.fetching.received(expectedResults)),
|
})).toEqual(expected);
|
||||||
).toEqual(expected);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('handling actions.fetching.error', () => {
|
it('updates fetch assignmentTypes failure state', () => {
|
||||||
it('sets errorFetching=true and finishedFetching=true', () => {
|
const expected = {
|
||||||
const expected = {
|
...initialState,
|
||||||
...testingState,
|
errorFetching: true,
|
||||||
errorFetching: true,
|
finishedFetching: true,
|
||||||
finishedFetching: true,
|
};
|
||||||
};
|
expect(assignmentTypes(undefined, {
|
||||||
expect(
|
type: ERROR_FETCHING_ASSIGNMENT_TYPES,
|
||||||
assignmentTypes(testingState, actions.fetching.error()),
|
})).toEqual(expected);
|
||||||
).toEqual(expected);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('handling actions.gotGradesFrozen', () => {
|
it('updates areGradesFrozen success state', () => {
|
||||||
it('loads areGradesFrozen and sets errorFetching=false and finishedFetching=true', () => {
|
const expected = {
|
||||||
const expectedAreGradesFrozen = true;
|
...initialState,
|
||||||
const expected = {
|
errorFetching: false,
|
||||||
...testingState,
|
finishedFetching: true,
|
||||||
errorFetching: false,
|
areGradesFrozen: true,
|
||||||
finishedFetching: true,
|
};
|
||||||
areGradesFrozen: expectedAreGradesFrozen,
|
expect(assignmentTypes(undefined, {
|
||||||
};
|
type: GOT_ARE_GRADES_FROZEN,
|
||||||
expect(
|
areGradesFrozen: true,
|
||||||
assignmentTypes(testingState, actions.gotGradesFrozen(expectedAreGradesFrozen)),
|
})).toEqual(expected);
|
||||||
).toEqual(expected);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import actions from '../actions/cohorts';
|
import {
|
||||||
|
STARTED_FETCHING_COHORTS,
|
||||||
|
ERROR_FETCHING_COHORTS,
|
||||||
|
GOT_COHORTS,
|
||||||
|
} from '../constants/actionTypes/cohorts';
|
||||||
|
|
||||||
const initialState = {
|
const initialState = {
|
||||||
results: [],
|
results: [],
|
||||||
@@ -6,21 +10,22 @@ const initialState = {
|
|||||||
errorFetching: false,
|
errorFetching: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const cohorts = (state = initialState, action) => {
|
const cohorts = (state = initialState, action) => {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case actions.fetching.started.toString():
|
case GOT_COHORTS:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
results: action.cohorts,
|
||||||
|
finishedFetching: true,
|
||||||
|
errorFetching: false,
|
||||||
|
};
|
||||||
|
case STARTED_FETCHING_COHORTS:
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
startedFetching: true,
|
startedFetching: true,
|
||||||
};
|
};
|
||||||
case actions.fetching.received.toString():
|
case ERROR_FETCHING_COHORTS:
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
results: action.payload,
|
|
||||||
finishedFetching: true,
|
|
||||||
errorFetching: false,
|
|
||||||
};
|
|
||||||
case actions.fetching.error.toString():
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
finishedFetching: true,
|
finishedFetching: true,
|
||||||
@@ -31,5 +36,5 @@ const cohorts = (state = initialState, action) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export { initialState };
|
|
||||||
export default cohorts;
|
export default cohorts;
|
||||||
|
|
||||||
|
|||||||
@@ -1,61 +1,70 @@
|
|||||||
import cohorts, { initialState } from './cohorts';
|
import cohorts from './cohorts';
|
||||||
import actions from '../actions/cohorts';
|
import {
|
||||||
|
STARTED_FETCHING_COHORTS,
|
||||||
|
ERROR_FETCHING_COHORTS,
|
||||||
|
GOT_COHORTS,
|
||||||
|
} from '../constants/actionTypes/cohorts';
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
results: [],
|
||||||
|
startedFetching: false,
|
||||||
|
errorFetching: false,
|
||||||
|
};
|
||||||
|
|
||||||
const cohortsData = [
|
const cohortsData = [
|
||||||
{ arbitraryCohortField: 'some data' },
|
{
|
||||||
{ anotherArbitraryCohortField: 'some data' },
|
assignment_type: 'manual',
|
||||||
];
|
group_id: null,
|
||||||
|
id: 1,
|
||||||
const testingState = {
|
name: 'default_group',
|
||||||
...initialState,
|
user_count: 2,
|
||||||
results: cohortsData,
|
user_partition_id: null,
|
||||||
arbitraryField: 'arbitrary',
|
},
|
||||||
};
|
{
|
||||||
|
assignment_type: 'auto',
|
||||||
|
group_id: null,
|
||||||
|
id: 2,
|
||||||
|
name: 'auto_group',
|
||||||
|
user_count: 5,
|
||||||
|
user_partition_id: null,
|
||||||
|
}];
|
||||||
|
|
||||||
describe('cohorts reducer', () => {
|
describe('cohorts reducer', () => {
|
||||||
it('has initial state', () => {
|
it('has initial state', () => {
|
||||||
expect(
|
expect(cohorts(undefined, {})).toEqual(initialState);
|
||||||
cohorts(undefined, {}),
|
|
||||||
).toEqual(initialState);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('handling actions.fetching.started', () => {
|
it('updates fetch cohorts request state', () => {
|
||||||
it('sets startedFetching=true', () => {
|
const expected = {
|
||||||
const expected = {
|
...initialState,
|
||||||
...testingState,
|
startedFetching: true,
|
||||||
startedFetching: true,
|
};
|
||||||
};
|
expect(cohorts(undefined, {
|
||||||
expect(
|
type: STARTED_FETCHING_COHORTS,
|
||||||
cohorts(testingState, actions.fetching.started()),
|
})).toEqual(expected);
|
||||||
).toEqual(expected);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('handling actions.fetching.received', () => {
|
it('updates fetch cohorts success state', () => {
|
||||||
it('loads results and sets finishedFetching=true and errorFetching=false', () => {
|
const expected = {
|
||||||
const newCohortData = [{ newResultFields: 'recieved data' }];
|
...initialState,
|
||||||
const expected = {
|
results: cohortsData,
|
||||||
...testingState,
|
errorFetching: false,
|
||||||
results: newCohortData,
|
finishedFetching: true,
|
||||||
errorFetching: false,
|
};
|
||||||
finishedFetching: true,
|
expect(cohorts(undefined, {
|
||||||
};
|
type: GOT_COHORTS,
|
||||||
expect(
|
cohorts: cohortsData,
|
||||||
cohorts(testingState, actions.fetching.received(newCohortData)),
|
})).toEqual(expected);
|
||||||
).toEqual(expected);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('handling actions.fetching.error', () => {
|
it('updates fetch cohorts failure state', () => {
|
||||||
it('sets finishedFetching=true and errorFetching=true', () => {
|
const expected = {
|
||||||
const expected = {
|
...initialState,
|
||||||
...testingState,
|
errorFetching: true,
|
||||||
errorFetching: true,
|
finishedFetching: true,
|
||||||
finishedFetching: true,
|
};
|
||||||
};
|
expect(cohorts(undefined, {
|
||||||
expect(
|
type: ERROR_FETCHING_COHORTS,
|
||||||
cohorts(testingState, actions.fetching.error()),
|
})).toEqual(expected);
|
||||||
).toEqual(expected);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user