Compare commits

..

5 Commits

Author SHA1 Message Date
Kyle McCormick
f7a13d58f2 Fix logout URL 2020-04-09 12:34:22 -04:00
Kyle McCormick
6728242671 Temporary: Absolute /gradebook/ React route 2020-04-08 17:20:47 -04:00
Kyle McCormick
beb2c3cbae Make React routes relative 2020-04-08 17:17:47 -04:00
Kyle McCormick
973c6e9a0a Update config for devstack 2020-04-08 16:53:16 -04:00
Kyle McCormick
09cfee335f Add dev-build command 2020-04-08 16:37:28 -04:00
51 changed files with 19258 additions and 15809 deletions

17
.babelrc Executable file
View File

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

33
.env
View File

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

View File

@@ -1,35 +0,0 @@
NODE_ENV='development'
PORT=1994
BASE_URL='localhost:1994'
LMS_BASE_URL='http://localhost:18000'
LOGIN_URL='http://localhost:18000/login'
LOGOUT_URL='http://localhost:18000/login'
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 Executable file
View File

@@ -0,0 +1,27 @@
{
"extends": "eslint-config-edx",
"parser": "babel-eslint",
"rules": {
"import/no-extraneous-dependencies": [
"error",
{
"devDependencies": [
"config/*.js",
"**/*.test.jsx",
"**/*.test.js"
]
}
],
// https://github.com/evcohen/eslint-plugin-jsx-a11y/issues/340#issuecomment-338424908
"jsx-a11y/anchor-is-valid": [ "error", {
"components": [ "Link" ],
"specialLink": [ "to" ]
}],
// https://github.com/yannickcr/eslint-plugin-react/issues/1754#issuecomment-378838053
// tl;dr: this rule is no longer going to cause any user-facing visual weirdness, its original motivation
"react/no-did-mount-set-state": "off"
},
"env": {
"jest": true
}
}

View File

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

2
.gitignore vendored
View File

@@ -12,5 +12,3 @@ dist/
### Emacs ### ### Emacs ###
*~ *~
*.swo
*.swp

View File

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

16
config/webpack.common.config.js Executable file
View File

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

147
config/webpack.dev.config.js Executable file
View File

@@ -0,0 +1,147 @@
// This is the dev Webpack config. All settings here should prefer a fast build
// time at the expense of creating larger, unoptimized bundles.
const Merge = require('webpack-merge');
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const webpack = require('webpack');
const commonConfig = require('./webpack.common.config.js');
module.exports = Merge.smart(commonConfig, {
mode: 'development',
entry: [
// enable react's custom hot dev client so we get errors reported in the browser
require.resolve('react-dev-utils/webpackHotDevClient'),
path.resolve(__dirname, '../src/segment.js'),
path.resolve(__dirname, '../src/index.jsx'),
],
module: {
// Specify file-by-file rules to Webpack. Some file-types need a particular kind of loader.
rules: [
// The babel-loader transforms newer ES2015+ syntax to older ES5 for older browsers.
// Babel is configured with the .babelrc file at the root of the project.
{
test: /\.(js|jsx)$/,
include: [
path.resolve(__dirname, '../src'),
],
loader: 'babel-loader',
options: {
// Caches result of loader to the filesystem. Future builds will attempt to read from the
// cache to avoid needing to run the expensive recompilation process on each run.
cacheDirectory: true,
},
},
// We are not extracting CSS from the javascript bundles in development because extracting
// prevents hot-reloading from working, it increases build time, and we don't care about
// flash-of-unstyled-content issues in development.
{
test: /(.scss|.css)$/,
use: [
'style-loader', // creates style nodes from JS strings
{
loader: 'css-loader', // translates CSS into CommonJS
options: {
sourceMap: true,
},
},
{
loader: 'sass-loader', // compiles Sass to CSS
options: {
sourceMap: true,
includePaths: [
path.join(__dirname, '../node_modules'),
path.join(__dirname, '../src'),
],
},
},
],
},
// Webpack, by default, uses the url-loader for images and fonts that are required/included by
// files it processes, which just base64 encodes them and inlines them in the javascript
// bundles. This makes the javascript bundles ginormous and defeats caching so we will use the
// file-loader instead to copy the files directly to the output directory.
{
test: /\.(woff2?|ttf|svg|eot)(\?v=\d+\.\d+\.\d+)?$/,
loader: 'file-loader',
},
{
test: /\.(jpe?g|png|gif|ico)(\?v=\d+\.\d+\.\d+)?$/,
use: [
'file-loader',
{
loader: 'image-webpack-loader',
options: {
optimizationlevel: 7,
mozjpeg: {
progressive: true,
},
gifsicle: {
interlaced: false,
},
pngquant: {
quality: '65-90',
speed: 4,
},
},
},
],
},
],
},
// Specify additional processing or side-effects done on the Webpack output bundles as a whole.
plugins: [
// Generates an HTML file in the output directory.
new HtmlWebpackPlugin({
inject: true, // Appends script tags linking to the webpack bundles at the end of the body
template: path.resolve(__dirname, '../public/index.html'),
}),
new webpack.EnvironmentPlugin({
NODE_ENV: 'development',
BASE_URL: 'localhost:19000/gradebook',
LMS_BASE_URL: 'http://localhost:18000',
LOGIN_URL: 'http://localhost:18000/login',
LOGOUT_URL: 'http://localhost:18000/logout',
CSRF_TOKEN_API_PATH: '/csrf/api/v1/token',
REFRESH_ACCESS_TOKEN_ENDPOINT: 'http://localhost:18000/login_refresh',
DATA_API_BASE_URL: 'http://localhost:8000',
// LMS_CLIENT_ID should match the lms DOT client application id your LMS container
LMS_CLIENT_ID: 'login-service-client-id',
SEGMENT_KEY: null,
FEATURE_FLAGS: {},
ACCESS_TOKEN_COOKIE_NAME: 'edx-jwt-cookie-header-payload',
CSRF_COOKIE_NAME: 'csrftoken',
SITE_NAME: 'edX',
MARKETING_SITE_BASE_URL: 'http://localhost:18000',
SUPPORT_URL: 'http://localhost:18000/support',
CONTACT_URL: 'http://localhost:18000/contact',
OPEN_SOURCE_URL: 'http://localhost:18000/openedx',
TERMS_OF_SERVICE_URL: 'http://localhost:18000/terms-of-service',
PRIVACY_POLICY_URL: 'http://localhost:18000/privacy-policy',
FACEBOOK_URL: 'https://www.facebook.com',
TWITTER_URL: 'https://twitter.com',
YOU_TUBE_URL: 'https://www.youtube.com',
LINKED_IN_URL: 'https://www.linkedin.com',
REDDIT_URL: 'https://www.reddit.com',
APPLE_APP_STORE_URL: 'https://www.apple.com/ios/app-store/',
GOOGLE_PLAY_URL: 'https://play.google.com/store',
ENTERPRISE_MARKETING_URL: 'http://example.com',
ENTERPRISE_MARKETING_UTM_SOURCE: 'example.com',
ENTERPRISE_MARKETING_UTM_CAMPAIGN: 'example.com Referral',
ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM: 'Footer',
}),
// when the --hot option is not passed in as part of the command
// the HotModuleReplacementPlugin has to be specified in the Webpack configuration
// https://webpack.js.org/configuration/dev-server/#devserver-hot
new webpack.HotModuleReplacementPlugin(),
],
// This configures webpack-dev-server which serves bundles from memory and provides live
// reloading.
devServer: {
host: '0.0.0.0',
port: 1994,
historyApiFallback: true,
hot: true,
inline: true,
},
});

149
config/webpack.prod.config.js Executable file
View File

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

View File

@@ -2,12 +2,8 @@
# http://open-edx-proposals.readthedocs.io/en/latest/oeps/oep-0002.html#specification # http://open-edx-proposals.readthedocs.io/en/latest/oeps/oep-0002.html#specification
nick: grbk nick: grbk
tags: oeps: {}
- frontend-app owner: schenedx
- masters supporting_teams:
oeps: - masters-devs
oep-2: true # Repository metadata
openedx-release: {ref: master} openedx-release: {ref: master}
owner:
type: team
team: edx/masters-devs-gta

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,21 +1,21 @@
{ {
"name": "@edx/frontend-app-gradebook", "name": "@edx/frontend-app-gradebook",
"version": "1.4.14", "version": "0.1.0",
"description": "edx editable gradebook-ui to manipulate grade overrides on subsections", "description": "edx editable gradebook-ui to manipulate grade overrides on subsections",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/edx/frontend-app-gradebook.git" "url": "git+https://github.com/edx/frontend-app-gradebook.git"
}, },
"scripts": { "scripts": {
"build": "fedx-scripts webpack", "build": "NODE_ENV=production BABEL_ENV=production webpack --config=config/webpack.prod.config.js",
"dev-build": "NODE_ENV=development BABEL_ENV=development webpack --config=config/webpack.dev.config.js",
"coveralls": "cat ./coverage/lcov.info | coveralls", "coveralls": "cat ./coverage/lcov.info | coveralls",
"is-es5": "es-check es5 ./dist/*.js", "is-es5": "es-check es5 ./dist/*.js",
"lint": "fedx-scripts eslint --ext .jsx,.js src/", "lint": "eslint --ext .js --ext .jsx .",
"lint-fix": "fedx-scripts eslint --fix --ext .jsx,.js src/", "precommit": "npm run lint",
"prepush": "npm run lint",
"semantic-release": "semantic-release", "semantic-release": "semantic-release",
"start": "fedx-scripts webpack-dev-server --progress", "start": "NODE_ENV=development BABEL_ENV=development node_modules/.bin/webpack-dev-server --config=config/webpack.dev.config.js --progress",
"test": "TZ=GMT fedx-scripts jest --coverage --passWithNoTests", "test": "jest --coverage --passWithNoTests",
"watch-tests": "jest --watch", "watch-tests": "jest --watch",
"travis-deploy-once": "travis-deploy-once" "travis-deploy-once": "travis-deploy-once"
}, },
@@ -26,52 +26,96 @@
"access": "public" "access": "public"
}, },
"dependencies": { "dependencies": {
"@edx/frontend-component-footer": "10.0.11", "@edx/edx-bootstrap": "^0.4.3",
"@edx/frontend-platform": "1.6.1", "@edx/frontend-auth": "^4.0.0",
"@edx/paragon": "10.0.1", "@edx/frontend-component-footer": "^4.1.5",
"@edx/paragon": "^7.1.5",
"@fortawesome/fontawesome-svg-core": "^1.2.25", "@fortawesome/fontawesome-svg-core": "^1.2.25",
"@fortawesome/free-brands-svg-icons": "^5.11.2", "@fortawesome/free-brands-svg-icons": "^5.11.2",
"@fortawesome/free-solid-svg-icons": "^5.11.2", "@fortawesome/free-solid-svg-icons": "^5.11.2",
"@fortawesome/react-fontawesome": "^0.1.5", "@fortawesome/react-fontawesome": "^0.1.5",
"@redux-beacon/segment": "^1.0.0", "@redux-beacon/segment": "^1.0.0",
"babel-polyfill": "^6.26.0",
"classnames": "^2.2.6", "classnames": "^2.2.6",
"core-js": "3.6.5",
"email-prop-type": "^1.1.7", "email-prop-type": "^1.1.7",
"font-awesome": "4.7.0", "font-awesome": "^4.7.0",
"history": "4.10.1", "history": "^4.10.1",
"node-sass": "^4.14.1", "prop-types": "^15.7.2",
"prop-types": "15.7.2", "query-string": "^5.1.1",
"query-string": "6.13.0", "react": "^16.10.1",
"react": "16.13.1", "react-dom": "^16.10.1",
"react-dom": "16.13.1",
"react-intl": "^2.9.0", "react-intl": "^2.9.0",
"react-redux": "^5.1.1", "react-redux": "^5.1.1",
"react-router": "5.2.0", "react-router": "^4.3.1",
"react-router-dom": "5.2.0", "react-router-dom": "^4.3.1",
"react-router-redux": "^5.0.0-alpha.9", "react-router-redux": "^5.0.0-alpha.9",
"redux": "4.0.5", "redux": "^3.7.2",
"redux-beacon": "^2.1.0", "redux-beacon": "^2.1.0",
"redux-devtools-extension": "2.13.8", "redux-devtools-extension": "^2.13.8",
"redux-logger": "3.0.6", "redux-logger": "^3.0.6",
"redux-thunk": "2.3.0", "redux-thunk": "^2.3.0",
"regenerator-runtime": "^0.13.7",
"whatwg-fetch": "^2.0.4" "whatwg-fetch": "^2.0.4"
}, },
"devDependencies": { "devDependencies": {
"@edx/frontend-build": "5.3.2", "autoprefixer": "^9.6.1",
"axios": "0.19.2",
"axios-mock-adapter": "^1.17.0", "axios-mock-adapter": "^1.17.0",
"babel-cli": "^6.26.0",
"babel-eslint": "^8.2.6",
"babel-jest": "^22.4.4",
"babel-loader": "^7.1.5",
"babel-plugin-transform-class-properties": "^6.24.1",
"babel-plugin-transform-object-rest-spread": "^6.26.0",
"babel-preset-env": "^1.7.0",
"babel-preset-react": "^6.24.1",
"codecov": "^3.6.1", "codecov": "^3.6.1",
"css-loader": "^0.28.11",
"enzyme": "^3.10.0", "enzyme": "^3.10.0",
"enzyme-adapter-react-16": "^1.14.0", "enzyme-adapter-react-16": "^1.14.0",
"es-check": "^2.3.0", "es-check": "^2.3.0",
"eslint-config-edx": "^4.0.4",
"fetch-mock": "^6.5.2", "fetch-mock": "^6.5.2",
"husky": "2.7.0", "file-loader": "^1.1.9",
"html-webpack-harddisk-plugin": "^0.2.0",
"html-webpack-plugin": "^3.2.0",
"husky": "^0.14.3",
"identity-obj-proxy": "^3.0.0", "identity-obj-proxy": "^3.0.0",
"image-webpack-loader": "^4.2.0",
"jest": "^22.4.4",
"mini-css-extract-plugin": "^0.4.0",
"node-sass": "^4.12.0",
"postcss-loader": "^3.0.0",
"react-dev-utils": "^5.0.3", "react-dev-utils": "^5.0.3",
"react-test-renderer": "^16.10.1", "react-test-renderer": "^16.10.1",
"redux-mock-store": "^1.5.3", "redux-mock-store": "^1.5.3",
"sass-loader": "^6.0.6",
"semantic-release": "^15.13.24", "semantic-release": "^15.13.24",
"travis-deploy-once": "^5.0.11" "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"
} }
} }

View File

@@ -6,7 +6,7 @@ $fa-font-path: "~font-awesome/fonts";
$input-focus-box-shadow: $input-box-shadow; // hack to get upgrade to paragon 4.0.0 to work $input-focus-box-shadow: $input-box-shadow; // hack to get upgrade to paragon 4.0.0 to work
@import "~@edx/frontend-component-footer/dist/_footer"; @import "~@edx/frontend-component-footer/src/lib/scss/site-footer";
@import "./components/Gradebook/gradebook"; @import "./components/Gradebook/gradebook";
@import "./components/Drawer/Drawer"; @import "./components/Drawer/Drawer";

View File

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

View File

@@ -1,3 +1,4 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import React from 'react'; import React from 'react';
@@ -26,43 +27,27 @@ function RangeFilterBadge({
filterValue2, filterValue2,
handleBadgeClose, handleBadgeClose,
}) { }) {
return ((filterValue1 !== initialFilters[filterName1]) return ((filterValue1 !== initialFilters[filterName1]) ||
|| (filterValue2 !== initialFilters[filterName2])) (filterValue2 !== initialFilters[filterName2]))
&& ( &&
<FilterBadge <FilterBadge
name={displayName} name={displayName}
value={`${filterValue1} - ${filterValue2}`} value={`${filterValue1} - ${filterValue2}`}
onClick={handleBadgeClose} onClick={handleBadgeClose}
/> />;
);
} }
RangeFilterBadge.propTypes = {
displayName: PropTypes.string.isRequired,
filterName1: PropTypes.string.isRequired,
filterValue1: PropTypes.string.isRequired,
filterName2: PropTypes.string.isRequired,
filterValue2: PropTypes.string.isRequired,
handleBadgeClose: PropTypes.func.isRequired,
};
function SingleValueFilterBadge({ function SingleValueFilterBadge({
displayName, filterName, filterValue, handleBadgeClose, displayName, filterName, filterValue, handleBadgeClose,
}) { }) {
return (filterValue !== initialFilters[filterName]) return (filterValue !== initialFilters[filterName]) &&
&& (
<FilterBadge <FilterBadge
name={displayName} name={displayName}
value={filterValue} value={filterValue}
onClick={handleBadgeClose} onClick={handleBadgeClose}
/> />;
);
} }
SingleValueFilterBadge.propTypes = {
displayName: PropTypes.string.isRequired,
filterName: PropTypes.string.isRequired,
filterValue: PropTypes.string.isRequired,
handleBadgeClose: PropTypes.func.isRequired,
};
function FilterBadges({ function FilterBadges({
assignment, assignment,
@@ -165,3 +150,4 @@ FilterBadges.propTypes = {
courseGradeMax: PropTypes.string, courseGradeMax: PropTypes.string,
handleFilterBadgeClose: PropTypes.func.isRequired, handleFilterBadgeClose: PropTypes.func.isRequired,
}; };

View File

@@ -1,200 +0,0 @@
/* eslint-disable react/sort-comp, react/button-has-type */
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import {
Button,
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" open className="filter-group mb-3">
<div>
<div className="student-filters">
<span className="label">
Assignment Types:
</span>
<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">
<span className="label">
Assignment:
</span>
<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>
<p>Grade Range (0% - 100%)</p>
<form className="d-fnlex justify-content-between align-items-center" onSubmit={this.handleSubmitAssignmentGrade}>
<InputText
label="Min Grade"
name="assignmentGradeMin"
type="number"
min={0}
max={100}
step={1}
value={this.props.assignmentGradeMin}
disabled={!this.props.selectedAssignment}
onChange={this.props.setAssignmentGradeMin}
/>
<span className="input-percent-label">%</span>
<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>
<Button
type="submit"
className="btn-outline-secondary"
name="assignmentGradeMinMax"
disabled={!this.props.selectedAssignment}
>
Apply
</Button>
</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);

View File

@@ -1,200 +0,0 @@
/* eslint-disable react/sort-comp, react/button-has-type */
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import {
Button,
StatusAlert,
Table,
Tab,
} 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 (
<Tab eventKey="bulk_management" title="Bulk Management">
<h4>Use this feature by downloading a CSV for bulk management,
overriding grades locally, and coming back here to upload.
</h4>
<form ref={this.fileFormRef} action={this.props.gradeExportUrl} method="post">
<StatusAlert
alertType="danger"
dialog={this.props.bulkImportError}
open={this.props.bulkImportError}
dismissible={false}
/>
<StatusAlert
alertType="success"
dialog="CSV processing. File uploads may take several minutes to complete"
open={this.props.uploadSuccess}
dismissible={false}
/>
<input
className="d-none"
type="file"
name="csv"
label="Upload Grade CSV"
onChange={this.handleFileInputChange}
ref={this.fileInputRef}
/>
</form>
<Button
variant="primary"
onClick={this.handleClickImportGrades}
>
Import Grades
</Button>
<p>
Results appear in the table below.<br />
Grade processing may take a few seconds.
</p>
<Table
data={this.props.bulkManagementHistory.map(this.formatHistoryRow)}
hasFixedColumnWidths
columns={[
{
key: 'filename',
label: 'Gradebook',
columnSortable: false,
width: 'col-5',
},
{
key: 'resultsSummary',
label: 'Download Summary',
columnSortable: false,
width: 'col',
},
{
key: 'user',
label: 'Who',
columnSortable: false,
width: 'col-1',
},
{
key: 'timeUploaded',
label: 'When',
columnSortable: false,
width: 'col',
},
]}
className="table-striped"
/>
</Tab>
);
}
}
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);

View File

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

View File

@@ -1,203 +0,0 @@
/* eslint-disable react/sort-comp, react/button-has-type */
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import {
Button,
Modal,
StatusAlert,
Table,
} from '@edx/paragon';
import {
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);

View File

@@ -1,203 +0,0 @@
/* eslint-disable react/sort-comp, react/button-has-type */
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { Table } 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);

View File

@@ -57,17 +57,9 @@
.grade-history-current-grade{ .grade-history-current-grade{
padding-right: 25px; padding-right: 25px;
} }
.gradebook-container {
width: 100%;
overflow-x: auto;
height: 600px;
overflow-y: auto;
word-break: break-word;
position: relative;
}
.gbook { .gbook {
width: 100%; overflow-x: scroll;
.grade-button { .grade-button {
text-decoration: underline; text-decoration: underline;
@@ -77,24 +69,18 @@
font-size: 14px; font-size: 14px;
} }
.table {
.table thead tr { padding-left: 244px;
min-height: 60px; // prevents the table from shrinking to a width where "Final 01" breaks to two lines
&:nth-child(1) { min-width: 731px;
position: sticky; th {
top: 0; vertical-align: top;
z-index: 10; font-size: 14px;
background-color: white;
th {
background-color: white;
border-bottom: 1px solid $gray_200;
}
} }
} }
thead, tbody, tr, td, th { .table thead tr {
display: block; height: 60px;
word-break: break-word;
} }
.table tr th:first-child { .table tr th:first-child {
@@ -103,47 +89,16 @@
.table tr th:first-child, .table tr th:first-child,
.table tr td:first-child { .table tr td:first-child {
position: sticky; position: sticky;
width: 160px;
left: 0; left: 0;
z-index: 1; // to float over the following children in the side-scrolling case z-index: 1; // to float over the following children in the side-scrolling case
background: white; background: white;
} }
.table tr {
th:nth-child(1),
td:nth-child(1),
th:nth-child(2),
td:nth-child(2) {
min-width: 240px;
}
th, td {
min-width: 120px;
}
}
.table tbody th { .table tbody th {
font-weight: normal; font-weight: normal;
} }
.table {
overflow-x: hidden;
height: 100%;
tbody {
overflow-y: auto;
display: block;
}
thead, tbody tr {
display: table;
table-layout: fixed;
}
th {
vertical-align: top;
font-size: 14px;
}
}
.link-style { .link-style {
color: #0075b4; color: #0075b4;
&:hover, &:focus { &:hover, &:focus {

View File

@@ -1,4 +1,3 @@
/* eslint-disable react/sort-comp, react/button-has-type, import/no-named-as-default */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { import {
@@ -7,46 +6,48 @@ import {
Icon, Icon,
InputSelect, InputSelect,
InputText, InputText,
Modal,
SearchField, SearchField,
StatefulButton,
StatusAlert, StatusAlert,
Tab, Table,
Tabs, Tabs,
} from '@edx/paragon'; } from '@edx/paragon';
import queryString from 'query-string'; import queryString from 'query-string';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faFilter } from '@fortawesome/free-solid-svg-icons'; import { faDownload, faSpinner, faFilter } from '@fortawesome/free-solid-svg-icons';
import { configuration } from '../../config'; import { configuration } from '../../config';
import PageButtons from '../PageButtons'; import PageButtons from '../PageButtons';
import Drawer from '../Drawer'; import Drawer from '../Drawer';
import { formatDateForDisplay } from '../../data/actions/utils';
import initialFilters from '../../data/constants/filters'; import initialFilters from '../../data/constants/filters';
import ConnectedFilterBadges from '../FilterBadges'; import ConnectedFilterBadges from '../FilterBadges';
import Assignments from './Assignments';
import BulkManagement from './BulkManagement'; const DECIMAL_PRECISION = 2;
import BulkManagementControls from './BulkManagementControls'; const GRADE_OVERRIDE_HISTORY_COLUMNS = [{ label: 'Date', key: 'date' }, { label: 'Grader', key: 'grader' },
import EditModal from './EditModal'; { label: 'Reason', key: 'reason' },
import GradebookTable from './GradebookTable'; { label: 'Adjusted grade', key: 'adjustedGrade' }];
export default class Gradebook extends React.Component { export default class Gradebook extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
adjustedGradePossible: '', filterValue: '',
adjustedGradeValue: 0,
assignmentGradeMin: '0',
assignmentGradeMax: '100',
assignmentName: '',
courseGradeMin: '0', courseGradeMin: '0',
courseGradeMax: '100', courseGradeMax: '100',
filterValue: '',
isMinCourseGradeFilterValid: true,
isMaxCourseGradeFilterValid: true,
modalOpen: false, modalOpen: false,
reasonForChange: '', adjustedGradeValue: 0,
todaysDate: '',
updateModuleId: null, updateModuleId: null,
updateUserId: null, updateUserId: null,
reasonForChange: '',
assignmentGradeMin: '0',
assignmentGradeMax: '100',
isMinCourseGradeFilterValid: true,
isMaxCourseGradeFilterValid: true,
}; };
this.fileFormRef = React.createRef();
this.fileInputRef = React.createRef();
this.myRef = React.createRef(); this.myRef = React.createRef();
} }
@@ -54,6 +55,7 @@ export default class Gradebook extends React.Component {
const urlQuery = queryString.parse(this.props.location.search); const urlQuery = queryString.parse(this.props.location.search);
this.props.initializeFilters(urlQuery); this.props.initializeFilters(urlQuery);
this.props.getRoles(this.props.courseId); this.props.getRoles(this.props.courseId);
this.overrideReasonInput.focus();
const newStateFields = {}; const newStateFields = {};
['assignmentGradeMin', 'assignmentGradeMax', 'courseGradeMin', 'courseGradeMax'].forEach((attr) => { ['assignmentGradeMin', 'assignmentGradeMax', 'courseGradeMin', 'courseGradeMax'].forEach((attr) => {
@@ -69,6 +71,37 @@ export default class Gradebook extends React.Component {
this.setState({ [e.target.name]: e.target.value }); this.setState({ [e.target.name]: e.target.value });
} }
setNewModalState = (userEntry, subsection) => {
this.props.fetchGradeOverrideHistory(
subsection.module_id,
userEntry.user_id,
);
let adjustedGradePossible = '';
if (subsection.attempted) {
adjustedGradePossible = subsection.score_possible;
}
this.setState({
modalAssignmentName: `${subsection.subsection_name}`,
modalOpen: true,
updateModuleId: subsection.module_id,
updateUserId: userEntry.user_id,
updateUserName: userEntry.username,
todaysDate: formatDateForDisplay(new Date()),
adjustedGradePossible,
reasonForChange: '',
adjustedGradeValue: '',
});
}
getLearnerInformation = entry => (
<div>
<div>{entry.username}</div>
{entry.external_user_key && <div className="student-key">{entry.external_user_key}</div>}
</div>
)
getActiveTabs = () => { getActiveTabs = () => {
if (this.props.showBulkManagement) { if (this.props.showBulkManagement) {
return ['Grades', 'Bulk Management']; return ['Grades', 'Bulk Management'];
@@ -76,6 +109,17 @@ export default class Gradebook extends React.Component {
return ['Grades']; return ['Grades'];
}; };
getAssignmentFilterOptions = () => [
{ label: 'All', value: '' },
...this.props.assignmentFilterOptions.map((assignment) => {
const { label, subsectionLabel } = assignment;
return {
label: `${label}: ${subsectionLabel}`,
value: label,
};
}),
];
getCourseGradeFilterAlertDialog = () => { getCourseGradeFilterAlertDialog = () => {
let dialog = ''; let dialog = '';
@@ -88,6 +132,53 @@ export default class Gradebook extends React.Component {
return dialog; return dialog;
}; };
handleAdjustedGradeClick = () => {
this.props.updateGrades(
this.props.courseId, [
{
user_id: this.state.updateUserId,
usage_id: this.state.updateModuleId,
grade: {
earned_graded_override: this.state.adjustedGradeValue,
comment: this.state.reasonForChange,
},
},
],
this.state.filterValue,
this.props.selectedCohort,
this.props.selectedTrack,
);
this.closeAssignmentModal();
}
closeAssignmentModal = () => {
this.props.doneViewingAssignment();
this.setState({
adjustedGradePossible: '',
adjustedGradeValue: '',
modalOpen: false,
reasonForChange: '',
updateModuleId: null,
updateUserId: null,
});
};
handleAssignmentFilterChange = (assignment) => {
const selectedFilterOption = this.props.assignmentFilterOptions.find(assig =>
assig.label === assignment);
const { type, id } = selectedFilterOption || {};
const typedValue = { label: assignment, type, id };
this.props.updateAssignmentFilter(typedValue);
this.updateQueryParams({ assignment: id });
this.props.updateGradesIfAssignmentGradeFiltersSet(
this.props.courseId,
this.props.selectedCohort,
this.props.selectedTrack,
this.props.selectedAssignmentType,
);
};
updateQueryParams = (queryParams) => { updateQueryParams = (queryParams) => {
const parsed = queryString.parse(this.props.location.search); const parsed = queryString.parse(this.props.location.search);
Object.keys(queryParams).forEach((key) => { Object.keys(queryParams).forEach((key) => {
@@ -100,6 +191,15 @@ export default class Gradebook extends React.Component {
this.props.history.push(`?${queryString.stringify(parsed)}`); this.props.history.push(`?${queryString.stringify(parsed)}`);
}; };
mapAssignmentTypeEntries = (entries) => {
const mapped = entries.map(entry => ({
id: entry,
label: entry,
}));
mapped.unshift({ id: 0, label: 'All', value: '' });
return mapped;
};
mapCohortsEntries = (entries) => { mapCohortsEntries = (entries) => {
const mapped = entries.map(entry => ({ const mapped = entries.map(entry => ({
id: entry.id, id: entry.id,
@@ -118,6 +218,58 @@ export default class Gradebook extends React.Component {
return mapped; 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) => { updateTracks = (event) => {
const selectedTrackItem = this.props.tracks.find(x => x.name === event); const selectedTrackItem = this.props.tracks.find(x => x.name === event);
let selectedTrackSlug = null; let selectedTrackSlug = null;
@@ -148,6 +300,60 @@ export default class Gradebook extends React.Component {
this.updateQueryParams({ cohort: selectedCohortId }); 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) => { mapSelectedCohortEntry = (entry) => {
const selectedCohortEntry = this.props.cohorts.find(x => x.id === parseInt(entry, 10)); const selectedCohortEntry = this.props.cohorts.find(x => x.id === parseInt(entry, 10));
if (selectedCohortEntry) { if (selectedCohortEntry) {
@@ -164,8 +370,104 @@ export default class Gradebook extends React.Component {
return 'Tracks'; return 'Tracks';
}; };
roundGrade = percent => parseFloat((percent || 0).toFixed(DECIMAL_PRECISION));
formatter = {
percent: (entries, areGradesFrozen) => entries.map((entry) => {
const learnerInformation = this.getLearnerInformation(entry);
const results = {
Username: (
<div><span className="wrap-text-in-cell">{learnerInformation}</span></div>
),
Email: (
<span className="wrap-text-in-cell">{entry.email}</span>
),
};
const assignments = entry.section_breakdown
.reduce((acc, subsection) => {
if (areGradesFrozen) {
acc[subsection.label] = `${this.roundGrade(subsection.percent * 100)} %`;
} else {
acc[subsection.label] = (
<button
className="btn btn-header link-style grade-button"
onClick={() => this.setNewModalState(entry, subsection)}
>
{this.roundGrade(subsection.percent * 100)}%
</button>);
}
return acc;
}, {});
const totals = { Total: `${this.roundGrade(entry.percent * 100)}%` };
return Object.assign(results, assignments, totals);
}),
absolute: (entries, areGradesFrozen) => entries.map((entry) => {
const learnerInformation = this.getLearnerInformation(entry);
const results = {
Username: (
<div><span className="wrap-text-in-cell">{learnerInformation}</span></div>
),
Email: (
<span className="wrap-text-in-cell">{entry.email}</span>
),
};
const assignments = entry.section_breakdown
.reduce((acc, subsection) => {
const scoreEarned = this.roundGrade(subsection.score_earned);
const scorePossible = this.roundGrade(subsection.score_possible);
let label = `${scoreEarned}`;
if (subsection.attempted) {
label = `${scoreEarned}/${scorePossible}`;
}
if (areGradesFrozen) {
acc[subsection.label] = label;
} else {
acc[subsection.label] = (
<button
className="btn btn-header link-style"
onClick={() => this.setNewModalState(entry, subsection)}
>
{label}
</button>
);
}
return acc;
}, {});
const totals = { Total: `${this.roundGrade(entry.percent * 100)}/100` };
return Object.assign(results, assignments, totals);
}),
};
lmsInstructorDashboardUrl = courseId => `${configuration.LMS_BASE_URL}/courses/${courseId}/instructor`; lmsInstructorDashboardUrl = courseId => `${configuration.LMS_BASE_URL}/courses/${courseId}/instructor`;
formatHeadings = () => {
let headings = [...this.props.headings];
if (headings.length > 0) {
const userInformationHeadingLabel = (
<div>
<div>Username</div>
<div className="font-weight-normal student-key">Student Key*</div>
</div>
);
const emailHeadingLabel = 'Email*';
headings = headings.map(heading => ({ label: heading, key: heading, width: 'col' }));
// replace username heading label to include additional user data
headings[0].label = userInformationHeadingLabel;
headings[0].width = 'col-2';
headings[1].label = emailHeadingLabel;
headings[1].width = 'col-2';
}
return headings;
}
handleCourseGradeFilterChange = (type, value) => { handleCourseGradeFilterChange = (type, value) => {
const filterValue = value; const filterValue = value;
@@ -238,29 +540,6 @@ export default class Gradebook extends React.Component {
); );
} }
createStateFieldSetter = (key) => (value) => this.setState({ [key]: value });
createStateFieldOnChange = (key) => ({ target }) => this.setState({ [key]: target.value });
createLimitedSetter = (...keys) => (values) => this.setState(
keys.reduce(
(obj, key) => (values[key] === undefined ? obj : { ...obj, [key]: values[key] }),
{},
),
)
safeSetState = this.createLimitedSetter(
'adjustedGradePossible',
'adjustedGradeValue',
'assignmnentName',
'modalOpen',
'reasonForChange',
'todaysDate',
'updateModuleId',
'updateUserId',
'updateUserName',
);
render() { render() {
return ( return (
<Drawer <Drawer
@@ -270,45 +549,47 @@ export default class Gradebook extends React.Component {
href={this.lmsInstructorDashboardUrl(this.props.courseId)} href={this.lmsInstructorDashboardUrl(this.props.courseId)}
className="mb-3" className="mb-3"
> >
<span aria-hidden="true">{'<< '}</span> Back to Dashboard <span aria-hidden="true">{'<< '}</span> {'Back to Dashboard'}
</a> </a>
<h1>Gradebook</h1> <h1>Gradebook</h1>
<h3> {this.props.courseId}</h3> <h3> {this.props.courseId}</h3>
{this.props.areGradesFrozen {this.props.areGradesFrozen &&
&& ( <div className="alert alert-warning" role="alert" >
<div className="alert alert-warning" role="alert">
The grades for this course are now frozen. Editing of grades is no longer allowed. The grades for this course are now frozen. Editing of grades is no longer allowed.
</div> </div>
)} }
{(this.props.canUserViewGradebook === false) {(this.props.canUserViewGradebook === false) &&
&& ( <div className="alert alert-warning" role="alert" >
<div className="alert alert-warning" role="alert">
You are not authorized to view the gradebook for this course. You are not authorized to view the gradebook for this course.
</div> </div>
)} }
<Tabs defaultActiveKey="grades"> <Tabs labels={this.getActiveTabs()}>
<Tab eventKey="grades" title="Grades"> <div>
<h4>Step 1: Filter the Grade Report</h4> <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>} {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> <Button className="btn-primary align-self-start" onClick={toggleFilterDrawer}><FontAwesomeIcon icon={faFilter} /> Edit Filters</Button>
<div> <div>
<SearchField <SearchField
onSubmit={value => this.props.searchForUser( onSubmit={value =>
this.props.courseId, this.props.searchForUser(
value, this.props.courseId,
this.props.selectedCohort, value,
this.props.selectedTrack, this.props.selectedCohort,
this.props.selectedAssignmentType, this.props.selectedTrack,
)} this.props.selectedAssignmentType,
)
}
inputLabel="Search for a learner" inputLabel="Search for a learner"
onChange={filterValue => this.setState({ filterValue })} onChange={filterValue => this.setState({ filterValue })}
onClear={() => this.props.getUserGrades( onClear={() =>
this.props.courseId, this.props.getUserGrades(
this.props.selectedCohort, this.props.courseId,
this.props.selectedTrack, this.props.selectedCohort,
this.props.selectedAssignmentType, this.props.selectedTrack,
)} this.props.selectedAssignmentType,
)
}
value={this.state.filterValue} value={this.state.filterValue}
/> />
<small className="form-text text-muted search-help-text">Search by username, email, or student key</small> <small className="form-text text-muted search-help-text">Search by username, email, or student key</small>
@@ -328,22 +609,21 @@ export default class Gradebook extends React.Component {
dialog={this.getCourseGradeFilterAlertDialog()} dialog={this.getCourseGradeFilterAlertDialog()}
dismissible={false} dismissible={false}
open={ open={
!this.state.isMinCourseGradeFilterValid !this.state.isMinCourseGradeFilterValid ||
|| !this.state.isMaxCourseGradeFilterValid !this.state.isMaxCourseGradeFilterValid
} }
/> />
<h4>Step 2: View or Modify Individual Grades</h4> <h4>Step 2: View or Modify Individual Grades</h4>
{this.props.totalUsersCount {this.props.totalUsersCount ?
? ( <div>
<div> Showing
Showing <span className="font-weight-bold"> {this.props.filteredUsersCount} </span>
<span className="font-weight-bold"> {this.props.filteredUsersCount} </span> of
of <span className="font-weight-bold"> {this.props.totalUsersCount} </span>
<span className="font-weight-bold"> {this.props.totalUsersCount} </span> total learners
total learners </div> :
</div> null
) }
: null}
<div className="d-flex justify-content-between align-items-center mb-2"> <div className="d-flex justify-content-between align-items-center mb-2">
<InputSelect <InputSelect
label="Score View:" label="Score View:"
@@ -353,62 +633,263 @@ export default class Gradebook extends React.Component {
onChange={this.props.toggleFormat} onChange={this.props.toggleFormat}
/> />
{this.props.showBulkManagement && ( {this.props.showBulkManagement && (
<BulkManagementControls <div>
courseId={this.props.courseId} <StatefulButton
gradeExportUrl={this.props.gradeExportUrl} buttonType="outline-primary"
interventionExportUrl={this.props.interventionExportUrl} onClick={this.handleClickExportGrades}
showSpinner={this.props.showSpinner} state={this.props.showSpinner ? 'pending' : 'default'}
/> labels={{
default: 'Bulk Management',
pending: 'Bulk Management',
}}
icons={{
default: <FontAwesomeIcon className="mr-2" icon={faDownload} />,
pending: <FontAwesomeIcon className="fa-spin mr-2" icon={faSpinner} />,
}}
disabledStates={['pending']}
/>
<StatefulButton
buttonType="outline-primary"
onClick={this.handleClickDownloadInterventions}
state={this.props.showSpinner ? 'pending' : 'default'}
className="ml-2"
labels={{
default: 'Interventions*',
pending: 'Interventions*',
}}
icons={{
default: <FontAwesomeIcon className="mr-2" icon={faDownload} />,
pending: <FontAwesomeIcon className="fa-spin mr-2" icon={faSpinner} />,
}}
disabledStates={['pending']}
/>
</div>
)} )}
</div> </div>
<GradebookTable setGradebookState={this.safeSetState} /> <div className="gradebook-container">
<div className="gbook">
<Table
columns={this.formatHeadings()}
data={this.formatter[this.props.format](
this.props.grades,
this.props.areGradesFrozen,
)}
rowHeaderColumnKey="username"
hasFixedColumnWidths
/>
</div>
</div>
{PageButtons(this.props)} {PageButtons(this.props)}
<p>* available for learners in the Master&apos;s track only</p> <p>* available for learners in the Master&apos;s track only</p>
<EditModal <Modal
assignmentName={this.state.assignmentName}
adjustedGradeValue={this.state.adjustedGradeValue}
adjustedGradePossible={this.state.adjustedGradePossible}
courseId={this.props.courseId}
filterValue={this.state.filterValue}
onChange={this.onChange}
open={this.state.modalOpen} open={this.state.modalOpen}
reasonForChange={this.state.reasonForChange} title="Edit Grades"
setAdjustedGradeValue={this.createStateFieldOnChange('adjustedGradeValue')} closeText="Cancel"
setGradebookState={this.safeSetState} body={(
setReasonForChange={this.createStateFieldOnChange('reasonForChange')} <div>
todaysDate={this.state.todaysDate} <div>
updateModuleId={this.state.updateModuleId} <div className="grade-history-header grade-history-assignment">Assignment: </div> <div>{this.state.modalAssignmentName}</div>
updateUserId={this.state.updateUserId} <div className="grade-history-header grade-history-student">Student: </div> <div>{this.state.updateUserName}</div>
updateUserName={this.state.updateUserName} <div className="grade-history-header grade-history-original-grade">Original Grade: </div> <div>{this.props.gradeOriginalEarnedGraded}</div>
/> <div className="grade-history-header grade-history-current-grade">Current Grade: </div> <div>{this.props.gradeOverrideCurrentEarnedGradedOverride}</div>
</div>
<StatusAlert
alertType="danger"
dialog="Error retrieving grade override history."
open={this.props.errorFetchingGradeOverrideHistory}
dismissible={false}
/>
{!this.props.errorFetchingGradeOverrideHistory && (
<Table
columns={GRADE_OVERRIDE_HISTORY_COLUMNS}
data={[...this.props.gradeOverrides, {
date: this.state.todaysDate,
reason: (<input
type="text"
name="reasonForChange"
value={this.state.reasonForChange}
onChange={value => this.onChange(value)}
ref={(input) => { this.overrideReasonInput = input; }}
/>),
adjustedGrade: (
<span>
<input
type="text"
name="adjustedGradeValue"
value={this.state.adjustedGradeValue}
onChange={value => this.onChange(value)}
/>
{(this.state.adjustedGradePossible
|| this.props.gradeOriginalPossibleGraded)
&& ' / '}
{this.state.adjustedGradePossible
|| this.props.gradeOriginalPossibleGraded}
</span>),
}]}
/>)}
</Tab> <div>Showing most recent actions (max 5). To see more, please contact
{this.props.showBulkManagement support.
&& ( </div>
<BulkManagement <div>Note: Once you save, your changes will be visible to students.</div>
courseId={this.props.courseId} </div>
gradeExportUrl={this.props.gradeExportUrl} )}
buttons={[
<Button
buttonType="primary"
onClick={this.handleAdjustedGradeClick}
>
Save Grade
</Button>,
]}
onClose={this.closeAssignmentModal}
/> />
)} </div>
{this.props.showBulkManagement && (
<div>
<h4>Use this feature by downloading a CSV for bulk management,
overriding grades locally, and coming back here to upload.
</h4>
<form ref={this.fileFormRef} action={this.props.gradeExportUrl} method="post">
<StatusAlert
alertType="danger"
dialog={this.props.bulkImportError}
open={this.props.bulkImportError}
dismissible={false}
/>
<StatusAlert
alertType="success"
dialog="CSV processing. File uploads may take several minutes to complete"
open={this.props.uploadSuccess}
dismissible={false}
/>
<input
className="d-none"
type="file"
name="csv"
label="Upload Grade CSV"
onChange={this.handleFileInputChange}
ref={this.fileInputRef}
/>
</form>
<Button
buttonType="primary"
onClick={this.handleClickImportGrades}
>
Import Grades
</Button>
<p>
Results appear in the table below.<br />
Grade processing may take a few seconds.
</p>
<Table
data={this.props.bulkManagementHistory.map(this.formatHistoryRow)}
hasFixedColumnWidths
columns={[
{
key: 'filename',
label: 'Gradebook',
columnSortable: false,
width: 'col-5',
},
{
key: 'resultsSummary',
label: 'Download Summary',
columnSortable: false,
width: 'col',
},
{
key: 'user',
label: 'Who',
columnSortable: false,
width: 'col-1',
},
{
key: 'timeUploaded',
label: 'When',
columnSortable: false,
width: 'col',
},
]}
className="table-striped"
/>
</div>)}
</Tabs> </Tabs>
</div> </div>
)} )}
initiallyOpen={false} initiallyOpen={false}
title={( title={
<> <React.Fragment>
<FontAwesomeIcon icon={faFilter} /> Filter By... <FontAwesomeIcon icon={faFilter} /> Filter By...
</> </React.Fragment>
)} }
> >
<Assignments <Collapsible title="Assignments" isOpen className="filter-group mb-3">
assignmentGradeMin={this.state.assignmentGradeMin} <div>
assignmentGradeMax={this.state.assignmentGradeMax} <div className="student-filters">
courseId={this.props.courseId} <span className="label">
setAssignmentGradeMin={this.createStateFieldSetter('assignmentGradeMin')} Assignment Types:
setAssignmentGradeMax={this.createStateFieldSetter('assignmentGradeMax')} </span>
updateQueryParams={this.updateQueryParams} <InputSelect
/> name="assignment-types"
<Collapsible title="Overall Grade" open className="filter-group mb-3"> aria-label="Assignment Types"
value={this.props.selectedAssignmentType}
options={this.mapAssignmentTypeEntries(this.props.assignmentTypes)}
onChange={this.updateAssignmentTypes}
disabled={this.props.assignmentFilterOptions.length === 0}
/>
</div>
<div className="student-filters">
<span className="label">
Assignment:
</span>
<InputSelect
name="assignment"
aria-label="Assignment"
value={this.props.selectedAssignment}
options={this.getAssignmentFilterOptions()}
onChange={this.handleAssignmentFilterChange}
disabled={this.props.assignmentFilterOptions.length === 0}
/>
</div>
<p>Grade Range (0% - 100%)</p>
<form className="d-flex justify-content-between align-items-center" onSubmit={this.handleSubmitAssignmentGrade}>
<InputText
label="Min Grade"
name="assignmentGradeMin"
type="number"
min={0}
max={100}
step={1}
value={this.state.assignmentGradeMin}
disabled={!this.props.selectedAssignment}
onChange={this.handleMinAssigGradeChange}
/>
<span className="input-percent-label">%</span>
<InputText
label="Max Grade"
name="assignmentGradeMax"
type="number"
min={0}
max={100}
step={1}
value={this.state.assignmentGradeMax}
disabled={!this.props.selectedAssignment}
onChange={this.handleMaxAssigGradeChange}
/>
<span className="input-percent-label">%</span>
<Button
type="submit"
className="btn-outline-secondary"
name="assignmentGradeMinMax"
disabled={!this.props.selectedAssignment}
>
Apply
</Button>
</form>
</div>
</Collapsible>
<Collapsible title="Overall Grade" isOpen className="filter-group mb-3">
<div className="d-flex justify-content-between align-items-center"> <div className="d-flex justify-content-between align-items-center">
<InputText <InputText
value={this.state.courseGradeMin} value={this.state.courseGradeMin}
@@ -431,16 +912,15 @@ export default class Gradebook extends React.Component {
/> />
<span className="input-percent-label">%</span> <span className="input-percent-label">%</span>
<Button <Button
variant="outline-secondary" buttonType="outline-secondary"
onClick={this.handleCourseGradeFilterApplyButtonClick} onClick={this.handleCourseGradeFilterApplyButtonClick}
> >
Apply Apply
</Button> </Button>
</div> </div>
</Collapsible> </Collapsible>
<Collapsible title="Student Groups" open className="filter-group mb-3"> <Collapsible title="Student Groups" isOpen className="filter-group mb-3">
<InputSelect <InputSelect
label="Tracks"
name="Tracks" name="Tracks"
aria-label="Tracks" aria-label="Tracks"
disabled={this.props.tracks.length === 0} disabled={this.props.tracks.length === 0}
@@ -451,7 +931,6 @@ export default class Gradebook extends React.Component {
<InputSelect <InputSelect
name="Cohorts" name="Cohorts"
aria-label="Cohorts" aria-label="Cohorts"
label="Cohorts"
disabled={this.props.cohorts.length === 0} disabled={this.props.cohorts.length === 0}
value={this.mapSelectedCohortEntry(this.props.selectedCohort)} value={this.mapSelectedCohortEntry(this.props.selectedCohort)}
options={this.mapCohortsEntries(this.props.cohorts)} options={this.mapCohortsEntries(this.props.cohorts)}
@@ -465,55 +944,123 @@ export default class Gradebook extends React.Component {
Gradebook.defaultProps = { Gradebook.defaultProps = {
areGradesFrozen: false, areGradesFrozen: false,
assignmentTypes: [],
assignmentFilterOptions: [],
canUserViewGradebook: false, canUserViewGradebook: false,
cohorts: [], cohorts: [],
courseId: '', grades: [],
filteredUsersCount: null, gradeOverrides: [],
gradeOverrideCurrentEarnedGradedOverride: null,
gradeOriginalEarnedGraded: null,
gradeOriginalPossibleGraded: null,
location: { location: {
search: '', search: '',
}, },
selectedAssignmentType: '', courseId: '',
selectedCohort: null, selectedCohort: null,
selectedTrack: null, selectedTrack: null,
showBulkManagement: false, selectedAssignmentType: '',
selectedAssignment: '',
showSpinner: false, showSpinner: false,
totalUsersCount: null,
tracks: [], tracks: [],
bulkImportError: '',
uploadSuccess: false,
showBulkManagement: false,
bulkManagementHistory: [],
errorFetchingGradeOverrideHistory: false,
totalUsersCount: null,
filteredUsersCount: null,
}; };
Gradebook.propTypes = { Gradebook.propTypes = {
areGradesFrozen: PropTypes.bool, areGradesFrozen: PropTypes.bool,
assignmentTypes: PropTypes.arrayOf(PropTypes.string),
assignmentFilterOptions: PropTypes.arrayOf(PropTypes.shape({
label: PropTypes.string,
subsectionLabel: PropTypes.string,
})),
canUserViewGradebook: PropTypes.bool, canUserViewGradebook: PropTypes.bool,
closeBanner: PropTypes.func.isRequired,
cohorts: PropTypes.arrayOf(PropTypes.shape({ cohorts: PropTypes.arrayOf(PropTypes.shape({
name: PropTypes.string, name: PropTypes.string,
id: PropTypes.number, id: PropTypes.number,
})), })),
courseId: PropTypes.string, filterAssignmentType: PropTypes.func.isRequired,
filteredUsersCount: PropTypes.number, updateAssignmentFilter: PropTypes.func.isRequired,
updateAssignmentLimits: PropTypes.func.isRequired,
format: PropTypes.string.isRequired,
getRoles: PropTypes.func.isRequired, getRoles: PropTypes.func.isRequired,
getUserGrades: PropTypes.func.isRequired, getUserGrades: PropTypes.func.isRequired,
gradeExportUrl: PropTypes.string.isRequired, fetchGradeOverrideHistory: PropTypes.func.isRequired,
grades: PropTypes.arrayOf(PropTypes.shape({
percent: PropTypes.number,
section_breakdown: PropTypes.arrayOf(PropTypes.shape({
attempted: PropTypes.bool,
category: PropTypes.string,
label: PropTypes.string,
module_id: PropTypes.string,
percent: PropTypes.number,
scoreEarned: PropTypes.number,
scorePossible: PropTypes.number,
subsection_name: PropTypes.string,
})),
user_id: PropTypes.number,
user_name: PropTypes.string,
})),
gradeOverrides: PropTypes.arrayOf(PropTypes.shape({
date: PropTypes.string,
grader: PropTypes.string,
reason: PropTypes.string,
adjustedGrade: PropTypes.number,
})),
gradeOverrideCurrentEarnedGradedOverride: PropTypes.number,
gradeOriginalEarnedGraded: PropTypes.number,
gradeOriginalPossibleGraded: PropTypes.number,
doneViewingAssignment: PropTypes.func.isRequired,
headings: PropTypes.arrayOf(PropTypes.string).isRequired,
history: PropTypes.shape({ history: PropTypes.shape({
push: PropTypes.func, push: PropTypes.func,
}).isRequired, }).isRequired,
initializeFilters: PropTypes.func.isRequired,
interventionExportUrl: PropTypes.string.isRequired,
location: PropTypes.shape({ location: PropTypes.shape({
search: PropTypes.string, search: PropTypes.string,
}), }),
resetFilters: PropTypes.func.isRequired, courseId: PropTypes.string,
searchForUser: PropTypes.func.isRequired, searchForUser: PropTypes.func.isRequired,
selectedAssignmentType: PropTypes.string, selectedAssignmentType: PropTypes.string,
selectedAssignment: PropTypes.string,
selectedCohort: PropTypes.string, selectedCohort: PropTypes.string,
selectedTrack: PropTypes.string, selectedTrack: PropTypes.string,
showBulkManagement: PropTypes.bool, resetFilters: PropTypes.func.isRequired,
showSpinner: PropTypes.bool, showSpinner: PropTypes.bool,
showSuccess: PropTypes.bool.isRequired, showSuccess: PropTypes.bool.isRequired,
toggleFormat: PropTypes.func.isRequired, toggleFormat: PropTypes.func.isRequired,
totalUsersCount: PropTypes.number,
tracks: PropTypes.arrayOf(PropTypes.shape({ tracks: PropTypes.arrayOf(PropTypes.shape({
name: PropTypes.string, 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, updateCourseGradeFilter: PropTypes.func.isRequired,
downloadBulkGradesReport: PropTypes.func.isRequired,
downloadInterventionReport: PropTypes.func.isRequired,
}; };

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ import { connect } from 'react-redux';
import Gradebook from '../../components/Gradebook'; import Gradebook from '../../components/Gradebook';
import { import {
closeBanner, closeBanner,
doneViewingAssignment,
fetchGradeOverrideHistory, fetchGradeOverrideHistory,
fetchGrades, fetchGrades,
fetchMatchingUserGrades, fetchMatchingUserGrades,
@@ -10,14 +11,14 @@ import {
filterAssignmentType, filterAssignmentType,
submitFileUploadFormData, submitFileUploadFormData,
toggleGradeFormat, toggleGradeFormat,
updateGrades,
updateGradesIfAssignmentGradeFiltersSet,
downloadBulkGradesReport, downloadBulkGradesReport,
downloadInterventionReport, downloadInterventionReport,
} from '../../data/actions/grades'; } from '../../data/actions/grades';
import { fetchCohorts } from '../../data/actions/cohorts'; import { fetchCohorts } from '../../data/actions/cohorts';
import { fetchTracks } from '../../data/actions/tracks'; import { fetchTracks } from '../../data/actions/tracks';
import { import { initializeFilters, resetFilters, updateAssignmentFilter, updateAssignmentLimits, updateCourseGradeFilter } from '../../data/actions/filters';
initializeFilters, resetFilters, updateAssignmentFilter, updateAssignmentLimits, updateCourseGradeFilter,
} from '../../data/actions/filters';
import stateHasMastersTrack from '../../data/selectors/tracks'; import stateHasMastersTrack from '../../data/selectors/tracks';
import { import {
getBulkManagementHistory, getBulkManagementHistory,
@@ -36,7 +37,7 @@ import LmsApiService from '../../data/services/LmsApiService';
function shouldShowSpinner(state) { function shouldShowSpinner(state) {
if (state.roles.canUserViewGradebook === true) { if (state.roles.canUserViewGradebook === true) {
return state.grades.showSpinner; return state.grades.showSpinner;
} if (state.roles.canUserViewGradebook === false) { } else if (state.roles.canUserViewGradebook === false) {
return false; return false;
} // canUserViewGradebook === null } // canUserViewGradebook === null
return true; return true;
@@ -44,19 +45,33 @@ function shouldShowSpinner(state) {
const mapStateToProps = (state, ownProps) => ( const mapStateToProps = (state, ownProps) => (
{ {
areGradesFrozen: state.assignmentTypes.areGradesFrozen, courseId: ownProps.match.params.courseId,
grades: state.grades.results,
gradeOverrides: state.grades.gradeOverrideHistoryResults,
gradeOverrideCurrentEarnedAllOverride: state.grades.gradeOverrideCurrentEarnedAllOverride,
gradeOverrideCurrentPossibleAllOverride: state.grades.gradeOverrideCurrentPossibleAllOverride,
gradeOverrideCurrentEarnedGradedOverride: state.grades.gradeOverrideCurrentEarnedGradedOverride,
gradeOverrideCurrentPossibleGradedOverride:
state.grades.gradeOverrideCurrentPossibleGradedOverride,
gradeOriginalEarnedGraded: state.grades.gradeOriginalEarnedGraded,
gradeOriginalPossibleGraded: state.grades.gradeOriginalPossibleGraded,
headings: getHeadings(state),
tracks: state.tracks.results,
cohorts: state.cohorts.results,
selectedTrack: state.filters.track,
selectedCohort: state.filters.cohort,
selectedAssignmentType: state.filters.assignmentType,
selectedAssignment: (state.filters.assignment || {}).label,
format: state.grades.gradeFormat,
showSuccess: state.grades.showSuccess,
errorFetchingGradeOverrideHistory: state.grades.errorFetchingOverrideHistory,
prevPage: state.grades.prevPage,
nextPage: state.grades.nextPage,
assignmentTypes: state.assignmentTypes.results, assignmentTypes: state.assignmentTypes.results,
assignmentFilterOptions: selectableAssignmentLabels(state), assignmentFilterOptions: selectableAssignmentLabels(state),
bulkImportError: state.grades.bulkManagement areGradesFrozen: state.assignmentTypes.areGradesFrozen,
&& state.grades.bulkManagement.errorMessages showSpinner: shouldShowSpinner(state),
? `Errors while processing: ${state.grades.bulkManagement.errorMessages.join(', ')}`
: '',
bulkManagementHistory: getBulkManagementHistory(state),
cohorts: state.cohorts.results,
courseId: ownProps.match.params.courseId,
canUserViewGradebook: state.roles.canUserViewGradebook, canUserViewGradebook: state.roles.canUserViewGradebook,
filteredUsersCount: state.grades.filteredUsersCount,
format: state.grades.gradeFormat,
gradeExportUrl: LmsApiService.getGradeExportCsvUrl(ownProps.match.params.courseId, { gradeExportUrl: LmsApiService.getGradeExportCsvUrl(ownProps.match.params.courseId, {
cohort: getCohortNameById(state, state.filters.cohort), cohort: getCohortNameById(state, state.filters.cohort),
track: state.filters.track, track: state.filters.track,
@@ -73,8 +88,6 @@ const mapStateToProps = (state, ownProps) => (
courseGradeMin: formatMinCourseGrade(state.filters.courseGradeMin), courseGradeMin: formatMinCourseGrade(state.filters.courseGradeMin),
courseGradeMax: formatMaxCourseGrade(state.filters.courseGradeMax), courseGradeMax: formatMaxCourseGrade(state.filters.courseGradeMax),
}), }),
grades: state.grades.results,
headings: getHeadings(state),
interventionExportUrl: interventionExportUrl:
LmsApiService.getInterventionExportCsvUrl(ownProps.match.params.courseId, { LmsApiService.getInterventionExportCsvUrl(ownProps.match.params.courseId, {
cohort: getCohortNameById(state, state.filters.cohort), cohort: getCohortNameById(state, state.filters.cohort),
@@ -91,42 +104,42 @@ const mapStateToProps = (state, ownProps) => (
courseGradeMin: formatMinCourseGrade(state.filters.courseGradeMin), courseGradeMin: formatMinCourseGrade(state.filters.courseGradeMin),
courseGradeMax: formatMaxCourseGrade(state.filters.courseGradeMax), courseGradeMax: formatMaxCourseGrade(state.filters.courseGradeMax),
}), }),
nextPage: state.grades.nextPage, bulkImportError: state.grades.bulkManagement &&
prevPage: state.grades.prevPage, state.grades.bulkManagement.errorMessages ?
selectedTrack: state.filters.track, `Errors while processing: ${state.grades.bulkManagement.errorMessages.join(', ')}` :
selectedCohort: state.filters.cohort, '',
selectedAssignmentType: state.filters.assignmentType, uploadSuccess: !!(state.grades.bulkManagement &&
selectedAssignment: (state.filters.assignment || {}).label, state.grades.bulkManagement.uploadSuccess),
showBulkManagement: stateHasMastersTrack(state) && state.config.bulkManagementAvailable, showBulkManagement: stateHasMastersTrack(state) && state.config.bulkManagementAvailable,
showSpinner: shouldShowSpinner(state), bulkManagementHistory: getBulkManagementHistory(state),
showSuccess: state.grades.showSuccess,
totalUsersCount: state.grades.totalUsersCount, totalUsersCount: state.grades.totalUsersCount,
tracks: state.tracks.results, filteredUsersCount: state.grades.filteredUsersCount,
uploadSuccess: !!(state.grades.bulkManagement
&& state.grades.bulkManagement.uploadSuccess),
} }
); );
const mapDispatchToProps = { const mapDispatchToProps = {
closeBanner, doneViewingAssignment,
downloadBulkGradesReport,
downloadInterventionReport,
fetchGradeOverrideHistory,
filterAssignmentType,
getAssignmentTypes: fetchAssignmentTypes,
getCohorts: fetchCohorts,
getPrevNextGrades: fetchPrevNextGrades,
getRoles,
getTracks: fetchTracks,
getUserGrades: fetchGrades, getUserGrades: fetchGrades,
fetchGradeOverrideHistory,
searchForUser: fetchMatchingUserGrades,
getPrevNextGrades: fetchPrevNextGrades,
getCohorts: fetchCohorts,
getTracks: fetchTracks,
getAssignmentTypes: fetchAssignmentTypes,
updateGrades,
toggleFormat: toggleGradeFormat,
filterAssignmentType,
closeBanner,
getRoles,
submitFileUploadFormData,
initializeFilters, initializeFilters,
resetFilters, resetFilters,
searchForUser: fetchMatchingUserGrades,
submitFileUploadFormData,
toggleFormat: toggleGradeFormat,
updateAssignmentFilter, updateAssignmentFilter,
updateAssignmentLimits, updateAssignmentLimits,
updateGradesIfAssignmentGradeFiltersSet,
updateCourseGradeFilter, updateCourseGradeFilter,
downloadBulkGradesReport,
downloadInterventionReport,
}; };
const GradebookPage = connect( const GradebookPage = connect(

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,5 @@
import initialFilters from '../constants/filters'; import initialFilters from '../constants/filters';
import { import { INITIALIZE_FILTERS, RESET_FILTERS, UPDATE_ASSIGNMENT_FILTER, UPDATE_ASSIGNMENT_LIMITS, UPDATE_COURSE_GRADE_LIMITS } from '../constants/actionTypes/filters';
INITIALIZE_FILTERS, RESET_FILTERS, UPDATE_ASSIGNMENT_FILTER, UPDATE_ASSIGNMENT_LIMITS, UPDATE_COURSE_GRADE_LIMITS,
} from '../constants/actionTypes/filters';
const initializeFilters = ({ const initializeFilters = ({
assignment = initialFilters.assignment, assignment = initialFilters.assignment,

View File

@@ -1,4 +1,3 @@
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { import {
STARTED_FETCHING_GRADES, STARTED_FETCHING_GRADES,
FINISHED_FETCHING_GRADES, FINISHED_FETCHING_GRADES,
@@ -24,13 +23,11 @@ import {
BULK_GRADE_REPORT_DOWNLOADED, BULK_GRADE_REPORT_DOWNLOADED,
INTERVENTION_REPORT_DOWNLOADED, INTERVENTION_REPORT_DOWNLOADED,
} from '../constants/actionTypes/grades'; } from '../constants/actionTypes/grades';
import GRADE_OVERRIDE_HISTORY_ERROR_DEFAULT_MSG from '../constants/errors';
import LmsApiService from '../services/LmsApiService'; import LmsApiService from '../services/LmsApiService';
import { sortAlphaAsc, formatDateForDisplay } from './utils'; import { sortAlphaAsc, formatDateForDisplay } from './utils';
import { import { formatMaxAssignmentGrade, formatMinAssignmentGrade, formatMaxCourseGrade, formatMinCourseGrade } from '../selectors/grades';
formatMaxAssignmentGrade, formatMinAssignmentGrade, formatMaxCourseGrade, formatMinCourseGrade,
} from '../selectors/grades';
import { getFilters } from '../selectors/filters'; import { getFilters } from '../selectors/filters';
import apiClient from '../apiClient';
const defaultAssignmentFilter = 'All'; const defaultAssignmentFilter = 'All';
@@ -43,10 +40,7 @@ const bulkHistoryError = () => ({ type: BULK_HISTORY_ERR });
const startedFetchingGrades = () => ({ type: STARTED_FETCHING_GRADES }); const startedFetchingGrades = () => ({ type: STARTED_FETCHING_GRADES });
const finishedFetchingGrades = () => ({ type: FINISHED_FETCHING_GRADES }); const finishedFetchingGrades = () => ({ type: FINISHED_FETCHING_GRADES });
const errorFetchingGrades = () => ({ type: ERROR_FETCHING_GRADES }); const errorFetchingGrades = () => ({ type: ERROR_FETCHING_GRADES });
const errorFetchingGradeOverrideHistory = errorMessage => ({ const errorFetchingGradeOverrideHistory = () => ({ type: ERROR_FETCHING_GRADE_OVERRIDE_HISTORY });
type: ERROR_FETCHING_GRADE_OVERRIDE_HISTORY,
errorMessage,
});
const gotGrades = ({ const gotGrades = ({
grades, cohort, track, assignmentType, headings, prev, grades, cohort, track, assignmentType, headings, prev,
@@ -114,6 +108,7 @@ const uploadOverrideFailure = (courseId, error) => ({
payload: { error }, payload: { error },
}); });
const toggleGradeFormat = formatType => ({ type: TOGGLE_GRADE_FORMAT, formatType }); const toggleGradeFormat = formatType => ({ type: TOGGLE_GRADE_FORMAT, formatType });
const filterAssignmentType = filterType => ( const filterAssignmentType = filterType => (
@@ -196,31 +191,27 @@ const doneViewingAssignment = () => dispatch => dispatch({
type: DONE_VIEWING_ASSIGNMENT, type: DONE_VIEWING_ASSIGNMENT,
}); });
const fetchGradeOverrideHistory = (subsectionId, userId) => ( const fetchGradeOverrideHistory = (subsectionId, userId) => (
dispatch => LmsApiService.fetchGradeOverrideHistory(subsectionId, userId) dispatch =>
.then(response => response.data) LmsApiService.fetchGradeOverrideHistory(subsectionId, userId)
.then((data) => { .then(response => response.data)
if (data.success) { .then((data) => {
dispatch(gotGradeOverrideHistory({ dispatch(gotGradeOverrideHistory({
overrideHistory: formatGradeOverrideForDisplay(data.history), overrideHistory: formatGradeOverrideForDisplay(data.history),
currentEarnedAllOverride: data.override ? data.override.earned_all_override : null, currentEarnedAllOverride: data.override ? data.override.earned_all_override : null,
currentPossibleAllOverride: data.override ? data.override.possible_all_override : null, currentPossibleAllOverride: data.override ? data.override.possible_all_override : null,
currentEarnedGradedOverride: data.override ? data.override.earned_graded_override : null, currentEarnedGradedOverride: data.override ? data.override.earned_graded_override : null,
currentPossibleGradedOverride: data.override currentPossibleGradedOverride: data.override ?
? data.override.possible_graded_override : null, data.override.possible_graded_override : null,
originalGradeEarnedAll: data.original_grade ? data.original_grade.earned_all : null, originalGradeEarnedAll: data.original_grade ? data.original_grade.earned_all : null,
originalGradePossibleAll: data.original_grade ? data.original_grade.possible_all : null, originalGradePossibleAll: data.original_grade ? data.original_grade.possible_all : null,
originalGradeEarnedGraded: data.original_grade originalGradeEarnedGraded: data.original_grade ? data.original_grade.earned_graded : null,
? data.original_grade.earned_graded : null, originalGradePossibleGraded: data.original_grade ?
originalGradePossibleGraded: data.original_grade data.original_grade.possible_graded : null,
? data.original_grade.possible_graded : null,
})); }));
} else { })
dispatch(errorFetchingGradeOverrideHistory(data.error_message)); .catch(() => {
} dispatch(errorFetchingGradeOverrideHistory());
}) })
.catch(() => {
dispatch(errorFetchingGradeOverrideHistory(GRADE_OVERRIDE_HISTORY_ERROR_DEFAULT_MSG));
})
); );
const fetchMatchingUserGrades = ( const fetchMatchingUserGrades = (
@@ -239,7 +230,7 @@ const fetchMatchingUserGrades = (
const fetchPrevNextGrades = (endpoint, courseId, cohort, track, assignmentType) => ( const fetchPrevNextGrades = (endpoint, courseId, cohort, track, assignmentType) => (
(dispatch) => { (dispatch) => {
dispatch(startedFetchingGrades()); dispatch(startedFetchingGrades());
return getAuthenticatedHttpClient().get(endpoint) return apiClient.get(endpoint)
.then(response => response.data) .then(response => response.data)
.then((data) => { .then((data) => {
dispatch(gotGrades({ dispatch(gotGrades({
@@ -302,10 +293,11 @@ const submitFileUploadFormData = (courseId, formData) => (
); );
const fetchBulkUpgradeHistory = courseId => ( const fetchBulkUpgradeHistory = courseId => (
// todo add loading effect dispatch =>
dispatch => LmsApiService.fetchGradeBulkOperationHistory(courseId).then( // todo add loading effect
(response) => { dispatch(gotBulkHistory(response)); }, LmsApiService.fetchGradeBulkOperationHistory(courseId).then((response) => {
).catch(() => dispatch(bulkHistoryError())) dispatch(gotBulkHistory(response));
}).catch(() => dispatch(bulkHistoryError()))
); );
const updateGradesIfAssignmentGradeFiltersSet = ( const updateGradesIfAssignmentGradeFiltersSet = (

View File

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

View File

@@ -1,9 +1,8 @@
import axios from 'axios';
import configureMockStore from 'redux-mock-store'; import configureMockStore from 'redux-mock-store';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import thunk from 'redux-thunk'; import thunk from 'redux-thunk';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import apiClient from '../apiClient';
import { configuration } from '../../config'; import { configuration } from '../../config';
import { getRoles } from './roles'; import { getRoles } from './roles';
import { import {
@@ -15,13 +14,11 @@ import { STARTED_FETCHING_TRACKS } from '../constants/actionTypes/tracks';
import { STARTED_FETCHING_COHORTS } from '../constants/actionTypes/cohorts'; import { STARTED_FETCHING_COHORTS } from '../constants/actionTypes/cohorts';
import { STARTED_FETCHING_ASSIGNMENT_TYPES } from '../constants/actionTypes/assignmentTypes'; import { STARTED_FETCHING_ASSIGNMENT_TYPES } from '../constants/actionTypes/assignmentTypes';
const mockStore = configureMockStore([thunk]);
jest.mock('@edx/frontend-platform/auth'); const mockStore = configureMockStore([thunk]);
const axiosMock = new MockAdapter(axios); const axiosMock = new MockAdapter(apiClient);
getAuthenticatedHttpClient.mockReturnValue(axios); apiClient.isAccessTokenExpired = jest.fn();
axios.isAccessTokenExpired = jest.fn(); apiClient.isAccessTokenExpired.mockReturnValue(false);
axios.isAccessTokenExpired.mockReturnValue(false);
const course1Id = 'course-v1:edX+DemoX+Demo_Course'; const course1Id = 'course-v1:edX+DemoX+Demo_Course';
const course2Id = 'course-v1:edX+DemoX+Demo_Course_2'; const course2Id = 'course-v1:edX+DemoX+Demo_Course_2';

View File

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

View File

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

15
src/data/apiClient.js Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,6 @@
import { GOT_GRADES, FILTER_BY_ASSIGNMENT_TYPE } from '../constants/actionTypes/grades'; import { GOT_GRADES, FILTER_BY_ASSIGNMENT_TYPE } from '../constants/actionTypes/grades';
import { import { INITIALIZE_FILTERS, UPDATE_ASSIGNMENT_FILTER, UPDATE_ASSIGNMENT_LIMITS, UPDATE_COURSE_GRADE_LIMITS, RESET_FILTERS } from '../constants/actionTypes/filters';
INITIALIZE_FILTERS, UPDATE_ASSIGNMENT_FILTER, UPDATE_ASSIGNMENT_LIMITS, UPDATE_COURSE_GRADE_LIMITS, RESET_FILTERS,
} from '../constants/actionTypes/filters';
import initialFilters from '../constants/filters'; import initialFilters from '../constants/filters';
@@ -17,8 +15,8 @@ const reducer = (state = initialState, action) => {
...state, ...state,
assignmentType: action.filterType, assignmentType: action.filterType,
assignment: ( assignment: (
action.filterType !== '' action.filterType !== '' &&
&& (state.assignment || {}).type !== action.filterType) (state.assignment || {}).type !== action.filterType)
? '' : state.assignment, ? '' : state.assignment,
}; };
case INITIALIZE_FILTERS: case INITIALIZE_FILTERS:

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
const getCohorts = state => state.cohorts.results || []; const getCohorts = state => state.cohorts.results || [];
const getCohortById = (state, selectedCohortId) => { const getCohortById = (state, selectedCohortId) => {
@@ -5,6 +6,7 @@ const getCohortById = (state, selectedCohortId) => {
return cohort; return cohort;
}; };
const getCohortNameById = (state, selectedCohortId) => (getCohortById(state, selectedCohortId) || {}).name; const getCohortNameById = (state, selectedCohortId) =>
(getCohortById(state, selectedCohortId) || {}).name;
export { getCohortById, getCohortNameById, getCohorts }; export { getCohortById, getCohortNameById, getCohorts };

View File

@@ -1,6 +1,7 @@
const getFilters = state => state.filters || {}; const getFilters = state => state.filters || {};
const getAssignmentsFromResultsSubstate = results => (results[0] || {}).section_breakdown || []; const getAssignmentsFromResultsSubstate = results =>
(results[0] || {}).section_breakdown || [];
const selectableAssignments = (state) => { const selectableAssignments = (state) => {
const selectedAssignmentType = getFilters(state).assignmentType; const selectedAssignmentType = getFilters(state).assignmentType;
@@ -19,12 +20,14 @@ const chooseRelevantAssignmentData = assignment => ({
id: assignment.module_id, id: assignment.module_id,
}); });
const selectableAssignmentLabels = state => selectableAssignments(state).map(chooseRelevantAssignmentData); const selectableAssignmentLabels = state =>
selectableAssignments(state).map(chooseRelevantAssignmentData);
const typeOfSelectedAssignment = (state) => { const typeOfSelectedAssignment = (state) => {
const selectedAssignmentLabel = getFilters(state).assignment; const selectedAssignmentLabel = getFilters(state).assignment;
const sectionBreakdown = (state.grades.results[0] || {}).section_breakdown || []; 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; return selectedAssignment && selectedAssignment.category;
}; };

View File

@@ -33,8 +33,10 @@ const transformHistoryEntry = (historyRow) => {
...rest, ...rest,
}; };
}; };
const getBulkManagementHistoryFromState = state => state.grades.bulkManagement.history || []; const getBulkManagementHistoryFromState = state =>
const getBulkManagementHistory = state => getBulkManagementHistoryFromState(state).map(transformHistoryEntry); state.grades.bulkManagement.history || [];
const getBulkManagementHistory = state =>
getBulkManagementHistoryFromState(state).map(transformHistoryEntry);
const headingMapper = (category, label = 'All') => { const headingMapper = (category, label = 'All') => {
const filters = { const filters = {
@@ -80,12 +82,13 @@ const getHeadings = (state) => {
return headingMapper(type, assignment)(assignments); return headingMapper(type, assignment)(assignments);
}; };
const composeFilters = (...predicates) => (percentGrade, options = {}) => predicates.reduce((accum, predicate) => { const composeFilters = (...predicates) => (percentGrade, options = {}) =>
if (predicate(percentGrade, options)) { predicates.reduce((accum, predicate) => {
return null; if (predicate(percentGrade, options)) {
} return null;
return accum; }
}, percentGrade); return accum;
}, percentGrade);
const percentGradeIsMax = percentGrade => ( const percentGradeIsMax = percentGrade => (
percentGrade === '100' percentGrade === '100'

View File

@@ -1,6 +1,7 @@
const compose = (...fns) => { const compose = (...fns) => {
const [firstFunc, ...rest] = fns.reverse(); 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 || []; const getTracks = state => state.tracks.results || [];

View File

@@ -1,9 +1,8 @@
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import apiClient from '../apiClient';
import { configuration } from '../../config'; import { configuration } from '../../config';
class LmsApiService { class LmsApiService {
static baseUrl = configuration.LMS_BASE_URL; static baseUrl = configuration.LMS_BASE_URL;
static pageSize = 25 static pageSize = 25
static fetchGradebookData(courseId, searchText, cohort, track, options = {}) { static fetchGradebookData(courseId, searchText, cohort, track, options = {}) {
@@ -43,7 +42,7 @@ class LmsApiService {
const gradebookUrl = `${LmsApiService.baseUrl}/api/grades/v1/gradebook/${courseId}/?${queryParamString}`; const gradebookUrl = `${LmsApiService.baseUrl}/api/grades/v1/gradebook/${courseId}/?${queryParamString}`;
return getAuthenticatedHttpClient().get(gradebookUrl); return apiClient.get(gradebookUrl);
} }
static updateGradebookData(courseId, updateData) { static updateGradebookData(courseId, updateData) {
@@ -71,34 +70,34 @@ class LmsApiService {
] ]
*/ */
const gradebookUrl = `${LmsApiService.baseUrl}/api/grades/v1/gradebook/${courseId}/bulk-update`; const gradebookUrl = `${LmsApiService.baseUrl}/api/grades/v1/gradebook/${courseId}/bulk-update`;
return getAuthenticatedHttpClient().post(gradebookUrl, updateData); return apiClient.post(gradebookUrl, updateData);
} }
static fetchTracks(courseId) { static fetchTracks(courseId) {
const trackUrl = `${LmsApiService.baseUrl}/api/enrollment/v1/course/${courseId}?include_expired=1`; const trackUrl = `${LmsApiService.baseUrl}/api/enrollment/v1/course/${courseId}?include_expired=1`;
return getAuthenticatedHttpClient().get(trackUrl); return apiClient.get(trackUrl);
} }
static fetchCohorts(courseId) { static fetchCohorts(courseId) {
const cohortsUrl = `${LmsApiService.baseUrl}/courses/${courseId}/cohorts/`; const cohortsUrl = `${LmsApiService.baseUrl}/courses/${courseId}/cohorts/`;
return getAuthenticatedHttpClient().get(cohortsUrl); return apiClient.get(cohortsUrl);
} }
static fetchAssignmentTypes(courseId) { static fetchAssignmentTypes(courseId) {
const assignmentTypesUrl = `${LmsApiService.baseUrl}/api/grades/v1/gradebook/${courseId}/grading-info?graded_only=true`; const assignmentTypesUrl = `${LmsApiService.baseUrl}/api/grades/v1/gradebook/${courseId}/grading-info?graded_only=true`;
return getAuthenticatedHttpClient().get(assignmentTypesUrl); return apiClient.get(assignmentTypesUrl);
} }
static fetchUserRoles(courseId) { static fetchUserRoles(courseId) {
const rolesUrl = `${LmsApiService.baseUrl}/api/enrollment/v1/roles/?course_id=${encodeURIComponent(courseId)}`; const rolesUrl = `${LmsApiService.baseUrl}/api/enrollment/v1/roles/?course_id=${encodeURIComponent(courseId)}`;
return getAuthenticatedHttpClient().get(rolesUrl); return apiClient.get(rolesUrl);
} }
static getGradeExportCsvUrl(courseId, options = {}) { static getGradeExportCsvUrl(courseId, options = {}) {
const queryParams = ['track', 'cohort', 'assignment', 'assignmentType', 'assignmentGradeMax', const queryParams = ['track', 'cohort', 'assignment', 'assignmentType', 'assignmentGradeMax',
'assignmentGradeMin', 'courseGradeMin', 'courseGradeMax'] 'assignmentGradeMin', 'courseGradeMin', 'courseGradeMax']
.filter(opt => options[opt] .filter(opt => options[opt] &&
&& options[opt] !== 'All') options[opt] !== 'All')
.map(opt => `${opt}=${encodeURIComponent(options[opt])}`) .map(opt => `${opt}=${encodeURIComponent(options[opt])}`)
.join('&'); .join('&');
return `${LmsApiService.baseUrl}/api/bulk_grades/course/${courseId}/?${queryParams}`; return `${LmsApiService.baseUrl}/api/bulk_grades/course/${courseId}/?${queryParams}`;
@@ -107,8 +106,8 @@ class LmsApiService {
static getInterventionExportCsvUrl(courseId, options = {}) { static getInterventionExportCsvUrl(courseId, options = {}) {
const queryParams = ['track', 'cohort', 'assignment', 'assignmentType', 'assignmentGradeMax', const queryParams = ['track', 'cohort', 'assignment', 'assignmentType', 'assignmentGradeMax',
'assignmentGradeMin', 'courseGradeMin', 'courseGradeMax'] 'assignmentGradeMin', 'courseGradeMin', 'courseGradeMax']
.filter(opt => options[opt] .filter(opt => options[opt] &&
&& options[opt] !== 'All') options[opt] !== 'All')
.map(opt => `${opt}=${encodeURIComponent(options[opt])}`) .map(opt => `${opt}=${encodeURIComponent(options[opt])}`)
.join('&'); .join('&');
return `${LmsApiService.baseUrl}/api/bulk_grades/course/${courseId}/intervention?${queryParams}`; return `${LmsApiService.baseUrl}/api/bulk_grades/course/${courseId}/intervention?${queryParams}`;
@@ -118,7 +117,7 @@ class LmsApiService {
static uploadGradeCsv(courseId, formData) { static uploadGradeCsv(courseId, formData) {
const fileUploadUrl = LmsApiService.getGradeImportCsvUrl(courseId); const fileUploadUrl = LmsApiService.getGradeImportCsvUrl(courseId);
return getAuthenticatedHttpClient().post(fileUploadUrl, formData).then((result) => { return apiClient.post(fileUploadUrl, formData).then((result) => {
if (result.status === 200 && !result.data.error_messages.length) { if (result.status === 200 && !result.data.error_messages.length) {
return result.data; return result.data;
} }
@@ -128,12 +127,12 @@ class LmsApiService {
static fetchGradeBulkOperationHistory(courseId) { static fetchGradeBulkOperationHistory(courseId) {
const url = `${LmsApiService.baseUrl}/api/bulk_grades/course/${courseId}/history/`; const url = `${LmsApiService.baseUrl}/api/bulk_grades/course/${courseId}/history/`;
return getAuthenticatedHttpClient().get(url).then(response => response.data).catch(() => Promise.reject(Error('unhandled response error'))); return apiClient.get(url).then(response => response.data).catch(() => Promise.reject(Error('unhandled response error')));
} }
static fetchGradeOverrideHistory(subsectionId, userId) { static fetchGradeOverrideHistory(subsectionId, userId) {
const historyUrl = `${LmsApiService.baseUrl}/api/grades/v1/subsection/${subsectionId}/?user_id=${userId}&history_record_limit=5`; const historyUrl = `${LmsApiService.baseUrl}/api/grades/v1/subsection/${subsectionId}/?user_id=${userId}&history_record_limit=5`;
return getAuthenticatedHttpClient().get(historyUrl); return apiClient.get(historyUrl);
} }
} }

View File

@@ -90,6 +90,7 @@ const eventsMap = {
const segmentMiddleware = createMiddleware(eventsMap, Segment()); const segmentMiddleware = createMiddleware(eventsMap, Segment());
const store = createStore( const store = createStore(
reducers, reducers,
composeWithDevTools(applyMiddleware(thunkMiddleware, loggerMiddleware, segmentMiddleware)), composeWithDevTools(applyMiddleware(thunkMiddleware, loggerMiddleware, segmentMiddleware)),

View File

@@ -1,17 +1,9 @@
import 'core-js/stable'; import 'babel-polyfill';
import 'regenerator-runtime/runtime';
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom'; import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import SiteFooter, { messages as footerMessages } from '@edx/frontend-component-footer'; import SiteFooter from '@edx/frontend-component-footer';
import {
APP_READY,
getConfig,
initialize,
subscribe,
} from '@edx/frontend-platform';
import { IntlProvider } from 'react-intl'; import { IntlProvider } from 'react-intl';
import { import {
@@ -22,6 +14,7 @@ import {
} from '@fortawesome/free-brands-svg-icons'; } from '@fortawesome/free-brands-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import apiClient from './data/apiClient';
import GradebookPage from './containers/GradebookPage'; import GradebookPage from './containers/GradebookPage';
import Header from './components/Header'; import Header from './components/Header';
import store from './data/store'; import store from './data/store';
@@ -56,14 +49,14 @@ const socialLinks = [
]; ];
const App = () => ( const App = () => (
<IntlProvider locale="en"> <IntlProvider>
<Provider store={store}> <Provider store={store}>
<Router> <Router>
<div> <div>
<Header /> <Header />
<main> <main>
<Switch> <Switch>
<Route exact path={getConfig().PUBLIC_PATH.concat(':courseId')} component={GradebookPage} /> <Route exact path="/gradebook/:courseId" component={GradebookPage} />
</Switch> </Switch>
</main> </main>
<SiteFooter <SiteFooter
@@ -93,13 +86,9 @@ const App = () => (
</IntlProvider> </IntlProvider>
); );
subscribe(APP_READY, () => { apiClient.ensurePublicOrAuthenticationAndCookies(
ReactDOM.render(<App />, document.getElementById('root')); window.location.pathname,
}); () => {
ReactDOM.render(<App />, document.getElementById('root'));
initialize({ },
messages: [ );
footerMessages,
],
requireAuthenticatedUser: true,
});