Compare commits

...

108 Commits

Author SHA1 Message Date
Richard I Reilly
93be5329ca Merge pull request #79 from edx/rir/lint
fix(lint): Fix all eslint issues and prop validation
2019-01-23 12:21:33 -05:00
Rick Reilly
80ba7e7152 fix(lint): Fix all eslint issues and prop validation 2019-01-23 12:18:32 -05:00
Alex Dusenbery
f88526aa3a Include expired course modes when fetching data from course enrollment API. 2019-01-23 10:16:07 -05:00
Simon Chen
c0f08eee58 Merge pull request #80 from edx/schen/improve_analytics
fix(analytics): Add the proper labels to analytics for gradebook
2019-01-22 13:43:29 -05:00
Simon Chen
ef62ea35dc fix(analytics): Add the proper labels to analytics for gradebook 2019-01-22 13:25:41 -05:00
Simon Chen
34eaa31776 Merge pull request #78 from edx/schen/EDUCATOR-3925
fix(bug): make sure gradebook rounding handle null input
2019-01-17 14:44:23 -05:00
Simon Chen
a7316e6824 fix(bug): make sure gradebook rounding handle null input 2019-01-17 14:37:21 -05:00
Alex Dusenbery
c0ab04f20c Merge pull request #77 from edx/aed/adrs
Add ADRs about API usage and UX.
2019-01-17 11:19:24 -05:00
Alex Dusenbery
ed72e7c203 Add ADRs about API usage and UX. 2019-01-16 17:00:21 -05:00
Simon Chen
223d9a00bd Merge pull request #76 from edx/schen/analytics_setup
Add segment library integration with Gradebook to track events
2019-01-16 16:10:41 -05:00
Simon Chen
8379f48e50 fix(analytics): Add segment integration into Gradebook
Gradebook should now have segment.io tracking
2019-01-16 13:41:35 -05:00
Jansen Kantor
9e1268e388 Merge pull request #75 from edx/jkantor/a11y-2
fix(a11y): add select aria-labels, row headers
2019-01-16 10:16:14 -05:00
jansenk
57e0f2254a fix(a11y): add select aria-labels, row headers
EDUCATOR-3858
2019-01-15 16:40:38 -05:00
Douglas Hall
2cc14191b4 Merge pull request #71 from edx/douglashall/frontend-component-footer
Move footer component to npm package
2019-01-10 14:32:19 -05:00
Douglas Hall
603dbeb823 fix(footer): move footer component to npm package 2019-01-09 16:38:01 -05:00
Zachary Hancock
55cb1f4140 Merge pull request #74 from edx/zhancock/openedx-meta
add metadata for openedx releases
2019-01-08 16:39:25 -05:00
Zach Hancock
55648a62ff add metadata for openedx releases 2019-01-08 15:28:30 -05:00
Zachary Hancock
62f9d24704 Merge pull request #73 from edx/zhancock/devstack-integration
move project run/setup to devstack
2019-01-08 11:19:19 -05:00
Zach Hancock
f036b0cf34 move project run/setup to devstack 2019-01-07 14:10:55 -05:00
Jansen Kantor
67493d1e9e Merge pull request #69 from edx/jkantor/change-message
changed role error message and don't show during loading
2019-01-03 10:59:37 -05:00
jansenk
e5bca7e526 changed role error message and don't show during loading 2019-01-02 16:35:06 -05:00
Jansen Kantor
52c5357ce7 Merge pull request #66 from edx/jkantor/change-pagination-buttons
implement gradebook pagination button feedback
2019-01-02 16:19:43 -05:00
jansenk
d469cc2de7 implement gradebook pagination button feedback
refactor buttons to a pure function component
change labels
disable rather than hide

EDUCATOR-3825
2019-01-02 16:11:01 -05:00
Jansen Kantor
86092f22b3 Merge pull request #65 from edx/jkantor/disable-student-groups
disable rather than hide empty groups and cohorts
2019-01-02 09:22:19 -05:00
Zachary Hancock
c8cb07228f Merge pull request #63 from edx/zhancock/edit-modal
Gradebook edit modal updates
2018-12-27 10:04:57 -05:00
Jansen Kantor
a1946e7bc4 Merge pull request #64 from edx/jkantor/roles-filter
request filtered roles
2018-12-21 15:08:09 -05:00
jansenk
01d80e0fff disable rather than hide empty groups and cohorts
EDUCATOR-3824
2018-12-21 14:23:19 -05:00
jansenk
e6da087e83 request filtered roles 2018-12-21 12:53:07 -05:00
Jansen Kantor
ac5eaed5cb Merge pull request #62 from edx/jkantor/staff
fix(auth) allow global staff to view gradebook
2018-12-21 11:46:02 -05:00
jansenk
88997ca242 fix(auth) allow global staff to view gradebook 2018-12-21 11:34:28 -05:00
Zach Hancock
d5daf9086f gradebook edit modal message 2018-12-21 10:09:35 -05:00
Simon Chen
8a01a60d63 Merge pull request #61 from edx/schen/default_select
fix(functionality): Make sure we default select radio button
2018-12-20 10:06:27 -05:00
Simon Chen
66cdcc7f2a fix(functionality): Make sure we default select radio button 2018-12-20 09:57:14 -05:00
Robert Raposa
0c73d66666 Merge pull request #59 from edx/robrap/footer-logo
Update footer logo.
2018-12-19 16:13:55 -05:00
albemarle
28e3e6d0e6 Merge pull request #60 from edx/home-link
add aria-label for edX Home
2018-12-19 15:29:57 -05:00
albemarle
6473bafa3d edx -> edX 2018-12-19 15:16:37 -05:00
Jansen Kantor
167901e665 Merge pull request #55 from edx/jkantor/role-error
render error message if user is not allowed to view gradebook
2018-12-19 15:15:34 -05:00
albemarle
50a0d6e579 add aria-label for edX Home 2018-12-19 15:09:58 -05:00
Robert Raposa
a284c286f5 Update footer logo.
ARCH-322
2018-12-19 14:04:31 -05:00
jansenk
dd967e703c render error message if user is not allowed to view gradebook 2018-12-19 14:00:27 -05:00
Richard I Reilly
725dc071e3 Merge pull request #58 from edx/rir/base-html-fixes
Add necessary meta tag to the base html file, language attribute for …
2018-12-19 13:37:45 -05:00
Rick Reilly
3da7730f23 Add necessary meta tag to the base html file, language attribute for a11y, and title 2018-12-19 11:20:33 -05:00
Richard I Reilly
bea36fb387 Merge pull request #57 from edx/kill-the-cats
Minor clean-up.
2018-12-18 18:29:09 -05:00
Robert Raposa
1408b0ae7e Minor clean-up. 2018-12-18 17:35:37 -05:00
Robert Raposa
7b817a4234 Merge pull request #56 from edx/dynamic-copyright
ARCH-321: Dynamic copyright in footer.
2018-12-18 17:13:42 -05:00
Richard I Reilly
a762c47d77 Merge pull request #37 from edx/add-footer
ARCH-308: Reimplement LMS footer in React in Gradebook.
2018-12-18 16:55:08 -05:00
Robert Raposa
aecb93c252 Dynamic copyright in footer.
ARCH-321
2018-12-18 16:51:27 -05:00
Robert Raposa
5a489b1bd5 Add footer matching LMS courses footer.
Note: There are still some follow-up tasks in ARCH-308
for analytics, i18n, etc. This gets the base functionality
in place.

ARCH-308
2018-12-18 14:19:01 -05:00
Simon Chen
5c642a1be5 Merge pull request #54 from edx/schen/alert
Update the color of the alert from red to yellow
2018-12-13 15:30:20 -05:00
Simon Chen
9a0e0e0ece Update the color of the alert from red to yellow 2018-12-13 14:50:38 -05:00
Alex Dusenbery
7486a342e2 Merge pull request #53 from edx/aed/show-attempted
Distinguish unattempted subsections.
2018-12-13 14:20:18 -05:00
Alex Dusenbery
fd807c54f8 Update README.md 2018-12-12 16:12:15 -05:00
Alex Dusenbery
9b894b502f Distinguish unattempted subsections. 2018-12-12 15:03:42 -05:00
Douglas Hall
afd9692f6b Merge pull request #51 from edx/douglashall/fix_prod_webpack_config
fix(webpack): use the appropriate css loader in prod webpack config
2018-12-12 10:56:04 -05:00
Douglas Hall
8f77dea222 fix(webpack): use the appropriate css loader in prod webpack config 2018-12-12 09:48:15 -05:00
Simon Chen
3bf3acaaec Merge pull request #50 from edx/schen/frozen_grades
fix(feat): Prevent editing if course grades are frozen
2018-12-11 13:45:23 -05:00
Simon Chen
f1ab3d0330 fix(feat): Prevent editing if course grades are frozen 2018-12-11 13:29:00 -05:00
Simon Chen
8d89bc16b1 Merge pull request #48 from edx/schen/update_wording
Update link wording
2018-12-10 15:29:01 -05:00
Alex Dusenbery
d93476c198 fix(UI): round numerator of grade ratios. 2018-12-10 14:26:19 -05:00
Simon Chen
b46d47286b Update link wording 2018-12-10 14:22:49 -05:00
Simon Chen
4aecfce14a Merge pull request #42 from edx/schen/test3
Add all the tests to reducers
2018-12-10 13:01:03 -05:00
Douglas Hall
14f7ad01b8 Merge pull request #46 from douglashall/douglashall/fix_logo
fix(header): fix edX logo in header
2018-12-10 12:29:39 -05:00
Douglas Hall
104cb30ef5 fix(header): fix edX logo in header 2018-12-10 12:16:58 -05:00
Simon Chen
e9bc4cebe4 Merge pull request #43 from edx/schen/fix_local
fix(auth): fix locally running gradebook auth refresh issue
2018-12-07 14:15:03 -05:00
Simon Chen
559180592c fix(auth): fix locally running gradebook auth refresh issue 2018-12-07 14:09:50 -05:00
Richard I Reilly
0f1f0ae89d Merge pull request #41 from edx/rir/header
Add super simple header
2018-12-07 14:04:14 -05:00
Simon Chen
a812ee3816 Add all the tests to reducers 2018-12-07 12:11:21 -05:00
Rick Reilly
2e725e0441 Add super simple header 2018-12-07 11:48:26 -05:00
Simon Chen
a1c2ccc539 Merge pull request #40 from edx/schen/tests2
Add more unit tests on actions and reducers
2018-12-07 10:59:02 -05:00
Simon Chen
a70ddd79f6 Add more unit tests on actions and reducers 2018-12-07 10:12:13 -05:00
Simon Chen
dd82054bbc Merge pull request #39 from edx/schen/setup-test
feat(test): Setup unit testing
2018-12-05 14:20:58 -05:00
Jansen Kantor
6a4bc67841 Merge pull request #38 from edx/encoding
fix(UI) specify utf8 to avoid incorrect character rendering
2018-12-05 14:15:51 -05:00
Simon Chen
adfefac85d feat(test): Setup unit testing 2018-12-05 13:52:33 -05:00
jkantor
c92144c436 fix(UI) specify utf8 to avoid incorrect character rendering 2018-12-05 13:35:28 -05:00
Jansen Kantor
ca0156ea4c Merge pull request #36 from edx/rounded-percents
fix(UI) rounded percentages to two decimal places
2018-12-05 13:33:38 -05:00
jkantor
61c4bc11bd fix(UI) rounded percentages to two decimal places 2018-12-05 10:53:55 -05:00
Jansen Kantor
db25a18f9d Merge pull request #35 from edx/updateMessage-filter
fix(UI) box should appear after editing grade
2018-12-04 14:01:05 -05:00
jkantor
0d7fa18acd fix(UI) box should appear after editing grade 2018-12-04 13:38:27 -05:00
Simon Chen
012bb3a1f3 Merge pull request #34 from edx/schen/EDUCATOR-3754
Add hardcoded page size on frontend
2018-12-04 09:58:43 -05:00
Simon Chen
de233e0285 fix(pagination): Add hardcoded page size on frontend 2018-12-03 16:04:41 -05:00
Richard I Reilly
ae7544cd53 Merge pull request #31 from edx/rir/spinner
Show a spinner when waiting for the grades call to come back
2018-12-03 15:04:09 -05:00
Rick Reilly
14df81b312 Show a spinner when waiting for the grades call to come back 2018-12-03 14:54:07 -05:00
Jansen Kantor
4706cfcd94 Merge pull request #30 from edx/retain_filter
fix(filter) filter should remain active after we edit a grade
2018-11-30 15:52:45 -05:00
jkantor
1f5a2469b2 fix(filter) filter should remain active after we edit a grade 2018-11-30 12:53:18 -05:00
Simon Chen
e31c670938 Merge pull request #29 from edx/schen/percent
fix(UI): Update the percent number view so it is actually percent
2018-11-30 12:07:47 -05:00
Simon Chen
db9f683297 fix(UI): Update the percent number view so it is actually percent with symbols 2018-11-30 11:55:02 -05:00
Richard I Reilly
7a43cdcaea Merge pull request #27 from edx/rir/dynamic-assignments
Make it so the assignment time column filter is dynamic and api driven
2018-11-30 11:08:59 -05:00
Rick Reilly
d5637a4550 Make it so the assignment time column filter is dynamic and api driven 2018-11-30 10:56:08 -05:00
Simon Chen
0b9fa36fb7 Merge pull request #28 from edx/schen/styles
fix(Styles): Fix the gradebook styles to increase its width
2018-11-30 10:51:03 -05:00
Simon Chen
7bd0c49c14 fix(Styles): Fix the gradebook styles to increase its width 2018-11-30 10:30:59 -05:00
Alex Dusenbery
44f91bb453 Add documentation on platform configuration for local development. 2018-11-30 10:16:08 -05:00
Jansen Kantor
d8f229838f Merge pull request #25 from edx/pagination
fix(pagination) use paragon buttons, hide rather than disable
2018-11-29 16:22:38 -05:00
jkantor
7b5a095898 fix(pagination) use paragon buttons, hide rather than disable 2018-11-29 15:35:13 -05:00
Jansen Kantor
ff7937c2d7 Merge pull request #24 from edx/pagination
feat(pagination) added next and previous buttons to grades page
2018-11-29 14:34:56 -05:00
jkantor
d057497105 feat(pagination) added next and previous buttons to grades page 2018-11-29 13:11:11 -05:00
Richard I Reilly
e1402b0d4f Merge pull request #23 from edx/rir/update-auth
Update auth to the latest version of edx/frontend-auth
2018-11-28 15:41:24 -05:00
Rick Reilly
3ea337e3f8 Update auth to the latest version of edx/frontend-auth 2018-11-28 15:31:19 -05:00
Alex Dusenbery
9c2c16e378 Remove boilerplate README strings, add note about installing into LMS. 2018-11-28 10:30:12 -05:00
Simon Chen
7e9ef204a7 Merge pull request #22 from edx/schen/fix_search
fix(functionality): Fix search so results are rendered with proper heading
2018-11-27 15:02:07 -05:00
Simon Chen
f779e7fd35 fix(functionality): Fix search so results are rendered with proper heading 2018-11-27 14:47:57 -05:00
Richard I Reilly
a5a62922b5 Merge pull request #13 from edx/schen/clean
Clean up not needed example code
2018-11-26 14:41:00 -05:00
Richard I Reilly
2c6aa96f8e Merge pull request #20 from edx/rir/fix-sorting
Fix column sorting in the grade table
2018-11-26 14:29:48 -05:00
Rick Reilly
30e866128f Fix column sorting in the grade table 2018-11-26 14:25:11 -05:00
Simon Chen
ec81eb47d9 Merge pull request #18 from edx/aed/edit-modal
feat(editing): display a status alert after grades have updated.
2018-11-14 21:03:12 -05:00
Alex Dusenbery
cd2a5ae903 feat(editing): display a status alert after grades have updated. 2018-11-14 20:36:41 -05:00
Richard I Reilly
7a02330e9e Merge pull request #17 from edx/rir/frontend-auth-1-1
Return the frontend-auth npm package to 1.1.0
2018-11-14 16:45:51 -05:00
Rick Reilly
a929194a29 Return the frontend-auth npm package to 1.1.0 2018-11-14 16:16:47 -05:00
Simon Chen
1687a6ca1a Clean up not needed example code 2018-11-14 14:02:53 -05:00
66 changed files with 10963 additions and 7949 deletions

View File

@@ -1,3 +1,5 @@
coverage/*
dist/
node_modules/
src/postcss.config.js
src/segment.js

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
.DS_Store
.eslintcache
.idea
node_modules
npm-debug.log
coverage

View File

@@ -23,6 +23,7 @@ before_script: greenkeeper-lockfile-update
after_script: greenkeeper-lockfile-upload
script:
- make validate-no-uncommitted-package-lock-changes
- npm run lint
- npm run test
- npm run build
after_success:

View File

@@ -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

View File

@@ -1,32 +1,9 @@
shell: ## run a shell on the cookie-cutter container
docker exec -it edx.gradebook /bin/bash
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
npm-install-%: ## install specified % npm package
npm install $* --save-dev
git add package.json
restart:
make down
make up
restart-detached:
make down
make up-detached
validate-no-uncommitted-package-lock-changes:
git diff --exit-code package-lock.json
test:
npm run test

View File

@@ -21,13 +21,40 @@ npm i --save @edx/gradebook
## 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
@@ -46,61 +73,6 @@ Note that `make up-detached` executes the `npm run start` script which will hot-
* `constants`
* `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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -4,6 +4,7 @@ const path = require('path');
module.exports = {
entry: {
segment: path.resolve(__dirname, '../src/segment.js'),
app: path.resolve(__dirname, '../src/index.jsx'),
},
output: {

View File

@@ -12,6 +12,7 @@ module.exports = Merge.smart(commonConfig, {
entry: [
// enable react's custom hot dev client so we get errors reported in the browser
require.resolve('react-dev-utils/webpackHotDevClient'),
path.resolve(__dirname, '../src/segment.js'),
path.resolve(__dirname, '../src/index.jsx'),
],
module: {
@@ -64,6 +65,28 @@ module.exports = Merge.smart(commonConfig, {
test: /\.(woff2?|ttf|svg|eot)(\?v=\d+\.\d+\.\d+)?$/,
loader: 'file-loader',
},
{
test: /\.(jpe?g|png|gif|ico)(\?v=\d+\.\d+\.\d+)?$/,
use: [
'file-loader',
{
loader: 'image-webpack-loader',
options: {
optimizationlevel: 7,
mozjpeg: {
progressive: true,
},
gifsicle: {
interlaced: false,
},
pngquant: {
quality: '65-90',
speed: 4,
},
},
},
],
},
],
},
// Specify additional processing or side-effects done on the Webpack output bundles as a whole.
@@ -75,11 +98,12 @@ module.exports = Merge.smart(commonConfig, {
}),
new webpack.EnvironmentPlugin({
NODE_ENV: 'development',
BASE_URL: 'localhost:1991',
BASE_URL: 'localhost:1994',
LMS_BASE_URL: 'http://localhost:18000',
LOGIN_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',
// LMS_CLIENT_ID should match the lms DOT client application id your LMS container
LMS_CLIENT_ID: 'login-service-client-id',
@@ -87,6 +111,21 @@ module.exports = Merge.smart(commonConfig, {
FEATURE_FLAGS: {},
ACCESS_TOKEN_COOKIE_NAME: 'edx-jwt-cookie-header-payload',
CSRF_COOKIE_NAME: 'csrftoken',
SITE_NAME: 'edX',
MARKETING_SITE_BASE_URL: 'http://localhost:18000',
SUPPORT_URL: 'http://localhost:18000/support',
CONTACT_URL: 'http://localhost:18000/contact',
OPEN_SOURCE_URL: 'http://localhost:18000/openedx',
TERMS_OF_SERVICE_URL: 'http://localhost:18000/terms-of-service',
PRIVACY_POLICY_URL: 'http://localhost:18000/privacy-policy',
FACEBOOK_URL: 'https://www.facebook.com',
TWITTER_URL: 'https://twitter.com',
YOU_TUBE_URL: 'https://www.youtube.com',
LINKED_IN_URL: 'https://www.linkedin.com',
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
// the HotModuleReplacementPlugin has to be specified in the Webpack configuration
@@ -97,7 +136,7 @@ module.exports = Merge.smart(commonConfig, {
// reloading.
devServer: {
host: '0.0.0.0',
port: 1991,
port: 1994,
historyApiFallback: true,
hot: true,
inline: true,

View File

@@ -4,8 +4,8 @@ const Merge = require('webpack-merge');
const commonConfig = require('./webpack.common.config.js');
const path = require('path');
const webpack = require('webpack');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = Merge.smart(commonConfig, {
mode: 'production',
@@ -38,29 +38,27 @@ module.exports = Merge.smart(commonConfig, {
// increases build time.
{
test: /(.scss|.css)$/,
use: ExtractTextPlugin.extract({
// creates style nodes from JS strings, only used if extracting fails
fallback: 'style-loader',
use: [
{
loader: 'css-loader', // translates CSS into CommonJS
options: {
sourceMap: true,
minimize: true,
},
use: [
MiniCssExtractPlugin.loader,
{
loader: 'css-loader', // translates CSS into CommonJS
options: {
sourceMap: true,
minimize: true,
},
{
loader: 'sass-loader', // compiles Sass to CSS
options: {
sourceMap: true,
includePaths: [
path.join(__dirname, '../node_modules'),
path.join(__dirname, '../src'),
],
},
},
'postcss-loader', // for autoprefixing, needs to be before the sass loader, not sure why
{
loader: 'sass-loader', // compiles Sass to CSS
options: {
sourceMap: true,
includePaths: [
path.join(__dirname, '../node_modules'),
path.join(__dirname, '../src'),
],
},
],
}),
},
],
},
// Webpack, by default, uses the url-loader for images and fonts that are required/included by
// files it processes, which just base64 encodes them and inlines them in the javascript
@@ -70,6 +68,28 @@ module.exports = Merge.smart(commonConfig, {
test: /\.(woff2?|ttf|svg|eot)(\?v=\d+\.\d+\.\d+)?$/,
loader: 'file-loader',
},
{
test: /\.(jpe?g|png|gif|ico)(\?v=\d+\.\d+\.\d+)?$/,
use: [
'file-loader',
{
loader: 'image-webpack-loader',
options: {
optimizationlevel: 7,
mozjpeg: {
progressive: true,
},
gifsicle: {
interlaced: false,
},
pngquant: {
quality: '65-90',
speed: 4,
},
},
},
],
},
],
},
// New in Webpack 4. Replaces CommonChunksPlugin. Extract common modules among all chunks to one
@@ -83,9 +103,8 @@ module.exports = Merge.smart(commonConfig, {
// Specify additional processing or side-effects done on the Webpack output bundles as a whole.
plugins: [
// Writes the extracted CSS from each entry to a file in the output directory.
new ExtractTextPlugin({
filename: '[name].min.css',
allChunks: true,
new MiniCssExtractPlugin({
filename: '[name].[chunkhash].css',
}),
// Generates an HTML file in the output directory.
new HtmlWebpackPlugin({
@@ -98,6 +117,7 @@ module.exports = Merge.smart(commonConfig, {
LMS_BASE_URL: null,
LOGIN_URL: null,
LOGOUT_URL: null,
CSRF_TOKEN_API_PATH: null,
REFRESH_ACCESS_TOKEN_ENDPOINT: null,
DATA_API_BASE_URL: null,
SEGMENT_KEY: null,
@@ -106,6 +126,21 @@ module.exports = Merge.smart(commonConfig, {
CSRF_COOKIE_NAME: 'csrftoken',
NEW_RELIC_APP_ID: null,
NEW_RELIC_LICENSE_KEY: null,
SITE_NAME: null,
MARKETING_SITE_BASE_URL: null,
SUPPORT_URL: null,
CONTACT_URL: null,
OPEN_SOURCE_URL: null,
TERMS_OF_SERVICE_URL: null,
PRIVACY_POLICY_URL: null,
FACEBOOK_URL: null,
TWITTER_URL: null,
YOU_TUBE_URL: null,
LINKED_IN_URL: null,
GOOGLE_PLUS_URL: null,
REDDIT_URL: null,
APPLE_APP_STORE_URL: null,
GOOGLE_PLAY_URL: null,
}),
],
});

View File

@@ -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:

View 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
View 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

14755
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -25,62 +25,69 @@
},
"dependencies": {
"@edx/edx-bootstrap": "^0.4.3",
"@edx/frontend-auth": "^1.1.0",
"@edx/paragon": "^3.7.1",
"@edx/frontend-auth": "^1.3.0",
"@edx/frontend-component-footer": "^1.0.0",
"@edx/paragon": "^3.8.3",
"@redux-beacon/segment": "^1.0.0",
"babel-polyfill": "^6.26.0",
"classnames": "^2.2.5",
"email-prop-type": "^1.1.5",
"classnames": "^2.2.6",
"email-prop-type": "^1.1.7",
"font-awesome": "^4.7.0",
"history": "^4.7.2",
"prop-types": "^15.5.10",
"query-string": "^6.2.0",
"react": "^16.2.0",
"react-dom": "^16.2.0",
"react-redux": "^5.0.7",
"react-router": "^4.2.0",
"react-router-dom": "^4.2.2",
"prop-types": "^15.6.2",
"query-string": "^5.1.1",
"react": "^16.7.0",
"react-dom": "^16.7.0",
"react-redux": "^5.1.1",
"react-router": "^4.3.1",
"react-router-dom": "^4.3.1",
"react-router-redux": "^5.0.0-alpha.9",
"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-thunk": "^2.2.0",
"whatwg-fetch": "^2.0.3"
"redux-thunk": "^2.3.0",
"whatwg-fetch": "^2.0.4"
},
"devDependencies": {
"autoprefixer": "^9.4.5",
"axios-mock-adapter": "^1.16.0",
"babel-cli": "^6.26.0",
"babel-eslint": "^8.2.2",
"babel-jest": "^22.4.0",
"babel-loader": "^7.1.2",
"babel-eslint": "^8.2.6",
"babel-jest": "^22.4.4",
"babel-loader": "^7.1.5",
"babel-plugin-transform-class-properties": "^6.24.1",
"babel-plugin-transform-object-rest-spread": "^6.26.0",
"babel-preset-env": "^1.6.1",
"babel-preset-env": "^1.7.0",
"babel-preset-react": "^6.24.1",
"codecov": "^3.0.0",
"css-loader": "^0.28.9",
"enzyme": "^3.3.0",
"enzyme-adapter-react-16": "^1.1.1",
"es-check": "^2.0.2",
"eslint-config-edx": "^4.0.3",
"extract-text-webpack-plugin": "^4.0.0-beta.0",
"fetch-mock": "^6.3.0",
"codecov": "^3.1.0",
"css-loader": "^0.28.11",
"enzyme": "^3.8.0",
"enzyme-adapter-react-16": "^1.7.1",
"es-check": "^2.3.0",
"eslint-config-edx": "^4.0.4",
"fetch-mock": "^6.5.2",
"file-loader": "^1.1.9",
"html-webpack-harddisk-plugin": "^0.2.0",
"html-webpack-plugin": "^3.0.3",
"html-webpack-plugin": "^3.2.0",
"husky": "^0.14.3",
"identity-obj-proxy": "^3.0.0",
"jest": "^22.4.0",
"node-sass": "^4.7.2",
"react-dev-utils": "^5.0.0",
"react-test-renderer": "^16.2.0",
"redux-mock-store": "^1.5.1",
"image-webpack-loader": "^4.2.0",
"jest": "^22.4.4",
"mini-css-extract-plugin": "^0.4.0",
"node-sass": "^4.11.0",
"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",
"semantic-release": "^15.10.7",
"style-loader": "^0.20.2",
"travis-deploy-once": "^5.0.9",
"webpack": "^4.1.0",
"webpack-cli": "^2.0.10",
"webpack-dev-server": "^3.1.0",
"webpack-merge": "^4.1.1"
"semantic-release": "^15.13.3",
"style-loader": "^0.20.3",
"travis-deploy-once": "^5.0.11",
"webpack": "^4.28.4",
"webpack-cli": "^3.2.1",
"webpack-dev-server": "^3.1.14",
"webpack-merge": "^4.2.1"
},
"jest": {
"setupFiles": [
@@ -101,6 +108,7 @@
],
"transformIgnorePatterns": [
"/node_modules/(?!(@edx/paragon)/).*/"
]
],
"testURL": "http://localhost"
}
}

View File

@@ -1,6 +1,10 @@
<!doctype html>
<html>
<head></head>
<html lang="en-us">
<head>
<title>Gradebook | edX</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
<div id="root"></div>
</body>

View File

@@ -6,4 +6,6 @@ $fa-font-path: "~font-awesome/fonts";
@import "~@edx/paragon/src/SearchField/SearchField";
@import "~@edx/frontend-component-footer/src/lib/scss/site-footer";
@import "./components/Gradebook/gradebook";

View File

@@ -1,6 +1,34 @@
.back-link{
float:right;
.spinner-overlay {
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{
display: flex;
.label{
@@ -32,10 +60,6 @@
display: block;
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) {
box-sizing: content-box;
padding-left: 170px;
@@ -44,6 +68,10 @@
padding-left: 170px;
}
.table tbody th {
font-weight: normal;
}
.link-style {
color: #0075b4;
&:hover, &:focus {

View File

@@ -1,16 +1,25 @@
import React from 'react';
import { Button, Modal, SearchField, Table, InputSelect } 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 { configuration } from '../../config';
import PageButtons from '../PageButtons';
const DECIMAL_PRECISION = 2;
export default class Gradebook extends React.Component {
constructor(props) {
super(props);
this.state = {
grades: [], // this.mapUserEntriesPercent(this.props.grades).sort(this.sortAlphaDesc),
headings: [], // this.mapHeadings(this.props.grades[0]),
filterValue: '',
modalContent: (<h1>Hello, World!</h1>),
modalOpen: false,
modalModel: [{}],
updateVal: 0,
@@ -21,27 +30,27 @@ export default class Gradebook extends React.Component {
componentDidMount() {
const urlQuery = queryString.parse(this.props.location.search);
this.props.getUserGrades(
this.props.match.params.courseId,
urlQuery.cohort,
urlQuery.track,
);
this.props.getTracks(this.props.match.params.courseId);
this.props.getCohorts(this.props.match.params.courseId);
this.props.getRoles(this.props.match.params.courseId, urlQuery);
}
setNewModalState = (userEntry, subsection) => {
let adjustedGradePossible = '';
let currentGradePossible = '';
if (subsection.attempted) {
adjustedGradePossible = ` / ${subsection.score_possible}`;
currentGradePossible = `/${subsection.score_possible}`;
}
this.setState({
modalModel: [{
username: userEntry.username,
currentGrade: `${subsection.score_earned}/${subsection.score_possible}`,
currentGrade: `${subsection.score_earned}${currentGradePossible}`,
adjustedGrade: (
<span>
<input
style={{ width: '25px' }}
type="text"
onChange={event => this.setState({ updateVal: event.target.value })}
/> / {subsection.score_possible}
/>{adjustedGradePossible}
</span>
),
assignmentName: `${subsection.subsection_name}`,
@@ -53,22 +62,44 @@ export default class Gradebook extends React.Component {
}
handleAdjustedGradeClick = () => {
this.props.updateGrades(this.props.match.params.courseId, [
{
user_id: this.state.updateUserId,
usage_id: this.state.updateModuleId,
grade: {
earned_graded_override: this.state.updateVal,
this.props.updateGrades(
this.props.match.params.courseId, [
{
user_id: this.state.updateUserId,
usage_id: this.state.updateModuleId,
grade: {
earned_graded_override: this.state.updateVal,
},
},
},
]);
],
this.state.filterValue,
this.props.selectedCohort,
this.props.selectedTrack,
);
this.setState({
modalModel: [{}],
modalOpen: false,
updateModuleId: null,
updateUserId: null,
});
}
updateQueryParams = (queryKey, queryValue) => {
const parsed = queryString.parse(this.props.location.search);
parsed[queryKey] = queryValue;
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) => {
const mapped = entries.map(entry => ({
id: entry.id,
@@ -87,6 +118,10 @@ export default class Gradebook extends React.Component {
return mapped;
};
updateAssignmentTypes = (event) => {
this.props.filterColumns(event, this.props.grades[0]);
}
updateTracks = (event) => {
const selectedTrackItem = this.props.tracks.find(x => x.name === event);
let selectedTrackSlug = null;
@@ -98,8 +133,7 @@ export default class Gradebook extends React.Component {
this.props.selectedCohort,
selectedTrackSlug,
);
const updatedQueryStrings = this.updateQueryParams('track', selectedTrackSlug);
this.props.history.push(updatedQueryStrings);
this.updateQueryParams('track', selectedTrackSlug);
};
updateCohorts = (event) => {
@@ -113,8 +147,16 @@ export default class Gradebook extends React.Component {
selectedCohortId,
this.props.selectedTrack,
);
const updatedQueryStrings = this.updateQueryParams('cohort', selectedCohortId);
this.props.history.push(updatedQueryStrings);
this.updateQueryParams('cohort', selectedCohortId);
};
mapSelectedAssignmentTypeEntry = (entry) => {
const selectedAssignmentTypeEntry = this.props.assignmentTypes
.find(x => x.id === parseInt(entry, 10));
if (selectedAssignmentTypeEntry) {
return selectedAssignmentTypeEntry.name;
}
return 'All';
};
mapSelectedCohortEntry = (entry) => {
@@ -133,41 +175,58 @@ export default class Gradebook extends React.Component {
return 'Tracks';
};
roundGrade = percent => parseFloat((percent || 0).toFixed(DECIMAL_PRECISION));
formatter = {
percent: entries => entries.map((entry) => {
percent: (entries, areGradesFrozen) => entries.map((entry) => {
const results = { username: entry.username };
const assignments = entry.section_breakdown
.filter(section => section.is_graded)
.reduce((acc, subsection) => {
acc[subsection.label] = (
<button
className="btn btn-header link-style"
onClick={() => this.setNewModalState(entry, subsection)}
>
{subsection.percent}
</button>);
if (areGradesFrozen) {
acc[subsection.label] = `${this.roundGrade(subsection.percent * 100)} %`;
} else {
acc[subsection.label] = (
<button
className="btn btn-header link-style"
onClick={() => this.setNewModalState(entry, subsection)}
>
{this.roundGrade(subsection.percent * 100)}%
</button>);
}
return acc;
}, {});
const totals = { total: entry.percent * 100 };
const totals = { total: `${this.roundGrade(entry.percent * 100)}%` };
return Object.assign(results, assignments, totals);
}),
absolute: entries => entries.map((entry) => {
absolute: (entries, areGradesFrozen) => entries.map((entry) => {
const results = { username: entry.username };
const assignments = entry.section_breakdown
.filter(section => section.is_graded)
.reduce((acc, subsection) => {
acc[subsection.label] = (
<button
className="btn btn-header link-style"
onClick={() => this.setNewModalState(entry, subsection)}
>
{subsection.score_earned}/{subsection.score_possible}
</button>);
const scoreEarned = this.roundGrade(subsection.score_earned);
const scorePossible = this.roundGrade(subsection.score_possible);
let label = `${scoreEarned}`;
if (subsection.attempted) {
label = `${scoreEarned}/${scorePossible}`;
}
if (areGradesFrozen) {
acc[subsection.label] = label;
} else {
acc[subsection.label] = (
<button
className="btn btn-header link-style"
onClick={() => this.setNewModalState(entry, subsection)}
>
{label}
</button>
);
}
return acc;
}, {});
const totals = { total: entry.percent * 100 };
const totals = { total: `${this.roundGrade(entry.percent * 100)}/100` };
return Object.assign(results, assignments, totals);
}),
};
@@ -177,147 +236,160 @@ export default class Gradebook extends React.Component {
render() {
return (
<div className="d-flex justify-content-center">
<div className="card" style={{ width: '50rem' }}>
<div className="card-body">
{ this.props.showSpinner && <div className="spinner-overlay"><Icon className={['fa', 'fa-spinner', 'fa-spin', 'fa-5x', 'color-black']} /></div>}
<div className="gradebook-container">
<div>
<a
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>
<h1>Gradebook</h1>
<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 />
<div className="d-flex justify-content-between" >
<div>
<div>
Score View:
<div role="radiogroup" aria-labelledby="score-view-group-label">
<span id="score-view-group-label">Score View:</span>
<span>
<input
id="score-view-percent"
className="ml-2 mr-1"
type="radio"
name="score-view"
value="percent"
onClick={() => this.props.toggleFormat('percent')}
/>
<label className="mr-2" htmlFor="score-view-percent">Percent</label>
</span>
<span>
<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 className="mr-2" htmlFor="score-view-percent">
<input
id="score-view-percent"
className="ml-2 mr-1"
type="radio"
name="score-view"
value="percent"
defaultChecked
onClick={() => this.props.toggleFormat('percent')}
/>
Percent
</label>
</span>
<span>
<input
id="category-homework"
className="mr-1"
type="radio"
name="category"
value="homework"
onClick={() => this.props.filterColumns('hw', this.props.grades[0])}
/>
<label className="mr-2" htmlFor="category-homework">Homework</label>
</span>
<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="Exam">
Exam
<label htmlFor="score-view-absolute">
<input
id="score-view-absolute"
type="radio"
name="score-view"
value="absolute"
className="mr-1"
onClick={() => this.props.toggleFormat('absolute')}
/>
Absolute
</label>
</span>
</div>
{(this.props.tracks.length > 0 || this.props.cohorts.length > 0) &&
{ this.props.assignmentTypes.length > 0 &&
<div className="student-filters">
<span className="label">
Student Groups:
Assignment Types:
</span>
{this.props.tracks.length > 0 &&
<InputSelect
name="Tracks"
value={this.mapSelectedTrackEntry(this.props.selectedTrack)}
options={this.mapTracksEntries(this.props.tracks)}
onChange={this.updateTracks}
/>
}
{this.props.cohorts.length > 0 &&
<InputSelect
name="Cohorts"
value={this.mapSelectedCohortEntry(this.props.selectedCohort)}
options={this.mapCohortsEntries(this.props.cohorts)}
onChange={this.updateCohorts}
/>
}
<InputSelect
name="assignment-types"
ariaLabel="Assignment Types"
value="All"
options={this.mapAssignmentTypeEntries(this.props.assignmentTypes)}
onChange={this.updateAssignmentTypes}
/>
</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 style={{ marginLeft: '10px', marginBottom: '10px' }}>
<a href={`${this.lmsInstructorDashboardUrl(this.props.match.params.courseId)}#view-data_download`}>Download Grade Report</a>
<a href={`${this.lmsInstructorDashboardUrl(this.props.match.params.courseId)}#view-data_download`}>Generate Grade Report</a>
</div>
<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,
)
}
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,
)
}
value={this.state.filterValue}
/>
</div>
</div>
<br />
<StatusAlert
alertType="success"
dialog="The grade has been successfully edited."
onClose={() => this.props.updateBanner(false)}
open={this.props.showSuccess}
/>
{PageButtons(this.props)}
<div className="gbook">
<Table
columns={this.props.headings}
data={this.formatter[this.props.format](this.props.grades)}
defaultSortDirection="desc"
data={this.formatter[this.props.format](
this.props.grades,
this.props.areGradesFrozen,
)}
tableSortable
defaultSortDirection="asc"
defaultSortedColumn="username"
rowHeaderColumnKey="username"
/>
</div>
{PageButtons(this.props)}
<Modal
open={this.state.modalOpen}
title="Edit Grades"
closeText="Cancel"
body={(
<div>
<h3>{this.state.modalModel[0].assignmentName}</h3>
<Table
columns={[{ label: 'Username', key: 'username' }, { label: 'Current grade', key: 'currentGrade' }, { label: 'Adjusted grade', key: 'adjustedGrade' }]}
data={this.state.modalModel}
tableSortable
defaultSortDirection="desc"
defaultSortedColumn="username"
/>
<div>Note: Once you save, your changes will be visible to students.</div>
</div>
)}
buttons={[
<Button
label="Edit Grade"
label="Save Grade"
buttonType="primary"
onClick={this.handleAdjustedGradeClick}
/>,
@@ -337,3 +409,76 @@ 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,
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,
is_graded: PropTypes.bool,
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,
columnSortable: PropTypes.bool,
onSort: PropTypes.func,
})).isRequired,
location: PropTypes.shape({
search: PropTypes.string,
}),
match: PropTypes.shape({
params: PropTypes.shape({
courseId: PropTypes.string,
}),
}),
searchForUser: PropTypes.func.isRequired,
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,
};

View 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>
);
}
}

View 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'));
});
});

View File

@@ -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-primary"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
style={
Object {
"margin": "20px",
}
}
type="button"
>
Previous Page
</button>
<button
className="btn btn-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-primary"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
style={
Object {
"margin": "20px",
}
}
type="button"
>
Previous Page
</button>
<button
className="btn btn-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-primary"
disabled={true}
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
style={
Object {
"margin": "20px",
}
}
type="button"
>
Previous Page
</button>
<button
className="btn btn-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-primary"
disabled={true}
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
style={
Object {
"margin": "20px",
}
}
type="button"
>
Previous Page
</button>
<button
className="btn btn-primary"
disabled={true}
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
style={
Object {
"margin": "20px",
}
}
type="button"
>
Next Page
</button>
</div>
`;

View File

@@ -0,0 +1,72 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Button } from '@edx/paragon';
export default function PageButtons({
prevPage, nextPage, selectedTrack, selectedCohort, getPrevNextGrades, match,
}) {
return (
<div
className="d-flex justify-content-center"
style={{ paddingBottom: '20px' }}
>
<Button
label="Previous Page"
style={{ margin: '20px' }}
buttonType="primary"
disabled={!prevPage}
onClick={() =>
getPrevNextGrades(
prevPage,
selectedCohort,
selectedTrack,
match.params.courseId,
)}
/>
<Button
label="Next Page"
style={{ margin: '20px' }}
buttonType="primary"
disabled={!nextPage}
onClick={() =>
getPrevNextGrades(
nextPage,
selectedCohort,
selectedTrack,
match.params.courseId,
)}
/>
</div>
);
}
PageButtons.defaultProps = {
match: {
params: {
courseId: '',
},
},
nextPage: '',
prevPage: '',
selectedCohort: null,
selectedTrack: null,
};
PageButtons.propTypes = {
getPrevNextGrades: PropTypes.func.isRequired,
match: PropTypes.shape({
params: PropTypes.shape({
courseId: PropTypes.string,
}),
}),
nextPage: PropTypes.string,
prevPage: PropTypes.string,
selectedCohort: PropTypes.shape({
name: PropTypes.string,
}),
selectedTrack: PropTypes.shape({
name: PropTypes.string,
}),
};

View File

@@ -3,6 +3,7 @@ const configuration = {
LMS_BASE_URL: process.env.LMS_BASE_URL,
LOGIN_URL: process.env.LOGIN_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,
DATA_API_BASE_URL: process.env.DATA_API_BASE_URL,
SECURE_COOKIES: process.env.NODE_ENV !== 'development',

View File

@@ -4,12 +4,25 @@ import Gradebook from '../../components/Gradebook';
import {
fetchGrades,
fetchMatchingUserGrades,
fetchPrevNextGrades,
updateGrades,
toggleGradeFormat,
filterColumns,
updateBanner,
} from '../../data/actions/grades';
import { fetchCohorts } from '../../data/actions/cohorts';
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 => (
{
@@ -20,6 +33,13 @@ const mapStateToProps = state => (
selectedTrack: state.grades.selectedTrack,
selectedCohort: state.grades.selectedCohort,
format: state.grades.gradeFormat,
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,
}
);
@@ -29,7 +49,10 @@ const mapDispatchToProps = dispatch => (
dispatch(fetchGrades(courseId, cohort, track));
},
searchForUser: (courseId, searchText, cohort, track) => {
dispatch(fetchMatchingUserGrades(courseId, searchText, cohort, track));
dispatch(fetchMatchingUserGrades(courseId, searchText, cohort, track, false));
},
getPrevNextGrades: (endpoint, cohort, track, courseId) => {
dispatch(fetchPrevNextGrades(endpoint, cohort, track, courseId));
},
getCohorts: (courseId) => {
dispatch(fetchCohorts(courseId));
@@ -37,8 +60,11 @@ const mapDispatchToProps = dispatch => (
getTracks: (courseId) => {
dispatch(fetchTracks(courseId));
},
updateGrades: (courseId, updateData) => {
dispatch(updateGrades(courseId, updateData));
getAssignmentTypes: (courseId) => {
dispatch(fetchAssignmentTypes(courseId));
},
updateGrades: (courseId, updateData, searchText, cohort, track) => {
dispatch(updateGrades(courseId, updateData, searchText, cohort, track));
},
toggleFormat: (formatType) => {
dispatch(toggleGradeFormat(formatType));
@@ -46,6 +72,12 @@ const mapDispatchToProps = dispatch => (
filterColumns: (filterType, exampleUser) => {
dispatch(filterColumns(filterType, exampleUser));
},
updateBanner: (showSuccess) => {
dispatch(updateBanner(showSuccess));
},
getRoles: (matchParams, urlQuery) => {
dispatch(getRoles(matchParams, urlQuery));
},
}
);

View 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,
};

View 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);
});
});
});
});

View File

@@ -17,7 +17,7 @@ const fetchCohorts = courseId => (
.then((data) => {
dispatch(gotCohorts(data.cohorts));
})
.catch((error) => {
.catch(() => {
dispatch(errorFetchingCohorts());
});
}

View 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);
});
});
});
});

View File

@@ -1,38 +0,0 @@
import 'whatwg-fetch';
import {
STARTED_FETCHING_COMMENT,
FINISHED_FETCHING_COMMENT,
ERROR_FETCHING_COMMENT,
GET_COMMENT,
} from '../constants/actionTypes/comment';
const startedFetchingComment = () => ({ type: STARTED_FETCHING_COMMENT });
const finishedFetchingComment = () => ({ type: FINISHED_FETCHING_COMMENT });
const errorFetchingComment = () => ({ type: ERROR_FETCHING_COMMENT });
const getComment = comment => ({ type: GET_COMMENT, comment });
const fetchComment = commentId => (
(dispatch) => {
dispatch(startedFetchingComment());
return fetch(`https://jsonplaceholder.typicode.com/comments/${commentId}`)
.then((response) => {
if (response.ok) {
return response.json();
}
throw new Error();
})
.then((data) => {
dispatch(getComment(data));
dispatch(finishedFetchingComment());
})
.catch(() => dispatch(errorFetchingComment()));
}
);
export {
startedFetchingComment,
finishedFetchingComment,
errorFetchingComment,
getComment,
fetchComment,
};

View File

@@ -9,48 +9,81 @@ import {
TOGGLE_GRADE_FORMAT,
SORT_GRADES,
FILTER_COLUMNS,
UPDATE_BANNER,
} from '../constants/actionTypes/grades';
import LmsApiService from '../services/LmsApiService';
import { headingMapper } from './utils';
import store from '../store';
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 finishedFetchingGrades = () => ({ type: FINISHED_FETCHING_GRADES });
const errorFetchingGrades = () => ({ type: ERROR_FETCHING_GRADES });
const gotGrades = (grades, cohort, track, headings) => ({
const gotGrades = (grades, cohort, track, headings, prev, next, courseId) => ({
type: GOT_GRADES,
grades,
cohort,
track,
headings,
prev,
next,
courseId,
});
const gradeUpdateRequest = () => ({ type: GRADE_UPDATE_REQUEST });
const gradeUpdateSuccess = responseData => ({
const gradeUpdateSuccess = (courseId, responseData) => ({
type: GRADE_UPDATE_SUCCESS,
courseId,
payload: { responseData },
});
const gradeUpdateFailure = error => ({
const gradeUpdateFailure = (courseId, error) => ({
type: GRADE_UPDATE_FAILURE,
courseId,
payload: { error },
});
const toggleGradeFormat = formatType => ({ type: TOGGLE_GRADE_FORMAT, formatType });
const sortGrades = (columnName, direction) => ({ type: SORT_GRADES, columnName, direction });
const filterColumns = (filterType, exampleUser) => ({
type: FILTER_COLUMNS,
headings: headingMapper[filterType](exampleUser)
});
const filterColumns = (filterType, exampleUser) => (
dispatch => dispatch({
type: FILTER_COLUMNS,
headings: headingMapper(filterType)(dispatch, exampleUser),
})
);
const fetchGrades = (courseId, cohort, track) => (
const updateBanner = showSuccess => ({ type: UPDATE_BANNER, showSuccess });
const fetchGrades = (courseId, cohort, track, showSuccess) => (
(dispatch) => {
dispatch(startedFetchingGrades());
return LmsApiService.fetchGradebookData(courseId, null, cohort, track)
.then(response => response.data)
.then((data) => {
dispatch(gotGrades(data.results, cohort, track, headingMapper.all(data.results[0])));
dispatch(gotGrades(
data.results.sort(sortAlphaAsc),
cohort,
track,
headingMapper(defaultAssignmentFilter)(dispatch, data.results[0]),
data.previous,
data.next,
courseId,
));
dispatch(finishedFetchingGrades());
dispatch(updateBanner(!!showSuccess));
})
.catch(() => {
dispatch(errorFetchingGrades());
@@ -58,13 +91,45 @@ const fetchGrades = (courseId, cohort, track) => (
}
);
const fetchMatchingUserGrades = (courseId, searchText, cohort, track) => (
const fetchMatchingUserGrades = (courseId, searchText, cohort, track, showSuccess) => (
(dispatch) => {
dispatch(startedFetchingGrades());
return LmsApiService.fetchGradebookData(courseId, searchText, cohort, track)
.then(response => response.data)
.then((data) => {
dispatch(gotGrades(data.results, cohort, track));
dispatch(gotGrades(
data.results.sort(sortAlphaAsc),
cohort,
track,
headingMapper(defaultAssignmentFilter)(dispatch, data.results[0]),
data.previous,
data.next,
courseId,
));
dispatch(finishedFetchingGrades());
dispatch(updateBanner(showSuccess));
})
.catch(() => {
dispatch(errorFetchingGrades());
});
}
);
const fetchPrevNextGrades = (endpoint, cohort, track, courseId) => (
(dispatch) => {
dispatch(startedFetchingGrades());
return apiClient.get(endpoint)
.then(response => response.data)
.then((data) => {
dispatch(gotGrades(
data.results.sort(sortAlphaAsc),
cohort,
track,
headingMapper(defaultAssignmentFilter)(dispatch, data.results[0]),
data.previous,
data.next,
courseId,
));
dispatch(finishedFetchingGrades());
})
.catch(() => {
@@ -73,16 +138,18 @@ const fetchMatchingUserGrades = (courseId, searchText, cohort, track) => (
}
);
const updateGrades = (courseId, updateData) => (
const updateGrades = (courseId, updateData, searchText, cohort, track) => (
(dispatch) => {
dispatch(gradeUpdateRequest());
return LmsApiService.updateGradebookData(courseId, updateData)
.then(response => response.data)
.then((data) => {
dispatch(gradeUpdateSuccess(data));
dispatch(gradeUpdateSuccess(courseId, data));
dispatch(fetchMatchingUserGrades(courseId, searchText, cohort, track, true));
})
.catch((error) => {
dispatch(gradeUpdateFailure(error));
dispatch(gradeUpdateFailure(courseId, error));
});
}
);
@@ -94,6 +161,7 @@ export {
gotGrades,
fetchGrades,
fetchMatchingUserGrades,
fetchPrevNextGrades,
gradeUpdateRequest,
gradeUpdateSuccess,
gradeUpdateFailure,
@@ -101,4 +169,5 @@ export {
toggleGradeFormat,
sortGrades,
filterColumns,
updateBanner,
};

View File

@@ -0,0 +1,145 @@
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 fetchGradesURL = `${configuration.LMS_BASE_URL}/api/grades/v1/gradebook/${courseId}/?page_size=10&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,
headings: [
{
columnSortable: true,
key: 'username',
label: 'Username',
onSort: expect.anything(),
},
{
columnSortable: true,
key: 'total',
label: 'Total',
onSort: expect.anything(),
},
],
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, 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, false))
.then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
});
});

View File

@@ -1,46 +0,0 @@
import 'whatwg-fetch';
import {
STARTED_FETCHING_POSTS,
FINISHED_FETCHING_POSTS,
GET_POSTS,
} from '../constants/actionTypes/posts';
const startedFetchingPosts = () => (
{
type: STARTED_FETCHING_POSTS,
}
);
const finishedFetchingPosts = () => (
{
type: FINISHED_FETCHING_POSTS,
}
);
const getPosts = posts => (
{
type: GET_POSTS,
posts,
}
);
const fetchPosts = () => (
(dispatch) => {
dispatch(startedFetchingPosts());
return fetch('https://jsonplaceholder.typicode.com/posts')
// TODO: handle response error
.then(response => response.json())
.then((data) => {
dispatch(getPosts(data));
dispatch(finishedFetchingPosts());
});
}
);
export {
startedFetchingPosts,
finishedFetchingPosts,
getPosts,
fetchPosts,
};

View File

@@ -1,65 +0,0 @@
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import fetchMock from 'fetch-mock';
import {
startedFetchingPosts,
finishedFetchingPosts,
getPosts,
fetchPosts,
} from './posts';
import {
STARTED_FETCHING_POSTS,
GET_POSTS,
FINISHED_FETCHING_POSTS,
} from '../constants/actionTypes/posts';
const mockStore = configureMockStore([thunk]);
describe('actions', () => {
afterEach(() => {
fetchMock.reset();
fetchMock.restore();
});
it('sends started fetching post action', () => {
const expected = { type: STARTED_FETCHING_POSTS };
expect(startedFetchingPosts()).toEqual(expected);
});
it('sends finished fetching posts', () => {
const expected = { type: FINISHED_FETCHING_POSTS };
expect(finishedFetchingPosts()).toEqual(expected);
});
it('sends posts', () => {
const data = 'data';
const expected = { type: GET_POSTS, posts: data };
expect(getPosts(data)).toEqual(expected);
});
it('fetches posts', () => {
const posts = [
{
id: 1,
title: 'title',
body: 'body',
},
];
fetchMock.getOnce('https://jsonplaceholder.typicode.com/posts', {
body: JSON.stringify({ posts }),
headers: { 'content-type': 'application/json' },
});
const store = mockStore({ posts: [] });
const expectedActions = [
{ type: STARTED_FETCHING_POSTS },
{ type: GET_POSTS, posts: { posts } },
{ type: FINISHED_FETCHING_POSTS },
];
return store.dispatch(fetchPosts()).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
});

42
src/data/actions/roles.js Normal file
View 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));
dispatch(fetchTracks(courseId));
dispatch(fetchCohorts(courseId));
dispatch(fetchAssignmentTypes(courseId));
}
})
.catch(() => {
dispatch(errorFetchingRoles());
}));
export {
getRoles,
errorFetchingRoles,
};

View 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);
});
});
});
});

View File

@@ -17,7 +17,7 @@ const fetchTracks = courseId => (
.then((data) => {
dispatch(gotTracks(data.course_modes));
})
.catch((error) => {
.catch(() => {
dispatch(errorFetchingTracks());
});
}

View 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);
});
});
});
});

View File

@@ -1,5 +1,6 @@
import { sortGrades } from './grades';
const sortAlphaDesc = (gradeRowA, gradeRowB) => {
const sortAlphaAsc = (gradeRowA, gradeRowB) => {
const a = gradeRowA.username.toUpperCase();
const b = gradeRowB.username.toUpperCase();
if (a < b) {
@@ -11,7 +12,7 @@ const sortAlphaDesc = (gradeRowA, gradeRowB) => {
return 0;
};
const sortAlphaAsc = (gradeRowA, gradeRowB) => {
const sortAlphaDesc = (gradeRowA, gradeRowB) => {
const a = gradeRowA.username.toUpperCase();
const b = gradeRowB.username.toUpperCase();
if (a < b) {
@@ -24,40 +25,49 @@ const sortAlphaAsc = (gradeRowA, gradeRowB) => {
};
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) {
if (gradeRowA[colKey] < gradeRowB[colKey]) {
return -1;
}
if (gradeRowA[colKey] > gradeRowB[colKey]) {
return 1;
}
return 0;
const { a, b } = getPercents(gradeRowA, gradeRowB);
return a - b;
}
function sortNumDesc(gradeRowA, gradeRowB) {
if (gradeRowA[colKey] < gradeRowB[colKey]) {
return 1;
}
if (gradeRowA[colKey] > gradeRowB[colKey]) {
return -1;
}
return 0;
const { a, b } = getPercents(gradeRowA, gradeRowB);
return b - a;
}
this.setState({ grades: [...this.state.grades].sort(direction === 'desc' ? sortNumDesc : sortNumAsc) });
return direction === 'desc' ? sortNumDesc : sortNumAsc;
};
const headingMapper = {
all: (entry) => {
function gradeSortMap(columnName, direction) {
if (columnName === 'username' && direction === 'desc') {
return sortAlphaDesc;
} else if (columnName === 'username') {
return sortAlphaAsc;
}
return sortNumerically(columnName, direction);
}
const headingMapper = (filterKey) => {
function all(dispatch, entry) {
if (entry) {
const results = [{
label: 'Username',
key: 'username',
columnSortable: true,
onSort: (direction) => {
this.setState({
grades: [...this.state.grades].sort(direction === 'desc' ? this.sortAlphaDesc : this.sortAlphaAsc),
});
},
onSort: (direction) => { dispatch(sortGrades('username', direction)); },
}];
const assignmentHeadings = entry.section_breakdown
@@ -66,34 +76,31 @@ const headingMapper = {
label: s.label,
key: s.label,
columnSortable: true,
onSort: (direction) => { this.sortNumerically(s.label, direction); },
onSort: direction => dispatch(sortGrades(s.label, direction)),
}));
const totals = [{
label: 'Total',
key: 'total',
columnSortable: true,
onSort: (direction) => { this.sortNumerically('total', direction); },
onSort: direction => dispatch(sortGrades('total', direction)),
}];
return results.concat(assignmentHeadings).concat(totals);
}
return [];
},
hw: (entry) => {
}
function some(dispatch, entry) {
const results = [{
label: 'Username',
key: 'username',
columnSortable: true,
onSort: (direction) => {
this.setState({
grades: [...this.state.grades].sort(direction === 'desc' ? this.sortAlphaDesc : this.sortAlphaAsc),
});
},
onSort: (direction) => { dispatch(sortGrades('username', direction)); },
}];
const assignmentHeadings = entry.section_breakdown
.filter(section => section.is_graded && section.label && section.category == 'Homework')
.filter(section => section.is_graded && section.label && section.category === filterKey)
.map(s => ({
label: s.label,
key: s.label,
@@ -101,31 +108,18 @@ const headingMapper = {
onSort: (direction) => { this.sortNumerically(s.label, direction); },
}));
return results.concat(assignmentHeadings);
},
exam: (entry) => {
const results = [{
label: 'Username',
key: 'username',
columnSortable: false,
onSort: (direction) => {
this.setState({
grades: [...this.state.grades].sort(direction === 'desc' ? this.sortAlphaDesc : this.sortAlphaAsc),
});
},
const totals = [{
label: 'Total',
key: 'total',
columnSortable: true,
onSort: direction => dispatch(sortGrades('total', direction)),
}];
const assignmentHeadings = entry.section_breakdown
.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).concat(totals);
}
return results.concat(assignmentHeadings);
},
return filterKey === 'All' ? all : some;
};
export { headingMapper };
export { headingMapper, gradeSortMap, sortAlphaAsc };

View File

@@ -6,6 +6,7 @@ const apiClient = getAuthenticatedAPIClient({
appBaseUrl: configuration.BASE_URL,
loginUrl: configuration.LOGIN_URL,
logoutUrl: configuration.LOGOUT_URL,
csrfTokenApiPath: process.env.CSRF_TOKEN_API_PATH,
refreshAccessTokenEndpoint: configuration.REFRESH_ACCESS_TOKEN_ENDPOINT,
accessTokenCookieName: configuration.ACCESS_TOKEN_COOKIE_NAME,
csrfCookieName: configuration.CSRF_COOKIE_NAME,

View 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,
};

View File

@@ -1,11 +0,0 @@
const STARTED_FETCHING_COMMENT = 'STARTED_FETCHING_COMMENT';
const FINISHED_FETCHING_COMMENT = 'FINISHED_FETCHING_COMMENT';
const ERROR_FETCHING_COMMENT = 'ERROR_FETCHING_COMMENT';
const GET_COMMENT = 'GET_COMMENT';
export {
STARTED_FETCHING_COMMENT,
FINISHED_FETCHING_COMMENT,
ERROR_FETCHING_COMMENT,
GET_COMMENT,
};

View File

@@ -10,6 +10,7 @@ const GRADE_UPDATE_FAILURE = 'GRADE_UPDATE_FAILURE';
const TOGGLE_GRADE_FORMAT = 'TOGGLE_GRADE_FORMAT';
const SORT_GRADES = 'SORT_GRADES';
const FILTER_COLUMNS = 'FILTER_COLUMNS';
const UPDATE_BANNER = 'UPDATE_BANNER';
export {
STARTED_FETCHING_GRADES,
@@ -22,4 +23,5 @@ export {
TOGGLE_GRADE_FORMAT,
SORT_GRADES,
FILTER_COLUMNS,
UPDATE_BANNER,
};

View File

@@ -1,9 +0,0 @@
const STARTED_FETCHING_POSTS = 'STARTED_FETCHING_POSTS';
const GET_POSTS = 'GET_POSTS';
const FINISHED_FETCHING_POSTS = 'FINISHED_FETCHING_POSTS';
export {
STARTED_FETCHING_POSTS,
GET_POSTS,
FINISHED_FETCHING_POSTS,
};

View File

@@ -0,0 +1,7 @@
const GOT_ROLES = 'GOT_ROLES';
const ERROR_FETCHING_ROLES = 'ERROR_FETCHING_ROLES';
export {
GOT_ROLES,
ERROR_FETCHING_ROLES,
};

View 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;

View 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);
});
});

View File

@@ -17,6 +17,7 @@ const cohorts = (state = initialState, action) => {
return {
...state,
results: action.cohorts,
finishedFetching: true,
errorFetching: false,
};
case STARTED_FETCHING_COHORTS:

View 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);
});
});

View File

@@ -1,46 +0,0 @@
import {
STARTED_FETCHING_COMMENT,
ERROR_FETCHING_COMMENT,
GET_COMMENT,
} from '../constants/actionTypes/comment';
const initialState = {
details: {
id: null,
postId: null,
name: '',
email: 'example@example.com',
body: '',
},
startedFetching: false,
finishedFetching: false,
errorFetching: false,
};
const comment = (state = initialState, action) => {
switch (action.type) {
case GET_COMMENT:
return {
...state,
details: { ...action.comment },
finishedFetching: true,
errorFetching: false,
};
case STARTED_FETCHING_COMMENT:
return {
...state,
startedFetching: true,
finishedFetching: false,
};
case ERROR_FETCHING_COMMENT:
return {
...state,
finishedFetching: true,
errorFetching: true,
};
default:
return state;
}
};
export default comment;

View File

@@ -4,6 +4,8 @@ import {
GOT_GRADES,
TOGGLE_GRADE_FORMAT,
FILTER_COLUMNS,
UPDATE_BANNER,
SORT_GRADES,
} from '../constants/actionTypes/grades';
const initialState = {
@@ -13,6 +15,10 @@ const initialState = {
finishedFetching: false,
errorFetching: false,
gradeFormat: 'percent',
showSuccess: false,
prevPage: null,
nextPage: null,
showSpinner: true,
};
const grades = (state = initialState, action) => {
@@ -26,12 +32,17 @@ const grades = (state = initialState, action) => {
errorFetching: false,
selectedTrack: action.track,
selectedCohort: action.cohort,
prevPage: action.prev,
nextPage: action.next,
showSpinner: false,
courseId: action.courseId,
};
case STARTED_FETCHING_GRADES:
return {
...state,
startedFetching: true,
finishedFetching: false,
showSpinner: true,
};
case ERROR_FETCHING_GRADES:
return {
@@ -49,1009 +60,19 @@ const grades = (state = initialState, action) => {
...state,
headings: action.headings,
};
case UPDATE_BANNER:
return {
...state,
showSuccess: action.showSuccess,
};
case SORT_GRADES:
return {
...state,
results: action.results,
};
default:
return state;
}
};
export default grades;
// Gradebook.defaultProps = {
// "results": [
// {
// "course_id": "course-v1:edX+DemoX+Demo_Course",
// "email": "honor@example.com",
// "user_id": 6,
// "username": "honor",
// "full_name": "",
// "passed": false,
// "percent": 0,
// "letter_grade": null,
// "progress_page_url": "/courses/course-v1:edX+DemoX+Demo_Course/progress/6/",
// "section_breakdown": [
// {
// "are_grades_published": true,
// "auto_grade": false,
// "category": null,
// "chapter_name": "Introduction",
// "comment": "",
// "detail": "",
// "displayed_value": "0.00",
// "is_graded": false,
// "grade_description": "(0.00/0.00)",
// "is_ag": false,
// "is_average": false,
// "is_manually_graded": false,
// "label": null,
// "letter_grade": null,
// "module_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction",
// "percent": 0,
// "score_earned": 0,
// "score_possible": 0,
// "section_block_id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@d8a6192ade314473a78242dfeedfbf5b",
// "subsection_name": "Demo Course Overview"
// },
// {
// "are_grades_published": true,
// "auto_grade": false,
// "category": null,
// "chapter_name": "Example Week 1: Getting Started",
// "comment": "",
// "detail": "",
// "displayed_value": "0.00",
// "is_graded": false,
// "grade_description": "(0.00/3.00)",
// "is_ag": false,
// "is_average": false,
// "is_manually_graded": false,
// "label": null,
// "letter_grade": null,
// "module_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@19a30717eff543078a5d94ae9d6c18a5",
// "percent": 0,
// "score_earned": 0,
// "score_possible": 3,
// "section_block_id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@interactive_demonstrations",
// "subsection_name": "Lesson 1 - Getting Started"
// },
// {
// "are_grades_published": true,
// "auto_grade": false,
// "category": "Homework",
// "chapter_name": "Example Week 1: Getting Started",
// "comment": "",
// "detail": "",
// "displayed_value": "0.00",
// "is_graded": true,
// "grade_description": "(0.00/11.00)",
// "is_ag": false,
// "is_average": false,
// "is_manually_graded": false,
// "label": "Ex 01",
// "letter_grade": null,
// "module_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions",
// "percent": 0,
// "score_earned": 0,
// "score_possible": 11,
// "section_block_id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@interactive_demonstrations",
// "subsection_name": "Homework - Question Styles"
// },
// {
// "are_grades_published": true,
// "auto_grade": false,
// "category": null,
// "chapter_name": "Example Week 2: Get Interactive",
// "comment": "",
// "detail": "",
// "displayed_value": "0.00",
// "is_graded": false,
// "grade_description": "(0.00/0.00)",
// "is_ag": false,
// "is_average": false,
// "is_manually_graded": false,
// "label": null,
// "letter_grade": null,
// "module_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@simulations",
// "percent": 0,
// "score_earned": 0,
// "score_possible": 0,
// "section_block_id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@graded_interactions",
// "subsection_name": "Lesson 2 - Let's Get Interactive!"
// },
// {
// "are_grades_published": true,
// "auto_grade": false,
// "category": "Homework",
// "chapter_name": "Example Week 2: Get Interactive",
// "comment": "",
// "detail": "",
// "displayed_value": "0.00",
// "is_graded": true,
// "grade_description": "(0.00/5.00)",
// "is_ag": false,
// "is_average": false,
// "is_manually_graded": false,
// "label": "Ex 02",
// "letter_grade": null,
// "module_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@graded_simulations",
// "percent": 0,
// "score_earned": 0,
// "score_possible": 5,
// "section_block_id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@graded_interactions",
// "subsection_name": "Homework - Labs and Demos"
// },
// {
// "are_grades_published": true,
// "auto_grade": false,
// "category": null,
// "chapter_name": "Example Week 2: Get Interactive",
// "comment": "",
// "detail": "",
// "displayed_value": "0.00",
// "is_graded": false,
// "grade_description": "(0.00/19.00)",
// "is_ag": false,
// "is_average": false,
// "is_manually_graded": false,
// "label": null,
// "letter_grade": null,
// "module_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@175e76c4951144a29d46211361266e0e",
// "percent": 0,
// "score_earned": 0,
// "score_possible": 19,
// "section_block_id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@graded_interactions",
// "subsection_name": "Homework - Essays"
// },
// {
// "are_grades_published": true,
// "auto_grade": false,
// "category": null,
// "chapter_name": "Example Week 3: Be Social",
// "comment": "",
// "detail": "",
// "displayed_value": "0.00",
// "is_graded": false,
// "grade_description": "(0.00/0.00)",
// "is_ag": false,
// "is_average": false,
// "is_manually_graded": false,
// "label": null,
// "letter_grade": null,
// "module_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@48ecb924d7fe4b66a230137626bfa93e",
// "percent": 0,
// "score_earned": 0,
// "score_possible": 0,
// "section_block_id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@social_integration",
// "subsection_name": "Lesson 3 - Be Social"
// },
// {
// "are_grades_published": true,
// "auto_grade": false,
// "category": null,
// "chapter_name": "Example Week 3: Be Social",
// "comment": "",
// "detail": "",
// "displayed_value": "0.00",
// "is_graded": false,
// "grade_description": "(0.00/0.00)",
// "is_ag": false,
// "is_average": false,
// "is_manually_graded": false,
// "label": null,
// "letter_grade": null,
// "module_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@dbe8fc027bcb4fe9afb744d2e8415855",
// "percent": 0,
// "score_earned": 0,
// "score_possible": 0,
// "section_block_id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@social_integration",
// "subsection_name": "Homework - Find Your Study Buddy"
// },
// {
// "are_grades_published": true,
// "auto_grade": false,
// "category": null,
// "chapter_name": "Example Week 3: Be Social",
// "comment": "",
// "detail": "",
// "displayed_value": "0.00",
// "is_graded": false,
// "grade_description": "(0.00/0.00)",
// "is_ag": false,
// "is_average": false,
// "is_manually_graded": false,
// "label": null,
// "letter_grade": null,
// "module_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@6ab9c442501d472c8ed200e367b4edfa",
// "percent": 0,
// "score_earned": 0,
// "score_possible": 0,
// "section_block_id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@social_integration",
// "subsection_name": "More Ways to Connect"
// },
// {
// "are_grades_published": true,
// "auto_grade": false,
// "category": "Exam",
// "chapter_name": "About Exams and Certificates",
// "comment": "",
// "detail": "",
// "displayed_value": "0.00",
// "is_graded": true,
// "grade_description": "(0.00/6.00)",
// "is_ag": false,
// "is_average": false,
// "is_manually_graded": false,
// "label": null,
// "letter_grade": null,
// "module_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@workflow",
// "percent": 0,
// "score_earned": 0,
// "score_possible": 6,
// "section_block_id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@1414ffd5143b4b508f739b563ab468b7",
// "subsection_name": "edX Exams"
// }
// ],
// "aggregates": {
// "Exam": {
// "score_possible": 6,
// "score_earned": 0
// },
// "Homework": {
// "score_possible": 16,
// "score_earned": 0
// }
// }
// },
// {
// "course_id": "course-v1:edX+DemoX+Demo_Course",
// "email": "audit@example.com",
// "user_id": 7,
// "username": "audit",
// "full_name": "",
// "passed": false,
// "percent": 0.17,
// "letter_grade": null,
// "progress_page_url": "/courses/course-v1:edX+DemoX+Demo_Course/progress/7/",
// "section_breakdown": [
// {
// "are_grades_published": true,
// "auto_grade": false,
// "category": null,
// "chapter_name": "Introduction",
// "comment": "",
// "detail": "",
// "displayed_value": "0.00",
// "is_graded": false,
// "grade_description": "(0.00/0.00)",
// "is_ag": false,
// "is_average": false,
// "is_manually_graded": false,
// "label": null,
// "letter_grade": null,
// "module_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction",
// "percent": 0,
// "score_earned": 0,
// "score_possible": 0,
// "section_block_id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@d8a6192ade314473a78242dfeedfbf5b",
// "subsection_name": "Demo Course Overview"
// },
// {
// "are_grades_published": true,
// "auto_grade": false,
// "category": null,
// "chapter_name": "Example Week 1: Getting Started",
// "comment": "",
// "detail": "",
// "displayed_value": "0.00",
// "is_graded": false,
// "grade_description": "(0.00/3.00)",
// "is_ag": false,
// "is_average": false,
// "is_manually_graded": false,
// "label": null,
// "letter_grade": null,
// "module_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@19a30717eff543078a5d94ae9d6c18a5",
// "percent": 0,
// "score_earned": 0,
// "score_possible": 3,
// "section_block_id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@interactive_demonstrations",
// "subsection_name": "Lesson 1 - Getting Started"
// },
// {
// "are_grades_published": true,
// "auto_grade": false,
// "category": "Homework",
// "chapter_name": "Example Week 1: Getting Started",
// "comment": "",
// "detail": "",
// "displayed_value": "0.45",
// "is_graded": true,
// "grade_description": "(5.00/11.00)",
// "is_ag": false,
// "is_average": false,
// "is_manually_graded": false,
// "label": "Ex 01",
// "letter_grade": null,
// "module_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions",
// "percent": 0.45,
// "score_earned": 5,
// "score_possible": 11,
// "section_block_id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@interactive_demonstrations",
// "subsection_name": "Homework - Question Styles"
// },
// {
// "are_grades_published": true,
// "auto_grade": false,
// "category": null,
// "chapter_name": "Example Week 2: Get Interactive",
// "comment": "",
// "detail": "",
// "displayed_value": "0.00",
// "is_graded": false,
// "grade_description": "(0.00/0.00)",
// "is_ag": false,
// "is_average": false,
// "is_manually_graded": false,
// "label": null,
// "letter_grade": null,
// "module_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@simulations",
// "percent": 0,
// "score_earned": 0,
// "score_possible": 0,
// "section_block_id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@graded_interactions",
// "subsection_name": "Lesson 2 - Let's Get Interactive!"
// },
// {
// "are_grades_published": true,
// "auto_grade": false,
// "category": "Homework",
// "chapter_name": "Example Week 2: Get Interactive",
// "comment": "",
// "detail": "",
// "displayed_value": "0.00",
// "is_graded": true,
// "grade_description": "(0.00/5.00)",
// "is_ag": false,
// "is_average": false,
// "is_manually_graded": false,
// "label": "Ex 02",
// "letter_grade": null,
// "module_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@graded_simulations",
// "percent": 0,
// "score_earned": 0,
// "score_possible": 5,
// "section_block_id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@graded_interactions",
// "subsection_name": "Homework - Labs and Demos"
// },
// {
// "are_grades_published": true,
// "auto_grade": false,
// "category": null,
// "chapter_name": "Example Week 2: Get Interactive",
// "comment": "",
// "detail": "",
// "displayed_value": "0.00",
// "is_graded": false,
// "grade_description": "(0.00/19.00)",
// "is_ag": false,
// "is_average": false,
// "is_manually_graded": false,
// "label": null,
// "letter_grade": null,
// "module_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@175e76c4951144a29d46211361266e0e",
// "percent": 0,
// "score_earned": 0,
// "score_possible": 19,
// "section_block_id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@graded_interactions",
// "subsection_name": "Homework - Essays"
// },
// {
// "are_grades_published": true,
// "auto_grade": false,
// "category": null,
// "chapter_name": "Example Week 3: Be Social",
// "comment": "",
// "detail": "",
// "displayed_value": "0.00",
// "is_graded": false,
// "grade_description": "(0.00/0.00)",
// "is_ag": false,
// "is_average": false,
// "is_manually_graded": false,
// "label": null,
// "letter_grade": null,
// "module_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@48ecb924d7fe4b66a230137626bfa93e",
// "percent": 0,
// "score_earned": 0,
// "score_possible": 0,
// "section_block_id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@social_integration",
// "subsection_name": "Lesson 3 - Be Social"
// },
// {
// "are_grades_published": true,
// "auto_grade": false,
// "category": null,
// "chapter_name": "Example Week 3: Be Social",
// "comment": "",
// "detail": "",
// "displayed_value": "0.00",
// "is_graded": false,
// "grade_description": "(0.00/0.00)",
// "is_ag": false,
// "is_average": false,
// "is_manually_graded": false,
// "label": null,
// "letter_grade": null,
// "module_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@dbe8fc027bcb4fe9afb744d2e8415855",
// "percent": 0,
// "score_earned": 0,
// "score_possible": 0,
// "section_block_id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@social_integration",
// "subsection_name": "Homework - Find Your Study Buddy"
// },
// {
// "are_grades_published": true,
// "auto_grade": false,
// "category": null,
// "chapter_name": "Example Week 3: Be Social",
// "comment": "",
// "detail": "",
// "displayed_value": "0.00",
// "is_graded": false,
// "grade_description": "(0.00/0.00)",
// "is_ag": false,
// "is_average": false,
// "is_manually_graded": false,
// "label": null,
// "letter_grade": null,
// "module_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@6ab9c442501d472c8ed200e367b4edfa",
// "percent": 0,
// "score_earned": 0,
// "score_possible": 0,
// "section_block_id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@social_integration",
// "subsection_name": "More Ways to Connect"
// },
// {
// "are_grades_published": true,
// "auto_grade": false,
// "category": "Exam",
// "chapter_name": "About Exams and Certificates",
// "comment": "",
// "detail": "",
// "displayed_value": "0.00",
// "is_graded": true,
// "grade_description": "(0.00/6.00)",
// "is_ag": false,
// "is_average": false,
// "is_manually_graded": false,
// "label": null,
// "letter_grade": null,
// "module_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@workflow",
// "percent": 0,
// "score_earned": 0,
// "score_possible": 6,
// "section_block_id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@1414ffd5143b4b508f739b563ab468b7",
// "subsection_name": "edX Exams"
// }
// ],
// "aggregates": {
// "Exam": {
// "score_possible": 6,
// "score_earned": 0
// },
// "Homework": {
// "score_possible": 16,
// "score_earned": 5
// }
// }
// },
// {
// "course_id": "course-v1:edX+DemoX+Demo_Course",
// "email": "verified@example.com",
// "user_id": 8,
// "username": "verified",
// "full_name": "",
// "passed": false,
// "percent": 0,
// "letter_grade": null,
// "progress_page_url": "/courses/course-v1:edX+DemoX+Demo_Course/progress/8/",
// "section_breakdown": [
// {
// "are_grades_published": true,
// "auto_grade": false,
// "category": null,
// "chapter_name": "Introduction",
// "comment": "",
// "detail": "",
// "displayed_value": "0.00",
// "is_graded": false,
// "grade_description": "(0.00/0.00)",
// "is_ag": false,
// "is_average": false,
// "is_manually_graded": false,
// "label": null,
// "letter_grade": null,
// "module_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction",
// "percent": 0,
// "score_earned": 0,
// "score_possible": 0,
// "section_block_id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@d8a6192ade314473a78242dfeedfbf5b",
// "subsection_name": "Demo Course Overview"
// },
// {
// "are_grades_published": true,
// "auto_grade": false,
// "category": null,
// "chapter_name": "Example Week 1: Getting Started",
// "comment": "",
// "detail": "",
// "displayed_value": "0.00",
// "is_graded": false,
// "grade_description": "(0.00/3.00)",
// "is_ag": false,
// "is_average": false,
// "is_manually_graded": false,
// "label": null,
// "letter_grade": null,
// "module_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@19a30717eff543078a5d94ae9d6c18a5",
// "percent": 0,
// "score_earned": 0,
// "score_possible": 3,
// "section_block_id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@interactive_demonstrations",
// "subsection_name": "Lesson 1 - Getting Started"
// },
// {
// "are_grades_published": true,
// "auto_grade": false,
// "category": "Homework",
// "chapter_name": "Example Week 1: Getting Started",
// "comment": "",
// "detail": "",
// "displayed_value": "0.00",
// "is_graded": true,
// "grade_description": "(0.00/11.00)",
// "is_ag": false,
// "is_average": false,
// "is_manually_graded": false,
// "label": "Ex 01",
// "letter_grade": null,
// "module_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions",
// "percent": 0,
// "score_earned": 0,
// "score_possible": 11,
// "section_block_id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@interactive_demonstrations",
// "subsection_name": "Homework - Question Styles"
// },
// {
// "are_grades_published": true,
// "auto_grade": false,
// "category": null,
// "chapter_name": "Example Week 2: Get Interactive",
// "comment": "",
// "detail": "",
// "displayed_value": "0.00",
// "is_graded": false,
// "grade_description": "(0.00/0.00)",
// "is_ag": false,
// "is_average": false,
// "is_manually_graded": false,
// "label": null,
// "letter_grade": null,
// "module_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@simulations",
// "percent": 0,
// "score_earned": 0,
// "score_possible": 0,
// "section_block_id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@graded_interactions",
// "subsection_name": "Lesson 2 - Let's Get Interactive!"
// },
// {
// "are_grades_published": true,
// "auto_grade": false,
// "category": "Homework",
// "chapter_name": "Example Week 2: Get Interactive",
// "comment": "",
// "detail": "",
// "displayed_value": "0.00",
// "is_graded": true,
// "grade_description": "(0.00/5.00)",
// "is_ag": false,
// "is_average": false,
// "is_manually_graded": false,
// "label": "Ex 02",
// "letter_grade": null,
// "module_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@graded_simulations",
// "percent": 0,
// "score_earned": 0,
// "score_possible": 5,
// "section_block_id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@graded_interactions",
// "subsection_name": "Homework - Labs and Demos"
// },
// {
// "are_grades_published": true,
// "auto_grade": false,
// "category": null,
// "chapter_name": "Example Week 2: Get Interactive",
// "comment": "",
// "detail": "",
// "displayed_value": "0.00",
// "is_graded": false,
// "grade_description": "(0.00/19.00)",
// "is_ag": false,
// "is_average": false,
// "is_manually_graded": false,
// "label": null,
// "letter_grade": null,
// "module_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@175e76c4951144a29d46211361266e0e",
// "percent": 0,
// "score_earned": 0,
// "score_possible": 19,
// "section_block_id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@graded_interactions",
// "subsection_name": "Homework - Essays"
// },
// {
// "are_grades_published": true,
// "auto_grade": false,
// "category": null,
// "chapter_name": "Example Week 3: Be Social",
// "comment": "",
// "detail": "",
// "displayed_value": "0.00",
// "is_graded": false,
// "grade_description": "(0.00/0.00)",
// "is_ag": false,
// "is_average": false,
// "is_manually_graded": false,
// "label": null,
// "letter_grade": null,
// "module_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@48ecb924d7fe4b66a230137626bfa93e",
// "percent": 0,
// "score_earned": 0,
// "score_possible": 0,
// "section_block_id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@social_integration",
// "subsection_name": "Lesson 3 - Be Social"
// },
// {
// "are_grades_published": true,
// "auto_grade": false,
// "category": null,
// "chapter_name": "Example Week 3: Be Social",
// "comment": "",
// "detail": "",
// "displayed_value": "0.00",
// "is_graded": false,
// "grade_description": "(0.00/0.00)",
// "is_ag": false,
// "is_average": false,
// "is_manually_graded": false,
// "label": null,
// "letter_grade": null,
// "module_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@dbe8fc027bcb4fe9afb744d2e8415855",
// "percent": 0,
// "score_earned": 0,
// "score_possible": 0,
// "section_block_id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@social_integration",
// "subsection_name": "Homework - Find Your Study Buddy"
// },
// {
// "are_grades_published": true,
// "auto_grade": false,
// "category": null,
// "chapter_name": "Example Week 3: Be Social",
// "comment": "",
// "detail": "",
// "displayed_value": "0.00",
// "is_graded": false,
// "grade_description": "(0.00/0.00)",
// "is_ag": false,
// "is_average": false,
// "is_manually_graded": false,
// "label": null,
// "letter_grade": null,
// "module_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@6ab9c442501d472c8ed200e367b4edfa",
// "percent": 0,
// "score_earned": 0,
// "score_possible": 0,
// "section_block_id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@social_integration",
// "subsection_name": "More Ways to Connect"
// },
// {
// "are_grades_published": true,
// "auto_grade": false,
// "category": "Exam",
// "chapter_name": "About Exams and Certificates",
// "comment": "",
// "detail": "",
// "displayed_value": "0.00",
// "is_graded": true,
// "grade_description": "(0.00/6.00)",
// "is_ag": false,
// "is_average": false,
// "is_manually_graded": false,
// "label": null,
// "letter_grade": null,
// "module_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@workflow",
// "percent": 0,
// "score_earned": 0,
// "score_possible": 6,
// "section_block_id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@1414ffd5143b4b508f739b563ab468b7",
// "subsection_name": "edX Exams"
// }
// ],
// "aggregates": {
// "Exam": {
// "score_possible": 6,
// "score_earned": 0
// },
// "Homework": {
// "score_possible": 16,
// "score_earned": 0
// }
// }
// },
// {
// "course_id": "course-v1:edX+DemoX+Demo_Course",
// "email": "staff@example.com",
// "user_id": 9,
// "username": "staff",
// "full_name": "",
// "passed": false,
// "percent": 0,
// "letter_grade": null,
// "progress_page_url": "/courses/course-v1:edX+DemoX+Demo_Course/progress/9/",
// "section_breakdown": [
// {
// "are_grades_published": true,
// "auto_grade": false,
// "category": null,
// "chapter_name": "Introduction",
// "comment": "",
// "detail": "",
// "displayed_value": "0.00",
// "is_graded": false,
// "grade_description": "(0.00/0.00)",
// "is_ag": false,
// "is_average": false,
// "is_manually_graded": false,
// "label": null,
// "letter_grade": null,
// "module_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction",
// "percent": 0,
// "score_earned": 0,
// "score_possible": 0,
// "section_block_id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@d8a6192ade314473a78242dfeedfbf5b",
// "subsection_name": "Demo Course Overview"
// },
// {
// "are_grades_published": true,
// "auto_grade": false,
// "category": null,
// "chapter_name": "Example Week 1: Getting Started",
// "comment": "",
// "detail": "",
// "displayed_value": "0.00",
// "is_graded": false,
// "grade_description": "(0.00/3.00)",
// "is_ag": false,
// "is_average": false,
// "is_manually_graded": false,
// "label": null,
// "letter_grade": null,
// "module_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@19a30717eff543078a5d94ae9d6c18a5",
// "percent": 0,
// "score_earned": 0,
// "score_possible": 3,
// "section_block_id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@interactive_demonstrations",
// "subsection_name": "Lesson 1 - Getting Started"
// },
// {
// "are_grades_published": true,
// "auto_grade": false,
// "category": "Homework",
// "chapter_name": "Example Week 1: Getting Started",
// "comment": "",
// "detail": "",
// "displayed_value": "0.00",
// "is_graded": true,
// "grade_description": "(0.00/11.00)",
// "is_ag": false,
// "is_average": false,
// "is_manually_graded": false,
// "label": "Ex 01",
// "letter_grade": null,
// "module_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions",
// "percent": 0,
// "score_earned": 0,
// "score_possible": 11,
// "section_block_id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@interactive_demonstrations",
// "subsection_name": "Homework - Question Styles"
// },
// {
// "are_grades_published": true,
// "auto_grade": false,
// "category": null,
// "chapter_name": "Example Week 2: Get Interactive",
// "comment": "",
// "detail": "",
// "displayed_value": "0.00",
// "is_graded": false,
// "grade_description": "(0.00/0.00)",
// "is_ag": false,
// "is_average": false,
// "is_manually_graded": false,
// "label": null,
// "letter_grade": null,
// "module_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@simulations",
// "percent": 0,
// "score_earned": 0,
// "score_possible": 0,
// "section_block_id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@graded_interactions",
// "subsection_name": "Lesson 2 - Let's Get Interactive!"
// },
// {
// "are_grades_published": true,
// "auto_grade": false,
// "category": "Homework",
// "chapter_name": "Example Week 2: Get Interactive",
// "comment": "",
// "detail": "",
// "displayed_value": "0.00",
// "is_graded": true,
// "grade_description": "(0.00/5.00)",
// "is_ag": false,
// "is_average": false,
// "is_manually_graded": false,
// "label": "Ex 02",
// "letter_grade": null,
// "module_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@graded_simulations",
// "percent": 0,
// "score_earned": 0,
// "score_possible": 5,
// "section_block_id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@graded_interactions",
// "subsection_name": "Homework - Labs and Demos"
// },
// {
// "are_grades_published": true,
// "auto_grade": false,
// "category": null,
// "chapter_name": "Example Week 2: Get Interactive",
// "comment": "",
// "detail": "",
// "displayed_value": "0.00",
// "is_graded": false,
// "grade_description": "(0.00/19.00)",
// "is_ag": false,
// "is_average": false,
// "is_manually_graded": false,
// "label": null,
// "letter_grade": null,
// "module_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@175e76c4951144a29d46211361266e0e",
// "percent": 0,
// "score_earned": 0,
// "score_possible": 19,
// "section_block_id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@graded_interactions",
// "subsection_name": "Homework - Essays"
// },
// {
// "are_grades_published": true,
// "auto_grade": false,
// "category": null,
// "chapter_name": "Example Week 3: Be Social",
// "comment": "",
// "detail": "",
// "displayed_value": "0.00",
// "is_graded": false,
// "grade_description": "(0.00/0.00)",
// "is_ag": false,
// "is_average": false,
// "is_manually_graded": false,
// "label": null,
// "letter_grade": null,
// "module_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@48ecb924d7fe4b66a230137626bfa93e",
// "percent": 0,
// "score_earned": 0,
// "score_possible": 0,
// "section_block_id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@social_integration",
// "subsection_name": "Lesson 3 - Be Social"
// },
// {
// "are_grades_published": true,
// "auto_grade": false,
// "category": null,
// "chapter_name": "Example Week 3: Be Social",
// "comment": "",
// "detail": "",
// "displayed_value": "0.00",
// "is_graded": false,
// "grade_description": "(0.00/0.00)",
// "is_ag": false,
// "is_average": false,
// "is_manually_graded": false,
// "label": null,
// "letter_grade": null,
// "module_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@dbe8fc027bcb4fe9afb744d2e8415855",
// "percent": 0,
// "score_earned": 0,
// "score_possible": 0,
// "section_block_id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@social_integration",
// "subsection_name": "Homework - Find Your Study Buddy"
// },
// {
// "are_grades_published": true,
// "auto_grade": false,
// "category": null,
// "chapter_name": "Example Week 3: Be Social",
// "comment": "",
// "detail": "",
// "displayed_value": "0.00",
// "is_graded": false,
// "grade_description": "(0.00/0.00)",
// "is_ag": false,
// "is_average": false,
// "is_manually_graded": false,
// "label": null,
// "letter_grade": null,
// "module_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@6ab9c442501d472c8ed200e367b4edfa",
// "percent": 0,
// "score_earned": 0,
// "score_possible": 0,
// "section_block_id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@social_integration",
// "subsection_name": "More Ways to Connect"
// },
// {
// "are_grades_published": true,
// "auto_grade": false,
// "category": "Exam",
// "chapter_name": "About Exams and Certificates",
// "comment": "",
// "detail": "",
// "displayed_value": "0.00",
// "is_graded": true,
// "grade_description": "(0.00/6.00)",
// "is_ag": false,
// "is_average": false,
// "is_manually_graded": false,
// "label": null,
// "letter_grade": null,
// "module_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@workflow",
// "percent": 0,
// "score_earned": 0,
// "score_possible": 6,
// "section_block_id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@1414ffd5143b4b508f739b563ab468b7",
// "subsection_name": "edX Exams"
// },
// {
// "are_grades_published": true,
// "auto_grade": false,
// "category": null,
// "chapter_name": "holding section",
// "comment": "",
// "detail": "",
// "displayed_value": "0.00",
// "is_graded": false,
// "grade_description": "(0.00/0.00)",
// "is_ag": false,
// "is_average": false,
// "is_manually_graded": false,
// "label": null,
// "letter_grade": null,
// "module_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@07bc32474380492cb34f76e5f9d9a135",
// "percent": 0,
// "score_earned": 0,
// "score_possible": 0,
// "section_block_id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@9fca584977d04885bc911ea76a9ef29e",
// "subsection_name": "New Subsection"
// }
// ],
// "aggregates": {
// "Exam": {
// "score_possible": 6,
// "score_earned": 0
// },
// "Homework": {
// "score_possible": 16,
// "score_earned": 0
// }
// }
// }
// ]
// };

View File

@@ -0,0 +1,188 @@
import grades from './grades';
import {
STARTED_FETCHING_GRADES,
ERROR_FETCHING_GRADES,
GOT_GRADES,
TOGGLE_GRADE_FORMAT,
FILTER_COLUMNS,
UPDATE_BANNER,
SORT_GRADES,
} 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 sort grades state success', () => {
const expected = {
...initialState,
results: gradesData,
};
expect(grades(undefined, {
type: SORT_GRADES,
results: gradesData,
})).toEqual(expected);
});
it('updates fetch grades failure state', () => {
const expected = {
...initialState,
errorFetching: true,
finishedFetching: true,
};
expect(grades(undefined, {
type: ERROR_FETCHING_GRADES,
})).toEqual(expected);
});
});

View File

@@ -3,11 +3,15 @@ import { combineReducers } from 'redux';
import cohorts from './cohorts';
import grades from './grades';
import tracks from './tracks';
import assignmentTypes from './assignmentTypes';
import roles from './roles';
const rootReducer = combineReducers({
grades,
cohorts,
tracks,
assignmentTypes,
roles,
});
export default rootReducer;

View File

@@ -1,31 +0,0 @@
import {
GET_POSTS,
STARTED_FETCHING_POSTS,
FINISHED_FETCHING_POSTS,
} from '../constants/actionTypes/posts';
const posts = (state = { posts: [], startedFetching: false, finishedFetching: false }, action) => {
switch (action.type) {
case GET_POSTS:
return {
...state,
posts: action.posts,
};
case STARTED_FETCHING_POSTS:
return {
...state,
startedFetching: true,
finishedFetching: false,
};
case FINISHED_FETCHING_POSTS:
return {
...state,
startedFetching: false,
finishedFetching: true,
};
default:
return state;
}
};
export default posts;

View File

@@ -1,45 +0,0 @@
import posts from './posts';
import {
GET_POSTS,
STARTED_FETCHING_POSTS,
FINISHED_FETCHING_POSTS,
} from '../constants/actionTypes/posts';
const initialState = {
posts: [],
startedFetching: false,
finishedFetching: false,
};
describe('posts reducer', () => {
it('has initial state', () => {
expect(posts(undefined, {})).toEqual(initialState);
});
it('adds posts', () => {
const fetchedPosts = [1, 2, 3];
const expected = {
...initialState,
posts: fetchedPosts,
};
expect(posts(undefined, { type: GET_POSTS, posts: fetchedPosts })).toEqual(expected);
});
it('updates started fetching posts state', () => {
const expected = {
...initialState,
startedFetching: true,
finishedFetching: false,
};
expect(posts(undefined, { type: STARTED_FETCHING_POSTS })).toEqual(expected);
});
it('updates finished fetching posts state', () => {
const expected = {
...initialState,
startedFetching: false,
finishedFetching: true,
};
expect(posts(undefined, { type: FINISHED_FETCHING_POSTS })).toEqual(expected);
});
});

View 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;

View 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);
});
});

View File

@@ -18,6 +18,7 @@ const tracks = (state = initialState, action) => {
...state,
results: action.tracks,
errorFetching: false,
finishedFetching: true,
};
case STARTED_FETCHING_TRACKS:
return {

View 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);
});
});

View File

@@ -3,12 +3,12 @@ import { configuration } from '../../config';
class LmsApiService {
static baseUrl = configuration.LMS_BASE_URL;
static pageSize = 10
static fetchGradebookData(courseId, searchText, cohort, track) {
let gradebookUrl = `${LmsApiService.baseUrl}/api/grades/v1/gradebook/${courseId}/`;
if (searchText || track || cohort) {
gradebookUrl += '?';
}
gradebookUrl += `?page_size=${LmsApiService.pageSize}&`;
if (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),
'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.
Example:
[
@@ -44,9 +47,9 @@ class LmsApiService {
const gradebookUrl = `${LmsApiService.baseUrl}/api/grades/v1/gradebook/${courseId}/bulk-update`;
return apiClient.post(gradebookUrl, updateData);
}
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);
}
@@ -54,6 +57,16 @@ class LmsApiService {
const cohortsUrl = `${LmsApiService.baseUrl}/courses/${courseId}/cohorts/`;
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;

View File

@@ -2,14 +2,56 @@ import { applyMiddleware, createStore } from 'redux';
import thunkMiddleware from 'redux-thunk';
import { composeWithDevTools } from 'redux-devtools-extension/logOnlyInProduction';
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';
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,
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(
reducers,
composeWithDevTools(applyMiddleware(thunkMiddleware, loggerMiddleware)),
composeWithDevTools(applyMiddleware(thunkMiddleware, loggerMiddleware, segmentMiddleware)),
);
export default store;

View File

@@ -3,21 +3,48 @@ import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
import { Provider } from 'react-redux';
import SiteFooter from '@edx/frontend-component-footer';
import apiClient from './data/apiClient';
import GradebookPage from './containers/GradebookPage';
import Header from './components/Header';
import store from './data/store';
import FooterLogo from '../assets/edx-footer.png';
import './App.scss';
const App = () => (
<Provider store={store}>
<Router>
<main>
<Switch>
<Route exact path="/:courseId" component={GradebookPage} />
</Switch>
</main>
<div>
<Header />
<main>
<Switch>
<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>
</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
View 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
View 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);
}());

View File

@@ -4,3 +4,7 @@ import Enzyme from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
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';