Compare commits
109 Commits
v1.3.1
...
aed/websoc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0aada7794 | ||
|
|
09e482e893 | ||
|
|
d4421d47fc | ||
|
|
0ef8e773cc | ||
|
|
1dac20b866 | ||
|
|
ed2d715ce0 | ||
|
|
c82c49ea59 | ||
|
|
a9f8aec5f9 | ||
|
|
a63e9a5347 | ||
|
|
2581812118 | ||
|
|
c4fe803a95 | ||
|
|
93be5329ca | ||
|
|
80ba7e7152 | ||
|
|
f88526aa3a | ||
|
|
c0f08eee58 | ||
|
|
ef62ea35dc | ||
|
|
34eaa31776 | ||
|
|
a7316e6824 | ||
|
|
c0ab04f20c | ||
|
|
ed72e7c203 | ||
|
|
223d9a00bd | ||
|
|
8379f48e50 | ||
|
|
9e1268e388 | ||
|
|
57e0f2254a | ||
|
|
2cc14191b4 | ||
|
|
603dbeb823 | ||
|
|
55cb1f4140 | ||
|
|
55648a62ff | ||
|
|
62f9d24704 | ||
|
|
f036b0cf34 | ||
|
|
67493d1e9e | ||
|
|
e5bca7e526 | ||
|
|
52c5357ce7 | ||
|
|
d469cc2de7 | ||
|
|
86092f22b3 | ||
|
|
c8cb07228f | ||
|
|
a1946e7bc4 | ||
|
|
01d80e0fff | ||
|
|
e6da087e83 | ||
|
|
ac5eaed5cb | ||
|
|
88997ca242 | ||
|
|
d5daf9086f | ||
|
|
8a01a60d63 | ||
|
|
66cdcc7f2a | ||
|
|
0c73d66666 | ||
|
|
28e3e6d0e6 | ||
|
|
6473bafa3d | ||
|
|
167901e665 | ||
|
|
50a0d6e579 | ||
|
|
a284c286f5 | ||
|
|
dd967e703c | ||
|
|
725dc071e3 | ||
|
|
3da7730f23 | ||
|
|
bea36fb387 | ||
|
|
1408b0ae7e | ||
|
|
7b817a4234 | ||
|
|
a762c47d77 | ||
|
|
aecb93c252 | ||
|
|
5a489b1bd5 | ||
|
|
5c642a1be5 | ||
|
|
9a0e0e0ece | ||
|
|
7486a342e2 | ||
|
|
fd807c54f8 | ||
|
|
9b894b502f | ||
|
|
afd9692f6b | ||
|
|
8f77dea222 | ||
|
|
3bf3acaaec | ||
|
|
f1ab3d0330 | ||
|
|
8d89bc16b1 | ||
|
|
d93476c198 | ||
|
|
b46d47286b | ||
|
|
4aecfce14a | ||
|
|
14f7ad01b8 | ||
|
|
104cb30ef5 | ||
|
|
e9bc4cebe4 | ||
|
|
559180592c | ||
|
|
0f1f0ae89d | ||
|
|
a812ee3816 | ||
|
|
2e725e0441 | ||
|
|
a1c2ccc539 | ||
|
|
a70ddd79f6 | ||
|
|
dd82054bbc | ||
|
|
6a4bc67841 | ||
|
|
adfefac85d | ||
|
|
c92144c436 | ||
|
|
ca0156ea4c | ||
|
|
61c4bc11bd | ||
|
|
db25a18f9d | ||
|
|
0d7fa18acd | ||
|
|
012bb3a1f3 | ||
|
|
de233e0285 | ||
|
|
ae7544cd53 | ||
|
|
14df81b312 | ||
|
|
4706cfcd94 | ||
|
|
1f5a2469b2 | ||
|
|
e31c670938 | ||
|
|
db9f683297 | ||
|
|
7a43cdcaea | ||
|
|
d5637a4550 | ||
|
|
0b9fa36fb7 | ||
|
|
7bd0c49c14 | ||
|
|
44f91bb453 | ||
|
|
d8f229838f | ||
|
|
7b5a095898 | ||
|
|
ff7937c2d7 | ||
|
|
d057497105 | ||
|
|
e1402b0d4f | ||
|
|
3ea337e3f8 | ||
|
|
9c2c16e378 |
@@ -1,3 +1,5 @@
|
|||||||
coverage/*
|
coverage/*
|
||||||
dist/
|
dist/
|
||||||
node_modules/
|
node_modules/
|
||||||
|
src/postcss.config.js
|
||||||
|
src/segment.js
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.eslintcache
|
.eslintcache
|
||||||
|
.idea
|
||||||
node_modules
|
node_modules
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
coverage
|
coverage
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ before_script: greenkeeper-lockfile-update
|
|||||||
after_script: greenkeeper-lockfile-upload
|
after_script: greenkeeper-lockfile-upload
|
||||||
script:
|
script:
|
||||||
- make validate-no-uncommitted-package-lock-changes
|
- make validate-no-uncommitted-package-lock-changes
|
||||||
|
- npm run lint
|
||||||
- npm run test
|
- npm run test
|
||||||
- npm run build
|
- npm run build
|
||||||
after_success:
|
after_success:
|
||||||
|
|||||||
29
Dockerfile
29
Dockerfile
@@ -1,29 +0,0 @@
|
|||||||
# Copied from https://github.com/BretFisher/node-docker-good-defaults/blob/master/Dockerfile
|
|
||||||
|
|
||||||
FROM node:8.9.3
|
|
||||||
|
|
||||||
# Create app directory
|
|
||||||
RUN mkdir -p /edx/app
|
|
||||||
|
|
||||||
ARG NODE_ENV=production
|
|
||||||
ENV NODE_ENV $NODE_ENV
|
|
||||||
|
|
||||||
ARG PORT=80
|
|
||||||
ENV PORT $PORT
|
|
||||||
EXPOSE $PORT 1991
|
|
||||||
|
|
||||||
WORKDIR /edx
|
|
||||||
# Install app dependencies
|
|
||||||
# A wildcard is used to ensure both package.json AND package-lock.json are copied
|
|
||||||
# where available (npm@5+)
|
|
||||||
COPY package*.json ./
|
|
||||||
|
|
||||||
# If you are building your code for production
|
|
||||||
# RUN npm install --only=production
|
|
||||||
RUN npm install
|
|
||||||
ENV PATH /edx/app/node_modules/.bin:$PATH
|
|
||||||
|
|
||||||
WORKDIR /edx/app
|
|
||||||
COPY . /edx/app
|
|
||||||
|
|
||||||
ENTRYPOINT npm install && npm run start
|
|
||||||
33
Makefile
33
Makefile
@@ -1,32 +1,9 @@
|
|||||||
shell: ## run a shell on the cookie-cutter container
|
npm-install-%: ## install specified % npm package
|
||||||
docker exec -it edx.gradebook /bin/bash
|
npm install $* --save-dev
|
||||||
|
|
||||||
build:
|
|
||||||
docker-compose build
|
|
||||||
|
|
||||||
up: ## bring up cookie-cutter container
|
|
||||||
docker-compose up
|
|
||||||
|
|
||||||
up-detached: ## bring up cookie-cutter container in detached mode
|
|
||||||
docker-compose up -d
|
|
||||||
|
|
||||||
logs: ## show logs for cookie-cutter container
|
|
||||||
docker-compose logs -f
|
|
||||||
|
|
||||||
down: ## stop and remove cookie-cutter container
|
|
||||||
docker-compose down
|
|
||||||
|
|
||||||
npm-install-%: ## install specified % npm package on the cookie-cutter container
|
|
||||||
docker exec npm install $* --save-dev
|
|
||||||
git add package.json
|
git add package.json
|
||||||
|
|
||||||
restart:
|
|
||||||
make down
|
|
||||||
make up
|
|
||||||
|
|
||||||
restart-detached:
|
|
||||||
make down
|
|
||||||
make up-detached
|
|
||||||
|
|
||||||
validate-no-uncommitted-package-lock-changes:
|
validate-no-uncommitted-package-lock-changes:
|
||||||
git diff --exit-code package-lock.json
|
git diff --exit-code package-lock.json
|
||||||
|
|
||||||
|
test:
|
||||||
|
npm run test
|
||||||
|
|||||||
90
README.md
90
README.md
@@ -21,13 +21,40 @@ npm i --save @edx/gradebook
|
|||||||
|
|
||||||
## Running the UI Standalone
|
## Running the UI Standalone
|
||||||
|
|
||||||
After cloning the repository, run `make up-detached` in the `gradebook` directory - this will build and start the `gradebook` web application in a docker container.
|
To install the project please refer to the [`edX Developer Stack`](https://github.com/edx/devstack) instructions.
|
||||||
|
|
||||||
The web application runs on port **1991**, so when you go to `http://localhost:1991` you should see the UI.
|
The web application runs on port **1994**, so when you go to `http://localhost:1994/course-v1:edX+DemoX+Demo_Course` you should see the UI (assuming you have such a Demo Course in your devstack). Note that you always have to provide a course id to actually see a gradebook.
|
||||||
|
|
||||||
If you don't, you can see the log messages for the docker container by executing `make logs` in the `gradebook` directory.
|
If you don't, you can see the log messages for the docker container by executing `make gradebook-logs` in the `devstack` directory.
|
||||||
|
|
||||||
Note that `make up-detached` executes the `npm run start` script which will hot-reload JavaScript and Sass files changes, so you should (:crossed_fingers:) not need to do anything (other than wait) when making changes.
|
Note that starting the container executes the `npm run start` script which will hot-reload JavaScript and Sass files changes, so you should (:crossed_fingers:) not need to do anything (other than wait) when making changes.
|
||||||
|
|
||||||
|
## Configuring for local use in edx-platform
|
||||||
|
|
||||||
|
Assuming you've got the UI running at `http://localhost:1994`, you can configure the LMS in edx-platform
|
||||||
|
to point to your local gradebook from the instructor dashboard by putting this settings in `lms/env/private.py`:
|
||||||
|
```
|
||||||
|
WRITABLE_GRADEBOOK_URL = 'http://localhost:1994'
|
||||||
|
```
|
||||||
|
|
||||||
|
There are also several edx-platform waffle and feature flags you'll have to enable from the Django admin:
|
||||||
|
|
||||||
|
1. Grades > Persistent grades enabled flag. Add this flag if it doesn't exist,
|
||||||
|
check the ``enabled`` and ``enabled for all courses`` boxes.
|
||||||
|
|
||||||
|
2. Waffle > Switches. Add the ``grades.assume_zero_grade_if_absent`` switch and make it active.
|
||||||
|
|
||||||
|
3. Waffle_utils > Waffle flag course overrides. You want to activate this flag for any course
|
||||||
|
in which you'd like to enable the gradebook. Add a course override flag using a course id and the flag name
|
||||||
|
``grades.writable_gradebook``. Make sure to check the ``enabled`` box. Alternatively, you could add this as a
|
||||||
|
regular waffle flag to enable the gradebook for all courses.
|
||||||
|
|
||||||
|
## Running tests
|
||||||
|
|
||||||
|
1. Assuming that you're operating in the context of the edX devstack,
|
||||||
|
run `gradebook-shell` from your devstack directory. This will start a bash shell inside your
|
||||||
|
running gradebook container.
|
||||||
|
2. Run `make test` (which executes `npm run test`). This will run all of the gradebook tests.
|
||||||
|
|
||||||
## Directory Structure
|
## Directory Structure
|
||||||
|
|
||||||
@@ -46,61 +73,6 @@ Note that `make up-detached` executes the `npm run start` script which will hot-
|
|||||||
* `constants`
|
* `constants`
|
||||||
* `reducers`
|
* `reducers`
|
||||||
* Directory for `Redux` reducers
|
* Directory for `Redux` reducers
|
||||||
* [`.babelrc`](#babelrc)
|
|
||||||
* [`.dockerignore`](#dockerignore)
|
|
||||||
* [`.eslintignore`](#eslintignore)
|
|
||||||
* [`.eslintrc.js`](#eslintrcjs)
|
|
||||||
* `.gitignore`
|
|
||||||
* [`npmignore`](#npmignore)
|
|
||||||
* [`.travis.yml`](#travisyml)
|
|
||||||
* `docker-compose.yml`
|
|
||||||
* `Dockerfile`
|
|
||||||
* `LICENSE`
|
|
||||||
* `Makefile`
|
|
||||||
* `package-lock.json`
|
|
||||||
* [`package.json`](#packagejson)
|
|
||||||
|
|
||||||
### `.babelrc`
|
|
||||||
|
|
||||||
We use [`Babel`](https://babeljs.io/) to transpile `ES2015+` JavaScript to `ES5` JavaScript. `ES5` JavaScript has [greater browser compatibility](http://kangax.github.io/compat-table/es5/) than [`ES2015+`](http://kangax.github.io/compat-table/es6/).
|
|
||||||
|
|
||||||
The `.babelrc` file is used to specify a particular configuration - for example, we use the [`babel-preset-react`](https://babeljs.io/docs/plugins/preset-react/), which, among other things, allows `babel` to parse `JSX`.
|
|
||||||
|
|
||||||
### `.dockerignore`
|
|
||||||
|
|
||||||
The important thing to remember is to add the `node_modules` directory to `.dockerignore` - for more information [see the Docker documentation](https://docs.docker.com/engine/reference/builder/#dockerignore-file).
|
|
||||||
|
|
||||||
### `.eslintignore`
|
|
||||||
|
|
||||||
We use [`eslint`](https://eslint.org/) for our `JavaScript` linting needs. The `.eslintignore` file is used to [specify files or directories to, well, ignore](https://eslint.org/docs/user-guide/configuring#ignoring-files-and-directories).
|
|
||||||
|
|
||||||
While `eslint` automatically ignores `node_modules`, we like to add it to the `.eslintignore` just for the added explicitness. In addition, you probably want to add the directory for your compiled files (in our case, `./dist`) and your coverage directory (in our case, `./coverage`).
|
|
||||||
|
|
||||||
### `.eslintrc`
|
|
||||||
|
|
||||||
This is where the actual `eslint` configuration is specified. All `edX` JavaScript projects should extend either the [`eslint-config-edx`](https://github.com/edx/eslint-config-edx/blob/master/packages/eslint-config-edx/README.md) or [`eslint-config-edx-es5`](https://github.com/edx/eslint-config-edx/blob/master/packages/eslint-config-edx-es5/README.md) configurations (for `ES2015+` and `ES5` JavaScript, respectively). Both configurations can be found in [the `eslint-config-edx` repository](https://github.com/edx/eslint-config-edx).
|
|
||||||
|
|
||||||
### `.npmignore`
|
|
||||||
|
|
||||||
We are not currently publishing this package to [`npm`](https://www.npmjs.com/). If we did, we would want to exclude certain files from getting uploaded to `npm` (like our coverage files, for example). For more information, see [the `npm` documentation](https://docs.npmjs.com/misc/developers#keeping-files-out-of-your-package).
|
|
||||||
|
|
||||||
### `.travis.yml`
|
|
||||||
|
|
||||||
We use [`Travis CI`](https://travis-ci.org/) to build (and deploy) our application. The `.travis.yml` file specifies the configuration for `Travis` builds. For more information, see [the `Travis` documentation](https://docs.travis-ci.com/user/customizing-the-build/).
|
|
||||||
|
|
||||||
### `package.json`
|
|
||||||
|
|
||||||
Arguably, one of the **most important files in an `npm`-based application**, the `package.json` file specifies everything from the `name` of the application, were it to be published to `npm`, to it's `dependencies`.
|
|
||||||
|
|
||||||
For more information, see [the `npm` documentation](https://docs.npmjs.com/files/package.json).
|
|
||||||
|
|
||||||
## Helpful Applications
|
|
||||||
|
|
||||||
### [`Greenkeeper`](https://greenkeeper.io/)
|
|
||||||
|
|
||||||
[`Greenkeeper`](https://greenkeeper.io/) is basically a `GitHub` application that handles `npm` dependencies. It will automatically open PRs with `package.json` updates when new versions of your `npm` dependencies get published. There are ways to also automatically keep the `package-lock.json` in-line, in the same PR, using [`greenkeeper-lockfile`].
|
|
||||||
|
|
||||||
For more information, see [the `Greenkeeper` documentation](https://greenkeeper.io/docs.html#what-greenkeeper-does).
|
|
||||||
|
|
||||||
## Authentication with backend API services
|
## Authentication with backend API services
|
||||||
|
|
||||||
|
|||||||
BIN
assets/edx-footer.png
Normal file
BIN
assets/edx-footer.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
BIN
assets/edx-sm.png
Normal file
BIN
assets/edx-sm.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
@@ -4,6 +4,7 @@ const path = require('path');
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
entry: {
|
entry: {
|
||||||
|
segment: path.resolve(__dirname, '../src/segment.js'),
|
||||||
app: path.resolve(__dirname, '../src/index.jsx'),
|
app: path.resolve(__dirname, '../src/index.jsx'),
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ module.exports = Merge.smart(commonConfig, {
|
|||||||
entry: [
|
entry: [
|
||||||
// enable react's custom hot dev client so we get errors reported in the browser
|
// enable react's custom hot dev client so we get errors reported in the browser
|
||||||
require.resolve('react-dev-utils/webpackHotDevClient'),
|
require.resolve('react-dev-utils/webpackHotDevClient'),
|
||||||
|
path.resolve(__dirname, '../src/segment.js'),
|
||||||
path.resolve(__dirname, '../src/index.jsx'),
|
path.resolve(__dirname, '../src/index.jsx'),
|
||||||
],
|
],
|
||||||
module: {
|
module: {
|
||||||
@@ -64,6 +65,28 @@ module.exports = Merge.smart(commonConfig, {
|
|||||||
test: /\.(woff2?|ttf|svg|eot)(\?v=\d+\.\d+\.\d+)?$/,
|
test: /\.(woff2?|ttf|svg|eot)(\?v=\d+\.\d+\.\d+)?$/,
|
||||||
loader: 'file-loader',
|
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.
|
// Specify additional processing or side-effects done on the Webpack output bundles as a whole.
|
||||||
@@ -75,11 +98,12 @@ module.exports = Merge.smart(commonConfig, {
|
|||||||
}),
|
}),
|
||||||
new webpack.EnvironmentPlugin({
|
new webpack.EnvironmentPlugin({
|
||||||
NODE_ENV: 'development',
|
NODE_ENV: 'development',
|
||||||
BASE_URL: 'localhost:1991',
|
BASE_URL: 'localhost:1994',
|
||||||
LMS_BASE_URL: 'http://localhost:18000',
|
LMS_BASE_URL: 'http://localhost:18000',
|
||||||
LOGIN_URL: 'http://localhost:18000/login',
|
LOGIN_URL: 'http://localhost:18000/login',
|
||||||
LOGOUT_URL: 'http://localhost:18000/login',
|
LOGOUT_URL: 'http://localhost:18000/login',
|
||||||
REFRESH_ACCESS_TOKEN_ENDPOINT: 'http://localhost:18000/login',
|
CSRF_TOKEN_API_PATH: '/csrf/api/v1/token',
|
||||||
|
REFRESH_ACCESS_TOKEN_ENDPOINT: 'http://localhost:18000/login_refresh',
|
||||||
DATA_API_BASE_URL: 'http://localhost:8000',
|
DATA_API_BASE_URL: 'http://localhost:8000',
|
||||||
// LMS_CLIENT_ID should match the lms DOT client application id your LMS container
|
// LMS_CLIENT_ID should match the lms DOT client application id your LMS container
|
||||||
LMS_CLIENT_ID: 'login-service-client-id',
|
LMS_CLIENT_ID: 'login-service-client-id',
|
||||||
@@ -87,6 +111,21 @@ module.exports = Merge.smart(commonConfig, {
|
|||||||
FEATURE_FLAGS: {},
|
FEATURE_FLAGS: {},
|
||||||
ACCESS_TOKEN_COOKIE_NAME: 'edx-jwt-cookie-header-payload',
|
ACCESS_TOKEN_COOKIE_NAME: 'edx-jwt-cookie-header-payload',
|
||||||
CSRF_COOKIE_NAME: 'csrftoken',
|
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',
|
||||||
|
GOOGLE_PLUS_URL: 'https://plus.google.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',
|
||||||
}),
|
}),
|
||||||
// when the --hot option is not passed in as part of the command
|
// when the --hot option is not passed in as part of the command
|
||||||
// the HotModuleReplacementPlugin has to be specified in the Webpack configuration
|
// the HotModuleReplacementPlugin has to be specified in the Webpack configuration
|
||||||
@@ -97,7 +136,7 @@ module.exports = Merge.smart(commonConfig, {
|
|||||||
// reloading.
|
// reloading.
|
||||||
devServer: {
|
devServer: {
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
port: 1991,
|
port: 1994,
|
||||||
historyApiFallback: true,
|
historyApiFallback: true,
|
||||||
hot: true,
|
hot: true,
|
||||||
inline: true,
|
inline: true,
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ const Merge = require('webpack-merge');
|
|||||||
const commonConfig = require('./webpack.common.config.js');
|
const commonConfig = require('./webpack.common.config.js');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const webpack = require('webpack');
|
const webpack = require('webpack');
|
||||||
const ExtractTextPlugin = require('extract-text-webpack-plugin');
|
|
||||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||||
|
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
||||||
|
|
||||||
module.exports = Merge.smart(commonConfig, {
|
module.exports = Merge.smart(commonConfig, {
|
||||||
mode: 'production',
|
mode: 'production',
|
||||||
@@ -38,29 +38,27 @@ module.exports = Merge.smart(commonConfig, {
|
|||||||
// increases build time.
|
// increases build time.
|
||||||
{
|
{
|
||||||
test: /(.scss|.css)$/,
|
test: /(.scss|.css)$/,
|
||||||
use: ExtractTextPlugin.extract({
|
use: [
|
||||||
// creates style nodes from JS strings, only used if extracting fails
|
MiniCssExtractPlugin.loader,
|
||||||
fallback: 'style-loader',
|
{
|
||||||
use: [
|
loader: 'css-loader', // translates CSS into CommonJS
|
||||||
{
|
options: {
|
||||||
loader: 'css-loader', // translates CSS into CommonJS
|
sourceMap: true,
|
||||||
options: {
|
minimize: true,
|
||||||
sourceMap: true,
|
|
||||||
minimize: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
},
|
||||||
loader: 'sass-loader', // compiles Sass to CSS
|
'postcss-loader', // for autoprefixing, needs to be before the sass loader, not sure why
|
||||||
options: {
|
{
|
||||||
sourceMap: true,
|
loader: 'sass-loader', // compiles Sass to CSS
|
||||||
includePaths: [
|
options: {
|
||||||
path.join(__dirname, '../node_modules'),
|
sourceMap: true,
|
||||||
path.join(__dirname, '../src'),
|
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
|
// 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
|
// files it processes, which just base64 encodes them and inlines them in the javascript
|
||||||
@@ -70,6 +68,28 @@ module.exports = Merge.smart(commonConfig, {
|
|||||||
test: /\.(woff2?|ttf|svg|eot)(\?v=\d+\.\d+\.\d+)?$/,
|
test: /\.(woff2?|ttf|svg|eot)(\?v=\d+\.\d+\.\d+)?$/,
|
||||||
loader: 'file-loader',
|
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
|
// New in Webpack 4. Replaces CommonChunksPlugin. Extract common modules among all chunks to one
|
||||||
@@ -83,9 +103,8 @@ module.exports = Merge.smart(commonConfig, {
|
|||||||
// Specify additional processing or side-effects done on the Webpack output bundles as a whole.
|
// Specify additional processing or side-effects done on the Webpack output bundles as a whole.
|
||||||
plugins: [
|
plugins: [
|
||||||
// Writes the extracted CSS from each entry to a file in the output directory.
|
// Writes the extracted CSS from each entry to a file in the output directory.
|
||||||
new ExtractTextPlugin({
|
new MiniCssExtractPlugin({
|
||||||
filename: '[name].min.css',
|
filename: '[name].[chunkhash].css',
|
||||||
allChunks: true,
|
|
||||||
}),
|
}),
|
||||||
// Generates an HTML file in the output directory.
|
// Generates an HTML file in the output directory.
|
||||||
new HtmlWebpackPlugin({
|
new HtmlWebpackPlugin({
|
||||||
@@ -98,6 +117,7 @@ module.exports = Merge.smart(commonConfig, {
|
|||||||
LMS_BASE_URL: null,
|
LMS_BASE_URL: null,
|
||||||
LOGIN_URL: null,
|
LOGIN_URL: null,
|
||||||
LOGOUT_URL: null,
|
LOGOUT_URL: null,
|
||||||
|
CSRF_TOKEN_API_PATH: null,
|
||||||
REFRESH_ACCESS_TOKEN_ENDPOINT: null,
|
REFRESH_ACCESS_TOKEN_ENDPOINT: null,
|
||||||
DATA_API_BASE_URL: null,
|
DATA_API_BASE_URL: null,
|
||||||
SEGMENT_KEY: null,
|
SEGMENT_KEY: null,
|
||||||
@@ -106,6 +126,21 @@ module.exports = Merge.smart(commonConfig, {
|
|||||||
CSRF_COOKIE_NAME: 'csrftoken',
|
CSRF_COOKIE_NAME: 'csrftoken',
|
||||||
NEW_RELIC_APP_ID: null,
|
NEW_RELIC_APP_ID: null,
|
||||||
NEW_RELIC_LICENSE_KEY: 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,
|
||||||
|
GOOGLE_PLUS_URL: null,
|
||||||
|
REDDIT_URL: null,
|
||||||
|
APPLE_APP_STORE_URL: null,
|
||||||
|
GOOGLE_PLAY_URL: null,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
version: "2"
|
|
||||||
services:
|
|
||||||
web:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
args:
|
|
||||||
- NODE_ENV=development
|
|
||||||
container_name: edx.gradebook
|
|
||||||
image: edxops/front-end-cookie-cutter:latest
|
|
||||||
volumes:
|
|
||||||
- .:/edx/app:delegated
|
|
||||||
- notused:/edx/app/node_modules
|
|
||||||
ports:
|
|
||||||
- "1991:1991"
|
|
||||||
environment:
|
|
||||||
- NODE_ENV=development
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
notused:
|
|
||||||
46
documentation/decisions/0001-update-api-usage.rst
Normal file
46
documentation/decisions/0001-update-api-usage.rst
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
Usage of the bulk-update API
|
||||||
|
============================
|
||||||
|
|
||||||
|
Context
|
||||||
|
=======
|
||||||
|
|
||||||
|
The LMS Grades API exposes a set of Gradebook-related endpoints:
|
||||||
|
https://github.com/edx/edx-platform/blob/master/lms/djangoapps/grades/api/v1/gradebook_views.py
|
||||||
|
The ``bulk-update`` endpoint defined therein allows for the creation/modification of subsection
|
||||||
|
grades for multiple users and sections in a single request. This allows clients of the API to limit
|
||||||
|
the number of network requests made and to more easily manage client-side data. Moreover,
|
||||||
|
the course grade updates that occur during calls to this API are synchronous - the entire update operation
|
||||||
|
is completed before a response is given to the client.
|
||||||
|
|
||||||
|
For decisions made about the implementation of this API, see:
|
||||||
|
https://github.com/edx/edx-platform/blob/master/lms/djangoapps/grades/docs/decisions/0001-gradebook-api.rst
|
||||||
|
|
||||||
|
Decision
|
||||||
|
========
|
||||||
|
|
||||||
|
The Gradebook front-end will post data about a single subsection and user in a single request
|
||||||
|
to the ``bulk-update`` API. That is, we currently need only the "update" aspect of this
|
||||||
|
endpoint, and not the "bulk" aspect, for satisfying the requirements of the current UX.
|
||||||
|
|
||||||
|
Status
|
||||||
|
======
|
||||||
|
|
||||||
|
Accepted (circa December 2018)
|
||||||
|
|
||||||
|
Consequences
|
||||||
|
============
|
||||||
|
|
||||||
|
This is a scenario in which the implementation of the API is coupled to the
|
||||||
|
UX that depends on the API. Because the course grade update is synchronous, it means
|
||||||
|
the API response can contain the updated subsection and course grade data. Because
|
||||||
|
a response from the API contains this data, the UI can operate in a very familiar way:
|
||||||
|
|
||||||
|
- A user clicks a button to submit a request with grade update data to the update endpoint.
|
||||||
|
- On the server, the subsection and course grades are modified.
|
||||||
|
- In the meantime, the client-side user looks at a spinner.
|
||||||
|
- A response is returned with updated data and the spinner goes away.
|
||||||
|
- Updated data is displayed to the user, along with a message indicative of the update.
|
||||||
|
|
||||||
|
If the update becomes asynchronous, the user experience outlined above has to change.
|
||||||
|
Because a single call to this endpoint updates grades data for only a single user,
|
||||||
|
the endpoint does not necessarily have to utilize an asynchronous operation at this time.
|
||||||
8
openedx.yaml
Normal file
8
openedx.yaml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# This file describes this Open edX repo, as described in OEP-2:
|
||||||
|
# http://open-edx-proposals.readthedocs.io/en/latest/oeps/oep-0002.html#specification
|
||||||
|
|
||||||
|
nick: grbk
|
||||||
|
oeps: {}
|
||||||
|
owner: edx/educator-neem
|
||||||
|
openedx-release: {ref: master}
|
||||||
|
track-pulls: true
|
||||||
14744
package-lock.json
generated
14744
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
88
package.json
88
package.json
@@ -25,62 +25,69 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@edx/edx-bootstrap": "^0.4.3",
|
"@edx/edx-bootstrap": "^0.4.3",
|
||||||
"@edx/frontend-auth": "1.1.0",
|
"@edx/frontend-auth": "^1.3.0",
|
||||||
"@edx/paragon": "^3.7.2",
|
"@edx/frontend-component-footer": "^1.0.0",
|
||||||
|
"@edx/paragon": "^3.8.3",
|
||||||
|
"@redux-beacon/segment": "^1.0.0",
|
||||||
"babel-polyfill": "^6.26.0",
|
"babel-polyfill": "^6.26.0",
|
||||||
"classnames": "^2.2.5",
|
"classnames": "^2.2.6",
|
||||||
"email-prop-type": "^1.1.5",
|
"email-prop-type": "^1.1.7",
|
||||||
"font-awesome": "^4.7.0",
|
"font-awesome": "^4.7.0",
|
||||||
"history": "^4.7.2",
|
"history": "^4.7.2",
|
||||||
"prop-types": "^15.5.10",
|
"prop-types": "^15.6.2",
|
||||||
"query-string": "^6.2.0",
|
"query-string": "^5.1.1",
|
||||||
"react": "^16.2.0",
|
"react": "^16.7.0",
|
||||||
"react-dom": "^16.2.0",
|
"react-dom": "^16.7.0",
|
||||||
"react-redux": "^5.0.7",
|
"react-redux": "^5.1.1",
|
||||||
"react-router": "^4.2.0",
|
"react-router": "^4.3.1",
|
||||||
"react-router-dom": "^4.2.2",
|
"react-router-dom": "^4.3.1",
|
||||||
"react-router-redux": "^5.0.0-alpha.9",
|
"react-router-redux": "^5.0.0-alpha.9",
|
||||||
"redux": "^3.7.2",
|
"redux": "^3.7.2",
|
||||||
"redux-devtools-extension": "^2.13.2",
|
"redux-devtools-extension": "^2.13.7",
|
||||||
|
"redux-beacon": "^2.0.3",
|
||||||
"redux-logger": "^3.0.6",
|
"redux-logger": "^3.0.6",
|
||||||
"redux-thunk": "^2.2.0",
|
"redux-thunk": "^2.3.0",
|
||||||
"whatwg-fetch": "^2.0.3"
|
"whatwg-fetch": "^2.0.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"autoprefixer": "^9.4.5",
|
||||||
|
"axios-mock-adapter": "^1.16.0",
|
||||||
"babel-cli": "^6.26.0",
|
"babel-cli": "^6.26.0",
|
||||||
"babel-eslint": "^8.2.2",
|
"babel-eslint": "^8.2.6",
|
||||||
"babel-jest": "^22.4.0",
|
"babel-jest": "^22.4.4",
|
||||||
"babel-loader": "^7.1.2",
|
"babel-loader": "^7.1.5",
|
||||||
"babel-plugin-transform-class-properties": "^6.24.1",
|
"babel-plugin-transform-class-properties": "^6.24.1",
|
||||||
"babel-plugin-transform-object-rest-spread": "^6.26.0",
|
"babel-plugin-transform-object-rest-spread": "^6.26.0",
|
||||||
"babel-preset-env": "^1.6.1",
|
"babel-preset-env": "^1.7.0",
|
||||||
"babel-preset-react": "^6.24.1",
|
"babel-preset-react": "^6.24.1",
|
||||||
"codecov": "^3.0.0",
|
"codecov": "^3.1.0",
|
||||||
"css-loader": "^0.28.9",
|
"css-loader": "^0.28.11",
|
||||||
"enzyme": "^3.3.0",
|
"enzyme": "^3.8.0",
|
||||||
"enzyme-adapter-react-16": "^1.1.1",
|
"enzyme-adapter-react-16": "^1.7.1",
|
||||||
"es-check": "^2.0.2",
|
"es-check": "^2.3.0",
|
||||||
"eslint-config-edx": "^4.0.3",
|
"eslint-config-edx": "^4.0.4",
|
||||||
"extract-text-webpack-plugin": "^4.0.0-beta.0",
|
"fetch-mock": "^6.5.2",
|
||||||
"fetch-mock": "^6.3.0",
|
|
||||||
"file-loader": "^1.1.9",
|
"file-loader": "^1.1.9",
|
||||||
"html-webpack-harddisk-plugin": "^0.2.0",
|
"html-webpack-harddisk-plugin": "^0.2.0",
|
||||||
"html-webpack-plugin": "^3.0.3",
|
"html-webpack-plugin": "^3.2.0",
|
||||||
"husky": "^0.14.3",
|
"husky": "^0.14.3",
|
||||||
"identity-obj-proxy": "^3.0.0",
|
"identity-obj-proxy": "^3.0.0",
|
||||||
"jest": "^22.4.0",
|
"image-webpack-loader": "^4.2.0",
|
||||||
"node-sass": "^4.7.2",
|
"jest": "^22.4.4",
|
||||||
"react-dev-utils": "^5.0.0",
|
"mini-css-extract-plugin": "^0.4.0",
|
||||||
"react-test-renderer": "^16.2.0",
|
"node-sass": "^4.11.0",
|
||||||
"redux-mock-store": "^1.5.1",
|
"postcss-loader": "^3.0.0",
|
||||||
|
"react-dev-utils": "^5.0.3",
|
||||||
|
"react-test-renderer": "^16.7.0",
|
||||||
|
"redux-mock-store": "^1.5.3",
|
||||||
"sass-loader": "^6.0.6",
|
"sass-loader": "^6.0.6",
|
||||||
"semantic-release": "^15.10.7",
|
"semantic-release": "^15.13.3",
|
||||||
"style-loader": "^0.20.2",
|
"style-loader": "^0.20.3",
|
||||||
"travis-deploy-once": "^5.0.9",
|
"travis-deploy-once": "^5.0.11",
|
||||||
"webpack": "^4.1.0",
|
"webpack": "^4.28.4",
|
||||||
"webpack-cli": "^2.0.10",
|
"webpack-cli": "^3.2.1",
|
||||||
"webpack-dev-server": "^3.1.0",
|
"webpack-dev-server": "^3.1.14",
|
||||||
"webpack-merge": "^4.1.1"
|
"webpack-merge": "^4.2.1"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"setupFiles": [
|
"setupFiles": [
|
||||||
@@ -101,6 +108,7 @@
|
|||||||
],
|
],
|
||||||
"transformIgnorePatterns": [
|
"transformIgnorePatterns": [
|
||||||
"/node_modules/(?!(@edx/paragon)/).*/"
|
"/node_modules/(?!(@edx/paragon)/).*/"
|
||||||
]
|
],
|
||||||
|
"testURL": "http://localhost"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html>
|
<html lang="en-us">
|
||||||
<head></head>
|
<head>
|
||||||
|
<title>Gradebook | edX</title>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -6,4 +6,6 @@ $fa-font-path: "~font-awesome/fonts";
|
|||||||
|
|
||||||
@import "~@edx/paragon/src/SearchField/SearchField";
|
@import "~@edx/paragon/src/SearchField/SearchField";
|
||||||
|
|
||||||
|
@import "~@edx/frontend-component-footer/src/lib/scss/site-footer";
|
||||||
|
|
||||||
@import "./components/Gradebook/gradebook";
|
@import "./components/Gradebook/gradebook";
|
||||||
|
|||||||
@@ -1,6 +1,34 @@
|
|||||||
.back-link{
|
.spinner-overlay {
|
||||||
float:right;
|
position: fixed;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
top: 0;
|
||||||
|
background-color: #999;
|
||||||
|
opacity: 0.5;
|
||||||
|
z-index: 99999;
|
||||||
|
display:flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.color-black {
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradebook-container{
|
||||||
|
width: 500px;
|
||||||
|
@media only screen and (min-width: 640px) {
|
||||||
|
width: 630px;
|
||||||
|
}
|
||||||
|
@media only screen and (min-width: 992px) {
|
||||||
|
width: 900px;
|
||||||
|
}
|
||||||
|
@media only screen and (min-width: 1200px) {
|
||||||
|
width: 1024px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.student-filters{
|
.student-filters{
|
||||||
display: flex;
|
display: flex;
|
||||||
.label{
|
.label{
|
||||||
@@ -32,10 +60,6 @@
|
|||||||
display: block;
|
display: block;
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
}
|
}
|
||||||
.table tr td:not(:first-child) {
|
|
||||||
//not real sylz. plz kill before prod
|
|
||||||
min-width: 250px;
|
|
||||||
}
|
|
||||||
.table tr td:nth-child(2) {
|
.table tr td:nth-child(2) {
|
||||||
box-sizing: content-box;
|
box-sizing: content-box;
|
||||||
padding-left: 170px;
|
padding-left: 170px;
|
||||||
@@ -44,6 +68,10 @@
|
|||||||
padding-left: 170px;
|
padding-left: 170px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.table tbody th {
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
.link-style {
|
.link-style {
|
||||||
color: #0075b4;
|
color: #0075b4;
|
||||||
&:hover, &:focus {
|
&:hover, &:focus {
|
||||||
@@ -53,3 +81,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mb-85 {
|
||||||
|
margin-bottom: 85px;
|
||||||
|
}
|
||||||
@@ -1,7 +1,19 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Button, InputSelect, Modal, SearchField, StatusAlert, Table } from '@edx/paragon';
|
import PropTypes from 'prop-types';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
InputSelect,
|
||||||
|
Modal,
|
||||||
|
SearchField,
|
||||||
|
StatusAlert,
|
||||||
|
Table,
|
||||||
|
Icon,
|
||||||
|
} from '@edx/paragon';
|
||||||
import queryString from 'query-string';
|
import queryString from 'query-string';
|
||||||
import { configuration } from '../../config';
|
import { configuration } from '../../config';
|
||||||
|
import PageButtons from '../PageButtons';
|
||||||
|
|
||||||
|
const DECIMAL_PRECISION = 2;
|
||||||
|
|
||||||
export default class Gradebook extends React.Component {
|
export default class Gradebook extends React.Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
@@ -13,32 +25,61 @@ export default class Gradebook extends React.Component {
|
|||||||
updateVal: 0,
|
updateVal: 0,
|
||||||
updateModuleId: null,
|
updateModuleId: null,
|
||||||
updateUserId: null,
|
updateUserId: null,
|
||||||
|
socket: null,
|
||||||
|
websocketMsg: {
|
||||||
|
visible: false,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
const urlQuery = queryString.parse(this.props.location.search);
|
const urlQuery = queryString.parse(this.props.location.search);
|
||||||
this.props.getUserGrades(
|
this.props.getRoles(this.props.match.params.courseId, urlQuery);
|
||||||
this.props.match.params.courseId,
|
const socket = new WebSocket('ws://localhost:8765/ws/gradebook/course-v1:edX+DemoX+Demo_Course/');
|
||||||
urlQuery.cohort,
|
socket.onmessage = this.socketMessageFunction;
|
||||||
urlQuery.track,
|
}
|
||||||
);
|
|
||||||
this.props.getTracks(this.props.match.params.courseId);
|
socketMessageFunction = (event) => {
|
||||||
this.props.getCohorts(this.props.match.params.courseId);
|
var data = JSON.parse(event.data);
|
||||||
|
console.log(data);
|
||||||
|
|
||||||
|
const userIndex = this.props.grades.findIndex((entry) => entry.user_id == data.user_id);
|
||||||
|
const username = this.props.grades[userIndex].username;
|
||||||
|
const subsectionIndex = this.props.grades[userIndex].section_breakdown.findIndex((entry) => entry.module_id = data.subsection_id);
|
||||||
|
const subsectionName = this.props.grades[userIndex].section_breakdown[subsectionIndex].label;
|
||||||
|
|
||||||
|
let subsectionGrade = this.props.grades[userIndex].section_breakdown[subsectionIndex];
|
||||||
|
subsectionGrade.score_earned = data.override.earned_graded_override;
|
||||||
|
subsectionGrade.score_possible = data.override.possible_graded_override;
|
||||||
|
|
||||||
|
const updatedMsg = {
|
||||||
|
visible: true,
|
||||||
|
username: username,
|
||||||
|
subsectionName: subsectionName,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.setState({ websocketMsg: updatedMsg });
|
||||||
|
this.props.gradeUpdateSuccess(this.props.match.params.courseId, this.props.grades);
|
||||||
}
|
}
|
||||||
|
|
||||||
setNewModalState = (userEntry, subsection) => {
|
setNewModalState = (userEntry, subsection) => {
|
||||||
|
let adjustedGradePossible = '';
|
||||||
|
let currentGradePossible = '';
|
||||||
|
if (subsection.attempted) {
|
||||||
|
adjustedGradePossible = ` / ${subsection.score_possible}`;
|
||||||
|
currentGradePossible = `/${subsection.score_possible}`;
|
||||||
|
}
|
||||||
this.setState({
|
this.setState({
|
||||||
modalModel: [{
|
modalModel: [{
|
||||||
username: userEntry.username,
|
username: userEntry.username,
|
||||||
currentGrade: `${subsection.score_earned}/${subsection.score_possible}`,
|
currentGrade: `${subsection.score_earned}${currentGradePossible}`,
|
||||||
adjustedGrade: (
|
adjustedGrade: (
|
||||||
<span>
|
<span>
|
||||||
<input
|
<input
|
||||||
style={{ width: '25px' }}
|
style={{ width: '25px' }}
|
||||||
type="text"
|
type="text"
|
||||||
onChange={event => this.setState({ updateVal: event.target.value })}
|
onChange={event => this.setState({ updateVal: event.target.value })}
|
||||||
/> / {subsection.score_possible}
|
/>{adjustedGradePossible}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
assignmentName: `${subsection.subsection_name}`,
|
assignmentName: `${subsection.subsection_name}`,
|
||||||
@@ -50,15 +91,20 @@ export default class Gradebook extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleAdjustedGradeClick = () => {
|
handleAdjustedGradeClick = () => {
|
||||||
this.props.updateGrades(this.props.match.params.courseId, [
|
this.props.updateGrades(
|
||||||
{
|
this.props.match.params.courseId, [
|
||||||
user_id: this.state.updateUserId,
|
{
|
||||||
usage_id: this.state.updateModuleId,
|
user_id: this.state.updateUserId,
|
||||||
grade: {
|
usage_id: this.state.updateModuleId,
|
||||||
earned_graded_override: this.state.updateVal,
|
grade: {
|
||||||
|
earned_graded_override: this.state.updateVal,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
],
|
||||||
]);
|
this.state.filterValue,
|
||||||
|
this.props.selectedCohort,
|
||||||
|
this.props.selectedTrack,
|
||||||
|
);
|
||||||
|
|
||||||
this.setState({
|
this.setState({
|
||||||
modalModel: [{}],
|
modalModel: [{}],
|
||||||
@@ -74,6 +120,15 @@ export default class Gradebook extends React.Component {
|
|||||||
return `?${queryString.stringify(parsed)}`;
|
return `?${queryString.stringify(parsed)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
mapAssignmentTypeEntries = (entries) => {
|
||||||
|
const mapped = entries.map(entry => ({
|
||||||
|
id: entry,
|
||||||
|
label: entry,
|
||||||
|
}));
|
||||||
|
mapped.unshift({ id: 0, label: 'All' });
|
||||||
|
return mapped;
|
||||||
|
};
|
||||||
|
|
||||||
mapCohortsEntries = (entries) => {
|
mapCohortsEntries = (entries) => {
|
||||||
const mapped = entries.map(entry => ({
|
const mapped = entries.map(entry => ({
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
@@ -92,6 +147,12 @@ export default class Gradebook extends React.Component {
|
|||||||
return mapped;
|
return mapped;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
updateAssignmentTypes = (event) => {
|
||||||
|
this.props.filterColumns(event, this.props.grades[0]);
|
||||||
|
const updatedQueryStrings = this.updateQueryParams('assignmentType', event);
|
||||||
|
this.props.history.push(updatedQueryStrings);
|
||||||
|
}
|
||||||
|
|
||||||
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;
|
||||||
@@ -102,6 +163,7 @@ export default class Gradebook extends React.Component {
|
|||||||
this.props.match.params.courseId,
|
this.props.match.params.courseId,
|
||||||
this.props.selectedCohort,
|
this.props.selectedCohort,
|
||||||
selectedTrackSlug,
|
selectedTrackSlug,
|
||||||
|
this.props.selectedAssignmentType,
|
||||||
);
|
);
|
||||||
const updatedQueryStrings = this.updateQueryParams('track', selectedTrackSlug);
|
const updatedQueryStrings = this.updateQueryParams('track', selectedTrackSlug);
|
||||||
this.props.history.push(updatedQueryStrings);
|
this.props.history.push(updatedQueryStrings);
|
||||||
@@ -117,9 +179,9 @@ export default class Gradebook extends React.Component {
|
|||||||
this.props.match.params.courseId,
|
this.props.match.params.courseId,
|
||||||
selectedCohortId,
|
selectedCohortId,
|
||||||
this.props.selectedTrack,
|
this.props.selectedTrack,
|
||||||
|
this.props.selectedAssignmentType,
|
||||||
);
|
);
|
||||||
const updatedQueryStrings = this.updateQueryParams('cohort', selectedCohortId);
|
this.updateQueryParams('cohort', selectedCohortId);
|
||||||
this.props.history.push(updatedQueryStrings);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
mapSelectedCohortEntry = (entry) => {
|
mapSelectedCohortEntry = (entry) => {
|
||||||
@@ -138,41 +200,56 @@ export default class Gradebook extends React.Component {
|
|||||||
return 'Tracks';
|
return 'Tracks';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
roundGrade = percent => parseFloat((percent || 0).toFixed(DECIMAL_PRECISION));
|
||||||
|
|
||||||
formatter = {
|
formatter = {
|
||||||
percent: entries => entries.map((entry) => {
|
percent: (entries, areGradesFrozen) => entries.map((entry) => {
|
||||||
const results = { username: entry.username };
|
const results = { username: entry.username };
|
||||||
const assignments = entry.section_breakdown
|
const assignments = entry.section_breakdown
|
||||||
.filter(section => section.is_graded)
|
|
||||||
.reduce((acc, subsection) => {
|
.reduce((acc, subsection) => {
|
||||||
acc[subsection.label] = (
|
if (areGradesFrozen) {
|
||||||
<button
|
acc[subsection.label] = `${this.roundGrade(subsection.percent * 100)} %`;
|
||||||
className="btn btn-header link-style"
|
} else {
|
||||||
onClick={() => this.setNewModalState(entry, subsection)}
|
acc[subsection.label] = (
|
||||||
>
|
<button
|
||||||
{subsection.percent}
|
className="btn btn-header link-style"
|
||||||
</button>);
|
onClick={() => this.setNewModalState(entry, subsection)}
|
||||||
|
>
|
||||||
|
{this.roundGrade(subsection.percent * 100)}%
|
||||||
|
</button>);
|
||||||
|
}
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
const totals = { total: entry.percent * 100 };
|
const totals = { total: `${this.roundGrade(entry.percent * 100)}%` };
|
||||||
return Object.assign(results, assignments, totals);
|
return Object.assign(results, assignments, totals);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
absolute: entries => entries.map((entry) => {
|
absolute: (entries, areGradesFrozen) => entries.map((entry) => {
|
||||||
const results = { username: entry.username };
|
const results = { username: entry.username };
|
||||||
const assignments = entry.section_breakdown
|
const assignments = entry.section_breakdown
|
||||||
.filter(section => section.is_graded)
|
|
||||||
.reduce((acc, subsection) => {
|
.reduce((acc, subsection) => {
|
||||||
acc[subsection.label] = (
|
const scoreEarned = this.roundGrade(subsection.score_earned);
|
||||||
<button
|
const scorePossible = this.roundGrade(subsection.score_possible);
|
||||||
className="btn btn-header link-style"
|
let label = `${scoreEarned}`;
|
||||||
onClick={() => this.setNewModalState(entry, subsection)}
|
if (subsection.attempted) {
|
||||||
>
|
label = `${scoreEarned}/${scorePossible}`;
|
||||||
{subsection.score_earned}/{subsection.score_possible}
|
}
|
||||||
</button>);
|
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;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
const totals = { total: entry.percent * 100 };
|
const totals = { total: `${this.roundGrade(entry.percent * 100)}/100` };
|
||||||
return Object.assign(results, assignments, totals);
|
return Object.assign(results, assignments, totals);
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -182,116 +259,120 @@ export default class Gradebook extends React.Component {
|
|||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<div className="d-flex justify-content-center">
|
<div className="d-flex justify-content-center">
|
||||||
<div className="card" style={{ width: '50rem' }}>
|
{ this.props.showSpinner && <div className="spinner-overlay"><Icon className={['fa', 'fa-spinner', 'fa-spin', 'fa-5x', 'color-black']} /></div>}
|
||||||
<div className="card-body">
|
<div className="gradebook-container">
|
||||||
|
<div>
|
||||||
<a
|
<a
|
||||||
href={this.lmsInstructorDashboardUrl(this.props.match.params.courseId)}
|
href={this.lmsInstructorDashboardUrl(this.props.match.params.courseId)}
|
||||||
className="back-link"
|
className="mb-3"
|
||||||
>
|
>
|
||||||
Back to Dashboard
|
<span aria-hidden="true">{'<< '}</span> {'Back to Dashboard'}
|
||||||
</a>
|
</a>
|
||||||
<h1>Gradebook</h1>
|
<h1>Gradebook</h1>
|
||||||
<h3> {this.props.match.params.courseId}</h3>
|
<h3> {this.props.match.params.courseId}</h3>
|
||||||
|
{ this.props.areGradesFrozen &&
|
||||||
|
<div className="alert alert-warning" role="alert" >
|
||||||
|
The grades for this course are now frozen. Editing of grades is no longer allowed.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
{ (this.props.canUserViewGradebook === false) &&
|
||||||
|
<div className="alert alert-warning" role="alert" >
|
||||||
|
You are not authorized to view the gradebook for this course.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<hr />
|
<hr />
|
||||||
<div className="d-flex justify-content-between" >
|
<div className="d-flex justify-content-between" >
|
||||||
<div>
|
<div>
|
||||||
<div>
|
<div role="radiogroup" aria-labelledby="score-view-group-label">
|
||||||
Score View:
|
<span id="score-view-group-label">Score View:</span>
|
||||||
<span>
|
<span>
|
||||||
<input
|
<label className="mr-2" htmlFor="score-view-percent">
|
||||||
id="score-view-percent"
|
<input
|
||||||
className="ml-2 mr-1"
|
id="score-view-percent"
|
||||||
type="radio"
|
className="ml-2 mr-1"
|
||||||
name="score-view"
|
type="radio"
|
||||||
value="percent"
|
name="score-view"
|
||||||
onClick={() => this.props.toggleFormat('percent')}
|
value="percent"
|
||||||
/>
|
defaultChecked
|
||||||
<label className="mr-2" htmlFor="score-view-percent">Percent</label>
|
onClick={() => this.props.toggleFormat('percent')}
|
||||||
</span>
|
/>
|
||||||
<span>
|
Percent
|
||||||
<input
|
|
||||||
id="score-view-absolute"
|
|
||||||
type="radio"
|
|
||||||
name="score-view"
|
|
||||||
value="absolute"
|
|
||||||
className="mr-1"
|
|
||||||
onClick={() => this.props.toggleFormat('absolute')}
|
|
||||||
/>
|
|
||||||
<label htmlFor="score-view-absolute">Absolute</label>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
Category:
|
|
||||||
<span>
|
|
||||||
<input
|
|
||||||
id="category-all"
|
|
||||||
className="ml-2 mr-1"
|
|
||||||
type="radio"
|
|
||||||
name="category"
|
|
||||||
value="all"
|
|
||||||
onClick={() => this.props.filterColumns('all', this.props.grades[0])}
|
|
||||||
/>
|
|
||||||
<label className="mr-2" htmlFor="category-all">
|
|
||||||
All
|
|
||||||
</label>
|
</label>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<input
|
<label htmlFor="score-view-absolute">
|
||||||
id="category-homework"
|
<input
|
||||||
className="mr-1"
|
id="score-view-absolute"
|
||||||
type="radio"
|
type="radio"
|
||||||
name="category"
|
name="score-view"
|
||||||
value="homework"
|
value="absolute"
|
||||||
onClick={() => this.props.filterColumns('hw', this.props.grades[0])}
|
className="mr-1"
|
||||||
/>
|
onClick={() => this.props.toggleFormat('absolute')}
|
||||||
<label className="mr-2" htmlFor="category-homework">Homework</label>
|
/>
|
||||||
</span>
|
Absolute
|
||||||
<span>
|
|
||||||
<input
|
|
||||||
id="category-exam"
|
|
||||||
type="radio"
|
|
||||||
name="category"
|
|
||||||
value="exam"
|
|
||||||
className="ml-2 mr-1"
|
|
||||||
onClick={() => this.props.filterColumns('exam', this.props.grades[0])}
|
|
||||||
/>
|
|
||||||
<label htmlFor="category-exam">
|
|
||||||
Exam
|
|
||||||
</label>
|
</label>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{(this.props.tracks.length > 0 || this.props.cohorts.length > 0) &&
|
{ this.props.assignmentTypes.length > 0 &&
|
||||||
<div className="student-filters">
|
<div className="student-filters">
|
||||||
<span className="label">
|
<span className="label">
|
||||||
Student Groups:
|
Assignment Types:
|
||||||
</span>
|
</span>
|
||||||
{this.props.tracks.length > 0 &&
|
<InputSelect
|
||||||
<InputSelect
|
name="assignment-types"
|
||||||
name="Tracks"
|
ariaLabel="Assignment Types"
|
||||||
value={this.mapSelectedTrackEntry(this.props.selectedTrack)}
|
value={this.props.selectedAssignmentType}
|
||||||
options={this.mapTracksEntries(this.props.tracks)}
|
options={this.mapAssignmentTypeEntries(this.props.assignmentTypes)}
|
||||||
onChange={this.updateTracks}
|
onChange={this.updateAssignmentTypes}
|
||||||
/>
|
/>
|
||||||
}
|
|
||||||
{this.props.cohorts.length > 0 &&
|
|
||||||
<InputSelect
|
|
||||||
name="Cohorts"
|
|
||||||
value={this.mapSelectedCohortEntry(this.props.selectedCohort)}
|
|
||||||
options={this.mapCohortsEntries(this.props.cohorts)}
|
|
||||||
onChange={this.updateCohorts}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
<div className="student-filters">
|
||||||
|
<span className="label">
|
||||||
|
Student Groups:
|
||||||
|
</span>
|
||||||
|
<InputSelect
|
||||||
|
name="Tracks"
|
||||||
|
ariaLabel="Tracks"
|
||||||
|
disabled={this.props.tracks.length === 0}
|
||||||
|
value={this.mapSelectedTrackEntry(this.props.selectedTrack)}
|
||||||
|
options={this.mapTracksEntries(this.props.tracks)}
|
||||||
|
onChange={this.updateTracks}
|
||||||
|
/>
|
||||||
|
<InputSelect
|
||||||
|
name="Cohorts"
|
||||||
|
ariaLabel="Cohorts"
|
||||||
|
disabled={this.props.cohorts.length === 0}
|
||||||
|
value={this.mapSelectedCohortEntry(this.props.selectedCohort)}
|
||||||
|
options={this.mapCohortsEntries(this.props.cohorts)}
|
||||||
|
onChange={this.updateCohorts}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ marginLeft: '10px', marginBottom: '10px' }}>
|
<div style={{ marginLeft: '10px', marginBottom: '10px' }}>
|
||||||
<a href={`${this.lmsInstructorDashboardUrl(this.props.match.params.courseId)}#view-data_download`}>Download Grade Report</a>
|
<a className="btn btn-outline-primary mb-85" href={`${this.lmsInstructorDashboardUrl(this.props.match.params.courseId)}#view-data_download`}>Generate Grade Report</a>
|
||||||
</div>
|
</div>
|
||||||
<SearchField
|
<SearchField
|
||||||
onSubmit={value => this.props.searchForUser(this.props.match.params.courseId, value, this.props.selectedCohort, this.props.selectedTrack)}
|
onSubmit={value =>
|
||||||
|
this.props.searchForUser(
|
||||||
|
this.props.match.params.courseId,
|
||||||
|
value,
|
||||||
|
this.props.selectedCohort,
|
||||||
|
this.props.selectedTrack,
|
||||||
|
this.props.selectedAssignmentType,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
inputLabel="Search Username:"
|
||||||
onChange={filterValue => this.setState({ filterValue })}
|
onChange={filterValue => this.setState({ filterValue })}
|
||||||
onClear={() => this.props.getUserGrades(this.props.match.params.courseId, this.props.selectedCohort, this.props.selectedTrack)}
|
onClear={() =>
|
||||||
|
this.props.getUserGrades(
|
||||||
|
this.props.match.params.courseId,
|
||||||
|
this.props.selectedCohort,
|
||||||
|
this.props.selectedTrack,
|
||||||
|
this.props.selectedAssignmentType,
|
||||||
|
)
|
||||||
|
}
|
||||||
value={this.state.filterValue}
|
value={this.state.filterValue}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -303,18 +384,28 @@ export default class Gradebook extends React.Component {
|
|||||||
onClose={() => this.props.updateBanner(false)}
|
onClose={() => this.props.updateBanner(false)}
|
||||||
open={this.props.showSuccess}
|
open={this.props.showSuccess}
|
||||||
/>
|
/>
|
||||||
|
<StatusAlert
|
||||||
|
alertType="success"
|
||||||
|
dialog={`Grade for user ${this.state.websocketMsg.username} in ${this.state.websocketMsg.subsectionName} was updated.`}
|
||||||
|
onClose={() => this.setState({ websocketMsg : false })}
|
||||||
|
open={this.state.websocketMsg.visible}
|
||||||
|
/>
|
||||||
|
{PageButtons(this.props)}
|
||||||
<div className="gbook">
|
<div className="gbook">
|
||||||
<Table
|
<Table
|
||||||
columns={this.props.headings}
|
columns={this.props.headings}
|
||||||
data={this.formatter[this.props.format](this.props.grades)}
|
data={this.formatter[this.props.format](
|
||||||
tableSortable
|
this.props.grades,
|
||||||
defaultSortDirection="asc"
|
this.props.areGradesFrozen,
|
||||||
defaultSortedColumn="username"
|
)}
|
||||||
|
rowHeaderColumnKey="username"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{PageButtons(this.props)}
|
||||||
<Modal
|
<Modal
|
||||||
open={this.state.modalOpen}
|
open={this.state.modalOpen}
|
||||||
title="Edit Grades"
|
title="Edit Grades"
|
||||||
|
closeText="Cancel"
|
||||||
body={(
|
body={(
|
||||||
<div>
|
<div>
|
||||||
<h3>{this.state.modalModel[0].assignmentName}</h3>
|
<h3>{this.state.modalModel[0].assignmentName}</h3>
|
||||||
@@ -322,11 +413,12 @@ export default class Gradebook extends React.Component {
|
|||||||
columns={[{ label: 'Username', key: 'username' }, { label: 'Current grade', key: 'currentGrade' }, { label: 'Adjusted grade', key: 'adjustedGrade' }]}
|
columns={[{ label: 'Username', key: 'username' }, { label: 'Current grade', key: 'currentGrade' }, { label: 'Adjusted grade', key: 'adjustedGrade' }]}
|
||||||
data={this.state.modalModel}
|
data={this.state.modalModel}
|
||||||
/>
|
/>
|
||||||
|
<div>Note: Once you save, your changes will be visible to students.</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
buttons={[
|
buttons={[
|
||||||
<Button
|
<Button
|
||||||
label="Edit Grade"
|
label="Save Grade"
|
||||||
buttonType="primary"
|
buttonType="primary"
|
||||||
onClick={this.handleAdjustedGradeClick}
|
onClick={this.handleAdjustedGradeClick}
|
||||||
/>,
|
/>,
|
||||||
@@ -346,3 +438,78 @@ export default class Gradebook extends React.Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Gradebook.defaultProps = {
|
||||||
|
areGradesFrozen: false,
|
||||||
|
assignmentTypes: [],
|
||||||
|
canUserViewGradebook: false,
|
||||||
|
cohorts: [],
|
||||||
|
grades: [],
|
||||||
|
location: {
|
||||||
|
search: '',
|
||||||
|
},
|
||||||
|
match: {
|
||||||
|
params: {
|
||||||
|
courseId: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
selectedCohort: null,
|
||||||
|
selectedTrack: null,
|
||||||
|
selectedAssignmentType: 'All',
|
||||||
|
showSpinner: false,
|
||||||
|
tracks: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
Gradebook.propTypes = {
|
||||||
|
areGradesFrozen: PropTypes.bool,
|
||||||
|
assignmentTypes: PropTypes.arrayOf(PropTypes.string),
|
||||||
|
canUserViewGradebook: PropTypes.bool,
|
||||||
|
cohorts: PropTypes.arrayOf(PropTypes.string),
|
||||||
|
filterColumns: PropTypes.func.isRequired,
|
||||||
|
format: PropTypes.string.isRequired,
|
||||||
|
getRoles: PropTypes.func.isRequired,
|
||||||
|
getUserGrades: 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,
|
||||||
|
})),
|
||||||
|
headings: PropTypes.arrayOf(PropTypes.shape({
|
||||||
|
label: PropTypes.string,
|
||||||
|
key: PropTypes.string,
|
||||||
|
})).isRequired,
|
||||||
|
history: PropTypes.shape({
|
||||||
|
push: PropTypes.func,
|
||||||
|
}).isRequired,
|
||||||
|
location: PropTypes.shape({
|
||||||
|
search: PropTypes.string,
|
||||||
|
}),
|
||||||
|
match: PropTypes.shape({
|
||||||
|
params: PropTypes.shape({
|
||||||
|
courseId: PropTypes.string,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
searchForUser: PropTypes.func.isRequired,
|
||||||
|
selectedAssignmentType: PropTypes.string,
|
||||||
|
selectedCohort: PropTypes.shape({
|
||||||
|
name: PropTypes.string,
|
||||||
|
}),
|
||||||
|
selectedTrack: PropTypes.string,
|
||||||
|
showSpinner: PropTypes.bool,
|
||||||
|
showSuccess: PropTypes.bool.isRequired,
|
||||||
|
toggleFormat: PropTypes.func.isRequired,
|
||||||
|
tracks: PropTypes.arrayOf(PropTypes.shape({
|
||||||
|
name: PropTypes.string,
|
||||||
|
})),
|
||||||
|
updateBanner: PropTypes.func.isRequired,
|
||||||
|
updateGrades: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|||||||
23
src/components/Header/index.jsx
Normal file
23
src/components/Header/index.jsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Hyperlink } from '@edx/paragon';
|
||||||
|
|
||||||
|
import EdxLogo from '../../../assets/edx-sm.png';
|
||||||
|
|
||||||
|
export default class Header extends React.Component {
|
||||||
|
renderLogo() {
|
||||||
|
return (
|
||||||
|
<img src={EdxLogo} alt="edX logo" height="30" width="60" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<div className="mb-3">
|
||||||
|
<header className="d-flex justify-content-center align-items-center p-3 border-bottom-blue">
|
||||||
|
<Hyperlink content={this.renderLogo()} destination="https://www.edx.org" />
|
||||||
|
<div />
|
||||||
|
</header>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/components/PageButtons/PageButtons.test.jsx
Normal file
36
src/components/PageButtons/PageButtons.test.jsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import renderer from 'react-test-renderer';
|
||||||
|
import PageButtons from '.';
|
||||||
|
|
||||||
|
const createInput = function createInput(prevPage, nextPage) {
|
||||||
|
return {
|
||||||
|
prevPage,
|
||||||
|
nextPage,
|
||||||
|
selectedTrack: 't',
|
||||||
|
selectedCohort: 'c',
|
||||||
|
getPrevNextGrades() {},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('PageButtons', () => {
|
||||||
|
const assertPageButtonsSnapshot = function assertPageButtonsSnapshot(input) {
|
||||||
|
const pb = renderer.create(PageButtons(input));
|
||||||
|
const tree = pb.toJSON();
|
||||||
|
expect(tree).toMatchSnapshot();
|
||||||
|
};
|
||||||
|
|
||||||
|
it('prev null, next null', () => {
|
||||||
|
assertPageButtonsSnapshot(createInput(null, null));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prev null, next not null', () => {
|
||||||
|
assertPageButtonsSnapshot(createInput(null, 'np'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prev not null, next null', () => {
|
||||||
|
assertPageButtonsSnapshot(createInput('pp', null));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prev not null, next not null', () => {
|
||||||
|
assertPageButtonsSnapshot(createInput('pp', 'np'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`PageButtons prev not null, next not null 1`] = `
|
||||||
|
<div
|
||||||
|
className="d-flex justify-content-center"
|
||||||
|
style={
|
||||||
|
Object {
|
||||||
|
"paddingBottom": "20px",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-primary"
|
||||||
|
disabled={false}
|
||||||
|
onBlur={[Function]}
|
||||||
|
onClick={[Function]}
|
||||||
|
onKeyDown={[Function]}
|
||||||
|
style={
|
||||||
|
Object {
|
||||||
|
"margin": "20px",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Previous Page
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-primary"
|
||||||
|
disabled={false}
|
||||||
|
onBlur={[Function]}
|
||||||
|
onClick={[Function]}
|
||||||
|
onKeyDown={[Function]}
|
||||||
|
style={
|
||||||
|
Object {
|
||||||
|
"margin": "20px",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Next Page
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`PageButtons prev not null, next null 1`] = `
|
||||||
|
<div
|
||||||
|
className="d-flex justify-content-center"
|
||||||
|
style={
|
||||||
|
Object {
|
||||||
|
"paddingBottom": "20px",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-primary"
|
||||||
|
disabled={false}
|
||||||
|
onBlur={[Function]}
|
||||||
|
onClick={[Function]}
|
||||||
|
onKeyDown={[Function]}
|
||||||
|
style={
|
||||||
|
Object {
|
||||||
|
"margin": "20px",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Previous Page
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-primary"
|
||||||
|
disabled={true}
|
||||||
|
onBlur={[Function]}
|
||||||
|
onClick={[Function]}
|
||||||
|
onKeyDown={[Function]}
|
||||||
|
style={
|
||||||
|
Object {
|
||||||
|
"margin": "20px",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Next Page
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`PageButtons prev null, next not null 1`] = `
|
||||||
|
<div
|
||||||
|
className="d-flex justify-content-center"
|
||||||
|
style={
|
||||||
|
Object {
|
||||||
|
"paddingBottom": "20px",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-primary"
|
||||||
|
disabled={true}
|
||||||
|
onBlur={[Function]}
|
||||||
|
onClick={[Function]}
|
||||||
|
onKeyDown={[Function]}
|
||||||
|
style={
|
||||||
|
Object {
|
||||||
|
"margin": "20px",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Previous Page
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-primary"
|
||||||
|
disabled={false}
|
||||||
|
onBlur={[Function]}
|
||||||
|
onClick={[Function]}
|
||||||
|
onKeyDown={[Function]}
|
||||||
|
style={
|
||||||
|
Object {
|
||||||
|
"margin": "20px",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Next Page
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`PageButtons prev null, next null 1`] = `
|
||||||
|
<div
|
||||||
|
className="d-flex justify-content-center"
|
||||||
|
style={
|
||||||
|
Object {
|
||||||
|
"paddingBottom": "20px",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-primary"
|
||||||
|
disabled={true}
|
||||||
|
onBlur={[Function]}
|
||||||
|
onClick={[Function]}
|
||||||
|
onKeyDown={[Function]}
|
||||||
|
style={
|
||||||
|
Object {
|
||||||
|
"margin": "20px",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Previous Page
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-primary"
|
||||||
|
disabled={true}
|
||||||
|
onBlur={[Function]}
|
||||||
|
onClick={[Function]}
|
||||||
|
onKeyDown={[Function]}
|
||||||
|
style={
|
||||||
|
Object {
|
||||||
|
"margin": "20px",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Next Page
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
77
src/components/PageButtons/index.jsx
Normal file
77
src/components/PageButtons/index.jsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { Button } from '@edx/paragon';
|
||||||
|
|
||||||
|
|
||||||
|
export default function PageButtons({
|
||||||
|
prevPage, nextPage, selectedTrack, selectedCohort, selectedAssignmentType,
|
||||||
|
getPrevNextGrades, match,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="d-flex justify-content-center"
|
||||||
|
style={{ paddingBottom: '20px' }}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
label="Previous Page"
|
||||||
|
style={{ margin: '20px' }}
|
||||||
|
buttonType="outline-primary"
|
||||||
|
disabled={!prevPage}
|
||||||
|
onClick={() =>
|
||||||
|
getPrevNextGrades(
|
||||||
|
prevPage,
|
||||||
|
match.params.courseId,
|
||||||
|
selectedCohort,
|
||||||
|
selectedTrack,
|
||||||
|
selectedAssignmentType,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Next Page"
|
||||||
|
style={{ margin: '20px' }}
|
||||||
|
buttonType="outline-primary"
|
||||||
|
disabled={!nextPage}
|
||||||
|
onClick={() =>
|
||||||
|
getPrevNextGrades(
|
||||||
|
nextPage,
|
||||||
|
match.params.courseId,
|
||||||
|
selectedCohort,
|
||||||
|
selectedTrack,
|
||||||
|
selectedAssignmentType,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
PageButtons.defaultProps = {
|
||||||
|
match: {
|
||||||
|
params: {
|
||||||
|
courseId: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
nextPage: '',
|
||||||
|
prevPage: '',
|
||||||
|
selectedCohort: null,
|
||||||
|
selectedTrack: null,
|
||||||
|
selectedAssignmentType: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
PageButtons.propTypes = {
|
||||||
|
getPrevNextGrades: PropTypes.func.isRequired,
|
||||||
|
match: PropTypes.shape({
|
||||||
|
params: PropTypes.shape({
|
||||||
|
courseId: PropTypes.string,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
nextPage: PropTypes.string,
|
||||||
|
prevPage: PropTypes.string,
|
||||||
|
selectedAssignmentType: PropTypes.string,
|
||||||
|
selectedCohort: PropTypes.shape({
|
||||||
|
name: PropTypes.string,
|
||||||
|
}),
|
||||||
|
selectedTrack: PropTypes.shape({
|
||||||
|
name: PropTypes.string,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
@@ -3,6 +3,7 @@ const configuration = {
|
|||||||
LMS_BASE_URL: process.env.LMS_BASE_URL,
|
LMS_BASE_URL: process.env.LMS_BASE_URL,
|
||||||
LOGIN_URL: process.env.LOGIN_URL,
|
LOGIN_URL: process.env.LOGIN_URL,
|
||||||
LOGOUT_URL: process.env.LOGOUT_URL,
|
LOGOUT_URL: process.env.LOGOUT_URL,
|
||||||
|
CSRF_TOKEN_API_PATH: process.env.CSRF_TOKEN_API_PATH,
|
||||||
REFRESH_ACCESS_TOKEN_ENDPOINT: process.env.REFRESH_ACCESS_TOKEN_ENDPOINT,
|
REFRESH_ACCESS_TOKEN_ENDPOINT: process.env.REFRESH_ACCESS_TOKEN_ENDPOINT,
|
||||||
DATA_API_BASE_URL: process.env.DATA_API_BASE_URL,
|
DATA_API_BASE_URL: process.env.DATA_API_BASE_URL,
|
||||||
SECURE_COOKIES: process.env.NODE_ENV !== 'development',
|
SECURE_COOKIES: process.env.NODE_ENV !== 'development',
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import Gradebook from '../../components/Gradebook';
|
|||||||
import {
|
import {
|
||||||
fetchGrades,
|
fetchGrades,
|
||||||
fetchMatchingUserGrades,
|
fetchMatchingUserGrades,
|
||||||
|
fetchPrevNextGrades,
|
||||||
|
gradeUpdateSuccess,
|
||||||
updateGrades,
|
updateGrades,
|
||||||
toggleGradeFormat,
|
toggleGradeFormat,
|
||||||
filterColumns,
|
filterColumns,
|
||||||
@@ -11,6 +13,17 @@ import {
|
|||||||
} 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 { fetchAssignmentTypes } from '../../data/actions/assignmentTypes';
|
||||||
|
import { getRoles } from '../../data/actions/roles';
|
||||||
|
|
||||||
|
function shouldShowSpinner(state) {
|
||||||
|
if (state.roles.canUserViewGradebook === true) {
|
||||||
|
return state.grades.showSpinner;
|
||||||
|
} else if (state.roles.canUserViewGradebook === false) {
|
||||||
|
return false;
|
||||||
|
} // canUserViewGradebook === null
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const mapStateToProps = state => (
|
const mapStateToProps = state => (
|
||||||
{
|
{
|
||||||
@@ -20,18 +33,28 @@ const mapStateToProps = state => (
|
|||||||
cohorts: state.cohorts.results,
|
cohorts: state.cohorts.results,
|
||||||
selectedTrack: state.grades.selectedTrack,
|
selectedTrack: state.grades.selectedTrack,
|
||||||
selectedCohort: state.grades.selectedCohort,
|
selectedCohort: state.grades.selectedCohort,
|
||||||
|
selectedAssignmentType: state.grades.selectedAssignmentType,
|
||||||
format: state.grades.gradeFormat,
|
format: state.grades.gradeFormat,
|
||||||
showSuccess: state.grades.showSuccess,
|
showSuccess: state.grades.showSuccess,
|
||||||
|
prevPage: state.grades.prevPage,
|
||||||
|
nextPage: state.grades.nextPage,
|
||||||
|
assignmentTypes: state.assignmentTypes.results,
|
||||||
|
areGradesFrozen: state.assignmentTypes.areGradesFrozen,
|
||||||
|
showSpinner: shouldShowSpinner(state),
|
||||||
|
canUserViewGradebook: state.roles.canUserViewGradebook,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const mapDispatchToProps = dispatch => (
|
const mapDispatchToProps = dispatch => (
|
||||||
{
|
{
|
||||||
getUserGrades: (courseId, cohort, track) => {
|
getUserGrades: (courseId, cohort, track, assignmentType) => {
|
||||||
dispatch(fetchGrades(courseId, cohort, track));
|
dispatch(fetchGrades(courseId, cohort, track, assignmentType));
|
||||||
},
|
},
|
||||||
searchForUser: (courseId, searchText, cohort, track) => {
|
searchForUser: (courseId, searchText, cohort, track, assignmentType) => {
|
||||||
dispatch(fetchMatchingUserGrades(courseId, searchText, cohort, track));
|
dispatch(fetchMatchingUserGrades(courseId, searchText, cohort, track, assignmentType, false));
|
||||||
|
},
|
||||||
|
getPrevNextGrades: (endpoint, courseId, cohort, track, assignmentType) => {
|
||||||
|
dispatch(fetchPrevNextGrades(endpoint, courseId, cohort, track, assignmentType));
|
||||||
},
|
},
|
||||||
getCohorts: (courseId) => {
|
getCohorts: (courseId) => {
|
||||||
dispatch(fetchCohorts(courseId));
|
dispatch(fetchCohorts(courseId));
|
||||||
@@ -39,8 +62,11 @@ const mapDispatchToProps = dispatch => (
|
|||||||
getTracks: (courseId) => {
|
getTracks: (courseId) => {
|
||||||
dispatch(fetchTracks(courseId));
|
dispatch(fetchTracks(courseId));
|
||||||
},
|
},
|
||||||
updateGrades: (courseId, updateData) => {
|
getAssignmentTypes: (courseId) => {
|
||||||
dispatch(updateGrades(courseId, updateData));
|
dispatch(fetchAssignmentTypes(courseId));
|
||||||
|
},
|
||||||
|
updateGrades: (courseId, updateData, searchText, cohort, track) => {
|
||||||
|
dispatch(updateGrades(courseId, updateData, searchText, cohort, track));
|
||||||
},
|
},
|
||||||
toggleFormat: (formatType) => {
|
toggleFormat: (formatType) => {
|
||||||
dispatch(toggleGradeFormat(formatType));
|
dispatch(toggleGradeFormat(formatType));
|
||||||
@@ -51,6 +77,12 @@ const mapDispatchToProps = dispatch => (
|
|||||||
updateBanner: (showSuccess) => {
|
updateBanner: (showSuccess) => {
|
||||||
dispatch(updateBanner(showSuccess));
|
dispatch(updateBanner(showSuccess));
|
||||||
},
|
},
|
||||||
|
getRoles: (matchParams, urlQuery) => {
|
||||||
|
dispatch(getRoles(matchParams, urlQuery));
|
||||||
|
},
|
||||||
|
gradeUpdateSuccess: (courseId, data) => {
|
||||||
|
dispatch(gradeUpdateSuccess(courseId, data));
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
35
src/data/actions/assignmentTypes.js
Normal file
35
src/data/actions/assignmentTypes.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import {
|
||||||
|
STARTED_FETCHING_ASSIGNMENT_TYPES,
|
||||||
|
GOT_ASSIGNMENT_TYPES,
|
||||||
|
ERROR_FETCHING_ASSIGNMENT_TYPES,
|
||||||
|
GOT_ARE_GRADES_FROZEN,
|
||||||
|
} from '../constants/actionTypes/assignmentTypes';
|
||||||
|
import LmsApiService from '../services/LmsApiService';
|
||||||
|
|
||||||
|
const startedFetchingAssignmentTypes = () => ({ type: STARTED_FETCHING_ASSIGNMENT_TYPES });
|
||||||
|
const errorFetchingAssignmentTypes = () => ({ type: ERROR_FETCHING_ASSIGNMENT_TYPES });
|
||||||
|
const gotAssignmentTypes = assignmentTypes => ({ type: GOT_ASSIGNMENT_TYPES, assignmentTypes });
|
||||||
|
const gotGradesFrozen = areGradesFrozen => ({ type: GOT_ARE_GRADES_FROZEN, areGradesFrozen });
|
||||||
|
|
||||||
|
const fetchAssignmentTypes = courseId => (
|
||||||
|
(dispatch) => {
|
||||||
|
dispatch(startedFetchingAssignmentTypes());
|
||||||
|
return LmsApiService.fetchAssignmentTypes(courseId)
|
||||||
|
.then(response => response.data)
|
||||||
|
.then((data) => {
|
||||||
|
dispatch(gotAssignmentTypes(Object.keys(data.assignment_types)));
|
||||||
|
dispatch(gotGradesFrozen(data.grades_frozen));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
dispatch(errorFetchingAssignmentTypes());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export {
|
||||||
|
fetchAssignmentTypes,
|
||||||
|
startedFetchingAssignmentTypes,
|
||||||
|
gotAssignmentTypes,
|
||||||
|
errorFetchingAssignmentTypes,
|
||||||
|
};
|
||||||
|
|
||||||
91
src/data/actions/assignmentTypes.test.js
Normal file
91
src/data/actions/assignmentTypes.test.js
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import configureMockStore from 'redux-mock-store';
|
||||||
|
import MockAdapter from 'axios-mock-adapter';
|
||||||
|
import thunk from 'redux-thunk';
|
||||||
|
|
||||||
|
import apiClient from '../apiClient';
|
||||||
|
import { configuration } from '../../config';
|
||||||
|
import { fetchAssignmentTypes } from './assignmentTypes';
|
||||||
|
import {
|
||||||
|
STARTED_FETCHING_ASSIGNMENT_TYPES,
|
||||||
|
GOT_ASSIGNMENT_TYPES,
|
||||||
|
ERROR_FETCHING_ASSIGNMENT_TYPES,
|
||||||
|
GOT_ARE_GRADES_FROZEN,
|
||||||
|
} from '../constants/actionTypes/assignmentTypes';
|
||||||
|
|
||||||
|
const mockStore = configureMockStore([thunk]);
|
||||||
|
const axiosMock = new MockAdapter(apiClient);
|
||||||
|
|
||||||
|
describe('actions', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
axiosMock.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchAssignmentTypes', () => {
|
||||||
|
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||||
|
const responseData = {
|
||||||
|
assignment_types: {
|
||||||
|
Exam: {
|
||||||
|
drop_count: 0,
|
||||||
|
min_count: 1,
|
||||||
|
short_label: 'Exam',
|
||||||
|
type: 'Exam',
|
||||||
|
weight: 0.25,
|
||||||
|
},
|
||||||
|
Homework: {
|
||||||
|
drop_count: 1,
|
||||||
|
min_count: 3,
|
||||||
|
short_label: 'Ex',
|
||||||
|
type: 'Homework',
|
||||||
|
weight: 0.75,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
grades_frozen: false,
|
||||||
|
};
|
||||||
|
it('dispatches success action after fetching fetchAssignmentTypes', () => {
|
||||||
|
const expectedActions = [
|
||||||
|
{ type: STARTED_FETCHING_ASSIGNMENT_TYPES },
|
||||||
|
{ type: GOT_ASSIGNMENT_TYPES, assignmentTypes: Object.keys(responseData.assignment_types) },
|
||||||
|
{ type: GOT_ARE_GRADES_FROZEN, areGradesFrozen: responseData.grades_frozen },
|
||||||
|
];
|
||||||
|
const store = mockStore();
|
||||||
|
|
||||||
|
axiosMock.onGet(`${configuration.LMS_BASE_URL}/api/grades/v1/gradebook/${courseId}/grading-info?graded_only=true`)
|
||||||
|
.replyOnce(200, JSON.stringify(responseData));
|
||||||
|
|
||||||
|
return store.dispatch(fetchAssignmentTypes(courseId)).then(() => {
|
||||||
|
expect(store.getActions()).toEqual(expectedActions);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches failure action after fetching cohorts', () => {
|
||||||
|
const expectedActions = [
|
||||||
|
{ type: STARTED_FETCHING_ASSIGNMENT_TYPES },
|
||||||
|
{ type: ERROR_FETCHING_ASSIGNMENT_TYPES },
|
||||||
|
];
|
||||||
|
const store = mockStore();
|
||||||
|
|
||||||
|
axiosMock.onGet(`${configuration.LMS_BASE_URL}/api/grades/v1/gradebook/${courseId}/grading-info?graded_only=true`)
|
||||||
|
.replyOnce(500, JSON.stringify({}));
|
||||||
|
|
||||||
|
return store.dispatch(fetchAssignmentTypes(courseId)).then(() => {
|
||||||
|
expect(store.getActions()).toEqual(expectedActions);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches frozen grade action with True value after fetching', () => {
|
||||||
|
const expectedActions = [
|
||||||
|
{ type: STARTED_FETCHING_ASSIGNMENT_TYPES },
|
||||||
|
{ type: GOT_ASSIGNMENT_TYPES, assignmentTypes: Object.keys(responseData.assignment_types) },
|
||||||
|
{ type: GOT_ARE_GRADES_FROZEN, areGradesFrozen: true },
|
||||||
|
];
|
||||||
|
const store = mockStore();
|
||||||
|
responseData.grades_frozen = true;
|
||||||
|
axiosMock.onGet(`${configuration.LMS_BASE_URL}/api/grades/v1/gradebook/${courseId}/grading-info?graded_only=true`)
|
||||||
|
.replyOnce(200, JSON.stringify(responseData));
|
||||||
|
|
||||||
|
return store.dispatch(fetchAssignmentTypes(courseId)).then(() => {
|
||||||
|
expect(store.getActions()).toEqual(expectedActions);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,7 +17,7 @@ const fetchCohorts = courseId => (
|
|||||||
.then((data) => {
|
.then((data) => {
|
||||||
dispatch(gotCohorts(data.cohorts));
|
dispatch(gotCohorts(data.cohorts));
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch(() => {
|
||||||
dispatch(errorFetchingCohorts());
|
dispatch(errorFetchingCohorts());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
74
src/data/actions/cohorts.test.js
Normal file
74
src/data/actions/cohorts.test.js
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import configureMockStore from 'redux-mock-store';
|
||||||
|
import MockAdapter from 'axios-mock-adapter';
|
||||||
|
import thunk from 'redux-thunk';
|
||||||
|
|
||||||
|
import apiClient from '../apiClient';
|
||||||
|
import { configuration } from '../../config';
|
||||||
|
import { fetchCohorts } from './cohorts';
|
||||||
|
import {
|
||||||
|
STARTED_FETCHING_COHORTS,
|
||||||
|
GOT_COHORTS,
|
||||||
|
ERROR_FETCHING_COHORTS,
|
||||||
|
} from '../constants/actionTypes/cohorts';
|
||||||
|
|
||||||
|
const mockStore = configureMockStore([thunk]);
|
||||||
|
const axiosMock = new MockAdapter(apiClient);
|
||||||
|
|
||||||
|
describe('actions', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
axiosMock.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchCohorts', () => {
|
||||||
|
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||||
|
|
||||||
|
it('dispatches success action after fetching cohorts', () => {
|
||||||
|
const responseData = {
|
||||||
|
cohorts: [
|
||||||
|
{
|
||||||
|
assignment_type: 'manual',
|
||||||
|
group_id: null,
|
||||||
|
id: 1,
|
||||||
|
name: 'default_group',
|
||||||
|
user_count: 2,
|
||||||
|
user_partition_id: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
assignment_type: 'auto',
|
||||||
|
group_id: null,
|
||||||
|
id: 2,
|
||||||
|
name: 'auto_group',
|
||||||
|
user_count: 5,
|
||||||
|
user_partition_id: null,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
const expectedActions = [
|
||||||
|
{ type: STARTED_FETCHING_COHORTS },
|
||||||
|
{ type: GOT_COHORTS, cohorts: responseData.cohorts },
|
||||||
|
];
|
||||||
|
const store = mockStore();
|
||||||
|
|
||||||
|
axiosMock.onGet(`${configuration.LMS_BASE_URL}/courses/${courseId}/cohorts/`)
|
||||||
|
.replyOnce(200, JSON.stringify(responseData));
|
||||||
|
|
||||||
|
return store.dispatch(fetchCohorts(courseId)).then(() => {
|
||||||
|
expect(store.getActions()).toEqual(expectedActions);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches failure action after fetching cohorts', () => {
|
||||||
|
const expectedActions = [
|
||||||
|
{ type: STARTED_FETCHING_COHORTS },
|
||||||
|
{ type: ERROR_FETCHING_COHORTS },
|
||||||
|
];
|
||||||
|
const store = mockStore();
|
||||||
|
|
||||||
|
axiosMock.onGet(`${configuration.LMS_BASE_URL}/courses/${courseId}/cohorts/`)
|
||||||
|
.replyOnce(500, JSON.stringify({}));
|
||||||
|
|
||||||
|
return store.dispatch(fetchCohorts(courseId)).then(() => {
|
||||||
|
expect(store.getActions()).toEqual(expectedActions);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,44 +7,39 @@ import {
|
|||||||
GRADE_UPDATE_SUCCESS,
|
GRADE_UPDATE_SUCCESS,
|
||||||
GRADE_UPDATE_FAILURE,
|
GRADE_UPDATE_FAILURE,
|
||||||
TOGGLE_GRADE_FORMAT,
|
TOGGLE_GRADE_FORMAT,
|
||||||
SORT_GRADES,
|
|
||||||
FILTER_COLUMNS,
|
FILTER_COLUMNS,
|
||||||
UPDATE_BANNER,
|
UPDATE_BANNER,
|
||||||
} from '../constants/actionTypes/grades';
|
} from '../constants/actionTypes/grades';
|
||||||
import LmsApiService from '../services/LmsApiService';
|
import LmsApiService from '../services/LmsApiService';
|
||||||
import store from '../store';
|
import { headingMapper, sortAlphaAsc } from './utils';
|
||||||
import { headingMapper, gradeSortMap, sortAlphaAsc } from './utils';
|
import apiClient from '../apiClient';
|
||||||
|
|
||||||
|
const defaultAssignmentFilter = 'All';
|
||||||
const sortGrades = (columnName, direction) => {
|
|
||||||
const sortFn = gradeSortMap(columnName, direction);
|
|
||||||
const { results } = store.getState().grades;
|
|
||||||
results.sort(sortFn);
|
|
||||||
|
|
||||||
/* have to make a copy of results or React wont know there was
|
|
||||||
* a change and wont trigger a re-render
|
|
||||||
*/
|
|
||||||
return ({ type: SORT_GRADES, results: [...results] });
|
|
||||||
};
|
|
||||||
|
|
||||||
const 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 gotGrades = (grades, cohort, track, headings) => ({
|
const gotGrades = (grades, cohort, track, assignmentType, headings, prev, next, courseId) => ({
|
||||||
type: GOT_GRADES,
|
type: GOT_GRADES,
|
||||||
grades,
|
grades,
|
||||||
cohort,
|
cohort,
|
||||||
track,
|
track,
|
||||||
|
assignmentType,
|
||||||
headings,
|
headings,
|
||||||
|
prev,
|
||||||
|
next,
|
||||||
|
courseId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const gradeUpdateRequest = () => ({ type: GRADE_UPDATE_REQUEST });
|
const gradeUpdateRequest = () => ({ type: GRADE_UPDATE_REQUEST });
|
||||||
const gradeUpdateSuccess = responseData => ({
|
const gradeUpdateSuccess = (courseId, responseData) => ({
|
||||||
type: GRADE_UPDATE_SUCCESS,
|
type: GRADE_UPDATE_SUCCESS,
|
||||||
|
courseId,
|
||||||
payload: { responseData },
|
payload: { responseData },
|
||||||
});
|
});
|
||||||
const gradeUpdateFailure = error => ({
|
const gradeUpdateFailure = (courseId, error) => ({
|
||||||
type: GRADE_UPDATE_FAILURE,
|
type: GRADE_UPDATE_FAILURE,
|
||||||
|
courseId,
|
||||||
payload: { error },
|
payload: { error },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -52,14 +47,15 @@ const gradeUpdateFailure = error => ({
|
|||||||
const toggleGradeFormat = formatType => ({ type: TOGGLE_GRADE_FORMAT, formatType });
|
const toggleGradeFormat = formatType => ({ type: TOGGLE_GRADE_FORMAT, formatType });
|
||||||
|
|
||||||
const filterColumns = (filterType, exampleUser) => (
|
const filterColumns = (filterType, exampleUser) => (
|
||||||
dispatch => ({
|
dispatch => dispatch({
|
||||||
type: FILTER_COLUMNS,
|
type: FILTER_COLUMNS,
|
||||||
headings: headingMapper[filterType](dispatch, exampleUser),
|
headings: headingMapper(filterType)(exampleUser),
|
||||||
}));
|
})
|
||||||
|
);
|
||||||
|
|
||||||
const updateBanner = showSuccess => ({ type: UPDATE_BANNER, showSuccess });
|
const updateBanner = showSuccess => ({ type: UPDATE_BANNER, showSuccess });
|
||||||
|
|
||||||
const fetchGrades = (courseId, cohort, track, showSuccess) => (
|
const fetchGrades = (courseId, cohort, track, assignmentType, showSuccess) => (
|
||||||
(dispatch) => {
|
(dispatch) => {
|
||||||
dispatch(startedFetchingGrades());
|
dispatch(startedFetchingGrades());
|
||||||
return LmsApiService.fetchGradebookData(courseId, null, cohort, track)
|
return LmsApiService.fetchGradebookData(courseId, null, cohort, track)
|
||||||
@@ -69,7 +65,11 @@ const fetchGrades = (courseId, cohort, track, showSuccess) => (
|
|||||||
data.results.sort(sortAlphaAsc),
|
data.results.sort(sortAlphaAsc),
|
||||||
cohort,
|
cohort,
|
||||||
track,
|
track,
|
||||||
headingMapper.all(dispatch, data.results[0]),
|
assignmentType,
|
||||||
|
headingMapper(assignmentType || defaultAssignmentFilter)(data.results[0]),
|
||||||
|
data.previous,
|
||||||
|
data.next,
|
||||||
|
courseId,
|
||||||
));
|
));
|
||||||
dispatch(finishedFetchingGrades());
|
dispatch(finishedFetchingGrades());
|
||||||
dispatch(updateBanner(!!showSuccess));
|
dispatch(updateBanner(!!showSuccess));
|
||||||
@@ -80,7 +80,14 @@ const fetchGrades = (courseId, cohort, track, showSuccess) => (
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const fetchMatchingUserGrades = (courseId, searchText, cohort, track) => (
|
const fetchMatchingUserGrades = (
|
||||||
|
courseId,
|
||||||
|
searchText,
|
||||||
|
cohort,
|
||||||
|
track,
|
||||||
|
assignmentType,
|
||||||
|
showSuccess,
|
||||||
|
) => (
|
||||||
(dispatch) => {
|
(dispatch) => {
|
||||||
dispatch(startedFetchingGrades());
|
dispatch(startedFetchingGrades());
|
||||||
return LmsApiService.fetchGradebookData(courseId, searchText, cohort, track)
|
return LmsApiService.fetchGradebookData(courseId, searchText, cohort, track)
|
||||||
@@ -90,7 +97,36 @@ const fetchMatchingUserGrades = (courseId, searchText, cohort, track) => (
|
|||||||
data.results.sort(sortAlphaAsc),
|
data.results.sort(sortAlphaAsc),
|
||||||
cohort,
|
cohort,
|
||||||
track,
|
track,
|
||||||
headingMapper.all(dispatch, data.results[0]),
|
assignmentType,
|
||||||
|
headingMapper(assignmentType || defaultAssignmentFilter)(data.results[0]),
|
||||||
|
data.previous,
|
||||||
|
data.next,
|
||||||
|
courseId,
|
||||||
|
));
|
||||||
|
dispatch(finishedFetchingGrades());
|
||||||
|
dispatch(updateBanner(showSuccess));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
dispatch(errorFetchingGrades());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetchPrevNextGrades = (endpoint, courseId, cohort, track, assignmentType) => (
|
||||||
|
(dispatch) => {
|
||||||
|
dispatch(startedFetchingGrades());
|
||||||
|
return apiClient.get(endpoint)
|
||||||
|
.then(response => response.data)
|
||||||
|
.then((data) => {
|
||||||
|
dispatch(gotGrades(
|
||||||
|
data.results.sort(sortAlphaAsc),
|
||||||
|
cohort,
|
||||||
|
track,
|
||||||
|
assignmentType,
|
||||||
|
headingMapper(assignmentType || defaultAssignmentFilter)(data.results[0]),
|
||||||
|
data.previous,
|
||||||
|
data.next,
|
||||||
|
courseId,
|
||||||
));
|
));
|
||||||
dispatch(finishedFetchingGrades());
|
dispatch(finishedFetchingGrades());
|
||||||
})
|
})
|
||||||
@@ -100,17 +136,24 @@ const fetchMatchingUserGrades = (courseId, searchText, cohort, track) => (
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateGrades = (courseId, updateData) => (
|
const updateGrades = (courseId, updateData, searchText, cohort, track) => (
|
||||||
(dispatch) => {
|
(dispatch) => {
|
||||||
dispatch(gradeUpdateRequest());
|
dispatch(gradeUpdateRequest());
|
||||||
return LmsApiService.updateGradebookData(courseId, updateData)
|
return LmsApiService.updateGradebookData(courseId, updateData)
|
||||||
.then(response => response.data)
|
.then(response => response.data)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
dispatch(gradeUpdateSuccess(data));
|
dispatch(gradeUpdateSuccess(courseId, data));
|
||||||
dispatch(fetchGrades(courseId, null, null, true));
|
// dispatch(fetchMatchingUserGrades(
|
||||||
|
// courseId,
|
||||||
|
// searchText,
|
||||||
|
// cohort,
|
||||||
|
// track,
|
||||||
|
// defaultAssignmentFilter,
|
||||||
|
// true,
|
||||||
|
// ));
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
dispatch(gradeUpdateFailure(error));
|
dispatch(gradeUpdateFailure(courseId, error));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -122,12 +165,12 @@ export {
|
|||||||
gotGrades,
|
gotGrades,
|
||||||
fetchGrades,
|
fetchGrades,
|
||||||
fetchMatchingUserGrades,
|
fetchMatchingUserGrades,
|
||||||
|
fetchPrevNextGrades,
|
||||||
gradeUpdateRequest,
|
gradeUpdateRequest,
|
||||||
gradeUpdateSuccess,
|
gradeUpdateSuccess,
|
||||||
gradeUpdateFailure,
|
gradeUpdateFailure,
|
||||||
updateGrades,
|
updateGrades,
|
||||||
toggleGradeFormat,
|
toggleGradeFormat,
|
||||||
sortGrades,
|
|
||||||
filterColumns,
|
filterColumns,
|
||||||
updateBanner,
|
updateBanner,
|
||||||
};
|
};
|
||||||
|
|||||||
191
src/data/actions/grades.test.js
Normal file
191
src/data/actions/grades.test.js
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import configureMockStore from 'redux-mock-store';
|
||||||
|
import MockAdapter from 'axios-mock-adapter';
|
||||||
|
import thunk from 'redux-thunk';
|
||||||
|
|
||||||
|
import apiClient from '../apiClient';
|
||||||
|
import { configuration } from '../../config';
|
||||||
|
import { fetchGrades } from './grades';
|
||||||
|
import {
|
||||||
|
STARTED_FETCHING_GRADES,
|
||||||
|
FINISHED_FETCHING_GRADES,
|
||||||
|
ERROR_FETCHING_GRADES,
|
||||||
|
GOT_GRADES,
|
||||||
|
UPDATE_BANNER,
|
||||||
|
} from '../constants/actionTypes/grades';
|
||||||
|
import { sortAlphaAsc } from './utils';
|
||||||
|
|
||||||
|
|
||||||
|
const mockStore = configureMockStore([thunk]);
|
||||||
|
const axiosMock = new MockAdapter(apiClient);
|
||||||
|
|
||||||
|
describe('actions', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
axiosMock.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchGrades', () => {
|
||||||
|
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||||
|
const expectedCohort = 1;
|
||||||
|
const expectedTrack = 'verified';
|
||||||
|
const expectedAssignmentType = 'Exam';
|
||||||
|
const fetchGradesURL = `${configuration.LMS_BASE_URL}/api/grades/v1/gradebook/${courseId}/?page_size=25&cohort_id=${expectedCohort}&enrollment_mode=${expectedTrack}`;
|
||||||
|
const responseData = {
|
||||||
|
next: `${fetchGradesURL}&cursor=2344fda`,
|
||||||
|
previous: null,
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
course_id: courseId,
|
||||||
|
email: 'user1@example.com',
|
||||||
|
username: 'user1',
|
||||||
|
user_id: 1,
|
||||||
|
percent: 0.5,
|
||||||
|
letter_grade: null,
|
||||||
|
section_breakdown: [
|
||||||
|
{
|
||||||
|
subsection_name: 'Demo Course Overview',
|
||||||
|
score_earned: 0,
|
||||||
|
score_possible: 0,
|
||||||
|
percent: 0,
|
||||||
|
displayed_value: '0.00',
|
||||||
|
grade_description: '(0.00/0.00)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
subsection_name: 'Example Week 1: Getting Started',
|
||||||
|
score_earned: 1,
|
||||||
|
score_possible: 1,
|
||||||
|
percent: 1,
|
||||||
|
displayed_value: '1.00',
|
||||||
|
grade_description: '(0.00/0.00)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
course_id: courseId,
|
||||||
|
email: 'user22@example.com',
|
||||||
|
username: 'user22',
|
||||||
|
user_id: 22,
|
||||||
|
percent: 0,
|
||||||
|
letter_grade: null,
|
||||||
|
section_breakdown: [
|
||||||
|
{
|
||||||
|
subsection_name: 'Demo Course Overview',
|
||||||
|
score_earned: 0,
|
||||||
|
score_possible: 0,
|
||||||
|
percent: 0,
|
||||||
|
displayed_value: '0.00',
|
||||||
|
grade_description: '(0.00/0.00)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
subsection_name: 'Example Week 1: Getting Started',
|
||||||
|
score_earned: 1,
|
||||||
|
score_possible: 1,
|
||||||
|
percent: 0,
|
||||||
|
displayed_value: '0.00',
|
||||||
|
grade_description: '(0.00/0.00)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
it('dispatches success action after fetching grades', () => {
|
||||||
|
const expectedActions = [
|
||||||
|
{ type: STARTED_FETCHING_GRADES },
|
||||||
|
{
|
||||||
|
type: GOT_GRADES,
|
||||||
|
grades: responseData.results.sort(sortAlphaAsc),
|
||||||
|
cohort: expectedCohort,
|
||||||
|
track: expectedTrack,
|
||||||
|
assignmentType: expectedAssignmentType,
|
||||||
|
headings: [
|
||||||
|
{
|
||||||
|
key: 'username',
|
||||||
|
label: 'Username',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'total',
|
||||||
|
label: 'Total',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
prev: responseData.previous,
|
||||||
|
next: responseData.next,
|
||||||
|
courseId,
|
||||||
|
},
|
||||||
|
{ type: FINISHED_FETCHING_GRADES },
|
||||||
|
{ type: UPDATE_BANNER, showSuccess: false },
|
||||||
|
];
|
||||||
|
const store = mockStore();
|
||||||
|
|
||||||
|
axiosMock.onGet(fetchGradesURL)
|
||||||
|
.replyOnce(200, JSON.stringify(responseData));
|
||||||
|
|
||||||
|
return store.dispatch(fetchGrades(
|
||||||
|
courseId,
|
||||||
|
expectedCohort,
|
||||||
|
expectedTrack,
|
||||||
|
expectedAssignmentType,
|
||||||
|
false,
|
||||||
|
)).then(() => {
|
||||||
|
expect(store.getActions()).toEqual(expectedActions);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches failure action after fetching grades', () => {
|
||||||
|
const expectedActions = [
|
||||||
|
{ type: STARTED_FETCHING_GRADES },
|
||||||
|
{ type: ERROR_FETCHING_GRADES },
|
||||||
|
];
|
||||||
|
const store = mockStore();
|
||||||
|
|
||||||
|
axiosMock.onGet(fetchGradesURL)
|
||||||
|
.replyOnce(500, JSON.stringify({}));
|
||||||
|
|
||||||
|
return store.dispatch(fetchGrades(
|
||||||
|
courseId,
|
||||||
|
expectedCohort,
|
||||||
|
expectedTrack,
|
||||||
|
expectedAssignmentType,
|
||||||
|
false,
|
||||||
|
)).then(() => {
|
||||||
|
expect(store.getActions()).toEqual(expectedActions);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches success action on empty response after fetching grades', () => {
|
||||||
|
const emptyResponseData = {
|
||||||
|
next: responseData.next,
|
||||||
|
previous: responseData.previous,
|
||||||
|
results: [],
|
||||||
|
};
|
||||||
|
const expectedActions = [
|
||||||
|
{ type: STARTED_FETCHING_GRADES },
|
||||||
|
{
|
||||||
|
type: GOT_GRADES,
|
||||||
|
grades: [],
|
||||||
|
cohort: expectedCohort,
|
||||||
|
track: expectedTrack,
|
||||||
|
assignmentType: expectedAssignmentType,
|
||||||
|
headings: [],
|
||||||
|
prev: responseData.previous,
|
||||||
|
next: responseData.next,
|
||||||
|
courseId,
|
||||||
|
},
|
||||||
|
{ type: FINISHED_FETCHING_GRADES },
|
||||||
|
{ type: UPDATE_BANNER, showSuccess: false },
|
||||||
|
];
|
||||||
|
const store = mockStore();
|
||||||
|
|
||||||
|
axiosMock.onGet(fetchGradesURL)
|
||||||
|
.replyOnce(200, JSON.stringify(emptyResponseData));
|
||||||
|
|
||||||
|
return store.dispatch(fetchGrades(
|
||||||
|
courseId,
|
||||||
|
expectedCohort,
|
||||||
|
expectedTrack,
|
||||||
|
expectedAssignmentType,
|
||||||
|
false,
|
||||||
|
)).then(() => {
|
||||||
|
expect(store.getActions()).toEqual(expectedActions);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
42
src/data/actions/roles.js
Normal file
42
src/data/actions/roles.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import {
|
||||||
|
GOT_ROLES,
|
||||||
|
ERROR_FETCHING_ROLES,
|
||||||
|
} from '../constants/actionTypes/roles';
|
||||||
|
import { fetchGrades } from './grades';
|
||||||
|
import { fetchTracks } from './tracks';
|
||||||
|
import { fetchCohorts } from './cohorts';
|
||||||
|
import { fetchAssignmentTypes } from './assignmentTypes';
|
||||||
|
import LmsApiService from '../services/LmsApiService';
|
||||||
|
|
||||||
|
const allowedRoles = ['staff', 'instructor', 'support'];
|
||||||
|
|
||||||
|
const gotRoles = (canUserViewGradebook, courseId) => ({
|
||||||
|
type: GOT_ROLES,
|
||||||
|
canUserViewGradebook,
|
||||||
|
courseId,
|
||||||
|
});
|
||||||
|
const errorFetchingRoles = () => ({ type: ERROR_FETCHING_ROLES });
|
||||||
|
|
||||||
|
const getRoles = (courseId, urlQuery) => (
|
||||||
|
dispatch => LmsApiService.fetchUserRoles(courseId)
|
||||||
|
.then(response => response.data)
|
||||||
|
.then((response) => {
|
||||||
|
const canUserViewGradebook = response.is_staff
|
||||||
|
|| (response.roles.some(role => (role.course_id === courseId)
|
||||||
|
&& allowedRoles.includes(role.role)));
|
||||||
|
dispatch(gotRoles(canUserViewGradebook, courseId));
|
||||||
|
if (canUserViewGradebook) {
|
||||||
|
dispatch(fetchGrades(courseId, urlQuery.cohort, urlQuery.track, urlQuery.assignmentType));
|
||||||
|
dispatch(fetchTracks(courseId));
|
||||||
|
dispatch(fetchCohorts(courseId));
|
||||||
|
dispatch(fetchAssignmentTypes(courseId));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
dispatch(errorFetchingRoles());
|
||||||
|
}));
|
||||||
|
|
||||||
|
export {
|
||||||
|
getRoles,
|
||||||
|
errorFetchingRoles,
|
||||||
|
};
|
||||||
161
src/data/actions/roles.test.js
Normal file
161
src/data/actions/roles.test.js
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import configureMockStore from 'redux-mock-store';
|
||||||
|
import MockAdapter from 'axios-mock-adapter';
|
||||||
|
import thunk from 'redux-thunk';
|
||||||
|
|
||||||
|
import apiClient from '../apiClient';
|
||||||
|
import { configuration } from '../../config';
|
||||||
|
import { getRoles } from './roles';
|
||||||
|
import {
|
||||||
|
GOT_ROLES,
|
||||||
|
ERROR_FETCHING_ROLES,
|
||||||
|
} from '../constants/actionTypes/roles';
|
||||||
|
import { STARTED_FETCHING_GRADES } from '../constants/actionTypes/grades';
|
||||||
|
import { STARTED_FETCHING_TRACKS } from '../constants/actionTypes/tracks';
|
||||||
|
import { STARTED_FETCHING_COHORTS } from '../constants/actionTypes/cohorts';
|
||||||
|
import { STARTED_FETCHING_ASSIGNMENT_TYPES } from '../constants/actionTypes/assignmentTypes';
|
||||||
|
|
||||||
|
|
||||||
|
const mockStore = configureMockStore([thunk]);
|
||||||
|
const axiosMock = new MockAdapter(apiClient);
|
||||||
|
|
||||||
|
const course1Id = 'course-v1:edX+DemoX+Demo_Course';
|
||||||
|
const course2Id = 'course-v1:edX+DemoX+Demo_Course_2';
|
||||||
|
const rolesUrl = `${configuration.LMS_BASE_URL}/api/enrollment/v1/roles/?course_id=${encodeURIComponent(course1Id)}`;
|
||||||
|
|
||||||
|
function makeRoleListObj(roles, isGlobalStaff) {
|
||||||
|
return {
|
||||||
|
roles,
|
||||||
|
is_staff: isGlobalStaff,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function makeRoleObj(courseId, role) {
|
||||||
|
return {
|
||||||
|
course_id: courseId,
|
||||||
|
role,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const course1StaffRole = makeRoleObj(course1Id, 'staff');
|
||||||
|
const course1DummyRole = makeRoleObj(course1Id, 'dummy');
|
||||||
|
const course2StaffRole = makeRoleObj(course2Id, 'staff');
|
||||||
|
const course2DummyRole = makeRoleObj(course2Id, 'dummy');
|
||||||
|
const urlParams = { cohort: null, track: null };
|
||||||
|
|
||||||
|
describe('actions', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
axiosMock.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getRoles', () => {
|
||||||
|
it('dispatches got_roles action and subsequent actions after fetching role that allows gradebook', () => {
|
||||||
|
const expectedActions = [
|
||||||
|
{ type: GOT_ROLES, canUserViewGradebook: true, courseId: course1Id },
|
||||||
|
{ type: STARTED_FETCHING_GRADES },
|
||||||
|
{ type: STARTED_FETCHING_TRACKS },
|
||||||
|
{ type: STARTED_FETCHING_COHORTS },
|
||||||
|
{ type: STARTED_FETCHING_ASSIGNMENT_TYPES },
|
||||||
|
];
|
||||||
|
const store = mockStore();
|
||||||
|
axiosMock.onGet(rolesUrl)
|
||||||
|
.replyOnce(
|
||||||
|
200,
|
||||||
|
JSON.stringify(makeRoleListObj([course1StaffRole, course2DummyRole], false)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return store.dispatch(getRoles(course1Id, urlParams)).then(() => {
|
||||||
|
expect(store.getActions()).toEqual(expectedActions);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches got_roles action and other actions after fetching irrelevent roles but user is global staff', () => {
|
||||||
|
const expectedActions = [
|
||||||
|
{ type: GOT_ROLES, canUserViewGradebook: true, courseId: course1Id },
|
||||||
|
{ type: STARTED_FETCHING_GRADES },
|
||||||
|
{ type: STARTED_FETCHING_TRACKS },
|
||||||
|
{ type: STARTED_FETCHING_COHORTS },
|
||||||
|
{ type: STARTED_FETCHING_ASSIGNMENT_TYPES },
|
||||||
|
];
|
||||||
|
const store = mockStore();
|
||||||
|
|
||||||
|
axiosMock.onGet(rolesUrl)
|
||||||
|
.replyOnce(
|
||||||
|
200,
|
||||||
|
JSON.stringify(makeRoleListObj([course1DummyRole, course2DummyRole], true)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return store.dispatch(getRoles(course1Id, urlParams)).then(() => {
|
||||||
|
expect(store.getActions()).toEqual(expectedActions);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches got_roles action and no other actions after fetching role that disallows gradebook', () => {
|
||||||
|
const expectedActions = [
|
||||||
|
{
|
||||||
|
type: GOT_ROLES, canUserViewGradebook: false, courseId: course1Id,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const store = mockStore();
|
||||||
|
|
||||||
|
axiosMock.onGet(rolesUrl)
|
||||||
|
.replyOnce(
|
||||||
|
200,
|
||||||
|
JSON.stringify(makeRoleListObj([course1DummyRole, course2StaffRole], false)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return store.dispatch(getRoles(course1Id, urlParams)).then(() => {
|
||||||
|
expect(store.getActions()).toEqual(expectedActions);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches got_roles action and no other actions after fetching empty roles', () => {
|
||||||
|
const expectedActions = [
|
||||||
|
{ type: GOT_ROLES, canUserViewGradebook: false, courseId: course1Id },
|
||||||
|
];
|
||||||
|
const store = mockStore();
|
||||||
|
|
||||||
|
axiosMock.onGet(rolesUrl)
|
||||||
|
.replyOnce(
|
||||||
|
200,
|
||||||
|
JSON.stringify(makeRoleListObj([], false)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return store.dispatch(getRoles(course1Id, urlParams)).then(() => {
|
||||||
|
expect(store.getActions()).toEqual(expectedActions);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches got_roles action and other actions after fetching empty roles but user is global staff', () => {
|
||||||
|
const expectedActions = [
|
||||||
|
{ type: GOT_ROLES, canUserViewGradebook: true, courseId: course1Id },
|
||||||
|
{ type: STARTED_FETCHING_GRADES },
|
||||||
|
{ type: STARTED_FETCHING_TRACKS },
|
||||||
|
{ type: STARTED_FETCHING_COHORTS },
|
||||||
|
{ type: STARTED_FETCHING_ASSIGNMENT_TYPES },
|
||||||
|
];
|
||||||
|
const store = mockStore();
|
||||||
|
|
||||||
|
axiosMock.onGet(rolesUrl)
|
||||||
|
.replyOnce(
|
||||||
|
200,
|
||||||
|
JSON.stringify(makeRoleListObj([], true)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return store.dispatch(getRoles(course1Id, urlParams)).then(() => {
|
||||||
|
expect(store.getActions()).toEqual(expectedActions);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches error action after getting an error when trying to get roles', () => {
|
||||||
|
const expectedActions = [
|
||||||
|
{ type: ERROR_FETCHING_ROLES },
|
||||||
|
];
|
||||||
|
const store = mockStore();
|
||||||
|
|
||||||
|
axiosMock.onGet(rolesUrl).replyOnce(400);
|
||||||
|
|
||||||
|
return store.dispatch(getRoles(course1Id, urlParams)).then(() => {
|
||||||
|
expect(store.getActions()).toEqual(expectedActions);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,7 +17,7 @@ const fetchTracks = courseId => (
|
|||||||
.then((data) => {
|
.then((data) => {
|
||||||
dispatch(gotTracks(data.course_modes));
|
dispatch(gotTracks(data.course_modes));
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch(() => {
|
||||||
dispatch(errorFetchingTracks());
|
dispatch(errorFetchingTracks());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
81
src/data/actions/tracks.test.js
Normal file
81
src/data/actions/tracks.test.js
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import configureMockStore from 'redux-mock-store';
|
||||||
|
import MockAdapter from 'axios-mock-adapter';
|
||||||
|
import thunk from 'redux-thunk';
|
||||||
|
|
||||||
|
import apiClient from '../apiClient';
|
||||||
|
import { configuration } from '../../config';
|
||||||
|
import { fetchTracks } from './tracks';
|
||||||
|
import {
|
||||||
|
STARTED_FETCHING_TRACKS,
|
||||||
|
GOT_TRACKS,
|
||||||
|
ERROR_FETCHING_TRACKS,
|
||||||
|
} from '../constants/actionTypes/tracks';
|
||||||
|
|
||||||
|
const mockStore = configureMockStore([thunk]);
|
||||||
|
const axiosMock = new MockAdapter(apiClient);
|
||||||
|
|
||||||
|
describe('actions', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
axiosMock.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchTracks', () => {
|
||||||
|
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||||
|
const trackUrl = `${configuration.LMS_BASE_URL}/api/enrollment/v1/course/${courseId}?include_expired=1`;
|
||||||
|
|
||||||
|
it('dispatches success action after fetching tracks', () => {
|
||||||
|
const responseData = {
|
||||||
|
course_modes: [
|
||||||
|
{
|
||||||
|
slug: 'audit',
|
||||||
|
name: 'Audit',
|
||||||
|
min_price: 0,
|
||||||
|
suggested_prices: '',
|
||||||
|
currency: 'usd',
|
||||||
|
expiration_datetime: null,
|
||||||
|
description: null,
|
||||||
|
sku: '68EFFFF',
|
||||||
|
bulk_sku: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'verified',
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
min_price: 100,
|
||||||
|
suggested_prices: '',
|
||||||
|
currency: 'usd',
|
||||||
|
expiration_datetime: '2021-05-04T18:08:12.644361Z',
|
||||||
|
description: null,
|
||||||
|
sku: '8CF08E5',
|
||||||
|
bulk_sku: 'A5B6DBE',
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
const expectedActions = [
|
||||||
|
{ type: STARTED_FETCHING_TRACKS },
|
||||||
|
{ type: GOT_TRACKS, tracks: responseData.course_modes },
|
||||||
|
];
|
||||||
|
const store = mockStore();
|
||||||
|
|
||||||
|
axiosMock.onGet(trackUrl)
|
||||||
|
.replyOnce(200, JSON.stringify(responseData));
|
||||||
|
|
||||||
|
return store.dispatch(fetchTracks(courseId)).then(() => {
|
||||||
|
expect(store.getActions()).toEqual(expectedActions);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches failure action after fetching tracks', () => {
|
||||||
|
const expectedActions = [
|
||||||
|
{ type: STARTED_FETCHING_TRACKS },
|
||||||
|
{ type: ERROR_FETCHING_TRACKS },
|
||||||
|
];
|
||||||
|
const store = mockStore();
|
||||||
|
|
||||||
|
axiosMock.onGet(trackUrl)
|
||||||
|
.replyOnce(500, JSON.stringify({}));
|
||||||
|
|
||||||
|
return store.dispatch(fetchTracks(courseId)).then(() => {
|
||||||
|
expect(store.getActions()).toEqual(expectedActions);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
import { sortGrades } from './grades';
|
|
||||||
|
|
||||||
const sortAlphaAsc = (gradeRowA, gradeRowB) => {
|
const sortAlphaAsc = (gradeRowA, gradeRowB) => {
|
||||||
const a = gradeRowA.username.toUpperCase();
|
const a = gradeRowA.username.toUpperCase();
|
||||||
const b = gradeRowB.username.toUpperCase();
|
const b = gradeRowB.username.toUpperCase();
|
||||||
@@ -12,131 +10,56 @@ const sortAlphaAsc = (gradeRowA, gradeRowB) => {
|
|||||||
return 0;
|
return 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const sortAlphaDesc = (gradeRowA, gradeRowB) => {
|
const headingMapper = (filterKey) => {
|
||||||
const a = gradeRowA.username.toUpperCase();
|
function all(entry) {
|
||||||
const b = gradeRowB.username.toUpperCase();
|
|
||||||
if (a < b) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
if (a > b) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const sortNumerically = (colKey, direction) => {
|
|
||||||
function getPercents(gradeRowA, gradeRowB) {
|
|
||||||
if (colKey !== 'total') {
|
|
||||||
return {
|
|
||||||
a: gradeRowA.section_breakdown.find(x => x.label === colKey).percent,
|
|
||||||
b: gradeRowB.section_breakdown.find(x => x.label === colKey).percent,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
a: gradeRowA.percent,
|
|
||||||
b: gradeRowB.percent,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortNumAsc(gradeRowA, gradeRowB) {
|
|
||||||
const { a, b } = getPercents(gradeRowA, gradeRowB);
|
|
||||||
return a - b;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortNumDesc(gradeRowA, gradeRowB) {
|
|
||||||
const { a, b } = getPercents(gradeRowA, gradeRowB);
|
|
||||||
return b - a;
|
|
||||||
}
|
|
||||||
|
|
||||||
return direction === 'desc' ? sortNumDesc : sortNumAsc;
|
|
||||||
};
|
|
||||||
|
|
||||||
function gradeSortMap(columnName, direction) {
|
|
||||||
if (columnName === 'username' && direction === 'desc') {
|
|
||||||
return sortAlphaDesc;
|
|
||||||
} else if (columnName === 'username') {
|
|
||||||
return sortAlphaAsc;
|
|
||||||
}
|
|
||||||
return sortNumerically(columnName, direction);
|
|
||||||
}
|
|
||||||
|
|
||||||
const headingMapper = {
|
|
||||||
all: (dispatch, entry) => {
|
|
||||||
if (entry) {
|
if (entry) {
|
||||||
const results = [{
|
const results = [{
|
||||||
label: 'Username',
|
label: 'Username',
|
||||||
key: 'username',
|
key: 'username',
|
||||||
columnSortable: true,
|
|
||||||
onSort: (direction) => { dispatch(sortGrades('username', direction)); },
|
|
||||||
}];
|
}];
|
||||||
|
|
||||||
const assignmentHeadings = entry.section_breakdown
|
const assignmentHeadings = entry.section_breakdown
|
||||||
.filter(section => section.is_graded && section.label)
|
.filter(section => section.label)
|
||||||
.map(s => ({
|
.map(s => ({
|
||||||
label: s.label,
|
label: s.label,
|
||||||
key: s.label,
|
key: s.label,
|
||||||
columnSortable: true,
|
|
||||||
onSort: direction => dispatch(sortGrades(s.label, direction)),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const totals = [{
|
const totals = [{
|
||||||
label: 'Total',
|
label: 'Total',
|
||||||
key: 'total',
|
key: 'total',
|
||||||
columnSortable: true,
|
|
||||||
onSort: direction => dispatch(sortGrades('total', direction)),
|
|
||||||
}];
|
}];
|
||||||
|
|
||||||
return results.concat(assignmentHeadings).concat(totals);
|
return results.concat(assignmentHeadings).concat(totals);
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
},
|
}
|
||||||
hw: (dispatch, entry) => {
|
|
||||||
|
function some(entry) {
|
||||||
|
if (!entry) return [];
|
||||||
|
|
||||||
const results = [{
|
const results = [{
|
||||||
label: 'Username',
|
label: 'Username',
|
||||||
key: 'username',
|
key: 'username',
|
||||||
columnSortable: true,
|
|
||||||
onSort: (direction) => {
|
|
||||||
this.setState({
|
|
||||||
grades: [...this.state.grades].sort(direction === 'desc' ? this.sortAlphaDesc : this.sortAlphaAsc),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
}];
|
}];
|
||||||
|
|
||||||
const assignmentHeadings = entry.section_breakdown
|
const assignmentHeadings = entry.section_breakdown
|
||||||
.filter(section => section.is_graded && section.label && section.category == 'Homework')
|
.filter(section => section.label && section.category === filterKey)
|
||||||
.map(s => ({
|
.map(s => ({
|
||||||
label: s.label,
|
label: s.label,
|
||||||
key: s.label,
|
key: s.label,
|
||||||
columnSortable: false,
|
|
||||||
onSort: (direction) => { this.sortNumerically(s.label, direction); },
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return results.concat(assignmentHeadings);
|
const totals = [{
|
||||||
},
|
label: 'Total',
|
||||||
exam: (dispatch, entry) => {
|
key: 'total',
|
||||||
const results = [{
|
|
||||||
label: 'Username',
|
|
||||||
key: 'username',
|
|
||||||
columnSortable: false,
|
|
||||||
onSort: (direction) => {
|
|
||||||
this.setState({
|
|
||||||
grades: [...this.state.grades].sort(direction === 'desc' ? this.sortAlphaDesc : this.sortAlphaAsc),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
}];
|
}];
|
||||||
|
|
||||||
const assignmentHeadings = entry.section_breakdown
|
return results.concat(assignmentHeadings).concat(totals);
|
||||||
.filter(section => section.is_graded && section.label && section.category == 'Exam')
|
}
|
||||||
.map(s => ({
|
|
||||||
label: s.label,
|
|
||||||
key: s.label,
|
|
||||||
columnSortable: false,
|
|
||||||
onSort: (direction) => { this.sortNumerically(s.label, direction); },
|
|
||||||
}));
|
|
||||||
|
|
||||||
return results.concat(assignmentHeadings);
|
return filterKey === 'All' ? all : some;
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export { headingMapper, gradeSortMap, sortAlphaAsc };
|
export { headingMapper, sortAlphaAsc };
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const apiClient = getAuthenticatedAPIClient({
|
|||||||
appBaseUrl: configuration.BASE_URL,
|
appBaseUrl: configuration.BASE_URL,
|
||||||
loginUrl: configuration.LOGIN_URL,
|
loginUrl: configuration.LOGIN_URL,
|
||||||
logoutUrl: configuration.LOGOUT_URL,
|
logoutUrl: configuration.LOGOUT_URL,
|
||||||
|
csrfTokenApiPath: process.env.CSRF_TOKEN_API_PATH,
|
||||||
refreshAccessTokenEndpoint: configuration.REFRESH_ACCESS_TOKEN_ENDPOINT,
|
refreshAccessTokenEndpoint: configuration.REFRESH_ACCESS_TOKEN_ENDPOINT,
|
||||||
accessTokenCookieName: configuration.ACCESS_TOKEN_COOKIE_NAME,
|
accessTokenCookieName: configuration.ACCESS_TOKEN_COOKIE_NAME,
|
||||||
csrfCookieName: configuration.CSRF_COOKIE_NAME,
|
csrfCookieName: configuration.CSRF_COOKIE_NAME,
|
||||||
|
|||||||
12
src/data/constants/actionTypes/assignmentTypes.js
Normal file
12
src/data/constants/actionTypes/assignmentTypes.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
const STARTED_FETCHING_ASSIGNMENT_TYPES = 'STARTED_FETCHING_ASSIGNMENT_TYPES';
|
||||||
|
const GOT_ASSIGNMENT_TYPES = 'GOT_ASSIGNMENT_TYPES';
|
||||||
|
const ERROR_FETCHING_ASSIGNMENT_TYPES = 'ERROR_FETCHING_ASSIGNMENT_TYPES';
|
||||||
|
const GOT_ARE_GRADES_FROZEN = 'GOT_ARE_GRADES_FROZEN';
|
||||||
|
|
||||||
|
export {
|
||||||
|
STARTED_FETCHING_ASSIGNMENT_TYPES,
|
||||||
|
GOT_ASSIGNMENT_TYPES,
|
||||||
|
ERROR_FETCHING_ASSIGNMENT_TYPES,
|
||||||
|
GOT_ARE_GRADES_FROZEN,
|
||||||
|
};
|
||||||
|
|
||||||
@@ -8,7 +8,6 @@ const GRADE_UPDATE_SUCCESS = 'GRADE_UPDATE_SUCCESS';
|
|||||||
const GRADE_UPDATE_FAILURE = 'GRADE_UPDATE_FAILURE';
|
const GRADE_UPDATE_FAILURE = 'GRADE_UPDATE_FAILURE';
|
||||||
|
|
||||||
const TOGGLE_GRADE_FORMAT = 'TOGGLE_GRADE_FORMAT';
|
const TOGGLE_GRADE_FORMAT = 'TOGGLE_GRADE_FORMAT';
|
||||||
const SORT_GRADES = 'SORT_GRADES';
|
|
||||||
const FILTER_COLUMNS = 'FILTER_COLUMNS';
|
const FILTER_COLUMNS = 'FILTER_COLUMNS';
|
||||||
const UPDATE_BANNER = 'UPDATE_BANNER';
|
const UPDATE_BANNER = 'UPDATE_BANNER';
|
||||||
|
|
||||||
@@ -21,7 +20,6 @@ export {
|
|||||||
GRADE_UPDATE_SUCCESS,
|
GRADE_UPDATE_SUCCESS,
|
||||||
GRADE_UPDATE_FAILURE,
|
GRADE_UPDATE_FAILURE,
|
||||||
TOGGLE_GRADE_FORMAT,
|
TOGGLE_GRADE_FORMAT,
|
||||||
SORT_GRADES,
|
|
||||||
FILTER_COLUMNS,
|
FILTER_COLUMNS,
|
||||||
UPDATE_BANNER,
|
UPDATE_BANNER,
|
||||||
};
|
};
|
||||||
|
|||||||
7
src/data/constants/actionTypes/roles.js
Normal file
7
src/data/constants/actionTypes/roles.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
const GOT_ROLES = 'GOT_ROLES';
|
||||||
|
const ERROR_FETCHING_ROLES = 'ERROR_FETCHING_ROLES';
|
||||||
|
|
||||||
|
export {
|
||||||
|
GOT_ROLES,
|
||||||
|
ERROR_FETCHING_ROLES,
|
||||||
|
};
|
||||||
48
src/data/reducers/assignmentTypes.js
Normal file
48
src/data/reducers/assignmentTypes.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import {
|
||||||
|
STARTED_FETCHING_ASSIGNMENT_TYPES,
|
||||||
|
ERROR_FETCHING_ASSIGNMENT_TYPES,
|
||||||
|
GOT_ASSIGNMENT_TYPES,
|
||||||
|
GOT_ARE_GRADES_FROZEN,
|
||||||
|
} from '../constants/actionTypes/assignmentTypes';
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
results: [],
|
||||||
|
startedFetching: false,
|
||||||
|
errorFetching: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const assignmentTypes = (state = initialState, action) => {
|
||||||
|
switch (action.type) {
|
||||||
|
case GOT_ASSIGNMENT_TYPES:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
results: action.assignmentTypes,
|
||||||
|
errorFetching: false,
|
||||||
|
finishedFetching: true,
|
||||||
|
};
|
||||||
|
case STARTED_FETCHING_ASSIGNMENT_TYPES:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
startedFetching: true,
|
||||||
|
};
|
||||||
|
case ERROR_FETCHING_ASSIGNMENT_TYPES:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
finishedFetching: true,
|
||||||
|
errorFetching: true,
|
||||||
|
};
|
||||||
|
case GOT_ARE_GRADES_FROZEN:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
areGradesFrozen: action.areGradesFrozen,
|
||||||
|
errorFetching: false,
|
||||||
|
finishedFetching: true,
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default assignmentTypes;
|
||||||
|
|
||||||
68
src/data/reducers/assignmentTypes.test.js
Normal file
68
src/data/reducers/assignmentTypes.test.js
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import assignmentTypes from './assignmentTypes';
|
||||||
|
import {
|
||||||
|
STARTED_FETCHING_ASSIGNMENT_TYPES,
|
||||||
|
ERROR_FETCHING_ASSIGNMENT_TYPES,
|
||||||
|
GOT_ASSIGNMENT_TYPES,
|
||||||
|
GOT_ARE_GRADES_FROZEN,
|
||||||
|
} from '../constants/actionTypes/assignmentTypes';
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
results: [],
|
||||||
|
startedFetching: false,
|
||||||
|
errorFetching: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const assignmentTypesData = ['Exam', 'Homework'];
|
||||||
|
|
||||||
|
describe('assignmentTypes reducer', () => {
|
||||||
|
it('has initial state', () => {
|
||||||
|
expect(assignmentTypes(undefined, {})).toEqual(initialState);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates fetch assignmentTypes request state', () => {
|
||||||
|
const expected = {
|
||||||
|
...initialState,
|
||||||
|
startedFetching: true,
|
||||||
|
};
|
||||||
|
expect(assignmentTypes(undefined, {
|
||||||
|
type: STARTED_FETCHING_ASSIGNMENT_TYPES,
|
||||||
|
})).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates fetch assignmentTypes success state', () => {
|
||||||
|
const expected = {
|
||||||
|
...initialState,
|
||||||
|
results: assignmentTypesData,
|
||||||
|
errorFetching: false,
|
||||||
|
finishedFetching: true,
|
||||||
|
};
|
||||||
|
expect(assignmentTypes(undefined, {
|
||||||
|
type: GOT_ASSIGNMENT_TYPES,
|
||||||
|
assignmentTypes: assignmentTypesData,
|
||||||
|
})).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates fetch assignmentTypes failure state', () => {
|
||||||
|
const expected = {
|
||||||
|
...initialState,
|
||||||
|
errorFetching: true,
|
||||||
|
finishedFetching: true,
|
||||||
|
};
|
||||||
|
expect(assignmentTypes(undefined, {
|
||||||
|
type: ERROR_FETCHING_ASSIGNMENT_TYPES,
|
||||||
|
})).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates areGradesFrozen success state', () => {
|
||||||
|
const expected = {
|
||||||
|
...initialState,
|
||||||
|
errorFetching: false,
|
||||||
|
finishedFetching: true,
|
||||||
|
areGradesFrozen: true,
|
||||||
|
};
|
||||||
|
expect(assignmentTypes(undefined, {
|
||||||
|
type: GOT_ARE_GRADES_FROZEN,
|
||||||
|
areGradesFrozen: true,
|
||||||
|
})).toEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,6 +17,7 @@ const cohorts = (state = initialState, action) => {
|
|||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
results: action.cohorts,
|
results: action.cohorts,
|
||||||
|
finishedFetching: true,
|
||||||
errorFetching: false,
|
errorFetching: false,
|
||||||
};
|
};
|
||||||
case STARTED_FETCHING_COHORTS:
|
case STARTED_FETCHING_COHORTS:
|
||||||
|
|||||||
70
src/data/reducers/cohorts.test.js
Normal file
70
src/data/reducers/cohorts.test.js
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import cohorts from './cohorts';
|
||||||
|
import {
|
||||||
|
STARTED_FETCHING_COHORTS,
|
||||||
|
ERROR_FETCHING_COHORTS,
|
||||||
|
GOT_COHORTS,
|
||||||
|
} from '../constants/actionTypes/cohorts';
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
results: [],
|
||||||
|
startedFetching: false,
|
||||||
|
errorFetching: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const cohortsData = [
|
||||||
|
{
|
||||||
|
assignment_type: 'manual',
|
||||||
|
group_id: null,
|
||||||
|
id: 1,
|
||||||
|
name: 'default_group',
|
||||||
|
user_count: 2,
|
||||||
|
user_partition_id: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
assignment_type: 'auto',
|
||||||
|
group_id: null,
|
||||||
|
id: 2,
|
||||||
|
name: 'auto_group',
|
||||||
|
user_count: 5,
|
||||||
|
user_partition_id: null,
|
||||||
|
}];
|
||||||
|
|
||||||
|
describe('cohorts reducer', () => {
|
||||||
|
it('has initial state', () => {
|
||||||
|
expect(cohorts(undefined, {})).toEqual(initialState);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates fetch cohorts request state', () => {
|
||||||
|
const expected = {
|
||||||
|
...initialState,
|
||||||
|
startedFetching: true,
|
||||||
|
};
|
||||||
|
expect(cohorts(undefined, {
|
||||||
|
type: STARTED_FETCHING_COHORTS,
|
||||||
|
})).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates fetch cohorts success state', () => {
|
||||||
|
const expected = {
|
||||||
|
...initialState,
|
||||||
|
results: cohortsData,
|
||||||
|
errorFetching: false,
|
||||||
|
finishedFetching: true,
|
||||||
|
};
|
||||||
|
expect(cohorts(undefined, {
|
||||||
|
type: GOT_COHORTS,
|
||||||
|
cohorts: cohortsData,
|
||||||
|
})).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates fetch cohorts failure state', () => {
|
||||||
|
const expected = {
|
||||||
|
...initialState,
|
||||||
|
errorFetching: true,
|
||||||
|
finishedFetching: true,
|
||||||
|
};
|
||||||
|
expect(cohorts(undefined, {
|
||||||
|
type: ERROR_FETCHING_COHORTS,
|
||||||
|
})).toEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,9 +4,7 @@ import {
|
|||||||
GOT_GRADES,
|
GOT_GRADES,
|
||||||
TOGGLE_GRADE_FORMAT,
|
TOGGLE_GRADE_FORMAT,
|
||||||
FILTER_COLUMNS,
|
FILTER_COLUMNS,
|
||||||
GRADE_UPDATE_SUCCESS,
|
|
||||||
UPDATE_BANNER,
|
UPDATE_BANNER,
|
||||||
SORT_GRADES,
|
|
||||||
} from '../constants/actionTypes/grades';
|
} from '../constants/actionTypes/grades';
|
||||||
|
|
||||||
const initialState = {
|
const initialState = {
|
||||||
@@ -17,6 +15,9 @@ const initialState = {
|
|||||||
errorFetching: false,
|
errorFetching: false,
|
||||||
gradeFormat: 'percent',
|
gradeFormat: 'percent',
|
||||||
showSuccess: false,
|
showSuccess: false,
|
||||||
|
prevPage: null,
|
||||||
|
nextPage: null,
|
||||||
|
showSpinner: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const grades = (state = initialState, action) => {
|
const grades = (state = initialState, action) => {
|
||||||
@@ -30,12 +31,18 @@ const grades = (state = initialState, action) => {
|
|||||||
errorFetching: false,
|
errorFetching: false,
|
||||||
selectedTrack: action.track,
|
selectedTrack: action.track,
|
||||||
selectedCohort: action.cohort,
|
selectedCohort: action.cohort,
|
||||||
|
selectedAssignmentType: action.assignmentType,
|
||||||
|
prevPage: action.prev,
|
||||||
|
nextPage: action.next,
|
||||||
|
showSpinner: false,
|
||||||
|
courseId: action.courseId,
|
||||||
};
|
};
|
||||||
case STARTED_FETCHING_GRADES:
|
case STARTED_FETCHING_GRADES:
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
startedFetching: true,
|
startedFetching: true,
|
||||||
finishedFetching: false,
|
finishedFetching: false,
|
||||||
|
showSpinner: true,
|
||||||
};
|
};
|
||||||
case ERROR_FETCHING_GRADES:
|
case ERROR_FETCHING_GRADES:
|
||||||
return {
|
return {
|
||||||
@@ -58,11 +65,6 @@ const grades = (state = initialState, action) => {
|
|||||||
...state,
|
...state,
|
||||||
showSuccess: action.showSuccess,
|
showSuccess: action.showSuccess,
|
||||||
};
|
};
|
||||||
case SORT_GRADES:
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
results: action.results,
|
|
||||||
};
|
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|||||||
176
src/data/reducers/grades.test.js
Normal file
176
src/data/reducers/grades.test.js
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import grades from './grades';
|
||||||
|
import {
|
||||||
|
STARTED_FETCHING_GRADES,
|
||||||
|
ERROR_FETCHING_GRADES,
|
||||||
|
GOT_GRADES,
|
||||||
|
TOGGLE_GRADE_FORMAT,
|
||||||
|
FILTER_COLUMNS,
|
||||||
|
UPDATE_BANNER,
|
||||||
|
} from '../constants/actionTypes/grades';
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
results: [],
|
||||||
|
headings: [],
|
||||||
|
startedFetching: false,
|
||||||
|
finishedFetching: false,
|
||||||
|
errorFetching: false,
|
||||||
|
gradeFormat: 'percent',
|
||||||
|
showSuccess: false,
|
||||||
|
prevPage: null,
|
||||||
|
nextPage: null,
|
||||||
|
showSpinner: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||||
|
const headingsData = [
|
||||||
|
{ name: 'exam' },
|
||||||
|
{ name: 'homework2' },
|
||||||
|
];
|
||||||
|
const gradesData = [
|
||||||
|
{
|
||||||
|
course_id: courseId,
|
||||||
|
email: 'user1@example.com',
|
||||||
|
username: 'user1',
|
||||||
|
user_id: 1,
|
||||||
|
percent: 0.5,
|
||||||
|
letter_grade: null,
|
||||||
|
section_breakdown: [
|
||||||
|
{
|
||||||
|
subsection_name: 'Demo Course Overview',
|
||||||
|
score_earned: 0,
|
||||||
|
score_possible: 0,
|
||||||
|
percent: 0,
|
||||||
|
displayed_value: '0.00',
|
||||||
|
grade_description: '(0.00/0.00)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
subsection_name: 'Example Week 1: Getting Started',
|
||||||
|
score_earned: 1,
|
||||||
|
score_possible: 1,
|
||||||
|
percent: 1,
|
||||||
|
displayed_value: '1.00',
|
||||||
|
grade_description: '(0.00/0.00)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
course_id: courseId,
|
||||||
|
email: 'user22@example.com',
|
||||||
|
username: 'user22',
|
||||||
|
user_id: 22,
|
||||||
|
percent: 0,
|
||||||
|
letter_grade: null,
|
||||||
|
section_breakdown: [
|
||||||
|
{
|
||||||
|
subsection_name: 'Demo Course Overview',
|
||||||
|
score_earned: 0,
|
||||||
|
score_possible: 0,
|
||||||
|
percent: 0,
|
||||||
|
displayed_value: '0.00',
|
||||||
|
grade_description: '(0.00/0.00)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
subsection_name: 'Example Week 1: Getting Started',
|
||||||
|
score_earned: 1,
|
||||||
|
score_possible: 1,
|
||||||
|
percent: 0,
|
||||||
|
displayed_value: '0.00',
|
||||||
|
grade_description: '(0.00/0.00)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}];
|
||||||
|
|
||||||
|
describe('grades reducer', () => {
|
||||||
|
it('has initial state', () => {
|
||||||
|
expect(grades(undefined, {})).toEqual(initialState);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates fetch grades request state', () => {
|
||||||
|
const expected = {
|
||||||
|
...initialState,
|
||||||
|
startedFetching: true,
|
||||||
|
showSpinner: true,
|
||||||
|
};
|
||||||
|
expect(grades(undefined, {
|
||||||
|
type: STARTED_FETCHING_GRADES,
|
||||||
|
})).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates fetch grades success state', () => {
|
||||||
|
const expectedPrev = 'testPrevUrl';
|
||||||
|
const expectedNext = 'testNextUrl';
|
||||||
|
const expectedTrack = 'verified';
|
||||||
|
const expectedCohortId = 2;
|
||||||
|
const expected = {
|
||||||
|
...initialState,
|
||||||
|
results: gradesData,
|
||||||
|
headings: headingsData,
|
||||||
|
errorFetching: false,
|
||||||
|
finishedFetching: true,
|
||||||
|
selectedTrack: expectedTrack,
|
||||||
|
selectedCohort: expectedCohortId,
|
||||||
|
prevPage: expectedPrev,
|
||||||
|
nextPage: expectedNext,
|
||||||
|
showSpinner: false,
|
||||||
|
courseId,
|
||||||
|
};
|
||||||
|
expect(grades(undefined, {
|
||||||
|
type: GOT_GRADES,
|
||||||
|
grades: gradesData,
|
||||||
|
headings: headingsData,
|
||||||
|
prev: expectedPrev,
|
||||||
|
next: expectedNext,
|
||||||
|
track: expectedTrack,
|
||||||
|
cohort: expectedCohortId,
|
||||||
|
showSpinner: true,
|
||||||
|
courseId,
|
||||||
|
})).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates toggle grade format state success', () => {
|
||||||
|
const formatTypeData = 'percent';
|
||||||
|
const expected = {
|
||||||
|
...initialState,
|
||||||
|
gradeFormat: formatTypeData,
|
||||||
|
};
|
||||||
|
expect(grades(undefined, {
|
||||||
|
type: TOGGLE_GRADE_FORMAT,
|
||||||
|
formatType: formatTypeData,
|
||||||
|
})).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates filter columns state success', () => {
|
||||||
|
const expectedHeadings = headingsData;
|
||||||
|
const expected = {
|
||||||
|
...initialState,
|
||||||
|
headings: expectedHeadings,
|
||||||
|
};
|
||||||
|
expect(grades(undefined, {
|
||||||
|
type: FILTER_COLUMNS,
|
||||||
|
headings: expectedHeadings,
|
||||||
|
})).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates update_banner state success', () => {
|
||||||
|
const expectedShowSuccess = true;
|
||||||
|
const expected = {
|
||||||
|
...initialState,
|
||||||
|
showSuccess: expectedShowSuccess,
|
||||||
|
};
|
||||||
|
expect(grades(undefined, {
|
||||||
|
type: UPDATE_BANNER,
|
||||||
|
showSuccess: expectedShowSuccess,
|
||||||
|
})).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates fetch grades failure state', () => {
|
||||||
|
const expected = {
|
||||||
|
...initialState,
|
||||||
|
errorFetching: true,
|
||||||
|
finishedFetching: true,
|
||||||
|
};
|
||||||
|
expect(grades(undefined, {
|
||||||
|
type: ERROR_FETCHING_GRADES,
|
||||||
|
})).toEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,11 +3,15 @@ import { combineReducers } from 'redux';
|
|||||||
import cohorts from './cohorts';
|
import cohorts from './cohorts';
|
||||||
import grades from './grades';
|
import grades from './grades';
|
||||||
import tracks from './tracks';
|
import tracks from './tracks';
|
||||||
|
import assignmentTypes from './assignmentTypes';
|
||||||
|
import roles from './roles';
|
||||||
|
|
||||||
const rootReducer = combineReducers({
|
const rootReducer = combineReducers({
|
||||||
grades,
|
grades,
|
||||||
cohorts,
|
cohorts,
|
||||||
tracks,
|
tracks,
|
||||||
|
assignmentTypes,
|
||||||
|
roles,
|
||||||
});
|
});
|
||||||
|
|
||||||
export default rootReducer;
|
export default rootReducer;
|
||||||
|
|||||||
27
src/data/reducers/roles.js
Normal file
27
src/data/reducers/roles.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import {
|
||||||
|
GOT_ROLES,
|
||||||
|
ERROR_FETCHING_ROLES,
|
||||||
|
} from '../constants/actionTypes/roles';
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
canUserViewGradebook: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const roles = (state = initialState, action) => {
|
||||||
|
switch (action.type) {
|
||||||
|
case GOT_ROLES:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
canUserViewGradebook: action.canUserViewGradebook,
|
||||||
|
};
|
||||||
|
case ERROR_FETCHING_ROLES:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
canUserViewGradebook: false,
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default roles;
|
||||||
47
src/data/reducers/roles.test.js
Normal file
47
src/data/reducers/roles.test.js
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import roles from './roles';
|
||||||
|
import {
|
||||||
|
ERROR_FETCHING_ROLES,
|
||||||
|
GOT_ROLES,
|
||||||
|
} from '../constants/actionTypes/roles';
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
canUserViewGradebook: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('tracks reducer', () => {
|
||||||
|
it('has initial state', () => {
|
||||||
|
expect(roles(undefined, {})).toEqual(initialState);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates canUserViewGradebook to true', () => {
|
||||||
|
const expected = {
|
||||||
|
...initialState,
|
||||||
|
canUserViewGradebook: true,
|
||||||
|
};
|
||||||
|
expect(roles(undefined, {
|
||||||
|
type: GOT_ROLES,
|
||||||
|
canUserViewGradebook: true,
|
||||||
|
})).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates canUserViewGradebook to false', () => {
|
||||||
|
const expected = {
|
||||||
|
...initialState,
|
||||||
|
canUserViewGradebook: false,
|
||||||
|
};
|
||||||
|
expect(roles(undefined, {
|
||||||
|
type: GOT_ROLES,
|
||||||
|
canUserViewGradebook: false,
|
||||||
|
})).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates fetch roles failure state', () => {
|
||||||
|
const expected = {
|
||||||
|
...initialState,
|
||||||
|
canUserViewGradebook: false,
|
||||||
|
};
|
||||||
|
expect(roles(undefined, {
|
||||||
|
type: ERROR_FETCHING_ROLES,
|
||||||
|
})).toEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,6 +18,7 @@ const tracks = (state = initialState, action) => {
|
|||||||
...state,
|
...state,
|
||||||
results: action.tracks,
|
results: action.tracks,
|
||||||
errorFetching: false,
|
errorFetching: false,
|
||||||
|
finishedFetching: true,
|
||||||
};
|
};
|
||||||
case STARTED_FETCHING_TRACKS:
|
case STARTED_FETCHING_TRACKS:
|
||||||
return {
|
return {
|
||||||
|
|||||||
76
src/data/reducers/tracks.test.js
Normal file
76
src/data/reducers/tracks.test.js
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import tracks from './tracks';
|
||||||
|
import {
|
||||||
|
STARTED_FETCHING_TRACKS,
|
||||||
|
ERROR_FETCHING_TRACKS,
|
||||||
|
GOT_TRACKS,
|
||||||
|
} from '../constants/actionTypes/tracks';
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
results: [],
|
||||||
|
startedFetching: false,
|
||||||
|
errorFetching: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const tracksData = [
|
||||||
|
{
|
||||||
|
slug: 'audit',
|
||||||
|
name: 'Audit',
|
||||||
|
min_price: 0,
|
||||||
|
suggested_prices: '',
|
||||||
|
currency: 'usd',
|
||||||
|
expiration_datetime: null,
|
||||||
|
description: null,
|
||||||
|
sku: '68EFFFF',
|
||||||
|
bulk_sku: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'verified',
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
min_price: 100,
|
||||||
|
suggested_prices: '',
|
||||||
|
currency: 'usd',
|
||||||
|
expiration_datetime: '2021-05-04T18:08:12.644361Z',
|
||||||
|
description: null,
|
||||||
|
sku: '8CF08E5',
|
||||||
|
bulk_sku: 'A5B6DBE',
|
||||||
|
}];
|
||||||
|
|
||||||
|
describe('tracks reducer', () => {
|
||||||
|
it('has initial state', () => {
|
||||||
|
expect(tracks(undefined, {})).toEqual(initialState);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates fetch tracks request state', () => {
|
||||||
|
const expected = {
|
||||||
|
...initialState,
|
||||||
|
startedFetching: true,
|
||||||
|
};
|
||||||
|
expect(tracks(undefined, {
|
||||||
|
type: STARTED_FETCHING_TRACKS,
|
||||||
|
})).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates fetch tracks success state', () => {
|
||||||
|
const expected = {
|
||||||
|
...initialState,
|
||||||
|
results: tracksData,
|
||||||
|
errorFetching: false,
|
||||||
|
finishedFetching: true,
|
||||||
|
};
|
||||||
|
expect(tracks(undefined, {
|
||||||
|
type: GOT_TRACKS,
|
||||||
|
tracks: tracksData,
|
||||||
|
})).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates fetch tracks failure state', () => {
|
||||||
|
const expected = {
|
||||||
|
...initialState,
|
||||||
|
errorFetching: true,
|
||||||
|
finishedFetching: true,
|
||||||
|
};
|
||||||
|
expect(tracks(undefined, {
|
||||||
|
type: ERROR_FETCHING_TRACKS,
|
||||||
|
})).toEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,12 +3,12 @@ import { configuration } from '../../config';
|
|||||||
|
|
||||||
class LmsApiService {
|
class LmsApiService {
|
||||||
static baseUrl = configuration.LMS_BASE_URL;
|
static baseUrl = configuration.LMS_BASE_URL;
|
||||||
|
static pageSize = 25
|
||||||
|
|
||||||
static fetchGradebookData(courseId, searchText, cohort, track) {
|
static fetchGradebookData(courseId, searchText, cohort, track) {
|
||||||
let gradebookUrl = `${LmsApiService.baseUrl}/api/grades/v1/gradebook/${courseId}/`;
|
let gradebookUrl = `${LmsApiService.baseUrl}/api/grades/v1/gradebook/${courseId}/`;
|
||||||
if (searchText || track || cohort) {
|
|
||||||
gradebookUrl += '?';
|
gradebookUrl += `?page_size=${LmsApiService.pageSize}&`;
|
||||||
}
|
|
||||||
if (searchText) {
|
if (searchText) {
|
||||||
gradebookUrl += `username_contains=${searchText}&`;
|
gradebookUrl += `username_contains=${searchText}&`;
|
||||||
}
|
}
|
||||||
@@ -25,7 +25,10 @@ class LmsApiService {
|
|||||||
/*
|
/*
|
||||||
updateData is expected to be a list of objects with the keys 'user_id' (an integer),
|
updateData is expected to be a list of objects with the keys 'user_id' (an integer),
|
||||||
'usage_id' (a string) and 'grade', which is an object with the keys:
|
'usage_id' (a string) and 'grade', which is an object with the keys:
|
||||||
'earned_all_override', 'possible_all_override', 'earned_graded_override', and 'possible_graded_override',
|
'earned_all_override',
|
||||||
|
'possible_all_override',
|
||||||
|
'earned_graded_override',
|
||||||
|
and 'possible_graded_override',
|
||||||
each of which should be an integer.
|
each of which should be an integer.
|
||||||
Example:
|
Example:
|
||||||
[
|
[
|
||||||
@@ -44,9 +47,9 @@ 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 apiClient.post(gradebookUrl, updateData);
|
return apiClient.post(gradebookUrl, updateData);
|
||||||
}
|
}
|
||||||
|
|
||||||
static fetchTracks(courseId) {
|
static fetchTracks(courseId) {
|
||||||
const trackUrl = `${LmsApiService.baseUrl}/api/enrollment/v1/course/${courseId}`;
|
const trackUrl = `${LmsApiService.baseUrl}/api/enrollment/v1/course/${courseId}?include_expired=1`;
|
||||||
return apiClient.get(trackUrl);
|
return apiClient.get(trackUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +57,16 @@ class LmsApiService {
|
|||||||
const cohortsUrl = `${LmsApiService.baseUrl}/courses/${courseId}/cohorts/`;
|
const cohortsUrl = `${LmsApiService.baseUrl}/courses/${courseId}/cohorts/`;
|
||||||
return apiClient.get(cohortsUrl);
|
return apiClient.get(cohortsUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static fetchAssignmentTypes(courseId) {
|
||||||
|
const assignmentTypesUrl = `${LmsApiService.baseUrl}/api/grades/v1/gradebook/${courseId}/grading-info?graded_only=true`;
|
||||||
|
return apiClient.get(assignmentTypesUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
static fetchUserRoles(courseId) {
|
||||||
|
const rolesUrl = `${LmsApiService.baseUrl}/api/enrollment/v1/roles/?course_id=${encodeURIComponent(courseId)}`;
|
||||||
|
return apiClient.get(rolesUrl);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default LmsApiService;
|
export default LmsApiService;
|
||||||
|
|||||||
@@ -2,14 +2,57 @@ import { applyMiddleware, createStore } from 'redux';
|
|||||||
import thunkMiddleware from 'redux-thunk';
|
import thunkMiddleware from 'redux-thunk';
|
||||||
import { composeWithDevTools } from 'redux-devtools-extension/logOnlyInProduction';
|
import { composeWithDevTools } from 'redux-devtools-extension/logOnlyInProduction';
|
||||||
import { createLogger } from 'redux-logger';
|
import { createLogger } from 'redux-logger';
|
||||||
|
import { createMiddleware } from 'redux-beacon';
|
||||||
|
import Segment, { trackEvent, trackPageView } from '@redux-beacon/segment';
|
||||||
|
import { GOT_ROLES } from './constants/actionTypes/roles';
|
||||||
|
import { GOT_GRADES, GRADE_UPDATE_SUCCESS, GRADE_UPDATE_FAILURE } from './constants/actionTypes/grades';
|
||||||
|
|
||||||
import reducers from './reducers';
|
import reducers from './reducers';
|
||||||
|
|
||||||
const loggerMiddleware = createLogger();
|
const loggerMiddleware = createLogger();
|
||||||
|
const trackingCategory = 'gradebook';
|
||||||
|
|
||||||
|
const eventsMap = {
|
||||||
|
[GOT_ROLES]: trackPageView(action => ({
|
||||||
|
category: trackingCategory,
|
||||||
|
page: action.courseId,
|
||||||
|
})),
|
||||||
|
[GOT_GRADES]: trackEvent(action => ({
|
||||||
|
name: 'Grades displayed or paginated',
|
||||||
|
properties: {
|
||||||
|
category: trackingCategory,
|
||||||
|
courseId: action.courseId,
|
||||||
|
track: action.track,
|
||||||
|
cohort: action.cohort,
|
||||||
|
assignmentType: action.assignmentType,
|
||||||
|
prev: action.prev,
|
||||||
|
next: action.next,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
[GRADE_UPDATE_SUCCESS]: trackEvent(action => ({
|
||||||
|
name: 'Grades Updated',
|
||||||
|
properties: {
|
||||||
|
category: trackingCategory,
|
||||||
|
courseId: action.courseId,
|
||||||
|
updatedGrades: action.payload.responseData,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
[GRADE_UPDATE_FAILURE]: trackEvent(action => ({
|
||||||
|
name: 'Grades Fail to Update',
|
||||||
|
properties: {
|
||||||
|
category: trackingCategory,
|
||||||
|
courseId: action.courseId,
|
||||||
|
error: action.payload.error,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const segmentMiddleware = createMiddleware(eventsMap, Segment());
|
||||||
|
|
||||||
|
|
||||||
const store = createStore(
|
const store = createStore(
|
||||||
reducers,
|
reducers,
|
||||||
composeWithDevTools(applyMiddleware(thunkMiddleware, loggerMiddleware)),
|
composeWithDevTools(applyMiddleware(thunkMiddleware, loggerMiddleware, segmentMiddleware)),
|
||||||
);
|
);
|
||||||
|
|
||||||
export default store;
|
export default store;
|
||||||
|
|||||||
@@ -3,21 +3,48 @@ 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 from '@edx/frontend-component-footer';
|
||||||
|
|
||||||
|
import apiClient from './data/apiClient';
|
||||||
import GradebookPage from './containers/GradebookPage';
|
import GradebookPage from './containers/GradebookPage';
|
||||||
|
import Header from './components/Header';
|
||||||
import store from './data/store';
|
import store from './data/store';
|
||||||
|
import FooterLogo from '../assets/edx-footer.png';
|
||||||
import './App.scss';
|
import './App.scss';
|
||||||
|
|
||||||
const App = () => (
|
const App = () => (
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<Router>
|
<Router>
|
||||||
<main>
|
<div>
|
||||||
<Switch>
|
<Header />
|
||||||
<Route exact path="/:courseId" component={GradebookPage} />
|
<main>
|
||||||
</Switch>
|
<Switch>
|
||||||
</main>
|
<Route exact path="/:courseId" component={GradebookPage} />
|
||||||
|
</Switch>
|
||||||
|
</main>
|
||||||
|
<SiteFooter
|
||||||
|
siteName={process.env.SITE_NAME}
|
||||||
|
siteLogo={FooterLogo}
|
||||||
|
marketingSiteBaseUrl={process.env.MARKETING_SITE_BASE_URL}
|
||||||
|
supportUrl={process.env.SUPPORT_URL}
|
||||||
|
contactUrl={process.env.CONTACT_URL}
|
||||||
|
openSourceUrl={process.env.OPEN_SOURCE_URL}
|
||||||
|
termsOfServiceUrl={process.env.TERMS_OF_SERVICE_URL}
|
||||||
|
privacyPolicyUrl={process.env.PRIVACY_POLICY_URL}
|
||||||
|
facebookUrl={process.env.FACEBOOK_URL}
|
||||||
|
twitterUrl={process.env.TWITTER_URL}
|
||||||
|
youTubeUrl={process.env.YOU_TUBE_URL}
|
||||||
|
linkedInUrl={process.env.LINKED_IN_URL}
|
||||||
|
googlePlusUrl={process.env.GOOGLE_PLUS_URL}
|
||||||
|
redditUrl={process.env.REDDIT_URL}
|
||||||
|
appleAppStoreUrl={process.env.APPLE_APP_STORE_URL}
|
||||||
|
googlePlayUrl={process.env.GOOGLE_PLAY_URL}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</Router>
|
</Router>
|
||||||
</Provider>
|
</Provider>
|
||||||
);
|
);
|
||||||
|
|
||||||
ReactDOM.render(<App />, document.getElementById('root'));
|
if (apiClient.ensurePublicOrAuthencationAndCookies(window.location.pathname)) {
|
||||||
|
ReactDOM.render(<App />, document.getElementById('root'));
|
||||||
|
}
|
||||||
|
|||||||
7
src/postcss.config.js
Normal file
7
src/postcss.config.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/* I'm here to allow autoprefixing in webpack.prod.config.js */
|
||||||
|
module.exports = {
|
||||||
|
plugins: [
|
||||||
|
require('autoprefixer')({ grid: true, browsers: ['>1%'] }),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
85
src/segment.js
Normal file
85
src/segment.js
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
// The code in this file is from Segment's website:
|
||||||
|
// https://segment.com/docs/sources/website/analytics.js/quickstart/
|
||||||
|
import { configuration } from './config';
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
// Create a queue, but don't obliterate an existing one!
|
||||||
|
const analytics = window.analytics = window.analytics || [];
|
||||||
|
|
||||||
|
// If the real analytics.js is already on the page return.
|
||||||
|
if (analytics.initialize) return;
|
||||||
|
|
||||||
|
// If the snippet was invoked already show an error.
|
||||||
|
if (analytics.invoked) {
|
||||||
|
if (window.console && console.error) {
|
||||||
|
console.error('Segment snippet included twice.');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invoked flag, to make sure the snippet
|
||||||
|
// is never invoked twice.
|
||||||
|
analytics.invoked = true;
|
||||||
|
|
||||||
|
// A list of the methods in Analytics.js to stub.
|
||||||
|
analytics.methods = [
|
||||||
|
'trackSubmit',
|
||||||
|
'trackClick',
|
||||||
|
'trackLink',
|
||||||
|
'trackForm',
|
||||||
|
'pageview',
|
||||||
|
'identify',
|
||||||
|
'reset',
|
||||||
|
'group',
|
||||||
|
'track',
|
||||||
|
'ready',
|
||||||
|
'alias',
|
||||||
|
'debug',
|
||||||
|
'page',
|
||||||
|
'once',
|
||||||
|
'off',
|
||||||
|
'on',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Define a factory to create stubs. These are placeholders
|
||||||
|
// for methods in Analytics.js so that you never have to wait
|
||||||
|
// for it to load to actually record data. The `method` is
|
||||||
|
// stored as the first argument, so we can replay the data.
|
||||||
|
analytics.factory = function (method) {
|
||||||
|
return function () {
|
||||||
|
const args = Array.prototype.slice.call(arguments);
|
||||||
|
args.unshift(method);
|
||||||
|
analytics.push(args);
|
||||||
|
return analytics;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// For each of our methods, generate a queueing stub.
|
||||||
|
for (let i = 0; i < analytics.methods.length; i++) {
|
||||||
|
const key = analytics.methods[i];
|
||||||
|
analytics[key] = analytics.factory(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define a method to load Analytics.js from our CDN,
|
||||||
|
// and that will be sure to only ever load it once.
|
||||||
|
analytics.load = function (key, options) {
|
||||||
|
// Create an async script element based on your key.
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.type = 'text/javascript';
|
||||||
|
script.async = true;
|
||||||
|
script.src = `https://cdn.segment.com/analytics.js/v1/${
|
||||||
|
key}/analytics.min.js`;
|
||||||
|
|
||||||
|
// Insert our script next to the first script element.
|
||||||
|
const first = document.getElementsByTagName('script')[0];
|
||||||
|
first.parentNode.insertBefore(script, first);
|
||||||
|
analytics._loadOptions = options;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add a version to keep track of what's in the wild.
|
||||||
|
analytics.SNIPPET_VERSION = '4.1.0';
|
||||||
|
|
||||||
|
// Load Analytics.js with your key, which will automatically
|
||||||
|
// load the tools you've enabled for your account. Boosh!
|
||||||
|
analytics.load(configuration.SEGMENT_KEY);
|
||||||
|
}());
|
||||||
@@ -4,3 +4,7 @@ import Enzyme from 'enzyme';
|
|||||||
import Adapter from 'enzyme-adapter-react-16';
|
import Adapter from 'enzyme-adapter-react-16';
|
||||||
|
|
||||||
Enzyme.configure({ adapter: new Adapter() });
|
Enzyme.configure({ adapter: new Adapter() });
|
||||||
|
|
||||||
|
// These configuration values are usually set in webpack's EnvironmentPlugin however
|
||||||
|
// Jest does not use webpack so we need to set these so for testing
|
||||||
|
process.env.LMS_BASE_URL = 'http://localhost:18000';
|
||||||
|
|||||||
Reference in New Issue
Block a user