Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9f01ea3a3 | ||
|
|
100fbc08bf | ||
|
|
500364dc99 | ||
|
|
609c0a8d3a | ||
|
|
5f81624342 | ||
|
|
647ecbab75 | ||
|
|
de539382bd | ||
|
|
cc01ab0a92 | ||
|
|
8881e62337 | ||
|
|
92e7cc39cd | ||
|
|
d6d09205f4 | ||
|
|
b2e4e330bf | ||
|
|
d10dc54116 | ||
|
|
15d7dcfe85 | ||
|
|
6717663c07 | ||
|
|
ac229ebc85 | ||
|
|
4c481721bc | ||
|
|
40f52b2dc9 | ||
|
|
a25e446998 | ||
|
|
326ae93ed7 | ||
|
|
95e9b51aca | ||
|
|
4d76329946 | ||
|
|
5c565bebb0 | ||
|
|
d1ca314565 | ||
|
|
677521808b | ||
|
|
139a0de6a6 | ||
|
|
42b4a5b3dd | ||
|
|
9b9c703214 | ||
|
|
d46f4da9d5 | ||
|
|
fa3826b452 | ||
|
|
fd3eb71820 | ||
|
|
3dff787b37 | ||
|
|
ac56ab766b | ||
|
|
dbe3dfa323 | ||
|
|
9e0c326dfc | ||
|
|
351bf48561 | ||
|
|
30c51668c4 | ||
|
|
9bc86fc4f6 | ||
|
|
aa39fcc7e0 | ||
|
|
f7fcaef03a | ||
|
|
6f9c051ded | ||
|
|
dabd923b10 | ||
|
|
da60ff9f1d | ||
|
|
f9d5987488 | ||
|
|
493d5df8fa | ||
|
|
cf4f806d76 | ||
|
|
ee274a2fb0 | ||
|
|
baa26f3b20 | ||
|
|
0f7ffcbec1 | ||
|
|
f5616f77fd | ||
|
|
ecf5ef4664 |
17
.babelrc
17
.babelrc
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"presets": [
|
||||
[
|
||||
"env",
|
||||
{
|
||||
"targets": {
|
||||
"browsers": ["last 2 versions", "ie 11"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"babel-preset-react"
|
||||
],
|
||||
"plugins": [
|
||||
"transform-object-rest-spread",
|
||||
"transform-class-properties"
|
||||
]
|
||||
}
|
||||
33
.env
Normal file
33
.env
Normal file
@@ -0,0 +1,33 @@
|
||||
NODE_ENV='production',
|
||||
NODE_PATH=./src
|
||||
BASE_URL=null,
|
||||
LMS_BASE_URL=null,
|
||||
LOGIN_URL=null,
|
||||
LOGOUT_URL=null,
|
||||
CSRF_TOKEN_API_PATH=null,
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT=null,
|
||||
DATA_API_BASE_URL=null,
|
||||
SEGMENT_KEY=null,
|
||||
FEATURE_FLAGS={},
|
||||
ACCESS_TOKEN_COOKIE_NAME=null,
|
||||
CSRF_COOKIE_NAME='csrftoken',
|
||||
NEW_RELIC_APP_ID=null,
|
||||
NEW_RELIC_LICENSE_KEY=null,
|
||||
SITE_NAME=null,
|
||||
MARKETING_SITE_BASE_URL=null,
|
||||
SUPPORT_URL=null,
|
||||
CONTACT_URL=null,
|
||||
OPEN_SOURCE_URL=null,
|
||||
TERMS_OF_SERVICE_URL=null,
|
||||
PRIVACY_POLICY_URL=null,
|
||||
FACEBOOK_URL=null,
|
||||
TWITTER_URL=null,
|
||||
YOU_TUBE_URL=null,
|
||||
LINKED_IN_URL=null,
|
||||
REDDIT_URL=null,
|
||||
APPLE_APP_STORE_URL=null,
|
||||
GOOGLE_PLAY_URL=null,
|
||||
ENTERPRISE_MARKETING_URL=null,
|
||||
ENTERPRISE_MARKETING_UTM_SOURCE=null,
|
||||
ENTERPRISE_MARKETING_UTM_CAMPAIGN=null,
|
||||
ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM=null,
|
||||
40
.env.development
Normal file
40
.env.development
Normal file
@@ -0,0 +1,40 @@
|
||||
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='edX'
|
||||
|
||||
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'
|
||||
27
.eslintrc
27
.eslintrc
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"extends": "eslint-config-edx",
|
||||
"parser": "babel-eslint",
|
||||
"rules": {
|
||||
"import/no-extraneous-dependencies": [
|
||||
"error",
|
||||
{
|
||||
"devDependencies": [
|
||||
"config/*.js",
|
||||
"**/*.test.jsx",
|
||||
"**/*.test.js"
|
||||
]
|
||||
}
|
||||
],
|
||||
// https://github.com/evcohen/eslint-plugin-jsx-a11y/issues/340#issuecomment-338424908
|
||||
"jsx-a11y/anchor-is-valid": [ "error", {
|
||||
"components": [ "Link" ],
|
||||
"specialLink": [ "to" ]
|
||||
}],
|
||||
// https://github.com/yannickcr/eslint-plugin-react/issues/1754#issuecomment-378838053
|
||||
// tl;dr: this rule is no longer going to cause any user-facing visual weirdness, its original motivation
|
||||
"react/no-did-mount-set-state": "off"
|
||||
},
|
||||
"env": {
|
||||
"jest": true
|
||||
}
|
||||
}
|
||||
3
.eslintrc.js
Normal file
3
.eslintrc.js
Normal file
@@ -0,0 +1,3 @@
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
|
||||
module.exports = createConfig('eslint');
|
||||
6
.github/CODEOWNERS
vendored
Normal file
6
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
# Code owners for frontend-app-gradebook, editable gradebook micro-frontend (MFE)
|
||||
|
||||
# These owners will be the default owners for everything in
|
||||
# the repo. Unless a later match takes precedence, they will
|
||||
# be requested for review when someone opens a pull request.
|
||||
* @edx/masters-devs-gta
|
||||
28
.github/pull_request_template.md
vendored
Normal file
28
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
**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
|
||||
- [ ] 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,6 +1,5 @@
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
.idea
|
||||
node_modules
|
||||
npm-debug.log
|
||||
coverage
|
||||
@@ -12,3 +11,9 @@ dist/
|
||||
|
||||
### Emacs ###
|
||||
*~
|
||||
*.swo
|
||||
*.swp
|
||||
|
||||
### Development environments ###
|
||||
.idea
|
||||
.vscode
|
||||
|
||||
11
.travis.yml
11
.travis.yml
@@ -1,21 +1,14 @@
|
||||
language: node_js
|
||||
node_js:
|
||||
- lts/*
|
||||
cache:
|
||||
directories:
|
||||
- "~/.npm"
|
||||
node_js: 12
|
||||
notifications:
|
||||
email:
|
||||
recipients:
|
||||
- adusenbery@edx.org
|
||||
- rreilly@edx.org
|
||||
- schen@edx.org
|
||||
- masters-grades@edx.org
|
||||
on_success: never
|
||||
on_failure: always
|
||||
webhooks: https://www.travisbuddy.com/
|
||||
on_success: never
|
||||
before_install:
|
||||
- npm install -g npm@latest
|
||||
- npm install -g greenkeeper-lockfile@1.14.0
|
||||
install:
|
||||
- npm ci
|
||||
|
||||
69
README.md
69
README.md
@@ -1,18 +1,60 @@
|
||||
[](https://travis-ci.org/edx/frontend-app-gradebook) [](https://coveralls.io/github/edx/frontend-app-gradebook)
|
||||
[](https://travis-ci.com/edx/frontend-app-gradebook) [](https://coveralls.io/github/edx/frontend-app-gradebook)
|
||||
[](@edx/frontend-app-gradebook)
|
||||
[](@edx/frontend-app-gradebook)
|
||||
[](@edx/frontend-app-gradebook)
|
||||
[](https://github.com/semantic-release/semantic-release)
|
||||
|
||||
# gradebook
|
||||
# Gradebook
|
||||
|
||||
Please tag **@edx/educator-neem** on any PRs or issues.
|
||||
Gradebook allows course staff to view, filter, and override subsection grades for a course. Additionally for Masters courses, Gradebook enables bulk management of subsection grades.
|
||||
|
||||
## Introduction
|
||||
Jump to:
|
||||
|
||||
The front-end of our editable Gradebook feature.
|
||||
- [Should I use Gradebook in my course?](#should-i-use-gradebook-in-my-course)
|
||||
- [Quickstart](#quickstart)
|
||||
|
||||
## Usage
|
||||
For existing documentation see:
|
||||
|
||||
- Basic Usage: [Review Learner Grades (read-the-docs)](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/student_progress/course_grades.html#review-learner-grades-on-the-instructor-dashboard)
|
||||
- Bulk Grade Management: [Override Learner Subsection Scores in Bulk (read-the-docs)](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/student_progress/course_grades.html#review-learner-grades-on-the-instructor-dashboard)
|
||||
|
||||
## 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:
|
||||
```
|
||||
@@ -32,7 +74,7 @@ Note that starting the container executes the `npm run start` script which will
|
||||
## Configuring for local use in edx-platform
|
||||
|
||||
Assuming you've got the UI running at `http://localhost:1994`, you can configure the LMS in edx-platform
|
||||
to point to your local gradebook from the instructor dashboard by putting this settings in `lms/env/private.py`:
|
||||
to point to your local gradebook from the instructor dashboard by putting this setting in `lms/env/private.py`:
|
||||
```
|
||||
WRITABLE_GRADEBOOK_URL = 'http://localhost:1994'
|
||||
```
|
||||
@@ -44,10 +86,15 @@ check the ``enabled`` and ``enabled for all courses`` boxes.
|
||||
|
||||
2. Waffle > Switches. Add the ``grades.assume_zero_grade_if_absent`` switch and make it active.
|
||||
|
||||
3. Waffle_utils > Waffle flag course overrides. You want to activate this flag for any course
|
||||
in which you'd like to enable the gradebook. Add a course override flag using a course id and the flag name
|
||||
``grades.writable_gradebook``. Make sure to check the ``enabled`` box. Alternatively, you could add this as a
|
||||
regular waffle flag to enable the gradebook for all courses.
|
||||
3. Waffle_utils > Waffle flag course overrides. Activate waffle flags for courses where you want to enable Gradebook functionality:
|
||||
- Enable Gradebook by adding the ``grades.writable_gradebook`` add checking the ``enabled`` box.
|
||||
- Enable Bulk Grade Management by adding the ``grades.bulk_management`` flag and checking the ``enabled`` box.
|
||||
|
||||
Alternatively, you could add these as regular waffle flags to enable the functionality for all courses.
|
||||
|
||||
**NOTE:** IF the above flags are not configured correctly, the gradebook may appear to work, but will return bogus
|
||||
numbers for grades. If your gradebook isn't accepting your changes, or the changes aren't resulting in sane,
|
||||
recalculated grade values, verify you've set all flags correctly.
|
||||
|
||||
## Running tests
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.0 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 KiB |
3
babel.config.js
Normal file
3
babel.config.js
Normal file
@@ -0,0 +1,3 @@
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
|
||||
module.exports = createConfig('babel');
|
||||
@@ -1,16 +0,0 @@
|
||||
// This is the common Webpack config. The dev and prod Webpack configs both
|
||||
// inherit config defined here.
|
||||
const path = require('path');
|
||||
|
||||
module.exports = {
|
||||
entry: {
|
||||
segment: path.resolve(__dirname, '../src/segment.js'),
|
||||
app: path.resolve(__dirname, '../src/index.jsx'),
|
||||
},
|
||||
output: {
|
||||
path: path.resolve(__dirname, '../dist'),
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.js', '.jsx'],
|
||||
},
|
||||
};
|
||||
@@ -1,147 +0,0 @@
|
||||
// This is the dev Webpack config. All settings here should prefer a fast build
|
||||
// time at the expense of creating larger, unoptimized bundles.
|
||||
const Merge = require('webpack-merge');
|
||||
const path = require('path');
|
||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||
const webpack = require('webpack');
|
||||
|
||||
const commonConfig = require('./webpack.common.config.js');
|
||||
|
||||
module.exports = Merge.smart(commonConfig, {
|
||||
mode: 'development',
|
||||
entry: [
|
||||
// enable react's custom hot dev client so we get errors reported in the browser
|
||||
require.resolve('react-dev-utils/webpackHotDevClient'),
|
||||
path.resolve(__dirname, '../src/segment.js'),
|
||||
path.resolve(__dirname, '../src/index.jsx'),
|
||||
],
|
||||
module: {
|
||||
// Specify file-by-file rules to Webpack. Some file-types need a particular kind of loader.
|
||||
rules: [
|
||||
// The babel-loader transforms newer ES2015+ syntax to older ES5 for older browsers.
|
||||
// Babel is configured with the .babelrc file at the root of the project.
|
||||
{
|
||||
test: /\.(js|jsx)$/,
|
||||
include: [
|
||||
path.resolve(__dirname, '../src'),
|
||||
],
|
||||
loader: 'babel-loader',
|
||||
options: {
|
||||
// Caches result of loader to the filesystem. Future builds will attempt to read from the
|
||||
// cache to avoid needing to run the expensive recompilation process on each run.
|
||||
cacheDirectory: true,
|
||||
},
|
||||
},
|
||||
// We are not extracting CSS from the javascript bundles in development because extracting
|
||||
// prevents hot-reloading from working, it increases build time, and we don't care about
|
||||
// flash-of-unstyled-content issues in development.
|
||||
{
|
||||
test: /(.scss|.css)$/,
|
||||
use: [
|
||||
'style-loader', // creates style nodes from JS strings
|
||||
{
|
||||
loader: 'css-loader', // translates CSS into CommonJS
|
||||
options: {
|
||||
sourceMap: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
loader: 'sass-loader', // compiles Sass to CSS
|
||||
options: {
|
||||
sourceMap: true,
|
||||
includePaths: [
|
||||
path.join(__dirname, '../node_modules'),
|
||||
path.join(__dirname, '../src'),
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
// Webpack, by default, uses the url-loader for images and fonts that are required/included by
|
||||
// files it processes, which just base64 encodes them and inlines them in the javascript
|
||||
// bundles. This makes the javascript bundles ginormous and defeats caching so we will use the
|
||||
// file-loader instead to copy the files directly to the output directory.
|
||||
{
|
||||
test: /\.(woff2?|ttf|svg|eot)(\?v=\d+\.\d+\.\d+)?$/,
|
||||
loader: 'file-loader',
|
||||
},
|
||||
{
|
||||
test: /\.(jpe?g|png|gif|ico)(\?v=\d+\.\d+\.\d+)?$/,
|
||||
use: [
|
||||
'file-loader',
|
||||
{
|
||||
loader: 'image-webpack-loader',
|
||||
options: {
|
||||
optimizationlevel: 7,
|
||||
mozjpeg: {
|
||||
progressive: true,
|
||||
},
|
||||
gifsicle: {
|
||||
interlaced: false,
|
||||
},
|
||||
pngquant: {
|
||||
quality: '65-90',
|
||||
speed: 4,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
// Specify additional processing or side-effects done on the Webpack output bundles as a whole.
|
||||
plugins: [
|
||||
// Generates an HTML file in the output directory.
|
||||
new HtmlWebpackPlugin({
|
||||
inject: true, // Appends script tags linking to the webpack bundles at the end of the body
|
||||
template: path.resolve(__dirname, '../public/index.html'),
|
||||
}),
|
||||
new webpack.EnvironmentPlugin({
|
||||
NODE_ENV: 'development',
|
||||
BASE_URL: 'localhost:1994',
|
||||
LMS_BASE_URL: 'http://localhost:18000',
|
||||
LOGIN_URL: 'http://localhost:18000/login',
|
||||
LOGOUT_URL: 'http://localhost:18000/login',
|
||||
CSRF_TOKEN_API_PATH: '/csrf/api/v1/token',
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT: 'http://localhost:18000/login_refresh',
|
||||
DATA_API_BASE_URL: 'http://localhost:8000',
|
||||
// LMS_CLIENT_ID should match the lms DOT client application id your LMS container
|
||||
LMS_CLIENT_ID: 'login-service-client-id',
|
||||
SEGMENT_KEY: null,
|
||||
FEATURE_FLAGS: {},
|
||||
ACCESS_TOKEN_COOKIE_NAME: 'edx-jwt-cookie-header-payload',
|
||||
CSRF_COOKIE_NAME: 'csrftoken',
|
||||
SITE_NAME: 'edX',
|
||||
MARKETING_SITE_BASE_URL: 'http://localhost:18000',
|
||||
SUPPORT_URL: 'http://localhost:18000/support',
|
||||
CONTACT_URL: 'http://localhost:18000/contact',
|
||||
OPEN_SOURCE_URL: 'http://localhost:18000/openedx',
|
||||
TERMS_OF_SERVICE_URL: 'http://localhost:18000/terms-of-service',
|
||||
PRIVACY_POLICY_URL: 'http://localhost:18000/privacy-policy',
|
||||
FACEBOOK_URL: 'https://www.facebook.com',
|
||||
TWITTER_URL: 'https://twitter.com',
|
||||
YOU_TUBE_URL: 'https://www.youtube.com',
|
||||
LINKED_IN_URL: 'https://www.linkedin.com',
|
||||
REDDIT_URL: 'https://www.reddit.com',
|
||||
APPLE_APP_STORE_URL: 'https://www.apple.com/ios/app-store/',
|
||||
GOOGLE_PLAY_URL: 'https://play.google.com/store',
|
||||
ENTERPRISE_MARKETING_URL: 'http://example.com',
|
||||
ENTERPRISE_MARKETING_UTM_SOURCE: 'example.com',
|
||||
ENTERPRISE_MARKETING_UTM_CAMPAIGN: 'example.com Referral',
|
||||
ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM: 'Footer',
|
||||
}),
|
||||
// when the --hot option is not passed in as part of the command
|
||||
// the HotModuleReplacementPlugin has to be specified in the Webpack configuration
|
||||
// https://webpack.js.org/configuration/dev-server/#devserver-hot
|
||||
new webpack.HotModuleReplacementPlugin(),
|
||||
],
|
||||
// This configures webpack-dev-server which serves bundles from memory and provides live
|
||||
// reloading.
|
||||
devServer: {
|
||||
host: '0.0.0.0',
|
||||
port: 1994,
|
||||
historyApiFallback: true,
|
||||
hot: true,
|
||||
inline: true,
|
||||
},
|
||||
});
|
||||
@@ -1,149 +0,0 @@
|
||||
// This is the prod Webpack config. All settings here should prefer smaller,
|
||||
// optimized bundles at the expense of a longer build time.
|
||||
const Merge = require('webpack-merge');
|
||||
const commonConfig = require('./webpack.common.config.js');
|
||||
const path = require('path');
|
||||
const webpack = require('webpack');
|
||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
||||
|
||||
module.exports = Merge.smart(commonConfig, {
|
||||
mode: 'production',
|
||||
devtool: 'source-map',
|
||||
output: {
|
||||
filename: '[name].[chunkhash].js',
|
||||
path: path.resolve(__dirname, '../dist'),
|
||||
},
|
||||
module: {
|
||||
// Specify file-by-file rules to Webpack. Some file-types need a particular kind of loader.
|
||||
rules: [
|
||||
// The babel-loader transforms newer ES2015+ syntax to older ES5 for older browsers.
|
||||
// Babel is configured with the .babelrc file at the root of the project.
|
||||
{
|
||||
test: /\.(js|jsx)$/,
|
||||
include: [
|
||||
path.resolve(__dirname, '../src'),
|
||||
],
|
||||
loader: 'babel-loader',
|
||||
},
|
||||
// Webpack, by default, includes all CSS in the javascript bundles. Unfortunately, that means:
|
||||
// a) The CSS won't be cached by browsers separately (a javascript change will force CSS
|
||||
// re-download). b) Since CSS is applied asyncronously, it causes an ugly
|
||||
// flash-of-unstyled-content.
|
||||
//
|
||||
// To avoid these problems, we extract the CSS from the bundles into separate CSS files that
|
||||
// can be included as <link> tags in the HTML <head> manually.
|
||||
//
|
||||
// We will not do this in development because it prevents hot-reloading from working and it
|
||||
// increases build time.
|
||||
{
|
||||
test: /(.scss|.css)$/,
|
||||
use: [
|
||||
MiniCssExtractPlugin.loader,
|
||||
{
|
||||
loader: 'css-loader', // translates CSS into CommonJS
|
||||
options: {
|
||||
sourceMap: true,
|
||||
minimize: true,
|
||||
},
|
||||
},
|
||||
'postcss-loader', // for autoprefixing, needs to be before the sass loader, not sure why
|
||||
{
|
||||
loader: 'sass-loader', // compiles Sass to CSS
|
||||
options: {
|
||||
sourceMap: true,
|
||||
includePaths: [
|
||||
path.join(__dirname, '../node_modules'),
|
||||
path.join(__dirname, '../src'),
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
// Webpack, by default, uses the url-loader for images and fonts that are required/included by
|
||||
// files it processes, which just base64 encodes them and inlines them in the javascript
|
||||
// bundles. This makes the javascript bundles ginormous and defeats caching so we will use the
|
||||
// file-loader instead to copy the files directly to the output directory.
|
||||
{
|
||||
test: /\.(woff2?|ttf|svg|eot)(\?v=\d+\.\d+\.\d+)?$/,
|
||||
loader: 'file-loader',
|
||||
},
|
||||
{
|
||||
test: /\.(jpe?g|png|gif|ico)(\?v=\d+\.\d+\.\d+)?$/,
|
||||
use: [
|
||||
'file-loader',
|
||||
{
|
||||
loader: 'image-webpack-loader',
|
||||
options: {
|
||||
optimizationlevel: 7,
|
||||
mozjpeg: {
|
||||
progressive: true,
|
||||
},
|
||||
gifsicle: {
|
||||
interlaced: false,
|
||||
},
|
||||
pngquant: {
|
||||
quality: '65-90',
|
||||
speed: 4,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
// New in Webpack 4. Replaces CommonChunksPlugin. Extract common modules among all chunks to one
|
||||
// common chunk and extract the Webpack runtime to a single runtime chunk.
|
||||
optimization: {
|
||||
runtimeChunk: 'single',
|
||||
splitChunks: {
|
||||
chunks: 'all',
|
||||
},
|
||||
},
|
||||
// Specify additional processing or side-effects done on the Webpack output bundles as a whole.
|
||||
plugins: [
|
||||
// Writes the extracted CSS from each entry to a file in the output directory.
|
||||
new MiniCssExtractPlugin({
|
||||
filename: '[name].[chunkhash].css',
|
||||
}),
|
||||
// Generates an HTML file in the output directory.
|
||||
new HtmlWebpackPlugin({
|
||||
inject: true, // Appends script tags linking to the webpack bundles at the end of the body
|
||||
template: path.resolve(__dirname, '../public/index.html'),
|
||||
}),
|
||||
new webpack.EnvironmentPlugin({
|
||||
NODE_ENV: 'production',
|
||||
BASE_URL: null,
|
||||
LMS_BASE_URL: null,
|
||||
LOGIN_URL: null,
|
||||
LOGOUT_URL: null,
|
||||
CSRF_TOKEN_API_PATH: null,
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT: null,
|
||||
DATA_API_BASE_URL: null,
|
||||
SEGMENT_KEY: null,
|
||||
FEATURE_FLAGS: {},
|
||||
ACCESS_TOKEN_COOKIE_NAME: null,
|
||||
CSRF_COOKIE_NAME: 'csrftoken',
|
||||
NEW_RELIC_APP_ID: null,
|
||||
NEW_RELIC_LICENSE_KEY: null,
|
||||
SITE_NAME: null,
|
||||
MARKETING_SITE_BASE_URL: null,
|
||||
SUPPORT_URL: null,
|
||||
CONTACT_URL: null,
|
||||
OPEN_SOURCE_URL: null,
|
||||
TERMS_OF_SERVICE_URL: null,
|
||||
PRIVACY_POLICY_URL: null,
|
||||
FACEBOOK_URL: null,
|
||||
TWITTER_URL: null,
|
||||
YOU_TUBE_URL: null,
|
||||
LINKED_IN_URL: null,
|
||||
REDDIT_URL: null,
|
||||
APPLE_APP_STORE_URL: null,
|
||||
GOOGLE_PLAY_URL: null,
|
||||
ENTERPRISE_MARKETING_URL: null,
|
||||
ENTERPRISE_MARKETING_UTM_SOURCE: null,
|
||||
ENTERPRISE_MARKETING_UTM_CAMPAIGN: null,
|
||||
ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM: null,
|
||||
}),
|
||||
],
|
||||
});
|
||||
BIN
documentation/screenshots/grade-editing.png
Normal file
BIN
documentation/screenshots/grade-editing.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 254 KiB |
BIN
documentation/screenshots/grade-filtering.png
Normal file
BIN
documentation/screenshots/grade-filtering.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 240 KiB |
BIN
documentation/screenshots/grade-listings.png
Normal file
BIN
documentation/screenshots/grade-listings.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 319 KiB |
12
openedx.yaml
12
openedx.yaml
@@ -2,8 +2,12 @@
|
||||
# http://open-edx-proposals.readthedocs.io/en/latest/oeps/oep-0002.html#specification
|
||||
|
||||
nick: grbk
|
||||
oeps: {}
|
||||
owner: schenedx
|
||||
supporting_teams:
|
||||
- masters-devs
|
||||
tags:
|
||||
- frontend-app
|
||||
- masters
|
||||
oeps:
|
||||
oep-2: true # Repository metadata
|
||||
openedx-release: {ref: master}
|
||||
owner:
|
||||
type: team
|
||||
team: edx/masters-devs-gta
|
||||
|
||||
31850
package-lock.json
generated
Executable file → Normal file
31850
package-lock.json
generated
Executable file → Normal file
File diff suppressed because it is too large
Load Diff
104
package.json
104
package.json
@@ -1,20 +1,21 @@
|
||||
{
|
||||
"name": "@edx/frontend-app-gradebook",
|
||||
"version": "0.1.0",
|
||||
"version": "1.4.18",
|
||||
"description": "edx editable gradebook-ui to manipulate grade overrides on subsections",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/edx/frontend-app-gradebook.git"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "NODE_ENV=production BABEL_ENV=production webpack --config=config/webpack.prod.config.js",
|
||||
"build": "fedx-scripts webpack",
|
||||
"coveralls": "cat ./coverage/lcov.info | coveralls",
|
||||
"is-es5": "es-check es5 ./dist/*.js",
|
||||
"lint": "eslint --ext .js --ext .jsx .",
|
||||
"precommit": "npm run lint",
|
||||
"lint": "fedx-scripts eslint --ext .jsx,.js src/",
|
||||
"lint-fix": "fedx-scripts eslint --fix --ext .jsx,.js src/",
|
||||
"prepush": "npm run lint",
|
||||
"semantic-release": "semantic-release",
|
||||
"start": "NODE_ENV=development BABEL_ENV=development node_modules/.bin/webpack-dev-server --config=config/webpack.dev.config.js --progress",
|
||||
"test": "jest --coverage --passWithNoTests",
|
||||
"start": "fedx-scripts webpack-dev-server --progress",
|
||||
"test": "TZ=GMT fedx-scripts jest --coverage --passWithNoTests",
|
||||
"watch-tests": "jest --watch",
|
||||
"travis-deploy-once": "travis-deploy-once"
|
||||
},
|
||||
@@ -25,96 +26,53 @@
|
||||
"access": "public"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edx/edx-bootstrap": "^0.4.3",
|
||||
"@edx/frontend-auth": "^4.0.0",
|
||||
"@edx/frontend-component-footer": "^4.1.5",
|
||||
"@edx/paragon": "^4.2.3",
|
||||
"@edx/brand": "npm:@edx/brand-edx.org@^1.3.2",
|
||||
"@edx/frontend-component-footer": "10.1.1",
|
||||
"@edx/frontend-platform": "1.8.1",
|
||||
"@edx/paragon": "12.4.1",
|
||||
"@fortawesome/fontawesome-svg-core": "^1.2.25",
|
||||
"@fortawesome/free-brands-svg-icons": "^5.11.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^5.11.2",
|
||||
"@fortawesome/react-fontawesome": "^0.1.5",
|
||||
"@redux-beacon/segment": "^1.0.0",
|
||||
"babel-polyfill": "^6.26.0",
|
||||
"classnames": "^2.2.6",
|
||||
"core-js": "3.6.5",
|
||||
"email-prop-type": "^1.1.7",
|
||||
"font-awesome": "^4.7.0",
|
||||
"history": "^4.10.1",
|
||||
"prop-types": "^15.7.2",
|
||||
"query-string": "^5.1.1",
|
||||
"react": "^16.10.1",
|
||||
"react-dom": "^16.10.1",
|
||||
"font-awesome": "4.7.0",
|
||||
"history": "4.10.1",
|
||||
"node-sass": "^4.14.1",
|
||||
"prop-types": "15.7.2",
|
||||
"query-string": "6.13.0",
|
||||
"react": "16.13.1",
|
||||
"react-dom": "16.13.1",
|
||||
"react-intl": "^2.9.0",
|
||||
"react-redux": "^5.1.1",
|
||||
"react-router": "^4.3.1",
|
||||
"react-router-dom": "^4.3.1",
|
||||
"react-router": "5.2.0",
|
||||
"react-router-dom": "5.2.0",
|
||||
"react-router-redux": "^5.0.0-alpha.9",
|
||||
"redux": "^3.7.2",
|
||||
"redux": "4.0.5",
|
||||
"redux-beacon": "^2.1.0",
|
||||
"redux-devtools-extension": "^2.13.8",
|
||||
"redux-logger": "^3.0.6",
|
||||
"redux-thunk": "^2.3.0",
|
||||
"redux-devtools-extension": "2.13.8",
|
||||
"redux-logger": "3.0.6",
|
||||
"redux-thunk": "2.3.0",
|
||||
"regenerator-runtime": "^0.13.7",
|
||||
"whatwg-fetch": "^2.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"autoprefixer": "^9.6.1",
|
||||
"@edx/frontend-build": "5.5.2",
|
||||
"axios": "0.21.1",
|
||||
"axios-mock-adapter": "^1.17.0",
|
||||
"babel-cli": "^6.26.0",
|
||||
"babel-eslint": "^8.2.6",
|
||||
"babel-jest": "^22.4.4",
|
||||
"babel-loader": "^7.1.5",
|
||||
"babel-plugin-transform-class-properties": "^6.24.1",
|
||||
"babel-plugin-transform-object-rest-spread": "^6.26.0",
|
||||
"babel-preset-env": "^1.7.0",
|
||||
"babel-preset-react": "^6.24.1",
|
||||
"codecov": "^3.6.1",
|
||||
"css-loader": "^0.28.11",
|
||||
"enzyme": "^3.10.0",
|
||||
"enzyme-adapter-react-16": "^1.14.0",
|
||||
"es-check": "^2.3.0",
|
||||
"eslint-config-edx": "^4.0.4",
|
||||
"fetch-mock": "^6.5.2",
|
||||
"file-loader": "^1.1.9",
|
||||
"html-webpack-harddisk-plugin": "^0.2.0",
|
||||
"html-webpack-plugin": "^3.2.0",
|
||||
"husky": "^0.14.3",
|
||||
"husky": "2.7.0",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"image-webpack-loader": "^4.2.0",
|
||||
"jest": "^22.4.4",
|
||||
"mini-css-extract-plugin": "^0.4.0",
|
||||
"node-sass": "^4.12.0",
|
||||
"postcss-loader": "^3.0.0",
|
||||
"react-dev-utils": "^5.0.3",
|
||||
"react-test-renderer": "^16.10.1",
|
||||
"redux-mock-store": "^1.5.3",
|
||||
"sass-loader": "^6.0.6",
|
||||
"semantic-release": "^15.13.24",
|
||||
"style-loader": "^0.20.3",
|
||||
"travis-deploy-once": "^5.0.11",
|
||||
"webpack": "^4.41.0",
|
||||
"webpack-cli": "^3.3.9",
|
||||
"webpack-dev-server": "^3.8.2",
|
||||
"webpack-merge": "^4.2.2"
|
||||
},
|
||||
"jest": {
|
||||
"setupFiles": [
|
||||
"./src/setupTest.js"
|
||||
],
|
||||
"moduleNameMapper": {
|
||||
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/__mocks__/fileMock.js",
|
||||
"\\.(css|scss)$": "identity-obj-proxy"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"src/**/*.{js,jsx}"
|
||||
],
|
||||
"coveragePathIgnorePatterns": [
|
||||
"/node_modules/",
|
||||
"src/setupTest.js",
|
||||
"src/index.js",
|
||||
"/tests/"
|
||||
],
|
||||
"transformIgnorePatterns": [
|
||||
"/node_modules/(?!(@edx/paragon)/).*/"
|
||||
],
|
||||
"testURL": "http://localhost"
|
||||
"semantic-release": "^17.2.3",
|
||||
"travis-deploy-once": "^5.0.11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<title>Gradebook | edX</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="shortcut icon" href="<%=htmlWebpackPlugin.options.FAVICON_URL%>" type="image/x-icon" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
12
src/App.scss
12
src/App.scss
@@ -1,15 +1,15 @@
|
||||
@import "~@edx/edx-bootstrap/sass/edx/theme";
|
||||
@import "~bootstrap/scss/bootstrap";
|
||||
// frontend-app-*/src/index.scss
|
||||
@import "~@edx/brand/paragon/fonts";
|
||||
@import "~@edx/brand/paragon/variables";
|
||||
@import "~@edx/paragon/scss/core/core";
|
||||
@import "~@edx/brand/paragon/overrides";
|
||||
|
||||
$fa-font-path: "~font-awesome/fonts";
|
||||
@import "~font-awesome/scss/font-awesome";
|
||||
|
||||
$input-focus-box-shadow: $input-box-shadow; // hack to get upgrade to paragon 4.0.0 to work
|
||||
@import "~@edx/paragon/src/SearchField/SearchField";
|
||||
@import "~@edx/paragon/src/Collapsible/Collapsible";
|
||||
|
||||
@import "~@edx/frontend-component-footer/src/lib/scss/site-footer";
|
||||
@import "~@edx/frontend-component-footer/dist/_footer";
|
||||
|
||||
@import "./components/Gradebook/gradebook";
|
||||
@import "./components/Drawer/Drawer";
|
||||
|
||||
|
||||
@@ -14,11 +14,6 @@ export default class Drawer extends React.Component {
|
||||
};
|
||||
}
|
||||
|
||||
deferToNextRepaint(callback) {
|
||||
window.requestAnimationFrame(() =>
|
||||
window.setTimeout(callback, 0));
|
||||
}
|
||||
|
||||
close = () => {
|
||||
if (this.state.open) {
|
||||
this.toggleOpen();
|
||||
@@ -39,6 +34,10 @@ export default class Drawer extends React.Component {
|
||||
}
|
||||
};
|
||||
|
||||
deferToNextRepaint(callback) {
|
||||
window.requestAnimationFrame(() => window.setTimeout(callback, 0));
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="d-flex drawer-container">
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import React from 'react';
|
||||
@@ -27,27 +26,43 @@ function RangeFilterBadge({
|
||||
filterValue2,
|
||||
handleBadgeClose,
|
||||
}) {
|
||||
return ((filterValue1 !== initialFilters[filterName1]) ||
|
||||
(filterValue2 !== initialFilters[filterName2]))
|
||||
&&
|
||||
return ((filterValue1 !== initialFilters[filterName1])
|
||||
|| (filterValue2 !== initialFilters[filterName2]))
|
||||
&& (
|
||||
<FilterBadge
|
||||
name={displayName}
|
||||
value={`${filterValue1} - ${filterValue2}`}
|
||||
onClick={handleBadgeClose}
|
||||
/>;
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
RangeFilterBadge.propTypes = {
|
||||
displayName: PropTypes.string.isRequired,
|
||||
filterName1: PropTypes.string.isRequired,
|
||||
filterValue1: PropTypes.string.isRequired,
|
||||
filterName2: PropTypes.string.isRequired,
|
||||
filterValue2: PropTypes.string.isRequired,
|
||||
handleBadgeClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
function SingleValueFilterBadge({
|
||||
displayName, filterName, filterValue, handleBadgeClose,
|
||||
}) {
|
||||
return (filterValue !== initialFilters[filterName]) &&
|
||||
return (filterValue !== initialFilters[filterName])
|
||||
&& (
|
||||
<FilterBadge
|
||||
name={displayName}
|
||||
value={filterValue}
|
||||
onClick={handleBadgeClose}
|
||||
/>;
|
||||
/>
|
||||
);
|
||||
}
|
||||
SingleValueFilterBadge.propTypes = {
|
||||
displayName: PropTypes.string.isRequired,
|
||||
filterName: PropTypes.string.isRequired,
|
||||
filterValue: PropTypes.string.isRequired,
|
||||
handleBadgeClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
function FilterBadges({
|
||||
assignment,
|
||||
@@ -150,4 +165,3 @@ FilterBadges.propTypes = {
|
||||
courseGradeMax: PropTypes.string,
|
||||
handleFilterBadgeClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
|
||||
199
src/components/Gradebook/Assignments.jsx
Normal file
199
src/components/Gradebook/Assignments.jsx
Normal file
@@ -0,0 +1,199 @@
|
||||
/* eslint-disable react/sort-comp, react/button-has-type */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Collapsible,
|
||||
InputSelect,
|
||||
InputText,
|
||||
} from '@edx/paragon';
|
||||
import { selectableAssignmentLabels } from '../../data/selectors/filters';
|
||||
import {
|
||||
filterAssignmentType,
|
||||
fetchGrades,
|
||||
updateGradesIfAssignmentGradeFiltersSet,
|
||||
} from '../../data/actions/grades';
|
||||
import {
|
||||
updateAssignmentFilter,
|
||||
updateAssignmentLimits,
|
||||
} from '../../data/actions/filters';
|
||||
|
||||
export class Assignments extends React.Component {
|
||||
getAssignmentFilterOptions = () => [
|
||||
{ label: 'All', value: '' },
|
||||
...this.props.assignmentFilterOptions.map(({ label, subsectionLabel }) => ({
|
||||
label: `${label}: ${subsectionLabel}`,
|
||||
value: label,
|
||||
})),
|
||||
];
|
||||
|
||||
handleAssignmentFilterChange = (assignment) => {
|
||||
const selectedFilterOption = this.props.assignmentFilterOptions.find(assig => assig.label === assignment);
|
||||
const { type, id } = selectedFilterOption || {};
|
||||
const typedValue = { label: assignment, type, id };
|
||||
this.props.updateAssignmentFilter(typedValue);
|
||||
this.updateQueryParams({ assignment: id });
|
||||
this.props.updateGradesIfAssignmentGradeFiltersSet(
|
||||
this.props.courseId,
|
||||
this.props.selectedCohort,
|
||||
this.props.selectedTrack,
|
||||
this.props.selectedAssignmentType,
|
||||
);
|
||||
};
|
||||
|
||||
handleSubmitAssignmentGrade = (event) => {
|
||||
event.preventDefault();
|
||||
const {
|
||||
assignmentGradeMin,
|
||||
assignmentGradeMax,
|
||||
} = this.props;
|
||||
|
||||
this.props.updateAssignmentLimits(assignmentGradeMin, assignmentGradeMax);
|
||||
this.props.getUserGrades(
|
||||
this.props.courseId,
|
||||
this.props.selectedCohort,
|
||||
this.props.selectedTrack,
|
||||
this.props.selectedAssignmentType,
|
||||
);
|
||||
this.props.updateQueryParams({ assignmentGradeMin, assignmentGradeMax });
|
||||
};
|
||||
|
||||
mapAssignmentTypeEntries = (entries) => {
|
||||
const mapped = [
|
||||
{ id: 0, label: 'All', value: '' },
|
||||
...entries.map(entry => ({ id: entry, label: entry })),
|
||||
];
|
||||
return mapped;
|
||||
};
|
||||
|
||||
updateAssignmentTypes = (assignmentType) => {
|
||||
this.props.filterAssignmentType(assignmentType);
|
||||
this.updateQueryParams({ assignmentType });
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Collapsible title="Assignments" defaultOpen className="filter-group mb-3">
|
||||
<div>
|
||||
<div className="student-filters">
|
||||
<InputSelect
|
||||
label="Assignment Types"
|
||||
name="assignment-types"
|
||||
aria-label="Assignment Types"
|
||||
value={this.props.selectedAssignmentType}
|
||||
options={this.mapAssignmentTypeEntries(this.props.assignmentTypes)}
|
||||
onChange={this.updateAssignmentTypes}
|
||||
disabled={this.props.assignmentFilterOptions.length === 0}
|
||||
/>
|
||||
</div>
|
||||
<div className="student-filters">
|
||||
<InputSelect
|
||||
label="Assignment"
|
||||
name="assignment"
|
||||
aria-label="Assignment"
|
||||
value={this.props.selectedAssignment}
|
||||
options={this.getAssignmentFilterOptions()}
|
||||
onChange={this.handleAssignmentFilterChange}
|
||||
disabled={this.props.assignmentFilterOptions.length === 0}
|
||||
/>
|
||||
</div>
|
||||
<form className="grade-filter-inputs" onSubmit={this.handleSubmitAssignmentGrade}>
|
||||
<div className="percent-group">
|
||||
<InputText
|
||||
label="Min Grade"
|
||||
name="assignmentGradeMin"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={this.props.assignmentGradeMin}
|
||||
disabled={!this.props.selectedAssignment}
|
||||
onChange={this.props.setAssignmentGradeMin}
|
||||
/>
|
||||
<span className="input-percent-label">%</span>
|
||||
</div>
|
||||
<div className="percent-group">
|
||||
<InputText
|
||||
label="Max Grade"
|
||||
name="assignmentGradeMax"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={this.props.assignmentGradeMax}
|
||||
disabled={!this.props.selectedAssignment}
|
||||
onChange={this.props.setAssignmentGradeMax}
|
||||
/>
|
||||
<span className="input-percent-label">%</span>
|
||||
</div>
|
||||
<div className="grade-filter-action">
|
||||
<Button
|
||||
type="submit"
|
||||
className="btn-outline-secondary"
|
||||
name="assignmentGradeMinMax"
|
||||
disabled={!this.props.selectedAssignment}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Assignments.defaultProps = {
|
||||
assignmentTypes: [],
|
||||
assignmentFilterOptions: [],
|
||||
selectedAssignment: '',
|
||||
selectedAssignmentType: '',
|
||||
selectedCohort: null,
|
||||
selectedTrack: null,
|
||||
};
|
||||
|
||||
Assignments.propTypes = {
|
||||
assignmentGradeMin: PropTypes.string.isRequired,
|
||||
assignmentGradeMax: PropTypes.string.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
setAssignmentGradeMin: PropTypes.func.isRequired,
|
||||
setAssignmentGradeMax: PropTypes.func.isRequired,
|
||||
updateQueryParams: PropTypes.func.isRequired,
|
||||
|
||||
// redux
|
||||
assignmentTypes: PropTypes.arrayOf(PropTypes.string),
|
||||
assignmentFilterOptions: PropTypes.arrayOf(PropTypes.shape({
|
||||
label: PropTypes.string,
|
||||
subsectionLabel: PropTypes.string,
|
||||
})),
|
||||
filterAssignmentType: PropTypes.func.isRequired,
|
||||
getUserGrades: PropTypes.func.isRequired,
|
||||
selectedAssignmentType: PropTypes.string,
|
||||
selectedAssignment: PropTypes.string,
|
||||
selectedCohort: PropTypes.string,
|
||||
selectedTrack: PropTypes.string,
|
||||
updateGradesIfAssignmentGradeFiltersSet: PropTypes.func.isRequired,
|
||||
updateAssignmentFilter: PropTypes.func.isRequired,
|
||||
updateAssignmentLimits: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => ({
|
||||
assignmentTypes: state.assignmentTypes.results,
|
||||
assignmentFilterOptions: selectableAssignmentLabels(state),
|
||||
selectedAssignment: (state.filters.assignment || {}).label,
|
||||
selectedAssignmentTypes: state.filters.assignmentType,
|
||||
selectedCohort: state.filters.cohort,
|
||||
selectedTrack: state.filters.track,
|
||||
});
|
||||
|
||||
export const mapDispatchToProps = {
|
||||
getUserGrades: fetchGrades,
|
||||
filterAssignmentType,
|
||||
updateAssignmentFilter,
|
||||
updateAssignmentLimits,
|
||||
updateGradesIfAssignmentGradeFiltersSet,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(Assignments);
|
||||
199
src/components/Gradebook/BulkManagement.jsx
Normal file
199
src/components/Gradebook/BulkManagement.jsx
Normal file
@@ -0,0 +1,199 @@
|
||||
/* eslint-disable react/sort-comp, react/button-has-type */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import {
|
||||
Button,
|
||||
StatusAlert,
|
||||
Table,
|
||||
} from '@edx/paragon';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faDownload } from '@fortawesome/free-solid-svg-icons';
|
||||
import { configuration } from '../../config';
|
||||
|
||||
import { submitFileUploadFormData } from '../../data/actions/grades';
|
||||
import { getBulkManagementHistory } from '../../data/selectors/grades';
|
||||
|
||||
export class BulkManagement extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.fileFormRef = React.createRef();
|
||||
this.fileInputRef = React.createRef();
|
||||
}
|
||||
|
||||
formatHistoryRow = (row) => {
|
||||
const {
|
||||
summaryOfRowsProcessed: {
|
||||
total,
|
||||
successfullyProcessed,
|
||||
failed,
|
||||
skipped,
|
||||
},
|
||||
unique_id: courseId,
|
||||
originalFilename,
|
||||
id,
|
||||
user: username,
|
||||
...rest
|
||||
} = row;
|
||||
const resultsText = [
|
||||
`${total} Students: ${successfullyProcessed} processed`,
|
||||
...(skipped > 0 ? [`${skipped} skipped`] : []),
|
||||
...(failed > 0 ? [`${failed} failed`] : []),
|
||||
].join(', ');
|
||||
const resultsSummary = (
|
||||
<a
|
||||
href={`${configuration.LMS_BASE_URL}/api/bulk_grades/course/${courseId}/?error_id=${id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<FontAwesomeIcon icon={faDownload} />
|
||||
{resultsText}
|
||||
</a>
|
||||
);
|
||||
const createWrappedCell = (text) => (<span className="wrap-text-in-cell">{text}</span>);
|
||||
const filename = createWrappedCell(originalFilename);
|
||||
const user = createWrappedCell(username);
|
||||
return {
|
||||
resultsSummary,
|
||||
filename,
|
||||
user,
|
||||
...rest,
|
||||
};
|
||||
};
|
||||
|
||||
handleClickImportGrades = () => {
|
||||
const fileInput = this.fileInputRef.current;
|
||||
if (fileInput) {
|
||||
fileInput.click();
|
||||
}
|
||||
};
|
||||
|
||||
handleFileInputChange = (event) => {
|
||||
const fileInput = event.target;
|
||||
const file = fileInput.files[0];
|
||||
const form = this.fileFormRef.current;
|
||||
if (file && form) {
|
||||
const formData = new FormData(form);
|
||||
this.props.submitFileUploadFormData(this.props.courseId, formData).then(() => {
|
||||
fileInput.value = null;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<h4>Use this feature by downloading a CSV for bulk management,
|
||||
overriding grades locally, and coming back here to upload.
|
||||
</h4>
|
||||
<form ref={this.fileFormRef} action={this.props.gradeExportUrl} method="post">
|
||||
<StatusAlert
|
||||
alertType="danger"
|
||||
dialog={this.props.bulkImportError}
|
||||
isOpen={this.props.bulkImportError}
|
||||
dismissible={false}
|
||||
/>
|
||||
<StatusAlert
|
||||
alertType="success"
|
||||
dialog="CSV processing. File uploads may take several minutes to complete"
|
||||
open={this.props.uploadSuccess}
|
||||
dismissible={false}
|
||||
/>
|
||||
<input
|
||||
className="d-none"
|
||||
type="file"
|
||||
name="csv"
|
||||
label="Upload Grade CSV"
|
||||
onChange={this.handleFileInputChange}
|
||||
ref={this.fileInputRef}
|
||||
/>
|
||||
</form>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={this.handleClickImportGrades}
|
||||
>
|
||||
Import Grades
|
||||
</Button>
|
||||
<p>
|
||||
Results appear in the table below.<br />
|
||||
Grade processing may take a few seconds.
|
||||
</p>
|
||||
<Table
|
||||
data={this.props.bulkManagementHistory.map(this.formatHistoryRow)}
|
||||
hasFixedColumnWidths
|
||||
columns={[
|
||||
{
|
||||
key: 'filename',
|
||||
label: 'Gradebook',
|
||||
columnSortable: false,
|
||||
width: 'col-5',
|
||||
},
|
||||
{
|
||||
key: 'resultsSummary',
|
||||
label: 'Download Summary',
|
||||
columnSortable: false,
|
||||
width: 'col',
|
||||
},
|
||||
{
|
||||
key: 'user',
|
||||
label: 'Who',
|
||||
columnSortable: false,
|
||||
width: 'col-1',
|
||||
},
|
||||
{
|
||||
key: 'timeUploaded',
|
||||
label: 'When',
|
||||
columnSortable: false,
|
||||
width: 'col',
|
||||
},
|
||||
]}
|
||||
className="table-striped"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
BulkManagement.defaultProps = {
|
||||
bulkImportError: '',
|
||||
bulkManagementHistory: [],
|
||||
courseId: '',
|
||||
uploadSuccess: false,
|
||||
};
|
||||
|
||||
BulkManagement.propTypes = {
|
||||
courseId: PropTypes.string,
|
||||
gradeExportUrl: PropTypes.string.isRequired,
|
||||
|
||||
// redux
|
||||
bulkImportError: PropTypes.string,
|
||||
bulkManagementHistory: PropTypes.arrayOf(PropTypes.shape({
|
||||
originalFilename: PropTypes.string.isRequired,
|
||||
user: PropTypes.string.isRequired,
|
||||
timeUploaded: PropTypes.string.isRequired,
|
||||
summaryOfRowsProcessed: PropTypes.shape({
|
||||
total: PropTypes.number.isRequired,
|
||||
successfullyProcessed: PropTypes.number.isRequired,
|
||||
failed: PropTypes.number.isRequired,
|
||||
skipped: PropTypes.number.isRequired,
|
||||
}).isRequired,
|
||||
})),
|
||||
submitFileUploadFormData: PropTypes.func.isRequired,
|
||||
uploadSuccess: PropTypes.bool,
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => ({
|
||||
bulkImportError: state.grades.bulkManagement
|
||||
&& state.grades.bulkManagement.errorMessages
|
||||
? `Errors while processing: ${state.grades.bulkManagement.errorMessages.join(', ')}`
|
||||
: '',
|
||||
bulkManagementHistory: getBulkManagementHistory(state),
|
||||
uploadSuccess: !!(state.grades.bulkManagement && state.grades.bulkManagement.uploadSuccess),
|
||||
});
|
||||
|
||||
export const mapDispatchToProps = {
|
||||
submitFileUploadFormData,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(BulkManagement);
|
||||
90
src/components/Gradebook/BulkManagementControls.jsx
Normal file
90
src/components/Gradebook/BulkManagementControls.jsx
Normal file
@@ -0,0 +1,90 @@
|
||||
/* eslint-disable react/sort-comp, react/button-has-type */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { StatefulButton } from '@edx/paragon';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faDownload, faSpinner } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
import {
|
||||
downloadBulkGradesReport,
|
||||
downloadInterventionReport,
|
||||
} from '../../data/actions/grades';
|
||||
|
||||
export class BulkManagementControls extends React.Component {
|
||||
handleClickDownloadInterventions = () => {
|
||||
this.props.downloadInterventionReport(this.props.courseId);
|
||||
window.location = this.props.interventionExportUrl;
|
||||
};
|
||||
|
||||
// At present, we don't store label and value in google analytics. By setting the label
|
||||
// property of the below events, I want to verify that we can set the label of google anlatyics
|
||||
// The following properties of a google analytics event are:
|
||||
// category (used), name(used), lavel(not used), value(not used)
|
||||
handleClickExportGrades = () => {
|
||||
this.props.downloadBulkGradesReport(this.props.courseId);
|
||||
window.location = this.props.gradeExportUrl;
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<StatefulButton
|
||||
variant="outline-primary"
|
||||
onClick={this.handleClickExportGrades}
|
||||
state={this.props.showSpinner ? 'pending' : 'default'}
|
||||
labels={{
|
||||
default: 'Bulk Management',
|
||||
pending: 'Bulk Management',
|
||||
}}
|
||||
icons={{
|
||||
default: <FontAwesomeIcon className="mr-2" icon={faDownload} />,
|
||||
pending: <FontAwesomeIcon className="fa-spin mr-2" icon={faSpinner} />,
|
||||
}}
|
||||
disabledStates={['pending']}
|
||||
/>
|
||||
<StatefulButton
|
||||
variant="outline-primary"
|
||||
onClick={this.handleClickDownloadInterventions}
|
||||
state={this.props.showSpinner ? 'pending' : 'default'}
|
||||
className="ml-2"
|
||||
labels={{
|
||||
default: 'Interventions*',
|
||||
pending: 'Interventions*',
|
||||
}}
|
||||
icons={{
|
||||
default: <FontAwesomeIcon className="mr-2" icon={faDownload} />,
|
||||
pending: <FontAwesomeIcon className="fa-spin mr-2" icon={faSpinner} />,
|
||||
}}
|
||||
disabledStates={['pending']}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
BulkManagementControls.defaultProps = {
|
||||
courseId: '',
|
||||
showSpinner: false,
|
||||
};
|
||||
|
||||
BulkManagementControls.propTypes = {
|
||||
courseId: PropTypes.string,
|
||||
gradeExportUrl: PropTypes.string.isRequired,
|
||||
interventionExportUrl: PropTypes.string.isRequired,
|
||||
showSpinner: PropTypes.bool,
|
||||
|
||||
// redux
|
||||
downloadBulkGradesReport: PropTypes.func.isRequired,
|
||||
downloadInterventionReport: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export const mapStateToProps = () => ({ });
|
||||
|
||||
export const mapDispatchToProps = {
|
||||
downloadBulkGradesReport,
|
||||
downloadInterventionReport,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(BulkManagementControls);
|
||||
203
src/components/Gradebook/EditModal.jsx
Normal file
203
src/components/Gradebook/EditModal.jsx
Normal file
@@ -0,0 +1,203 @@
|
||||
/* eslint-disable react/sort-comp, react/button-has-type */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
StatusAlert,
|
||||
Table,
|
||||
} from '@edx/paragon';
|
||||
|
||||
import {
|
||||
doneViewingAssignment,
|
||||
updateGrades,
|
||||
} from '../../data/actions/grades';
|
||||
|
||||
const GRADE_OVERRIDE_HISTORY_COLUMNS = [{ label: 'Date', key: 'date' }, { label: 'Grader', key: 'grader' },
|
||||
{ label: 'Reason', key: 'reason' },
|
||||
{ label: 'Adjusted grade', key: 'adjustedGrade' }];
|
||||
|
||||
export class EditModal extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.overrideReasonInput = React.createRef();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.overrideReasonInput.current.focus();
|
||||
}
|
||||
|
||||
handleAdjustedGradeClick = () => {
|
||||
this.props.updateGrades(
|
||||
this.props.courseId, [
|
||||
{
|
||||
user_id: this.props.updateUserId,
|
||||
usage_id: this.props.updateModuleId,
|
||||
grade: {
|
||||
earned_graded_override: this.props.adjustedGradeValue,
|
||||
comment: this.props.reasonForChange,
|
||||
},
|
||||
},
|
||||
],
|
||||
this.props.filterValue,
|
||||
this.props.selectedCohort,
|
||||
this.props.selectedTrack,
|
||||
);
|
||||
|
||||
this.closeAssignmentModal();
|
||||
}
|
||||
|
||||
closeAssignmentModal = () => {
|
||||
this.props.doneViewingAssignment();
|
||||
this.props.setGradebookState({
|
||||
adjustedGradePossible: '',
|
||||
adjustedGradeValue: '',
|
||||
modalOpen: false,
|
||||
reasonForChange: '',
|
||||
updateModuleId: null,
|
||||
updateUserId: null,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Modal
|
||||
open={this.props.open}
|
||||
title="Edit Grades"
|
||||
closeText="Cancel"
|
||||
body={(
|
||||
<div>
|
||||
<div>
|
||||
<div className="grade-history-header grade-history-assignment">Assignment: </div>
|
||||
<div>{this.props.assignmentName}</div>
|
||||
<div className="grade-history-header grade-history-student">Student: </div>
|
||||
<div>{this.props.updateUserName}</div>
|
||||
<div className="grade-history-header grade-history-original-grade">Original Grade: </div>
|
||||
<div>{this.props.gradeOriginalEarnedGraded}</div>
|
||||
<div className="grade-history-header grade-history-current-grade">Current Grade: </div>
|
||||
<div>{this.props.gradeOverrideCurrentEarnedGradedOverride}</div>
|
||||
</div>
|
||||
<StatusAlert
|
||||
alertType="danger"
|
||||
dialog={this.props.gradeOverrideHistoryError}
|
||||
open={!!this.props.gradeOverrideHistoryError}
|
||||
dismissible={false}
|
||||
/>
|
||||
{!this.props.gradeOverrideHistoryError && (
|
||||
<Table
|
||||
columns={GRADE_OVERRIDE_HISTORY_COLUMNS}
|
||||
data={[...this.props.gradeOverrides, {
|
||||
date: this.props.todaysDate,
|
||||
reason: (<input
|
||||
type="text"
|
||||
name="reasonForChange"
|
||||
value={this.props.reasonForChange}
|
||||
onChange={this.props.setReasonForChange}
|
||||
ref={this.overrideReasonInput}
|
||||
/>),
|
||||
adjustedGrade: (
|
||||
<span>
|
||||
<input
|
||||
type="text"
|
||||
name="adjustedGradeValue"
|
||||
value={this.props.adjustedGradeValue}
|
||||
onChange={this.props.setAdjustedGradeValue}
|
||||
/>
|
||||
{(this.props.adjustedGradePossible || this.props.gradeOriginalPossibleGraded) && ' / '}
|
||||
{this.props.adjustedGradePossible || this.props.gradeOriginalPossibleGraded}
|
||||
</span>),
|
||||
}]}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div>Showing most recent actions (max 5). To see more, please contact
|
||||
support.
|
||||
</div>
|
||||
<div>Note: Once you save, your changes will be visible to students.</div>
|
||||
</div>
|
||||
)}
|
||||
buttons={[
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={this.handleAdjustedGradeClick}
|
||||
>
|
||||
Save Grade
|
||||
</Button>,
|
||||
]}
|
||||
onClose={this.closeAssignmentModal}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
EditModal.defaultProps = {
|
||||
adjustedGradeValue: null,
|
||||
courseId: '',
|
||||
gradeOverrideCurrentEarnedGradedOverride: null,
|
||||
gradeOverrideHistoryError: '',
|
||||
gradeOverrides: [],
|
||||
gradeOriginalEarnedGraded: null,
|
||||
gradeOriginalPossibleGraded: null,
|
||||
selectedCohort: null,
|
||||
selectedTrack: null,
|
||||
updateModuleId: '',
|
||||
updateUserId: '',
|
||||
updateUserName: '',
|
||||
};
|
||||
|
||||
EditModal.propTypes = {
|
||||
courseId: PropTypes.string,
|
||||
|
||||
// Gradebook State
|
||||
adjustedGradePossible: PropTypes.string.isRequired,
|
||||
// should pick one?
|
||||
adjustedGradeValue: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
|
||||
assignmentName: PropTypes.string.isRequired,
|
||||
filterValue: PropTypes.string.isRequired,
|
||||
open: PropTypes.bool.isRequired,
|
||||
reasonForChange: PropTypes.string.isRequired,
|
||||
todaysDate: PropTypes.string.isRequired,
|
||||
updateModuleId: PropTypes.string,
|
||||
updateUserId: PropTypes.number,
|
||||
updateUserName: PropTypes.string,
|
||||
|
||||
// Gradebook State Setters
|
||||
setAdjustedGradeValue: PropTypes.func.isRequired,
|
||||
setGradebookState: PropTypes.func.isRequired,
|
||||
setReasonForChange: PropTypes.func.isRequired,
|
||||
|
||||
// redux
|
||||
doneViewingAssignment: PropTypes.func.isRequired,
|
||||
gradeOverrideCurrentEarnedGradedOverride: PropTypes.number,
|
||||
gradeOverrideHistoryError: PropTypes.string,
|
||||
gradeOverrides: PropTypes.arrayOf(PropTypes.shape({
|
||||
date: PropTypes.string,
|
||||
grader: PropTypes.string,
|
||||
reason: PropTypes.string,
|
||||
adjustedGrade: PropTypes.number,
|
||||
})),
|
||||
gradeOriginalEarnedGraded: PropTypes.number,
|
||||
gradeOriginalPossibleGraded: PropTypes.number,
|
||||
selectedCohort: PropTypes.string,
|
||||
selectedTrack: PropTypes.string,
|
||||
updateGrades: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => ({
|
||||
gradeOverrides: state.grades.gradeOverrideHistoryResults,
|
||||
gradeOverrideCurrentEarnedGradedOverride: state.grades.gradeOverrideCurrentEarnedGradedOverride,
|
||||
gradeOverrideHistoryError: state.grades.gradeOverrideHistoryError,
|
||||
gradeOriginalEarnedGraded: state.grades.gradeOriginalEarnedGraded,
|
||||
grdaeOriginalPossibleGraded: state.grades.grdaeOriginalPossibleGraded,
|
||||
selectedCohort: state.filters.cohort,
|
||||
selectedTrack: state.filters.track,
|
||||
});
|
||||
|
||||
export const mapDispatchToProps = {
|
||||
doneViewingAssignment,
|
||||
updateGrades,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(EditModal);
|
||||
203
src/components/Gradebook/GradebookTable.jsx
Normal file
203
src/components/Gradebook/GradebookTable.jsx
Normal file
@@ -0,0 +1,203 @@
|
||||
/* eslint-disable react/sort-comp, react/button-has-type */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { Table } from '@edx/paragon';
|
||||
|
||||
import { formatDateForDisplay } from '../../data/actions/utils';
|
||||
import { getHeadings } from '../../data/selectors/grades';
|
||||
import { fetchGradeOverrideHistory } from '../../data/actions/grades';
|
||||
|
||||
const DECIMAL_PRECISION = 2;
|
||||
|
||||
export class GradebookTable extends React.Component {
|
||||
setNewModalState = (userEntry, subsection) => {
|
||||
this.props.fetchGradeOverrideHistory(
|
||||
subsection.module_id,
|
||||
userEntry.user_id,
|
||||
);
|
||||
|
||||
let adjustedGradePossible = '';
|
||||
if (subsection.attempted) {
|
||||
adjustedGradePossible = subsection.score_possible;
|
||||
}
|
||||
|
||||
this.props.setGradebookState({
|
||||
adjustedGradePossible,
|
||||
adjustedGradeValue: '',
|
||||
assignmentName: `${subsection.subsection_name}`,
|
||||
modalOpen: true,
|
||||
reasonForChange: '',
|
||||
todaysDate: formatDateForDisplay(new Date()),
|
||||
updateModuleId: subsection.module_id,
|
||||
updateUserId: userEntry.user_id,
|
||||
updateUserName: userEntry.username,
|
||||
});
|
||||
}
|
||||
|
||||
getLearnerInformation = entry => (
|
||||
<div>
|
||||
<div>{entry.username}</div>
|
||||
{entry.external_user_key && <div className="student-key">{entry.external_user_key}</div>}
|
||||
</div>
|
||||
)
|
||||
|
||||
roundGrade = percent => parseFloat((percent || 0).toFixed(DECIMAL_PRECISION));
|
||||
|
||||
formatter = {
|
||||
percent: (entries, areGradesFrozen) => entries.map((entry) => {
|
||||
const learnerInformation = this.getLearnerInformation(entry);
|
||||
const results = {
|
||||
Username: (
|
||||
<div><span className="wrap-text-in-cell">{learnerInformation}</span></div>
|
||||
),
|
||||
Email: (
|
||||
<span className="wrap-text-in-cell">{entry.email}</span>
|
||||
),
|
||||
};
|
||||
|
||||
const assignments = entry.section_breakdown
|
||||
.reduce((acc, subsection) => {
|
||||
if (areGradesFrozen) {
|
||||
acc[subsection.label] = `${this.roundGrade(subsection.percent * 100)} %`;
|
||||
} else {
|
||||
acc[subsection.label] = (
|
||||
<button
|
||||
className="btn btn-header link-style grade-button"
|
||||
onClick={() => this.setNewModalState(entry, subsection)}
|
||||
>
|
||||
{this.roundGrade(subsection.percent * 100)}%
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
const totals = { Total: `${this.roundGrade(entry.percent * 100)}%` };
|
||||
return Object.assign(results, assignments, totals);
|
||||
}),
|
||||
|
||||
absolute: (entries, areGradesFrozen) => entries.map((entry) => {
|
||||
const learnerInformation = this.getLearnerInformation(entry);
|
||||
const results = {
|
||||
Username: (
|
||||
<div><span className="wrap-text-in-cell">{learnerInformation}</span></div>
|
||||
),
|
||||
Email: (
|
||||
<span className="wrap-text-in-cell">{entry.email}</span>
|
||||
),
|
||||
};
|
||||
|
||||
const assignments = entry.section_breakdown
|
||||
.reduce((acc, subsection) => {
|
||||
const scoreEarned = this.roundGrade(subsection.score_earned);
|
||||
const scorePossible = this.roundGrade(subsection.score_possible);
|
||||
let label = `${scoreEarned}`;
|
||||
if (subsection.attempted) {
|
||||
label = `${scoreEarned}/${scorePossible}`;
|
||||
}
|
||||
if (areGradesFrozen) {
|
||||
acc[subsection.label] = label;
|
||||
} else {
|
||||
acc[subsection.label] = (
|
||||
<button
|
||||
className="btn btn-header link-style"
|
||||
onClick={() => this.setNewModalState(entry, subsection)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const totals = { Total: `${this.roundGrade(entry.percent * 100)}/100` };
|
||||
return Object.assign(results, assignments, totals);
|
||||
}),
|
||||
};
|
||||
|
||||
formatHeadings = () => {
|
||||
let headings = [...this.props.headings];
|
||||
|
||||
if (headings.length > 0) {
|
||||
const userInformationHeadingLabel = (
|
||||
<div>
|
||||
<div>Username</div>
|
||||
<div className="font-weight-normal student-key">Student Key*</div>
|
||||
</div>
|
||||
);
|
||||
const emailHeadingLabel = 'Email*';
|
||||
|
||||
headings = headings.map(heading => ({
|
||||
label: heading,
|
||||
key: heading,
|
||||
}));
|
||||
|
||||
// replace username heading label to include additional user data
|
||||
headings[0].label = userInformationHeadingLabel;
|
||||
headings[1].label = emailHeadingLabel;
|
||||
}
|
||||
|
||||
return headings;
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="gradebook-container">
|
||||
<div className="gbook">
|
||||
<Table
|
||||
columns={this.formatHeadings()}
|
||||
data={this.formatter[this.props.format](
|
||||
this.props.grades,
|
||||
this.props.areGradesFrozen,
|
||||
)}
|
||||
rowHeaderColumnKey="username"
|
||||
hasFixedColumnWidths
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
GradebookTable.defaultProps = {
|
||||
areGradesFrozen: false,
|
||||
grades: [],
|
||||
};
|
||||
|
||||
GradebookTable.propTypes = {
|
||||
setGradebookState: PropTypes.func.isRequired,
|
||||
// redux
|
||||
areGradesFrozen: PropTypes.bool,
|
||||
format: PropTypes.string.isRequired,
|
||||
grades: PropTypes.arrayOf(PropTypes.shape({
|
||||
percent: PropTypes.number,
|
||||
section_breakdown: PropTypes.arrayOf(PropTypes.shape({
|
||||
attempted: PropTypes.bool,
|
||||
category: PropTypes.string,
|
||||
label: PropTypes.string,
|
||||
module_id: PropTypes.string,
|
||||
percent: PropTypes.number,
|
||||
scoreEarned: PropTypes.number,
|
||||
scorePossible: PropTypes.number,
|
||||
subsection_name: PropTypes.string,
|
||||
})),
|
||||
user_id: PropTypes.number,
|
||||
user_name: PropTypes.string,
|
||||
})),
|
||||
headings: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
fetchGradeOverrideHistory: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => ({
|
||||
areGradesFrozen: state.assignmentTypes.areGradesFrozen,
|
||||
format: state.grades.gradeFormat,
|
||||
grades: state.grades.results,
|
||||
headings: getHeadings(state),
|
||||
});
|
||||
|
||||
export const mapDispatchToProps = {
|
||||
fetchGradeOverrideHistory,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(GradebookTable);
|
||||
@@ -31,13 +31,9 @@
|
||||
}
|
||||
|
||||
.student-filters{
|
||||
display: flex;
|
||||
.label{
|
||||
padding-top: 30px;
|
||||
}
|
||||
.form-group{
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
.grade-history-header{
|
||||
float: left;
|
||||
@@ -57,9 +53,17 @@
|
||||
.grade-history-current-grade{
|
||||
padding-right: 25px;
|
||||
}
|
||||
.gradebook-container {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
height: 600px;
|
||||
overflow-y: auto;
|
||||
word-break: break-word;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.gbook {
|
||||
overflow-x: scroll;
|
||||
width: 100%;
|
||||
|
||||
.grade-button {
|
||||
text-decoration: underline;
|
||||
@@ -68,19 +72,25 @@
|
||||
.student-key {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
|
||||
.table {
|
||||
padding-left: 244px;
|
||||
// prevents the table from shrinking to a width where "Final 01" breaks to two lines
|
||||
min-width: 731px;
|
||||
th {
|
||||
vertical-align: top;
|
||||
font-size: 14px;
|
||||
.table thead tr {
|
||||
min-height: 60px;
|
||||
&:nth-child(1) {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background-color: white;
|
||||
th {
|
||||
background-color: white;
|
||||
border-bottom: 1px solid $gray_200;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.table thead tr {
|
||||
height: 60px;
|
||||
thead, tbody, tr, td, th {
|
||||
display: block;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.table tr th:first-child {
|
||||
@@ -89,16 +99,47 @@
|
||||
.table tr th:first-child,
|
||||
.table tr td:first-child {
|
||||
position: sticky;
|
||||
width: 160px;
|
||||
left: 0;
|
||||
z-index: 1; // to float over the following children in the side-scrolling case
|
||||
background: white;
|
||||
}
|
||||
|
||||
.table tr {
|
||||
th:nth-child(1),
|
||||
td:nth-child(1),
|
||||
th:nth-child(2),
|
||||
td:nth-child(2) {
|
||||
width: 240px;
|
||||
}
|
||||
th, td {
|
||||
width: 120px;
|
||||
}
|
||||
}
|
||||
.table tbody th {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.table {
|
||||
overflow-x: hidden;
|
||||
|
||||
height: 100%;
|
||||
|
||||
tbody {
|
||||
overflow-y: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
thead, tbody tr {
|
||||
display: table;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
th {
|
||||
vertical-align: top;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.link-style {
|
||||
color: #0075b4;
|
||||
&:hover, &:focus {
|
||||
@@ -106,10 +147,33 @@
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.input-percent-label {
|
||||
margin-top: 10px;
|
||||
.form-group {
|
||||
label {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
.grade-filter-inputs {
|
||||
.percent-group {
|
||||
display: inline-block;
|
||||
.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 {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable react/sort-comp, react/button-has-type, import/no-named-as-default */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
@@ -6,48 +7,46 @@ import {
|
||||
Icon,
|
||||
InputSelect,
|
||||
InputText,
|
||||
Modal,
|
||||
SearchField,
|
||||
StatefulButton,
|
||||
StatusAlert,
|
||||
Table,
|
||||
Tab,
|
||||
Tabs,
|
||||
} from '@edx/paragon';
|
||||
import queryString from 'query-string';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faDownload, faSpinner, faFilter } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faFilter } from '@fortawesome/free-solid-svg-icons';
|
||||
import { configuration } from '../../config';
|
||||
import PageButtons from '../PageButtons';
|
||||
import Drawer from '../Drawer';
|
||||
import { formatDateForDisplay } from '../../data/actions/utils';
|
||||
import initialFilters from '../../data/constants/filters';
|
||||
import ConnectedFilterBadges from '../FilterBadges';
|
||||
|
||||
|
||||
const DECIMAL_PRECISION = 2;
|
||||
const GRADE_OVERRIDE_HISTORY_COLUMNS = [{ label: 'Date', key: 'date' }, { label: 'Grader', key: 'grader' },
|
||||
{ label: 'Reason', key: 'reason' },
|
||||
{ label: 'Adjusted grade', key: 'adjustedGrade' }];
|
||||
import Assignments from './Assignments';
|
||||
import BulkManagement from './BulkManagement';
|
||||
import BulkManagementControls from './BulkManagementControls';
|
||||
import EditModal from './EditModal';
|
||||
import GradebookTable from './GradebookTable';
|
||||
|
||||
export default class Gradebook extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
filterValue: '',
|
||||
courseGradeMin: '0',
|
||||
courseGradeMax: '100',
|
||||
modalOpen: false,
|
||||
adjustedGradePossible: '',
|
||||
adjustedGradeValue: 0,
|
||||
updateModuleId: null,
|
||||
updateUserId: null,
|
||||
reasonForChange: '',
|
||||
assignmentGradeMin: '0',
|
||||
assignmentGradeMax: '100',
|
||||
assignmentName: '',
|
||||
courseGradeMin: '0',
|
||||
courseGradeMax: '100',
|
||||
filterValue: '',
|
||||
isMinCourseGradeFilterValid: true,
|
||||
isMaxCourseGradeFilterValid: true,
|
||||
modalOpen: false,
|
||||
reasonForChange: '',
|
||||
todaysDate: '',
|
||||
updateModuleId: null,
|
||||
updateUserId: null,
|
||||
};
|
||||
this.fileFormRef = React.createRef();
|
||||
this.fileInputRef = React.createRef();
|
||||
this.myRef = React.createRef();
|
||||
}
|
||||
|
||||
@@ -55,7 +54,6 @@ export default class Gradebook extends React.Component {
|
||||
const urlQuery = queryString.parse(this.props.location.search);
|
||||
this.props.initializeFilters(urlQuery);
|
||||
this.props.getRoles(this.props.courseId);
|
||||
this.overrideReasonInput.focus();
|
||||
|
||||
const newStateFields = {};
|
||||
['assignmentGradeMin', 'assignmentGradeMax', 'courseGradeMin', 'courseGradeMax'].forEach((attr) => {
|
||||
@@ -71,37 +69,6 @@ export default class Gradebook extends React.Component {
|
||||
this.setState({ [e.target.name]: e.target.value });
|
||||
}
|
||||
|
||||
setNewModalState = (userEntry, subsection) => {
|
||||
this.props.fetchGradeOverrideHistory(
|
||||
subsection.module_id,
|
||||
userEntry.user_id,
|
||||
);
|
||||
|
||||
let adjustedGradePossible = '';
|
||||
if (subsection.attempted) {
|
||||
adjustedGradePossible = subsection.score_possible;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
modalAssignmentName: `${subsection.subsection_name}`,
|
||||
modalOpen: true,
|
||||
updateModuleId: subsection.module_id,
|
||||
updateUserId: userEntry.user_id,
|
||||
updateUserName: userEntry.username,
|
||||
todaysDate: formatDateForDisplay(new Date()),
|
||||
adjustedGradePossible,
|
||||
reasonForChange: '',
|
||||
adjustedGradeValue: '',
|
||||
});
|
||||
}
|
||||
|
||||
getLearnerInformation = entry => (
|
||||
<div>
|
||||
<div>{entry.username}</div>
|
||||
{entry.external_user_key && <div className="student-key">{entry.external_user_key}</div>}
|
||||
</div>
|
||||
)
|
||||
|
||||
getActiveTabs = () => {
|
||||
if (this.props.showBulkManagement) {
|
||||
return ['Grades', 'Bulk Management'];
|
||||
@@ -109,17 +76,6 @@ export default class Gradebook extends React.Component {
|
||||
return ['Grades'];
|
||||
};
|
||||
|
||||
getAssignmentFilterOptions = () => [
|
||||
{ label: 'All', value: '' },
|
||||
...this.props.assignmentFilterOptions.map((assignment) => {
|
||||
const { label, subsectionLabel } = assignment;
|
||||
return {
|
||||
label: `${label}: ${subsectionLabel}`,
|
||||
value: label,
|
||||
};
|
||||
}),
|
||||
];
|
||||
|
||||
getCourseGradeFilterAlertDialog = () => {
|
||||
let dialog = '';
|
||||
|
||||
@@ -132,53 +88,6 @@ export default class Gradebook extends React.Component {
|
||||
return dialog;
|
||||
};
|
||||
|
||||
handleAdjustedGradeClick = () => {
|
||||
this.props.updateGrades(
|
||||
this.props.courseId, [
|
||||
{
|
||||
user_id: this.state.updateUserId,
|
||||
usage_id: this.state.updateModuleId,
|
||||
grade: {
|
||||
earned_graded_override: this.state.adjustedGradeValue,
|
||||
comment: this.state.reasonForChange,
|
||||
},
|
||||
},
|
||||
],
|
||||
this.state.filterValue,
|
||||
this.props.selectedCohort,
|
||||
this.props.selectedTrack,
|
||||
);
|
||||
|
||||
this.closeAssignmentModal();
|
||||
}
|
||||
|
||||
closeAssignmentModal = () => {
|
||||
this.props.doneViewingAssignment();
|
||||
this.setState({
|
||||
adjustedGradePossible: '',
|
||||
adjustedGradeValue: '',
|
||||
modalOpen: false,
|
||||
reasonForChange: '',
|
||||
updateModuleId: null,
|
||||
updateUserId: null,
|
||||
});
|
||||
};
|
||||
|
||||
handleAssignmentFilterChange = (assignment) => {
|
||||
const selectedFilterOption = this.props.assignmentFilterOptions.find(assig =>
|
||||
assig.label === assignment);
|
||||
const { type, id } = selectedFilterOption || {};
|
||||
const typedValue = { label: assignment, type, id };
|
||||
this.props.updateAssignmentFilter(typedValue);
|
||||
this.updateQueryParams({ assignment: id });
|
||||
this.props.updateGradesIfAssignmentGradeFiltersSet(
|
||||
this.props.courseId,
|
||||
this.props.selectedCohort,
|
||||
this.props.selectedTrack,
|
||||
this.props.selectedAssignmentType,
|
||||
);
|
||||
};
|
||||
|
||||
updateQueryParams = (queryParams) => {
|
||||
const parsed = queryString.parse(this.props.location.search);
|
||||
Object.keys(queryParams).forEach((key) => {
|
||||
@@ -191,15 +100,6 @@ export default class Gradebook extends React.Component {
|
||||
this.props.history.push(`?${queryString.stringify(parsed)}`);
|
||||
};
|
||||
|
||||
mapAssignmentTypeEntries = (entries) => {
|
||||
const mapped = entries.map(entry => ({
|
||||
id: entry,
|
||||
label: entry,
|
||||
}));
|
||||
mapped.unshift({ id: 0, label: 'All', value: '' });
|
||||
return mapped;
|
||||
};
|
||||
|
||||
mapCohortsEntries = (entries) => {
|
||||
const mapped = entries.map(entry => ({
|
||||
id: entry.id,
|
||||
@@ -218,58 +118,6 @@ export default class Gradebook extends React.Component {
|
||||
return mapped;
|
||||
};
|
||||
|
||||
formatHistoryRow = (row) => {
|
||||
const {
|
||||
summaryOfRowsProcessed: {
|
||||
total,
|
||||
successfullyProcessed,
|
||||
failed,
|
||||
skipped,
|
||||
},
|
||||
unique_id: courseId,
|
||||
originalFilename,
|
||||
id,
|
||||
user: username,
|
||||
...rest
|
||||
} = row;
|
||||
const resultsText = [
|
||||
`${total} Students: ${successfullyProcessed} processed`,
|
||||
...(skipped > 0 ? [`${skipped} skipped`] : []),
|
||||
...(failed > 0 ? [`${failed} failed`] : []),
|
||||
].join(', ');
|
||||
const resultsSummary = (
|
||||
<a
|
||||
href={`${configuration.LMS_BASE_URL}/api/bulk_grades/course/${courseId}/?error_id=${id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<FontAwesomeIcon icon={faDownload} />
|
||||
{resultsText}
|
||||
</a>
|
||||
);
|
||||
const filename = (
|
||||
<span className="wrap-text-in-cell">
|
||||
{originalFilename}
|
||||
</span>
|
||||
);
|
||||
const user = (
|
||||
<span className="wrap-text-in-cell">
|
||||
{username}
|
||||
</span>
|
||||
);
|
||||
return {
|
||||
resultsSummary,
|
||||
filename,
|
||||
user,
|
||||
...rest,
|
||||
};
|
||||
};
|
||||
|
||||
updateAssignmentTypes = (assignmentType) => {
|
||||
this.props.filterAssignmentType(assignmentType);
|
||||
this.updateQueryParams({ assignmentType });
|
||||
}
|
||||
|
||||
updateTracks = (event) => {
|
||||
const selectedTrackItem = this.props.tracks.find(x => x.name === event);
|
||||
let selectedTrackSlug = null;
|
||||
@@ -300,60 +148,6 @@ export default class Gradebook extends React.Component {
|
||||
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) {
|
||||
@@ -370,104 +164,8 @@ export default class Gradebook extends React.Component {
|
||||
return 'Tracks';
|
||||
};
|
||||
|
||||
roundGrade = percent => parseFloat((percent || 0).toFixed(DECIMAL_PRECISION));
|
||||
|
||||
formatter = {
|
||||
percent: (entries, areGradesFrozen) => entries.map((entry) => {
|
||||
const learnerInformation = this.getLearnerInformation(entry);
|
||||
const results = {
|
||||
Username: (
|
||||
<div><span className="wrap-text-in-cell">{learnerInformation}</span></div>
|
||||
),
|
||||
Email: (
|
||||
<span className="wrap-text-in-cell">{entry.email}</span>
|
||||
),
|
||||
};
|
||||
|
||||
const assignments = entry.section_breakdown
|
||||
.reduce((acc, subsection) => {
|
||||
if (areGradesFrozen) {
|
||||
acc[subsection.label] = `${this.roundGrade(subsection.percent * 100)} %`;
|
||||
} else {
|
||||
acc[subsection.label] = (
|
||||
<button
|
||||
className="btn btn-header link-style grade-button"
|
||||
onClick={() => this.setNewModalState(entry, subsection)}
|
||||
>
|
||||
{this.roundGrade(subsection.percent * 100)}%
|
||||
</button>);
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
const totals = { Total: `${this.roundGrade(entry.percent * 100)}%` };
|
||||
return Object.assign(results, assignments, totals);
|
||||
}),
|
||||
|
||||
absolute: (entries, areGradesFrozen) => entries.map((entry) => {
|
||||
const learnerInformation = this.getLearnerInformation(entry);
|
||||
const results = {
|
||||
Username: (
|
||||
<div><span className="wrap-text-in-cell">{learnerInformation}</span></div>
|
||||
),
|
||||
Email: (
|
||||
<span className="wrap-text-in-cell">{entry.email}</span>
|
||||
),
|
||||
};
|
||||
|
||||
const assignments = entry.section_breakdown
|
||||
.reduce((acc, subsection) => {
|
||||
const scoreEarned = this.roundGrade(subsection.score_earned);
|
||||
const scorePossible = this.roundGrade(subsection.score_possible);
|
||||
let label = `${scoreEarned}`;
|
||||
if (subsection.attempted) {
|
||||
label = `${scoreEarned}/${scorePossible}`;
|
||||
}
|
||||
if (areGradesFrozen) {
|
||||
acc[subsection.label] = label;
|
||||
} else {
|
||||
acc[subsection.label] = (
|
||||
<button
|
||||
className="btn btn-header link-style"
|
||||
onClick={() => this.setNewModalState(entry, subsection)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const totals = { Total: `${this.roundGrade(entry.percent * 100)}/100` };
|
||||
return Object.assign(results, assignments, totals);
|
||||
}),
|
||||
};
|
||||
|
||||
lmsInstructorDashboardUrl = courseId => `${configuration.LMS_BASE_URL}/courses/${courseId}/instructor`;
|
||||
|
||||
formatHeadings = () => {
|
||||
let headings = [...this.props.headings];
|
||||
|
||||
if (headings.length > 0) {
|
||||
const userInformationHeadingLabel = (
|
||||
<div>
|
||||
<div>Username</div>
|
||||
<div className="font-weight-normal student-key">Student Key*</div>
|
||||
</div>
|
||||
);
|
||||
const emailHeadingLabel = 'Email*';
|
||||
|
||||
headings = headings.map(heading => ({ label: heading, key: heading, width: 'col' }));
|
||||
|
||||
// replace username heading label to include additional user data
|
||||
headings[0].label = userInformationHeadingLabel;
|
||||
headings[0].width = 'col-2';
|
||||
headings[1].label = emailHeadingLabel;
|
||||
headings[1].width = 'col-2';
|
||||
}
|
||||
|
||||
return headings;
|
||||
}
|
||||
|
||||
handleCourseGradeFilterChange = (type, value) => {
|
||||
const filterValue = value;
|
||||
|
||||
@@ -540,6 +238,29 @@ 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',
|
||||
'assignmnentName',
|
||||
'modalOpen',
|
||||
'reasonForChange',
|
||||
'todaysDate',
|
||||
'updateModuleId',
|
||||
'updateUserId',
|
||||
'updateUserName',
|
||||
);
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Drawer
|
||||
@@ -549,47 +270,45 @@ export default class Gradebook extends React.Component {
|
||||
href={this.lmsInstructorDashboardUrl(this.props.courseId)}
|
||||
className="mb-3"
|
||||
>
|
||||
<span aria-hidden="true">{'<< '}</span> {'Back to Dashboard'}
|
||||
<span aria-hidden="true">{'<< '}</span> Back to Dashboard
|
||||
</a>
|
||||
<h1>Gradebook</h1>
|
||||
<h3> {this.props.courseId}</h3>
|
||||
{this.props.areGradesFrozen &&
|
||||
<div className="alert alert-warning" role="alert" >
|
||||
{this.props.areGradesFrozen
|
||||
&& (
|
||||
<div className="alert alert-warning" role="alert">
|
||||
The grades for this course are now frozen. Editing of grades is no longer allowed.
|
||||
</div>
|
||||
}
|
||||
{(this.props.canUserViewGradebook === false) &&
|
||||
<div className="alert alert-warning" role="alert" >
|
||||
)}
|
||||
{(this.props.canUserViewGradebook === false)
|
||||
&& (
|
||||
<div className="alert alert-warning" role="alert">
|
||||
You are not authorized to view the gradebook for this course.
|
||||
</div>
|
||||
}
|
||||
<Tabs labels={this.getActiveTabs()}>
|
||||
<div>
|
||||
)}
|
||||
<Tabs defaultActiveKey="grades">
|
||||
<Tab eventKey="grades" title="Grades">
|
||||
<h4>Step 1: Filter the Grade Report</h4>
|
||||
<div className="d-flex justify-content-between" >
|
||||
<div className="d-flex justify-content-between">
|
||||
{this.props.showSpinner && <div className="spinner-overlay"><Icon className="fa fa-spinner fa-spin fa-5x color-black" /></div>}
|
||||
<Button className="btn-primary align-self-start" onClick={toggleFilterDrawer}><FontAwesomeIcon icon={faFilter} /> Edit Filters</Button>
|
||||
<div>
|
||||
<SearchField
|
||||
onSubmit={value =>
|
||||
this.props.searchForUser(
|
||||
this.props.courseId,
|
||||
value,
|
||||
this.props.selectedCohort,
|
||||
this.props.selectedTrack,
|
||||
this.props.selectedAssignmentType,
|
||||
)
|
||||
}
|
||||
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,
|
||||
)
|
||||
}
|
||||
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>
|
||||
@@ -609,21 +328,22 @@ export default class Gradebook extends React.Component {
|
||||
dialog={this.getCourseGradeFilterAlertDialog()}
|
||||
dismissible={false}
|
||||
open={
|
||||
!this.state.isMinCourseGradeFilterValid ||
|
||||
!this.state.isMaxCourseGradeFilterValid
|
||||
!this.state.isMinCourseGradeFilterValid
|
||||
|| !this.state.isMaxCourseGradeFilterValid
|
||||
}
|
||||
/>
|
||||
<h4>Step 2: View or Modify Individual Grades</h4>
|
||||
{this.props.totalUsersCount ?
|
||||
<div>
|
||||
Showing
|
||||
<span className="font-weight-bold"> {this.props.filteredUsersCount} </span>
|
||||
of
|
||||
<span className="font-weight-bold"> {this.props.totalUsersCount} </span>
|
||||
total learners
|
||||
</div> :
|
||||
null
|
||||
}
|
||||
{this.props.totalUsersCount
|
||||
? (
|
||||
<div>
|
||||
Showing
|
||||
<span className="font-weight-bold"> {this.props.filteredUsersCount} </span>
|
||||
of
|
||||
<span className="font-weight-bold"> {this.props.totalUsersCount} </span>
|
||||
total learners
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
<div className="d-flex justify-content-between align-items-center mb-2">
|
||||
<InputSelect
|
||||
label="Score View:"
|
||||
@@ -633,294 +353,102 @@ export default class Gradebook extends React.Component {
|
||||
onChange={this.props.toggleFormat}
|
||||
/>
|
||||
{this.props.showBulkManagement && (
|
||||
<div>
|
||||
<StatefulButton
|
||||
buttonType="outline-primary"
|
||||
onClick={this.handleClickExportGrades}
|
||||
state={this.props.showSpinner ? 'pending' : 'default'}
|
||||
labels={{
|
||||
default: 'Bulk Management',
|
||||
pending: 'Bulk Management',
|
||||
}}
|
||||
icons={{
|
||||
default: <FontAwesomeIcon className="mr-2" icon={faDownload} />,
|
||||
pending: <FontAwesomeIcon className="fa-spin mr-2" icon={faSpinner} />,
|
||||
}}
|
||||
disabledStates={['pending']}
|
||||
/>
|
||||
<StatefulButton
|
||||
buttonType="outline-primary"
|
||||
onClick={this.handleClickDownloadInterventions}
|
||||
state={this.props.showSpinner ? 'pending' : 'default'}
|
||||
className="ml-2"
|
||||
labels={{
|
||||
default: 'Interventions*',
|
||||
pending: 'Interventions*',
|
||||
}}
|
||||
icons={{
|
||||
default: <FontAwesomeIcon className="mr-2" icon={faDownload} />,
|
||||
pending: <FontAwesomeIcon className="fa-spin mr-2" icon={faSpinner} />,
|
||||
}}
|
||||
disabledStates={['pending']}
|
||||
/>
|
||||
</div>
|
||||
<BulkManagementControls
|
||||
courseId={this.props.courseId}
|
||||
gradeExportUrl={this.props.gradeExportUrl}
|
||||
interventionExportUrl={this.props.interventionExportUrl}
|
||||
showSpinner={this.props.showSpinner}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="gradebook-container">
|
||||
<div className="gbook">
|
||||
<Table
|
||||
columns={this.formatHeadings()}
|
||||
data={this.formatter[this.props.format](
|
||||
this.props.grades,
|
||||
this.props.areGradesFrozen,
|
||||
)}
|
||||
rowHeaderColumnKey="username"
|
||||
hasFixedColumnWidths
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<GradebookTable setGradebookState={this.safeSetState} />
|
||||
{PageButtons(this.props)}
|
||||
<p>* available for learners in the Master's track only</p>
|
||||
<Modal
|
||||
<EditModal
|
||||
assignmentName={this.state.assignmentName}
|
||||
adjustedGradeValue={this.state.adjustedGradeValue}
|
||||
adjustedGradePossible={this.state.adjustedGradePossible}
|
||||
courseId={this.props.courseId}
|
||||
filterValue={this.state.filterValue}
|
||||
onChange={this.onChange}
|
||||
open={this.state.modalOpen}
|
||||
title="Edit Grades"
|
||||
closeText="Cancel"
|
||||
body={(
|
||||
<div>
|
||||
<div>
|
||||
<div className="grade-history-header grade-history-assignment">Assignment: </div> <div>{this.state.modalAssignmentName}</div>
|
||||
<div className="grade-history-header grade-history-student">Student: </div> <div>{this.state.updateUserName}</div>
|
||||
<div className="grade-history-header grade-history-original-grade">Original Grade: </div> <div>{this.props.gradeOriginalEarnedGraded}</div>
|
||||
<div className="grade-history-header grade-history-current-grade">Current Grade: </div> <div>{this.props.gradeOverrideCurrentEarnedGradedOverride}</div>
|
||||
</div>
|
||||
<StatusAlert
|
||||
alertType="danger"
|
||||
dialog="Error retrieving grade override history."
|
||||
open={this.props.errorFetchingGradeOverrideHistory}
|
||||
dismissible={false}
|
||||
/>
|
||||
{!this.props.errorFetchingGradeOverrideHistory && (
|
||||
<Table
|
||||
columns={GRADE_OVERRIDE_HISTORY_COLUMNS}
|
||||
data={[...this.props.gradeOverrides, {
|
||||
date: this.state.todaysDate,
|
||||
reason: (<input
|
||||
type="text"
|
||||
name="reasonForChange"
|
||||
value={this.state.reasonForChange}
|
||||
onChange={value => this.onChange(value)}
|
||||
ref={(input) => { this.overrideReasonInput = input; }}
|
||||
/>),
|
||||
adjustedGrade: (
|
||||
<span>
|
||||
<input
|
||||
type="text"
|
||||
name="adjustedGradeValue"
|
||||
value={this.state.adjustedGradeValue}
|
||||
onChange={value => this.onChange(value)}
|
||||
/>
|
||||
{(this.state.adjustedGradePossible
|
||||
|| this.props.gradeOriginalPossibleGraded)
|
||||
&& ' / '}
|
||||
{this.state.adjustedGradePossible
|
||||
|| this.props.gradeOriginalPossibleGraded}
|
||||
</span>),
|
||||
}]}
|
||||
/>)}
|
||||
|
||||
<div>Showing most recent actions (max 5). To see more, please contact
|
||||
support.
|
||||
</div>
|
||||
<div>Note: Once you save, your changes will be visible to students.</div>
|
||||
</div>
|
||||
)}
|
||||
buttons={[
|
||||
<Button
|
||||
buttonType="primary"
|
||||
onClick={this.handleAdjustedGradeClick}
|
||||
>
|
||||
Save Grade
|
||||
</Button>,
|
||||
]}
|
||||
onClose={this.closeAssignmentModal}
|
||||
reasonForChange={this.state.reasonForChange}
|
||||
setAdjustedGradeValue={this.createStateFieldOnChange('adjustedGradeValue')}
|
||||
setGradebookState={this.safeSetState}
|
||||
setReasonForChange={this.createStateFieldOnChange('reasonForChange')}
|
||||
todaysDate={this.state.todaysDate}
|
||||
updateModuleId={this.state.updateModuleId}
|
||||
updateUserId={this.state.updateUserId}
|
||||
updateUserName={this.state.updateUserName}
|
||||
/>
|
||||
</div>
|
||||
{this.props.showBulkManagement && (
|
||||
<div>
|
||||
<h4>Use this feature by downloading a CSV for bulk management,
|
||||
overriding grades locally, and coming back here to upload.
|
||||
</h4>
|
||||
<form ref={this.fileFormRef} action={this.props.gradeExportUrl} method="post">
|
||||
<StatusAlert
|
||||
alertType="danger"
|
||||
dialog={this.props.bulkImportError}
|
||||
open={this.props.bulkImportError}
|
||||
dismissible={false}
|
||||
/>
|
||||
<StatusAlert
|
||||
alertType="success"
|
||||
dialog="CSV processing. File uploads may take several minutes to complete"
|
||||
open={this.props.uploadSuccess}
|
||||
dismissible={false}
|
||||
/>
|
||||
<input
|
||||
className="d-none"
|
||||
type="file"
|
||||
name="csv"
|
||||
label="Upload Grade CSV"
|
||||
onChange={this.handleFileInputChange}
|
||||
ref={this.fileInputRef}
|
||||
/>
|
||||
</form>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
onClick={this.handleClickImportGrades}
|
||||
>
|
||||
Import Grades
|
||||
</Button>
|
||||
<p>
|
||||
Results appear in the table below.<br />
|
||||
Grade processing may take a few seconds.
|
||||
</p>
|
||||
<Table
|
||||
data={this.props.bulkManagementHistory.map(this.formatHistoryRow)}
|
||||
hasFixedColumnWidths
|
||||
columns={[
|
||||
{
|
||||
key: 'filename',
|
||||
label: 'Gradebook',
|
||||
columnSortable: false,
|
||||
width: 'col-5',
|
||||
},
|
||||
{
|
||||
key: 'resultsSummary',
|
||||
label: 'Download Summary',
|
||||
columnSortable: false,
|
||||
width: 'col',
|
||||
},
|
||||
{
|
||||
key: 'user',
|
||||
label: 'Who',
|
||||
columnSortable: false,
|
||||
width: 'col-1',
|
||||
},
|
||||
{
|
||||
key: 'timeUploaded',
|
||||
label: 'When',
|
||||
columnSortable: false,
|
||||
width: 'col',
|
||||
},
|
||||
]}
|
||||
className="table-striped"
|
||||
|
||||
</Tab>
|
||||
{this.props.showBulkManagement
|
||||
&& (
|
||||
<Tab eventKey="bulk_management" title="Bulk Management">
|
||||
<BulkManagement
|
||||
courseId={this.props.courseId}
|
||||
gradeExportUrl={this.props.gradeExportUrl}
|
||||
/>
|
||||
</div>)}
|
||||
</Tab>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
initiallyOpen={false}
|
||||
title={
|
||||
<React.Fragment>
|
||||
title={(
|
||||
<>
|
||||
<FontAwesomeIcon icon={faFilter} /> Filter By...
|
||||
</React.Fragment>
|
||||
}
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<Collapsible title="Assignments" isOpen className="filter-group">
|
||||
<div>
|
||||
<div className="student-filters">
|
||||
<span className="label">
|
||||
Assignment Types:
|
||||
</span>
|
||||
<InputSelect
|
||||
name="assignment-types"
|
||||
aria-label="Assignment Types"
|
||||
value={this.props.selectedAssignmentType}
|
||||
options={this.mapAssignmentTypeEntries(this.props.assignmentTypes)}
|
||||
onChange={this.updateAssignmentTypes}
|
||||
disabled={this.props.assignmentFilterOptions.length === 0}
|
||||
/>
|
||||
</div>
|
||||
<div className="student-filters">
|
||||
<span className="label">
|
||||
Assignment:
|
||||
</span>
|
||||
<InputSelect
|
||||
name="assignment"
|
||||
aria-label="Assignment"
|
||||
value={this.props.selectedAssignment}
|
||||
options={this.getAssignmentFilterOptions()}
|
||||
onChange={this.handleAssignmentFilterChange}
|
||||
disabled={this.props.assignmentFilterOptions.length === 0}
|
||||
/>
|
||||
</div>
|
||||
<p>Grade Range (0% - 100%)</p>
|
||||
<form className="d-flex justify-content-between align-items-center" onSubmit={this.handleSubmitAssignmentGrade}>
|
||||
<Assignments
|
||||
assignmentGradeMin={this.state.assignmentGradeMin}
|
||||
assignmentGradeMax={this.state.assignmentGradeMax}
|
||||
courseId={this.props.courseId}
|
||||
setAssignmentGradeMin={this.createStateFieldSetter('assignmentGradeMin')}
|
||||
setAssignmentGradeMax={this.createStateFieldSetter('assignmentGradeMax')}
|
||||
updateQueryParams={this.updateQueryParams}
|
||||
/>
|
||||
<Collapsible title="Overall Grade" defaultOpen className="filter-group mb-3">
|
||||
<div className="grade-filter-inputs">
|
||||
<div className="percent-group">
|
||||
<InputText
|
||||
value={this.state.courseGradeMin}
|
||||
name="minimum-grade"
|
||||
label="Min Grade"
|
||||
name="assignmentGradeMin"
|
||||
onChange={value => this.handleCourseGradeFilterChange('min', value)}
|
||||
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>
|
||||
</div>
|
||||
<div className="percent-group">
|
||||
<InputText
|
||||
value={this.state.courseGradeMax}
|
||||
name="max-grade"
|
||||
label="Max Grade"
|
||||
name="assignmentGradeMax"
|
||||
onChange={value => this.handleCourseGradeFilterChange('max', value)}
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={this.state.assignmentGradeMax}
|
||||
disabled={!this.props.selectedAssignment}
|
||||
onChange={this.handleMaxAssigGradeChange}
|
||||
/>
|
||||
<span className="input-percent-label">%</span>
|
||||
<Button
|
||||
type="submit"
|
||||
className="btn-outline-secondary"
|
||||
name="assignmentGradeMinMax"
|
||||
disabled={!this.props.selectedAssignment}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible>
|
||||
<Collapsible title="Overall Grade" isOpen className="filter-group">
|
||||
<div className="d-flex justify-content-between align-items-center">
|
||||
<InputText
|
||||
value={this.state.courseGradeMin}
|
||||
name="minimum-grade"
|
||||
label="Min Grade"
|
||||
onChange={value => this.handleCourseGradeFilterChange('min', value)}
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
/>
|
||||
<span className="input-percent-label">%</span>
|
||||
<InputText
|
||||
value={this.state.courseGradeMax}
|
||||
name="max-grade"
|
||||
label="Max Grade"
|
||||
onChange={value => this.handleCourseGradeFilterChange('max', value)}
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
/>
|
||||
<span className="input-percent-label">%</span>
|
||||
<div className="grade-filter-action">
|
||||
<Button
|
||||
buttonType="outline-secondary"
|
||||
variant="outline-secondary"
|
||||
onClick={this.handleCourseGradeFilterApplyButtonClick}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</Collapsible>
|
||||
<Collapsible title="Student Groups" isOpen className="filter-group">
|
||||
<Collapsible title="Student Groups" defaultOpen className="filter-group mb-3">
|
||||
<InputSelect
|
||||
label="Tracks"
|
||||
name="Tracks"
|
||||
aria-label="Tracks"
|
||||
disabled={this.props.tracks.length === 0}
|
||||
@@ -931,6 +459,7 @@ export default class Gradebook extends React.Component {
|
||||
<InputSelect
|
||||
name="Cohorts"
|
||||
aria-label="Cohorts"
|
||||
label="Cohorts"
|
||||
disabled={this.props.cohorts.length === 0}
|
||||
value={this.mapSelectedCohortEntry(this.props.selectedCohort)}
|
||||
options={this.mapCohortsEntries(this.props.cohorts)}
|
||||
@@ -944,123 +473,55 @@ export default class Gradebook extends React.Component {
|
||||
|
||||
Gradebook.defaultProps = {
|
||||
areGradesFrozen: false,
|
||||
assignmentTypes: [],
|
||||
assignmentFilterOptions: [],
|
||||
canUserViewGradebook: false,
|
||||
cohorts: [],
|
||||
grades: [],
|
||||
gradeOverrides: [],
|
||||
gradeOverrideCurrentEarnedGradedOverride: null,
|
||||
gradeOriginalEarnedGraded: null,
|
||||
gradeOriginalPossibleGraded: null,
|
||||
courseId: '',
|
||||
filteredUsersCount: null,
|
||||
location: {
|
||||
search: '',
|
||||
},
|
||||
courseId: '',
|
||||
selectedAssignmentType: '',
|
||||
selectedCohort: null,
|
||||
selectedTrack: null,
|
||||
selectedAssignmentType: '',
|
||||
selectedAssignment: '',
|
||||
showSpinner: false,
|
||||
tracks: [],
|
||||
bulkImportError: '',
|
||||
uploadSuccess: false,
|
||||
showBulkManagement: false,
|
||||
bulkManagementHistory: [],
|
||||
errorFetchingGradeOverrideHistory: false,
|
||||
showSpinner: false,
|
||||
totalUsersCount: null,
|
||||
filteredUsersCount: null,
|
||||
tracks: [],
|
||||
};
|
||||
|
||||
Gradebook.propTypes = {
|
||||
areGradesFrozen: PropTypes.bool,
|
||||
assignmentTypes: PropTypes.arrayOf(PropTypes.string),
|
||||
assignmentFilterOptions: PropTypes.arrayOf(PropTypes.shape({
|
||||
label: PropTypes.string,
|
||||
subsectionLabel: PropTypes.string,
|
||||
})),
|
||||
canUserViewGradebook: PropTypes.bool,
|
||||
closeBanner: PropTypes.func.isRequired,
|
||||
cohorts: PropTypes.arrayOf(PropTypes.shape({
|
||||
name: PropTypes.string,
|
||||
id: PropTypes.number,
|
||||
})),
|
||||
filterAssignmentType: PropTypes.func.isRequired,
|
||||
updateAssignmentFilter: PropTypes.func.isRequired,
|
||||
updateAssignmentLimits: PropTypes.func.isRequired,
|
||||
format: PropTypes.string.isRequired,
|
||||
courseId: PropTypes.string,
|
||||
filteredUsersCount: PropTypes.number,
|
||||
getRoles: PropTypes.func.isRequired,
|
||||
getUserGrades: PropTypes.func.isRequired,
|
||||
fetchGradeOverrideHistory: PropTypes.func.isRequired,
|
||||
grades: PropTypes.arrayOf(PropTypes.shape({
|
||||
percent: PropTypes.number,
|
||||
section_breakdown: PropTypes.arrayOf(PropTypes.shape({
|
||||
attempted: PropTypes.bool,
|
||||
category: PropTypes.string,
|
||||
label: PropTypes.string,
|
||||
module_id: PropTypes.string,
|
||||
percent: PropTypes.number,
|
||||
scoreEarned: PropTypes.number,
|
||||
scorePossible: PropTypes.number,
|
||||
subsection_name: PropTypes.string,
|
||||
})),
|
||||
user_id: PropTypes.number,
|
||||
user_name: PropTypes.string,
|
||||
})),
|
||||
gradeOverrides: PropTypes.arrayOf(PropTypes.shape({
|
||||
date: PropTypes.string,
|
||||
grader: PropTypes.string,
|
||||
reason: PropTypes.string,
|
||||
adjustedGrade: PropTypes.number,
|
||||
})),
|
||||
gradeOverrideCurrentEarnedGradedOverride: PropTypes.number,
|
||||
gradeOriginalEarnedGraded: PropTypes.number,
|
||||
gradeOriginalPossibleGraded: PropTypes.number,
|
||||
doneViewingAssignment: PropTypes.func.isRequired,
|
||||
headings: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
gradeExportUrl: PropTypes.string.isRequired,
|
||||
history: PropTypes.shape({
|
||||
push: PropTypes.func,
|
||||
}).isRequired,
|
||||
initializeFilters: PropTypes.func.isRequired,
|
||||
interventionExportUrl: PropTypes.string.isRequired,
|
||||
location: PropTypes.shape({
|
||||
search: PropTypes.string,
|
||||
}),
|
||||
courseId: PropTypes.string,
|
||||
resetFilters: PropTypes.func.isRequired,
|
||||
searchForUser: PropTypes.func.isRequired,
|
||||
selectedAssignmentType: PropTypes.string,
|
||||
selectedAssignment: PropTypes.string,
|
||||
selectedCohort: PropTypes.string,
|
||||
selectedTrack: PropTypes.string,
|
||||
resetFilters: PropTypes.func.isRequired,
|
||||
showBulkManagement: PropTypes.bool,
|
||||
showSpinner: PropTypes.bool,
|
||||
showSuccess: PropTypes.bool.isRequired,
|
||||
toggleFormat: PropTypes.func.isRequired,
|
||||
totalUsersCount: PropTypes.number,
|
||||
tracks: PropTypes.arrayOf(PropTypes.shape({
|
||||
name: PropTypes.string,
|
||||
})),
|
||||
closeBanner: PropTypes.func.isRequired,
|
||||
updateGrades: PropTypes.func.isRequired,
|
||||
gradeExportUrl: PropTypes.string.isRequired,
|
||||
interventionExportUrl: PropTypes.string.isRequired,
|
||||
submitFileUploadFormData: PropTypes.func.isRequired,
|
||||
bulkImportError: PropTypes.string,
|
||||
uploadSuccess: PropTypes.bool,
|
||||
errorFetchingGradeOverrideHistory: PropTypes.bool,
|
||||
showBulkManagement: PropTypes.bool,
|
||||
bulkManagementHistory: PropTypes.arrayOf(PropTypes.shape({
|
||||
originalFilename: PropTypes.string.isRequired,
|
||||
user: PropTypes.string.isRequired,
|
||||
timeUploaded: PropTypes.string.isRequired,
|
||||
summaryOfRowsProcessed: PropTypes.shape({
|
||||
total: PropTypes.number.isRequired,
|
||||
successfullyProcessed: PropTypes.number.isRequired,
|
||||
failed: PropTypes.number.isRequired,
|
||||
skipped: PropTypes.number.isRequired,
|
||||
}).isRequired,
|
||||
})),
|
||||
totalUsersCount: PropTypes.number,
|
||||
filteredUsersCount: PropTypes.number,
|
||||
initializeFilters: PropTypes.func.isRequired,
|
||||
updateGradesIfAssignmentGradeFiltersSet: PropTypes.func.isRequired,
|
||||
updateCourseGradeFilter: PropTypes.func.isRequired,
|
||||
downloadBulkGradesReport: PropTypes.func.isRequired,
|
||||
downloadInterventionReport: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import React from 'react';
|
||||
import { Hyperlink } from '@edx/paragon';
|
||||
|
||||
import EdxLogo from '../../../assets/edx-sm.png';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
export default class Header extends React.Component {
|
||||
renderLogo() {
|
||||
return (
|
||||
<img src={EdxLogo} alt="edX logo" height="30" width="60" />
|
||||
<img src={getConfig().LOGO_URL} alt="edX logo" height="30" width="60" />
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,9 +12,7 @@ exports[`PageButtons prev not null, next not null 1`] = `
|
||||
<button
|
||||
className="btn btn-outline-primary"
|
||||
disabled={false}
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"margin": "20px",
|
||||
@@ -27,9 +25,7 @@ exports[`PageButtons prev not null, next not null 1`] = `
|
||||
<button
|
||||
className="btn btn-outline-primary"
|
||||
disabled={false}
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"margin": "20px",
|
||||
@@ -54,9 +50,7 @@ exports[`PageButtons prev not null, next null 1`] = `
|
||||
<button
|
||||
className="btn btn-outline-primary"
|
||||
disabled={false}
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"margin": "20px",
|
||||
@@ -69,9 +63,7 @@ exports[`PageButtons prev not null, next null 1`] = `
|
||||
<button
|
||||
className="btn btn-outline-primary"
|
||||
disabled={true}
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"margin": "20px",
|
||||
@@ -96,9 +88,7 @@ exports[`PageButtons prev null, next not null 1`] = `
|
||||
<button
|
||||
className="btn btn-outline-primary"
|
||||
disabled={true}
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"margin": "20px",
|
||||
@@ -111,9 +101,7 @@ exports[`PageButtons prev null, next not null 1`] = `
|
||||
<button
|
||||
className="btn btn-outline-primary"
|
||||
disabled={false}
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"margin": "20px",
|
||||
@@ -138,9 +126,7 @@ exports[`PageButtons prev null, next null 1`] = `
|
||||
<button
|
||||
className="btn btn-outline-primary"
|
||||
disabled={true}
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"margin": "20px",
|
||||
@@ -153,9 +139,7 @@ exports[`PageButtons prev null, next null 1`] = `
|
||||
<button
|
||||
className="btn btn-outline-primary"
|
||||
disabled={true}
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"margin": "20px",
|
||||
|
||||
@@ -2,7 +2,6 @@ import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Button } from '@edx/paragon';
|
||||
|
||||
|
||||
export default function PageButtons({
|
||||
prevPage, nextPage, selectedTrack, selectedCohort, selectedAssignmentType,
|
||||
getPrevNextGrades, match,
|
||||
@@ -14,31 +13,29 @@ export default function PageButtons({
|
||||
>
|
||||
<Button
|
||||
style={{ margin: '20px' }}
|
||||
buttonType="outline-primary"
|
||||
variant="outline-primary"
|
||||
disabled={!prevPage}
|
||||
onClick={() =>
|
||||
getPrevNextGrades(
|
||||
prevPage,
|
||||
match.params.courseId,
|
||||
selectedCohort,
|
||||
selectedTrack,
|
||||
selectedAssignmentType,
|
||||
)}
|
||||
onClick={() => getPrevNextGrades(
|
||||
prevPage,
|
||||
match.params.courseId,
|
||||
selectedCohort,
|
||||
selectedTrack,
|
||||
selectedAssignmentType,
|
||||
)}
|
||||
>
|
||||
Previous Page
|
||||
</Button>
|
||||
<Button
|
||||
style={{ margin: '20px' }}
|
||||
buttonType="outline-primary"
|
||||
variant="outline-primary"
|
||||
disabled={!nextPage}
|
||||
onClick={() =>
|
||||
getPrevNextGrades(
|
||||
nextPage,
|
||||
match.params.courseId,
|
||||
selectedCohort,
|
||||
selectedTrack,
|
||||
selectedAssignmentType,
|
||||
)}
|
||||
onClick={() => getPrevNextGrades(
|
||||
nextPage,
|
||||
match.params.courseId,
|
||||
selectedCohort,
|
||||
selectedTrack,
|
||||
selectedAssignmentType,
|
||||
)}
|
||||
>
|
||||
Next Page
|
||||
</Button>
|
||||
@@ -76,4 +73,3 @@ PageButtons.propTypes = {
|
||||
name: PropTypes.string,
|
||||
}),
|
||||
};
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import { connect } from 'react-redux';
|
||||
import Gradebook from '../../components/Gradebook';
|
||||
import {
|
||||
closeBanner,
|
||||
doneViewingAssignment,
|
||||
fetchGradeOverrideHistory,
|
||||
fetchGrades,
|
||||
fetchMatchingUserGrades,
|
||||
@@ -11,14 +10,14 @@ import {
|
||||
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 {
|
||||
initializeFilters, resetFilters, updateAssignmentFilter, updateAssignmentLimits, updateCourseGradeFilter,
|
||||
} from '../../data/actions/filters';
|
||||
import stateHasMastersTrack from '../../data/selectors/tracks';
|
||||
import {
|
||||
getBulkManagementHistory,
|
||||
@@ -37,7 +36,7 @@ import LmsApiService from '../../data/services/LmsApiService';
|
||||
function shouldShowSpinner(state) {
|
||||
if (state.roles.canUserViewGradebook === true) {
|
||||
return state.grades.showSpinner;
|
||||
} else if (state.roles.canUserViewGradebook === false) {
|
||||
} if (state.roles.canUserViewGradebook === false) {
|
||||
return false;
|
||||
} // canUserViewGradebook === null
|
||||
return true;
|
||||
@@ -45,33 +44,19 @@ function shouldShowSpinner(state) {
|
||||
|
||||
const mapStateToProps = (state, ownProps) => (
|
||||
{
|
||||
courseId: ownProps.match.params.courseId,
|
||||
grades: state.grades.results,
|
||||
gradeOverrides: state.grades.gradeOverrideHistoryResults,
|
||||
gradeOverrideCurrentEarnedAllOverride: state.grades.gradeOverrideCurrentEarnedAllOverride,
|
||||
gradeOverrideCurrentPossibleAllOverride: state.grades.gradeOverrideCurrentPossibleAllOverride,
|
||||
gradeOverrideCurrentEarnedGradedOverride: state.grades.gradeOverrideCurrentEarnedGradedOverride,
|
||||
gradeOverrideCurrentPossibleGradedOverride:
|
||||
state.grades.gradeOverrideCurrentPossibleGradedOverride,
|
||||
gradeOriginalEarnedGraded: state.grades.gradeOriginalEarnedGraded,
|
||||
gradeOriginalPossibleGraded: state.grades.gradeOriginalPossibleGraded,
|
||||
headings: getHeadings(state),
|
||||
tracks: state.tracks.results,
|
||||
cohorts: state.cohorts.results,
|
||||
selectedTrack: state.filters.track,
|
||||
selectedCohort: state.filters.cohort,
|
||||
selectedAssignmentType: state.filters.assignmentType,
|
||||
selectedAssignment: (state.filters.assignment || {}).label,
|
||||
format: state.grades.gradeFormat,
|
||||
showSuccess: state.grades.showSuccess,
|
||||
errorFetchingGradeOverrideHistory: state.grades.errorFetchingOverrideHistory,
|
||||
prevPage: state.grades.prevPage,
|
||||
nextPage: state.grades.nextPage,
|
||||
areGradesFrozen: state.assignmentTypes.areGradesFrozen,
|
||||
assignmentTypes: state.assignmentTypes.results,
|
||||
assignmentFilterOptions: selectableAssignmentLabels(state),
|
||||
areGradesFrozen: state.assignmentTypes.areGradesFrozen,
|
||||
showSpinner: shouldShowSpinner(state),
|
||||
bulkImportError: state.grades.bulkManagement
|
||||
&& state.grades.bulkManagement.errorMessages
|
||||
? `Errors while processing: ${state.grades.bulkManagement.errorMessages.join(', ')}`
|
||||
: '',
|
||||
bulkManagementHistory: getBulkManagementHistory(state),
|
||||
cohorts: state.cohorts.results,
|
||||
courseId: ownProps.match.params.courseId,
|
||||
canUserViewGradebook: state.roles.canUserViewGradebook,
|
||||
filteredUsersCount: state.grades.filteredUsersCount,
|
||||
format: state.grades.gradeFormat,
|
||||
gradeExportUrl: LmsApiService.getGradeExportCsvUrl(ownProps.match.params.courseId, {
|
||||
cohort: getCohortNameById(state, state.filters.cohort),
|
||||
track: state.filters.track,
|
||||
@@ -88,6 +73,8 @@ const mapStateToProps = (state, ownProps) => (
|
||||
courseGradeMin: formatMinCourseGrade(state.filters.courseGradeMin),
|
||||
courseGradeMax: formatMaxCourseGrade(state.filters.courseGradeMax),
|
||||
}),
|
||||
grades: state.grades.results,
|
||||
headings: getHeadings(state),
|
||||
interventionExportUrl:
|
||||
LmsApiService.getInterventionExportCsvUrl(ownProps.match.params.courseId, {
|
||||
cohort: getCohortNameById(state, state.filters.cohort),
|
||||
@@ -104,42 +91,42 @@ const mapStateToProps = (state, ownProps) => (
|
||||
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),
|
||||
nextPage: state.grades.nextPage,
|
||||
prevPage: state.grades.prevPage,
|
||||
selectedTrack: state.filters.track,
|
||||
selectedCohort: state.filters.cohort,
|
||||
selectedAssignmentType: state.filters.assignmentType,
|
||||
selectedAssignment: (state.filters.assignment || {}).label,
|
||||
showBulkManagement: stateHasMastersTrack(state) && state.config.bulkManagementAvailable,
|
||||
bulkManagementHistory: getBulkManagementHistory(state),
|
||||
showSpinner: shouldShowSpinner(state),
|
||||
showSuccess: state.grades.showSuccess,
|
||||
totalUsersCount: state.grades.totalUsersCount,
|
||||
filteredUsersCount: state.grades.filteredUsersCount,
|
||||
tracks: state.tracks.results,
|
||||
uploadSuccess: !!(state.grades.bulkManagement
|
||||
&& state.grades.bulkManagement.uploadSuccess),
|
||||
}
|
||||
);
|
||||
|
||||
const mapDispatchToProps = {
|
||||
doneViewingAssignment,
|
||||
getUserGrades: fetchGrades,
|
||||
fetchGradeOverrideHistory,
|
||||
searchForUser: fetchMatchingUserGrades,
|
||||
getPrevNextGrades: fetchPrevNextGrades,
|
||||
getCohorts: fetchCohorts,
|
||||
getTracks: fetchTracks,
|
||||
getAssignmentTypes: fetchAssignmentTypes,
|
||||
updateGrades,
|
||||
toggleFormat: toggleGradeFormat,
|
||||
filterAssignmentType,
|
||||
closeBanner,
|
||||
getRoles,
|
||||
submitFileUploadFormData,
|
||||
initializeFilters,
|
||||
resetFilters,
|
||||
updateAssignmentFilter,
|
||||
updateAssignmentLimits,
|
||||
updateGradesIfAssignmentGradeFiltersSet,
|
||||
updateCourseGradeFilter,
|
||||
downloadBulkGradesReport,
|
||||
downloadInterventionReport,
|
||||
fetchGradeOverrideHistory,
|
||||
filterAssignmentType,
|
||||
getAssignmentTypes: fetchAssignmentTypes,
|
||||
getCohorts: fetchCohorts,
|
||||
getPrevNextGrades: fetchPrevNextGrades,
|
||||
getRoles,
|
||||
getTracks: fetchTracks,
|
||||
getUserGrades: fetchGrades,
|
||||
initializeFilters,
|
||||
resetFilters,
|
||||
searchForUser: fetchMatchingUserGrades,
|
||||
submitFileUploadFormData,
|
||||
toggleFormat: toggleGradeFormat,
|
||||
updateAssignmentFilter,
|
||||
updateAssignmentLimits,
|
||||
updateCourseGradeFilter,
|
||||
};
|
||||
|
||||
const GradebookPage = connect(
|
||||
|
||||
@@ -38,4 +38,3 @@ export {
|
||||
gotAssignmentTypes,
|
||||
errorFetchingAssignmentTypes,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import axios from 'axios';
|
||||
import configureMockStore from 'redux-mock-store';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import thunk from 'redux-thunk';
|
||||
|
||||
import apiClient from '../apiClient';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { configuration } from '../../config';
|
||||
import { fetchAssignmentTypes } from './assignmentTypes';
|
||||
import {
|
||||
@@ -14,9 +15,12 @@ import {
|
||||
import GOT_BULK_MANAGEMENT_CONFIG from '../constants/actionTypes/config';
|
||||
|
||||
const mockStore = configureMockStore([thunk]);
|
||||
const axiosMock = new MockAdapter(apiClient);
|
||||
apiClient.isAccessTokenExpired = jest.fn();
|
||||
apiClient.isAccessTokenExpired.mockReturnValue(false);
|
||||
|
||||
jest.mock('@edx/frontend-platform/auth');
|
||||
const axiosMock = new MockAdapter(axios);
|
||||
getAuthenticatedHttpClient.mockReturnValue(axios);
|
||||
axios.isAccessTokenExpired = jest.fn();
|
||||
axios.isAccessTokenExpired.mockReturnValue(false);
|
||||
|
||||
describe('actions', () => {
|
||||
afterEach(() => {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import axios from 'axios';
|
||||
import configureMockStore from 'redux-mock-store';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import thunk from 'redux-thunk';
|
||||
|
||||
import apiClient from '../apiClient';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { configuration } from '../../config';
|
||||
import { fetchCohorts } from './cohorts';
|
||||
import {
|
||||
@@ -12,9 +13,12 @@ import {
|
||||
} from '../constants/actionTypes/cohorts';
|
||||
|
||||
const mockStore = configureMockStore([thunk]);
|
||||
const axiosMock = new MockAdapter(apiClient);
|
||||
apiClient.isAccessTokenExpired = jest.fn();
|
||||
apiClient.isAccessTokenExpired.mockReturnValue(false);
|
||||
|
||||
jest.mock('@edx/frontend-platform/auth');
|
||||
const axiosMock = new MockAdapter(axios);
|
||||
getAuthenticatedHttpClient.mockReturnValue(axios);
|
||||
axios.isAccessTokenExpired = jest.fn();
|
||||
axios.isAccessTokenExpired.mockReturnValue(false);
|
||||
|
||||
describe('actions', () => {
|
||||
afterEach(() => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import initialFilters from '../constants/filters';
|
||||
import { INITIALIZE_FILTERS, RESET_FILTERS, UPDATE_ASSIGNMENT_FILTER, UPDATE_ASSIGNMENT_LIMITS, UPDATE_COURSE_GRADE_LIMITS } from '../constants/actionTypes/filters';
|
||||
import {
|
||||
INITIALIZE_FILTERS, RESET_FILTERS, UPDATE_ASSIGNMENT_FILTER, UPDATE_ASSIGNMENT_LIMITS, UPDATE_COURSE_GRADE_LIMITS,
|
||||
} from '../constants/actionTypes/filters';
|
||||
|
||||
const initializeFilters = ({
|
||||
assignment = initialFilters.assignment,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import {
|
||||
STARTED_FETCHING_GRADES,
|
||||
FINISHED_FETCHING_GRADES,
|
||||
@@ -23,11 +24,13 @@ import {
|
||||
BULK_GRADE_REPORT_DOWNLOADED,
|
||||
INTERVENTION_REPORT_DOWNLOADED,
|
||||
} from '../constants/actionTypes/grades';
|
||||
import GRADE_OVERRIDE_HISTORY_ERROR_DEFAULT_MSG from '../constants/errors';
|
||||
import LmsApiService from '../services/LmsApiService';
|
||||
import { sortAlphaAsc, formatDateForDisplay } from './utils';
|
||||
import { formatMaxAssignmentGrade, formatMinAssignmentGrade, formatMaxCourseGrade, formatMinCourseGrade } from '../selectors/grades';
|
||||
import {
|
||||
formatMaxAssignmentGrade, formatMinAssignmentGrade, formatMaxCourseGrade, formatMinCourseGrade,
|
||||
} from '../selectors/grades';
|
||||
import { getFilters } from '../selectors/filters';
|
||||
import apiClient from '../apiClient';
|
||||
|
||||
const defaultAssignmentFilter = 'All';
|
||||
|
||||
@@ -40,7 +43,10 @@ const bulkHistoryError = () => ({ type: BULK_HISTORY_ERR });
|
||||
const startedFetchingGrades = () => ({ type: STARTED_FETCHING_GRADES });
|
||||
const finishedFetchingGrades = () => ({ type: FINISHED_FETCHING_GRADES });
|
||||
const errorFetchingGrades = () => ({ type: ERROR_FETCHING_GRADES });
|
||||
const errorFetchingGradeOverrideHistory = () => ({ type: ERROR_FETCHING_GRADE_OVERRIDE_HISTORY });
|
||||
const errorFetchingGradeOverrideHistory = errorMessage => ({
|
||||
type: ERROR_FETCHING_GRADE_OVERRIDE_HISTORY,
|
||||
errorMessage,
|
||||
});
|
||||
|
||||
const gotGrades = ({
|
||||
grades, cohort, track, assignmentType, headings, prev,
|
||||
@@ -108,7 +114,6 @@ const uploadOverrideFailure = (courseId, error) => ({
|
||||
payload: { error },
|
||||
});
|
||||
|
||||
|
||||
const toggleGradeFormat = formatType => ({ type: TOGGLE_GRADE_FORMAT, formatType });
|
||||
|
||||
const filterAssignmentType = filterType => (
|
||||
@@ -191,27 +196,31 @@ const doneViewingAssignment = () => dispatch => dispatch({
|
||||
type: DONE_VIEWING_ASSIGNMENT,
|
||||
});
|
||||
const fetchGradeOverrideHistory = (subsectionId, userId) => (
|
||||
dispatch =>
|
||||
LmsApiService.fetchGradeOverrideHistory(subsectionId, userId)
|
||||
.then(response => response.data)
|
||||
.then((data) => {
|
||||
dispatch => LmsApiService.fetchGradeOverrideHistory(subsectionId, userId)
|
||||
.then(response => response.data)
|
||||
.then((data) => {
|
||||
if (data.success) {
|
||||
dispatch(gotGradeOverrideHistory({
|
||||
overrideHistory: formatGradeOverrideForDisplay(data.history),
|
||||
currentEarnedAllOverride: data.override ? data.override.earned_all_override : null,
|
||||
currentPossibleAllOverride: data.override ? data.override.possible_all_override : null,
|
||||
currentEarnedGradedOverride: data.override ? data.override.earned_graded_override : null,
|
||||
currentPossibleGradedOverride: data.override ?
|
||||
data.override.possible_graded_override : null,
|
||||
currentPossibleGradedOverride: data.override
|
||||
? data.override.possible_graded_override : null,
|
||||
originalGradeEarnedAll: data.original_grade ? data.original_grade.earned_all : null,
|
||||
originalGradePossibleAll: data.original_grade ? data.original_grade.possible_all : null,
|
||||
originalGradeEarnedGraded: data.original_grade ? data.original_grade.earned_graded : null,
|
||||
originalGradePossibleGraded: data.original_grade ?
|
||||
data.original_grade.possible_graded : null,
|
||||
originalGradeEarnedGraded: data.original_grade
|
||||
? data.original_grade.earned_graded : null,
|
||||
originalGradePossibleGraded: data.original_grade
|
||||
? data.original_grade.possible_graded : null,
|
||||
}));
|
||||
})
|
||||
.catch(() => {
|
||||
dispatch(errorFetchingGradeOverrideHistory());
|
||||
})
|
||||
} else {
|
||||
dispatch(errorFetchingGradeOverrideHistory(data.error_message));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
dispatch(errorFetchingGradeOverrideHistory(GRADE_OVERRIDE_HISTORY_ERROR_DEFAULT_MSG));
|
||||
})
|
||||
);
|
||||
|
||||
const fetchMatchingUserGrades = (
|
||||
@@ -230,7 +239,7 @@ const fetchMatchingUserGrades = (
|
||||
const fetchPrevNextGrades = (endpoint, courseId, cohort, track, assignmentType) => (
|
||||
(dispatch) => {
|
||||
dispatch(startedFetchingGrades());
|
||||
return apiClient.get(endpoint)
|
||||
return getAuthenticatedHttpClient().get(endpoint)
|
||||
.then(response => response.data)
|
||||
.then((data) => {
|
||||
dispatch(gotGrades({
|
||||
@@ -293,11 +302,10 @@ const submitFileUploadFormData = (courseId, formData) => (
|
||||
);
|
||||
|
||||
const fetchBulkUpgradeHistory = courseId => (
|
||||
dispatch =>
|
||||
// todo add loading effect
|
||||
LmsApiService.fetchGradeBulkOperationHistory(courseId).then((response) => {
|
||||
dispatch(gotBulkHistory(response));
|
||||
}).catch(() => dispatch(bulkHistoryError()))
|
||||
// todo add loading effect
|
||||
dispatch => LmsApiService.fetchGradeBulkOperationHistory(courseId).then(
|
||||
(response) => { dispatch(gotBulkHistory(response)); },
|
||||
).catch(() => dispatch(bulkHistoryError()))
|
||||
);
|
||||
|
||||
const updateGradesIfAssignmentGradeFiltersSet = (
|
||||
|
||||
@@ -1,23 +1,30 @@
|
||||
import axios from 'axios';
|
||||
import configureMockStore from 'redux-mock-store';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import thunk from 'redux-thunk';
|
||||
|
||||
import apiClient from '../apiClient';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { configuration } from '../../config';
|
||||
import { fetchGrades } from './grades';
|
||||
import { fetchGrades, fetchGradeOverrideHistory } from './grades';
|
||||
import {
|
||||
STARTED_FETCHING_GRADES,
|
||||
FINISHED_FETCHING_GRADES,
|
||||
ERROR_FETCHING_GRADES,
|
||||
GOT_GRADES,
|
||||
GOT_GRADE_OVERRIDE_HISTORY,
|
||||
ERROR_FETCHING_GRADE_OVERRIDE_HISTORY,
|
||||
} from '../constants/actionTypes/grades';
|
||||
import GRADE_OVERRIDE_HISTORY_ERROR_DEFAULT_MSG from '../constants/errors';
|
||||
import { sortAlphaAsc } from './utils';
|
||||
|
||||
import LmsApiService from '../services/LmsApiService';
|
||||
|
||||
const mockStore = configureMockStore([thunk]);
|
||||
const axiosMock = new MockAdapter(apiClient);
|
||||
apiClient.isAccessTokenExpired = jest.fn();
|
||||
apiClient.isAccessTokenExpired.mockReturnValue(false);
|
||||
|
||||
jest.mock('@edx/frontend-platform/auth');
|
||||
const axiosMock = new MockAdapter(axios);
|
||||
getAuthenticatedHttpClient.mockReturnValue(axios);
|
||||
axios.isAccessTokenExpired = jest.fn();
|
||||
axios.isAccessTokenExpired.mockReturnValue(false);
|
||||
|
||||
describe('actions', () => {
|
||||
afterEach(() => {
|
||||
@@ -176,4 +183,92 @@ describe('actions', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchGradeOverridHistory', () => {
|
||||
const subsectionId = 'subsectionId-11111';
|
||||
const userId = 'user-id-11111';
|
||||
|
||||
const fetchOverridesURL = `${LmsApiService.baseUrl}/api/grades/v1/subsection/${subsectionId}/?user_id=${userId}&history_record_limit=5`;
|
||||
|
||||
const originalGrade = {
|
||||
earned_all: 1.0,
|
||||
possible_all: 12.0,
|
||||
earned_graded: 3.0,
|
||||
possible_graded: 8.0,
|
||||
};
|
||||
|
||||
const override = {
|
||||
earned_all_override: 13.0,
|
||||
possible_all_override: 13.0,
|
||||
earned_graded_override: 10.0,
|
||||
possible_graded_override: 10.0,
|
||||
};
|
||||
|
||||
it('dispatches success action after successfully getting override info', () => {
|
||||
const responseData = {
|
||||
success: true,
|
||||
original_grade: originalGrade,
|
||||
history: [],
|
||||
override,
|
||||
};
|
||||
|
||||
axiosMock.onGet(fetchOverridesURL)
|
||||
.replyOnce(200, JSON.stringify(responseData));
|
||||
|
||||
const expectedActions = [
|
||||
{
|
||||
type: GOT_GRADE_OVERRIDE_HISTORY,
|
||||
overrideHistory: [],
|
||||
currentEarnedAllOverride: override.earned_all_override,
|
||||
currentPossibleAllOverride: override.possible_all_override,
|
||||
currentEarnedGradedOverride: override.earned_graded_override,
|
||||
currentPossibleGradedOverride: override.possible_graded_override,
|
||||
originalGradeEarnedAll: originalGrade.earned_all,
|
||||
originalGradePossibleAll: originalGrade.possible_all,
|
||||
originalGradeEarnedGraded: originalGrade.earned_graded,
|
||||
originalGradePossibleGraded: originalGrade.possible_graded,
|
||||
},
|
||||
];
|
||||
const store = mockStore();
|
||||
|
||||
return store.dispatch(fetchGradeOverrideHistory(subsectionId, userId)).then(() => {
|
||||
expect(store.getActions()).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dispatches failure action with expected message', () => {
|
||||
test('on failure response', () => {
|
||||
const responseData = {
|
||||
success: false,
|
||||
error_message: 'There was an error!!!!!!!!!',
|
||||
};
|
||||
|
||||
axiosMock.onGet(fetchOverridesURL).replyOnce(200, JSON.stringify(responseData));
|
||||
|
||||
const expectedActions = [{
|
||||
type: ERROR_FETCHING_GRADE_OVERRIDE_HISTORY,
|
||||
errorMessage: responseData.error_message,
|
||||
}];
|
||||
const store = mockStore();
|
||||
|
||||
return store.dispatch(fetchGradeOverrideHistory(subsectionId, userId)).then(() => {
|
||||
expect(store.getActions()).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
|
||||
test('on 500 error', () => {
|
||||
axiosMock.onGet(fetchOverridesURL).replyOnce(500);
|
||||
|
||||
const expectedActions = [{
|
||||
type: ERROR_FETCHING_GRADE_OVERRIDE_HISTORY,
|
||||
errorMessage: GRADE_OVERRIDE_HISTORY_ERROR_DEFAULT_MSG,
|
||||
}];
|
||||
const store = mockStore();
|
||||
|
||||
return store.dispatch(fetchGradeOverrideHistory(subsectionId, userId)).then(() => {
|
||||
expect(store.getActions()).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import axios from 'axios';
|
||||
import configureMockStore from 'redux-mock-store';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import thunk from 'redux-thunk';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
import apiClient from '../apiClient';
|
||||
import { configuration } from '../../config';
|
||||
import { getRoles } from './roles';
|
||||
import {
|
||||
@@ -14,11 +15,13 @@ import { STARTED_FETCHING_TRACKS } from '../constants/actionTypes/tracks';
|
||||
import { STARTED_FETCHING_COHORTS } from '../constants/actionTypes/cohorts';
|
||||
import { STARTED_FETCHING_ASSIGNMENT_TYPES } from '../constants/actionTypes/assignmentTypes';
|
||||
|
||||
|
||||
const mockStore = configureMockStore([thunk]);
|
||||
const axiosMock = new MockAdapter(apiClient);
|
||||
apiClient.isAccessTokenExpired = jest.fn();
|
||||
apiClient.isAccessTokenExpired.mockReturnValue(false);
|
||||
|
||||
jest.mock('@edx/frontend-platform/auth');
|
||||
const axiosMock = new MockAdapter(axios);
|
||||
getAuthenticatedHttpClient.mockReturnValue(axios);
|
||||
axios.isAccessTokenExpired = jest.fn();
|
||||
axios.isAccessTokenExpired.mockReturnValue(false);
|
||||
|
||||
const course1Id = 'course-v1:edX+DemoX+Demo_Course';
|
||||
const course2Id = 'course-v1:edX+DemoX+Demo_Course_2';
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import axios from 'axios';
|
||||
import configureMockStore from 'redux-mock-store';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import thunk from 'redux-thunk';
|
||||
|
||||
import apiClient from '../apiClient';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { configuration } from '../../config';
|
||||
import { fetchTracks } from './tracks';
|
||||
import {
|
||||
@@ -12,9 +13,12 @@ import {
|
||||
} from '../constants/actionTypes/tracks';
|
||||
|
||||
const mockStore = configureMockStore([thunk]);
|
||||
const axiosMock = new MockAdapter(apiClient);
|
||||
apiClient.isAccessTokenExpired = jest.fn();
|
||||
apiClient.isAccessTokenExpired.mockReturnValue(false);
|
||||
|
||||
jest.mock('@edx/frontend-platform/auth');
|
||||
const axiosMock = new MockAdapter(axios);
|
||||
getAuthenticatedHttpClient.mockReturnValue(axios);
|
||||
axios.isAccessTokenExpired = jest.fn();
|
||||
axios.isAccessTokenExpired.mockReturnValue(false);
|
||||
|
||||
describe('actions', () => {
|
||||
afterEach(() => {
|
||||
|
||||
@@ -27,4 +27,3 @@ const sortAlphaAsc = (gradeRowA, gradeRowB) => {
|
||||
};
|
||||
|
||||
export { sortAlphaAsc, formatDateForDisplay };
|
||||
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { getAuthenticatedAPIClient } from '@edx/frontend-auth';
|
||||
|
||||
import { configuration } from '../config';
|
||||
|
||||
const apiClient = getAuthenticatedAPIClient({
|
||||
appBaseUrl: configuration.BASE_URL,
|
||||
loginUrl: configuration.LOGIN_URL,
|
||||
logoutUrl: configuration.LOGOUT_URL,
|
||||
csrfTokenApiPath: process.env.CSRF_TOKEN_API_PATH,
|
||||
refreshAccessTokenEndpoint: configuration.REFRESH_ACCESS_TOKEN_ENDPOINT,
|
||||
accessTokenCookieName: configuration.ACCESS_TOKEN_COOKIE_NAME,
|
||||
csrfCookieName: configuration.CSRF_COOKIE_NAME,
|
||||
});
|
||||
|
||||
export default apiClient;
|
||||
@@ -9,4 +9,3 @@ export {
|
||||
ERROR_FETCHING_ASSIGNMENT_TYPES,
|
||||
GOT_ARE_GRADES_FROZEN,
|
||||
};
|
||||
|
||||
|
||||
3
src/data/constants/errors.js
Normal file
3
src/data/constants/errors.js
Normal file
@@ -0,0 +1,3 @@
|
||||
const GRADE_OVERRIDE_HISTORY_ERROR_DEFAULT_MSG = 'Error retrieving grade override history.';
|
||||
|
||||
export default GRADE_OVERRIDE_HISTORY_ERROR_DEFAULT_MSG;
|
||||
@@ -11,7 +11,6 @@ const initialState = {
|
||||
errorFetching: false,
|
||||
};
|
||||
|
||||
|
||||
const assignmentTypes = (state = initialState, action) => {
|
||||
switch (action.type) {
|
||||
case GOT_ASSIGNMENT_TYPES:
|
||||
@@ -45,4 +44,3 @@ const assignmentTypes = (state = initialState, action) => {
|
||||
};
|
||||
|
||||
export default assignmentTypes;
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ const initialState = {
|
||||
errorFetching: false,
|
||||
};
|
||||
|
||||
|
||||
const cohorts = (state = initialState, action) => {
|
||||
switch (action.type) {
|
||||
case GOT_COHORTS:
|
||||
@@ -37,4 +36,3 @@ const cohorts = (state = initialState, action) => {
|
||||
};
|
||||
|
||||
export default cohorts;
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { GOT_GRADES, FILTER_BY_ASSIGNMENT_TYPE } from '../constants/actionTypes/grades';
|
||||
|
||||
import { INITIALIZE_FILTERS, UPDATE_ASSIGNMENT_FILTER, UPDATE_ASSIGNMENT_LIMITS, UPDATE_COURSE_GRADE_LIMITS, RESET_FILTERS } from '../constants/actionTypes/filters';
|
||||
import {
|
||||
INITIALIZE_FILTERS, UPDATE_ASSIGNMENT_FILTER, UPDATE_ASSIGNMENT_LIMITS, UPDATE_COURSE_GRADE_LIMITS, RESET_FILTERS,
|
||||
} from '../constants/actionTypes/filters';
|
||||
|
||||
import initialFilters from '../constants/filters';
|
||||
|
||||
@@ -15,8 +17,8 @@ const reducer = (state = initialState, action) => {
|
||||
...state,
|
||||
assignmentType: action.filterType,
|
||||
assignment: (
|
||||
action.filterType !== '' &&
|
||||
(state.assignment || {}).type !== action.filterType)
|
||||
action.filterType !== ''
|
||||
&& (state.assignment || {}).type !== action.filterType)
|
||||
? '' : state.assignment,
|
||||
};
|
||||
case INITIALIZE_FILTERS:
|
||||
|
||||
@@ -30,6 +30,7 @@ const initialState = {
|
||||
startedFetching: false,
|
||||
finishedFetching: false,
|
||||
errorFetching: false,
|
||||
overrideHistoryError: '',
|
||||
gradeFormat: 'percent',
|
||||
showSuccess: false,
|
||||
prevPage: null,
|
||||
@@ -83,14 +84,14 @@ const grades = (state = initialState, action) => {
|
||||
gradeOriginalPossibleAll: action.originalGradePossibleAll,
|
||||
gradeOriginalEarnedGraded: action.originalGradeEarnedGraded,
|
||||
gradeOriginalPossibleGraded: action.originalGradePossibleGraded,
|
||||
errorFetchingOverrideHistory: false,
|
||||
overrideHistoryError: '',
|
||||
};
|
||||
|
||||
case ERROR_FETCHING_GRADE_OVERRIDE_HISTORY:
|
||||
return {
|
||||
...state,
|
||||
finishedFetchingOverrideHistory: true,
|
||||
errorFetchingOverrideHistory: true,
|
||||
overrideHistoryError: action.errorMessage,
|
||||
};
|
||||
|
||||
case STARTED_FETCHING_GRADES:
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
TOGGLE_GRADE_FORMAT,
|
||||
FILTER_BY_ASSIGNMENT_TYPE,
|
||||
OPEN_BANNER,
|
||||
ERROR_FETCHING_GRADE_OVERRIDE_HISTORY,
|
||||
} from '../constants/actionTypes/grades';
|
||||
|
||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
@@ -161,4 +162,17 @@ describe('grades reducer', () => {
|
||||
type: ERROR_FETCHING_GRADES,
|
||||
})).toEqual(expected);
|
||||
});
|
||||
|
||||
it('updates fetch grade override history failure state', () => {
|
||||
const errorMessage = 'This is the error message';
|
||||
const expected = {
|
||||
...initialState,
|
||||
finishedFetchingOverrideHistory: true,
|
||||
overrideHistoryError: errorMessage,
|
||||
};
|
||||
expect(grades(undefined, {
|
||||
type: ERROR_FETCHING_GRADE_OVERRIDE_HISTORY,
|
||||
errorMessage,
|
||||
})).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,6 @@ const initialState = {
|
||||
errorFetching: false,
|
||||
};
|
||||
|
||||
|
||||
const tracks = (state = initialState, action) => {
|
||||
switch (action.type) {
|
||||
case GOT_TRACKS:
|
||||
@@ -37,4 +36,3 @@ const tracks = (state = initialState, action) => {
|
||||
};
|
||||
|
||||
export default tracks;
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
const getCohorts = state => state.cohorts.results || [];
|
||||
|
||||
const getCohortById = (state, selectedCohortId) => {
|
||||
@@ -6,7 +5,6 @@ const getCohortById = (state, selectedCohortId) => {
|
||||
return cohort;
|
||||
};
|
||||
|
||||
const getCohortNameById = (state, selectedCohortId) =>
|
||||
(getCohortById(state, selectedCohortId) || {}).name;
|
||||
const getCohortNameById = (state, selectedCohortId) => (getCohortById(state, selectedCohortId) || {}).name;
|
||||
|
||||
export { getCohortById, getCohortNameById, getCohorts };
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
const getFilters = state => state.filters || {};
|
||||
|
||||
const getAssignmentsFromResultsSubstate = results =>
|
||||
(results[0] || {}).section_breakdown || [];
|
||||
const getAssignmentsFromResultsSubstate = results => (results[0] || {}).section_breakdown || [];
|
||||
|
||||
const selectableAssignments = (state) => {
|
||||
const selectedAssignmentType = getFilters(state).assignmentType;
|
||||
@@ -20,14 +19,12 @@ const chooseRelevantAssignmentData = assignment => ({
|
||||
id: assignment.module_id,
|
||||
});
|
||||
|
||||
const selectableAssignmentLabels = state =>
|
||||
selectableAssignments(state).map(chooseRelevantAssignmentData);
|
||||
const selectableAssignmentLabels = state => selectableAssignments(state).map(chooseRelevantAssignmentData);
|
||||
|
||||
const typeOfSelectedAssignment = (state) => {
|
||||
const selectedAssignmentLabel = getFilters(state).assignment;
|
||||
const sectionBreakdown = (state.grades.results[0] || {}).section_breakdown || [];
|
||||
const selectedAssignment = sectionBreakdown.find(section =>
|
||||
section.label === selectedAssignmentLabel);
|
||||
const selectedAssignment = sectionBreakdown.find(section => section.label === selectedAssignmentLabel);
|
||||
return selectedAssignment && selectedAssignment.category;
|
||||
};
|
||||
|
||||
|
||||
@@ -33,10 +33,8 @@ const transformHistoryEntry = (historyRow) => {
|
||||
...rest,
|
||||
};
|
||||
};
|
||||
const getBulkManagementHistoryFromState = state =>
|
||||
state.grades.bulkManagement.history || [];
|
||||
const getBulkManagementHistory = state =>
|
||||
getBulkManagementHistoryFromState(state).map(transformHistoryEntry);
|
||||
const getBulkManagementHistoryFromState = state => state.grades.bulkManagement.history || [];
|
||||
const getBulkManagementHistory = state => getBulkManagementHistoryFromState(state).map(transformHistoryEntry);
|
||||
|
||||
const headingMapper = (category, label = 'All') => {
|
||||
const filters = {
|
||||
@@ -82,13 +80,12 @@ const getHeadings = (state) => {
|
||||
return headingMapper(type, assignment)(assignments);
|
||||
};
|
||||
|
||||
const composeFilters = (...predicates) => (percentGrade, options = {}) =>
|
||||
predicates.reduce((accum, predicate) => {
|
||||
if (predicate(percentGrade, options)) {
|
||||
return null;
|
||||
}
|
||||
return accum;
|
||||
}, percentGrade);
|
||||
const composeFilters = (...predicates) => (percentGrade, options = {}) => predicates.reduce((accum, predicate) => {
|
||||
if (predicate(percentGrade, options)) {
|
||||
return null;
|
||||
}
|
||||
return accum;
|
||||
}, percentGrade);
|
||||
|
||||
const percentGradeIsMax = percentGrade => (
|
||||
percentGrade === '100'
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
const compose = (...fns) => {
|
||||
const [firstFunc, ...rest] = fns.reverse();
|
||||
return (...args) =>
|
||||
rest.reduce((accum, fn) => fn(accum), firstFunc(...args));
|
||||
return (...args) => rest.reduce((accum, fn) => fn(accum), firstFunc(...args));
|
||||
};
|
||||
|
||||
const getTracks = state => state.tracks.results || [];
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import apiClient from '../apiClient';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { configuration } from '../../config';
|
||||
|
||||
class LmsApiService {
|
||||
static baseUrl = configuration.LMS_BASE_URL;
|
||||
|
||||
static pageSize = 25
|
||||
|
||||
static fetchGradebookData(courseId, searchText, cohort, track, options = {}) {
|
||||
@@ -42,7 +43,7 @@ class LmsApiService {
|
||||
|
||||
const gradebookUrl = `${LmsApiService.baseUrl}/api/grades/v1/gradebook/${courseId}/?${queryParamString}`;
|
||||
|
||||
return apiClient.get(gradebookUrl);
|
||||
return getAuthenticatedHttpClient().get(gradebookUrl);
|
||||
}
|
||||
|
||||
static updateGradebookData(courseId, updateData) {
|
||||
@@ -70,34 +71,34 @@ class LmsApiService {
|
||||
]
|
||||
*/
|
||||
const gradebookUrl = `${LmsApiService.baseUrl}/api/grades/v1/gradebook/${courseId}/bulk-update`;
|
||||
return apiClient.post(gradebookUrl, updateData);
|
||||
return getAuthenticatedHttpClient().post(gradebookUrl, updateData);
|
||||
}
|
||||
|
||||
static fetchTracks(courseId) {
|
||||
const trackUrl = `${LmsApiService.baseUrl}/api/enrollment/v1/course/${courseId}?include_expired=1`;
|
||||
return apiClient.get(trackUrl);
|
||||
return getAuthenticatedHttpClient().get(trackUrl);
|
||||
}
|
||||
|
||||
static fetchCohorts(courseId) {
|
||||
const cohortsUrl = `${LmsApiService.baseUrl}/courses/${courseId}/cohorts/`;
|
||||
return apiClient.get(cohortsUrl);
|
||||
return getAuthenticatedHttpClient().get(cohortsUrl);
|
||||
}
|
||||
|
||||
static fetchAssignmentTypes(courseId) {
|
||||
const assignmentTypesUrl = `${LmsApiService.baseUrl}/api/grades/v1/gradebook/${courseId}/grading-info?graded_only=true`;
|
||||
return apiClient.get(assignmentTypesUrl);
|
||||
return getAuthenticatedHttpClient().get(assignmentTypesUrl);
|
||||
}
|
||||
|
||||
static fetchUserRoles(courseId) {
|
||||
const rolesUrl = `${LmsApiService.baseUrl}/api/enrollment/v1/roles/?course_id=${encodeURIComponent(courseId)}`;
|
||||
return apiClient.get(rolesUrl);
|
||||
return getAuthenticatedHttpClient().get(rolesUrl);
|
||||
}
|
||||
|
||||
static getGradeExportCsvUrl(courseId, options = {}) {
|
||||
const queryParams = ['track', 'cohort', 'assignment', 'assignmentType', 'assignmentGradeMax',
|
||||
'assignmentGradeMin', 'courseGradeMin', 'courseGradeMax']
|
||||
.filter(opt => options[opt] &&
|
||||
options[opt] !== 'All')
|
||||
.filter(opt => options[opt]
|
||||
&& options[opt] !== 'All')
|
||||
.map(opt => `${opt}=${encodeURIComponent(options[opt])}`)
|
||||
.join('&');
|
||||
return `${LmsApiService.baseUrl}/api/bulk_grades/course/${courseId}/?${queryParams}`;
|
||||
@@ -106,8 +107,8 @@ class LmsApiService {
|
||||
static getInterventionExportCsvUrl(courseId, options = {}) {
|
||||
const queryParams = ['track', 'cohort', 'assignment', 'assignmentType', 'assignmentGradeMax',
|
||||
'assignmentGradeMin', 'courseGradeMin', 'courseGradeMax']
|
||||
.filter(opt => options[opt] &&
|
||||
options[opt] !== 'All')
|
||||
.filter(opt => options[opt]
|
||||
&& options[opt] !== 'All')
|
||||
.map(opt => `${opt}=${encodeURIComponent(options[opt])}`)
|
||||
.join('&');
|
||||
return `${LmsApiService.baseUrl}/api/bulk_grades/course/${courseId}/intervention?${queryParams}`;
|
||||
@@ -117,7 +118,7 @@ class LmsApiService {
|
||||
|
||||
static uploadGradeCsv(courseId, formData) {
|
||||
const fileUploadUrl = LmsApiService.getGradeImportCsvUrl(courseId);
|
||||
return apiClient.post(fileUploadUrl, formData).then((result) => {
|
||||
return getAuthenticatedHttpClient().post(fileUploadUrl, formData).then((result) => {
|
||||
if (result.status === 200 && !result.data.error_messages.length) {
|
||||
return result.data;
|
||||
}
|
||||
@@ -127,12 +128,12 @@ class LmsApiService {
|
||||
|
||||
static fetchGradeBulkOperationHistory(courseId) {
|
||||
const url = `${LmsApiService.baseUrl}/api/bulk_grades/course/${courseId}/history/`;
|
||||
return apiClient.get(url).then(response => response.data).catch(() => Promise.reject(Error('unhandled response error')));
|
||||
return getAuthenticatedHttpClient().get(url).then(response => response.data).catch(() => Promise.reject(Error('unhandled response error')));
|
||||
}
|
||||
|
||||
static fetchGradeOverrideHistory(subsectionId, userId) {
|
||||
const historyUrl = `${LmsApiService.baseUrl}/api/grades/v1/subsection/${subsectionId}/?user_id=${userId}&history_record_limit=5`;
|
||||
return apiClient.get(historyUrl);
|
||||
return getAuthenticatedHttpClient().get(historyUrl);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -90,7 +90,6 @@ const eventsMap = {
|
||||
|
||||
const segmentMiddleware = createMiddleware(eventsMap, Segment());
|
||||
|
||||
|
||||
const store = createStore(
|
||||
reducers,
|
||||
composeWithDevTools(applyMiddleware(thunkMiddleware, loggerMiddleware, segmentMiddleware)),
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import 'babel-polyfill';
|
||||
import 'core-js/stable';
|
||||
import 'regenerator-runtime/runtime';
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
|
||||
import { Provider } from 'react-redux';
|
||||
import SiteFooter from '@edx/frontend-component-footer';
|
||||
import {
|
||||
APP_READY,
|
||||
getConfig,
|
||||
initialize,
|
||||
subscribe,
|
||||
} from '@edx/frontend-platform';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
|
||||
import {
|
||||
@@ -14,11 +21,11 @@ import {
|
||||
} from '@fortawesome/free-brands-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
|
||||
import apiClient from './data/apiClient';
|
||||
import SiteFooter, { messages as footerMessages } from '@edx/frontend-component-footer';
|
||||
|
||||
import GradebookPage from './containers/GradebookPage';
|
||||
import Header from './components/Header';
|
||||
import store from './data/store';
|
||||
import FooterLogo from '../assets/edx-footer.png';
|
||||
import './App.scss';
|
||||
|
||||
const socialLinks = [
|
||||
@@ -49,19 +56,19 @@ const socialLinks = [
|
||||
];
|
||||
|
||||
const App = () => (
|
||||
<IntlProvider>
|
||||
<IntlProvider locale="en">
|
||||
<Provider store={store}>
|
||||
<Router>
|
||||
<div>
|
||||
<Header />
|
||||
<main>
|
||||
<Switch>
|
||||
<Route exact path="/:courseId" component={GradebookPage} />
|
||||
<Route exact path={getConfig().PUBLIC_PATH.concat(':courseId')} component={GradebookPage} />
|
||||
</Switch>
|
||||
</main>
|
||||
<SiteFooter
|
||||
siteName={process.env.SITE_NAME}
|
||||
siteLogo={FooterLogo}
|
||||
logo={process.env.LOGO_POWERED_BY_OPEN_EDX_URL_SVG}
|
||||
marketingSiteBaseUrl={process.env.MARKETING_SITE_BASE_URL}
|
||||
supportUrl={process.env.SUPPORT_URL}
|
||||
contactUrl={process.env.CONTACT_URL}
|
||||
@@ -86,9 +93,13 @@ const App = () => (
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
apiClient.ensurePublicOrAuthenticationAndCookies(
|
||||
window.location.pathname,
|
||||
() => {
|
||||
ReactDOM.render(<App />, document.getElementById('root'));
|
||||
},
|
||||
);
|
||||
subscribe(APP_READY, () => {
|
||||
ReactDOM.render(<App />, document.getElementById('root'));
|
||||
});
|
||||
|
||||
initialize({
|
||||
messages: [
|
||||
footerMessages,
|
||||
],
|
||||
requireAuthenticatedUser: true,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user