Compare commits
236 Commits
douglashal
...
v1.4.26
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
807a57d947 | ||
|
|
0c242ab6f0 | ||
|
|
ee2c573017 | ||
|
|
4fdc541992 | ||
|
|
658b45136e | ||
|
|
61fdb31316 | ||
|
|
93f45d0784 | ||
|
|
6c88291626 | ||
|
|
621c297f1a | ||
|
|
76b349e377 | ||
|
|
d88475aab5 | ||
|
|
ddad9d9513 | ||
|
|
9f7e29ed76 | ||
|
|
539202f511 | ||
|
|
c42a995b11 | ||
|
|
78644daf26 | ||
|
|
7fd38dbcf1 | ||
|
|
62aad2aa2f | ||
|
|
12d32efe08 | ||
|
|
c60358941e | ||
|
|
1345666e53 | ||
|
|
c4bd8dc416 | ||
|
|
83986ea994 | ||
|
|
f891f90f77 | ||
|
|
313840fa10 | ||
|
|
84a7531530 | ||
|
|
27296449b4 | ||
|
|
2b37919222 | ||
|
|
384d6cc296 | ||
|
|
a0943b3946 | ||
|
|
8bc1fc82f2 | ||
|
|
1c26aa1d71 | ||
|
|
582b6cb1c5 | ||
|
|
bc04f6d86f | ||
|
|
84f1efefb3 | ||
|
|
e9f01ea3a3 | ||
|
|
100fbc08bf | ||
|
|
500364dc99 | ||
|
|
609c0a8d3a | ||
|
|
5f81624342 | ||
|
|
647ecbab75 | ||
|
|
de539382bd | ||
|
|
cc01ab0a92 | ||
|
|
8881e62337 | ||
|
|
92e7cc39cd | ||
|
|
d6d09205f4 | ||
|
|
b2e4e330bf | ||
|
|
d10dc54116 | ||
|
|
15d7dcfe85 | ||
|
|
6717663c07 | ||
|
|
ac229ebc85 | ||
|
|
4c481721bc | ||
|
|
40f52b2dc9 | ||
|
|
a25e446998 | ||
|
|
326ae93ed7 | ||
|
|
95e9b51aca | ||
|
|
4d76329946 | ||
|
|
5c565bebb0 | ||
|
|
d1ca314565 | ||
|
|
677521808b | ||
|
|
139a0de6a6 | ||
|
|
42b4a5b3dd | ||
|
|
9b9c703214 | ||
|
|
d46f4da9d5 | ||
|
|
fa3826b452 | ||
|
|
fd3eb71820 | ||
|
|
3dff787b37 | ||
|
|
ac56ab766b | ||
|
|
dbe3dfa323 | ||
|
|
9e0c326dfc | ||
|
|
351bf48561 | ||
|
|
30c51668c4 | ||
|
|
9bc86fc4f6 | ||
|
|
aa39fcc7e0 | ||
|
|
f7fcaef03a | ||
|
|
6f9c051ded | ||
|
|
dabd923b10 | ||
|
|
da60ff9f1d | ||
|
|
f9d5987488 | ||
|
|
493d5df8fa | ||
|
|
cf4f806d76 | ||
|
|
ee274a2fb0 | ||
|
|
baa26f3b20 | ||
|
|
0f7ffcbec1 | ||
|
|
f5616f77fd | ||
|
|
ecf5ef4664 | ||
|
|
480ea1915c | ||
|
|
a646b7b77c | ||
|
|
a2503d7067 | ||
|
|
7413c2100f | ||
|
|
8733ab5e1f | ||
|
|
8dc5836daa | ||
|
|
993809b5b0 | ||
|
|
9665859ea2 | ||
|
|
386bbea84b | ||
|
|
ee8e56a792 | ||
|
|
80aa4f10a0 | ||
|
|
2d0bf8c322 | ||
|
|
eaa2db5019 | ||
|
|
de6b2f1219 | ||
|
|
12db9448d8 | ||
|
|
52da367d18 | ||
|
|
e8cbc66e6f | ||
|
|
bc39443d94 | ||
|
|
15cdd7a256 | ||
|
|
81601ca6ea | ||
|
|
c773c35bbc | ||
|
|
b4e77b2b3f | ||
|
|
56b90cd223 | ||
|
|
7fa24969ac | ||
|
|
924dcda088 | ||
|
|
18f9ffcfde | ||
|
|
1e7871795c | ||
|
|
cd309c47e1 | ||
|
|
5d9d2af578 | ||
|
|
c5306296c9 | ||
|
|
49e3fd23d7 | ||
|
|
7bdd1533c3 | ||
|
|
22cf805960 | ||
|
|
be84c91981 | ||
|
|
4434857ada | ||
|
|
7764f78386 | ||
|
|
b4a5288b4b | ||
|
|
caea72b92e | ||
|
|
93128ac728 | ||
|
|
05bd0d740c | ||
|
|
f041d6b16d | ||
|
|
b90df949b9 | ||
|
|
9c1f5ed239 | ||
|
|
f38cd91fb1 | ||
|
|
588528abf6 | ||
|
|
d62b9ca520 | ||
|
|
ec08ef4bcb | ||
|
|
d5b3edc850 | ||
|
|
4f7d764d20 | ||
|
|
2dacc2c037 | ||
|
|
88fa2e60f4 | ||
|
|
2822e36b7b | ||
|
|
f46fe3f8ee | ||
|
|
e787d3a697 | ||
|
|
d77737f132 | ||
|
|
9ed5c6cb34 | ||
|
|
5b16a5dbb2 | ||
|
|
2c9c505b32 | ||
|
|
9425e06f8f | ||
|
|
e7134d8f68 | ||
|
|
e547d67f19 | ||
|
|
8b1d5aacf2 | ||
|
|
5a243e1b5f | ||
|
|
a957e16424 | ||
|
|
0d12eaf979 | ||
|
|
3e2787b0e0 | ||
|
|
35c632b716 | ||
|
|
801e2d6d59 | ||
|
|
7bd81d7a2b | ||
|
|
2b8ecf2c78 | ||
|
|
de13ac5093 | ||
|
|
ade906896c | ||
|
|
43181e39cc | ||
|
|
f60a9647b4 | ||
|
|
7dc1ffdd42 | ||
|
|
2913f1d965 | ||
|
|
7f85fb12e3 | ||
|
|
90f66d3759 | ||
|
|
fb09bee8d9 | ||
|
|
e31495b00b | ||
|
|
0557f29e0b | ||
|
|
e08b13f218 | ||
|
|
dee42eee7e | ||
|
|
1a0fe945a5 | ||
|
|
704144f9d1 | ||
|
|
a1ab29702f | ||
|
|
f619308ce5 | ||
|
|
c235da8cc0 | ||
|
|
777e0696ee | ||
|
|
95b8ba146e | ||
|
|
8463a28c7d | ||
|
|
bdee388dbd | ||
|
|
e0633dc816 | ||
|
|
668620a5f0 | ||
|
|
cfbb02a54c | ||
|
|
b22b44bbf5 | ||
|
|
80d9e32fba | ||
|
|
ef8975df86 | ||
|
|
09e482e893 | ||
|
|
d4421d47fc | ||
|
|
0ef8e773cc | ||
|
|
1dac20b866 | ||
|
|
ed2d715ce0 | ||
|
|
c82c49ea59 | ||
|
|
a9f8aec5f9 | ||
|
|
a63e9a5347 | ||
|
|
2581812118 | ||
|
|
c4fe803a95 | ||
|
|
93be5329ca | ||
|
|
80ba7e7152 | ||
|
|
f88526aa3a | ||
|
|
c0f08eee58 | ||
|
|
ef62ea35dc | ||
|
|
34eaa31776 | ||
|
|
a7316e6824 | ||
|
|
c0ab04f20c | ||
|
|
ed72e7c203 | ||
|
|
223d9a00bd | ||
|
|
8379f48e50 | ||
|
|
9e1268e388 | ||
|
|
57e0f2254a | ||
|
|
2cc14191b4 | ||
|
|
603dbeb823 | ||
|
|
55cb1f4140 | ||
|
|
55648a62ff | ||
|
|
62f9d24704 | ||
|
|
f036b0cf34 | ||
|
|
67493d1e9e | ||
|
|
e5bca7e526 | ||
|
|
52c5357ce7 | ||
|
|
d469cc2de7 | ||
|
|
86092f22b3 | ||
|
|
c8cb07228f | ||
|
|
a1946e7bc4 | ||
|
|
01d80e0fff | ||
|
|
e6da087e83 | ||
|
|
ac5eaed5cb | ||
|
|
88997ca242 | ||
|
|
d5daf9086f | ||
|
|
8a01a60d63 | ||
|
|
66cdcc7f2a | ||
|
|
0c73d66666 | ||
|
|
28e3e6d0e6 | ||
|
|
6473bafa3d | ||
|
|
167901e665 | ||
|
|
50a0d6e579 | ||
|
|
a284c286f5 | ||
|
|
dd967e703c | ||
|
|
725dc071e3 | ||
|
|
3da7730f23 |
17
.babelrc
17
.babelrc
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"presets": [
|
||||
[
|
||||
"env",
|
||||
{
|
||||
"targets": {
|
||||
"browsers": ["last 2 versions", "ie 11"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"babel-preset-react"
|
||||
],
|
||||
"plugins": [
|
||||
"transform-object-rest-spread",
|
||||
"transform-class-properties"
|
||||
]
|
||||
}
|
||||
35
.env
Normal file
35
.env
Normal file
@@ -0,0 +1,35 @@
|
||||
NODE_ENV='production',
|
||||
NODE_PATH=./src
|
||||
BASE_URL=null,
|
||||
LMS_BASE_URL=null,
|
||||
LOGIN_URL=null,
|
||||
LOGOUT_URL=null,
|
||||
CSRF_TOKEN_API_PATH=null,
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT=null,
|
||||
DATA_API_BASE_URL=null,
|
||||
SEGMENT_KEY=null,
|
||||
FEATURE_FLAGS={},
|
||||
ACCESS_TOKEN_COOKIE_NAME=null,
|
||||
CSRF_COOKIE_NAME='csrftoken',
|
||||
NEW_RELIC_APP_ID=null,
|
||||
NEW_RELIC_LICENSE_KEY=null,
|
||||
SITE_NAME='',
|
||||
MARKETING_SITE_BASE_URL=null,
|
||||
SUPPORT_URL=null,
|
||||
CONTACT_URL=null,
|
||||
OPEN_SOURCE_URL=null,
|
||||
TERMS_OF_SERVICE_URL=null,
|
||||
PRIVACY_POLICY_URL=null,
|
||||
FACEBOOK_URL=null,
|
||||
TWITTER_URL=null,
|
||||
YOU_TUBE_URL=null,
|
||||
LINKED_IN_URL=null,
|
||||
REDDIT_URL=null,
|
||||
APPLE_APP_STORE_URL=null,
|
||||
GOOGLE_PLAY_URL=null,
|
||||
ENTERPRISE_MARKETING_URL=null,
|
||||
ENTERPRISE_MARKETING_UTM_SOURCE=null,
|
||||
ENTERPRISE_MARKETING_UTM_CAMPAIGN=null,
|
||||
ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM=null,
|
||||
|
||||
BULK_MANAGEMENT_SPECIAL_ACCESS_COURSE_IDS=null,
|
||||
42
.env.development
Normal file
42
.env.development
Normal file
@@ -0,0 +1,42 @@
|
||||
NODE_ENV='development'
|
||||
PORT=1994
|
||||
BASE_URL='localhost:1994'
|
||||
LMS_BASE_URL='http://localhost:18000'
|
||||
LOGIN_URL='http://localhost:18000/login'
|
||||
LOGOUT_URL='http://localhost:18000/login'
|
||||
LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
|
||||
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
|
||||
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
|
||||
LOGO_POWERED_BY_OPEN_EDX_URL_SVG=https://edx-cdn.org/v3/stage/open-edx-tag.svg
|
||||
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
|
||||
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||
SITE_NAME=localhost
|
||||
|
||||
DATA_API_BASE_URL='http://localhost:8000'
|
||||
// LMS_CLIENT_ID should match the lms DOT client application id your LMS containe
|
||||
LMS_CLIENT_ID='login-service-client-id'
|
||||
SEGMENT_KEY=null
|
||||
FEATURE_FLAGS={}
|
||||
CSRF_COOKIE_NAME='csrftoken'
|
||||
MARKETING_SITE_BASE_URL='http://localhost:18000'
|
||||
SUPPORT_URL='http://localhost:18000/support'
|
||||
CONTACT_URL='http://localhost:18000/contact'
|
||||
OPEN_SOURCE_URL='http://localhost:18000/openedx'
|
||||
TERMS_OF_SERVICE_URL='http://localhost:18000/terms-of-service'
|
||||
PRIVACY_POLICY_URL='http://localhost:18000/privacy-policy'
|
||||
FACEBOOK_URL='https://www.facebook.com'
|
||||
TWITTER_URL='https://twitter.com'
|
||||
YOU_TUBE_URL='https://www.youtube.com'
|
||||
LINKED_IN_URL='https://www.linkedin.com'
|
||||
REDDIT_URL='https://www.reddit.com'
|
||||
APPLE_APP_STORE_URL='https://www.apple.com/ios/app-store/'
|
||||
GOOGLE_PLAY_URL='https://play.google.com/store'
|
||||
ENTERPRISE_MARKETING_URL='http://example.com'
|
||||
ENTERPRISE_MARKETING_UTM_SOURCE='example.com'
|
||||
ENTERPRISE_MARKETING_UTM_CAMPAIGN='example.com Referral'
|
||||
ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM='Footer'
|
||||
|
||||
BULK_MANAGEMENT_SPECIAL_ACCESS_COURSE_IDS=null
|
||||
@@ -1,3 +1,5 @@
|
||||
coverage/*
|
||||
dist/
|
||||
node_modules/
|
||||
src/postcss.config.js
|
||||
src/segment.js
|
||||
|
||||
24
.eslintrc
24
.eslintrc
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"extends": "eslint-config-edx",
|
||||
"parser": "babel-eslint",
|
||||
"rules": {
|
||||
"import/no-extraneous-dependencies": [
|
||||
"error",
|
||||
{
|
||||
"devDependencies": [
|
||||
"config/*.js",
|
||||
"**/*.test.jsx",
|
||||
"**/*.test.js"
|
||||
]
|
||||
}
|
||||
],
|
||||
// https://github.com/evcohen/eslint-plugin-jsx-a11y/issues/340#issuecomment-338424908
|
||||
"jsx-a11y/anchor-is-valid": [ "error", {
|
||||
"components": [ "Link" ],
|
||||
"specialLink": [ "to" ]
|
||||
}]
|
||||
},
|
||||
"env": {
|
||||
"jest": true
|
||||
}
|
||||
}
|
||||
14
.eslintrc.js
Normal file
14
.eslintrc.js
Normal file
@@ -0,0 +1,14 @@
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
|
||||
const config = createConfig('eslint');
|
||||
|
||||
config.settings = {
|
||||
"import/resolver": {
|
||||
node: {
|
||||
paths: ["src", "node_modules"],
|
||||
extensions: [".js", ".jsx"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
6
.github/CODEOWNERS
vendored
Normal file
6
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
# Code owners for frontend-app-gradebook, editable gradebook micro-frontend (MFE)
|
||||
|
||||
# These owners will be the default owners for everything in
|
||||
# the repo. Unless a later match takes precedence, they will
|
||||
# be requested for review when someone opens a pull request.
|
||||
* @edx/masters-devs-gta
|
||||
29
.github/pull_request_template.md
vendored
Normal file
29
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
**TL;DR -** [ A short summary of what this PR does and why ]
|
||||
|
||||
JIRA: [JIRA-XXXX](https://openedx.atlassian.net/browse/JIRA-XXXX)
|
||||
|
||||
**What changed?**
|
||||
|
||||
- [ More in depth breakdown of changes ]
|
||||
- [ Peripheral things that got changed ]
|
||||
- [ etc... ]
|
||||
|
||||
**Developer Checklist**
|
||||
- [ ] Test suites passing
|
||||
- [ ] Documentation and test plan updated, if applicable
|
||||
- [ ] Received code-owner approving review
|
||||
- [ ] Bumped version number [package.json](../package.json)
|
||||
|
||||
**Testing Instructions**
|
||||
|
||||
[ How should a reviewer test this PR? ]
|
||||
|
||||
**Reviewer Checklist**
|
||||
|
||||
Collectively, these should be completed by reviewers of this PR:
|
||||
|
||||
- [ ] I've done a visual code review
|
||||
- [ ] I've tested the new functionality
|
||||
|
||||
|
||||
FYI: @edx/masters-devs-gta
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,6 +1,5 @@
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
.idea
|
||||
node_modules
|
||||
npm-debug.log
|
||||
coverage
|
||||
@@ -12,3 +11,9 @@ dist/
|
||||
|
||||
### Emacs ###
|
||||
*~
|
||||
*.swo
|
||||
*.swp
|
||||
|
||||
### Development environments ###
|
||||
.idea
|
||||
.vscode
|
||||
|
||||
12
.travis.yml
12
.travis.yml
@@ -1,21 +1,14 @@
|
||||
language: node_js
|
||||
node_js:
|
||||
- lts/*
|
||||
cache:
|
||||
directories:
|
||||
- "~/.npm"
|
||||
node_js: 12
|
||||
notifications:
|
||||
email:
|
||||
recipients:
|
||||
- adusenbery@edx.org
|
||||
- rreilly@edx.org
|
||||
- schen@edx.org
|
||||
- masters-grades@edx.org
|
||||
on_success: never
|
||||
on_failure: always
|
||||
webhooks: https://www.travisbuddy.com/
|
||||
on_success: never
|
||||
before_install:
|
||||
- npm install -g npm@latest
|
||||
- npm install -g greenkeeper-lockfile@1.14.0
|
||||
install:
|
||||
- npm ci
|
||||
@@ -23,6 +16,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:
|
||||
|
||||
29
Dockerfile
29
Dockerfile
@@ -1,29 +0,0 @@
|
||||
# Copied from https://github.com/BretFisher/node-docker-good-defaults/blob/master/Dockerfile
|
||||
|
||||
FROM node:8.9.3
|
||||
|
||||
# Create app directory
|
||||
RUN mkdir -p /edx/app
|
||||
|
||||
ARG NODE_ENV=production
|
||||
ENV NODE_ENV $NODE_ENV
|
||||
|
||||
ARG PORT=80
|
||||
ENV PORT $PORT
|
||||
EXPOSE $PORT 1991
|
||||
|
||||
WORKDIR /edx
|
||||
# Install app dependencies
|
||||
# A wildcard is used to ensure both package.json AND package-lock.json are copied
|
||||
# where available (npm@5+)
|
||||
COPY package*.json ./
|
||||
|
||||
# If you are building your code for production
|
||||
# RUN npm install --only=production
|
||||
RUN npm install
|
||||
ENV PATH /edx/app/node_modules/.bin:$PATH
|
||||
|
||||
WORKDIR /edx/app
|
||||
COPY . /edx/app
|
||||
|
||||
ENTRYPOINT npm install && npm run start
|
||||
149
LICENSE
149
LICENSE
@@ -1,23 +1,21 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
@@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
@@ -72,7 +60,7 @@ modification follow.
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
@@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
@@ -635,40 +633,29 @@ the "copyright" line and a pointer to where the full notice is found.
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<http://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
32
Makefile
32
Makefile
@@ -1,35 +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:
|
||||
docker exec -it edx.gradebook jest
|
||||
npm run test
|
||||
|
||||
96
README.md
96
README.md
@@ -1,40 +1,82 @@
|
||||
[](https://travis-ci.org/edx/gradebook) [](https://coveralls.io/github/edx/gradebook)
|
||||
[](@edx/gradebook)
|
||||
[](@edx/gradebook)
|
||||
[](@edx/gradebook)
|
||||
[](https://travis-ci.com/edx/frontend-app-gradebook) [](https://coveralls.io/github/edx/frontend-app-gradebook)
|
||||
[](@edx/frontend-app-gradebook)
|
||||
[](@edx/frontend-app-gradebook)
|
||||
[](@edx/frontend-app-gradebook)
|
||||
[](https://github.com/semantic-release/semantic-release)
|
||||
|
||||
# gradebook
|
||||
# Gradebook
|
||||
|
||||
Please tag **@edx/educator-neem** on any PRs or issues.
|
||||
Gradebook allows course staff to view, filter, and override subsection grades for a course. Additionally for Masters courses, Gradebook enables bulk management of subsection grades.
|
||||
|
||||
## Introduction
|
||||
Jump to:
|
||||
|
||||
The front-end of our editable Gradebook feature.
|
||||
- [Should I use Gradebook in my course?](#should-i-use-gradebook-in-my-course)
|
||||
- [Quickstart](#quickstart)
|
||||
|
||||
## Usage
|
||||
For existing documentation see:
|
||||
|
||||
- Basic Usage: [Review Learner Grades (read-the-docs)](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/student_progress/course_grades.html#review-learner-grades-on-the-instructor-dashboard)
|
||||
- Bulk Grade Management: [Override Learner Subsection Scores in Bulk (read-the-docs)](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/student_progress/course_grades.html#override-learner-subsection-scores-in-bulk)
|
||||
|
||||
## Should I use Gradebook in my course?
|
||||
|
||||
### What does this offer over the legacy gradebook?
|
||||
|
||||

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

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

|
||||
|
||||
### What does the legacy gradebook offer that this project does not?
|
||||
|
||||
This project does not (yet, at least) create any graphs, which the traditional gradebook does. It also does not give
|
||||
quick links to the problems for the instructor to visit. It expects the instructor to be familiar with the problems they
|
||||
are grading and which unit they refer to.
|
||||
|
||||
The gradebook is expected to be much more performant for larger numbers of students as well. The Instructor Dashboard
|
||||
link for the legacy gradebook reports that "this feature is available only to courses with a small number of enrolled
|
||||
learners." However, this project comes with no such warning.
|
||||
|
||||
### Who should not change to this gradebook?
|
||||
|
||||
Groups whose instructors need not ever manually override grades do not need this project, but may not be any worse off
|
||||
depending on their needs. Instructors that expect to review grades infrequently enough that not having a direct link
|
||||
to the problem in question will have a worse UX than the legacy gradebook provides. Instructors that rely on the graphs
|
||||
generated by the current gradebook might find the lack of autogenerated graphs to be frustrating.
|
||||
|
||||
## Quickstart
|
||||
|
||||
### Installation
|
||||
|
||||
To install gradebook into your project:
|
||||
```
|
||||
npm i --save @edx/gradebook
|
||||
npm i --save @edx/frontend-app-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/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.
|
||||
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:1991`, 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`:
|
||||
Assuming you've got the UI running at `http://localhost:1994`, you can configure the LMS in edx-platform
|
||||
to point to your local gradebook from the instructor dashboard by putting this setting in `lms/env/private.py`:
|
||||
```
|
||||
WRITABLE_GRADEBOOK_URL = 'http://localhost:1991'
|
||||
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:
|
||||
@@ -44,10 +86,22 @@ check the ``enabled`` and ``enabled for all courses`` boxes.
|
||||
|
||||
2. Waffle > Switches. Add the ``grades.assume_zero_grade_if_absent`` switch and make it active.
|
||||
|
||||
3. Waffle_utils > Waffle flag course overrides. You want to activate this flag for any course
|
||||
in which you'd like to enable the gradebook. Add a course override flag using a course id and the flag name
|
||||
``grades.writable_gradebook``. Make sure to check the ``enabled`` box. Alternatively, you could add this as a
|
||||
regular waffle flag to enable the gradebook for all courses.
|
||||
3. Waffle_utils > Waffle flag course overrides. Activate waffle flags for courses where you want to enable Gradebook functionality:
|
||||
- Enable Gradebook by adding the ``grades.writable_gradebook`` add checking the ``enabled`` box.
|
||||
- Enable Bulk Grade Management by adding the ``grades.bulk_management`` flag and checking the ``enabled`` box.
|
||||
|
||||
Alternatively, you could add these as regular waffle flags to enable the functionality for all courses.
|
||||
|
||||
**NOTE:** IF the above flags are not configured correctly, the gradebook may appear to work, but will return bogus
|
||||
numbers for grades. If your gradebook isn't accepting your changes, or the changes aren't resulting in sane,
|
||||
recalculated grade values, verify you've set all flags correctly.
|
||||
|
||||
## Running tests
|
||||
|
||||
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
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 3.0 KiB |
3
babel.config.js
Normal file
3
babel.config.js
Normal file
@@ -0,0 +1,3 @@
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
|
||||
module.exports = createConfig('babel');
|
||||
@@ -1,15 +0,0 @@
|
||||
// This is the common Webpack config. The dev and prod Webpack configs both
|
||||
// inherit config defined here.
|
||||
const path = require('path');
|
||||
|
||||
module.exports = {
|
||||
entry: {
|
||||
app: path.resolve(__dirname, '../src/index.jsx'),
|
||||
},
|
||||
output: {
|
||||
path: path.resolve(__dirname, '../dist'),
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.js', '.jsx'],
|
||||
},
|
||||
};
|
||||
@@ -1,128 +0,0 @@
|
||||
// This is the dev Webpack config. All settings here should prefer a fast build
|
||||
// time at the expense of creating larger, unoptimized bundles.
|
||||
const Merge = require('webpack-merge');
|
||||
const path = require('path');
|
||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||
const webpack = require('webpack');
|
||||
|
||||
const commonConfig = require('./webpack.common.config.js');
|
||||
|
||||
module.exports = Merge.smart(commonConfig, {
|
||||
mode: 'development',
|
||||
entry: [
|
||||
// enable react's custom hot dev client so we get errors reported in the browser
|
||||
require.resolve('react-dev-utils/webpackHotDevClient'),
|
||||
path.resolve(__dirname, '../src/index.jsx'),
|
||||
],
|
||||
module: {
|
||||
// Specify file-by-file rules to Webpack. Some file-types need a particular kind of loader.
|
||||
rules: [
|
||||
// The babel-loader transforms newer ES2015+ syntax to older ES5 for older browsers.
|
||||
// Babel is configured with the .babelrc file at the root of the project.
|
||||
{
|
||||
test: /\.(js|jsx)$/,
|
||||
include: [
|
||||
path.resolve(__dirname, '../src'),
|
||||
],
|
||||
loader: 'babel-loader',
|
||||
options: {
|
||||
// Caches result of loader to the filesystem. Future builds will attempt to read from the
|
||||
// cache to avoid needing to run the expensive recompilation process on each run.
|
||||
cacheDirectory: true,
|
||||
},
|
||||
},
|
||||
// We are not extracting CSS from the javascript bundles in development because extracting
|
||||
// prevents hot-reloading from working, it increases build time, and we don't care about
|
||||
// flash-of-unstyled-content issues in development.
|
||||
{
|
||||
test: /(.scss|.css)$/,
|
||||
use: [
|
||||
'style-loader', // creates style nodes from JS strings
|
||||
{
|
||||
loader: 'css-loader', // translates CSS into CommonJS
|
||||
options: {
|
||||
sourceMap: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
loader: 'sass-loader', // compiles Sass to CSS
|
||||
options: {
|
||||
sourceMap: true,
|
||||
includePaths: [
|
||||
path.join(__dirname, '../node_modules'),
|
||||
path.join(__dirname, '../src'),
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
// Webpack, by default, uses the url-loader for images and fonts that are required/included by
|
||||
// files it processes, which just base64 encodes them and inlines them in the javascript
|
||||
// bundles. This makes the javascript bundles ginormous and defeats caching so we will use the
|
||||
// file-loader instead to copy the files directly to the output directory.
|
||||
{
|
||||
test: /\.(woff2?|ttf|svg|eot)(\?v=\d+\.\d+\.\d+)?$/,
|
||||
loader: 'file-loader',
|
||||
},
|
||||
{
|
||||
test: /\.(jpe?g|png|gif|ico)(\?v=\d+\.\d+\.\d+)?$/,
|
||||
use: [
|
||||
'file-loader',
|
||||
{
|
||||
loader: 'image-webpack-loader',
|
||||
options: {
|
||||
optimizationlevel: 7,
|
||||
mozjpeg: {
|
||||
progressive: true,
|
||||
},
|
||||
gifsicle: {
|
||||
interlaced: false,
|
||||
},
|
||||
pngquant: {
|
||||
quality: '65-90',
|
||||
speed: 4,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
// Specify additional processing or side-effects done on the Webpack output bundles as a whole.
|
||||
plugins: [
|
||||
// Generates an HTML file in the output directory.
|
||||
new HtmlWebpackPlugin({
|
||||
inject: true, // Appends script tags linking to the webpack bundles at the end of the body
|
||||
template: path.resolve(__dirname, '../public/index.html'),
|
||||
}),
|
||||
new webpack.EnvironmentPlugin({
|
||||
NODE_ENV: 'development',
|
||||
BASE_URL: 'localhost:1991',
|
||||
LMS_BASE_URL: 'http://localhost:18000',
|
||||
LOGIN_URL: 'http://localhost:18000/login',
|
||||
LOGOUT_URL: 'http://localhost:18000/login',
|
||||
CSRF_TOKEN_API_PATH: '/csrf/api/v1/token',
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT: 'http://localhost:18000/login_refresh',
|
||||
DATA_API_BASE_URL: 'http://localhost:8000',
|
||||
// LMS_CLIENT_ID should match the lms DOT client application id your LMS container
|
||||
LMS_CLIENT_ID: 'login-service-client-id',
|
||||
SEGMENT_KEY: null,
|
||||
FEATURE_FLAGS: {},
|
||||
ACCESS_TOKEN_COOKIE_NAME: 'edx-jwt-cookie-header-payload',
|
||||
CSRF_COOKIE_NAME: 'csrftoken',
|
||||
}),
|
||||
// when the --hot option is not passed in as part of the command
|
||||
// the HotModuleReplacementPlugin has to be specified in the Webpack configuration
|
||||
// https://webpack.js.org/configuration/dev-server/#devserver-hot
|
||||
new webpack.HotModuleReplacementPlugin(),
|
||||
],
|
||||
// This configures webpack-dev-server which serves bundles from memory and provides live
|
||||
// reloading.
|
||||
devServer: {
|
||||
host: '0.0.0.0',
|
||||
port: 1991,
|
||||
historyApiFallback: true,
|
||||
hot: true,
|
||||
inline: true,
|
||||
},
|
||||
});
|
||||
@@ -1,131 +0,0 @@
|
||||
// This is the prod Webpack config. All settings here should prefer smaller,
|
||||
// optimized bundles at the expense of a longer build time.
|
||||
const Merge = require('webpack-merge');
|
||||
const commonConfig = require('./webpack.common.config.js');
|
||||
const path = require('path');
|
||||
const webpack = require('webpack');
|
||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
||||
|
||||
module.exports = Merge.smart(commonConfig, {
|
||||
mode: 'production',
|
||||
devtool: 'source-map',
|
||||
output: {
|
||||
filename: '[name].[chunkhash].js',
|
||||
path: path.resolve(__dirname, '../dist'),
|
||||
},
|
||||
module: {
|
||||
// Specify file-by-file rules to Webpack. Some file-types need a particular kind of loader.
|
||||
rules: [
|
||||
// The babel-loader transforms newer ES2015+ syntax to older ES5 for older browsers.
|
||||
// Babel is configured with the .babelrc file at the root of the project.
|
||||
{
|
||||
test: /\.(js|jsx)$/,
|
||||
include: [
|
||||
path.resolve(__dirname, '../src'),
|
||||
],
|
||||
loader: 'babel-loader',
|
||||
},
|
||||
// Webpack, by default, includes all CSS in the javascript bundles. Unfortunately, that means:
|
||||
// a) The CSS won't be cached by browsers separately (a javascript change will force CSS
|
||||
// re-download). b) Since CSS is applied asyncronously, it causes an ugly
|
||||
// flash-of-unstyled-content.
|
||||
//
|
||||
// To avoid these problems, we extract the CSS from the bundles into separate CSS files that
|
||||
// can be included as <link> tags in the HTML <head> manually.
|
||||
//
|
||||
// We will not do this in development because it prevents hot-reloading from working and it
|
||||
// increases build time.
|
||||
{
|
||||
test: /(.scss|.css)$/,
|
||||
use: [
|
||||
MiniCssExtractPlugin.loader,
|
||||
{
|
||||
loader: 'css-loader', // translates CSS into CommonJS
|
||||
options: {
|
||||
sourceMap: true,
|
||||
minimize: true,
|
||||
},
|
||||
},
|
||||
'postcss-loader',
|
||||
{
|
||||
loader: 'sass-loader', // compiles Sass to CSS
|
||||
options: {
|
||||
sourceMap: true,
|
||||
includePaths: [
|
||||
path.join(__dirname, '../node_modules'),
|
||||
path.join(__dirname, '../src'),
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
// Webpack, by default, uses the url-loader for images and fonts that are required/included by
|
||||
// files it processes, which just base64 encodes them and inlines them in the javascript
|
||||
// bundles. This makes the javascript bundles ginormous and defeats caching so we will use the
|
||||
// file-loader instead to copy the files directly to the output directory.
|
||||
{
|
||||
test: /\.(woff2?|ttf|svg|eot)(\?v=\d+\.\d+\.\d+)?$/,
|
||||
loader: 'file-loader',
|
||||
},
|
||||
{
|
||||
test: /\.(jpe?g|png|gif|ico)(\?v=\d+\.\d+\.\d+)?$/,
|
||||
use: [
|
||||
'file-loader',
|
||||
{
|
||||
loader: 'image-webpack-loader',
|
||||
options: {
|
||||
optimizationlevel: 7,
|
||||
mozjpeg: {
|
||||
progressive: true,
|
||||
},
|
||||
gifsicle: {
|
||||
interlaced: false,
|
||||
},
|
||||
pngquant: {
|
||||
quality: '65-90',
|
||||
speed: 4,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
// New in Webpack 4. Replaces CommonChunksPlugin. Extract common modules among all chunks to one
|
||||
// common chunk and extract the Webpack runtime to a single runtime chunk.
|
||||
optimization: {
|
||||
runtimeChunk: 'single',
|
||||
splitChunks: {
|
||||
chunks: 'all',
|
||||
},
|
||||
},
|
||||
// Specify additional processing or side-effects done on the Webpack output bundles as a whole.
|
||||
plugins: [
|
||||
// Writes the extracted CSS from each entry to a file in the output directory.
|
||||
new MiniCssExtractPlugin({
|
||||
filename: '[name].[chunkhash].css',
|
||||
}),
|
||||
// Generates an HTML file in the output directory.
|
||||
new HtmlWebpackPlugin({
|
||||
inject: true, // Appends script tags linking to the webpack bundles at the end of the body
|
||||
template: path.resolve(__dirname, '../public/index.html'),
|
||||
}),
|
||||
new webpack.EnvironmentPlugin({
|
||||
NODE_ENV: 'production',
|
||||
BASE_URL: null,
|
||||
LMS_BASE_URL: null,
|
||||
LOGIN_URL: null,
|
||||
LOGOUT_URL: null,
|
||||
CSRF_TOKEN_API_PATH: null,
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT: null,
|
||||
DATA_API_BASE_URL: null,
|
||||
SEGMENT_KEY: null,
|
||||
FEATURE_FLAGS: {},
|
||||
ACCESS_TOKEN_COOKIE_NAME: null,
|
||||
CSRF_COOKIE_NAME: 'csrftoken',
|
||||
NEW_RELIC_APP_ID: null,
|
||||
NEW_RELIC_LICENSE_KEY: null,
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
version: "2"
|
||||
services:
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
- NODE_ENV=development
|
||||
container_name: edx.gradebook
|
||||
image: edxops/front-end-cookie-cutter:latest
|
||||
volumes:
|
||||
- .:/edx/app:delegated
|
||||
- notused:/edx/app/node_modules
|
||||
ports:
|
||||
- "1991:1991"
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
|
||||
volumes:
|
||||
notused:
|
||||
46
documentation/decisions/0001-update-api-usage.rst
Normal file
46
documentation/decisions/0001-update-api-usage.rst
Normal file
@@ -0,0 +1,46 @@
|
||||
Usage of the bulk-update API
|
||||
============================
|
||||
|
||||
Context
|
||||
=======
|
||||
|
||||
The LMS Grades API exposes a set of Gradebook-related endpoints:
|
||||
https://github.com/edx/edx-platform/blob/master/lms/djangoapps/grades/api/v1/gradebook_views.py
|
||||
The ``bulk-update`` endpoint defined therein allows for the creation/modification of subsection
|
||||
grades for multiple users and sections in a single request. This allows clients of the API to limit
|
||||
the number of network requests made and to more easily manage client-side data. Moreover,
|
||||
the course grade updates that occur during calls to this API are synchronous - the entire update operation
|
||||
is completed before a response is given to the client.
|
||||
|
||||
For decisions made about the implementation of this API, see:
|
||||
https://github.com/edx/edx-platform/blob/master/lms/djangoapps/grades/docs/decisions/0001-gradebook-api.rst
|
||||
|
||||
Decision
|
||||
========
|
||||
|
||||
The Gradebook front-end will post data about a single subsection and user in a single request
|
||||
to the ``bulk-update`` API. That is, we currently need only the "update" aspect of this
|
||||
endpoint, and not the "bulk" aspect, for satisfying the requirements of the current UX.
|
||||
|
||||
Status
|
||||
======
|
||||
|
||||
Accepted (circa December 2018)
|
||||
|
||||
Consequences
|
||||
============
|
||||
|
||||
This is a scenario in which the implementation of the API is coupled to the
|
||||
UX that depends on the API. Because the course grade update is synchronous, it means
|
||||
the API response can contain the updated subsection and course grade data. Because
|
||||
a response from the API contains this data, the UI can operate in a very familiar way:
|
||||
|
||||
- A user clicks a button to submit a request with grade update data to the update endpoint.
|
||||
- On the server, the subsection and course grades are modified.
|
||||
- In the meantime, the client-side user looks at a spinner.
|
||||
- A response is returned with updated data and the spinner goes away.
|
||||
- Updated data is displayed to the user, along with a message indicative of the update.
|
||||
|
||||
If the update becomes asynchronous, the user experience outlined above has to change.
|
||||
Because a single call to this endpoint updates grades data for only a single user,
|
||||
the endpoint does not necessarily have to utilize an asynchronous operation at this time.
|
||||
BIN
documentation/screenshots/grade-editing.png
Normal file
BIN
documentation/screenshots/grade-editing.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 254 KiB |
BIN
documentation/screenshots/grade-filtering.png
Normal file
BIN
documentation/screenshots/grade-filtering.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 240 KiB |
BIN
documentation/screenshots/grade-listings.png
Normal file
BIN
documentation/screenshots/grade-listings.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 319 KiB |
100
documentation/testing/test-plan.md
Normal file
100
documentation/testing/test-plan.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# Test Plan
|
||||
|
||||
Designed to be a catalog of major Gradebook workflows to aid in testing. This should be kept up-to-date with new feature changes.
|
||||
|
||||
## Quickstart
|
||||
|
||||
Check that the items below are complete and continue to [Workflow Tests](#workflow-tests). Otherwise, followed the detailed setup in [test-setup.md](./test-setup.md).
|
||||
|
||||
- [ ] Course set up with graded content.
|
||||
- [ ] Gradebook & feature toggles set up for course.
|
||||
- [ ] Course has a Master's track for testing Master's-only features.
|
||||
- [ ] Different types of students enrolled in course (e.g. Master's, TA's).
|
||||
- [ ] Gradebook running.
|
||||
|
||||
## Workflow Tests
|
||||
|
||||
Visit a course as an instructor/staff then **Instructor** tab > **Student Admin** sub-tab > click **Show Gradebook**. Should navigate to `<root-url>:1994/{course-id}`.
|
||||
|
||||
Confirm the following workflows:
|
||||
|
||||
- [ ] Grades table results can be filtered from the "Filter" panel.
|
||||
- The "Edit Filters" button renders for all courses.
|
||||
- Click the "Edit Filters" button to open the "Filter" panel.
|
||||
- [ ] Filter panel shows the sections: Assignments, Overall Grade, Student Groups, Include Course Team Members.
|
||||
- **Note:** Filters are cumulative and act with other applied filters.
|
||||
- Assignments pane
|
||||
- [ ] Applying the "Assignment Types" filter limits the assignment columns show in the grades table to the selected assignment types.
|
||||
- [ ] Applying an "Assignment" filter shows only the selected assignment column in the grades table.
|
||||
- [ ] With an "Assignment" filter already selected, setting a "Min/Max Grade" filter shows only student rows with grades for the assignment within the filtered range.
|
||||
- Overall Grade pane
|
||||
- [ ] Applying a "Min/Max Grade" filter shows only students with Total Course Grades within the filtered range.
|
||||
- Student Groups pane
|
||||
- [ ] Applying a "Tracks" filter shows only student rows matching the selected track.
|
||||
- [ ] Applying a "Cohorts" filter shows only student rows matching the selected cohort.
|
||||
- Include Course Team Members pane
|
||||
- By default, any user with a course role (e.g. staff, beta testers, TA's) are hidden from the grades table.
|
||||
- [ ] Selecting "Include Course Team Members" shows course team members in the grades table.
|
||||
- [ ] Deselecting "Include Course Team Members" shows only students without course roles in the grades table.
|
||||
|
||||
- [ ] Users can be searched/filtered using the Search box.
|
||||
- The Search Box renders for all courses.
|
||||
- [ ] Entering characters into the Search Box filters students on top of already applied filters.
|
||||
- Note: characters can appear anywhere in a name or email, even though emails are only shown for masters-track students. It doesn't appear that search actually works for student keys.
|
||||
|
||||
- [ ] Grades table "Score View" allows selecting how scores are displayed.
|
||||
- [ ] The "Score View" selector renders with the options: Absolute, Percent.
|
||||
- [ ] Changing the "Score View" dropdown to "Percent" shows scores as percentages in the assignment columns (note that scores can be over 100%).
|
||||
- [ ] Changing the "Score View" dropdown to "Absolute" shows scores as {awarded-points}/{possible-points} values, rounded to 2 decimal points.
|
||||
- [ ] For unattempted problems score shows '0'.
|
||||
- [ ] For attempted problems, score always shows an {awarded-points}/{possible-points} value.
|
||||
- [ ] "Total Course Grade" always shows scores as percentages (including 0% for unattempted).
|
||||
|
||||
- [ ] Grades table displays correctly.
|
||||
- [ ] The grades table shows with columns: Username, Email, {numbered-assignments}, Total.
|
||||
- [ ] Usernames appear in the "Username" column.
|
||||
- [ ] Student external keys (where applicable) also appear in the "Username" column.
|
||||
- [ ] Student emails appear in the "Email" column only for masters-track students.
|
||||
- [ ] Assignment scores show in their respective assignment columns.
|
||||
- [ ] Total course grade shows in the "Total Course Grade" column.
|
||||
|
||||
- [ ] Grade overrides can be applied.
|
||||
- [ ] Clicking on an assignment score in the grades table opens the "Edit Grades" modal.
|
||||
- [ ] "Assignment name", "Student username", "Original grade", and "Current grade" display in the modal.
|
||||
- [ ] A history of grade overrides including "Date", "Grader", "Reason", and "Adjusted Grade" shows (if the subsection was previously overridden).
|
||||
- [ ] An entry with the current time appears in the table with areas to enter adjusted grades and reasons for adjusting.
|
||||
- Enter an "Adjusted Grade" and "Reason" for the override.
|
||||
- [ ] Modal can be navigated away from by clicking outside the modal, clicking the "x" button, or hitting "Cancel".
|
||||
- [ ] Clicking "Save Grade" applies the override, shows the successful "grade has been edited" banner and updates score in grades table (may take a few seconds).
|
||||
- [ ] Opening back up the "Edit Grades" modal shows the change as an entry in the override history table.
|
||||
|
||||
- [ ] *Masters only*: "Bulk Management" allows overriding grades in bulk.
|
||||
- Open a non-masters-track course.
|
||||
- [ ] Verify that the "Bulk Management" tab does not appear.
|
||||
- [ ] Verify that the "Bulk Management" button does not appear.
|
||||
- Open a masters-track course.
|
||||
- [ ] Verify that the "Bulk Management" tab appears to the right of the "Grades" tab.
|
||||
- [ ] Verify that the "Bulk Management" button appears.
|
||||
- Click the "Bulk Management" button. This downloads existing student/assignment info.
|
||||
- [ ] Open the downloaded CSV and verify that students and assignments in the file match applied filters/searches.
|
||||
- Add values in the "new_override-{subsection-short-id}" columns for student grades to be overridden and save the CSV file.
|
||||
- [ ] Clicking the "Bulk Management" tab shows the Bulk Management page.
|
||||
- [ ] The bulk management history table appears with columns: "Gradebook", "Download Summary", "Who", "When".
|
||||
- [ ] Previous bulk management imports (if applicable) appear in the table.
|
||||
- Click the "Import Grades" button and select the modified CSV file.
|
||||
- [ ] Verify that the "CSV processing" banner appears.
|
||||
- Wait for processing to complete and reload the page. (Can take seconds to minutes depending on environment and size of the override.)
|
||||
- Navigate back to the "Bulk Management" tab.
|
||||
- [ ] Verify that a new entry appears in the results table indicating how many students were affected by the bulk grade change.
|
||||
- Click the "Download Summary" link to see the summary of changes from the bulk grade changes.
|
||||
- [ ] Verify that students are shown with modified subsections and actions: "No Action" for unchanged users, "Success" for successful overrides.
|
||||
|
||||
- [ ] *Masters only*: Interventions report shows student activity in the course.
|
||||
- Open a non-masters-track course.
|
||||
- [ ] Verify that the "Interventions" tab does not appear.
|
||||
- [ ] Verify that the "Interventions" button does not appear.
|
||||
- Open a masters-track course.
|
||||
- [ ] Verify that the "Interventions" tab appears to the right of the "Grades" tab.
|
||||
- [ ] Verify that the "Interventions" button appears.
|
||||
- Click on the "Interventions" button to generate a CSV students and activity info.
|
||||
- Open the interventions report and verify student info and activity info appear.
|
||||
52
documentation/testing/test-setup.md
Normal file
52
documentation/testing/test-setup.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# Test Setup
|
||||
|
||||
Instructions for setting up environments and data for testing Gradebook.
|
||||
|
||||
## Set up a course with graded content
|
||||
|
||||
A course with graded content is the first prerequisite to testing. Use an existing course (e.g. the DemoX Demonstration Course in Devstack) or see [Building and Running an edX Course > Developing Your Course](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/developing_course/index.html) for notes on how to develop a course from scratch.
|
||||
|
||||
Notably, the course needs a grading policy and subsections with scoreable content.
|
||||
After creating subsections with content, they need to be configured with an "Assignment Type" to be included in grading.
|
||||
|
||||
Suggested resources:
|
||||
- [Establishing a Grading Policy For Your Course](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/grading/index.html)
|
||||
- [Adding Exercises and Tools](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/grading/index.html)
|
||||
- [Set the Assignment Type and Due Date for a Subsection](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/developing_course/course_subsections.html#set-the-assignment-type-and-due-date-for-a-subsection)
|
||||
|
||||
## Enable Gradebook and feature toggles for course
|
||||
|
||||
See README.md #Quickstart for more detailed instructions.
|
||||
|
||||
As an admin user, visit Django Admin (`{lms-url}/admin`) to modify features.
|
||||
- In Grades > Persistent Grades Enabled flag, click "Add persistent grades enabled flag"
|
||||
- [ ] Enable the flag globally or for the course and click "Save"
|
||||
- In Django-Waffle > Switches, click "Add switch"
|
||||
- [ ] Set name to `grades.assume_zero_grade_if_absent`, select "Active", and click "Save"
|
||||
- In Waffle_Utils > Waffle flag course overrides:
|
||||
- [ ] Add a new flag called `grades.writeable_gradebook`, select "Force On", and enable it for your course
|
||||
- [ ] Add a new flag called `grades.bulk_management`, select "Force On", and enable it for your course
|
||||
|
||||
## Create a Master's track for testing Master's-only features
|
||||
|
||||
[source](https://openedx.atlassian.net/wiki/spaces/MS/pages/1453818012/Add+a+learner+into+a+master+s+track)
|
||||
|
||||
Add a Master's track in your course:
|
||||
- As an admin user, go to Django Admin (`{lms-url}/admin`) > Course Modes and add a new course mode
|
||||
- Set the Mode to "Master's"
|
||||
- Set any valid price and currency values
|
||||
- Click "Save"
|
||||
|
||||
Enroll a student in the Master's track:
|
||||
- As a staff/admin user, go to `{lms-url}/support/enrollment`
|
||||
- Search for the username or email of student to enroll
|
||||
- In the results table row matching the user/course, click the "Change Enrollment" button
|
||||
- Select the "Master's" enrollment mode and click "Submit enrollment change"
|
||||
|
||||
## Setup different types of students in course
|
||||
|
||||
To fully test features the course should have at least:
|
||||
- [ ] An audit-track student
|
||||
- [ ] A master's-track student
|
||||
- [ ] A staff member
|
||||
- [ ] A non-staff user
|
||||
11
jest.config.js
Normal file
11
jest.config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
|
||||
module.exports = createConfig('jest', {
|
||||
setupFilesAfterEnv: [
|
||||
'<rootDir>/src/setupTest.js',
|
||||
],
|
||||
modulePaths: ['<rootDir>/src/'],
|
||||
snapshotSerializers: [
|
||||
'enzyme-to-json/serializer',
|
||||
],
|
||||
});
|
||||
9
openedx.yaml
Normal file
9
openedx.yaml
Normal file
@@ -0,0 +1,9 @@
|
||||
# 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
|
||||
|
||||
tags:
|
||||
- frontend-app
|
||||
- masters
|
||||
oeps:
|
||||
oep-2: true # Repository metadata
|
||||
openedx-release: {ref: master}
|
||||
34996
package-lock.json
generated
Executable file → Normal file
34996
package-lock.json
generated
Executable file → Normal file
File diff suppressed because it is too large
Load Diff
144
package.json
144
package.json
@@ -1,110 +1,80 @@
|
||||
{
|
||||
"name": "@edx/gradebook",
|
||||
"version": "0.1.0",
|
||||
"name": "@edx/frontend-app-gradebook",
|
||||
"version": "1.4.26",
|
||||
"description": "edx editable gradebook-ui to manipulate grade overrides on subsections",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/edx/gradebook.git"
|
||||
"url": "git+https://github.com/edx/frontend-app-gradebook.git"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "NODE_ENV=production BABEL_ENV=production webpack --config=config/webpack.prod.config.js",
|
||||
"build": "fedx-scripts webpack",
|
||||
"coveralls": "cat ./coverage/lcov.info | coveralls",
|
||||
"is-es5": "es-check es5 ./dist/*.js",
|
||||
"lint": "eslint --ext .js --ext .jsx .",
|
||||
"precommit": "npm run lint",
|
||||
"lint": "fedx-scripts eslint --ext .jsx,.js src/",
|
||||
"lint-fix": "fedx-scripts eslint --fix --ext .jsx,.js src/",
|
||||
"prepush": "npm run lint",
|
||||
"semantic-release": "semantic-release",
|
||||
"start": "NODE_ENV=development BABEL_ENV=development node_modules/.bin/webpack-dev-server --config=config/webpack.dev.config.js --progress",
|
||||
"test": "jest --coverage --passWithNoTests",
|
||||
"start": "fedx-scripts webpack-dev-server --progress",
|
||||
"test": "TZ=GMT fedx-scripts jest --coverage --passWithNoTests",
|
||||
"watch-tests": "jest --watch",
|
||||
"travis-deploy-once": "travis-deploy-once"
|
||||
},
|
||||
"author": "edX",
|
||||
"license": "AGPL-3.0",
|
||||
"homepage": "https://github.com/edx/gradebook#readme",
|
||||
"homepage": "https://github.com/edx/frontend-app-gradebook#readme",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edx/edx-bootstrap": "^0.4.3",
|
||||
"@edx/frontend-auth": "^1.2.1",
|
||||
"@edx/paragon": "^3.7.2",
|
||||
"babel-polyfill": "^6.26.0",
|
||||
"classnames": "^2.2.5",
|
||||
"email-prop-type": "^1.1.5",
|
||||
"font-awesome": "^4.7.0",
|
||||
"history": "^4.7.2",
|
||||
"prop-types": "^15.5.10",
|
||||
"query-string": "^5.1.1",
|
||||
"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",
|
||||
"@edx/brand": "npm:@edx/brand-edx.org@^1.3.2",
|
||||
"@edx/frontend-component-footer": "10.1.1",
|
||||
"@edx/frontend-platform": "1.8.1",
|
||||
"@edx/paragon": "14.6.1",
|
||||
"@fortawesome/fontawesome-svg-core": "^1.2.25",
|
||||
"@fortawesome/free-brands-svg-icons": "^5.11.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^5.11.2",
|
||||
"@fortawesome/react-fontawesome": "^0.1.5",
|
||||
"@redux-beacon/segment": "^1.0.0",
|
||||
"classnames": "^2.2.6",
|
||||
"core-js": "3.6.5",
|
||||
"email-prop-type": "^1.1.7",
|
||||
"enzyme": "^3.10.0",
|
||||
"enzyme-to-json": "^3.6.2",
|
||||
"font-awesome": "4.7.0",
|
||||
"history": "4.10.1",
|
||||
"node-sass": "^4.14.1",
|
||||
"prop-types": "15.7.2",
|
||||
"query-string": "6.13.0",
|
||||
"react": "16.13.1",
|
||||
"react-dom": "16.13.1",
|
||||
"react-intl": "^2.9.0",
|
||||
"react-redux": "^5.1.1",
|
||||
"react-router": "5.2.0",
|
||||
"react-router-dom": "5.2.0",
|
||||
"react-router-redux": "^5.0.0-alpha.9",
|
||||
"redux": "^3.7.2",
|
||||
"redux-devtools-extension": "^2.13.2",
|
||||
"redux-logger": "^3.0.6",
|
||||
"redux-thunk": "^2.2.0",
|
||||
"whatwg-fetch": "^2.0.3"
|
||||
"redux": "4.0.5",
|
||||
"redux-beacon": "^2.1.0",
|
||||
"redux-devtools-extension": "2.13.8",
|
||||
"redux-logger": "3.0.6",
|
||||
"redux-thunk": "2.3.0",
|
||||
"regenerator-runtime": "^0.13.7",
|
||||
"whatwg-fetch": "^2.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"autoprefixer": "^9.4.2",
|
||||
"axios-mock-adapter": "^1.15.0",
|
||||
"babel-cli": "^6.26.0",
|
||||
"babel-eslint": "^8.2.2",
|
||||
"babel-jest": "^22.4.0",
|
||||
"babel-loader": "^7.1.2",
|
||||
"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-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",
|
||||
"fetch-mock": "^6.3.0",
|
||||
"file-loader": "^1.1.9",
|
||||
"html-webpack-harddisk-plugin": "^0.2.0",
|
||||
"html-webpack-plugin": "^3.0.3",
|
||||
"husky": "^0.14.3",
|
||||
"@edx/frontend-build": "5.5.2",
|
||||
"axios": "0.21.1",
|
||||
"axios-mock-adapter": "^1.17.0",
|
||||
"codecov": "^3.6.1",
|
||||
"enzyme-adapter-react-16": "^1.14.0",
|
||||
"es-check": "^2.3.0",
|
||||
"fetch-mock": "^6.5.2",
|
||||
"husky": "2.7.0",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"image-webpack-loader": "^4.2.0",
|
||||
"jest": "^22.4.0",
|
||||
"mini-css-extract-plugin": "^0.4.0",
|
||||
"node-sass": "^4.7.2",
|
||||
"postcss-loader": "^3.0.0",
|
||||
"react-dev-utils": "^5.0.0",
|
||||
"react-test-renderer": "^16.2.0",
|
||||
"redux-mock-store": "^1.5.1",
|
||||
"sass-loader": "^6.0.6",
|
||||
"semantic-release": "^15.10.7",
|
||||
"style-loader": "^0.20.2",
|
||||
"travis-deploy-once": "^5.0.9",
|
||||
"webpack": "^4.25.1",
|
||||
"webpack-cli": "^3.1.2",
|
||||
"webpack-dev-server": "^3.1.0",
|
||||
"webpack-merge": "^4.1.1"
|
||||
},
|
||||
"jest": {
|
||||
"setupFiles": [
|
||||
"./src/setupTest.js"
|
||||
],
|
||||
"moduleNameMapper": {
|
||||
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/__mocks__/fileMock.js",
|
||||
"\\.(css|scss)$": "identity-obj-proxy"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"src/**/*.{js,jsx}"
|
||||
],
|
||||
"coveragePathIgnorePatterns": [
|
||||
"/node_modules/",
|
||||
"src/setupTest.js",
|
||||
"src/index.js",
|
||||
"/tests/"
|
||||
],
|
||||
"transformIgnorePatterns": [
|
||||
"/node_modules/(?!(@edx/paragon)/).*/"
|
||||
]
|
||||
"jest": "24.9.0",
|
||||
"react-dev-utils": "^5.0.3",
|
||||
"react-test-renderer": "^16.10.1",
|
||||
"redux-mock-store": "^1.5.3",
|
||||
"semantic-release": "^17.2.3",
|
||||
"travis-deploy-once": "^5.0.11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<html lang="en-us">
|
||||
<head>
|
||||
<title>Gradebook | <%= process.env.SITE_NAME %></title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="shortcut icon" href="<%=htmlWebpackPlugin.options.FAVICON_URL%>" type="image/x-icon" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
13
src/App.scss
13
src/App.scss
@@ -1,10 +1,15 @@
|
||||
@import "~@edx/edx-bootstrap/sass/edx/theme";
|
||||
@import "~bootstrap/scss/bootstrap";
|
||||
// frontend-app-*/src/index.scss
|
||||
@import "~@edx/brand/paragon/fonts";
|
||||
@import "~@edx/brand/paragon/variables";
|
||||
@import "~@edx/paragon/scss/core/core";
|
||||
@import "~@edx/brand/paragon/overrides";
|
||||
|
||||
$fa-font-path: "~font-awesome/fonts";
|
||||
@import "~font-awesome/scss/font-awesome";
|
||||
|
||||
@import "~@edx/paragon/src/SearchField/SearchField";
|
||||
$input-focus-box-shadow: $input-box-shadow; // hack to get upgrade to paragon 4.0.0 to work
|
||||
|
||||
@import "~@edx/frontend-component-footer/dist/_footer";
|
||||
|
||||
@import "./components/Gradebook/gradebook";
|
||||
@import "./components/Gradebook/footer";
|
||||
@import "./components/Drawer/Drawer";
|
||||
|
||||
48
src/components/Drawer/Drawer.scss
Normal file
48
src/components/Drawer/Drawer.scss
Normal file
@@ -0,0 +1,48 @@
|
||||
$drawer-width: 350px;
|
||||
|
||||
.drawer-contents {
|
||||
overflow-x: auto;
|
||||
transition: margin 300ms cubic-bezier(0.4,0,0.2,1);
|
||||
margin-left: 0;
|
||||
.drawer.open + & {
|
||||
margin-left: $drawer-width;
|
||||
}
|
||||
&.opened {
|
||||
width: calc(100vw - #{$drawer-width});
|
||||
}
|
||||
}
|
||||
|
||||
.drawer-contents {
|
||||
overflow-x: auto;
|
||||
transition: margin 300ms cubic-bezier(0.4,0,0.2,1);
|
||||
margin-left: 0;
|
||||
.drawer.open + & {
|
||||
margin-left: $drawer-width;
|
||||
}
|
||||
&.opened {
|
||||
width: calc(100vw - #{$drawer-width});
|
||||
}
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.drawer-container .collapsible {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.drawer {
|
||||
height: 100%;
|
||||
width: $drawer-width;
|
||||
position: absolute;
|
||||
transform: translateX(-$drawer-width);
|
||||
flex-direction: column;
|
||||
transition: transform 300ms cubic-bezier(0.4,0,0.2,1);
|
||||
&.open {
|
||||
transform: translateX(0%);
|
||||
}
|
||||
}
|
||||
85
src/components/Drawer/index.jsx
Normal file
85
src/components/Drawer/index.jsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { Button } from '@edx/paragon';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faTimes } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
export default class Drawer extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
open: props.initiallyOpen,
|
||||
transitioning: false,
|
||||
};
|
||||
}
|
||||
|
||||
close = () => {
|
||||
if (this.state.open) {
|
||||
this.toggleOpen();
|
||||
}
|
||||
};
|
||||
|
||||
toggleOpen = () => {
|
||||
this.setState({ transitioning: true });
|
||||
// defer the transition to the next repaint so we can be sure that
|
||||
// opening drawer is visible before it transitions
|
||||
// (the start state of the opening animation doesn't work if the element starts hidden)
|
||||
this.deferToNextRepaint(() => this.setState(prevState => ({ open: !prevState.open })));
|
||||
};
|
||||
|
||||
handleSlideDone = (e) => {
|
||||
if (e.currentTarget === e.target) {
|
||||
this.setState({ transitioning: false });
|
||||
}
|
||||
};
|
||||
|
||||
deferToNextRepaint(callback) {
|
||||
window.requestAnimationFrame(() => window.setTimeout(callback, 0));
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="d-flex drawer-container">
|
||||
<aside
|
||||
className={classNames(
|
||||
'drawer',
|
||||
{
|
||||
open: this.state.open,
|
||||
'd-none': !this.state.transitioning && !this.state.open,
|
||||
},
|
||||
)}
|
||||
onTransitionEnd={this.handleSlideDone}
|
||||
>
|
||||
<div className="drawer-header">
|
||||
<h2>{this.props.title}</h2>
|
||||
<Button
|
||||
className="p-1"
|
||||
onClick={this.close}
|
||||
aria-label="Close Filters"
|
||||
>
|
||||
<FontAwesomeIcon icon={faTimes} />
|
||||
</Button>
|
||||
</div>
|
||||
{this.props.children}
|
||||
</aside>
|
||||
<div
|
||||
className={classNames(
|
||||
'drawer-contents',
|
||||
'position-relative',
|
||||
!this.state.drawerTransitioning && this.state.drawerOpen && 'opened',
|
||||
)}
|
||||
>
|
||||
{this.props.mainContent(this.toggleOpen)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Drawer.propTypes = {
|
||||
initiallyOpen: PropTypes.bool.isRequired,
|
||||
children: PropTypes.node.isRequired,
|
||||
mainContent: PropTypes.func.isRequired,
|
||||
title: PropTypes.node.isRequired,
|
||||
};
|
||||
198
src/components/FilterBadges/index.jsx
Normal file
198
src/components/FilterBadges/index.jsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import initialFilters from '../../data/constants/filters';
|
||||
|
||||
function FilterBadge({
|
||||
name, value, onClick, showValue,
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<span className="badge badge-info">
|
||||
<span>{name}{showValue && `: ${value}`}</span>
|
||||
<button type="button" className="btn-info" aria-label="Close" onClick={onClick}>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</span>
|
||||
<br />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
FilterBadge.defaultProps = {
|
||||
showValue: true,
|
||||
};
|
||||
|
||||
FilterBadge.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
value: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.bool,
|
||||
]).isRequired,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
showValue: PropTypes.bool,
|
||||
};
|
||||
|
||||
function RangeFilterBadge({
|
||||
displayName,
|
||||
filterName1,
|
||||
filterValue1,
|
||||
filterName2,
|
||||
filterValue2,
|
||||
handleBadgeClose,
|
||||
}) {
|
||||
return ((filterValue1 !== initialFilters[filterName1])
|
||||
|| (filterValue2 !== initialFilters[filterName2]))
|
||||
&& (
|
||||
<FilterBadge
|
||||
name={displayName}
|
||||
value={`${filterValue1} - ${filterValue2}`}
|
||||
onClick={handleBadgeClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
RangeFilterBadge.propTypes = {
|
||||
displayName: PropTypes.string.isRequired,
|
||||
filterName1: PropTypes.string.isRequired,
|
||||
filterValue1: PropTypes.string.isRequired,
|
||||
filterName2: PropTypes.string.isRequired,
|
||||
filterValue2: PropTypes.string.isRequired,
|
||||
handleBadgeClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
function SingleValueFilterBadge({
|
||||
displayName, filterName, filterValue, handleBadgeClose, showValue,
|
||||
}) {
|
||||
return (filterValue !== initialFilters[filterName])
|
||||
&& (
|
||||
<FilterBadge
|
||||
name={displayName}
|
||||
value={filterValue}
|
||||
onClick={handleBadgeClose}
|
||||
showValue={showValue}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
SingleValueFilterBadge.defaultProps = {
|
||||
showValue: true,
|
||||
};
|
||||
|
||||
SingleValueFilterBadge.propTypes = {
|
||||
displayName: PropTypes.string.isRequired,
|
||||
filterName: PropTypes.string.isRequired,
|
||||
filterValue: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.bool,
|
||||
]).isRequired,
|
||||
handleBadgeClose: PropTypes.func.isRequired,
|
||||
showValue: PropTypes.bool,
|
||||
};
|
||||
|
||||
function FilterBadges({
|
||||
assignment,
|
||||
assignmentType,
|
||||
track,
|
||||
cohort,
|
||||
assignmentGradeMin,
|
||||
assignmentGradeMax,
|
||||
courseGradeMin,
|
||||
courseGradeMax,
|
||||
includeCourseRoleMembers,
|
||||
handleFilterBadgeClose,
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<SingleValueFilterBadge
|
||||
displayName="Assignment Type"
|
||||
filterName="assignmentType"
|
||||
filterValue={assignmentType}
|
||||
handleBadgeClose={handleFilterBadgeClose(['assignmentType'])}
|
||||
/>
|
||||
<SingleValueFilterBadge
|
||||
displayName="Assignment"
|
||||
filterName="assignment"
|
||||
filterValue={assignment}
|
||||
handleBadgeClose={handleFilterBadgeClose(['assignment', 'assignmentGradeMax', 'assignmentGradeMin'])}
|
||||
/>
|
||||
<RangeFilterBadge
|
||||
displayName="Assignment Grade"
|
||||
filterName1="assignmentGradeMin"
|
||||
filterValue1={assignmentGradeMin}
|
||||
filterName2="assignmentGradeMax"
|
||||
filterValue2={assignmentGradeMax}
|
||||
handleBadgeClose={handleFilterBadgeClose(['assignmentGradeMin', 'assignmentGradeMax'])}
|
||||
/>
|
||||
<RangeFilterBadge
|
||||
displayName="Course Grade"
|
||||
filterName1="courseGradeMin"
|
||||
filterValue1={courseGradeMin}
|
||||
filterName2="courseGradeMax"
|
||||
filterValue2={courseGradeMax}
|
||||
handleBadgeClose={handleFilterBadgeClose(['courseGradeMin', 'courseGradeMax'])}
|
||||
/>
|
||||
<SingleValueFilterBadge
|
||||
displayName="Track"
|
||||
filterName="track"
|
||||
filterValue={track}
|
||||
handleBadgeClose={handleFilterBadgeClose(['track'])}
|
||||
/>
|
||||
<SingleValueFilterBadge
|
||||
displayName="Cohort"
|
||||
filterName="cohort"
|
||||
filterValue={cohort}
|
||||
handleBadgeClose={handleFilterBadgeClose(['cohort'])}
|
||||
/>
|
||||
<SingleValueFilterBadge
|
||||
displayName="Including Course Team Members"
|
||||
filterName="includeCourseRoleMembers"
|
||||
filterValue={includeCourseRoleMembers}
|
||||
showValue={false}
|
||||
handleBadgeClose={handleFilterBadgeClose(['includeCourseRoleMembers'])}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const mapStateToProps = state => (
|
||||
{
|
||||
assignment: (state.filters.assignment || {}).label,
|
||||
assignmentType: state.filters.assignmentType,
|
||||
track: state.filters.track,
|
||||
cohort: state.filters.cohort,
|
||||
assignmentGradeMin: state.filters.assignmentGradeMin,
|
||||
assignmentGradeMax: state.filters.assignmentGradeMax,
|
||||
courseGradeMin: state.filters.courseGradeMin,
|
||||
courseGradeMax: state.filters.courseGradeMax,
|
||||
includeCourseRoleMembers: state.filters.includeCourseRoleMembers,
|
||||
}
|
||||
);
|
||||
|
||||
const ConnectedFilterBadges = connect(mapStateToProps)(FilterBadges);
|
||||
export default ConnectedFilterBadges;
|
||||
|
||||
FilterBadges.defaultProps = {
|
||||
assignment: initialFilters.assignmentType,
|
||||
assignmentType: initialFilters.assignmentType,
|
||||
track: initialFilters.track,
|
||||
cohort: initialFilters.cohort,
|
||||
assignmentGradeMin: initialFilters.assignmentGradeMin,
|
||||
assignmentGradeMax: initialFilters.assignmentGradeMax,
|
||||
courseGradeMin: initialFilters.courseGradeMin,
|
||||
courseGradeMax: initialFilters.courseGradeMax,
|
||||
includeCourseRoleMembers: initialFilters.includeCourseRoleMembers,
|
||||
};
|
||||
|
||||
FilterBadges.propTypes = {
|
||||
assignment: PropTypes.string,
|
||||
assignmentType: PropTypes.string,
|
||||
track: PropTypes.string,
|
||||
cohort: PropTypes.string,
|
||||
assignmentGradeMin: PropTypes.string,
|
||||
assignmentGradeMax: PropTypes.string,
|
||||
courseGradeMin: PropTypes.string,
|
||||
courseGradeMax: PropTypes.string,
|
||||
includeCourseRoleMembers: PropTypes.bool,
|
||||
handleFilterBadgeClose: PropTypes.func.isRequired,
|
||||
};
|
||||
199
src/components/Gradebook/BulkManagement.jsx
Normal file
199
src/components/Gradebook/BulkManagement.jsx
Normal file
@@ -0,0 +1,199 @@
|
||||
/* eslint-disable react/sort-comp, react/button-has-type */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import {
|
||||
Button,
|
||||
StatusAlert,
|
||||
Table,
|
||||
} from '@edx/paragon';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faDownload } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
import selectors from 'data/selectors';
|
||||
import { configuration } from '../../config';
|
||||
import { submitFileUploadFormData } from '../../data/actions/grades';
|
||||
|
||||
export class BulkManagement extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.fileFormRef = React.createRef();
|
||||
this.fileInputRef = React.createRef();
|
||||
}
|
||||
|
||||
formatHistoryRow = (row) => {
|
||||
const {
|
||||
summaryOfRowsProcessed: {
|
||||
total,
|
||||
successfullyProcessed,
|
||||
failed,
|
||||
skipped,
|
||||
},
|
||||
unique_id: courseId,
|
||||
originalFilename,
|
||||
id,
|
||||
user: username,
|
||||
...rest
|
||||
} = row;
|
||||
const resultsText = [
|
||||
`${total} Students: ${successfullyProcessed} processed`,
|
||||
...(skipped > 0 ? [`${skipped} skipped`] : []),
|
||||
...(failed > 0 ? [`${failed} failed`] : []),
|
||||
].join(', ');
|
||||
const resultsSummary = (
|
||||
<a
|
||||
href={`${configuration.LMS_BASE_URL}/api/bulk_grades/course/${courseId}/?error_id=${id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<FontAwesomeIcon icon={faDownload} />
|
||||
{resultsText}
|
||||
</a>
|
||||
);
|
||||
const createWrappedCell = (text) => (<span className="wrap-text-in-cell">{text}</span>);
|
||||
const filename = createWrappedCell(originalFilename);
|
||||
const user = createWrappedCell(username);
|
||||
return {
|
||||
resultsSummary,
|
||||
filename,
|
||||
user,
|
||||
...rest,
|
||||
};
|
||||
};
|
||||
|
||||
handleClickImportGrades = () => {
|
||||
const fileInput = this.fileInputRef.current;
|
||||
if (fileInput) {
|
||||
fileInput.click();
|
||||
}
|
||||
};
|
||||
|
||||
handleFileInputChange = (event) => {
|
||||
const fileInput = event.target;
|
||||
const file = fileInput.files[0];
|
||||
const form = this.fileFormRef.current;
|
||||
if (file && form) {
|
||||
const formData = new FormData(form);
|
||||
this.props.submitFileUploadFormData(this.props.courseId, formData).then(() => {
|
||||
fileInput.value = null;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<h4>Use this feature by downloading a CSV for bulk management,
|
||||
overriding grades locally, and coming back here to upload.
|
||||
</h4>
|
||||
<form ref={this.fileFormRef} action={this.props.gradeExportUrl} method="post">
|
||||
<StatusAlert
|
||||
alertType="danger"
|
||||
dialog={this.props.bulkImportError}
|
||||
isOpen={this.props.bulkImportError}
|
||||
dismissible={false}
|
||||
/>
|
||||
<StatusAlert
|
||||
alertType="success"
|
||||
dialog="CSV processing. File uploads may take several minutes to complete"
|
||||
open={this.props.uploadSuccess}
|
||||
dismissible={false}
|
||||
/>
|
||||
<input
|
||||
className="d-none"
|
||||
type="file"
|
||||
name="csv"
|
||||
label="Upload Grade CSV"
|
||||
onChange={this.handleFileInputChange}
|
||||
ref={this.fileInputRef}
|
||||
/>
|
||||
</form>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={this.handleClickImportGrades}
|
||||
>
|
||||
Import Grades
|
||||
</Button>
|
||||
<p>
|
||||
Results appear in the table below.<br />
|
||||
Grade processing may take a few seconds.
|
||||
</p>
|
||||
<Table
|
||||
data={this.props.bulkManagementHistory.map(this.formatHistoryRow)}
|
||||
hasFixedColumnWidths
|
||||
columns={[
|
||||
{
|
||||
key: 'filename',
|
||||
label: 'Gradebook',
|
||||
columnSortable: false,
|
||||
width: 'col-5',
|
||||
},
|
||||
{
|
||||
key: 'resultsSummary',
|
||||
label: 'Download Summary',
|
||||
columnSortable: false,
|
||||
width: 'col',
|
||||
},
|
||||
{
|
||||
key: 'user',
|
||||
label: 'Who',
|
||||
columnSortable: false,
|
||||
width: 'col-1',
|
||||
},
|
||||
{
|
||||
key: 'timeUploaded',
|
||||
label: 'When',
|
||||
columnSortable: false,
|
||||
width: 'col',
|
||||
},
|
||||
]}
|
||||
className="table-striped"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
BulkManagement.defaultProps = {
|
||||
bulkImportError: '',
|
||||
bulkManagementHistory: [],
|
||||
courseId: '',
|
||||
uploadSuccess: false,
|
||||
};
|
||||
|
||||
BulkManagement.propTypes = {
|
||||
courseId: PropTypes.string,
|
||||
gradeExportUrl: PropTypes.string.isRequired,
|
||||
|
||||
// redux
|
||||
bulkImportError: PropTypes.string,
|
||||
bulkManagementHistory: PropTypes.arrayOf(PropTypes.shape({
|
||||
originalFilename: PropTypes.string.isRequired,
|
||||
user: PropTypes.string.isRequired,
|
||||
timeUploaded: PropTypes.string.isRequired,
|
||||
summaryOfRowsProcessed: PropTypes.shape({
|
||||
total: PropTypes.number.isRequired,
|
||||
successfullyProcessed: PropTypes.number.isRequired,
|
||||
failed: PropTypes.number.isRequired,
|
||||
skipped: PropTypes.number.isRequired,
|
||||
}).isRequired,
|
||||
})),
|
||||
submitFileUploadFormData: PropTypes.func.isRequired,
|
||||
uploadSuccess: PropTypes.bool,
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => {
|
||||
const { grades } = selectors;
|
||||
return {
|
||||
bulkImportError: grades.bulkImportError(state),
|
||||
bulkManagementHistory: grades.bulkManagementHistoryEntries(state),
|
||||
uploadSuccess: grades.uploadSuccess(state),
|
||||
};
|
||||
};
|
||||
|
||||
export const mapDispatchToProps = {
|
||||
submitFileUploadFormData,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(BulkManagement);
|
||||
90
src/components/Gradebook/BulkManagementControls.jsx
Normal file
90
src/components/Gradebook/BulkManagementControls.jsx
Normal file
@@ -0,0 +1,90 @@
|
||||
/* eslint-disable react/sort-comp, react/button-has-type */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { StatefulButton } from '@edx/paragon';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faDownload, faSpinner } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
import {
|
||||
downloadBulkGradesReport,
|
||||
downloadInterventionReport,
|
||||
} from '../../data/actions/grades';
|
||||
|
||||
export class BulkManagementControls extends React.Component {
|
||||
handleClickDownloadInterventions = () => {
|
||||
this.props.downloadInterventionReport(this.props.courseId);
|
||||
window.location = this.props.interventionExportUrl;
|
||||
};
|
||||
|
||||
// At present, we don't store label and value in google analytics. By setting the label
|
||||
// property of the below events, I want to verify that we can set the label of google anlatyics
|
||||
// The following properties of a google analytics event are:
|
||||
// category (used), name(used), lavel(not used), value(not used)
|
||||
handleClickExportGrades = () => {
|
||||
this.props.downloadBulkGradesReport(this.props.courseId);
|
||||
window.location = this.props.gradeExportUrl;
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<StatefulButton
|
||||
variant="outline-primary"
|
||||
onClick={this.handleClickExportGrades}
|
||||
state={this.props.showSpinner ? 'pending' : 'default'}
|
||||
labels={{
|
||||
default: 'Bulk Management',
|
||||
pending: 'Bulk Management',
|
||||
}}
|
||||
icons={{
|
||||
default: <FontAwesomeIcon className="mr-2" icon={faDownload} />,
|
||||
pending: <FontAwesomeIcon className="fa-spin mr-2" icon={faSpinner} />,
|
||||
}}
|
||||
disabledStates={['pending']}
|
||||
/>
|
||||
<StatefulButton
|
||||
variant="outline-primary"
|
||||
onClick={this.handleClickDownloadInterventions}
|
||||
state={this.props.showSpinner ? 'pending' : 'default'}
|
||||
className="ml-2"
|
||||
labels={{
|
||||
default: 'Interventions*',
|
||||
pending: 'Interventions*',
|
||||
}}
|
||||
icons={{
|
||||
default: <FontAwesomeIcon className="mr-2" icon={faDownload} />,
|
||||
pending: <FontAwesomeIcon className="fa-spin mr-2" icon={faSpinner} />,
|
||||
}}
|
||||
disabledStates={['pending']}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
BulkManagementControls.defaultProps = {
|
||||
courseId: '',
|
||||
showSpinner: false,
|
||||
};
|
||||
|
||||
BulkManagementControls.propTypes = {
|
||||
courseId: PropTypes.string,
|
||||
gradeExportUrl: PropTypes.string.isRequired,
|
||||
interventionExportUrl: PropTypes.string.isRequired,
|
||||
showSpinner: PropTypes.bool,
|
||||
|
||||
// redux
|
||||
downloadBulkGradesReport: PropTypes.func.isRequired,
|
||||
downloadInterventionReport: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export const mapStateToProps = () => ({ });
|
||||
|
||||
export const mapDispatchToProps = {
|
||||
downloadBulkGradesReport,
|
||||
downloadInterventionReport,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(BulkManagementControls);
|
||||
210
src/components/Gradebook/EditModal.jsx
Normal file
210
src/components/Gradebook/EditModal.jsx
Normal file
@@ -0,0 +1,210 @@
|
||||
/* eslint-disable react/sort-comp, react/button-has-type */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
StatusAlert,
|
||||
Table,
|
||||
} from '@edx/paragon';
|
||||
|
||||
import selectors from 'data/selectors';
|
||||
import {
|
||||
doneViewingAssignment,
|
||||
updateGrades,
|
||||
} from '../../data/actions/grades';
|
||||
|
||||
const GRADE_OVERRIDE_HISTORY_COLUMNS = [
|
||||
{ label: 'Date', key: 'date' },
|
||||
{ label: 'Grader', key: 'grader' },
|
||||
{ label: 'Reason', key: 'reason' },
|
||||
{ label: 'Adjusted grade', key: 'adjustedGrade' },
|
||||
];
|
||||
|
||||
export class EditModal extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.overrideReasonInput = React.createRef();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.overrideReasonInput.current.focus();
|
||||
}
|
||||
|
||||
handleAdjustedGradeClick = () => {
|
||||
this.props.updateGrades(
|
||||
this.props.courseId, [
|
||||
{
|
||||
user_id: this.props.updateUserId,
|
||||
usage_id: this.props.updateModuleId,
|
||||
grade: {
|
||||
earned_graded_override: this.props.adjustedGradeValue,
|
||||
comment: this.props.reasonForChange,
|
||||
},
|
||||
},
|
||||
],
|
||||
this.props.filterValue,
|
||||
this.props.selectedCohort,
|
||||
this.props.selectedTrack,
|
||||
);
|
||||
|
||||
this.closeAssignmentModal();
|
||||
}
|
||||
|
||||
closeAssignmentModal = () => {
|
||||
this.props.doneViewingAssignment();
|
||||
this.props.setGradebookState({
|
||||
adjustedGradePossible: '',
|
||||
adjustedGradeValue: '',
|
||||
modalOpen: false,
|
||||
reasonForChange: '',
|
||||
updateModuleId: null,
|
||||
updateUserId: null,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Modal
|
||||
open={this.props.open}
|
||||
title="Edit Grades"
|
||||
closeText="Cancel"
|
||||
body={(
|
||||
<div>
|
||||
<div>
|
||||
<div className="grade-history-header grade-history-assignment">Assignment: </div>
|
||||
<div>{this.props.assignmentName}</div>
|
||||
<div className="grade-history-header grade-history-student">Student: </div>
|
||||
<div>{this.props.updateUserName}</div>
|
||||
<div className="grade-history-header grade-history-original-grade">Original Grade: </div>
|
||||
<div>{this.props.gradeOriginalEarnedGraded}</div>
|
||||
<div className="grade-history-header grade-history-current-grade">Current Grade: </div>
|
||||
<div>{this.props.gradeOverrideCurrentEarnedGradedOverride}</div>
|
||||
</div>
|
||||
<StatusAlert
|
||||
alertType="danger"
|
||||
dialog={this.props.gradeOverrideHistoryError}
|
||||
open={!!this.props.gradeOverrideHistoryError}
|
||||
dismissible={false}
|
||||
/>
|
||||
{!this.props.gradeOverrideHistoryError && (
|
||||
<Table
|
||||
columns={GRADE_OVERRIDE_HISTORY_COLUMNS}
|
||||
data={[...this.props.gradeOverrides, {
|
||||
date: this.props.todaysDate,
|
||||
reason: (<input
|
||||
type="text"
|
||||
name="reasonForChange"
|
||||
value={this.props.reasonForChange}
|
||||
onChange={this.props.setReasonForChange}
|
||||
ref={this.overrideReasonInput}
|
||||
/>),
|
||||
adjustedGrade: (
|
||||
<span>
|
||||
<input
|
||||
type="text"
|
||||
name="adjustedGradeValue"
|
||||
value={this.props.adjustedGradeValue}
|
||||
onChange={this.props.setAdjustedGradeValue}
|
||||
/>
|
||||
{(this.props.adjustedGradePossible || this.props.gradeOriginalPossibleGraded) && ' / '}
|
||||
{this.props.adjustedGradePossible || this.props.gradeOriginalPossibleGraded}
|
||||
</span>),
|
||||
}]}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div>Showing most recent actions (max 5). To see more, please contact
|
||||
support.
|
||||
</div>
|
||||
<div>Note: Once you save, your changes will be visible to students.</div>
|
||||
</div>
|
||||
)}
|
||||
buttons={[
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={this.handleAdjustedGradeClick}
|
||||
>
|
||||
Save Grade
|
||||
</Button>,
|
||||
]}
|
||||
onClose={this.closeAssignmentModal}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
EditModal.defaultProps = {
|
||||
adjustedGradeValue: null,
|
||||
courseId: '',
|
||||
gradeOverrideCurrentEarnedGradedOverride: null,
|
||||
gradeOverrideHistoryError: '',
|
||||
gradeOverrides: [],
|
||||
gradeOriginalEarnedGraded: null,
|
||||
gradeOriginalPossibleGraded: null,
|
||||
selectedCohort: null,
|
||||
selectedTrack: null,
|
||||
updateModuleId: '',
|
||||
updateUserId: '',
|
||||
updateUserName: '',
|
||||
};
|
||||
|
||||
EditModal.propTypes = {
|
||||
courseId: PropTypes.string,
|
||||
|
||||
// Gradebook State
|
||||
adjustedGradePossible: PropTypes.string.isRequired,
|
||||
// should pick one?
|
||||
adjustedGradeValue: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
|
||||
assignmentName: PropTypes.string.isRequired,
|
||||
filterValue: PropTypes.string.isRequired,
|
||||
open: PropTypes.bool.isRequired,
|
||||
reasonForChange: PropTypes.string.isRequired,
|
||||
todaysDate: PropTypes.string.isRequired,
|
||||
updateModuleId: PropTypes.string,
|
||||
updateUserId: PropTypes.number,
|
||||
updateUserName: PropTypes.string,
|
||||
|
||||
// Gradebook State Setters
|
||||
setAdjustedGradeValue: PropTypes.func.isRequired,
|
||||
setGradebookState: PropTypes.func.isRequired,
|
||||
setReasonForChange: PropTypes.func.isRequired,
|
||||
|
||||
// redux
|
||||
doneViewingAssignment: PropTypes.func.isRequired,
|
||||
gradeOverrideCurrentEarnedGradedOverride: PropTypes.number,
|
||||
gradeOverrideHistoryError: PropTypes.string,
|
||||
gradeOverrides: PropTypes.arrayOf(PropTypes.shape({
|
||||
date: PropTypes.string,
|
||||
grader: PropTypes.string,
|
||||
reason: PropTypes.string,
|
||||
adjustedGrade: PropTypes.number,
|
||||
})),
|
||||
gradeOriginalEarnedGraded: PropTypes.number,
|
||||
gradeOriginalPossibleGraded: PropTypes.number,
|
||||
selectedCohort: PropTypes.string,
|
||||
selectedTrack: PropTypes.string,
|
||||
updateGrades: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => {
|
||||
const { filters, grades } = selectors;
|
||||
return {
|
||||
gradeOverrides: grades.gradeOverrides(state),
|
||||
gradeOverrideCurrentEarnedGradedOverride: grades.gradeOverrideCurrentEarnedGradedOverride(state),
|
||||
gradeOverrideHistoryError: grades.gradeOverrideHistoryError(state),
|
||||
gradeOriginalEarnedGraded: grades.gradeOriginalEarnedGraded(state),
|
||||
gradeOriginalPossibleGraded: grades.gradeOriginalPossibleGraded(state),
|
||||
selectedCohort: filters.cohort(state),
|
||||
selectedTrack: filters.track(state),
|
||||
};
|
||||
};
|
||||
|
||||
export const mapDispatchToProps = {
|
||||
doneViewingAssignment,
|
||||
updateGrades,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(EditModal);
|
||||
@@ -0,0 +1,38 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`AssignmentFilter Component snapshots basic snapshot 1`] = `
|
||||
<div
|
||||
className="student-filters"
|
||||
>
|
||||
<SelectGroup
|
||||
disabled={false}
|
||||
id="assignment"
|
||||
label="Assignment"
|
||||
onChange={[MockFunction handleChange]}
|
||||
options={
|
||||
Array [
|
||||
<option
|
||||
value=""
|
||||
>
|
||||
All
|
||||
</option>,
|
||||
<option
|
||||
value="assgN1"
|
||||
>
|
||||
assgN1
|
||||
:
|
||||
subLabel1
|
||||
</option>,
|
||||
<option
|
||||
value="assgN2"
|
||||
>
|
||||
assgN2
|
||||
:
|
||||
subLabel2
|
||||
</option>,
|
||||
]
|
||||
}
|
||||
value="assgN1"
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,104 @@
|
||||
/* eslint-disable react/sort-comp, react/button-has-type */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import * as gradesActions from 'data/actions/grades';
|
||||
import * as filterActions from 'data/actions/filters';
|
||||
import selectors from 'data/selectors';
|
||||
|
||||
import SelectGroup from '../SelectGroup';
|
||||
|
||||
export class AssignmentFilter extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
}
|
||||
|
||||
handleChange(event) {
|
||||
const assignment = event.target.value;
|
||||
const selectedFilterOption = this.props.assignmentFilterOptions.find(assig => assig.label === assignment);
|
||||
const { type, id } = selectedFilterOption || {};
|
||||
const typedValue = { label: assignment, type, id };
|
||||
this.props.updateAssignmentFilter(typedValue);
|
||||
this.props.updateQueryParams({ assignment: id });
|
||||
this.props.updateGradesIfAssignmentGradeFiltersSet(
|
||||
this.props.courseId,
|
||||
this.props.selectedCohort,
|
||||
this.props.selectedTrack,
|
||||
this.props.selectedAssignmentType,
|
||||
);
|
||||
}
|
||||
|
||||
get options() {
|
||||
const mapper = ({ label, subsectionLabel }) => (
|
||||
<option key={label} value={label}>
|
||||
{label}: {subsectionLabel}
|
||||
</option>
|
||||
);
|
||||
return ([
|
||||
<option key="0" value="">All</option>,
|
||||
...this.props.assignmentFilterOptions.map(mapper),
|
||||
]);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="student-filters">
|
||||
<SelectGroup
|
||||
id="assignment"
|
||||
label="Assignment"
|
||||
value={this.props.selectedAssignment}
|
||||
onChange={this.handleChange}
|
||||
disabled={this.props.assignmentFilterOptions.length === 0}
|
||||
options={this.options}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AssignmentFilter.defaultProps = {
|
||||
assignmentFilterOptions: [],
|
||||
selectedAssignment: '',
|
||||
selectedAssignmentType: '',
|
||||
selectedCohort: null,
|
||||
selectedTrack: null,
|
||||
};
|
||||
|
||||
AssignmentFilter.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
updateQueryParams: PropTypes.func.isRequired,
|
||||
|
||||
// redux
|
||||
assignmentFilterOptions: PropTypes.arrayOf(PropTypes.shape({
|
||||
label: PropTypes.string,
|
||||
subsectionLabel: PropTypes.string,
|
||||
type: PropTypes.string,
|
||||
id: PropTypes.string,
|
||||
})),
|
||||
selectedAssignmentType: PropTypes.string,
|
||||
selectedAssignment: PropTypes.string,
|
||||
selectedCohort: PropTypes.string,
|
||||
selectedTrack: PropTypes.string,
|
||||
updateGradesIfAssignmentGradeFiltersSet: PropTypes.func.isRequired,
|
||||
updateAssignmentFilter: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => {
|
||||
const { filters } = selectors;
|
||||
return {
|
||||
assignmentFilterOptions: filters.selectableAssignmentLabels(state),
|
||||
selectedAssignment: filters.selectedAssignmentLabel(state),
|
||||
selectedAssignmentType: filters.assignmentType(state),
|
||||
selectedCohort: filters.cohort(state),
|
||||
selectedTrack: filters.track(state),
|
||||
};
|
||||
};
|
||||
|
||||
export const mapDispatchToProps = {
|
||||
updateAssignmentFilter: filterActions.updateAssignmentFilter,
|
||||
updateGradesIfAssignmentGradeFiltersSet: gradesActions.updateGradesIfAssignmentGradeFiltersSet,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(AssignmentFilter);
|
||||
@@ -0,0 +1,171 @@
|
||||
import React from 'react';
|
||||
import { mount, shallow } from 'enzyme';
|
||||
|
||||
import selectors from 'data/selectors';
|
||||
import { updateAssignmentFilter } from 'data/actions/filters';
|
||||
import { updateGradesIfAssignmentGradeFiltersSet } from 'data/actions/grades';
|
||||
import {
|
||||
AssignmentFilter,
|
||||
mapStateToProps,
|
||||
mapDispatchToProps,
|
||||
} from '.';
|
||||
|
||||
jest.mock('data/selectors', () => ({
|
||||
/** Mocking to use passed state for validation purposes */
|
||||
filters: {
|
||||
selectableAssignmentLabels: jest.fn(() => ([{
|
||||
label: 'assigNment',
|
||||
subsectionLabel: 'subsection',
|
||||
type: 'assignMentType',
|
||||
id: 'subsectionId',
|
||||
}])),
|
||||
selectedAssignmentLabel: jest.fn(() => 'assigNment'),
|
||||
assignmentType: jest.fn(() => 'assignMentType'),
|
||||
cohort: jest.fn(() => 'COhort'),
|
||||
track: jest.fn(() => 'traCK'),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('AssignmentFilter', () => {
|
||||
let props = {
|
||||
courseId: '12345',
|
||||
assignmentFilterOptions: [
|
||||
{
|
||||
label: 'assgN1',
|
||||
subsectionLabel: 'subLabel1',
|
||||
type: 'assgn_Type1',
|
||||
id: 'assgn_iD1',
|
||||
},
|
||||
{
|
||||
label: 'assgN2',
|
||||
subsectionLabel: 'subLabel2',
|
||||
type: 'assgn_Type2',
|
||||
id: 'assgn_iD2',
|
||||
},
|
||||
],
|
||||
selectedAssignmentType: 'assgnFilterLabel1',
|
||||
selectedAssignment: 'assgN1',
|
||||
selectedCohort: 'a cohort',
|
||||
selectedTrack: 'a track',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
props = {
|
||||
...props,
|
||||
updateQueryParams: jest.fn(),
|
||||
updateGradesIfAssignmentGradeFiltersSet: jest.fn(),
|
||||
updateAssignmentFilter: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Component', () => {
|
||||
describe('behavior', () => {
|
||||
describe('handleChange', () => {
|
||||
let el;
|
||||
const newAssgn = 'assgN1';
|
||||
const event = { target: { value: newAssgn } };
|
||||
const selected = props.assignmentFilterOptions[0];
|
||||
beforeEach(() => {
|
||||
el = mount(<AssignmentFilter {...props} />);
|
||||
el.instance().handleChange(event);
|
||||
});
|
||||
|
||||
it('calls props.updateAssignmentFilter with selection', () => {
|
||||
expect(props.updateAssignmentFilter).toHaveBeenCalledWith({
|
||||
label: newAssgn,
|
||||
type: selected.type,
|
||||
id: selected.id,
|
||||
});
|
||||
});
|
||||
it('calls props.updateQueryParams with selected assignment id',
|
||||
() => {
|
||||
expect(props.updateQueryParams).toHaveBeenCalledWith({
|
||||
assignment: selected.id,
|
||||
});
|
||||
});
|
||||
it('calls props.updateGradesIfAssignmentGradeFiltersSet', () => {
|
||||
const method = props.updateGradesIfAssignmentGradeFiltersSet;
|
||||
expect(method).toHaveBeenCalledWith(
|
||||
props.courseId,
|
||||
props.selectedCohort,
|
||||
props.selectedTrack,
|
||||
props.selectedAssignmentType,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('snapshots', () => {
|
||||
test('basic snapshot', () => {
|
||||
const el = shallow(<AssignmentFilter {...props} />);
|
||||
el.instance().handleChange = jest.fn().mockName('handleChange');
|
||||
expect(el.instance().render()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('mapStateToProps', () => {
|
||||
const state = {
|
||||
filters: {
|
||||
assignment: { label: 'assigNment' },
|
||||
assignmentType: 'assignMentType',
|
||||
cohort: 'COhort',
|
||||
track: 'traCK',
|
||||
},
|
||||
};
|
||||
describe('assignmentFilterOptions', () => {
|
||||
it('is selected from filters.selectableAssignmentLabels', () => {
|
||||
expect(
|
||||
mapStateToProps(state).assignmentFilterOptions,
|
||||
).toEqual(
|
||||
selectors.filters.selectableAssignmentLabels(state),
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('selectedAssignment', () => {
|
||||
it('is selected from filters.selectedAssignmentLabel', () => {
|
||||
expect(
|
||||
mapStateToProps(state).selectedAssignment,
|
||||
).toEqual(
|
||||
selectors.filters.selectedAssignmentLabel(state),
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('selectedAssignmentType', () => {
|
||||
it('is selected from filters.assignmentType', () => {
|
||||
expect(
|
||||
mapStateToProps(state).selectedAssignmentType,
|
||||
).toEqual(
|
||||
selectors.filters.assignmentType(state),
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('selectedCohort', () => {
|
||||
it('is selected from filters.cohort', () => {
|
||||
expect(
|
||||
mapStateToProps(state).selectedCohort,
|
||||
).toEqual(
|
||||
selectors.filters.cohort(state),
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('selectedTrack', () => {
|
||||
it('is selected from filters.track', () => {
|
||||
expect(
|
||||
mapStateToProps(state).selectedTrack,
|
||||
).toEqual(
|
||||
selectors.filters.track(state),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('mapDispatchToProps', () => {
|
||||
test('updateAssignmentFilter', () => {
|
||||
expect(mapDispatchToProps.updateAssignmentFilter).toEqual(
|
||||
updateAssignmentFilter,
|
||||
);
|
||||
});
|
||||
test('updateGradesIfAsssignmentGradeFiltersSet', () => {
|
||||
const prop = mapDispatchToProps.updateGradesIfAssignmentGradeFiltersSet;
|
||||
expect(prop).toEqual(updateGradesIfAssignmentGradeFiltersSet);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`AssignmentGradeFilter Component snapshots buttons and groups disabled if no selected assignment 1`] = `
|
||||
<div
|
||||
className="grade-filter-inputs"
|
||||
>
|
||||
<PercentGroup
|
||||
disabled={true}
|
||||
id="assignmentGradeMin"
|
||||
label="Min Grade"
|
||||
onChange={[MockFunction handleSetMin]}
|
||||
value="1"
|
||||
/>
|
||||
<PercentGroup
|
||||
disabled={true}
|
||||
id="assignmentGradeMax"
|
||||
label="Max Grade"
|
||||
onChange={[MockFunction handleSetMax]}
|
||||
value="100"
|
||||
/>
|
||||
<div
|
||||
className="grade-filter-action"
|
||||
>
|
||||
<Button
|
||||
active={false}
|
||||
disabled={true}
|
||||
name="assignmentGradeMinMax"
|
||||
onClick={[MockFunction handleSubmit]}
|
||||
type="submit"
|
||||
variant="outline-secondary"
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`AssignmentGradeFilter Component snapshots smoke test 1`] = `
|
||||
<div
|
||||
className="grade-filter-inputs"
|
||||
>
|
||||
<PercentGroup
|
||||
disabled={false}
|
||||
id="assignmentGradeMin"
|
||||
label="Min Grade"
|
||||
onChange={[MockFunction handleSetMin]}
|
||||
value="1"
|
||||
/>
|
||||
<PercentGroup
|
||||
disabled={false}
|
||||
id="assignmentGradeMax"
|
||||
label="Max Grade"
|
||||
onChange={[MockFunction handleSetMax]}
|
||||
value="100"
|
||||
/>
|
||||
<div
|
||||
className="grade-filter-action"
|
||||
>
|
||||
<Button
|
||||
active={false}
|
||||
disabled={false}
|
||||
name="assignmentGradeMinMax"
|
||||
onClick={[MockFunction handleSubmit]}
|
||||
type="submit"
|
||||
variant="outline-secondary"
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,125 @@
|
||||
/* eslint-disable react/sort-comp, react/button-has-type */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { Button } from '@edx/paragon';
|
||||
|
||||
import * as gradesActions from 'data/actions/grades';
|
||||
import * as filterActions from 'data/actions/filters';
|
||||
import selectors from 'data/selectors';
|
||||
|
||||
import PercentGroup from '../PercentGroup';
|
||||
|
||||
export class AssignmentGradeFilter extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
this.handleSetMax = this.handleSetMax.bind(this);
|
||||
this.handleSetMin = this.handleSetMin.bind(this);
|
||||
}
|
||||
|
||||
handleSubmit() {
|
||||
const {
|
||||
assignmentGradeMin,
|
||||
assignmentGradeMax,
|
||||
} = this.props.filterValues;
|
||||
|
||||
this.props.updateAssignmentLimits(
|
||||
assignmentGradeMin,
|
||||
assignmentGradeMax,
|
||||
);
|
||||
this.props.getUserGrades(
|
||||
this.props.courseId,
|
||||
this.props.selectedCohort,
|
||||
this.props.selectedTrack,
|
||||
this.props.selectedAssignmentType,
|
||||
);
|
||||
this.props.updateQueryParams({
|
||||
assignmentGradeMin,
|
||||
assignmentGradeMax,
|
||||
});
|
||||
}
|
||||
|
||||
handleSetMax(event) {
|
||||
this.props.setFilters({ assignmentGradeMax: event.target.value });
|
||||
}
|
||||
|
||||
handleSetMin(event) {
|
||||
this.props.setFilters({ assignmentGradeMin: event.target.value });
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="grade-filter-inputs">
|
||||
<PercentGroup
|
||||
id="assignmentGradeMin"
|
||||
label="Min Grade"
|
||||
value={this.props.filterValues.assignmentGradeMin}
|
||||
disabled={!this.props.selectedAssignment}
|
||||
onChange={this.handleSetMin}
|
||||
/>
|
||||
<PercentGroup
|
||||
id="assignmentGradeMax"
|
||||
label="Max Grade"
|
||||
value={this.props.filterValues.assignmentGradeMax}
|
||||
disabled={!this.props.selectedAssignment}
|
||||
onChange={this.handleSetMax}
|
||||
/>
|
||||
<div className="grade-filter-action">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline-secondary"
|
||||
name="assignmentGradeMinMax"
|
||||
disabled={!this.props.selectedAssignment}
|
||||
onClick={this.handleSubmit}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AssignmentGradeFilter.defaultProps = {
|
||||
selectedAssignment: '',
|
||||
selectedAssignmentType: '',
|
||||
selectedCohort: null,
|
||||
selectedTrack: null,
|
||||
};
|
||||
|
||||
AssignmentGradeFilter.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
filterValues: PropTypes.shape({
|
||||
assignmentGradeMin: PropTypes.string.isRequired,
|
||||
assignmentGradeMax: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
setFilters: PropTypes.func.isRequired,
|
||||
updateQueryParams: PropTypes.func.isRequired,
|
||||
|
||||
// redux
|
||||
getUserGrades: PropTypes.func.isRequired,
|
||||
selectedAssignmentType: PropTypes.string,
|
||||
selectedAssignment: PropTypes.string,
|
||||
selectedCohort: PropTypes.string,
|
||||
selectedTrack: PropTypes.string,
|
||||
updateAssignmentLimits: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => {
|
||||
const { filters } = selectors;
|
||||
return {
|
||||
selectedAssignment: filters.selectedAssignmentLabel(state),
|
||||
selectedAssignmentType: filters.assignmentType(state),
|
||||
selectedCohort: filters.cohort(state),
|
||||
selectedTrack: filters.track(state),
|
||||
};
|
||||
};
|
||||
|
||||
export const mapDispatchToProps = {
|
||||
getUserGrades: gradesActions.fetchGrades,
|
||||
updateAssignmentLimits: filterActions.updateAssignmentLimits,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(AssignmentGradeFilter);
|
||||
@@ -0,0 +1,174 @@
|
||||
import React from 'react';
|
||||
import { mount, shallow } from 'enzyme';
|
||||
|
||||
import { updateAssignmentLimits } from 'data/actions/filters';
|
||||
import { fetchGrades } from 'data/actions/grades';
|
||||
|
||||
import {
|
||||
AssignmentGradeFilter,
|
||||
mapStateToProps,
|
||||
mapDispatchToProps,
|
||||
} from '.';
|
||||
|
||||
describe('AssignmentGradeFilter', () => {
|
||||
let props = {
|
||||
filterValues: {
|
||||
assignmentGradeMin: '1',
|
||||
assignmentGradeMax: '100',
|
||||
},
|
||||
courseId: '12345',
|
||||
|
||||
selectedAssignmentType: 'assgnFilterLabel1',
|
||||
selectedAssignment: 'assgN1',
|
||||
selectedCohort: 'a cohort',
|
||||
selectedTrack: 'a track',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
props = {
|
||||
...props,
|
||||
setFilters: jest.fn(),
|
||||
updateQueryParams: jest.fn(),
|
||||
getUserGrades: jest.fn(),
|
||||
updateAssignmentLimits: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Component', () => {
|
||||
describe('behavior', () => {
|
||||
describe('handleSubmit', () => {
|
||||
let el;
|
||||
beforeEach(() => {
|
||||
el = mount(<AssignmentGradeFilter {...props} />);
|
||||
el.instance().handleSubmit();
|
||||
});
|
||||
it('calls props.updateAssignmentLimits with min and max', () => {
|
||||
expect(props.updateAssignmentLimits).toHaveBeenCalledWith(
|
||||
props.filterValues.assignmentGradeMin,
|
||||
props.filterValues.assignmentGradeMax,
|
||||
);
|
||||
});
|
||||
it('calls getUserGrades w/ selection', () => {
|
||||
expect(props.getUserGrades).toHaveBeenCalledWith(
|
||||
props.courseId,
|
||||
props.selectedCohort,
|
||||
props.selectedTrack,
|
||||
props.selectedAssignmentType,
|
||||
);
|
||||
});
|
||||
it('updates queryParams with assignment grade min and max', () => {
|
||||
expect(props.updateQueryParams).toHaveBeenCalledWith({
|
||||
assignmentGradeMin: props.filterValues.assignmentGradeMin,
|
||||
assignmentGradeMax: props.filterValues.assignmentGradeMax,
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('handleSetMin', () => {
|
||||
it('calls setFilters for assignmentGradeMin', () => {
|
||||
const testVal = 23;
|
||||
const el = mount(<AssignmentGradeFilter {...props} />);
|
||||
el.instance().handleSetMin({ target: { value: testVal } });
|
||||
expect(props.setFilters).toHaveBeenCalledWith({
|
||||
assignmentGradeMin: testVal,
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('handleSetMax', () => {
|
||||
it('calls setFilters for assignmentGradeMax', () => {
|
||||
const testVal = 92;
|
||||
const el = mount(<AssignmentGradeFilter {...props} />);
|
||||
el.instance().handleSetMax({ target: { value: testVal } });
|
||||
expect(props.setFilters).toHaveBeenCalledWith({
|
||||
assignmentGradeMax: testVal,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('snapshots', () => {
|
||||
let el;
|
||||
const mockMethods = () => {
|
||||
el.instance().handleSubmit = jest.fn().mockName('handleSubmit');
|
||||
el.instance().handleSetMax = jest.fn().mockName('handleSetMax');
|
||||
el.instance().handleSetMin = jest.fn().mockName('handleSetMin');
|
||||
};
|
||||
test('smoke test', () => {
|
||||
el = shallow(<AssignmentGradeFilter {...props} />);
|
||||
mockMethods(el);
|
||||
expect(el.instance().render()).toMatchSnapshot();
|
||||
});
|
||||
test('buttons and groups disabled if no selected assignment', () => {
|
||||
el = shallow(<AssignmentGradeFilter
|
||||
{...props}
|
||||
selectedAssignment={undefined}
|
||||
/>);
|
||||
mockMethods(el);
|
||||
expect(el.instance().render()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('mapStateToProps', () => {
|
||||
const state = {
|
||||
filters: {
|
||||
assignment: { label: 'assigNment' },
|
||||
assignmentType: 'assignMentType',
|
||||
cohort: 'COhort',
|
||||
track: 'traCK',
|
||||
},
|
||||
};
|
||||
describe('selectedAsssignment', () => {
|
||||
it('is undefined if no assignment is passed', () => {
|
||||
expect(
|
||||
mapStateToProps({ filters: {} }).selectedAssignment,
|
||||
).toEqual(undefined);
|
||||
});
|
||||
it('returns the label of selected assignment if there is one', () => {
|
||||
expect(
|
||||
mapStateToProps(state).selectedAssignment,
|
||||
).toEqual(
|
||||
state.filters.assignment.label,
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('selectedAssignmentType', () => {
|
||||
it('is drawn from state.filters.assignmentType', () => {
|
||||
expect(
|
||||
mapStateToProps(state).selectedAssignmentType,
|
||||
).toEqual(
|
||||
state.filters.assignmentType,
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('selectedCohort', () => {
|
||||
it('is drawn from state.filters.cohort', () => {
|
||||
expect(
|
||||
mapStateToProps(state).selectedCohort,
|
||||
).toEqual(
|
||||
state.filters.cohort,
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('selectedTrack', () => {
|
||||
it('is drawn from state.filters.track', () => {
|
||||
expect(
|
||||
mapStateToProps(state).selectedTrack,
|
||||
).toEqual(
|
||||
state.filters.track,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('mapDispatchToProps', () => {
|
||||
test('getUserGrades', () => {
|
||||
expect(mapDispatchToProps.getUserGrades).toEqual(
|
||||
fetchGrades,
|
||||
);
|
||||
});
|
||||
test('updateAssignmentLimits', () => {
|
||||
expect(
|
||||
mapDispatchToProps.updateAssignmentLimits,
|
||||
).toEqual(
|
||||
updateAssignmentLimits,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`AssignmentTypeFilter Component snapshots SelectGroup disabled if no assignmentFilterOptions 1`] = `
|
||||
<div
|
||||
className="student-filters"
|
||||
>
|
||||
<SelectGroup
|
||||
disabled={true}
|
||||
id="assignment-types"
|
||||
label="Assignment Types"
|
||||
onChange={[MockFunction handleChange]}
|
||||
options={
|
||||
Array [
|
||||
<option
|
||||
value=""
|
||||
>
|
||||
All
|
||||
</option>,
|
||||
<option
|
||||
value="assignMentType1"
|
||||
>
|
||||
assignMentType1
|
||||
</option>,
|
||||
<option
|
||||
value="AssigNmentType2"
|
||||
>
|
||||
AssigNmentType2
|
||||
</option>,
|
||||
]
|
||||
}
|
||||
value="assigNmentType2"
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`AssignmentTypeFilter Component snapshots smoke test 1`] = `
|
||||
<div
|
||||
className="student-filters"
|
||||
>
|
||||
<SelectGroup
|
||||
disabled={false}
|
||||
id="assignment-types"
|
||||
label="Assignment Types"
|
||||
onChange={[MockFunction handleChange]}
|
||||
options={
|
||||
Array [
|
||||
<option
|
||||
value=""
|
||||
>
|
||||
All
|
||||
</option>,
|
||||
<option
|
||||
value="assignMentType1"
|
||||
>
|
||||
assignMentType1
|
||||
</option>,
|
||||
<option
|
||||
value="AssigNmentType2"
|
||||
>
|
||||
AssigNmentType2
|
||||
</option>,
|
||||
]
|
||||
}
|
||||
value="assigNmentType2"
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,78 @@
|
||||
/* eslint-disable react/sort-comp, react/button-has-type */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import * as gradesActions from 'data/actions/grades';
|
||||
import selectors from 'data/selectors';
|
||||
|
||||
import SelectGroup from '../SelectGroup';
|
||||
|
||||
export class AssignmentTypeFilter extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
}
|
||||
|
||||
handleChange(event) {
|
||||
const assignmentType = event.target.value;
|
||||
this.props.filterAssignmentType(assignmentType);
|
||||
this.props.updateQueryParams({ assignmentType });
|
||||
}
|
||||
|
||||
get options() {
|
||||
const mapper = (entry) => (
|
||||
<option key={entry} value={entry}>{entry}</option>
|
||||
);
|
||||
return [
|
||||
<option key="0" value="">All</option>,
|
||||
...this.props.assignmentTypes.map(mapper),
|
||||
];
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="student-filters">
|
||||
<SelectGroup
|
||||
id="assignment-types"
|
||||
label="Assignment Types"
|
||||
value={this.props.selectedAssignmentType}
|
||||
onChange={this.handleChange}
|
||||
disabled={this.props.assignmentFilterOptions.length === 0}
|
||||
options={this.options}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AssignmentTypeFilter.defaultProps = {
|
||||
assignmentTypes: [],
|
||||
assignmentFilterOptions: [],
|
||||
selectedAssignmentType: '',
|
||||
};
|
||||
|
||||
AssignmentTypeFilter.propTypes = {
|
||||
updateQueryParams: PropTypes.func.isRequired,
|
||||
|
||||
// redux
|
||||
assignmentTypes: PropTypes.arrayOf(PropTypes.string),
|
||||
assignmentFilterOptions: PropTypes.arrayOf(PropTypes.shape({
|
||||
label: PropTypes.string,
|
||||
subsectionLabel: PropTypes.string,
|
||||
})),
|
||||
filterAssignmentType: PropTypes.func.isRequired,
|
||||
selectedAssignmentType: PropTypes.string,
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => ({
|
||||
assignmentTypes: selectors.assignmentTypes.allAssignmentTypes(state),
|
||||
assignmentFilterOptions: selectors.filters.selectableAssignmentLabels(state),
|
||||
selectedAssignmentType: selectors.filters.assignmentType(state),
|
||||
});
|
||||
|
||||
export const mapDispatchToProps = {
|
||||
filterAssignmentType: gradesActions.filterAssignmentType,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(AssignmentTypeFilter);
|
||||
@@ -0,0 +1,135 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import selectors from 'data/selectors';
|
||||
import { filterAssignmentType } from 'data/actions/grades';
|
||||
|
||||
import {
|
||||
AssignmentTypeFilter,
|
||||
mapStateToProps,
|
||||
mapDispatchToProps,
|
||||
} from '.';
|
||||
|
||||
jest.mock('data/selectors', () => ({
|
||||
/** Mocking to use passed state for validation purposes */
|
||||
assignmentTypes: {
|
||||
allAssignmentTypes: jest.fn(() => (['assignment', 'labs'])),
|
||||
},
|
||||
filters: {
|
||||
selectableAssignmentLabels: jest.fn(() => ([{
|
||||
label: 'assigNment',
|
||||
subsectionLabel: 'subsection',
|
||||
type: 'assignMentType',
|
||||
id: 'subsectionId',
|
||||
}])),
|
||||
assignmentType: jest.fn(() => 'assignMentType'),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('AssignmentTypeFilter', () => {
|
||||
let props = {
|
||||
assignmentTypes: ['assignMentType1', 'AssigNmentType2'],
|
||||
assignmentFilterOptions: [
|
||||
{ label: 'filterLabel1', subsectionLabel: 'filterSubLabel2' },
|
||||
{ label: 'filterLabel2', subsectionLabel: 'filterSubLabel1' },
|
||||
],
|
||||
selectedAssignmentType: 'assigNmentType2',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
props = {
|
||||
...props,
|
||||
filterAssignmentType: jest.fn(),
|
||||
updateQueryParams: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Component', () => {
|
||||
describe('behavior', () => {
|
||||
describe('handleChange', () => {
|
||||
let el;
|
||||
const newType = 'new Type';
|
||||
const event = { target: { value: newType } };
|
||||
beforeEach(() => {
|
||||
el = shallow(<AssignmentTypeFilter {...props} />);
|
||||
el.instance().handleChange(event);
|
||||
});
|
||||
it('calls props.filterAssignmentType with new type', () => {
|
||||
expect(props.filterAssignmentType).toHaveBeenCalledWith(
|
||||
newType,
|
||||
);
|
||||
});
|
||||
it('updates queryParams with assignmentType', () => {
|
||||
expect(props.updateQueryParams).toHaveBeenCalledWith({
|
||||
assignmentType: newType,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('snapshots', () => {
|
||||
let el;
|
||||
const mockMethods = () => {
|
||||
el.instance().handleChange = jest.fn().mockName('handleChange');
|
||||
};
|
||||
test('smoke test', () => {
|
||||
el = shallow(<AssignmentTypeFilter {...props} />);
|
||||
mockMethods(el);
|
||||
expect(el.instance().render()).toMatchSnapshot();
|
||||
});
|
||||
test('SelectGroup disabled if no assignmentFilterOptions', () => {
|
||||
el = shallow(<AssignmentTypeFilter
|
||||
{...props}
|
||||
assignmentFilterOptions={[]}
|
||||
/>);
|
||||
mockMethods(el);
|
||||
expect(el.instance().render()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('mapStateToProps', () => {
|
||||
const state = {
|
||||
assignmentTypes: {
|
||||
results: ['assignMentType1', 'assignMentType2'],
|
||||
},
|
||||
filters: {
|
||||
assignmentType: 'selectedAssignMent',
|
||||
cohort: 'selectedCOHOrt',
|
||||
track: 'SELectedTrack',
|
||||
},
|
||||
};
|
||||
describe('assignmentTypes', () => {
|
||||
it('is selected from assignmentTypes.allAssignmentTypes', () => {
|
||||
expect(
|
||||
mapStateToProps(state).assignmentTypes,
|
||||
).toEqual(
|
||||
selectors.assignmentTypes.allAssignmentTypes(state),
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('assignmentFilterOptions', () => {
|
||||
it('is selected from filters.selectableAssignmentLabels', () => {
|
||||
expect(
|
||||
mapStateToProps(state).assignmentFilterOptions,
|
||||
).toEqual(
|
||||
selectors.filters.selectableAssignmentLabels(state),
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('selectedAssignmentType', () => {
|
||||
it('is selected from filters.assignmentType', () => {
|
||||
expect(
|
||||
mapStateToProps(state).selectedAssignmentType,
|
||||
).toEqual(
|
||||
selectors.filters.assignmentType(state),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('mapDispatchToProps', () => {
|
||||
test('filterAssignmentType', () => {
|
||||
expect(mapDispatchToProps.filterAssignmentType).toEqual(
|
||||
filterAssignmentType,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`CourseGradeFilter Component snapshots basic snapshot 1`] = `
|
||||
<React.Fragment>
|
||||
<div
|
||||
className="grade-filter-inputs"
|
||||
>
|
||||
<PercentGroup
|
||||
disabled={false}
|
||||
id="minimum-grade"
|
||||
label="Min Grade"
|
||||
onChange={[MockFunction handleUpdateMin]}
|
||||
value="5"
|
||||
/>
|
||||
<PercentGroup
|
||||
disabled={false}
|
||||
id="maximum-grade"
|
||||
label="Max Grade"
|
||||
onChange={[MockFunction handleUpdateMax]}
|
||||
value="92"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="grade-filter-action"
|
||||
>
|
||||
<Button
|
||||
onClick={[MockFunction handleApplyClick]}
|
||||
variant="outline-secondary"
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
`;
|
||||
@@ -0,0 +1,136 @@
|
||||
/* eslint-disable react/sort-comp, react/button-has-type, import/no-named-as-default */
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
Button,
|
||||
} from '@edx/paragon';
|
||||
|
||||
import { updateCourseGradeFilter } from 'data/actions/filters';
|
||||
import { fetchGrades } from 'data/actions/grades';
|
||||
import selectors from 'data/selectors';
|
||||
import PercentGroup from '../PercentGroup';
|
||||
|
||||
export class CourseGradeFilter extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.handleApplyClick = this.handleApplyClick.bind(this);
|
||||
this.handleUpdateMin = this.handleUpdateMin.bind(this);
|
||||
this.handleUpdateMax = this.handleUpdateMax.bind(this);
|
||||
this.updateCourseGradeFilters = this.updateCourseGradeFilters.bind(this);
|
||||
}
|
||||
|
||||
handleApplyClick() {
|
||||
const { courseGradeMin, courseGradeMax } = this.props.filterValues;
|
||||
const isMinValid = this.isGradeFilterValueInRange(courseGradeMin);
|
||||
const isMaxValid = this.isGradeFilterValueInRange(courseGradeMax);
|
||||
|
||||
this.props.setFilters({
|
||||
isMinCourseGradeFilterValid: isMinValid,
|
||||
isMaxCourseGradeFilterValid: isMaxValid,
|
||||
});
|
||||
|
||||
if (isMinValid && isMaxValid) {
|
||||
this.updateCourseGradeFilters();
|
||||
}
|
||||
}
|
||||
|
||||
updateCourseGradeFilters() {
|
||||
const { courseGradeMin, courseGradeMax } = this.props.filterValues;
|
||||
this.props.updateFilter(
|
||||
courseGradeMin,
|
||||
courseGradeMax,
|
||||
this.props.courseId,
|
||||
);
|
||||
this.props.getUserGrades(
|
||||
this.props.courseId,
|
||||
this.props.selectedCohort,
|
||||
this.props.selectedTrack,
|
||||
this.props.selectedAssignmentType,
|
||||
{ courseGradeMin, courseGradeMax },
|
||||
);
|
||||
this.props.updateQueryParams({ courseGradeMin, courseGradeMax });
|
||||
}
|
||||
|
||||
handleUpdateMin(event) {
|
||||
this.props.setFilters({ courseGradeMin: event.target.value });
|
||||
}
|
||||
|
||||
handleUpdateMax(event) {
|
||||
this.props.setFilters({ courseGradeMax: event.target.value });
|
||||
}
|
||||
|
||||
isGradeFilterValueInRange = (value) => {
|
||||
const valueAsInt = parseInt(value, 10);
|
||||
return valueAsInt >= 0 && valueAsInt <= 100;
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<>
|
||||
<div className="grade-filter-inputs">
|
||||
<PercentGroup
|
||||
id="minimum-grade"
|
||||
label="Min Grade"
|
||||
value={this.props.filterValues.courseGradeMin}
|
||||
onChange={this.handleUpdateMin}
|
||||
/>
|
||||
<PercentGroup
|
||||
id="maximum-grade"
|
||||
label="Max Grade"
|
||||
value={this.props.filterValues.courseGradeMax}
|
||||
onChange={this.handleUpdateMax}
|
||||
/>
|
||||
</div>
|
||||
<div className="grade-filter-action">
|
||||
<Button
|
||||
variant="outline-secondary"
|
||||
onClick={this.handleApplyClick}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CourseGradeFilter.defaultProps = {
|
||||
courseId: '',
|
||||
selectedAssignmentType: '',
|
||||
selectedCohort: null,
|
||||
selectedTrack: null,
|
||||
};
|
||||
|
||||
CourseGradeFilter.propTypes = {
|
||||
courseId: PropTypes.string,
|
||||
filterValues: PropTypes.shape({
|
||||
courseGradeMin: PropTypes.string.isRequired,
|
||||
courseGradeMax: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
setFilters: PropTypes.func.isRequired,
|
||||
updateQueryParams: PropTypes.func.isRequired,
|
||||
|
||||
// Redux
|
||||
getUserGrades: PropTypes.func.isRequired,
|
||||
selectedAssignmentType: PropTypes.string,
|
||||
selectedCohort: PropTypes.string,
|
||||
selectedTrack: PropTypes.string,
|
||||
updateFilter: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => {
|
||||
const { filters } = selectors;
|
||||
return {
|
||||
selectedCohort: filters.cohort(state),
|
||||
selectedTrack: filters.track(state),
|
||||
selectedAssignmentType: filters.assignmentType(state),
|
||||
};
|
||||
};
|
||||
|
||||
export const mapDispatchToProps = {
|
||||
updateFilter: updateCourseGradeFilter,
|
||||
getUserGrades: fetchGrades,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(CourseGradeFilter);
|
||||
@@ -0,0 +1,196 @@
|
||||
/* eslint-disable import/no-named-as-default */
|
||||
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { updateCourseGradeFilter } from 'data/actions/filters';
|
||||
import { fetchGrades } from 'data/actions/grades';
|
||||
import {
|
||||
CourseGradeFilter,
|
||||
mapStateToProps,
|
||||
mapDispatchToProps,
|
||||
} from '.';
|
||||
|
||||
jest.mock('@edx/paragon', () => ({
|
||||
Button: 'Button',
|
||||
Collapsible: 'Collapsible',
|
||||
}));
|
||||
|
||||
describe('CourseGradeFilter', () => {
|
||||
let props = {
|
||||
filterValues: {
|
||||
courseGradeMin: '5',
|
||||
courseGradeMax: '92',
|
||||
},
|
||||
courseId: '12345',
|
||||
selectedAssignmentType: 'assignMent type 1',
|
||||
selectedCohort: 'COHort',
|
||||
selectedTrack: 'TracK',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
props = {
|
||||
...props,
|
||||
getUserGrades: jest.fn(),
|
||||
setFilters: jest.fn(),
|
||||
updateQueryParams: jest.fn(),
|
||||
updateFilter: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Component', () => {
|
||||
describe('snapshots', () => {
|
||||
test('basic snapshot', () => {
|
||||
const el = shallow(<CourseGradeFilter {...props} />);
|
||||
el.instance().handleUpdateMin = jest.fn().mockName(
|
||||
'handleUpdateMin',
|
||||
);
|
||||
el.instance().handleUpdateMax = jest.fn().mockName(
|
||||
'handleUpdateMax',
|
||||
);
|
||||
el.instance().handleApplyClick = jest.fn().mockName(
|
||||
'handleApplyClick',
|
||||
);
|
||||
expect(el.instance().render()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe('behavior', () => {
|
||||
let el;
|
||||
const testVal = 'TESTvalue';
|
||||
beforeEach(() => {
|
||||
el = shallow(<CourseGradeFilter {...props} />);
|
||||
});
|
||||
describe('handleApplyClick', () => {
|
||||
beforeEach(() => {
|
||||
el.instance().updateCourseGradeFilters = jest.fn();
|
||||
});
|
||||
it('calls setFilters for isMin(Max)CourseGradeFilterValid', () => {
|
||||
el.instance().isGradeFilterValueInRange = jest.fn().mockImplementation(v => v >= 50);
|
||||
el.instance().handleApplyClick();
|
||||
expect(props.setFilters).toHaveBeenCalledWith({
|
||||
isMinCourseGradeFilterValid: false,
|
||||
isMaxCourseGradeFilterValid: true,
|
||||
});
|
||||
});
|
||||
it('calls updateCourseGradeFilters only if both min and max are valid', () => {
|
||||
const isValid = jest.fn().mockImplementation(v => v >= 50);
|
||||
el.instance().isGradeFilterValueInRange = isValid;
|
||||
el.instance().handleApplyClick();
|
||||
expect(el.instance().updateCourseGradeFilters).not.toHaveBeenCalled();
|
||||
isValid.mockImplementation(v => v <= 50);
|
||||
el.instance().handleApplyClick();
|
||||
expect(el.instance().updateCourseGradeFilters).not.toHaveBeenCalled();
|
||||
isValid.mockImplementation(v => v >= 0);
|
||||
el.instance().handleApplyClick();
|
||||
expect(el.instance().updateCourseGradeFilters).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
describe('updateCourseGradeFilters', () => {
|
||||
beforeEach(() => {
|
||||
el.instance().updateCourseGradeFilters();
|
||||
});
|
||||
it('calls props.updateFilter with selection', () => {
|
||||
expect(props.updateFilter).toHaveBeenCalledWith(
|
||||
props.filterValues.courseGradeMin,
|
||||
props.filterValues.courseGradeMax,
|
||||
props.courseId,
|
||||
);
|
||||
});
|
||||
it('calls props.getUserGrades with selection', () => {
|
||||
expect(props.getUserGrades).toHaveBeenCalledWith(
|
||||
props.courseId,
|
||||
props.selectedCohort,
|
||||
props.selectedTrack,
|
||||
props.selectedAssignmentType,
|
||||
{
|
||||
courseGradeMin: props.filterValues.courseGradeMin,
|
||||
courseGradeMax: props.filterValues.courseGradeMax,
|
||||
},
|
||||
);
|
||||
});
|
||||
it('updates query params with courseGradeMin and courseGradeMax', () => {
|
||||
expect(props.updateQueryParams).toHaveBeenCalledWith({
|
||||
courseGradeMin: props.filterValues.courseGradeMin,
|
||||
courseGradeMax: props.filterValues.courseGradeMax,
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('handleUpdateMin', () => {
|
||||
it('calls props.setCourseGradeMin with event value', () => {
|
||||
el.instance().handleUpdateMin(
|
||||
{ target: { value: testVal } },
|
||||
);
|
||||
expect(props.setFilters).toHaveBeenCalledWith({
|
||||
courseGradeMin: testVal,
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('handleUpdateMax', () => {
|
||||
it('calls props.setCourseGradeMax with event value', () => {
|
||||
el.instance().handleUpdateMax(
|
||||
{ target: { value: testVal } },
|
||||
);
|
||||
expect(props.setFilters).toHaveBeenCalledWith({
|
||||
courseGradeMax: testVal,
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('isFilterValueInRange', () => {
|
||||
it('returns true for values between 0 and 100', () => {
|
||||
expect(el.instance().isGradeFilterValueInRange('0')).toEqual(true);
|
||||
expect(el.instance().isGradeFilterValueInRange(1.1)).toEqual(true);
|
||||
expect(el.instance().isGradeFilterValueInRange('43')).toEqual(true);
|
||||
expect(el.instance().isGradeFilterValueInRange(98.6)).toEqual(true);
|
||||
expect(el.instance().isGradeFilterValueInRange(100)).toEqual(true);
|
||||
});
|
||||
it('returns false for values below 0 and above 100', () => {
|
||||
expect(el.instance().isGradeFilterValueInRange(-1)).toEqual(false);
|
||||
expect(el.instance().isGradeFilterValueInRange(101)).toEqual(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('mapStateToProps', () => {
|
||||
const state = {
|
||||
filters: {
|
||||
cohort: 'COHort',
|
||||
track: 'TRacK',
|
||||
assignmentType: 'TYPe',
|
||||
},
|
||||
};
|
||||
describe('selectedAssignmentType', () => {
|
||||
test('drawn from filters.assignmentType', () => {
|
||||
expect(mapStateToProps(state).selectedAssignmentType).toEqual(
|
||||
state.filters.assignmentType,
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('selectedCohort', () => {
|
||||
test('drawn from filters.cohort', () => {
|
||||
expect(mapStateToProps(state).selectedCohort).toEqual(
|
||||
state.filters.cohort,
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('selectedTrack', () => {
|
||||
test('drawn from filters.track', () => {
|
||||
expect(mapStateToProps(state).selectedTrack).toEqual(
|
||||
state.filters.track,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('mapDispatchToProps', () => {
|
||||
describe('updateFilter', () => {
|
||||
test('from updateCourseGradeFilter', () => {
|
||||
expect(mapDispatchToProps.updateFilter).toEqual(updateCourseGradeFilter);
|
||||
});
|
||||
});
|
||||
describe('getUserGrades', () => {
|
||||
test('from fetchGrades', () => {
|
||||
expect(mapDispatchToProps.getUserGrades).toEqual(fetchGrades);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
39
src/components/Gradebook/GradebookFilters/PercentGroup.jsx
Normal file
39
src/components/Gradebook/GradebookFilters/PercentGroup.jsx
Normal file
@@ -0,0 +1,39 @@
|
||||
/* eslint-disable react/sort-comp */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Form } from '@edx/paragon';
|
||||
|
||||
const PercentGroup = ({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
disabled,
|
||||
onChange,
|
||||
}) => (
|
||||
<div className="percent-group">
|
||||
<Form.Group controlId={id}>
|
||||
<Form.Label>{label}</Form.Label>
|
||||
<Form.Control
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
{...{ value, disabled, onChange }}
|
||||
/>
|
||||
</Form.Group>
|
||||
<span className="input-percent-label">%</span>
|
||||
</div>
|
||||
);
|
||||
PercentGroup.defaultProps = {
|
||||
disabled: false,
|
||||
};
|
||||
PercentGroup.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
label: PropTypes.string.isRequired,
|
||||
value: PropTypes.string.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
disabled: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default PercentGroup;
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import PercentGroup from './PercentGroup';
|
||||
|
||||
describe('PercentGroup', () => {
|
||||
let props = {
|
||||
id: 'group id',
|
||||
label: 'Group Label',
|
||||
value: 'group VALUE',
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
props = {
|
||||
...props,
|
||||
onChange: jest.fn().mockName('props.onChange'),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Component', () => {
|
||||
describe('snapshots', () => {
|
||||
test('basic snapshot', () => {
|
||||
const el = shallow(<PercentGroup {...props} />);
|
||||
expect(el).toMatchSnapshot();
|
||||
});
|
||||
test('disabled', () => {
|
||||
const el = shallow(<PercentGroup {...props} disabled />);
|
||||
expect(el).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
36
src/components/Gradebook/GradebookFilters/SelectGroup.jsx
Normal file
36
src/components/Gradebook/GradebookFilters/SelectGroup.jsx
Normal file
@@ -0,0 +1,36 @@
|
||||
/* eslint-disable react/sort-comp, react/button-has-type */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Form } from '@edx/paragon';
|
||||
|
||||
const SelectGroup = ({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
options,
|
||||
}) => (
|
||||
<div className="student-filters">
|
||||
<Form.Group controlId={id}>
|
||||
<Form.Label>{label}</Form.Label>
|
||||
<Form.Control as="select" {...{ value, onChange, disabled }}>
|
||||
{options}
|
||||
</Form.Control>
|
||||
</Form.Group>
|
||||
</div>
|
||||
);
|
||||
SelectGroup.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
label: PropTypes.string.isRequired,
|
||||
value: PropTypes.string.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
disabled: PropTypes.bool,
|
||||
options: PropTypes.arrayOf(PropTypes.node).isRequired,
|
||||
};
|
||||
SelectGroup.defaultProps = {
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
export default SelectGroup;
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import SelectGroup from './SelectGroup';
|
||||
|
||||
describe('SelectGroup', () => {
|
||||
let props = {
|
||||
id: 'group id',
|
||||
label: 'Group Label',
|
||||
value: 'group VALUE',
|
||||
disabled: false,
|
||||
options: [
|
||||
<option value="opt1" key="opt1">Option 1</option>,
|
||||
<option value="opt2" key="opt2">Option 2</option>,
|
||||
<option value="opt3" key="opt3">Option 3</option>,
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
props = {
|
||||
...props,
|
||||
onChange: jest.fn().mockName('props.onChange'),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Component', () => {
|
||||
describe('snapshots', () => {
|
||||
test('basic snapshot', () => {
|
||||
const el = shallow(<SelectGroup {...props} />);
|
||||
expect(el).toMatchSnapshot();
|
||||
});
|
||||
test('disabled', () => {
|
||||
const el = shallow(<SelectGroup {...props} disabled />);
|
||||
expect(el).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`StudentGroupsFilter Component snapshots Cohorts group disabled if no cohorts 1`] = `
|
||||
<React.Fragment>
|
||||
<SelectGroup
|
||||
disabled={false}
|
||||
id="Tracks"
|
||||
label="Tracks"
|
||||
onChange={[Function]}
|
||||
options={
|
||||
Array [
|
||||
<option
|
||||
value="Track-All"
|
||||
>
|
||||
Track-All
|
||||
</option>,
|
||||
<option
|
||||
value="TracK1"
|
||||
>
|
||||
TracK1
|
||||
</option>,
|
||||
<option
|
||||
value="TracK2"
|
||||
>
|
||||
TracK2
|
||||
</option>,
|
||||
<option
|
||||
value="TRACK3"
|
||||
>
|
||||
TRACK3
|
||||
</option>,
|
||||
]
|
||||
}
|
||||
value="TracK2"
|
||||
/>
|
||||
<SelectGroup
|
||||
disabled={true}
|
||||
id="Cohorts"
|
||||
label="Cohorts"
|
||||
onChange={[Function]}
|
||||
options={
|
||||
Array [
|
||||
<option
|
||||
value="Cohort-All"
|
||||
>
|
||||
Cohort-All
|
||||
</option>,
|
||||
]
|
||||
}
|
||||
value="Cohorts"
|
||||
/>
|
||||
</React.Fragment>
|
||||
`;
|
||||
|
||||
exports[`StudentGroupsFilter Component snapshots basic snapshot 1`] = `
|
||||
<React.Fragment>
|
||||
<SelectGroup
|
||||
disabled={false}
|
||||
id="Tracks"
|
||||
label="Tracks"
|
||||
onChange={[MockFunction updateTracks]}
|
||||
options={
|
||||
Array [
|
||||
<option
|
||||
value="Track-All"
|
||||
>
|
||||
Track-All
|
||||
</option>,
|
||||
<option
|
||||
value="TracK1"
|
||||
>
|
||||
TracK1
|
||||
</option>,
|
||||
<option
|
||||
value="TracK2"
|
||||
>
|
||||
TracK2
|
||||
</option>,
|
||||
<option
|
||||
value="TRACK3"
|
||||
>
|
||||
TRACK3
|
||||
</option>,
|
||||
]
|
||||
}
|
||||
value="TracK2"
|
||||
/>
|
||||
<SelectGroup
|
||||
disabled={false}
|
||||
id="Cohorts"
|
||||
label="Cohorts"
|
||||
onChange={[MockFunction updateCohorts]}
|
||||
options={
|
||||
Array [
|
||||
<option
|
||||
value="Cohort-All"
|
||||
>
|
||||
Cohort-All
|
||||
</option>,
|
||||
<option
|
||||
value="cohorT1"
|
||||
>
|
||||
cohorT1
|
||||
</option>,
|
||||
<option
|
||||
value="cohorT2"
|
||||
>
|
||||
cohorT2
|
||||
</option>,
|
||||
<option
|
||||
value="cohorT3"
|
||||
>
|
||||
cohorT3
|
||||
</option>,
|
||||
]
|
||||
}
|
||||
value="cohorT3"
|
||||
/>
|
||||
</React.Fragment>
|
||||
`;
|
||||
|
||||
exports[`StudentGroupsFilter Component snapshots mapCohortsEntries cohort options: [Cohort-All, <{slug, name}...>] 1`] = `
|
||||
Array [
|
||||
<option
|
||||
value="Cohort-All"
|
||||
>
|
||||
Cohort-All
|
||||
</option>,
|
||||
<option
|
||||
value="cohorT1"
|
||||
>
|
||||
cohorT1
|
||||
</option>,
|
||||
<option
|
||||
value="cohorT2"
|
||||
>
|
||||
cohorT2
|
||||
</option>,
|
||||
<option
|
||||
value="cohorT3"
|
||||
>
|
||||
cohorT3
|
||||
</option>,
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`StudentGroupsFilter Component snapshots mapTracksEntries cohort options: [Track-All, <{id, name}...>] 1`] = `
|
||||
Array [
|
||||
<option
|
||||
value="Track-All"
|
||||
>
|
||||
Track-All
|
||||
</option>,
|
||||
<option
|
||||
value="TracK1"
|
||||
>
|
||||
TracK1
|
||||
</option>,
|
||||
<option
|
||||
value="TracK2"
|
||||
>
|
||||
TracK2
|
||||
</option>,
|
||||
<option
|
||||
value="TRACK3"
|
||||
>
|
||||
TRACK3
|
||||
</option>,
|
||||
]
|
||||
`;
|
||||
@@ -0,0 +1,153 @@
|
||||
/* eslint-disable react/sort-comp, react/button-has-type, import/no-named-as-default */
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { fetchGrades } from 'data/actions/grades';
|
||||
import selectors from 'data/selectors';
|
||||
import SelectGroup from '../SelectGroup';
|
||||
|
||||
export class StudentGroupsFilter extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.updateCohorts = this.updateCohorts.bind(this);
|
||||
this.updateTracks = this.updateTracks.bind(this);
|
||||
}
|
||||
|
||||
mapCohortsEntries = () => {
|
||||
const mapper = ({ id, name }) => (
|
||||
<option key={id} value={name}>{name}</option>
|
||||
);
|
||||
return [
|
||||
<option value="Cohort-All" key="0">Cohort-All</option>,
|
||||
...this.props.cohorts.map(mapper),
|
||||
];
|
||||
};
|
||||
|
||||
mapTracksEntries = () => {
|
||||
const mapper = ({ slug, name }) => (
|
||||
<option key={slug} value={name}>{name}</option>
|
||||
);
|
||||
return [
|
||||
<option value="Track-All" key="0">Track-All</option>,
|
||||
...this.props.tracks.map(mapper),
|
||||
];
|
||||
};
|
||||
|
||||
mapSelectedCohortEntry = () => {
|
||||
const selectedCohortEntry = this.props.cohorts.find(
|
||||
(x) => x.id === parseInt(this.props.selectedCohort, 10),
|
||||
);
|
||||
return selectedCohortEntry ? selectedCohortEntry.name : 'Cohorts';
|
||||
};
|
||||
|
||||
mapSelectedTrackEntry = () => {
|
||||
const selectedTrackEntry = this.props.tracks.find(
|
||||
({ slug }) => slug === this.props.selectedTrack,
|
||||
);
|
||||
return selectedTrackEntry ? selectedTrackEntry.name : 'Tracks';
|
||||
};
|
||||
|
||||
selectedTrackSlugFromEvent(event) {
|
||||
const selectedTrackItem = this.props.tracks.find(
|
||||
({ name }) => name === event.target.value,
|
||||
);
|
||||
return selectedTrackItem ? selectedTrackItem.slug : null;
|
||||
}
|
||||
|
||||
selectedCohortIdFromEvent(event) {
|
||||
const selectedCohortItem = this.props.cohorts.find(
|
||||
x => x.name === event.target.value,
|
||||
);
|
||||
return selectedCohortItem ? selectedCohortItem.id.toString() : null;
|
||||
}
|
||||
|
||||
updateTracks(event) {
|
||||
const selectedTrackSlug = this.selectedTrackSlugFromEvent(event);
|
||||
this.props.getUserGrades(
|
||||
this.props.courseId,
|
||||
this.props.selectedCohort,
|
||||
selectedTrackSlug,
|
||||
this.props.selectedAssignmentType,
|
||||
);
|
||||
this.props.updateQueryParams({ track: selectedTrackSlug });
|
||||
}
|
||||
|
||||
updateCohorts(event) {
|
||||
const selectedCohortId = this.selectedCohortIdFromEvent(event);
|
||||
this.props.getUserGrades(
|
||||
this.props.courseId,
|
||||
selectedCohortId,
|
||||
this.props.selectedTrack,
|
||||
this.props.selectedAssignmentType,
|
||||
);
|
||||
this.props.updateQueryParams({ cohort: selectedCohortId });
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<>
|
||||
<SelectGroup
|
||||
id="Tracks"
|
||||
label="Tracks"
|
||||
value={this.mapSelectedTrackEntry()}
|
||||
onChange={this.updateTracks}
|
||||
options={this.mapTracksEntries()}
|
||||
/>
|
||||
<SelectGroup
|
||||
id="Cohorts"
|
||||
label="Cohorts"
|
||||
value={this.mapSelectedCohortEntry()}
|
||||
disabled={this.props.cohorts.length === 0}
|
||||
onChange={this.updateCohorts}
|
||||
options={this.mapCohortsEntries()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
StudentGroupsFilter.defaultProps = {
|
||||
cohorts: [],
|
||||
courseId: '',
|
||||
selectedAssignmentType: '',
|
||||
selectedCohort: null,
|
||||
selectedTrack: null,
|
||||
tracks: [],
|
||||
};
|
||||
|
||||
StudentGroupsFilter.propTypes = {
|
||||
courseId: PropTypes.string,
|
||||
updateQueryParams: PropTypes.func.isRequired,
|
||||
|
||||
// redux
|
||||
cohorts: PropTypes.arrayOf(PropTypes.shape({
|
||||
name: PropTypes.string,
|
||||
id: PropTypes.number,
|
||||
})),
|
||||
getUserGrades: PropTypes.func.isRequired,
|
||||
selectedAssignmentType: PropTypes.string,
|
||||
selectedCohort: PropTypes.string,
|
||||
selectedTrack: PropTypes.string,
|
||||
tracks: PropTypes.arrayOf(PropTypes.shape({
|
||||
name: PropTypes.string,
|
||||
slug: PropTypes.string,
|
||||
})),
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => {
|
||||
const { filters, cohorts, tracks } = selectors;
|
||||
return {
|
||||
cohorts: cohorts.allCohorts(state),
|
||||
selectedAssignmentType: filters.assignmentType(state),
|
||||
selectedCohort: filters.cohort(state),
|
||||
selectedTrack: filters.track(state),
|
||||
tracks: tracks.allTracks(state),
|
||||
};
|
||||
};
|
||||
|
||||
export const mapDispatchToProps = {
|
||||
getUserGrades: fetchGrades,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(StudentGroupsFilter);
|
||||
@@ -0,0 +1,238 @@
|
||||
/* eslint-disable import/no-named-as-default */
|
||||
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { fetchGrades } from 'data/actions/grades';
|
||||
import {
|
||||
StudentGroupsFilter,
|
||||
mapStateToProps,
|
||||
mapDispatchToProps,
|
||||
} from '.';
|
||||
|
||||
describe('StudentGroupsFilter', () => {
|
||||
let props = {
|
||||
courseId: '12345',
|
||||
cohorts: [
|
||||
{ name: 'cohorT1', id: 8001 },
|
||||
{ name: 'cohorT2', id: 8002 },
|
||||
{ name: 'cohorT3', id: 8003 },
|
||||
],
|
||||
selectedAssignmentType: 'assignMent type 1',
|
||||
selectedCohort: '8003',
|
||||
selectedTrack: 'TracK2_slug',
|
||||
tracks: [
|
||||
{ name: 'TracK1', slug: 'TracK1_slug' },
|
||||
{ name: 'TracK2', slug: 'TracK2_slug' },
|
||||
{ name: 'TRACK3', slug: 'TRACK3_slug' },
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
props = {
|
||||
...props,
|
||||
getUserGrades: jest.fn(),
|
||||
updateQueryParams: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Component', () => {
|
||||
describe('snapshots', () => {
|
||||
let el;
|
||||
beforeEach(() => {
|
||||
el = shallow(<StudentGroupsFilter {...props} />);
|
||||
});
|
||||
test('basic snapshot', () => {
|
||||
el.instance().updateTracks = jest.fn().mockName(
|
||||
'updateTracks',
|
||||
);
|
||||
el.instance().updateCohorts = jest.fn().mockName(
|
||||
'updateCohorts',
|
||||
);
|
||||
expect(el.instance().render()).toMatchSnapshot();
|
||||
});
|
||||
test('Cohorts group disabled if no cohorts', () => {
|
||||
el.setProps({ cohorts: [] });
|
||||
expect(el.instance().render()).toMatchSnapshot();
|
||||
});
|
||||
describe('mapCohortsEntries', () => {
|
||||
test('cohort options: [Cohort-All, <{slug, name}...>]', () => {
|
||||
expect(el.instance().mapCohortsEntries()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
describe('mapTracksEntries', () => {
|
||||
test('cohort options: [Track-All, <{id, name}...>]', () => {
|
||||
expect(el.instance().mapTracksEntries()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('behavior', () => {
|
||||
let el;
|
||||
beforeEach(() => {
|
||||
el = shallow(<StudentGroupsFilter {...props} />);
|
||||
});
|
||||
describe('mapSelectedCohortEntry', () => {
|
||||
it('returns the name of the cohort with the same numerical id', () => {
|
||||
// Because selectedCohort is the id of cohorts[2]
|
||||
expect(el.instance().mapSelectedCohortEntry()).toEqual(
|
||||
props.cohorts[2].name,
|
||||
);
|
||||
});
|
||||
it('returns "Cohorts" if no cohort is found', () => {
|
||||
el.setProps({ selectedCohort: '999' });
|
||||
expect(el.instance().mapSelectedCohortEntry()).toEqual(
|
||||
'Cohorts',
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('mapSelectedTrackEntry', () => {
|
||||
it('returns the name of the track with the selected slug', () => {
|
||||
// Because selectedTrack is the slug of tracks[1]
|
||||
expect(el.instance().mapSelectedTrackEntry()).toEqual(
|
||||
props.tracks[1].name,
|
||||
);
|
||||
});
|
||||
it('returns "Tracks" if no track is found', () => {
|
||||
el.setProps({ selectedTrack: 'FAKE' });
|
||||
expect(el.instance().mapSelectedTrackEntry()).toEqual(
|
||||
'Tracks',
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('selectedCohortIdFromEvent', () => {
|
||||
it('returns the id of the cohort with the name matching the event', () => {
|
||||
expect(
|
||||
el.instance().selectedCohortIdFromEvent(
|
||||
{ target: { value: props.cohorts[1].name } },
|
||||
),
|
||||
).toEqual(props.cohorts[1].id.toString());
|
||||
});
|
||||
it('returns null if no matching cohort is found', () => {
|
||||
expect(
|
||||
el.instance().selectedCohortIdFromEvent(
|
||||
{ target: { value: 'FAKE' } },
|
||||
),
|
||||
).toEqual(null);
|
||||
});
|
||||
});
|
||||
describe('selectedTrackSlugFromEvent', () => {
|
||||
it('returns the slug of the track with the name matching the event', () => {
|
||||
expect(
|
||||
el.instance().selectedTrackSlugFromEvent(
|
||||
{ target: { value: props.tracks[1].name } },
|
||||
),
|
||||
).toEqual(props.tracks[1].slug);
|
||||
});
|
||||
it('returns null if no matching track is found', () => {
|
||||
expect(
|
||||
el.instance().selectedTrackSlugFromEvent(
|
||||
{ target: { value: 'FAKE' } },
|
||||
),
|
||||
).toEqual(null);
|
||||
});
|
||||
});
|
||||
describe('updateTracks', () => {
|
||||
const selectedSlug = 'SLUG';
|
||||
beforeEach(() => {
|
||||
el = shallow(<StudentGroupsFilter {...props} />);
|
||||
jest.spyOn(
|
||||
el.instance(),
|
||||
'selectedTrackSlugFromEvent',
|
||||
).mockReturnValue(selectedSlug);
|
||||
el.instance().updateTracks({ target: {} });
|
||||
});
|
||||
it('calls getUserGrades with selection', () => {
|
||||
expect(props.getUserGrades).toHaveBeenCalledWith(
|
||||
props.courseId,
|
||||
props.selectedCohort,
|
||||
selectedSlug,
|
||||
props.selectedAssignmentType,
|
||||
);
|
||||
});
|
||||
it('updates queryParams with track value', () => {
|
||||
expect(props.updateQueryParams).toHaveBeenCalledWith({
|
||||
track: selectedSlug,
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('updateCohorts', () => {
|
||||
const selectedId = 23;
|
||||
beforeEach(() => {
|
||||
el = shallow(<StudentGroupsFilter {...props} />);
|
||||
jest.spyOn(
|
||||
el.instance(),
|
||||
'selectedCohortIdFromEvent',
|
||||
).mockReturnValue(selectedId);
|
||||
el.instance().updateCohorts({ target: {} });
|
||||
});
|
||||
it('calls getUserGrades with selection', () => {
|
||||
expect(props.getUserGrades).toHaveBeenCalledWith(
|
||||
props.courseId,
|
||||
selectedId,
|
||||
props.selectedTrack,
|
||||
props.selectedAssignmentType,
|
||||
);
|
||||
});
|
||||
it('updates queryParams with cohort value', () => {
|
||||
expect(props.updateQueryParams).toHaveBeenCalledWith({
|
||||
cohort: selectedId,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('mapStateToProps', () => {
|
||||
const state = {
|
||||
cohorts: { results: ['some', 'cohorts'] },
|
||||
filters: {
|
||||
cohort: 'COHort',
|
||||
track: 'TRacK',
|
||||
assignmentType: 'TYPe',
|
||||
},
|
||||
tracks: { results: ['a', 'few', 'tracks'] },
|
||||
};
|
||||
describe('cohorts', () => {
|
||||
test('drawn from cohorts.results', () => {
|
||||
expect(mapStateToProps(state).cohorts).toEqual(
|
||||
state.cohorts.results,
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('selectedAssignmentType', () => {
|
||||
test('drawn from filters.assignmentType', () => {
|
||||
expect(mapStateToProps(state).selectedAssignmentType).toEqual(
|
||||
state.filters.assignmentType,
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('selectedCohort', () => {
|
||||
test('drawn from filters.cohort', () => {
|
||||
expect(mapStateToProps(state).selectedCohort).toEqual(
|
||||
state.filters.cohort,
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('selectedTrack', () => {
|
||||
test('drawn from filters.track', () => {
|
||||
expect(mapStateToProps(state).selectedTrack).toEqual(
|
||||
state.filters.track,
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('tracks', () => {
|
||||
test('drawn from tracks.results', () => {
|
||||
expect(mapStateToProps(state).tracks).toEqual(
|
||||
state.tracks.results,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('mapDispatchToProps', () => {
|
||||
describe('getUserGrades', () => {
|
||||
test('from fetchGrades', () => {
|
||||
expect(mapDispatchToProps.getUserGrades).toEqual(fetchGrades);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`PercentGroup Component snapshots basic snapshot 1`] = `
|
||||
<div
|
||||
className="percent-group"
|
||||
>
|
||||
<FormGroup
|
||||
as="div"
|
||||
controlId="group id"
|
||||
isInvalid={false}
|
||||
isValid={false}
|
||||
>
|
||||
<FormLabel
|
||||
isInline={false}
|
||||
>
|
||||
Group Label
|
||||
</FormLabel>
|
||||
<ForwardRef
|
||||
as="input"
|
||||
disabled={false}
|
||||
max={100}
|
||||
min={0}
|
||||
onChange={[MockFunction props.onChange]}
|
||||
plaintext={false}
|
||||
step={1}
|
||||
type="number"
|
||||
value="group VALUE"
|
||||
/>
|
||||
</FormGroup>
|
||||
<span
|
||||
className="input-percent-label"
|
||||
>
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`PercentGroup Component snapshots disabled 1`] = `
|
||||
<div
|
||||
className="percent-group"
|
||||
>
|
||||
<FormGroup
|
||||
as="div"
|
||||
controlId="group id"
|
||||
isInvalid={false}
|
||||
isValid={false}
|
||||
>
|
||||
<FormLabel
|
||||
isInline={false}
|
||||
>
|
||||
Group Label
|
||||
</FormLabel>
|
||||
<ForwardRef
|
||||
as="input"
|
||||
disabled={true}
|
||||
max={100}
|
||||
min={0}
|
||||
onChange={[MockFunction props.onChange]}
|
||||
plaintext={false}
|
||||
step={1}
|
||||
type="number"
|
||||
value="group VALUE"
|
||||
/>
|
||||
</FormGroup>
|
||||
<span
|
||||
className="input-percent-label"
|
||||
>
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,91 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`SelectGroup Component snapshots basic snapshot 1`] = `
|
||||
<div
|
||||
className="student-filters"
|
||||
>
|
||||
<FormGroup
|
||||
as="div"
|
||||
controlId="group id"
|
||||
isInvalid={false}
|
||||
isValid={false}
|
||||
>
|
||||
<FormLabel
|
||||
isInline={false}
|
||||
>
|
||||
Group Label
|
||||
</FormLabel>
|
||||
<ForwardRef
|
||||
as="select"
|
||||
disabled={false}
|
||||
onChange={[MockFunction props.onChange]}
|
||||
plaintext={false}
|
||||
value="group VALUE"
|
||||
>
|
||||
<option
|
||||
key="opt1"
|
||||
value="opt1"
|
||||
>
|
||||
Option 1
|
||||
</option>
|
||||
<option
|
||||
key="opt2"
|
||||
value="opt2"
|
||||
>
|
||||
Option 2
|
||||
</option>
|
||||
<option
|
||||
key="opt3"
|
||||
value="opt3"
|
||||
>
|
||||
Option 3
|
||||
</option>
|
||||
</ForwardRef>
|
||||
</FormGroup>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`SelectGroup Component snapshots disabled 1`] = `
|
||||
<div
|
||||
className="student-filters"
|
||||
>
|
||||
<FormGroup
|
||||
as="div"
|
||||
controlId="group id"
|
||||
isInvalid={false}
|
||||
isValid={false}
|
||||
>
|
||||
<FormLabel
|
||||
isInline={false}
|
||||
>
|
||||
Group Label
|
||||
</FormLabel>
|
||||
<ForwardRef
|
||||
as="select"
|
||||
disabled={true}
|
||||
onChange={[MockFunction props.onChange]}
|
||||
plaintext={false}
|
||||
value="group VALUE"
|
||||
>
|
||||
<option
|
||||
key="opt1"
|
||||
value="opt1"
|
||||
>
|
||||
Option 1
|
||||
</option>
|
||||
<option
|
||||
key="opt2"
|
||||
value="opt2"
|
||||
>
|
||||
Option 2
|
||||
</option>
|
||||
<option
|
||||
key="opt3"
|
||||
value="opt3"
|
||||
>
|
||||
Option 3
|
||||
</option>
|
||||
</ForwardRef>
|
||||
</FormGroup>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,75 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`GradebookFilters Component snapshots basic snapshot 1`] = `
|
||||
<React.Fragment>
|
||||
<Collapsible
|
||||
className="filter-group mb-3"
|
||||
defaultOpen={true}
|
||||
title="Assignments"
|
||||
>
|
||||
<div>
|
||||
<Connect(AssignmentTypeFilter)
|
||||
updateQueryParams={[MockFunction]}
|
||||
/>
|
||||
<Connect(AssignmentFilter)
|
||||
courseId="12345"
|
||||
updateQueryParams={[MockFunction]}
|
||||
/>
|
||||
<Connect(AssignmentGradeFilter)
|
||||
courseId="12345"
|
||||
filterValues={
|
||||
Object {
|
||||
"assignmentGradeMax": "90",
|
||||
"assignmentGradeMin": "10",
|
||||
"courseGradeMax": "80",
|
||||
"courseGradeMin": "20",
|
||||
}
|
||||
}
|
||||
setFilters={[MockFunction]}
|
||||
updateQueryParams={[MockFunction]}
|
||||
/>
|
||||
</div>
|
||||
</Collapsible>
|
||||
<Collapsible
|
||||
className="filter-group mb-3"
|
||||
defaultOpen={true}
|
||||
title="Overall Grade"
|
||||
>
|
||||
<Connect(CourseGradeFilter)
|
||||
courseId="12345"
|
||||
filterValues={
|
||||
Object {
|
||||
"assignmentGradeMax": "90",
|
||||
"assignmentGradeMin": "10",
|
||||
"courseGradeMax": "80",
|
||||
"courseGradeMin": "20",
|
||||
}
|
||||
}
|
||||
setFilters={[MockFunction]}
|
||||
updateQueryParams={[MockFunction]}
|
||||
/>
|
||||
</Collapsible>
|
||||
<Collapsible
|
||||
className="filter-group mb-3"
|
||||
defaultOpen={true}
|
||||
title="Student Groups"
|
||||
>
|
||||
<Connect(StudentGroupsFilter)
|
||||
courseId="12345"
|
||||
updateQueryParams={[MockFunction]}
|
||||
/>
|
||||
</Collapsible>
|
||||
<Collapsible
|
||||
className="filter-group mb-3"
|
||||
defaultOpen={true}
|
||||
title="Include Course Team Members"
|
||||
>
|
||||
<Checkbox
|
||||
checked={true}
|
||||
onChange={[MockFunction handleIncludeTeamMembersChange]}
|
||||
>
|
||||
Include Course Team Members
|
||||
</Checkbox>
|
||||
</Collapsible>
|
||||
</React.Fragment>
|
||||
`;
|
||||
120
src/components/Gradebook/GradebookFilters/index.jsx
Normal file
120
src/components/Gradebook/GradebookFilters/index.jsx
Normal file
@@ -0,0 +1,120 @@
|
||||
/* eslint-disable react/sort-comp, import/no-named-as-default */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { Collapsible, Form } from '@edx/paragon';
|
||||
|
||||
import * as filterActions from 'data/actions/filters';
|
||||
import selectors from 'data/selectors';
|
||||
|
||||
import AssignmentTypeFilter from './AssignmentTypeFilter';
|
||||
import AssignmentFilter from './AssignmentFilter';
|
||||
import AssignmentGradeFilter from './AssignmentGradeFilter';
|
||||
import CourseGradeFilter from './CourseGradeFilter';
|
||||
import StudentGroupsFilter from './StudentGroupsFilter';
|
||||
|
||||
export class GradebookFilters extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
includeCourseRoleMembers: this.props.includeCourseRoleMembers,
|
||||
};
|
||||
this.handleIncludeTeamMembersChange = this.handleIncludeTeamMembersChange.bind(this);
|
||||
}
|
||||
|
||||
handleIncludeTeamMembersChange(event) {
|
||||
const includeCourseRoleMembers = event.target.checked;
|
||||
this.setState({ includeCourseRoleMembers });
|
||||
this.props.updateIncludeCourseRoleMembers(includeCourseRoleMembers);
|
||||
this.props.updateQueryParams({ includeCourseRoleMembers });
|
||||
}
|
||||
|
||||
collapsibleGroup = (title, content) => (
|
||||
<Collapsible title={title} defaultOpen className="filter-group mb-3">
|
||||
{content}
|
||||
</Collapsible>
|
||||
);
|
||||
|
||||
render() {
|
||||
const {
|
||||
courseId,
|
||||
filterValues,
|
||||
setFilters,
|
||||
updateQueryParams,
|
||||
} = this.props;
|
||||
return (
|
||||
<>
|
||||
{this.collapsibleGroup('Assignments', (
|
||||
<div>
|
||||
<AssignmentTypeFilter
|
||||
updateQueryParams={updateQueryParams}
|
||||
/>
|
||||
<AssignmentFilter
|
||||
courseId={courseId}
|
||||
updateQueryParams={updateQueryParams}
|
||||
/>
|
||||
<AssignmentGradeFilter
|
||||
{...{
|
||||
courseId,
|
||||
filterValues,
|
||||
setFilters,
|
||||
updateQueryParams,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{this.collapsibleGroup('Overall Grade', (
|
||||
<CourseGradeFilter
|
||||
{...{
|
||||
filterValues,
|
||||
setFilters,
|
||||
courseId,
|
||||
updateQueryParams,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{this.collapsibleGroup('Student Groups', (
|
||||
<StudentGroupsFilter
|
||||
courseId={courseId}
|
||||
updateQueryParams={updateQueryParams}
|
||||
/>
|
||||
))}
|
||||
{this.collapsibleGroup('Include Course Team Members', (
|
||||
<Form.Checkbox
|
||||
checked={this.state.includeCourseRoleMembers}
|
||||
onChange={this.handleIncludeTeamMembersChange}
|
||||
>
|
||||
Include Course Team Members
|
||||
</Form.Checkbox>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
GradebookFilters.defaultProps = {
|
||||
includeCourseRoleMembers: false,
|
||||
};
|
||||
GradebookFilters.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
filterValues: PropTypes.shape({
|
||||
assignmentGradeMin: PropTypes.string,
|
||||
assignmentGradeMax: PropTypes.string,
|
||||
courseGradeMin: PropTypes.string,
|
||||
courseGradeMax: PropTypes.string,
|
||||
}).isRequired,
|
||||
setFilters: PropTypes.func.isRequired,
|
||||
includeCourseRoleMembers: PropTypes.bool,
|
||||
updateIncludeCourseRoleMembers: PropTypes.func.isRequired,
|
||||
updateQueryParams: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => ({
|
||||
includeCourseRoleMembers: selectors.filters.includeCourseRoleMembers(state),
|
||||
});
|
||||
|
||||
export const mapDispatchToProps = {
|
||||
updateIncludeCourseRoleMembers: filterActions.updateIncludeCourseRoleMembers,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(GradebookFilters);
|
||||
105
src/components/Gradebook/GradebookFilters/test.jsx
Normal file
105
src/components/Gradebook/GradebookFilters/test.jsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { updateIncludeCourseRoleMembers } from 'data/actions/filters';
|
||||
|
||||
import {
|
||||
GradebookFilters,
|
||||
mapStateToProps,
|
||||
mapDispatchToProps,
|
||||
} from '.';
|
||||
|
||||
jest.mock('@edx/paragon', () => ({
|
||||
Collapsible: 'Collapsible',
|
||||
Form: {
|
||||
Checkbox: 'Checkbox',
|
||||
},
|
||||
}));
|
||||
|
||||
describe('GradebookFilters', () => {
|
||||
let props = {
|
||||
courseId: '12345',
|
||||
filterValues: {
|
||||
assignmentGradeMin: '10',
|
||||
assignmentGradeMax: '90',
|
||||
courseGradeMin: '20',
|
||||
courseGradeMax: '80',
|
||||
},
|
||||
includeCourseRoleMembers: true,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
props = {
|
||||
...props,
|
||||
updateQueryParams: jest.fn(),
|
||||
updateIncludeCourseRoleMembers: jest.fn(),
|
||||
setFilters: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Component', () => {
|
||||
describe('behavior', () => {
|
||||
describe('handleIncludeTeamMembersChange', () => {
|
||||
let el;
|
||||
beforeEach(() => {
|
||||
el = shallow(<GradebookFilters {...props} />);
|
||||
el.instance().setState = jest.fn();
|
||||
});
|
||||
it('calls setState with newVal', () => {
|
||||
el.instance().handleIncludeTeamMembersChange(
|
||||
{ target: { checked: true } },
|
||||
);
|
||||
expect(
|
||||
el.instance().setState,
|
||||
).toHaveBeenCalledWith({ includeCourseRoleMembers: true });
|
||||
});
|
||||
it('calls props.updateIncludeCourseRoleMembers with newVal', () => {
|
||||
el.instance().handleIncludeTeamMembersChange(
|
||||
{ target: { checked: false } },
|
||||
);
|
||||
expect(
|
||||
props.updateIncludeCourseRoleMembers,
|
||||
).toHaveBeenCalledWith(false);
|
||||
});
|
||||
it('calls props.updateQueryParams with newVal', () => {
|
||||
el.instance().handleIncludeTeamMembersChange(
|
||||
{ target: { checked: true } },
|
||||
);
|
||||
expect(
|
||||
props.updateQueryParams,
|
||||
).toHaveBeenCalledWith({ includeCourseRoleMembers: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('snapshots', () => {
|
||||
test('basic snapshot', () => {
|
||||
const el = shallow(<GradebookFilters {...props} />);
|
||||
el.instance().handleIncludeTeamMembersChange = jest.fn().mockName(
|
||||
'handleIncludeTeamMembersChange',
|
||||
);
|
||||
expect(el.instance().render()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('mapStateToProps', () => {
|
||||
const state = {
|
||||
filters: {
|
||||
includeCourseRoleMembers: 'plz do',
|
||||
},
|
||||
};
|
||||
describe('includeCourseRoleMembers', () => {
|
||||
it('is drawn from filters.includeCourseRoleMembers', () => {
|
||||
expect(mapStateToProps(state).includeCourseRoleMembers).toEqual(
|
||||
state.filters.includeCourseRoleMembers,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('mapDispatchToProps', () => {
|
||||
test('updateIncludeCourseRoleMembers', () => {
|
||||
expect(mapDispatchToProps.updateIncludeCourseRoleMembers).toEqual(
|
||||
updateIncludeCourseRoleMembers,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
234
src/components/Gradebook/GradebookTable.jsx
Normal file
234
src/components/Gradebook/GradebookTable.jsx
Normal file
@@ -0,0 +1,234 @@
|
||||
/* eslint-disable react/sort-comp, react/button-has-type */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import {
|
||||
Table, OverlayTrigger, Tooltip, Icon,
|
||||
} from '@edx/paragon';
|
||||
|
||||
import { formatDateForDisplay } from '../../data/actions/utils';
|
||||
import { fetchGradeOverrideHistory } from '../../data/actions/grades';
|
||||
import { EMAIL_HEADING, TOTAL_COURSE_GRADE_HEADING, USERNAME_HEADING } from '../../data/constants/grades';
|
||||
import selectors from '../../data/selectors';
|
||||
|
||||
const DECIMAL_PRECISION = 2;
|
||||
|
||||
export class GradebookTable extends React.Component {
|
||||
setNewModalState = (userEntry, subsection) => {
|
||||
this.props.fetchGradeOverrideHistory(
|
||||
subsection.module_id,
|
||||
userEntry.user_id,
|
||||
);
|
||||
|
||||
let adjustedGradePossible = '';
|
||||
if (subsection.attempted) {
|
||||
adjustedGradePossible = subsection.score_possible;
|
||||
}
|
||||
|
||||
this.props.setGradebookState({
|
||||
adjustedGradePossible,
|
||||
adjustedGradeValue: '',
|
||||
assignmentName: `${subsection.subsection_name}`,
|
||||
modalOpen: true,
|
||||
reasonForChange: '',
|
||||
todaysDate: formatDateForDisplay(new Date()),
|
||||
updateModuleId: subsection.module_id,
|
||||
updateUserId: userEntry.user_id,
|
||||
updateUserName: userEntry.username,
|
||||
});
|
||||
}
|
||||
|
||||
getLearnerInformation = entry => (
|
||||
<div>
|
||||
<div>{entry.username}</div>
|
||||
{entry.external_user_key && <div className="student-key">{entry.external_user_key}</div>}
|
||||
</div>
|
||||
)
|
||||
|
||||
roundGrade = percent => parseFloat((percent || 0).toFixed(DECIMAL_PRECISION));
|
||||
|
||||
formatter = {
|
||||
percent: (entries, areGradesFrozen) => entries.map((entry) => {
|
||||
const learnerInformation = this.getLearnerInformation(entry);
|
||||
const results = {
|
||||
[USERNAME_HEADING]: (
|
||||
<div><span className="wrap-text-in-cell">{learnerInformation}</span></div>
|
||||
),
|
||||
[EMAIL_HEADING]: (
|
||||
<span className="wrap-text-in-cell">{entry.email}</span>
|
||||
),
|
||||
};
|
||||
|
||||
const assignments = entry.section_breakdown
|
||||
.reduce((acc, subsection) => {
|
||||
if (areGradesFrozen) {
|
||||
acc[subsection.label] = `${this.roundGrade(subsection.percent * 100)} %`;
|
||||
} else {
|
||||
acc[subsection.label] = (
|
||||
<button
|
||||
className="btn btn-header link-style grade-button"
|
||||
onClick={() => this.setNewModalState(entry, subsection)}
|
||||
>
|
||||
{this.roundGrade(subsection.percent * 100)}%
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
const totals = { [TOTAL_COURSE_GRADE_HEADING]: `${this.roundGrade(entry.percent * 100)}%` };
|
||||
return Object.assign(results, assignments, totals);
|
||||
}),
|
||||
|
||||
absolute: (entries, areGradesFrozen) => entries.map((entry) => {
|
||||
const learnerInformation = this.getLearnerInformation(entry);
|
||||
const results = {
|
||||
[USERNAME_HEADING]: (
|
||||
<div><span className="wrap-text-in-cell">{learnerInformation}</span></div>
|
||||
),
|
||||
[EMAIL_HEADING]: (
|
||||
<span className="wrap-text-in-cell">{entry.email}</span>
|
||||
),
|
||||
};
|
||||
|
||||
const assignments = entry.section_breakdown
|
||||
.reduce((acc, subsection) => {
|
||||
const scoreEarned = this.roundGrade(subsection.score_earned);
|
||||
const scorePossible = this.roundGrade(subsection.score_possible);
|
||||
let label = `${scoreEarned}`;
|
||||
if (subsection.attempted) {
|
||||
label = `${scoreEarned}/${scorePossible}`;
|
||||
}
|
||||
if (areGradesFrozen) {
|
||||
acc[subsection.label] = label;
|
||||
} else {
|
||||
acc[subsection.label] = (
|
||||
<button
|
||||
className="btn btn-header link-style"
|
||||
onClick={() => this.setNewModalState(entry, subsection)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// Show this as a percent no matter what the other setting is. The data
|
||||
// we're getting gives the final grade as a percentage so making it appear
|
||||
// to be "out of" 100 is misleading.
|
||||
const totals = { [TOTAL_COURSE_GRADE_HEADING]: `${this.roundGrade(entry.percent * 100)}%` };
|
||||
return Object.assign(results, assignments, totals);
|
||||
}),
|
||||
};
|
||||
|
||||
formatHeadings = () => {
|
||||
let headings = [...this.props.headings];
|
||||
|
||||
if (headings.length > 0) {
|
||||
const headerLabelReplacements = {};
|
||||
headerLabelReplacements[USERNAME_HEADING] = (
|
||||
<div>
|
||||
<div>Username</div>
|
||||
<div className="font-weight-normal student-key">Student Key*</div>
|
||||
</div>
|
||||
);
|
||||
headerLabelReplacements[EMAIL_HEADING] = 'Email*';
|
||||
|
||||
const totalGradePercentageMessage = 'Total Grade values are always displayed as a percentage.';
|
||||
headerLabelReplacements[TOTAL_COURSE_GRADE_HEADING] = (
|
||||
<div>
|
||||
<OverlayTrigger
|
||||
trigger={['hover', 'focus']}
|
||||
key="left-basic"
|
||||
placement="left"
|
||||
overlay={(<Tooltip id="course-grade-tooltip">{totalGradePercentageMessage}</Tooltip>)}
|
||||
>
|
||||
<div>
|
||||
{TOTAL_COURSE_GRADE_HEADING}
|
||||
<div id="courseGradeTooltipIcon">
|
||||
<Icon className="fa fa-info-circle" screenReaderText={totalGradePercentageMessage} />
|
||||
</div>
|
||||
</div>
|
||||
</OverlayTrigger>
|
||||
</div>
|
||||
);
|
||||
|
||||
headings = headings.map(heading => {
|
||||
const result = {
|
||||
label: heading,
|
||||
key: heading,
|
||||
};
|
||||
if (headerLabelReplacements[heading] !== undefined) {
|
||||
result.label = headerLabelReplacements[heading];
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
return headings;
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="gradebook-container">
|
||||
<div className="gbook">
|
||||
<Table
|
||||
columns={this.formatHeadings()}
|
||||
data={this.formatter[this.props.format](
|
||||
this.props.grades,
|
||||
this.props.areGradesFrozen,
|
||||
)}
|
||||
rowHeaderColumnKey="username"
|
||||
hasFixedColumnWidths
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
GradebookTable.defaultProps = {
|
||||
areGradesFrozen: false,
|
||||
grades: [],
|
||||
};
|
||||
|
||||
GradebookTable.propTypes = {
|
||||
setGradebookState: PropTypes.func.isRequired,
|
||||
// redux
|
||||
areGradesFrozen: PropTypes.bool,
|
||||
format: PropTypes.string.isRequired,
|
||||
grades: PropTypes.arrayOf(PropTypes.shape({
|
||||
percent: PropTypes.number,
|
||||
section_breakdown: PropTypes.arrayOf(PropTypes.shape({
|
||||
attempted: PropTypes.bool,
|
||||
category: PropTypes.string,
|
||||
label: PropTypes.string,
|
||||
module_id: PropTypes.string,
|
||||
percent: PropTypes.number,
|
||||
scoreEarned: PropTypes.number,
|
||||
scorePossible: PropTypes.number,
|
||||
subsection_name: PropTypes.string,
|
||||
})),
|
||||
user_id: PropTypes.number,
|
||||
user_name: PropTypes.string,
|
||||
})),
|
||||
headings: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
fetchGradeOverrideHistory: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => {
|
||||
const { assignmentTypes, grades, root } = selectors;
|
||||
return {
|
||||
areGradesFrozen: assignmentTypes.areGradesFrozen(state),
|
||||
format: grades.gradeFormat(state),
|
||||
grades: grades.allGrades(state),
|
||||
headings: root.getHeadings(state),
|
||||
};
|
||||
};
|
||||
|
||||
export const mapDispatchToProps = {
|
||||
fetchGradeOverrideHistory,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(GradebookTable);
|
||||
114
src/components/Gradebook/SearchControls.jsx
Normal file
114
src/components/Gradebook/SearchControls.jsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { Button, Icon, SearchField } from '@edx/paragon';
|
||||
|
||||
import selectors from 'data/selectors';
|
||||
import {
|
||||
fetchGrades,
|
||||
fetchMatchingUserGrades,
|
||||
} from '../../data/actions/grades';
|
||||
|
||||
/**
|
||||
* Controls for filtering the GradebookTable. Contains the "Edit Filters" button for opening the filter drawer
|
||||
* as well as the search box for searching by username/email.
|
||||
*/
|
||||
export class SearchControls extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.onSubmit = this.onSubmit.bind(this);
|
||||
this.onChange = this.onChange.bind(this);
|
||||
this.onClear = this.onClear.bind(this);
|
||||
}
|
||||
|
||||
/** Submitting searches for user matching the username/email in `value` */
|
||||
onSubmit(value) {
|
||||
this.props.searchForUser(
|
||||
this.props.courseId,
|
||||
value,
|
||||
this.props.selectedCohort,
|
||||
this.props.selectedTrack,
|
||||
this.props.selectedAssignmentType,
|
||||
);
|
||||
}
|
||||
|
||||
/** Changing the search value stores the key in Gradebook. Currently unused */
|
||||
onChange(filterValue) {
|
||||
this.props.setFilterValue(filterValue);
|
||||
}
|
||||
|
||||
/** Clearing the search box falls back to showing students with already applied filters */
|
||||
onClear() {
|
||||
this.props.getUserGrades(
|
||||
this.props.courseId,
|
||||
this.props.selectedCohort,
|
||||
this.props.selectedTrack,
|
||||
this.props.selectedAssignmentType,
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<>
|
||||
<h4>Step 1: Filter the Grade Report</h4>
|
||||
<div className="d-flex justify-content-between">
|
||||
<Button
|
||||
id="edit-filters-btn"
|
||||
className="btn-primary align-self-start"
|
||||
onClick={this.props.toggleFilterDrawer}
|
||||
>
|
||||
<Icon className="fa fa-filter" /> Edit Filters
|
||||
</Button>
|
||||
<div>
|
||||
<SearchField
|
||||
onSubmit={this.onSubmit}
|
||||
inputLabel="Search for a learner"
|
||||
onChange={this.onChange}
|
||||
onClear={this.onClear}
|
||||
value={this.props.filterValue}
|
||||
/>
|
||||
<small className="form-text text-muted search-help-text">Search by username, email, or student key</small>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SearchControls.defaultProps = {
|
||||
courseId: '',
|
||||
filterValue: '',
|
||||
selectedAssignmentType: '',
|
||||
selectedCohort: null,
|
||||
selectedTrack: null,
|
||||
};
|
||||
|
||||
SearchControls.propTypes = {
|
||||
courseId: PropTypes.string,
|
||||
filterValue: PropTypes.string,
|
||||
setFilterValue: PropTypes.func.isRequired,
|
||||
toggleFilterDrawer: PropTypes.func.isRequired,
|
||||
// From Redux
|
||||
getUserGrades: PropTypes.func.isRequired,
|
||||
searchForUser: PropTypes.func.isRequired,
|
||||
selectedAssignmentType: PropTypes.string,
|
||||
selectedCohort: PropTypes.string,
|
||||
selectedTrack: PropTypes.string,
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => {
|
||||
const { filters } = selectors;
|
||||
return {
|
||||
selectedAssignmentType: filters.assignmentType(state),
|
||||
selectedTrack: filters.track(state),
|
||||
selectedCohort: filters.cohort(state),
|
||||
};
|
||||
};
|
||||
|
||||
export const mapDispatchToProps = {
|
||||
getUserGrades: fetchGrades,
|
||||
searchForUser: fetchMatchingUserGrades,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(SearchControls);
|
||||
118
src/components/Gradebook/SearchControls.test.jsx
Normal file
118
src/components/Gradebook/SearchControls.test.jsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import {
|
||||
fetchGrades,
|
||||
fetchMatchingUserGrades,
|
||||
} from '../../data/actions/grades';
|
||||
import { mapDispatchToProps, mapStateToProps, SearchControls } from './SearchControls';
|
||||
|
||||
jest.mock('@edx/paragon', () => ({
|
||||
Icon: 'Icon',
|
||||
Button: 'Button',
|
||||
SearchField: 'SearchField',
|
||||
}));
|
||||
|
||||
describe('SearchControls', () => {
|
||||
let props;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
props = {
|
||||
courseId: 'course-v1:edX+DEV101+T1',
|
||||
filterValue: 'alice',
|
||||
selectedAssignmentType: 'homework',
|
||||
selectedCohort: 'spring term',
|
||||
selectedTrack: 'masters',
|
||||
getUserGrades: jest.fn(),
|
||||
searchForUser: jest.fn(),
|
||||
setFilterValue: jest.fn(),
|
||||
toggleFilterDrawer: jest.fn().mockName('toggleFilterDrawer'),
|
||||
};
|
||||
});
|
||||
|
||||
const searchControls = (overriddenProps) => {
|
||||
props = { ...props, ...overriddenProps };
|
||||
return shallow(<SearchControls {...props} />);
|
||||
};
|
||||
|
||||
describe('Component', () => {
|
||||
describe('onSubmit', () => {
|
||||
it('calls props.searchForUser with correct data', () => {
|
||||
const wrapper = searchControls();
|
||||
wrapper.instance().onSubmit('bob');
|
||||
|
||||
expect(props.searchForUser).toHaveBeenCalledWith(
|
||||
props.courseId,
|
||||
'bob',
|
||||
props.selectedCohort,
|
||||
props.selectedTrack,
|
||||
props.selectedAssignmentType,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onChange', () => {
|
||||
it('saves the changed search value to Gradebook state', () => {
|
||||
const wrapper = searchControls();
|
||||
wrapper.instance().onChange('bob');
|
||||
expect(props.setFilterValue).toHaveBeenCalledWith('bob');
|
||||
});
|
||||
});
|
||||
|
||||
describe('onClear', () => {
|
||||
it('re-runs search with existing filters', () => {
|
||||
const wrapper = searchControls();
|
||||
wrapper.instance().onClear();
|
||||
expect(props.getUserGrades).toHaveBeenCalledWith(
|
||||
props.courseId,
|
||||
props.selectedCohort,
|
||||
props.selectedTrack,
|
||||
props.selectedAssignmentType,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapStateToProps', () => {
|
||||
const state = {
|
||||
filters: {
|
||||
assignmentType: 'labs',
|
||||
track: 'honor',
|
||||
cohort: 'fall term',
|
||||
},
|
||||
};
|
||||
|
||||
it('maps assignment type filter correctly', () => {
|
||||
expect(mapStateToProps(state).selectedAssignmentType).toEqual(state.filters.assignmentType);
|
||||
});
|
||||
|
||||
it('maps track filter correctly', () => {
|
||||
expect(mapStateToProps(state).selectedTrack).toEqual(state.filters.track);
|
||||
});
|
||||
|
||||
it('maps cohort filter correctly', () => {
|
||||
expect(mapStateToProps(state).selectedCohort).toEqual(state.filters.cohort);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapDispatchToProps', () => {
|
||||
test('getUserGrades', () => {
|
||||
expect(mapDispatchToProps.getUserGrades).toEqual(fetchGrades);
|
||||
});
|
||||
|
||||
test('searchForUser', () => {
|
||||
expect(mapDispatchToProps.searchForUser).toEqual(fetchMatchingUserGrades);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Snapshots', () => {
|
||||
test('basic snapshot', () => {
|
||||
const wrapper = searchControls();
|
||||
wrapper.instance().onChange = jest.fn().mockName('onChange');
|
||||
wrapper.instance().onClear = jest.fn().mockName('onClear');
|
||||
wrapper.instance().onSubmit = jest.fn().mockName('onSubmit');
|
||||
expect(wrapper.instance().render()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
72
src/components/Gradebook/StatusAlerts.jsx
Normal file
72
src/components/Gradebook/StatusAlerts.jsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { StatusAlert } from '@edx/paragon';
|
||||
|
||||
import selectors from 'data/selectors';
|
||||
import { closeBanner } from '../../data/actions/grades';
|
||||
|
||||
export const maxCourseGradeInvalidMessage = 'Maximum course grade value must be between 0 and 100. ';
|
||||
export const minCourseGradeInvalidMessage = 'Minimum course grade value must be between 0 and 100. ';
|
||||
|
||||
export class StatusAlerts extends React.Component {
|
||||
get isCourseGradeFilterAlertOpen() {
|
||||
const r = !this.props.isMinCourseGradeFilterValid
|
||||
|| !this.props.isMaxCourseGradeFilterValid;
|
||||
return r;
|
||||
}
|
||||
|
||||
get courseGradeFilterAlertDialogText() {
|
||||
let dialogText = '';
|
||||
if (!this.props.isMinCourseGradeFilterValid) {
|
||||
dialogText += minCourseGradeInvalidMessage;
|
||||
}
|
||||
if (!this.props.isMaxCourseGradeFilterValid) {
|
||||
dialogText += maxCourseGradeInvalidMessage;
|
||||
}
|
||||
return dialogText;
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<>
|
||||
<StatusAlert
|
||||
alertType="success"
|
||||
dialog="The grade has been successfully edited. You may see a slight delay before updates appear in the Gradebook."
|
||||
onClose={this.props.handleCloseSuccessBanner}
|
||||
open={this.props.showSuccessBanner}
|
||||
/>
|
||||
<StatusAlert
|
||||
alertType="danger"
|
||||
dialog={this.courseGradeFilterAlertDialogText}
|
||||
dismissible={false}
|
||||
open={this.isCourseGradeFilterAlertOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
StatusAlerts.defaultProps = {
|
||||
isMinCourseGradeFilterValid: true,
|
||||
isMaxCourseGradeFilterValid: true,
|
||||
};
|
||||
|
||||
StatusAlerts.propTypes = {
|
||||
isMinCourseGradeFilterValid: PropTypes.bool,
|
||||
isMaxCourseGradeFilterValid: PropTypes.bool,
|
||||
// redux
|
||||
handleCloseSuccessBanner: PropTypes.func.isRequired,
|
||||
showSuccessBanner: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => ({
|
||||
showSuccessBanner: selectors.grades.showSuccess(state),
|
||||
});
|
||||
|
||||
export const mapDispatchToProps = {
|
||||
handleCloseSuccessBanner: closeBanner,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(StatusAlerts);
|
||||
99
src/components/Gradebook/StatusAlerts.test.jsx
Normal file
99
src/components/Gradebook/StatusAlerts.test.jsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import {
|
||||
StatusAlerts,
|
||||
mapDispatchToProps,
|
||||
mapStateToProps,
|
||||
maxCourseGradeInvalidMessage,
|
||||
minCourseGradeInvalidMessage,
|
||||
} from './StatusAlerts';
|
||||
import { closeBanner } from '../../data/actions/grades';
|
||||
|
||||
jest.mock('@edx/paragon', () => ({
|
||||
StatusAlert: 'StatusAlert',
|
||||
}));
|
||||
|
||||
describe('StatusAlerts', () => {
|
||||
let props = {
|
||||
showSuccessBanner: true,
|
||||
isMaxCourseGradeFilterValid: true,
|
||||
isMinCourseGradeFilterValid: true,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
props = {
|
||||
...props,
|
||||
handleCloseSuccessBanner: jest.fn().mockName('handleCloseSuccessBanner'),
|
||||
};
|
||||
});
|
||||
|
||||
describe('snapshots', () => {
|
||||
let el;
|
||||
it('basic snapshot', () => {
|
||||
el = shallow(<StatusAlerts {...props} />);
|
||||
const courseGradeFilterAlertDialogText = 'the quiCk brown does somEthing or other';
|
||||
jest.spyOn(
|
||||
el.instance(),
|
||||
'courseGradeFilterAlertDialogText',
|
||||
'get',
|
||||
).mockReturnValue(courseGradeFilterAlertDialogText);
|
||||
expect(el.instance().render()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe('behavior', () => {
|
||||
it.each([
|
||||
[false, false],
|
||||
[false, true],
|
||||
[true, false],
|
||||
[true, true],
|
||||
])('min + max course grade validity', (isMinCourseGradeFilterValid, isMaxCourseGradeFilterValid) => {
|
||||
props = {
|
||||
...props,
|
||||
isMinCourseGradeFilterValid,
|
||||
isMaxCourseGradeFilterValid,
|
||||
};
|
||||
const el = shallow(<StatusAlerts {...props} />);
|
||||
expect(
|
||||
el.instance().isCourseGradeFilterAlertOpen,
|
||||
).toEqual(
|
||||
!isMinCourseGradeFilterValid || !isMaxCourseGradeFilterValid,
|
||||
);
|
||||
if (!isMaxCourseGradeFilterValid) {
|
||||
expect(
|
||||
el.instance().courseGradeFilterAlertDialogText,
|
||||
).toEqual(
|
||||
expect.stringContaining(maxCourseGradeInvalidMessage),
|
||||
);
|
||||
}
|
||||
if (!isMinCourseGradeFilterValid) {
|
||||
expect(
|
||||
el.instance().courseGradeFilterAlertDialogText,
|
||||
).toEqual(
|
||||
expect.stringContaining(minCourseGradeInvalidMessage),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapStateToProps', () => {
|
||||
it('showSuccessBanner', () => {
|
||||
const arbitraryValue = 'AppleBananaCucumber';
|
||||
const state = {
|
||||
grades: {
|
||||
showSuccess: arbitraryValue,
|
||||
},
|
||||
};
|
||||
expect(mapStateToProps(state).showSuccessBanner).toBe(arbitraryValue);
|
||||
});
|
||||
});
|
||||
describe('handleCloseSuccessBanner', () => {
|
||||
test('handleCloseSuccessBanner', () => {
|
||||
expect(
|
||||
mapDispatchToProps.handleCloseSuccessBanner,
|
||||
).toEqual(
|
||||
closeBanner,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`SearchControls Component Snapshots basic snapshot 1`] = `
|
||||
<React.Fragment>
|
||||
<h4>
|
||||
Step 1: Filter the Grade Report
|
||||
</h4>
|
||||
<div
|
||||
className="d-flex justify-content-between"
|
||||
>
|
||||
<Button
|
||||
className="btn-primary align-self-start"
|
||||
id="edit-filters-btn"
|
||||
onClick={[MockFunction toggleFilterDrawer]}
|
||||
>
|
||||
<Icon
|
||||
className="fa fa-filter"
|
||||
/>
|
||||
Edit Filters
|
||||
</Button>
|
||||
<div>
|
||||
<SearchField
|
||||
inputLabel="Search for a learner"
|
||||
onChange={[MockFunction onChange]}
|
||||
onClear={[MockFunction onClear]}
|
||||
onSubmit={[MockFunction onSubmit]}
|
||||
value="alice"
|
||||
/>
|
||||
<small
|
||||
className="form-text text-muted search-help-text"
|
||||
>
|
||||
Search by username, email, or student key
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
`;
|
||||
@@ -0,0 +1,18 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`StatusAlerts snapshots basic snapshot 1`] = `
|
||||
<React.Fragment>
|
||||
<StatusAlert
|
||||
alertType="success"
|
||||
dialog="The grade has been successfully edited. You may see a slight delay before updates appear in the Gradebook."
|
||||
onClose={[MockFunction handleCloseSuccessBanner]}
|
||||
open={true}
|
||||
/>
|
||||
<StatusAlert
|
||||
alertType="danger"
|
||||
dialog="the quiCk brown does somEthing or other"
|
||||
dismissible={false}
|
||||
open={false}
|
||||
/>
|
||||
</React.Fragment>
|
||||
`;
|
||||
@@ -1,122 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Hyperlink, Icon } from '@edx/paragon';
|
||||
|
||||
import EdXLogo from '../../../assets/edx-sm.png';
|
||||
|
||||
export default function Footer() {
|
||||
function renderLogo() {
|
||||
return (
|
||||
<img src={EdXLogo} alt="edX logo" height="30" width="60" />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<footer
|
||||
role="contentinfo"
|
||||
aria-label="Page Footer"
|
||||
className="footer d-flex justify-content-center border-top py-3 px-4"
|
||||
>
|
||||
<div className="max-width-1180 d-grid">
|
||||
<div className="area-1">
|
||||
<Hyperlink destination="https://www.edx.org/" content={renderLogo()} />
|
||||
</div>
|
||||
<div className="area-2">
|
||||
<h2>edx</h2>
|
||||
<ul className="list-unstyled p-0 m-0">
|
||||
<li><a href="https://www.edx.org/about-us">About</a></li>
|
||||
<li><a href="https://www.edx.org/enterprise">edX for Business</a></li>
|
||||
<li><a href="https://www.edx.org/affiliate-program">Affiliates</a></li>
|
||||
<li><a href="http://open.edx.org">Open edX</a></li>
|
||||
<li><a href="https://www.edx.org/careers">Careers</a></li>
|
||||
<li><a href="https://www.edx.org/news-announcements">News</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="area-3">
|
||||
<h2>Legal</h2>
|
||||
<ul className="list-unstyled p-0 m-0">
|
||||
<li><a href="https://www.edx.org/edx-terms-service">Terms of Service & Honor Code</a></li>
|
||||
<li><a href="https://www.edx.org/edx-privacy-policy">Privacy Policy</a></li>
|
||||
<li><a href="https://www.edx.org/accessibility">Accessibility Policy</a></li>
|
||||
<li><a href="https://www.edx.org/trademarks">Trademark Policy</a></li>
|
||||
<li><a href="https://www.edx.org/sitemap">Sitemap</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="area-4">
|
||||
<h2>Connect</h2>
|
||||
<ul className="list-unstyled p-0 m-0">
|
||||
<li><a href="https://www.edx.org/blog">Blog</a></li>
|
||||
<li><a href="https://courses.edx.org/support/contact_us">Contact Us</a></li>
|
||||
<li><a href="https://support.edx.org">Help Center</a></li>
|
||||
<li><a href="https://www.edx.org/media-kit">Media Kit</a></li>
|
||||
<li><a href="https://www.edx.org/donate">Donate</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="area-5">
|
||||
<ul
|
||||
className="d-flex flex-row justify-content-between list-unstyled max-width-222 p-0 mb-4"
|
||||
>
|
||||
{/* TODO: Use Paragon HyperLink with Icon. */}
|
||||
{/* Would need to add rel to paragon if we still need it. */}
|
||||
<li>
|
||||
<a href="http://www.facebook.com/EdxOnline" title="Facebook" rel="noopener noreferrer" target="_blank">
|
||||
<Icon className={['fa', 'fa-facebook-square', 'fa-2x']} screenReaderText="Like edX on Facebook" />
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://twitter.com/edXOnline" title="Twitter" rel="noopener noreferrer" target="_blank">
|
||||
<Icon className={['fa', 'fa-twitter-square', 'fa-2x']} screenReaderText="Follow edX on Twitter" />
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://www.youtube.com/user/edxonline" title="Youtube" rel="noopener noreferrer" target="_blank">
|
||||
<Icon className={['fa', 'fa-youtube-square', 'fa-2x']} screenReaderText="Subscribe to the edX YouTube channel" />
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://www.linkedin.com/company/edx" title="LinkedIn" rel="noopener noreferrer" target="_blank">
|
||||
<Icon className={['fa', 'fa-linkedin-square', 'fa-2x']} screenReaderText="Follow edX on LinkedIn" />
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://plus.google.com/+edXOnline" title="Google+" rel="noopener noreferrer" target="_blank">
|
||||
<Icon className={['fa', 'fa-google-plus-square', 'fa-2x']} screenReaderText="Follow edX on Google+" />
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://www.reddit.com/r/edx" title="Reddit" rel="noopener noreferrer" target="_blank">
|
||||
<Icon className={['fa', 'fa-reddit-square', 'fa-2x']} screenReaderText="Subscribe to the edX subreddit" />
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<ul className="d-flex flex-row justify-content-between list-unstyled max-width-264 p-0 mb-5">
|
||||
<li>
|
||||
<a href="https://itunes.apple.com/us/app/edx/id945480667?mt=8" rel="noopener noreferrer" target="_blank">
|
||||
<img
|
||||
className="max-height-39"
|
||||
alt="Download the edX mobile app from the Apple App Store"
|
||||
src="https://prod-edxapp.edx-cdn.org/static/images/app/app_store_badge_135x40.d0558d910630.svg"
|
||||
/>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://play.google.com/store/apps/details?id=org.edx.mobile" rel="noopener noreferrer" target="_blank">
|
||||
<img
|
||||
className="max-height-39"
|
||||
alt="Download the edX mobile app from Google Play"
|
||||
src="https://prod-edxapp.edx-cdn.org/static/images/app/google_play_badge_45.6ea466e328da.png"
|
||||
/>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
© 2012–{(new Date().getFullYear())} edX Inc.
|
||||
<br />
|
||||
EdX, Open edX, and MicroMasters are registered trademarks of edX Inc.
|
||||
| 粤ICP备17044299号-2
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
.max-width-222 {
|
||||
max-width: 222px;
|
||||
}
|
||||
|
||||
.max-width-264 {
|
||||
max-width: 264px;
|
||||
}
|
||||
|
||||
.max-width-1180 {
|
||||
max-width: 1180px;
|
||||
}
|
||||
|
||||
.max-height-39 {
|
||||
max-height: 39px;
|
||||
}
|
||||
|
||||
.d-grid {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
$gray-footer: #fcfcfc;
|
||||
$border-1: 1px solid $gray-200;
|
||||
|
||||
.footer {
|
||||
background-color: $gray-footer;
|
||||
|
||||
.area-1 {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
border-bottom: $border-1;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.area-2 {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
border-bottom: $border-1;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.area-3 {
|
||||
grid-column: 1;
|
||||
grid-row: 3;
|
||||
border-bottom: $border-1;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.area-4 {
|
||||
grid-column: 1;
|
||||
grid-row: 4;
|
||||
border-bottom: $border-1;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.area-5 {
|
||||
grid-column: 1;
|
||||
grid-row: 5;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 717px) {
|
||||
.area-1 {
|
||||
grid-column: 1 / span 2;
|
||||
grid-row: 1;
|
||||
border-bottom: none;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.area-2 {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.area-3 {
|
||||
grid-column: 1;
|
||||
grid-row: 3;
|
||||
}
|
||||
|
||||
.area-4 {
|
||||
grid-column: 1;
|
||||
grid-row: 4;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.area-5 {
|
||||
grid-column: 2;
|
||||
grid-row: 2 / span 3;
|
||||
border-left: $border-1;
|
||||
padding-left: 1rem;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 870px) {
|
||||
.area-1 {
|
||||
grid-column: 1;
|
||||
grid-row: 1 / span 3;
|
||||
border-right: $border-1;
|
||||
padding-right: 1rem;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.area-2 {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
border-bottom: none;
|
||||
border-right: $border-1;
|
||||
padding-right: 1rem;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.area-3 {
|
||||
grid-column: 3;
|
||||
grid-row: 1;
|
||||
border-bottom: none;
|
||||
border-right: $border-1;
|
||||
padding-right: 1rem;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.area-4 {
|
||||
grid-column: 4;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.area-5 {
|
||||
grid-column: 2 / span 3;
|
||||
grid-row: 2;
|
||||
border: none;
|
||||
margin-left: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 1188px) {
|
||||
.area-1 {
|
||||
grid-column: 1 / span 1;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.area-2 {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.area-3 {
|
||||
grid-column: 3;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.area-4 {
|
||||
grid-column: 4;
|
||||
grid-row: 1;
|
||||
border-right: $border-1;
|
||||
padding-right: 1rem;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.area-5 {
|
||||
grid-column: 5 / span 1;
|
||||
grid-row: 1;
|
||||
max-width: 372px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background-color: #999;
|
||||
opacity: 0.5;
|
||||
z-index: 99999;
|
||||
@@ -16,56 +17,133 @@
|
||||
color: black;
|
||||
}
|
||||
|
||||
.gradebook-container{
|
||||
width: 500px;
|
||||
@media only screen and (min-width: 640px) {
|
||||
width: 630px;
|
||||
.gradebook-content {
|
||||
// note that this width isn't well-abstracted from Drawer code.
|
||||
// if we need to change it we may need to dig into those styles as well
|
||||
width: 100vw;
|
||||
.search-help-text {
|
||||
margin-left: 20px;
|
||||
}
|
||||
@media only screen and (min-width: 992px) {
|
||||
width: 900px;
|
||||
}
|
||||
@media only screen and (min-width: 1200px) {
|
||||
width: 1024px;
|
||||
h4 {
|
||||
font-weight: bold;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.student-filters{
|
||||
display: flex;
|
||||
.label{
|
||||
padding-top: 30px;
|
||||
}
|
||||
.form-group{
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
.gbook {
|
||||
overflow-x: scroll;
|
||||
.grade-history-header{
|
||||
float: left;
|
||||
}
|
||||
|
||||
.table {
|
||||
padding-left: 244px;
|
||||
.grade-history-assignment{
|
||||
padding-right: 49px;
|
||||
}
|
||||
.grade-history-student{
|
||||
padding-right: 75px;
|
||||
}
|
||||
|
||||
.grade-history-original-grade{
|
||||
padding-right: 25px;
|
||||
}
|
||||
|
||||
.grade-history-current-grade{
|
||||
padding-right: 25px;
|
||||
}
|
||||
.gradebook-container {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
height: 600px;
|
||||
overflow-y: auto;
|
||||
word-break: break-word;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.gbook {
|
||||
width: 100%;
|
||||
|
||||
.grade-button {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.student-key {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#courseGradeTooltipIcon {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.table thead tr {
|
||||
min-height: 60px;
|
||||
&:nth-child(1) {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background-color: white;
|
||||
th {
|
||||
background-color: white;
|
||||
border-bottom: 1px solid $gray_200;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
thead, tbody, tr, td, th {
|
||||
display: block;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.table tr th:first-child {
|
||||
position: absolute;
|
||||
width: 160px;
|
||||
height:50px;
|
||||
display: block;
|
||||
background-color: #fff;
|
||||
border-bottom: none;
|
||||
border-bottom: none;
|
||||
}
|
||||
.table tr th:first-child,
|
||||
.table tr td:first-child {
|
||||
position: absolute;
|
||||
width: 160px;
|
||||
height:50px;
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 1; // to float over the following children in the side-scrolling case
|
||||
background: white;
|
||||
}
|
||||
|
||||
.table tr {
|
||||
th:nth-child(1),
|
||||
td:nth-child(1),
|
||||
th:nth-child(2),
|
||||
td:nth-child(2) {
|
||||
width: 240px;
|
||||
}
|
||||
th:nth-last-of-type(1) {
|
||||
width: 150px;
|
||||
}
|
||||
th, td {
|
||||
width: 120px;
|
||||
}
|
||||
}
|
||||
.table tbody th {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.table {
|
||||
overflow-x: hidden;
|
||||
|
||||
height: 100%;
|
||||
|
||||
tbody {
|
||||
overflow-y: auto;
|
||||
display: block;
|
||||
background-color: #fff;
|
||||
}
|
||||
.table tr td:nth-child(2) {
|
||||
box-sizing: content-box;
|
||||
padding-left: 170px;
|
||||
}
|
||||
.table tr th:nth-child(2) {
|
||||
padding-left: 170px;
|
||||
}
|
||||
|
||||
thead, tbody tr {
|
||||
display: table;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
th {
|
||||
vertical-align: top;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.link-style {
|
||||
@@ -75,5 +153,44 @@
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.form-group, .pgn__form-group {
|
||||
label {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
.grade-filter-inputs {
|
||||
.percent-group {
|
||||
display: inline-block;
|
||||
.form-group, .pgn__form-group {
|
||||
width: 115px;
|
||||
display: inline-block;
|
||||
}
|
||||
.input-percent-label {
|
||||
margin-top: 22px;
|
||||
margin-left: 5px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.grade-filter-action {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.mb-85 {
|
||||
margin-bottom: 85px;
|
||||
}
|
||||
|
||||
.modal-dialog {
|
||||
max-width: 1000px;
|
||||
}
|
||||
|
||||
.wrap-text-in-cell {
|
||||
overflow-wrap: break-word;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
@@ -1,403 +1,319 @@
|
||||
/* eslint-disable react/sort-comp, react/button-has-type, import/no-named-as-default */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
Button,
|
||||
InputSelect,
|
||||
Modal,
|
||||
SearchField,
|
||||
StatusAlert,
|
||||
Table,
|
||||
Icon,
|
||||
InputSelect,
|
||||
Tab,
|
||||
Tabs,
|
||||
} from '@edx/paragon';
|
||||
import queryString from 'query-string';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faFilter } from '@fortawesome/free-solid-svg-icons';
|
||||
import { configuration } from '../../config';
|
||||
import PageButtons from '../PageButtons';
|
||||
import Drawer from '../Drawer';
|
||||
import initialFilters from '../../data/constants/filters';
|
||||
import ConnectedFilterBadges from '../FilterBadges';
|
||||
|
||||
const DECIMAL_PRECISION = 2;
|
||||
import BulkManagement from './BulkManagement';
|
||||
import BulkManagementControls from './BulkManagementControls';
|
||||
import EditModal from './EditModal';
|
||||
import GradebookFilters from './GradebookFilters';
|
||||
import GradebookTable from './GradebookTable';
|
||||
import SearchControls from './SearchControls';
|
||||
import StatusAlerts from './StatusAlerts';
|
||||
|
||||
export default class Gradebook extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
adjustedGradePossible: '',
|
||||
adjustedGradeValue: 0,
|
||||
assignmentGradeMin: '0',
|
||||
assignmentGradeMax: '100',
|
||||
assignmentName: '',
|
||||
courseGradeMin: '0',
|
||||
courseGradeMax: '100',
|
||||
filterValue: '',
|
||||
isMinCourseGradeFilterValid: true,
|
||||
isMaxCourseGradeFilterValid: true,
|
||||
modalOpen: false,
|
||||
modalModel: [{}],
|
||||
updateVal: 0,
|
||||
reasonForChange: '',
|
||||
todaysDate: '',
|
||||
updateModuleId: null,
|
||||
updateUserId: null,
|
||||
};
|
||||
this.myRef = React.createRef();
|
||||
}
|
||||
|
||||
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.getAssignmentTypes(this.props.match.params.courseId);
|
||||
this.props.initializeFilters(urlQuery);
|
||||
this.props.getRoles(this.props.courseId);
|
||||
|
||||
const newStateFields = {};
|
||||
['assignmentGradeMin', 'assignmentGradeMax', 'courseGradeMin', 'courseGradeMax'].forEach((attr) => {
|
||||
if (urlQuery[attr]) {
|
||||
newStateFields[attr] = urlQuery[attr];
|
||||
}
|
||||
});
|
||||
|
||||
this.setState(newStateFields);
|
||||
}
|
||||
|
||||
setNewModalState = (userEntry, subsection) => {
|
||||
let adjustedGradePossible = '';
|
||||
let currentGradePossible = '';
|
||||
if (subsection.attempted) {
|
||||
adjustedGradePossible = ` / ${subsection.score_possible}`;
|
||||
currentGradePossible = `/${subsection.score_possible}`;
|
||||
onChange(e) {
|
||||
this.setState({ [e.target.name]: e.target.value });
|
||||
}
|
||||
|
||||
getActiveTabs = () => {
|
||||
if (this.props.showBulkManagement) {
|
||||
return ['Grades', 'Bulk Management'];
|
||||
}
|
||||
this.setState({
|
||||
modalModel: [{
|
||||
username: userEntry.username,
|
||||
currentGrade: `${subsection.score_earned}${currentGradePossible}`,
|
||||
adjustedGrade: (
|
||||
<span>
|
||||
<input
|
||||
style={{ width: '25px' }}
|
||||
type="text"
|
||||
onChange={event => this.setState({ updateVal: event.target.value })}
|
||||
/>{adjustedGradePossible}
|
||||
</span>
|
||||
),
|
||||
assignmentName: `${subsection.subsection_name}`,
|
||||
}],
|
||||
modalOpen: true,
|
||||
updateModuleId: subsection.module_id,
|
||||
updateUserId: userEntry.user_id,
|
||||
});
|
||||
}
|
||||
return ['Grades'];
|
||||
};
|
||||
|
||||
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.state.filterValue,
|
||||
this.props.selectedCohort,
|
||||
this.props.selectedTrack,
|
||||
);
|
||||
|
||||
this.setState({
|
||||
modalModel: [{}],
|
||||
modalOpen: false,
|
||||
updateModuleId: null,
|
||||
updateUserId: null,
|
||||
});
|
||||
}
|
||||
|
||||
updateQueryParams = (queryKey, queryValue) => {
|
||||
updateQueryParams = (queryParams) => {
|
||||
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,
|
||||
label: entry.name,
|
||||
}));
|
||||
mapped.unshift({ id: 0, label: 'Cohort-All' });
|
||||
return mapped;
|
||||
};
|
||||
|
||||
mapTracksEntries = (entries) => {
|
||||
const mapped = entries.map(entry => ({
|
||||
id: entry.slug,
|
||||
label: entry.name,
|
||||
}));
|
||||
mapped.unshift({ label: 'Track-All' });
|
||||
return mapped;
|
||||
};
|
||||
|
||||
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;
|
||||
if (selectedTrackItem) {
|
||||
selectedTrackSlug = selectedTrackItem.slug;
|
||||
}
|
||||
this.props.getUserGrades(
|
||||
this.props.match.params.courseId,
|
||||
this.props.selectedCohort,
|
||||
selectedTrackSlug,
|
||||
);
|
||||
const updatedQueryStrings = this.updateQueryParams('track', selectedTrackSlug);
|
||||
this.props.history.push(updatedQueryStrings);
|
||||
};
|
||||
|
||||
updateCohorts = (event) => {
|
||||
const selectedCohortItem = this.props.cohorts.find(x => x.name === event);
|
||||
let selectedCohortId = null;
|
||||
if (selectedCohortItem) {
|
||||
selectedCohortId = selectedCohortItem.id;
|
||||
}
|
||||
this.props.getUserGrades(
|
||||
this.props.match.params.courseId,
|
||||
selectedCohortId,
|
||||
this.props.selectedTrack,
|
||||
);
|
||||
const updatedQueryStrings = this.updateQueryParams('cohort', selectedCohortId);
|
||||
this.props.history.push(updatedQueryStrings);
|
||||
};
|
||||
|
||||
mapSelectedAssignmentTypeEntry = (entry) => {
|
||||
const selectedAssignmentTypeEntry = this.props.assignmentTypes
|
||||
.find(x => x.id === parseInt(entry, 10));
|
||||
if (selectedAssignmentTypeEntry) {
|
||||
return selectedAssignmentTypeEntry.name;
|
||||
}
|
||||
return 'All';
|
||||
};
|
||||
|
||||
mapSelectedCohortEntry = (entry) => {
|
||||
const selectedCohortEntry = this.props.cohorts.find(x => x.id === parseInt(entry, 10));
|
||||
if (selectedCohortEntry) {
|
||||
return selectedCohortEntry.name;
|
||||
}
|
||||
return 'Cohorts';
|
||||
};
|
||||
|
||||
mapSelectedTrackEntry = (entry) => {
|
||||
const selectedTrackEntry = this.props.tracks.find(x => x.slug === entry);
|
||||
if (selectedTrackEntry) {
|
||||
return selectedTrackEntry.name;
|
||||
}
|
||||
return 'Tracks';
|
||||
};
|
||||
|
||||
roundGrade = percent => parseFloat(percent.toFixed(DECIMAL_PRECISION));
|
||||
|
||||
formatter = {
|
||||
percent: (entries, areGradesFrozen) => entries.map((entry) => {
|
||||
const results = { username: entry.username };
|
||||
const assignments = entry.section_breakdown
|
||||
.filter(section => section.is_graded)
|
||||
.reduce((acc, subsection) => {
|
||||
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: `${this.roundGrade(entry.percent * 100)}%` };
|
||||
return Object.assign(results, assignments, totals);
|
||||
}),
|
||||
|
||||
absolute: (entries, areGradesFrozen) => entries.map((entry) => {
|
||||
const results = { username: entry.username };
|
||||
const assignments = entry.section_breakdown
|
||||
.filter(section => section.is_graded)
|
||||
.reduce((acc, subsection) => {
|
||||
const scoreEarned = this.roundGrade(subsection.score_earned);
|
||||
const scorePossible = this.roundGrade(subsection.score_possible);
|
||||
let label = `${scoreEarned}`;
|
||||
if (subsection.attempted) {
|
||||
label = `${scoreEarned}/${scorePossible}`;
|
||||
}
|
||||
if (areGradesFrozen) {
|
||||
acc[subsection.label] = label;
|
||||
} else {
|
||||
acc[subsection.label] = (
|
||||
<button
|
||||
className="btn btn-header link-style"
|
||||
onClick={() => this.setNewModalState(entry, subsection)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const totals = { total: `${this.roundGrade(entry.percent * 100)}/100` };
|
||||
return Object.assign(results, assignments, totals);
|
||||
}),
|
||||
Object.keys(queryParams).forEach((key) => {
|
||||
if (queryParams[key]) {
|
||||
parsed[key] = queryParams[key];
|
||||
} else {
|
||||
delete parsed[key];
|
||||
}
|
||||
});
|
||||
this.props.history.push(`?${queryString.stringify(parsed)}`);
|
||||
};
|
||||
|
||||
lmsInstructorDashboardUrl = courseId => `${configuration.LMS_BASE_URL}/courses/${courseId}/instructor`;
|
||||
|
||||
handleFilterBadgeClose = filterNames => () => {
|
||||
this.props.resetFilters(filterNames);
|
||||
const queryParams = {};
|
||||
filterNames.forEach((filterName) => {
|
||||
queryParams[filterName] = false;
|
||||
});
|
||||
this.updateQueryParams(queryParams);
|
||||
const stateUpdate = {};
|
||||
const rangeStateFilters = ['assignmentGradeMin', 'assignmentGradeMax', 'courseGradeMin', 'courseGradeMax'];
|
||||
rangeStateFilters.forEach((filterName) => {
|
||||
if (filterNames.includes(filterName)) {
|
||||
stateUpdate[filterName] = initialFilters[filterName];
|
||||
}
|
||||
});
|
||||
this.setState(stateUpdate);
|
||||
this.props.getUserGrades(
|
||||
this.props.courseId,
|
||||
filterNames.includes('cohort') ? initialFilters.cohort : this.props.selectedCohort,
|
||||
filterNames.includes('track') ? initialFilters.track : this.props.selectedTrack,
|
||||
filterNames.includes('assignmentType') ? initialFilters.assignmentType : this.props.selectedAssignmentType,
|
||||
);
|
||||
}
|
||||
|
||||
createStateFieldSetter = (key) => (value) => this.setState({ [key]: value });
|
||||
|
||||
createStateFieldOnChange = (key) => ({ target }) => this.setState({ [key]: target.value });
|
||||
|
||||
createLimitedSetter = (...keys) => (values) => this.setState(
|
||||
keys.reduce(
|
||||
(obj, key) => (values[key] === undefined ? obj : { ...obj, [key]: values[key] }),
|
||||
{},
|
||||
),
|
||||
)
|
||||
|
||||
safeSetState = this.createLimitedSetter(
|
||||
'adjustedGradePossible',
|
||||
'adjustedGradeValue',
|
||||
'assignmentName',
|
||||
'filterValue',
|
||||
'modalOpen',
|
||||
'reasonForChange',
|
||||
'todaysDate',
|
||||
'updateModuleId',
|
||||
'updateUserId',
|
||||
'updateUserName',
|
||||
);
|
||||
|
||||
setFilters = this.createLimitedSetter(
|
||||
'assignmentGradeMin',
|
||||
'assignmentGradeMax',
|
||||
'courseGradeMin',
|
||||
'courseGradeMax',
|
||||
'isMinCourseGradeFilterValid',
|
||||
'isMaxCourseGradeFilterValid',
|
||||
);
|
||||
|
||||
filterValues = () => ({
|
||||
assignmentGradeMin: this.state.assignmentGradeMin,
|
||||
assignmentGradeMax: this.state.assignmentGradeMax,
|
||||
courseGradeMin: this.state.courseGradeMin,
|
||||
courseGradeMax: this.state.courseGradeMax,
|
||||
});
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="d-flex justify-content-center">
|
||||
{ this.props.showSpinner && <div className="spinner-overlay"><Icon className={['fa', 'fa-spinner', 'fa-spin', 'fa-5x', 'color-black']} /></div>}
|
||||
<div className="gradebook-container">
|
||||
<div>
|
||||
<Drawer
|
||||
mainContent={toggleFilterDrawer => (
|
||||
<div className="px-3 gradebook-content">
|
||||
<a
|
||||
href={this.lmsInstructorDashboardUrl(this.props.match.params.courseId)}
|
||||
href={this.lmsInstructorDashboardUrl(this.props.courseId)}
|
||||
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" >
|
||||
<h3> {this.props.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>
|
||||
}
|
||||
<hr />
|
||||
<div className="d-flex justify-content-between" >
|
||||
<div>
|
||||
<div>
|
||||
Score View:
|
||||
<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>
|
||||
{ this.props.assignmnetTypes.length > 0 &&
|
||||
<div className="student-filters">
|
||||
<span className="label">
|
||||
Assignment Types:
|
||||
</span>
|
||||
<InputSelect
|
||||
name="assignment-types"
|
||||
value={this.mapSelectedTrackEntry(this.props.selectedAssignmentType)}
|
||||
options={this.mapAssignmentTypeEntries(this.props.assignmnetTypes)}
|
||||
onChange={this.updateAssignmentTypes}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
{(this.props.tracks.length > 0 || this.props.cohorts.length > 0) &&
|
||||
<div className="student-filters">
|
||||
<span className="label">
|
||||
Student Groups:
|
||||
</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}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ marginLeft: '10px', marginBottom: '10px' }}>
|
||||
<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)}
|
||||
onChange={filterValue => this.setState({ filterValue })}
|
||||
onClear={() => this.props.getUserGrades(this.props.match.params.courseId, this.props.selectedCohort, this.props.selectedTrack)}
|
||||
value={this.state.filterValue}
|
||||
/>
|
||||
<div className="d-flex justify-content-end" style={{ marginTop: '20px' }}>
|
||||
<Button
|
||||
label="Previous"
|
||||
buttonType="primary"
|
||||
style={{ visibility: (!this.props.prevPage ? 'hidden' : 'visible') }}
|
||||
onClick={() => this.props.getPrevNextGrades(this.props.prevPage, this.props.selectedCohort, this.props.selectedTrack)}
|
||||
/>
|
||||
<div style={{ width: '10px' }} />
|
||||
<Button
|
||||
label="Next"
|
||||
buttonType="primary"
|
||||
style={{ visibility: (!this.props.nextPage ? 'hidden' : 'visible') }}
|
||||
onClick={() => this.props.getPrevNextGrades(this.props.nextPage, this.props.selectedCohort, this.props.selectedTrack)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
<StatusAlert
|
||||
alertType="success"
|
||||
dialog="The grade has been successfully edited."
|
||||
onClose={() => this.props.updateBanner(false)}
|
||||
open={this.props.showSuccess}
|
||||
/>
|
||||
<div className="gbook">
|
||||
<Table
|
||||
columns={this.props.headings}
|
||||
data={this.formatter[this.props.format](this.props.grades, this.props.areGradesFrozen)}
|
||||
tableSortable
|
||||
defaultSortDirection="asc"
|
||||
defaultSortedColumn="username"
|
||||
/>
|
||||
</div>
|
||||
<Modal
|
||||
open={this.state.modalOpen}
|
||||
title="Edit Grades"
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
buttons={[
|
||||
<Button
|
||||
label="Edit Grade"
|
||||
buttonType="primary"
|
||||
onClick={this.handleAdjustedGradeClick}
|
||||
/>,
|
||||
]}
|
||||
onClose={() => this.setState({
|
||||
modalOpen: false,
|
||||
modalModel: [{}],
|
||||
updateVal: 0,
|
||||
updateModuleId: null,
|
||||
updateUserId: null,
|
||||
})}
|
||||
/>
|
||||
{(this.props.canUserViewGradebook === false)
|
||||
&& (
|
||||
<div className="alert alert-warning" role="alert">
|
||||
You are not authorized to view the gradebook for this course.
|
||||
</div>
|
||||
)}
|
||||
<Tabs defaultActiveKey="grades">
|
||||
<Tab eventKey="grades" title="Grades">
|
||||
{this.props.showSpinner && (
|
||||
<div className="spinner-overlay">
|
||||
<Icon className="fa fa-spinner fa-spin fa-5x color-black" />
|
||||
</div>
|
||||
)}
|
||||
<SearchControls
|
||||
courseId={this.props.courseId}
|
||||
filterValue={this.state.filterValue}
|
||||
setFilterValue={this.createStateFieldSetter('filterValue')}
|
||||
toggleFilterDrawer={toggleFilterDrawer}
|
||||
/>
|
||||
<ConnectedFilterBadges
|
||||
handleFilterBadgeClose={this.handleFilterBadgeClose}
|
||||
/>
|
||||
<StatusAlerts
|
||||
isMinCourseGradeFilterValid={this.state.isMinCourseGradeFilterValid}
|
||||
isMaxCourseGradeFilterValid={this.state.isMaxCourseGradeFilterValid}
|
||||
/>
|
||||
<h4>Step 2: View or Modify Individual Grades</h4>
|
||||
{this.props.totalUsersCount
|
||||
? (
|
||||
<div>
|
||||
Showing
|
||||
<span className="font-weight-bold"> {this.props.filteredUsersCount} </span>
|
||||
of
|
||||
<span className="font-weight-bold"> {this.props.totalUsersCount} </span>
|
||||
total learners
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
<div className="d-flex justify-content-between align-items-center mb-2">
|
||||
<InputSelect
|
||||
label="Score View:"
|
||||
name="ScoreView"
|
||||
value="percent"
|
||||
options={[{ label: 'Percent', value: 'percent' }, { label: 'Absolute', value: 'absolute' }]}
|
||||
onChange={this.props.toggleFormat}
|
||||
/>
|
||||
{this.props.showBulkManagement && (
|
||||
<BulkManagementControls
|
||||
courseId={this.props.courseId}
|
||||
gradeExportUrl={this.props.gradeExportUrl}
|
||||
interventionExportUrl={this.props.interventionExportUrl}
|
||||
showSpinner={this.props.showSpinner}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<GradebookTable setGradebookState={this.safeSetState} />
|
||||
{PageButtons(this.props)}
|
||||
<p>* available for learners in the Master's track only</p>
|
||||
<EditModal
|
||||
assignmentName={this.state.assignmentName}
|
||||
adjustedGradeValue={this.state.adjustedGradeValue}
|
||||
adjustedGradePossible={this.state.adjustedGradePossible}
|
||||
courseId={this.props.courseId}
|
||||
filterValue={this.state.filterValue}
|
||||
onChange={this.onChange}
|
||||
open={this.state.modalOpen}
|
||||
reasonForChange={this.state.reasonForChange}
|
||||
setAdjustedGradeValue={this.createStateFieldOnChange('adjustedGradeValue')}
|
||||
setGradebookState={this.safeSetState}
|
||||
setReasonForChange={this.createStateFieldOnChange('reasonForChange')}
|
||||
todaysDate={this.state.todaysDate}
|
||||
updateModuleId={this.state.updateModuleId}
|
||||
updateUserId={this.state.updateUserId}
|
||||
updateUserName={this.state.updateUserName}
|
||||
/>
|
||||
|
||||
</Tab>
|
||||
{this.props.showBulkManagement
|
||||
&& (
|
||||
<Tab eventKey="bulk_management" title="Bulk Management">
|
||||
<BulkManagement
|
||||
courseId={this.props.courseId}
|
||||
gradeExportUrl={this.props.gradeExportUrl}
|
||||
/>
|
||||
</Tab>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
initiallyOpen={false}
|
||||
title={(
|
||||
<>
|
||||
<FontAwesomeIcon icon={faFilter} /> Filter By...
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<GradebookFilters
|
||||
setFilters={this.setFilters}
|
||||
filterValues={this.filterValues()}
|
||||
updateQueryParams={this.updateQueryParams}
|
||||
courseId={this.props.courseId}
|
||||
/>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Gradebook.defaultProps = {
|
||||
areGradesFrozen: false,
|
||||
canUserViewGradebook: false,
|
||||
courseId: '',
|
||||
filteredUsersCount: null,
|
||||
location: {
|
||||
search: '',
|
||||
},
|
||||
selectedAssignmentType: '',
|
||||
selectedCohort: null,
|
||||
selectedTrack: null,
|
||||
showBulkManagement: false,
|
||||
showSpinner: false,
|
||||
totalUsersCount: null,
|
||||
};
|
||||
|
||||
Gradebook.propTypes = {
|
||||
areGradesFrozen: PropTypes.bool,
|
||||
canUserViewGradebook: PropTypes.bool,
|
||||
courseId: PropTypes.string,
|
||||
filteredUsersCount: PropTypes.number,
|
||||
getRoles: PropTypes.func.isRequired,
|
||||
getUserGrades: PropTypes.func.isRequired,
|
||||
gradeExportUrl: PropTypes.string.isRequired,
|
||||
history: PropTypes.shape({
|
||||
push: PropTypes.func,
|
||||
}).isRequired,
|
||||
initializeFilters: PropTypes.func.isRequired,
|
||||
interventionExportUrl: PropTypes.string.isRequired,
|
||||
location: PropTypes.shape({
|
||||
search: PropTypes.string,
|
||||
}),
|
||||
resetFilters: PropTypes.func.isRequired,
|
||||
selectedAssignmentType: PropTypes.string,
|
||||
selectedCohort: PropTypes.string,
|
||||
selectedTrack: PropTypes.string,
|
||||
showBulkManagement: PropTypes.bool,
|
||||
showSpinner: PropTypes.bool,
|
||||
toggleFormat: PropTypes.func.isRequired,
|
||||
totalUsersCount: PropTypes.number,
|
||||
};
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
import React from 'react';
|
||||
import { Hyperlink } from '@edx/paragon';
|
||||
|
||||
import EdxLogo from '../../../assets/edx-sm.png';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
export default class Header extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
mobileNavOpen: false,
|
||||
};
|
||||
}
|
||||
|
||||
renderLogo() {
|
||||
return (
|
||||
<img src={EdxLogo} alt="edX logo" height="30" width="60" />
|
||||
<img src={getConfig().LOGO_URL} alt="edX logo" height="30" width="60" />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,7 +13,9 @@ export default class Header extends React.Component {
|
||||
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" />
|
||||
<Hyperlink destination="https://www.edx.org">
|
||||
{this.renderLogo()}
|
||||
</Hyperlink>
|
||||
<div />
|
||||
</header>
|
||||
</div>
|
||||
|
||||
36
src/components/PageButtons/PageButtons.test.jsx
Normal file
36
src/components/PageButtons/PageButtons.test.jsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import renderer from 'react-test-renderer';
|
||||
import PageButtons from '.';
|
||||
|
||||
const createInput = function createInput(prevPage, nextPage) {
|
||||
return {
|
||||
prevPage,
|
||||
nextPage,
|
||||
selectedTrack: 't',
|
||||
selectedCohort: 'c',
|
||||
getPrevNextGrades() {},
|
||||
};
|
||||
};
|
||||
|
||||
describe('PageButtons', () => {
|
||||
const assertPageButtonsSnapshot = function assertPageButtonsSnapshot(input) {
|
||||
const pb = renderer.create(PageButtons(input));
|
||||
const tree = pb.toJSON();
|
||||
expect(tree).toMatchSnapshot();
|
||||
};
|
||||
|
||||
it('prev null, next null', () => {
|
||||
assertPageButtonsSnapshot(createInput(null, null));
|
||||
});
|
||||
|
||||
it('prev null, next not null', () => {
|
||||
assertPageButtonsSnapshot(createInput(null, 'np'));
|
||||
});
|
||||
|
||||
it('prev not null, next null', () => {
|
||||
assertPageButtonsSnapshot(createInput('pp', null));
|
||||
});
|
||||
|
||||
it('prev not null, next not null', () => {
|
||||
assertPageButtonsSnapshot(createInput('pp', 'np'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`PageButtons prev not null, next not null 1`] = `
|
||||
<div
|
||||
className="d-flex justify-content-center"
|
||||
style={
|
||||
Object {
|
||||
"paddingBottom": "20px",
|
||||
}
|
||||
}
|
||||
>
|
||||
<button
|
||||
className="btn btn-outline-primary"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"margin": "20px",
|
||||
}
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
Previous Page
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-primary"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"margin": "20px",
|
||||
}
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
Next Page
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`PageButtons prev not null, next null 1`] = `
|
||||
<div
|
||||
className="d-flex justify-content-center"
|
||||
style={
|
||||
Object {
|
||||
"paddingBottom": "20px",
|
||||
}
|
||||
}
|
||||
>
|
||||
<button
|
||||
className="btn btn-outline-primary"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"margin": "20px",
|
||||
}
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
Previous Page
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-primary"
|
||||
disabled={true}
|
||||
onClick={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"margin": "20px",
|
||||
}
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
Next Page
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`PageButtons prev null, next not null 1`] = `
|
||||
<div
|
||||
className="d-flex justify-content-center"
|
||||
style={
|
||||
Object {
|
||||
"paddingBottom": "20px",
|
||||
}
|
||||
}
|
||||
>
|
||||
<button
|
||||
className="btn btn-outline-primary"
|
||||
disabled={true}
|
||||
onClick={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"margin": "20px",
|
||||
}
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
Previous Page
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-primary"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"margin": "20px",
|
||||
}
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
Next Page
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`PageButtons prev null, next null 1`] = `
|
||||
<div
|
||||
className="d-flex justify-content-center"
|
||||
style={
|
||||
Object {
|
||||
"paddingBottom": "20px",
|
||||
}
|
||||
}
|
||||
>
|
||||
<button
|
||||
className="btn btn-outline-primary"
|
||||
disabled={true}
|
||||
onClick={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"margin": "20px",
|
||||
}
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
Previous Page
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-primary"
|
||||
disabled={true}
|
||||
onClick={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"margin": "20px",
|
||||
}
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
Next Page
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
75
src/components/PageButtons/index.jsx
Normal file
75
src/components/PageButtons/index.jsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Button } from '@edx/paragon';
|
||||
|
||||
export default function PageButtons({
|
||||
prevPage, nextPage, selectedTrack, selectedCohort, selectedAssignmentType,
|
||||
getPrevNextGrades, match,
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="d-flex justify-content-center"
|
||||
style={{ paddingBottom: '20px' }}
|
||||
>
|
||||
<Button
|
||||
style={{ margin: '20px' }}
|
||||
variant="outline-primary"
|
||||
disabled={!prevPage}
|
||||
onClick={() => getPrevNextGrades(
|
||||
prevPage,
|
||||
match.params.courseId,
|
||||
selectedCohort,
|
||||
selectedTrack,
|
||||
selectedAssignmentType,
|
||||
)}
|
||||
>
|
||||
Previous Page
|
||||
</Button>
|
||||
<Button
|
||||
style={{ margin: '20px' }}
|
||||
variant="outline-primary"
|
||||
disabled={!nextPage}
|
||||
onClick={() => getPrevNextGrades(
|
||||
nextPage,
|
||||
match.params.courseId,
|
||||
selectedCohort,
|
||||
selectedTrack,
|
||||
selectedAssignmentType,
|
||||
)}
|
||||
>
|
||||
Next Page
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
PageButtons.defaultProps = {
|
||||
match: {
|
||||
params: {
|
||||
courseId: '',
|
||||
},
|
||||
},
|
||||
nextPage: '',
|
||||
prevPage: '',
|
||||
selectedCohort: null,
|
||||
selectedTrack: null,
|
||||
selectedAssignmentType: null,
|
||||
};
|
||||
|
||||
PageButtons.propTypes = {
|
||||
getPrevNextGrades: PropTypes.func.isRequired,
|
||||
match: PropTypes.shape({
|
||||
params: PropTypes.shape({
|
||||
courseId: PropTypes.string,
|
||||
}),
|
||||
}),
|
||||
nextPage: PropTypes.string,
|
||||
prevPage: PropTypes.string,
|
||||
selectedAssignmentType: PropTypes.string,
|
||||
selectedCohort: PropTypes.shape({
|
||||
name: PropTypes.string,
|
||||
}),
|
||||
selectedTrack: PropTypes.shape({
|
||||
name: PropTypes.string,
|
||||
}),
|
||||
};
|
||||
@@ -1,71 +1,83 @@
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import selectors from 'data/selectors';
|
||||
import Gradebook from '../../components/Gradebook';
|
||||
import {
|
||||
fetchGradeOverrideHistory,
|
||||
fetchGrades,
|
||||
fetchMatchingUserGrades,
|
||||
fetchPrevNextGrades,
|
||||
updateGrades,
|
||||
filterAssignmentType,
|
||||
submitFileUploadFormData,
|
||||
toggleGradeFormat,
|
||||
filterColumns,
|
||||
updateBanner,
|
||||
downloadBulkGradesReport,
|
||||
downloadInterventionReport,
|
||||
} from '../../data/actions/grades';
|
||||
import { fetchCohorts } from '../../data/actions/cohorts';
|
||||
import { fetchTracks } from '../../data/actions/tracks';
|
||||
import {
|
||||
initializeFilters,
|
||||
resetFilters,
|
||||
updateAssignmentFilter,
|
||||
updateAssignmentLimits,
|
||||
} from '../../data/actions/filters';
|
||||
import { fetchAssignmentTypes } from '../../data/actions/assignmentTypes';
|
||||
import { getRoles } from '../../data/actions/roles';
|
||||
|
||||
const mapStateToProps = state => (
|
||||
{
|
||||
grades: state.grades.results,
|
||||
headings: state.grades.headings,
|
||||
tracks: state.tracks.results,
|
||||
cohorts: state.cohorts.results,
|
||||
selectedTrack: state.grades.selectedTrack,
|
||||
selectedCohort: state.grades.selectedCohort,
|
||||
format: state.grades.gradeFormat,
|
||||
showSuccess: state.grades.showSuccess,
|
||||
prevPage: state.grades.prevPage,
|
||||
const mapStateToProps = (state, ownProps) => {
|
||||
const {
|
||||
root,
|
||||
assignmentTypes,
|
||||
filters,
|
||||
grades,
|
||||
roles,
|
||||
} = selectors;
|
||||
|
||||
const { courseId } = ownProps.match.params;
|
||||
return {
|
||||
courseId,
|
||||
areGradesFrozen: assignmentTypes.areGradesFrozen(state),
|
||||
assignmentTypes: assignmentTypes.allAssignmentTypes(state),
|
||||
assignmentFilterOptions: filters.selectableAssignmentLabels(state),
|
||||
bulkImportError: grades.bulkImportError(state),
|
||||
bulkManagementHistory: grades.bulkManagementHistoryEntries(state),
|
||||
canUserViewGradebook: roles.canUserViewGradebook(state),
|
||||
filteredUsersCount: grades.filteredUsersCount(state),
|
||||
format: grades.gradeFormat(state),
|
||||
gradeExportUrl: root.gradeExportUrl(state, { courseId }),
|
||||
grades: grades.allGrades(state),
|
||||
headings: root.getHeadings(state),
|
||||
interventionExportUrl: root.interventionExportUrl(state, { courseId }),
|
||||
nextPage: state.grades.nextPage,
|
||||
assignmnetTypes: state.assignmentTypes.results,
|
||||
areGradesFrozen: state.assignmentTypes.areGradesFrozen,
|
||||
showSpinner: state.grades.showSpinner,
|
||||
}
|
||||
);
|
||||
prevPage: state.grades.prevPage,
|
||||
selectedTrack: filters.track(state),
|
||||
selectedCohort: filters.cohort(state),
|
||||
selectedAssignmentType: filters.assignmentType(state),
|
||||
selectedAssignment: filters.selectedAssignmentLabel(state),
|
||||
showBulkManagement: root.showBulkManagement(state, { courseId }),
|
||||
showSpinner: root.shouldShowSpinner(state),
|
||||
totalUsersCount: grades.totalUsersCount(state),
|
||||
uploadSuccess: grades.uploadSuccess(state),
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = dispatch => (
|
||||
{
|
||||
getUserGrades: (courseId, cohort, track) => {
|
||||
dispatch(fetchGrades(courseId, cohort, track));
|
||||
},
|
||||
searchForUser: (courseId, searchText, cohort, track) => {
|
||||
dispatch(fetchMatchingUserGrades(courseId, searchText, cohort, track, false));
|
||||
},
|
||||
getPrevNextGrades: (endpoint, cohort, track) => {
|
||||
dispatch(fetchPrevNextGrades(endpoint, cohort, track));
|
||||
},
|
||||
getCohorts: (courseId) => {
|
||||
dispatch(fetchCohorts(courseId));
|
||||
},
|
||||
getTracks: (courseId) => {
|
||||
dispatch(fetchTracks(courseId));
|
||||
},
|
||||
getAssignmentTypes: (courseId) => {
|
||||
dispatch(fetchAssignmentTypes(courseId));
|
||||
},
|
||||
updateGrades: (courseId, updateData, searchText, cohort, track) => {
|
||||
dispatch(updateGrades(courseId, updateData, searchText, cohort, track));
|
||||
},
|
||||
toggleFormat: (formatType) => {
|
||||
dispatch(toggleGradeFormat(formatType));
|
||||
},
|
||||
filterColumns: (filterType, exampleUser) => {
|
||||
dispatch(filterColumns(filterType, exampleUser));
|
||||
},
|
||||
updateBanner: (showSuccess) => {
|
||||
dispatch(updateBanner(showSuccess));
|
||||
},
|
||||
}
|
||||
);
|
||||
const mapDispatchToProps = {
|
||||
downloadBulkGradesReport,
|
||||
downloadInterventionReport,
|
||||
fetchGradeOverrideHistory,
|
||||
filterAssignmentType,
|
||||
getAssignmentTypes: fetchAssignmentTypes,
|
||||
getCohorts: fetchCohorts,
|
||||
getPrevNextGrades: fetchPrevNextGrades,
|
||||
getRoles,
|
||||
getTracks: fetchTracks,
|
||||
getUserGrades: fetchGrades,
|
||||
initializeFilters,
|
||||
resetFilters,
|
||||
submitFileUploadFormData,
|
||||
toggleFormat: toggleGradeFormat,
|
||||
updateAssignmentFilter,
|
||||
updateAssignmentLimits,
|
||||
};
|
||||
|
||||
const GradebookPage = connect(
|
||||
mapStateToProps,
|
||||
|
||||
@@ -4,12 +4,17 @@ import {
|
||||
ERROR_FETCHING_ASSIGNMENT_TYPES,
|
||||
GOT_ARE_GRADES_FROZEN,
|
||||
} from '../constants/actionTypes/assignmentTypes';
|
||||
import GOT_BULK_MANAGEMENT_CONFIG from '../constants/actionTypes/config';
|
||||
import LmsApiService from '../services/LmsApiService';
|
||||
|
||||
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 gotBulkManagementConfig = bulkManagementEnabled => ({
|
||||
type: GOT_BULK_MANAGEMENT_CONFIG,
|
||||
data: bulkManagementEnabled,
|
||||
});
|
||||
|
||||
const fetchAssignmentTypes = courseId => (
|
||||
(dispatch) => {
|
||||
@@ -19,6 +24,7 @@ const fetchAssignmentTypes = courseId => (
|
||||
.then((data) => {
|
||||
dispatch(gotAssignmentTypes(Object.keys(data.assignment_types)));
|
||||
dispatch(gotGradesFrozen(data.grades_frozen));
|
||||
dispatch(gotBulkManagementConfig(data.can_see_bulk_management));
|
||||
})
|
||||
.catch(() => {
|
||||
dispatch(errorFetchingAssignmentTypes());
|
||||
@@ -32,4 +38,3 @@ export {
|
||||
gotAssignmentTypes,
|
||||
errorFetchingAssignmentTypes,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import axios from 'axios';
|
||||
import configureMockStore from 'redux-mock-store';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import thunk from 'redux-thunk';
|
||||
|
||||
import apiClient from '../apiClient';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { configuration } from '../../config';
|
||||
import { fetchAssignmentTypes } from './assignmentTypes';
|
||||
import {
|
||||
@@ -11,9 +12,15 @@ import {
|
||||
ERROR_FETCHING_ASSIGNMENT_TYPES,
|
||||
GOT_ARE_GRADES_FROZEN,
|
||||
} from '../constants/actionTypes/assignmentTypes';
|
||||
import GOT_BULK_MANAGEMENT_CONFIG from '../constants/actionTypes/config';
|
||||
|
||||
const mockStore = configureMockStore([thunk]);
|
||||
const axiosMock = new MockAdapter(apiClient);
|
||||
|
||||
jest.mock('@edx/frontend-platform/auth');
|
||||
const axiosMock = new MockAdapter(axios);
|
||||
getAuthenticatedHttpClient.mockReturnValue(axios);
|
||||
axios.isAccessTokenExpired = jest.fn();
|
||||
axios.isAccessTokenExpired.mockReturnValue(false);
|
||||
|
||||
describe('actions', () => {
|
||||
afterEach(() => {
|
||||
@@ -40,12 +47,14 @@ describe('actions', () => {
|
||||
},
|
||||
},
|
||||
grades_frozen: false,
|
||||
can_see_bulk_management: true,
|
||||
};
|
||||
it('dispatches success action after fetching fetchAssignmentTypes', () => {
|
||||
const expectedActions = [
|
||||
{ type: STARTED_FETCHING_ASSIGNMENT_TYPES },
|
||||
{ type: GOT_ASSIGNMENT_TYPES, assignmentTypes: Object.keys(responseData.assignment_types) },
|
||||
{ type: GOT_ARE_GRADES_FROZEN, areGradesFrozen: responseData.grades_frozen },
|
||||
{ type: GOT_BULK_MANAGEMENT_CONFIG, data: true },
|
||||
];
|
||||
const store = mockStore();
|
||||
|
||||
@@ -77,6 +86,7 @@ describe('actions', () => {
|
||||
{ type: STARTED_FETCHING_ASSIGNMENT_TYPES },
|
||||
{ type: GOT_ASSIGNMENT_TYPES, assignmentTypes: Object.keys(responseData.assignment_types) },
|
||||
{ type: GOT_ARE_GRADES_FROZEN, areGradesFrozen: true },
|
||||
{ type: GOT_BULK_MANAGEMENT_CONFIG, data: true },
|
||||
];
|
||||
const store = mockStore();
|
||||
responseData.grades_frozen = true;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import axios from 'axios';
|
||||
import configureMockStore from 'redux-mock-store';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import thunk from 'redux-thunk';
|
||||
|
||||
import apiClient from '../apiClient';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { configuration } from '../../config';
|
||||
import { fetchCohorts } from './cohorts';
|
||||
import {
|
||||
@@ -12,7 +13,12 @@ import {
|
||||
} from '../constants/actionTypes/cohorts';
|
||||
|
||||
const mockStore = configureMockStore([thunk]);
|
||||
const axiosMock = new MockAdapter(apiClient);
|
||||
|
||||
jest.mock('@edx/frontend-platform/auth');
|
||||
const axiosMock = new MockAdapter(axios);
|
||||
getAuthenticatedHttpClient.mockReturnValue(axios);
|
||||
axios.isAccessTokenExpired = jest.fn();
|
||||
axios.isAccessTokenExpired.mockReturnValue(false);
|
||||
|
||||
describe('actions', () => {
|
||||
afterEach(() => {
|
||||
|
||||
81
src/data/actions/filters.js
Normal file
81
src/data/actions/filters.js
Normal file
@@ -0,0 +1,81 @@
|
||||
import filterSelectors from 'data/selectors/filters';
|
||||
import initialFilters from '../constants/filters';
|
||||
import {
|
||||
INITIALIZE_FILTERS,
|
||||
RESET_FILTERS,
|
||||
UPDATE_ASSIGNMENT_FILTER,
|
||||
UPDATE_ASSIGNMENT_LIMITS,
|
||||
UPDATE_COURSE_GRADE_LIMITS,
|
||||
UPDATE_INCLUDE_COURSE_ROLE_MEMBERS,
|
||||
} from '../constants/actionTypes/filters';
|
||||
import { fetchGrades } from './grades';
|
||||
|
||||
const { allFilters } = filterSelectors;
|
||||
|
||||
const initializeFilters = ({
|
||||
assignment = initialFilters.assignment,
|
||||
assignmentType = initialFilters.assignmentType,
|
||||
track = initialFilters.track,
|
||||
cohort = initialFilters.cohort,
|
||||
assignmentGradeMin = initialFilters.assignmentGradeMin,
|
||||
assignmentGradeMax = initialFilters.assignmentGradeMax,
|
||||
courseGradeMin = initialFilters.courseGradeMin,
|
||||
courseGradeMax = initialFilters.assignmentGradeMax,
|
||||
includeCourseRoleMembers = initialFilters.includeCourseRoleMembers,
|
||||
}) => ({
|
||||
type: INITIALIZE_FILTERS,
|
||||
data: {
|
||||
assignment: { id: assignment },
|
||||
assignmentType,
|
||||
track,
|
||||
cohort,
|
||||
assignmentGradeMin,
|
||||
assignmentGradeMax,
|
||||
courseGradeMin,
|
||||
courseGradeMax,
|
||||
includeCourseRoleMembers: Boolean(includeCourseRoleMembers),
|
||||
},
|
||||
});
|
||||
|
||||
const resetFilters = filterNames => ({
|
||||
type: RESET_FILTERS,
|
||||
filterNames,
|
||||
});
|
||||
|
||||
const updateAssignmentFilter = assignment => ({
|
||||
type: UPDATE_ASSIGNMENT_FILTER,
|
||||
data: assignment,
|
||||
});
|
||||
|
||||
const updateAssignmentLimits = (minGrade, maxGrade) => ({
|
||||
type: UPDATE_ASSIGNMENT_LIMITS,
|
||||
data: { minGrade, maxGrade },
|
||||
});
|
||||
|
||||
const updateCourseGradeFilter = (courseGradeMin, courseGradeMax, courseId) => ({
|
||||
type: UPDATE_COURSE_GRADE_LIMITS,
|
||||
data: {
|
||||
courseGradeMin,
|
||||
courseGradeMax,
|
||||
courseId,
|
||||
},
|
||||
});
|
||||
|
||||
const updateIncludeCourseRoleMembersFilter = (includeCourseRoleMembers) => ({
|
||||
type: UPDATE_INCLUDE_COURSE_ROLE_MEMBERS,
|
||||
data: {
|
||||
includeCourseRoleMembers,
|
||||
},
|
||||
});
|
||||
|
||||
const updateIncludeCourseRoleMembers = includeCourseRoleMembers => (dispatch, getState) => {
|
||||
dispatch(updateIncludeCourseRoleMembersFilter(includeCourseRoleMembers));
|
||||
const state = getState();
|
||||
const { cohort, track, assignmentType } = allFilters(state);
|
||||
dispatch(fetchGrades(state.grades.courseId, cohort, track, assignmentType));
|
||||
};
|
||||
|
||||
export {
|
||||
initializeFilters, resetFilters, updateAssignmentFilter,
|
||||
updateAssignmentLimits, updateCourseGradeFilter, updateIncludeCourseRoleMembers,
|
||||
};
|
||||
@@ -1,3 +1,6 @@
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import gradesSelectors from 'data/selectors/grades';
|
||||
import filtersSelectors from 'data/selectors/filters';
|
||||
import {
|
||||
STARTED_FETCHING_GRADES,
|
||||
FINISHED_FETCHING_GRADES,
|
||||
@@ -7,79 +10,182 @@ import {
|
||||
GRADE_UPDATE_SUCCESS,
|
||||
GRADE_UPDATE_FAILURE,
|
||||
TOGGLE_GRADE_FORMAT,
|
||||
SORT_GRADES,
|
||||
FILTER_COLUMNS,
|
||||
UPDATE_BANNER,
|
||||
FILTER_BY_ASSIGNMENT_TYPE,
|
||||
OPEN_BANNER,
|
||||
CLOSE_BANNER,
|
||||
START_UPLOAD,
|
||||
UPLOAD_COMPLETE,
|
||||
UPLOAD_ERR,
|
||||
GOT_BULK_HISTORY,
|
||||
BULK_HISTORY_ERR,
|
||||
GOT_GRADE_OVERRIDE_HISTORY,
|
||||
DONE_VIEWING_ASSIGNMENT,
|
||||
ERROR_FETCHING_GRADE_OVERRIDE_HISTORY,
|
||||
UPLOAD_OVERRIDE,
|
||||
UPLOAD_OVERRIDE_ERROR,
|
||||
BULK_GRADE_REPORT_DOWNLOADED,
|
||||
INTERVENTION_REPORT_DOWNLOADED,
|
||||
} from '../constants/actionTypes/grades';
|
||||
import GRADE_OVERRIDE_HISTORY_ERROR_DEFAULT_MSG from '../constants/errors';
|
||||
import LmsApiService from '../services/LmsApiService';
|
||||
import store from '../store';
|
||||
import { headingMapper, gradeSortMap, sortAlphaAsc } from './utils';
|
||||
import apiClient from '../apiClient';
|
||||
import { sortAlphaAsc, formatDateForDisplay } from './utils';
|
||||
|
||||
const {
|
||||
formatMaxAssignmentGrade,
|
||||
formatMinAssignmentGrade,
|
||||
formatMaxCourseGrade,
|
||||
formatMinCourseGrade,
|
||||
} = gradesSelectors;
|
||||
const { allFilters } = filtersSelectors;
|
||||
|
||||
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 startedCsvUpload = () => ({ type: START_UPLOAD });
|
||||
const finishedCsvUpload = () => ({ type: UPLOAD_COMPLETE });
|
||||
const csvUploadError = data => ({ type: UPLOAD_ERR, data });
|
||||
const gotBulkHistory = data => ({ type: GOT_BULK_HISTORY, data });
|
||||
const bulkHistoryError = () => ({ type: BULK_HISTORY_ERR });
|
||||
|
||||
const startedFetchingGrades = () => ({ type: STARTED_FETCHING_GRADES });
|
||||
const finishedFetchingGrades = () => ({ type: FINISHED_FETCHING_GRADES });
|
||||
const errorFetchingGrades = () => ({ type: ERROR_FETCHING_GRADES });
|
||||
const gotGrades = (grades, cohort, track, headings, prev, next) => ({
|
||||
const errorFetchingGradeOverrideHistory = errorMessage => ({
|
||||
type: ERROR_FETCHING_GRADE_OVERRIDE_HISTORY,
|
||||
errorMessage,
|
||||
});
|
||||
|
||||
const gotGrades = ({
|
||||
grades, cohort, track, assignmentType, headings, prev,
|
||||
next, courseId, totalUsersCount, filteredUsersCount,
|
||||
}) => ({
|
||||
type: GOT_GRADES,
|
||||
grades,
|
||||
cohort,
|
||||
track,
|
||||
assignmentType,
|
||||
headings,
|
||||
prev,
|
||||
next,
|
||||
courseId,
|
||||
totalUsersCount,
|
||||
filteredUsersCount,
|
||||
});
|
||||
|
||||
const gotGradeOverrideHistory = ({
|
||||
overrideHistory, currentEarnedAllOverride, currentPossibleAllOverride,
|
||||
currentEarnedGradedOverride, currentPossibleGradedOverride,
|
||||
originalGradeEarnedAll, originalGradePossibleAll, originalGradeEarnedGraded,
|
||||
originalGradePossibleGraded,
|
||||
}) => ({
|
||||
type: GOT_GRADE_OVERRIDE_HISTORY,
|
||||
overrideHistory,
|
||||
currentEarnedAllOverride,
|
||||
currentPossibleAllOverride,
|
||||
currentEarnedGradedOverride,
|
||||
currentPossibleGradedOverride,
|
||||
originalGradeEarnedAll,
|
||||
originalGradePossibleAll,
|
||||
originalGradeEarnedGraded,
|
||||
originalGradePossibleGraded,
|
||||
});
|
||||
|
||||
const gradeUpdateRequest = () => ({ type: GRADE_UPDATE_REQUEST });
|
||||
const gradeUpdateSuccess = 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 uploadOverrideSuccess = courseId => ({
|
||||
type: UPLOAD_OVERRIDE,
|
||||
courseId,
|
||||
});
|
||||
// This action for google analytics only. Doesn't change redux state.
|
||||
const downloadBulkGradesReport = courseId => ({
|
||||
type: BULK_GRADE_REPORT_DOWNLOADED,
|
||||
courseId,
|
||||
});
|
||||
// This action for google analytics only. Doesn't change redux state.
|
||||
const downloadInterventionReport = courseId => ({
|
||||
type: INTERVENTION_REPORT_DOWNLOADED,
|
||||
courseId,
|
||||
});
|
||||
const uploadOverrideFailure = (courseId, error) => ({
|
||||
type: UPLOAD_OVERRIDE_ERROR,
|
||||
courseId,
|
||||
payload: { error },
|
||||
});
|
||||
|
||||
|
||||
const toggleGradeFormat = formatType => ({ type: TOGGLE_GRADE_FORMAT, formatType });
|
||||
|
||||
const filterColumns = (filterType, exampleUser) => (
|
||||
const filterAssignmentType = filterType => (
|
||||
dispatch => dispatch({
|
||||
type: FILTER_COLUMNS,
|
||||
headings: headingMapper(filterType)(dispatch, exampleUser),
|
||||
type: FILTER_BY_ASSIGNMENT_TYPE,
|
||||
filterType,
|
||||
})
|
||||
);
|
||||
|
||||
const updateBanner = showSuccess => ({ type: UPDATE_BANNER, showSuccess });
|
||||
const openBanner = () => ({ type: OPEN_BANNER });
|
||||
const closeBanner = () => ({ type: CLOSE_BANNER });
|
||||
|
||||
const fetchGrades = (courseId, cohort, track, showSuccess) => (
|
||||
(dispatch) => {
|
||||
const fetchGrades = (
|
||||
courseId,
|
||||
cohort,
|
||||
track,
|
||||
assignmentType,
|
||||
options = {},
|
||||
) => (
|
||||
(dispatch, getState) => {
|
||||
dispatch(startedFetchingGrades());
|
||||
return LmsApiService.fetchGradebookData(courseId, null, cohort, track)
|
||||
const {
|
||||
assignment,
|
||||
assignmentGradeMax: assignmentMax,
|
||||
assignmentGradeMin: assignmentMin,
|
||||
courseGradeMin,
|
||||
courseGradeMax,
|
||||
includeCourseRoleMembers,
|
||||
} = allFilters(getState());
|
||||
const { id: assignmentId } = assignment || {};
|
||||
const assignmentGradeMax = formatMaxAssignmentGrade(assignmentMax, { assignmentId });
|
||||
const assignmentGradeMin = formatMinAssignmentGrade(assignmentMin, { assignmentId });
|
||||
const courseGradeMinFormatted = formatMinCourseGrade(courseGradeMin);
|
||||
const courseGradeMaxFormatted = formatMaxCourseGrade(courseGradeMax);
|
||||
return LmsApiService.fetchGradebookData(
|
||||
courseId,
|
||||
options.searchText || null,
|
||||
cohort,
|
||||
track,
|
||||
{
|
||||
assignment: assignmentId,
|
||||
assignmentGradeMax,
|
||||
assignmentGradeMin,
|
||||
courseGradeMin: courseGradeMinFormatted,
|
||||
courseGradeMax: courseGradeMaxFormatted,
|
||||
includeCourseRoleMembers,
|
||||
},
|
||||
|
||||
)
|
||||
.then(response => response.data)
|
||||
.then((data) => {
|
||||
dispatch(gotGrades(
|
||||
data.results.sort(sortAlphaAsc),
|
||||
dispatch(gotGrades({
|
||||
grades: data.results.sort(sortAlphaAsc),
|
||||
cohort,
|
||||
track,
|
||||
headingMapper(defaultAssignmentFilter)(dispatch, data.results[0]),
|
||||
data.previous,
|
||||
data.next,
|
||||
));
|
||||
assignmentType,
|
||||
prev: data.previous,
|
||||
next: data.next,
|
||||
courseId,
|
||||
totalUsersCount: data.total_users_count,
|
||||
filteredUsersCount: data.filtered_users_count,
|
||||
}));
|
||||
dispatch(finishedFetchingGrades());
|
||||
dispatch(updateBanner(!!showSuccess));
|
||||
if (options.showSuccess) {
|
||||
dispatch(openBanner());
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
dispatch(errorFetchingGrades());
|
||||
@@ -87,43 +193,74 @@ const fetchGrades = (courseId, cohort, track, showSuccess) => (
|
||||
}
|
||||
);
|
||||
|
||||
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.sort(sortAlphaAsc),
|
||||
cohort,
|
||||
track,
|
||||
headingMapper(defaultAssignmentFilter)(dispatch, data.results[0]),
|
||||
data.previous,
|
||||
data.next,
|
||||
));
|
||||
dispatch(finishedFetchingGrades());
|
||||
dispatch(updateBanner(showSuccess));
|
||||
})
|
||||
.catch(() => {
|
||||
dispatch(errorFetchingGrades());
|
||||
});
|
||||
}
|
||||
const formatGradeOverrideForDisplay = historyArray => historyArray.map(item => ({
|
||||
date: formatDateForDisplay(new Date(item.history_date)),
|
||||
grader: item.history_user,
|
||||
reason: item.override_reason,
|
||||
adjustedGrade: item.earned_graded_override,
|
||||
}));
|
||||
|
||||
const doneViewingAssignment = () => dispatch => dispatch({
|
||||
type: DONE_VIEWING_ASSIGNMENT,
|
||||
});
|
||||
const fetchGradeOverrideHistory = (subsectionId, userId) => (
|
||||
dispatch => LmsApiService.fetchGradeOverrideHistory(subsectionId, userId)
|
||||
.then(response => response.data)
|
||||
.then((data) => {
|
||||
if (data.success) {
|
||||
dispatch(gotGradeOverrideHistory({
|
||||
overrideHistory: formatGradeOverrideForDisplay(data.history),
|
||||
currentEarnedAllOverride: data.override ? data.override.earned_all_override : null,
|
||||
currentPossibleAllOverride: data.override ? data.override.possible_all_override : null,
|
||||
currentEarnedGradedOverride: data.override ? data.override.earned_graded_override : null,
|
||||
currentPossibleGradedOverride: data.override
|
||||
? data.override.possible_graded_override : null,
|
||||
originalGradeEarnedAll: data.original_grade ? data.original_grade.earned_all : null,
|
||||
originalGradePossibleAll: data.original_grade ? data.original_grade.possible_all : null,
|
||||
originalGradeEarnedGraded: data.original_grade
|
||||
? data.original_grade.earned_graded : null,
|
||||
originalGradePossibleGraded: data.original_grade
|
||||
? data.original_grade.possible_graded : null,
|
||||
}));
|
||||
} else {
|
||||
dispatch(errorFetchingGradeOverrideHistory(data.error_message));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
dispatch(errorFetchingGradeOverrideHistory(GRADE_OVERRIDE_HISTORY_ERROR_DEFAULT_MSG));
|
||||
})
|
||||
);
|
||||
|
||||
const fetchPrevNextGrades = (endpoint, cohort, track) => (
|
||||
const fetchMatchingUserGrades = (
|
||||
courseId,
|
||||
searchText,
|
||||
cohort,
|
||||
track,
|
||||
assignmentType,
|
||||
showSuccess,
|
||||
options = {},
|
||||
) => {
|
||||
const newOptions = { ...options, searchText, showSuccess };
|
||||
return fetchGrades(courseId, cohort, track, assignmentType, newOptions);
|
||||
};
|
||||
|
||||
const fetchPrevNextGrades = (endpoint, courseId, cohort, track, assignmentType) => (
|
||||
(dispatch) => {
|
||||
dispatch(startedFetchingGrades());
|
||||
return apiClient.get(endpoint)
|
||||
return getAuthenticatedHttpClient().get(endpoint)
|
||||
.then(response => response.data)
|
||||
.then((data) => {
|
||||
dispatch(gotGrades(
|
||||
data.results.sort(sortAlphaAsc),
|
||||
dispatch(gotGrades({
|
||||
grades: data.results.sort(sortAlphaAsc),
|
||||
cohort,
|
||||
track,
|
||||
headingMapper(defaultAssignmentFilter)(dispatch, data.results[0]),
|
||||
data.previous,
|
||||
data.next,
|
||||
));
|
||||
assignmentType,
|
||||
prev: data.previous,
|
||||
next: data.next,
|
||||
courseId,
|
||||
totalUsersCount: data.total_users_count,
|
||||
filteredUsersCount: data.filtered_users_count,
|
||||
}));
|
||||
dispatch(finishedFetchingGrades());
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -132,22 +269,71 @@ const fetchPrevNextGrades = (endpoint, cohort, track) => (
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
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(fetchMatchingUserGrades(courseId, searchText, cohort, track, true));
|
||||
dispatch(gradeUpdateSuccess(courseId, data));
|
||||
dispatch(fetchMatchingUserGrades(
|
||||
courseId,
|
||||
searchText,
|
||||
cohort,
|
||||
track,
|
||||
defaultAssignmentFilter,
|
||||
true,
|
||||
{ searchText },
|
||||
));
|
||||
})
|
||||
.catch((error) => {
|
||||
dispatch(gradeUpdateFailure(error));
|
||||
dispatch(gradeUpdateFailure(courseId, error));
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const submitFileUploadFormData = (courseId, formData) => (
|
||||
(dispatch) => {
|
||||
dispatch(startedCsvUpload());
|
||||
return LmsApiService.uploadGradeCsv(courseId, formData).then(() => {
|
||||
dispatch(finishedCsvUpload());
|
||||
dispatch(uploadOverrideSuccess(courseId));
|
||||
}).catch((err) => {
|
||||
dispatch(uploadOverrideFailure(courseId, err));
|
||||
if (err.status === 200 && err.data.error_messages.length) {
|
||||
const { error_messages: errorMessages, saved, total } = err.data;
|
||||
return dispatch(csvUploadError({ errorMessages, saved, total }));
|
||||
}
|
||||
return dispatch(csvUploadError({ errorMessages: ['Unknown error.'] }));
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const fetchBulkUpgradeHistory = courseId => (
|
||||
// todo add loading effect
|
||||
dispatch => LmsApiService.fetchGradeBulkOperationHistory(courseId).then(
|
||||
(response) => { dispatch(gotBulkHistory(response)); },
|
||||
).catch(() => dispatch(bulkHistoryError()))
|
||||
);
|
||||
|
||||
const updateGradesIfAssignmentGradeFiltersSet = (
|
||||
courseId,
|
||||
cohort,
|
||||
track,
|
||||
assignmentType,
|
||||
) => (dispatch, getState) => {
|
||||
const { filters } = getState();
|
||||
const hasAssignmentGradeFiltersSet = filters.assignmentGradeMax || filters.assignmentGradeMin;
|
||||
if (hasAssignmentGradeFiltersSet) {
|
||||
dispatch(fetchGrades(
|
||||
courseId,
|
||||
cohort,
|
||||
track,
|
||||
assignmentType,
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
export {
|
||||
startedFetchingGrades,
|
||||
finishedFetchingGrades,
|
||||
@@ -161,7 +347,13 @@ export {
|
||||
gradeUpdateFailure,
|
||||
updateGrades,
|
||||
toggleGradeFormat,
|
||||
sortGrades,
|
||||
filterColumns,
|
||||
updateBanner,
|
||||
filterAssignmentType,
|
||||
closeBanner,
|
||||
submitFileUploadFormData,
|
||||
fetchBulkUpgradeHistory,
|
||||
doneViewingAssignment,
|
||||
fetchGradeOverrideHistory,
|
||||
updateGradesIfAssignmentGradeFiltersSet,
|
||||
downloadBulkGradesReport,
|
||||
downloadInterventionReport,
|
||||
};
|
||||
|
||||
@@ -1,22 +1,30 @@
|
||||
import axios from 'axios';
|
||||
import configureMockStore from 'redux-mock-store';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import thunk from 'redux-thunk';
|
||||
|
||||
import apiClient from '../apiClient';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { configuration } from '../../config';
|
||||
import { fetchGrades } from './grades';
|
||||
import { fetchGrades, fetchGradeOverrideHistory } from './grades';
|
||||
import {
|
||||
STARTED_FETCHING_GRADES,
|
||||
FINISHED_FETCHING_GRADES,
|
||||
ERROR_FETCHING_GRADES,
|
||||
GOT_GRADES,
|
||||
UPDATE_BANNER,
|
||||
GOT_GRADE_OVERRIDE_HISTORY,
|
||||
ERROR_FETCHING_GRADE_OVERRIDE_HISTORY,
|
||||
} from '../constants/actionTypes/grades';
|
||||
import GRADE_OVERRIDE_HISTORY_ERROR_DEFAULT_MSG from '../constants/errors';
|
||||
import { sortAlphaAsc } from './utils';
|
||||
|
||||
import LmsApiService from '../services/LmsApiService';
|
||||
|
||||
const mockStore = configureMockStore([thunk]);
|
||||
const axiosMock = new MockAdapter(apiClient);
|
||||
|
||||
jest.mock('@edx/frontend-platform/auth');
|
||||
const axiosMock = new MockAdapter(axios);
|
||||
getAuthenticatedHttpClient.mockReturnValue(axios);
|
||||
axios.isAccessTokenExpired = jest.fn();
|
||||
axios.isAccessTokenExpired.mockReturnValue(false);
|
||||
|
||||
describe('actions', () => {
|
||||
afterEach(() => {
|
||||
@@ -27,7 +35,8 @@ describe('actions', () => {
|
||||
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 expectedAssignmentType = 'Exam';
|
||||
const fetchGradesURL = `${configuration.LMS_BASE_URL}/api/grades/v1/gradebook/${courseId}/?page_size=25&cohort_id=${expectedCohort}&enrollment_mode=${expectedTrack}&excluded_course_roles=all`;
|
||||
const responseData = {
|
||||
next: `${fetchGradesURL}&cursor=2344fda`,
|
||||
previous: null,
|
||||
@@ -94,32 +103,25 @@ describe('actions', () => {
|
||||
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(),
|
||||
},
|
||||
],
|
||||
assignmentType: expectedAssignmentType,
|
||||
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(() => {
|
||||
return store.dispatch(fetchGrades(
|
||||
courseId,
|
||||
expectedCohort,
|
||||
expectedTrack,
|
||||
expectedAssignmentType,
|
||||
false,
|
||||
)).then(() => {
|
||||
expect(store.getActions()).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
@@ -134,9 +136,139 @@ describe('actions', () => {
|
||||
axiosMock.onGet(fetchGradesURL)
|
||||
.replyOnce(500, JSON.stringify({}));
|
||||
|
||||
return store.dispatch(fetchGrades(courseId, expectedCohort, expectedTrack, false)).then(() => {
|
||||
return store.dispatch(fetchGrades(
|
||||
courseId,
|
||||
expectedCohort,
|
||||
expectedTrack,
|
||||
expectedAssignmentType,
|
||||
false,
|
||||
)).then(() => {
|
||||
expect(store.getActions()).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
|
||||
it('dispatches success action on empty response after fetching grades', () => {
|
||||
const emptyResponseData = {
|
||||
next: responseData.next,
|
||||
previous: responseData.previous,
|
||||
results: [],
|
||||
};
|
||||
const expectedActions = [
|
||||
{ type: STARTED_FETCHING_GRADES },
|
||||
{
|
||||
type: GOT_GRADES,
|
||||
grades: [],
|
||||
cohort: expectedCohort,
|
||||
track: expectedTrack,
|
||||
assignmentType: expectedAssignmentType,
|
||||
prev: responseData.previous,
|
||||
next: responseData.next,
|
||||
courseId,
|
||||
},
|
||||
{ type: FINISHED_FETCHING_GRADES },
|
||||
];
|
||||
const store = mockStore();
|
||||
|
||||
axiosMock.onGet(fetchGradesURL)
|
||||
.replyOnce(200, JSON.stringify(emptyResponseData));
|
||||
|
||||
return store.dispatch(fetchGrades(
|
||||
courseId,
|
||||
expectedCohort,
|
||||
expectedTrack,
|
||||
expectedAssignmentType,
|
||||
false,
|
||||
)).then(() => {
|
||||
expect(store.getActions()).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchGradeOverridHistory', () => {
|
||||
const subsectionId = 'subsectionId-11111';
|
||||
const userId = 'user-id-11111';
|
||||
|
||||
const fetchOverridesURL = `${LmsApiService.baseUrl}/api/grades/v1/subsection/${subsectionId}/?user_id=${userId}&history_record_limit=5`;
|
||||
|
||||
const originalGrade = {
|
||||
earned_all: 1.0,
|
||||
possible_all: 12.0,
|
||||
earned_graded: 3.0,
|
||||
possible_graded: 8.0,
|
||||
};
|
||||
|
||||
const override = {
|
||||
earned_all_override: 13.0,
|
||||
possible_all_override: 13.0,
|
||||
earned_graded_override: 10.0,
|
||||
possible_graded_override: 10.0,
|
||||
};
|
||||
|
||||
it('dispatches success action after successfully getting override info', () => {
|
||||
const responseData = {
|
||||
success: true,
|
||||
original_grade: originalGrade,
|
||||
history: [],
|
||||
override,
|
||||
};
|
||||
|
||||
axiosMock.onGet(fetchOverridesURL)
|
||||
.replyOnce(200, JSON.stringify(responseData));
|
||||
|
||||
const expectedActions = [
|
||||
{
|
||||
type: GOT_GRADE_OVERRIDE_HISTORY,
|
||||
overrideHistory: [],
|
||||
currentEarnedAllOverride: override.earned_all_override,
|
||||
currentPossibleAllOverride: override.possible_all_override,
|
||||
currentEarnedGradedOverride: override.earned_graded_override,
|
||||
currentPossibleGradedOverride: override.possible_graded_override,
|
||||
originalGradeEarnedAll: originalGrade.earned_all,
|
||||
originalGradePossibleAll: originalGrade.possible_all,
|
||||
originalGradeEarnedGraded: originalGrade.earned_graded,
|
||||
originalGradePossibleGraded: originalGrade.possible_graded,
|
||||
},
|
||||
];
|
||||
const store = mockStore();
|
||||
|
||||
return store.dispatch(fetchGradeOverrideHistory(subsectionId, userId)).then(() => {
|
||||
expect(store.getActions()).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dispatches failure action with expected message', () => {
|
||||
test('on failure response', () => {
|
||||
const responseData = {
|
||||
success: false,
|
||||
error_message: 'There was an error!!!!!!!!!',
|
||||
};
|
||||
|
||||
axiosMock.onGet(fetchOverridesURL).replyOnce(200, JSON.stringify(responseData));
|
||||
|
||||
const expectedActions = [{
|
||||
type: ERROR_FETCHING_GRADE_OVERRIDE_HISTORY,
|
||||
errorMessage: responseData.error_message,
|
||||
}];
|
||||
const store = mockStore();
|
||||
|
||||
return store.dispatch(fetchGradeOverrideHistory(subsectionId, userId)).then(() => {
|
||||
expect(store.getActions()).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
|
||||
test('on 500 error', () => {
|
||||
axiosMock.onGet(fetchOverridesURL).replyOnce(500);
|
||||
|
||||
const expectedActions = [{
|
||||
type: ERROR_FETCHING_GRADE_OVERRIDE_HISTORY,
|
||||
errorMessage: GRADE_OVERRIDE_HISTORY_ERROR_DEFAULT_MSG,
|
||||
}];
|
||||
const store = mockStore();
|
||||
|
||||
return store.dispatch(fetchGradeOverrideHistory(subsectionId, userId)).then(() => {
|
||||
expect(store.getActions()).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
46
src/data/actions/roles.js
Normal file
46
src/data/actions/roles.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import filtersSelectors from 'data/selectors/filters';
|
||||
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 { allFilters } = filtersSelectors;
|
||||
|
||||
const allowedRoles = ['staff', 'instructor', 'support'];
|
||||
|
||||
const gotRoles = (canUserViewGradebook, courseId) => ({
|
||||
type: GOT_ROLES,
|
||||
canUserViewGradebook,
|
||||
courseId,
|
||||
});
|
||||
const errorFetchingRoles = () => ({ type: ERROR_FETCHING_ROLES });
|
||||
|
||||
const getRoles = courseId => (
|
||||
(dispatch, getState) => LmsApiService.fetchUserRoles(courseId)
|
||||
.then(response => response.data)
|
||||
.then((response) => {
|
||||
const canUserViewGradebook = response.is_staff
|
||||
|| (response.roles.some(role => (role.course_id === courseId)
|
||||
&& allowedRoles.includes(role.role)));
|
||||
dispatch(gotRoles(canUserViewGradebook, courseId));
|
||||
const { cohort, track, assignmentType } = allFilters(getState());
|
||||
if (canUserViewGradebook) {
|
||||
dispatch(fetchGrades(courseId, cohort, track, assignmentType));
|
||||
dispatch(fetchTracks(courseId));
|
||||
dispatch(fetchCohorts(courseId));
|
||||
dispatch(fetchAssignmentTypes(courseId));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
dispatch(errorFetchingRoles());
|
||||
}));
|
||||
|
||||
export {
|
||||
getRoles,
|
||||
errorFetchingRoles,
|
||||
};
|
||||
166
src/data/actions/roles.test.js
Normal file
166
src/data/actions/roles.test.js
Normal file
@@ -0,0 +1,166 @@
|
||||
import axios from 'axios';
|
||||
import configureMockStore from 'redux-mock-store';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import thunk from 'redux-thunk';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
import { 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]);
|
||||
|
||||
jest.mock('@edx/frontend-platform/auth');
|
||||
const axiosMock = new MockAdapter(axios);
|
||||
getAuthenticatedHttpClient.mockReturnValue(axios);
|
||||
axios.isAccessTokenExpired = jest.fn();
|
||||
axios.isAccessTokenExpired.mockReturnValue(false);
|
||||
|
||||
const course1Id = 'course-v1:edX+DemoX+Demo_Course';
|
||||
const course2Id = 'course-v1:edX+DemoX+Demo_Course_2';
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,21 +1,29 @@
|
||||
/* eslint-disable camelcase */
|
||||
import tracksSelectors from 'data/selectors/tracks';
|
||||
import {
|
||||
STARTED_FETCHING_TRACKS,
|
||||
GOT_TRACKS,
|
||||
ERROR_FETCHING_TRACKS,
|
||||
} from '../constants/actionTypes/tracks';
|
||||
import { fetchBulkUpgradeHistory } from './grades';
|
||||
import LmsApiService from '../services/LmsApiService';
|
||||
|
||||
const { hasMastersTrack } = tracksSelectors;
|
||||
|
||||
const startedFetchingTracks = () => ({ type: STARTED_FETCHING_TRACKS });
|
||||
const errorFetchingTracks = () => ({ type: ERROR_FETCHING_TRACKS });
|
||||
const gotTracks = tracks => ({ type: GOT_TRACKS, tracks });
|
||||
const gotTracks = (tracks) => ({ type: GOT_TRACKS, tracks });
|
||||
|
||||
const fetchTracks = courseId => (
|
||||
const fetchTracks = (courseId) => (
|
||||
(dispatch) => {
|
||||
dispatch(startedFetchingTracks());
|
||||
return LmsApiService.fetchTracks(courseId)
|
||||
.then(response => response.data)
|
||||
.then((data) => {
|
||||
dispatch(gotTracks(data.course_modes));
|
||||
.then(({ data }) => data)
|
||||
.then(({ course_modes }) => {
|
||||
dispatch(gotTracks(course_modes));
|
||||
if (hasMastersTrack(course_modes)) {
|
||||
dispatch(fetchBulkUpgradeHistory(courseId));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
dispatch(errorFetchingTracks());
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import axios from 'axios';
|
||||
import configureMockStore from 'redux-mock-store';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import thunk from 'redux-thunk';
|
||||
|
||||
import apiClient from '../apiClient';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { configuration } from '../../config';
|
||||
import { fetchTracks } from './tracks';
|
||||
import {
|
||||
@@ -12,7 +13,12 @@ import {
|
||||
} from '../constants/actionTypes/tracks';
|
||||
|
||||
const mockStore = configureMockStore([thunk]);
|
||||
const axiosMock = new MockAdapter(apiClient);
|
||||
|
||||
jest.mock('@edx/frontend-platform/auth');
|
||||
const axiosMock = new MockAdapter(axios);
|
||||
getAuthenticatedHttpClient.mockReturnValue(axios);
|
||||
axios.isAccessTokenExpired = jest.fn();
|
||||
axios.isAccessTokenExpired.mockReturnValue(false);
|
||||
|
||||
describe('actions', () => {
|
||||
afterEach(() => {
|
||||
@@ -21,6 +27,7 @@ describe('actions', () => {
|
||||
|
||||
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 = {
|
||||
@@ -54,7 +61,7 @@ describe('actions', () => {
|
||||
];
|
||||
const store = mockStore();
|
||||
|
||||
axiosMock.onGet(`${configuration.LMS_BASE_URL}/api/enrollment/v1/course/${courseId}`)
|
||||
axiosMock.onGet(trackUrl)
|
||||
.replyOnce(200, JSON.stringify(responseData));
|
||||
|
||||
return store.dispatch(fetchTracks(courseId)).then(() => {
|
||||
@@ -69,7 +76,7 @@ describe('actions', () => {
|
||||
];
|
||||
const store = mockStore();
|
||||
|
||||
axiosMock.onGet(`${configuration.LMS_BASE_URL}/api/enrollment/v1/course/${courseId}`)
|
||||
axiosMock.onGet(trackUrl)
|
||||
.replyOnce(500, JSON.stringify({}));
|
||||
|
||||
return store.dispatch(fetchTracks(courseId)).then(() => {
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
import { sortGrades } from './grades';
|
||||
const formatDateForDisplay = (inputDate) => {
|
||||
const options = {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
};
|
||||
const timeOptions = {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'UTC',
|
||||
timeZoneName: 'short',
|
||||
};
|
||||
return `${inputDate.toLocaleDateString('en-US', options)} at ${inputDate.toLocaleTimeString('en-US', timeOptions)}`;
|
||||
};
|
||||
|
||||
const sortAlphaAsc = (gradeRowA, gradeRowB) => {
|
||||
const a = gradeRowA.username.toUpperCase();
|
||||
@@ -12,114 +26,4 @@ const sortAlphaAsc = (gradeRowA, gradeRowB) => {
|
||||
return 0;
|
||||
};
|
||||
|
||||
const sortAlphaDesc = (gradeRowA, gradeRowB) => {
|
||||
const a = gradeRowA.username.toUpperCase();
|
||||
const b = gradeRowB.username.toUpperCase();
|
||||
if (a < b) {
|
||||
return 1;
|
||||
}
|
||||
if (a > b) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const sortNumerically = (colKey, direction) => {
|
||||
function getPercents(gradeRowA, gradeRowB) {
|
||||
if (colKey !== 'total') {
|
||||
return {
|
||||
a: gradeRowA.section_breakdown.find(x => x.label === colKey).percent,
|
||||
b: gradeRowB.section_breakdown.find(x => x.label === colKey).percent,
|
||||
};
|
||||
}
|
||||
return {
|
||||
a: gradeRowA.percent,
|
||||
b: gradeRowB.percent,
|
||||
};
|
||||
}
|
||||
|
||||
function sortNumAsc(gradeRowA, gradeRowB) {
|
||||
const { a, b } = getPercents(gradeRowA, gradeRowB);
|
||||
return a - b;
|
||||
}
|
||||
|
||||
function sortNumDesc(gradeRowA, gradeRowB) {
|
||||
const { a, b } = getPercents(gradeRowA, gradeRowB);
|
||||
return b - a;
|
||||
}
|
||||
|
||||
return direction === 'desc' ? sortNumDesc : sortNumAsc;
|
||||
};
|
||||
|
||||
function gradeSortMap(columnName, direction) {
|
||||
if (columnName === 'username' && direction === 'desc') {
|
||||
return sortAlphaDesc;
|
||||
} else if (columnName === 'username') {
|
||||
return sortAlphaAsc;
|
||||
}
|
||||
return sortNumerically(columnName, direction);
|
||||
}
|
||||
|
||||
const headingMapper = (filterKey) => {
|
||||
function all(dispatch, entry) {
|
||||
if (entry) {
|
||||
const results = [{
|
||||
label: 'Username',
|
||||
key: 'username',
|
||||
columnSortable: true,
|
||||
onSort: (direction) => { dispatch(sortGrades('username', direction)); },
|
||||
}];
|
||||
|
||||
const assignmentHeadings = entry.section_breakdown
|
||||
.filter(section => section.is_graded && section.label)
|
||||
.map(s => ({
|
||||
label: s.label,
|
||||
key: s.label,
|
||||
columnSortable: true,
|
||||
onSort: direction => dispatch(sortGrades(s.label, direction)),
|
||||
}));
|
||||
|
||||
const totals = [{
|
||||
label: 'Total',
|
||||
key: 'total',
|
||||
columnSortable: true,
|
||||
onSort: direction => dispatch(sortGrades('total', direction)),
|
||||
}];
|
||||
|
||||
return results.concat(assignmentHeadings).concat(totals);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function some(dispatch, entry) {
|
||||
const results = [{
|
||||
label: 'Username',
|
||||
key: 'username',
|
||||
columnSortable: true,
|
||||
onSort: (direction) => { dispatch(sortGrades('username', direction)); },
|
||||
}];
|
||||
|
||||
const assignmentHeadings = entry.section_breakdown
|
||||
.filter(section => section.is_graded && section.label && section.category === filterKey)
|
||||
.map(s => ({
|
||||
label: s.label,
|
||||
key: s.label,
|
||||
columnSortable: false,
|
||||
onSort: (direction) => { this.sortNumerically(s.label, direction); },
|
||||
}));
|
||||
|
||||
const totals = [{
|
||||
label: 'Total',
|
||||
key: 'total',
|
||||
columnSortable: true,
|
||||
onSort: direction => dispatch(sortGrades('total', direction)),
|
||||
}];
|
||||
|
||||
return results.concat(assignmentHeadings).concat(totals);
|
||||
}
|
||||
|
||||
return filterKey === 'All' ? all : some;
|
||||
};
|
||||
|
||||
export { headingMapper, gradeSortMap, sortAlphaAsc };
|
||||
|
||||
export { sortAlphaAsc, formatDateForDisplay };
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { getAuthenticatedAPIClient } from '@edx/frontend-auth';
|
||||
|
||||
import { configuration } from '../config';
|
||||
|
||||
const apiClient = getAuthenticatedAPIClient({
|
||||
appBaseUrl: configuration.BASE_URL,
|
||||
loginUrl: configuration.LOGIN_URL,
|
||||
logoutUrl: configuration.LOGOUT_URL,
|
||||
csrfTokenApiPath: process.env.CSRF_TOKEN_API_PATH,
|
||||
refreshAccessTokenEndpoint: configuration.REFRESH_ACCESS_TOKEN_ENDPOINT,
|
||||
accessTokenCookieName: configuration.ACCESS_TOKEN_COOKIE_NAME,
|
||||
csrfCookieName: configuration.CSRF_COOKIE_NAME,
|
||||
});
|
||||
|
||||
export default apiClient;
|
||||
@@ -9,4 +9,3 @@ export {
|
||||
ERROR_FETCHING_ASSIGNMENT_TYPES,
|
||||
GOT_ARE_GRADES_FROZEN,
|
||||
};
|
||||
|
||||
|
||||
3
src/data/constants/actionTypes/config.js
Normal file
3
src/data/constants/actionTypes/config.js
Normal file
@@ -0,0 +1,3 @@
|
||||
const GOT_BULK_MANAGEMENT_CONFIG = 'GOT_BULK_MANAGEMENT_CONFIG';
|
||||
|
||||
export default GOT_BULK_MANAGEMENT_CONFIG;
|
||||
10
src/data/constants/actionTypes/filters.js
Normal file
10
src/data/constants/actionTypes/filters.js
Normal file
@@ -0,0 +1,10 @@
|
||||
const INITIALIZE_FILTERS = 'INITIALIZE_FILTERS';
|
||||
const RESET_FILTERS = 'RESET_FILTERS';
|
||||
const UPDATE_ASSIGNMENT_FILTER = 'UPDATE_ASSIGNMENT_FILTER';
|
||||
const UPDATE_ASSIGNMENT_LIMITS = 'UPDATE_ASSIGNMENT_LIMITS';
|
||||
const UPDATE_COURSE_GRADE_LIMITS = 'UPDATE_COURSE_GRADE_LIMITS';
|
||||
const UPDATE_INCLUDE_COURSE_ROLE_MEMBERS = 'UPDATE_INCLUDE_COURSE_ROLE_MEMBERS';
|
||||
export {
|
||||
INITIALIZE_FILTERS, RESET_FILTERS, UPDATE_ASSIGNMENT_FILTER,
|
||||
UPDATE_ASSIGNMENT_LIMITS, UPDATE_COURSE_GRADE_LIMITS, UPDATE_INCLUDE_COURSE_ROLE_MEMBERS,
|
||||
};
|
||||
@@ -2,15 +2,32 @@ const STARTED_FETCHING_GRADES = 'STARTED_FETCHING_GRADES';
|
||||
const FINISHED_FETCHING_GRADES = 'FINISHED_FETCHING_GRADES';
|
||||
const ERROR_FETCHING_GRADES = 'ERROR_FETCHING_GRADES';
|
||||
const GOT_GRADES = 'GOT_GRADES';
|
||||
const DONE_VIEWING_ASSIGNMENT = 'DONE_VIEWING_ASSIGNMENT';
|
||||
const GOT_GRADE_OVERRIDE_HISTORY = 'GOT_GRADE_OVERRIDE_HISTORY';
|
||||
const ERROR_FETCHING_GRADE_OVERRIDE_HISTORY = 'ERROR_FETCHING_GRADE_OVERRIDE_HISTORY';
|
||||
|
||||
const FILTER_SELECTED = 'FILTER_SELECTED';
|
||||
const GRADE_OVERRIDE = 'GRADE_OVERRIDE';
|
||||
const REPORT_DOWNLOADED = 'REPORT_DOWNLOADED';
|
||||
const UPLOAD_OVERRIDE = 'UPLOAD_OVERRIDE';
|
||||
const UPLOAD_OVERRIDE_ERROR = 'UPLOAD_OVERRIDE_ERROR';
|
||||
|
||||
const GRADE_UPDATE_REQUEST = 'GRADE_UPDATE_REQUEST';
|
||||
const GRADE_UPDATE_SUCCESS = 'GRADE_UPDATE_SUCCESS';
|
||||
const GRADE_UPDATE_FAILURE = 'GRADE_UPDATE_FAILURE';
|
||||
|
||||
const TOGGLE_GRADE_FORMAT = 'TOGGLE_GRADE_FORMAT';
|
||||
const SORT_GRADES = 'SORT_GRADES';
|
||||
const FILTER_COLUMNS = 'FILTER_COLUMNS';
|
||||
const UPDATE_BANNER = 'UPDATE_BANNER';
|
||||
const FILTER_BY_ASSIGNMENT_TYPE = 'FILTER_BY_ASSIGNMENT_TYPE';
|
||||
const CLOSE_BANNER = 'CLOSE_BANNER';
|
||||
const OPEN_BANNER = 'OPEN_BANNER';
|
||||
|
||||
const START_UPLOAD = 'START_UPLOAD';
|
||||
const UPLOAD_COMPLETE = 'UPLOAD_COMPLETE';
|
||||
const UPLOAD_ERR = 'UPLOAD_ERR';
|
||||
const GOT_BULK_HISTORY = 'GOT_BULK_HISTORY';
|
||||
const BULK_HISTORY_ERR = 'BULK_HISTORY_ERR';
|
||||
const BULK_GRADE_REPORT_DOWNLOADED = 'BULK_GRADE_REPORT_DOWNLOADED';
|
||||
const INTERVENTION_REPORT_DOWNLOADED = 'INTERVENTION_REPORT_DOWNLOADED';
|
||||
|
||||
export {
|
||||
STARTED_FETCHING_GRADES,
|
||||
@@ -21,7 +38,22 @@ export {
|
||||
GRADE_UPDATE_SUCCESS,
|
||||
GRADE_UPDATE_FAILURE,
|
||||
TOGGLE_GRADE_FORMAT,
|
||||
SORT_GRADES,
|
||||
FILTER_COLUMNS,
|
||||
UPDATE_BANNER,
|
||||
FILTER_BY_ASSIGNMENT_TYPE,
|
||||
OPEN_BANNER,
|
||||
CLOSE_BANNER,
|
||||
START_UPLOAD,
|
||||
UPLOAD_COMPLETE,
|
||||
UPLOAD_ERR,
|
||||
GOT_BULK_HISTORY,
|
||||
BULK_HISTORY_ERR,
|
||||
DONE_VIEWING_ASSIGNMENT,
|
||||
GOT_GRADE_OVERRIDE_HISTORY,
|
||||
ERROR_FETCHING_GRADE_OVERRIDE_HISTORY,
|
||||
FILTER_SELECTED,
|
||||
GRADE_OVERRIDE,
|
||||
REPORT_DOWNLOADED,
|
||||
UPLOAD_OVERRIDE,
|
||||
UPLOAD_OVERRIDE_ERROR,
|
||||
BULK_GRADE_REPORT_DOWNLOADED,
|
||||
INTERVENTION_REPORT_DOWNLOADED,
|
||||
};
|
||||
|
||||
7
src/data/constants/actionTypes/roles.js
Normal file
7
src/data/constants/actionTypes/roles.js
Normal file
@@ -0,0 +1,7 @@
|
||||
const GOT_ROLES = 'GOT_ROLES';
|
||||
const ERROR_FETCHING_ROLES = 'ERROR_FETCHING_ROLES';
|
||||
|
||||
export {
|
||||
GOT_ROLES,
|
||||
ERROR_FETCHING_ROLES,
|
||||
};
|
||||
3
src/data/constants/errors.js
Normal file
3
src/data/constants/errors.js
Normal file
@@ -0,0 +1,3 @@
|
||||
const GRADE_OVERRIDE_HISTORY_ERROR_DEFAULT_MSG = 'Error retrieving grade override history.';
|
||||
|
||||
export default GRADE_OVERRIDE_HISTORY_ERROR_DEFAULT_MSG;
|
||||
13
src/data/constants/filters.js
Normal file
13
src/data/constants/filters.js
Normal file
@@ -0,0 +1,13 @@
|
||||
const initialFilters = {
|
||||
assignment: '',
|
||||
assignmentType: '',
|
||||
track: '',
|
||||
cohort: '',
|
||||
assignmentGradeMin: '0',
|
||||
assignmentGradeMax: '100',
|
||||
courseGradeMin: '0',
|
||||
courseGradeMax: '100',
|
||||
includeCourseRoleMembers: false,
|
||||
};
|
||||
|
||||
export default initialFilters;
|
||||
5
src/data/constants/grades.js
Normal file
5
src/data/constants/grades.js
Normal file
@@ -0,0 +1,5 @@
|
||||
const EMAIL_HEADING = 'Email';
|
||||
const TOTAL_COURSE_GRADE_HEADING = 'Total Grade (%)';
|
||||
const USERNAME_HEADING = 'Username';
|
||||
|
||||
export { EMAIL_HEADING, TOTAL_COURSE_GRADE_HEADING, USERNAME_HEADING };
|
||||
@@ -11,7 +11,6 @@ const initialState = {
|
||||
errorFetching: false,
|
||||
};
|
||||
|
||||
|
||||
const assignmentTypes = (state = initialState, action) => {
|
||||
switch (action.type) {
|
||||
case GOT_ASSIGNMENT_TYPES:
|
||||
@@ -45,4 +44,3 @@ const assignmentTypes = (state = initialState, action) => {
|
||||
};
|
||||
|
||||
export default assignmentTypes;
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user