Compare commits
103 Commits
kdmccormic
...
1.4.43
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b8f67e8d2 | ||
|
|
c6e33307ba | ||
|
|
a4df8f7238 | ||
|
|
15b76edb5d | ||
|
|
1ab2fee004 | ||
|
|
9c9ba45fec | ||
|
|
85d566d257 | ||
|
|
5688adcd57 | ||
|
|
b4f4a27f73 | ||
|
|
868048381b | ||
|
|
02c154ef50 | ||
|
|
7acefe0468 | ||
|
|
a836cc1b5b | ||
|
|
6a3db4a11b | ||
|
|
d727420c37 | ||
|
|
2029a7cef3 | ||
|
|
8462249d55 | ||
|
|
40059ec41e | ||
|
|
2ee522352e | ||
|
|
189152f51b | ||
|
|
3bc2511cc1 | ||
|
|
f60e3c1188 | ||
|
|
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 |
17
.babelrc
17
.babelrc
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"presets": [
|
|
||||||
[
|
|
||||||
"env",
|
|
||||||
{
|
|
||||||
"targets": {
|
|
||||||
"browsers": ["last 2 versions", "ie 11"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"babel-preset-react"
|
|
||||||
],
|
|
||||||
"plugins": [
|
|
||||||
"transform-object-rest-spread",
|
|
||||||
"transform-class-properties"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
33
.env
Normal file
33
.env
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
NODE_ENV='production'
|
||||||
|
NODE_PATH=./src
|
||||||
|
BASE_URL=''
|
||||||
|
LMS_BASE_URL=''
|
||||||
|
LOGIN_URL=''
|
||||||
|
LOGOUT_URL=''
|
||||||
|
CSRF_TOKEN_API_PATH=''
|
||||||
|
REFRESH_ACCESS_TOKEN_ENDPOINT=''
|
||||||
|
DATA_API_BASE_URL=''
|
||||||
|
SEGMENT_KEY=''
|
||||||
|
FEATURE_FLAGS={}
|
||||||
|
ACCESS_TOKEN_COOKIE_NAME=''
|
||||||
|
NEW_RELIC_APP_ID=''
|
||||||
|
NEW_RELIC_LICENSE_KEY=''
|
||||||
|
SITE_NAME=''
|
||||||
|
MARKETING_SITE_BASE_URL=''
|
||||||
|
SUPPORT_URL=''
|
||||||
|
CONTACT_URL=''
|
||||||
|
OPEN_SOURCE_URL=''
|
||||||
|
TERMS_OF_SERVICE_URL=''
|
||||||
|
PRIVACY_POLICY_URL=''
|
||||||
|
FACEBOOK_URL=''
|
||||||
|
TWITTER_URL=''
|
||||||
|
YOU_TUBE_URL=''
|
||||||
|
LINKED_IN_URL=''
|
||||||
|
REDDIT_URL=''
|
||||||
|
APPLE_APP_STORE_URL=''
|
||||||
|
GOOGLE_PLAY_URL=''
|
||||||
|
ENTERPRISE_MARKETING_URL=''
|
||||||
|
ENTERPRISE_MARKETING_UTM_SOURCE=''
|
||||||
|
ENTERPRISE_MARKETING_UTM_CAMPAIGN=''
|
||||||
|
ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM=''
|
||||||
|
BULK_MANAGEMENT_SPECIAL_ACCESS_COURSE_IDS=''
|
||||||
39
.env.development
Normal file
39
.env.development
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
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=''
|
||||||
|
FEATURE_FLAGS={}
|
||||||
|
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=''
|
||||||
27
.eslintrc
27
.eslintrc
@@ -1,27 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "eslint-config-edx",
|
|
||||||
"parser": "babel-eslint",
|
|
||||||
"rules": {
|
|
||||||
"import/no-extraneous-dependencies": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
"devDependencies": [
|
|
||||||
"config/*.js",
|
|
||||||
"**/*.test.jsx",
|
|
||||||
"**/*.test.js"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
// https://github.com/evcohen/eslint-plugin-jsx-a11y/issues/340#issuecomment-338424908
|
|
||||||
"jsx-a11y/anchor-is-valid": [ "error", {
|
|
||||||
"components": [ "Link" ],
|
|
||||||
"specialLink": [ "to" ]
|
|
||||||
}],
|
|
||||||
// https://github.com/yannickcr/eslint-plugin-react/issues/1754#issuecomment-378838053
|
|
||||||
// tl;dr: this rule is no longer going to cause any user-facing visual weirdness, its original motivation
|
|
||||||
"react/no-did-mount-set-state": "off"
|
|
||||||
},
|
|
||||||
"env": {
|
|
||||||
"jest": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
21
.eslintrc.js
Normal file
21
.eslintrc.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
const { createConfig } = require('@edx/frontend-build');
|
||||||
|
|
||||||
|
const config = createConfig('eslint', {
|
||||||
|
rules: {
|
||||||
|
'import/no-named-as-default': 'off',
|
||||||
|
'import/no-named-as-default-member': 'off',
|
||||||
|
'import/no-self-import': 'off',
|
||||||
|
'spaced-comment': ['error', 'always', { 'block': { 'exceptions': ['*'] } }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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
|
||||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -1,6 +1,5 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.eslintcache
|
.eslintcache
|
||||||
.idea
|
|
||||||
node_modules
|
node_modules
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
coverage
|
coverage
|
||||||
@@ -12,3 +11,13 @@ dist/
|
|||||||
|
|
||||||
### Emacs ###
|
### Emacs ###
|
||||||
*~
|
*~
|
||||||
|
*.swo
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
### Development environments ###
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
|
||||||
|
### transifex ###
|
||||||
|
src/i18n/transifex_input.json
|
||||||
|
temp
|
||||||
|
|||||||
8
.tx/config
Normal file
8
.tx/config
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
[main]
|
||||||
|
host = https://www.transifex.com
|
||||||
|
|
||||||
|
[edx-platform.frontend-app-gradebook]
|
||||||
|
file_filter = src/i18n/messages/<lang>.json
|
||||||
|
source_file = src/i18n/transifex_input.json
|
||||||
|
source_lang = en
|
||||||
|
type = KEYVALUEJSON
|
||||||
64
Makefile
64
Makefile
@@ -2,8 +2,64 @@ npm-install-%: ## install specified % npm package
|
|||||||
npm install $* --save-dev
|
npm install $* --save-dev
|
||||||
git add package.json
|
git add package.json
|
||||||
|
|
||||||
validate-no-uncommitted-package-lock-changes:
|
transifex_resource = frontend-app-gradebook
|
||||||
git diff --exit-code package-lock.json
|
transifex_langs = "ar,fr,es_419,zh_CN"
|
||||||
|
|
||||||
test:
|
transifex_utils = ./node_modules/.bin/transifex-utils.js
|
||||||
npm run test
|
i18n = ./src/i18n
|
||||||
|
transifex_input = $(i18n)/transifex_input.json
|
||||||
|
tx_url1 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/translation/en/strings/
|
||||||
|
tx_url2 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/source/
|
||||||
|
|
||||||
|
# This directory must match .babelrc .
|
||||||
|
transifex_temp = ./temp/babel-plugin-react-intl
|
||||||
|
|
||||||
|
NPM_TESTS=build i18n_extract lint test is-es5
|
||||||
|
|
||||||
|
.PHONY: test
|
||||||
|
test: $(addprefix test.npm.,$(NPM_TESTS)) ## validate ci suite
|
||||||
|
|
||||||
|
.PHONY: test.npm.*
|
||||||
|
test.npm.%: validate-no-uncommitted-package-lock-changes
|
||||||
|
test -d node_modules || $(MAKE) requirements
|
||||||
|
npm run $(*)
|
||||||
|
|
||||||
|
.PHONY: requirements
|
||||||
|
requirements: ## install ci requirements
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
i18n.extract:
|
||||||
|
# Pulling display strings from .jsx files into .json files...
|
||||||
|
rm -rf $(transifex_temp)
|
||||||
|
npm run-script i18n_extract
|
||||||
|
|
||||||
|
i18n.concat:
|
||||||
|
# Gathering JSON messages into one file...
|
||||||
|
$(transifex_utils) $(transifex_temp) $(transifex_input)
|
||||||
|
|
||||||
|
extract_translations: | requirements i18n.extract i18n.concat
|
||||||
|
|
||||||
|
# Despite the name, we actually need this target to detect changes in the incoming translated message files as well.
|
||||||
|
detect_changed_source_translations:
|
||||||
|
# Checking for changed translations...
|
||||||
|
git diff --exit-code $(i18n)
|
||||||
|
|
||||||
|
# Pushes translations to Transifex. You must run make extract_translations first.
|
||||||
|
push_translations:
|
||||||
|
# Pushing strings to Transifex...
|
||||||
|
tx push -s
|
||||||
|
# Fetching hashes from Transifex...
|
||||||
|
./node_modules/reactifex/bash_scripts/get_hashed_strings.sh $(tx_url1)
|
||||||
|
# Writing out comments to file...
|
||||||
|
$(transifex_utils) $(transifex_temp) --comments
|
||||||
|
# Pushing comments to Transifex...
|
||||||
|
./node_modules/reactifex/bash_scripts/put_comments.sh $(tx_url2)
|
||||||
|
|
||||||
|
# Pulls translations from Transifex.
|
||||||
|
pull_translations:
|
||||||
|
tx pull -f --mode reviewed --language=$(transifex_langs)
|
||||||
|
|
||||||
|
# This target is used by Travis.
|
||||||
|
validate-no-uncommitted-package-lock-changes:
|
||||||
|
# Checking for package-lock.json changes...
|
||||||
|
git diff --exit-code package-lock.json
|
||||||
|
|||||||
69
README.md
69
README.md
@@ -1,18 +1,60 @@
|
|||||||
[](https://travis-ci.org/edx/frontend-app-gradebook) [](https://coveralls.io/github/edx/frontend-app-gradebook)
|
[](https://travis-ci.com/edx/frontend-app-gradebook) [](https://coveralls.io/github/edx/frontend-app-gradebook)
|
||||||
[](@edx/frontend-app-gradebook)
|
[](@edx/frontend-app-gradebook)
|
||||||
[](@edx/frontend-app-gradebook)
|
[](@edx/frontend-app-gradebook)
|
||||||
[](@edx/frontend-app-gradebook)
|
[](@edx/frontend-app-gradebook)
|
||||||
[](https://github.com/semantic-release/semantic-release)
|
[](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:
|
To install gradebook into your project:
|
||||||
```
|
```
|
||||||
@@ -32,7 +74,7 @@ Note that starting the container executes the `npm run start` script which will
|
|||||||
## Configuring for local use in edx-platform
|
## Configuring for local use in edx-platform
|
||||||
|
|
||||||
Assuming you've got the UI running at `http://localhost:1994`, you can configure the LMS in edx-platform
|
Assuming you've got the UI running at `http://localhost:1994`, you can configure the LMS in edx-platform
|
||||||
to point to your local gradebook from the instructor dashboard by putting this settings in `lms/env/private.py`:
|
to point to your local gradebook from the instructor dashboard by putting this setting in `lms/env/private.py`:
|
||||||
```
|
```
|
||||||
WRITABLE_GRADEBOOK_URL = 'http://localhost:1994'
|
WRITABLE_GRADEBOOK_URL = 'http://localhost:1994'
|
||||||
```
|
```
|
||||||
@@ -44,10 +86,15 @@ check the ``enabled`` and ``enabled for all courses`` boxes.
|
|||||||
|
|
||||||
2. Waffle > Switches. Add the ``grades.assume_zero_grade_if_absent`` switch and make it active.
|
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
|
3. Waffle_utils > Waffle flag course overrides. Activate waffle flags for courses where you want to enable Gradebook functionality:
|
||||||
in which you'd like to enable the gradebook. Add a course override flag using a course id and the flag name
|
- Enable Gradebook by adding the ``grades.writable_gradebook`` add checking the ``enabled`` box.
|
||||||
``grades.writable_gradebook``. Make sure to check the ``enabled`` box. Alternatively, you could add this as a
|
- Enable Bulk Grade Management by adding the ``grades.bulk_management`` flag and checking the ``enabled`` box.
|
||||||
regular waffle flag to enable the gradebook for all courses.
|
|
||||||
|
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
|
## Running tests
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.0 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 KiB |
3
babel.config.js
Normal file
3
babel.config.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
const { createConfig } = require('@edx/frontend-build');
|
||||||
|
|
||||||
|
module.exports = createConfig('babel');
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
// This is the common Webpack config. The dev and prod Webpack configs both
|
|
||||||
// inherit config defined here.
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
entry: {
|
|
||||||
segment: path.resolve(__dirname, '../src/segment.js'),
|
|
||||||
app: path.resolve(__dirname, '../src/index.jsx'),
|
|
||||||
},
|
|
||||||
output: {
|
|
||||||
path: path.resolve(__dirname, '../dist'),
|
|
||||||
},
|
|
||||||
resolve: {
|
|
||||||
extensions: ['.js', '.jsx'],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
// This is the dev Webpack config. All settings here should prefer a fast build
|
|
||||||
// time at the expense of creating larger, unoptimized bundles.
|
|
||||||
const Merge = require('webpack-merge');
|
|
||||||
const path = require('path');
|
|
||||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
|
||||||
const webpack = require('webpack');
|
|
||||||
|
|
||||||
const commonConfig = require('./webpack.common.config.js');
|
|
||||||
|
|
||||||
module.exports = Merge.smart(commonConfig, {
|
|
||||||
mode: 'development',
|
|
||||||
entry: [
|
|
||||||
// enable react's custom hot dev client so we get errors reported in the browser
|
|
||||||
require.resolve('react-dev-utils/webpackHotDevClient'),
|
|
||||||
path.resolve(__dirname, '../src/segment.js'),
|
|
||||||
path.resolve(__dirname, '../src/index.jsx'),
|
|
||||||
],
|
|
||||||
module: {
|
|
||||||
// Specify file-by-file rules to Webpack. Some file-types need a particular kind of loader.
|
|
||||||
rules: [
|
|
||||||
// The babel-loader transforms newer ES2015+ syntax to older ES5 for older browsers.
|
|
||||||
// Babel is configured with the .babelrc file at the root of the project.
|
|
||||||
{
|
|
||||||
test: /\.(js|jsx)$/,
|
|
||||||
include: [
|
|
||||||
path.resolve(__dirname, '../src'),
|
|
||||||
],
|
|
||||||
loader: 'babel-loader',
|
|
||||||
options: {
|
|
||||||
// Caches result of loader to the filesystem. Future builds will attempt to read from the
|
|
||||||
// cache to avoid needing to run the expensive recompilation process on each run.
|
|
||||||
cacheDirectory: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// We are not extracting CSS from the javascript bundles in development because extracting
|
|
||||||
// prevents hot-reloading from working, it increases build time, and we don't care about
|
|
||||||
// flash-of-unstyled-content issues in development.
|
|
||||||
{
|
|
||||||
test: /(.scss|.css)$/,
|
|
||||||
use: [
|
|
||||||
'style-loader', // creates style nodes from JS strings
|
|
||||||
{
|
|
||||||
loader: 'css-loader', // translates CSS into CommonJS
|
|
||||||
options: {
|
|
||||||
sourceMap: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
loader: 'sass-loader', // compiles Sass to CSS
|
|
||||||
options: {
|
|
||||||
sourceMap: true,
|
|
||||||
includePaths: [
|
|
||||||
path.join(__dirname, '../node_modules'),
|
|
||||||
path.join(__dirname, '../src'),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
// Webpack, by default, uses the url-loader for images and fonts that are required/included by
|
|
||||||
// files it processes, which just base64 encodes them and inlines them in the javascript
|
|
||||||
// bundles. This makes the javascript bundles ginormous and defeats caching so we will use the
|
|
||||||
// file-loader instead to copy the files directly to the output directory.
|
|
||||||
{
|
|
||||||
test: /\.(woff2?|ttf|svg|eot)(\?v=\d+\.\d+\.\d+)?$/,
|
|
||||||
loader: 'file-loader',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
test: /\.(jpe?g|png|gif|ico)(\?v=\d+\.\d+\.\d+)?$/,
|
|
||||||
use: [
|
|
||||||
'file-loader',
|
|
||||||
{
|
|
||||||
loader: 'image-webpack-loader',
|
|
||||||
options: {
|
|
||||||
optimizationlevel: 7,
|
|
||||||
mozjpeg: {
|
|
||||||
progressive: true,
|
|
||||||
},
|
|
||||||
gifsicle: {
|
|
||||||
interlaced: false,
|
|
||||||
},
|
|
||||||
pngquant: {
|
|
||||||
quality: '65-90',
|
|
||||||
speed: 4,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
// Specify additional processing or side-effects done on the Webpack output bundles as a whole.
|
|
||||||
plugins: [
|
|
||||||
// Generates an HTML file in the output directory.
|
|
||||||
new HtmlWebpackPlugin({
|
|
||||||
inject: true, // Appends script tags linking to the webpack bundles at the end of the body
|
|
||||||
template: path.resolve(__dirname, '../public/index.html'),
|
|
||||||
}),
|
|
||||||
new webpack.EnvironmentPlugin({
|
|
||||||
NODE_ENV: 'development',
|
|
||||||
BASE_URL: 'localhost:19000/gradebook',
|
|
||||||
LMS_BASE_URL: 'http://localhost:18000',
|
|
||||||
LOGIN_URL: 'http://localhost:18000/login',
|
|
||||||
LOGOUT_URL: 'http://localhost:18000/logout',
|
|
||||||
CSRF_TOKEN_API_PATH: '/csrf/api/v1/token',
|
|
||||||
REFRESH_ACCESS_TOKEN_ENDPOINT: 'http://localhost:18000/login_refresh',
|
|
||||||
DATA_API_BASE_URL: 'http://localhost:8000',
|
|
||||||
// LMS_CLIENT_ID should match the lms DOT client application id your LMS container
|
|
||||||
LMS_CLIENT_ID: 'login-service-client-id',
|
|
||||||
SEGMENT_KEY: null,
|
|
||||||
FEATURE_FLAGS: {},
|
|
||||||
ACCESS_TOKEN_COOKIE_NAME: 'edx-jwt-cookie-header-payload',
|
|
||||||
CSRF_COOKIE_NAME: 'csrftoken',
|
|
||||||
SITE_NAME: 'edX',
|
|
||||||
MARKETING_SITE_BASE_URL: 'http://localhost:18000',
|
|
||||||
SUPPORT_URL: 'http://localhost:18000/support',
|
|
||||||
CONTACT_URL: 'http://localhost:18000/contact',
|
|
||||||
OPEN_SOURCE_URL: 'http://localhost:18000/openedx',
|
|
||||||
TERMS_OF_SERVICE_URL: 'http://localhost:18000/terms-of-service',
|
|
||||||
PRIVACY_POLICY_URL: 'http://localhost:18000/privacy-policy',
|
|
||||||
FACEBOOK_URL: 'https://www.facebook.com',
|
|
||||||
TWITTER_URL: 'https://twitter.com',
|
|
||||||
YOU_TUBE_URL: 'https://www.youtube.com',
|
|
||||||
LINKED_IN_URL: 'https://www.linkedin.com',
|
|
||||||
REDDIT_URL: 'https://www.reddit.com',
|
|
||||||
APPLE_APP_STORE_URL: 'https://www.apple.com/ios/app-store/',
|
|
||||||
GOOGLE_PLAY_URL: 'https://play.google.com/store',
|
|
||||||
ENTERPRISE_MARKETING_URL: 'http://example.com',
|
|
||||||
ENTERPRISE_MARKETING_UTM_SOURCE: 'example.com',
|
|
||||||
ENTERPRISE_MARKETING_UTM_CAMPAIGN: 'example.com Referral',
|
|
||||||
ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM: 'Footer',
|
|
||||||
}),
|
|
||||||
// when the --hot option is not passed in as part of the command
|
|
||||||
// the HotModuleReplacementPlugin has to be specified in the Webpack configuration
|
|
||||||
// https://webpack.js.org/configuration/dev-server/#devserver-hot
|
|
||||||
new webpack.HotModuleReplacementPlugin(),
|
|
||||||
],
|
|
||||||
// This configures webpack-dev-server which serves bundles from memory and provides live
|
|
||||||
// reloading.
|
|
||||||
devServer: {
|
|
||||||
host: '0.0.0.0',
|
|
||||||
port: 1994,
|
|
||||||
historyApiFallback: true,
|
|
||||||
hot: true,
|
|
||||||
inline: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
// This is the prod Webpack config. All settings here should prefer smaller,
|
|
||||||
// optimized bundles at the expense of a longer build time.
|
|
||||||
const Merge = require('webpack-merge');
|
|
||||||
const commonConfig = require('./webpack.common.config.js');
|
|
||||||
const path = require('path');
|
|
||||||
const webpack = require('webpack');
|
|
||||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
|
||||||
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
|
||||||
|
|
||||||
module.exports = Merge.smart(commonConfig, {
|
|
||||||
mode: 'production',
|
|
||||||
devtool: 'source-map',
|
|
||||||
output: {
|
|
||||||
filename: '[name].[chunkhash].js',
|
|
||||||
path: path.resolve(__dirname, '../dist'),
|
|
||||||
},
|
|
||||||
module: {
|
|
||||||
// Specify file-by-file rules to Webpack. Some file-types need a particular kind of loader.
|
|
||||||
rules: [
|
|
||||||
// The babel-loader transforms newer ES2015+ syntax to older ES5 for older browsers.
|
|
||||||
// Babel is configured with the .babelrc file at the root of the project.
|
|
||||||
{
|
|
||||||
test: /\.(js|jsx)$/,
|
|
||||||
include: [
|
|
||||||
path.resolve(__dirname, '../src'),
|
|
||||||
],
|
|
||||||
loader: 'babel-loader',
|
|
||||||
},
|
|
||||||
// Webpack, by default, includes all CSS in the javascript bundles. Unfortunately, that means:
|
|
||||||
// a) The CSS won't be cached by browsers separately (a javascript change will force CSS
|
|
||||||
// re-download). b) Since CSS is applied asyncronously, it causes an ugly
|
|
||||||
// flash-of-unstyled-content.
|
|
||||||
//
|
|
||||||
// To avoid these problems, we extract the CSS from the bundles into separate CSS files that
|
|
||||||
// can be included as <link> tags in the HTML <head> manually.
|
|
||||||
//
|
|
||||||
// We will not do this in development because it prevents hot-reloading from working and it
|
|
||||||
// increases build time.
|
|
||||||
{
|
|
||||||
test: /(.scss|.css)$/,
|
|
||||||
use: [
|
|
||||||
MiniCssExtractPlugin.loader,
|
|
||||||
{
|
|
||||||
loader: 'css-loader', // translates CSS into CommonJS
|
|
||||||
options: {
|
|
||||||
sourceMap: true,
|
|
||||||
minimize: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'postcss-loader', // for autoprefixing, needs to be before the sass loader, not sure why
|
|
||||||
{
|
|
||||||
loader: 'sass-loader', // compiles Sass to CSS
|
|
||||||
options: {
|
|
||||||
sourceMap: true,
|
|
||||||
includePaths: [
|
|
||||||
path.join(__dirname, '../node_modules'),
|
|
||||||
path.join(__dirname, '../src'),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
// Webpack, by default, uses the url-loader for images and fonts that are required/included by
|
|
||||||
// files it processes, which just base64 encodes them and inlines them in the javascript
|
|
||||||
// bundles. This makes the javascript bundles ginormous and defeats caching so we will use the
|
|
||||||
// file-loader instead to copy the files directly to the output directory.
|
|
||||||
{
|
|
||||||
test: /\.(woff2?|ttf|svg|eot)(\?v=\d+\.\d+\.\d+)?$/,
|
|
||||||
loader: 'file-loader',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
test: /\.(jpe?g|png|gif|ico)(\?v=\d+\.\d+\.\d+)?$/,
|
|
||||||
use: [
|
|
||||||
'file-loader',
|
|
||||||
{
|
|
||||||
loader: 'image-webpack-loader',
|
|
||||||
options: {
|
|
||||||
optimizationlevel: 7,
|
|
||||||
mozjpeg: {
|
|
||||||
progressive: true,
|
|
||||||
},
|
|
||||||
gifsicle: {
|
|
||||||
interlaced: false,
|
|
||||||
},
|
|
||||||
pngquant: {
|
|
||||||
quality: '65-90',
|
|
||||||
speed: 4,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
// New in Webpack 4. Replaces CommonChunksPlugin. Extract common modules among all chunks to one
|
|
||||||
// common chunk and extract the Webpack runtime to a single runtime chunk.
|
|
||||||
optimization: {
|
|
||||||
runtimeChunk: 'single',
|
|
||||||
splitChunks: {
|
|
||||||
chunks: 'all',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// Specify additional processing or side-effects done on the Webpack output bundles as a whole.
|
|
||||||
plugins: [
|
|
||||||
// Writes the extracted CSS from each entry to a file in the output directory.
|
|
||||||
new MiniCssExtractPlugin({
|
|
||||||
filename: '[name].[chunkhash].css',
|
|
||||||
}),
|
|
||||||
// Generates an HTML file in the output directory.
|
|
||||||
new HtmlWebpackPlugin({
|
|
||||||
inject: true, // Appends script tags linking to the webpack bundles at the end of the body
|
|
||||||
template: path.resolve(__dirname, '../public/index.html'),
|
|
||||||
}),
|
|
||||||
new webpack.EnvironmentPlugin({
|
|
||||||
NODE_ENV: 'production',
|
|
||||||
BASE_URL: null,
|
|
||||||
LMS_BASE_URL: null,
|
|
||||||
LOGIN_URL: null,
|
|
||||||
LOGOUT_URL: null,
|
|
||||||
CSRF_TOKEN_API_PATH: null,
|
|
||||||
REFRESH_ACCESS_TOKEN_ENDPOINT: null,
|
|
||||||
DATA_API_BASE_URL: null,
|
|
||||||
SEGMENT_KEY: null,
|
|
||||||
FEATURE_FLAGS: {},
|
|
||||||
ACCESS_TOKEN_COOKIE_NAME: null,
|
|
||||||
CSRF_COOKIE_NAME: 'csrftoken',
|
|
||||||
NEW_RELIC_APP_ID: null,
|
|
||||||
NEW_RELIC_LICENSE_KEY: null,
|
|
||||||
SITE_NAME: null,
|
|
||||||
MARKETING_SITE_BASE_URL: null,
|
|
||||||
SUPPORT_URL: null,
|
|
||||||
CONTACT_URL: null,
|
|
||||||
OPEN_SOURCE_URL: null,
|
|
||||||
TERMS_OF_SERVICE_URL: null,
|
|
||||||
PRIVACY_POLICY_URL: null,
|
|
||||||
FACEBOOK_URL: null,
|
|
||||||
TWITTER_URL: null,
|
|
||||||
YOU_TUBE_URL: null,
|
|
||||||
LINKED_IN_URL: null,
|
|
||||||
REDDIT_URL: null,
|
|
||||||
APPLE_APP_STORE_URL: null,
|
|
||||||
GOOGLE_PLAY_URL: null,
|
|
||||||
ENTERPRISE_MARKETING_URL: null,
|
|
||||||
ENTERPRISE_MARKETING_UTM_SOURCE: null,
|
|
||||||
ENTERPRISE_MARKETING_UTM_CAMPAIGN: null,
|
|
||||||
ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM: null,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
BIN
documentation/screenshots/grade-editing.png
Normal file
BIN
documentation/screenshots/grade-editing.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 254 KiB |
BIN
documentation/screenshots/grade-filtering.png
Normal file
BIN
documentation/screenshots/grade-filtering.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 240 KiB |
BIN
documentation/screenshots/grade-listings.png
Normal file
BIN
documentation/screenshots/grade-listings.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 319 KiB |
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
|
||||||
15
jest.config.js
Normal file
15
jest.config.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
const { createConfig } = require('@edx/frontend-build');
|
||||||
|
|
||||||
|
module.exports = createConfig('jest', {
|
||||||
|
setupFilesAfterEnv: [
|
||||||
|
'<rootDir>/src/setupTest.js',
|
||||||
|
],
|
||||||
|
modulePaths: ['<rootDir>/src/'],
|
||||||
|
snapshotSerializers: [
|
||||||
|
'enzyme-to-json/serializer',
|
||||||
|
],
|
||||||
|
coveragePathIgnorePatterns: [
|
||||||
|
'src/segment.js',
|
||||||
|
'src/postcss.config.js',
|
||||||
|
],
|
||||||
|
});
|
||||||
10
openedx.yaml
10
openedx.yaml
@@ -1,9 +1,9 @@
|
|||||||
# This file describes this Open edX repo, as described in OEP-2:
|
# 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
|
# http://open-edx-proposals.readthedocs.io/en/latest/oeps/oep-0002.html#specification
|
||||||
|
|
||||||
nick: grbk
|
tags:
|
||||||
oeps: {}
|
- frontend-app
|
||||||
owner: schenedx
|
- masters
|
||||||
supporting_teams:
|
oeps:
|
||||||
- masters-devs
|
oep-2: true # Repository metadata
|
||||||
openedx-release: {ref: master}
|
openedx-release: {ref: master}
|
||||||
|
|||||||
35991
package-lock.json
generated
Executable file → Normal file
35991
package-lock.json
generated
Executable file → Normal file
File diff suppressed because it is too large
Load Diff
113
package.json
113
package.json
@@ -1,21 +1,22 @@
|
|||||||
{
|
{
|
||||||
"name": "@edx/frontend-app-gradebook",
|
"name": "@edx/frontend-app-gradebook",
|
||||||
"version": "0.1.0",
|
"version": "1.4.43",
|
||||||
"description": "edx editable gradebook-ui to manipulate grade overrides on subsections",
|
"description": "edx editable gradebook-ui to manipulate grade overrides on subsections",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://github.com/edx/frontend-app-gradebook.git"
|
"url": "git+https://github.com/edx/frontend-app-gradebook.git"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "NODE_ENV=production BABEL_ENV=production webpack --config=config/webpack.prod.config.js",
|
"build": "fedx-scripts webpack",
|
||||||
"dev-build": "NODE_ENV=development BABEL_ENV=development webpack --config=config/webpack.dev.config.js",
|
|
||||||
"coveralls": "cat ./coverage/lcov.info | coveralls",
|
"coveralls": "cat ./coverage/lcov.info | coveralls",
|
||||||
"is-es5": "es-check es5 ./dist/*.js",
|
"is-es5": "es-check es5 ./dist/*.js",
|
||||||
"lint": "eslint --ext .js --ext .jsx .",
|
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
|
||||||
"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",
|
"semantic-release": "semantic-release",
|
||||||
"start": "NODE_ENV=development BABEL_ENV=development node_modules/.bin/webpack-dev-server --config=config/webpack.dev.config.js --progress",
|
"start": "fedx-scripts webpack-dev-server --progress",
|
||||||
"test": "jest --coverage --passWithNoTests",
|
"test": "TZ=GMT fedx-scripts jest --coverage --passWithNoTests",
|
||||||
"watch-tests": "jest --watch",
|
"watch-tests": "jest --watch",
|
||||||
"travis-deploy-once": "travis-deploy-once"
|
"travis-deploy-once": "travis-deploy-once"
|
||||||
},
|
},
|
||||||
@@ -26,96 +27,58 @@
|
|||||||
"access": "public"
|
"access": "public"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@edx/edx-bootstrap": "^0.4.3",
|
"@edx/brand": "npm:@edx/brand-edx.org@^1.3.2",
|
||||||
"@edx/frontend-auth": "^4.0.0",
|
"@edx/frontend-component-footer": "10.1.1",
|
||||||
"@edx/frontend-component-footer": "^4.1.5",
|
"@edx/frontend-platform": "1.9.5",
|
||||||
"@edx/paragon": "^7.1.5",
|
"@edx/paragon": "14.16.4",
|
||||||
"@fortawesome/fontawesome-svg-core": "^1.2.25",
|
"@fortawesome/fontawesome-svg-core": "^1.2.25",
|
||||||
"@fortawesome/free-brands-svg-icons": "^5.11.2",
|
"@fortawesome/free-brands-svg-icons": "^5.11.2",
|
||||||
"@fortawesome/free-solid-svg-icons": "^5.11.2",
|
"@fortawesome/free-solid-svg-icons": "^5.11.2",
|
||||||
"@fortawesome/react-fontawesome": "^0.1.5",
|
"@fortawesome/react-fontawesome": "^0.1.5",
|
||||||
"@redux-beacon/segment": "^1.0.0",
|
"@redux-beacon/segment": "^1.0.0",
|
||||||
"babel-polyfill": "^6.26.0",
|
"@reduxjs/toolkit": "^1.5.1",
|
||||||
"classnames": "^2.2.6",
|
"classnames": "^2.2.6",
|
||||||
|
"core-js": "3.6.5",
|
||||||
"email-prop-type": "^1.1.7",
|
"email-prop-type": "^1.1.7",
|
||||||
"font-awesome": "^4.7.0",
|
"enzyme": "^3.10.0",
|
||||||
"history": "^4.10.1",
|
"enzyme-to-json": "^3.6.2",
|
||||||
"prop-types": "^15.7.2",
|
"font-awesome": "4.7.0",
|
||||||
"query-string": "^5.1.1",
|
"history": "4.10.1",
|
||||||
"react": "^16.10.1",
|
"node-sass": "^4.14.1",
|
||||||
"react-dom": "^16.10.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-intl": "^2.9.0",
|
||||||
"react-redux": "^5.1.1",
|
"react-redux": "^5.1.1",
|
||||||
"react-router": "^4.3.1",
|
"react-router": "5.2.0",
|
||||||
"react-router-dom": "^4.3.1",
|
"react-router-dom": "5.2.0",
|
||||||
"react-router-redux": "^5.0.0-alpha.9",
|
"react-router-redux": "^5.0.0-alpha.9",
|
||||||
"redux": "^3.7.2",
|
"redux": "4.0.5",
|
||||||
"redux-beacon": "^2.1.0",
|
"redux-beacon": "^2.1.0",
|
||||||
"redux-devtools-extension": "^2.13.8",
|
"redux-devtools-extension": "2.13.8",
|
||||||
"redux-logger": "^3.0.6",
|
"redux-logger": "3.0.6",
|
||||||
"redux-thunk": "^2.3.0",
|
"redux-thunk": "2.3.0",
|
||||||
|
"regenerator-runtime": "^0.13.7",
|
||||||
|
"util": "^0.12.3",
|
||||||
"whatwg-fetch": "^2.0.4"
|
"whatwg-fetch": "^2.0.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"autoprefixer": "^9.6.1",
|
"@edx/frontend-build": "5.5.2",
|
||||||
|
"axios": "0.21.1",
|
||||||
"axios-mock-adapter": "^1.17.0",
|
"axios-mock-adapter": "^1.17.0",
|
||||||
"babel-cli": "^6.26.0",
|
|
||||||
"babel-eslint": "^8.2.6",
|
|
||||||
"babel-jest": "^22.4.4",
|
|
||||||
"babel-loader": "^7.1.5",
|
|
||||||
"babel-plugin-transform-class-properties": "^6.24.1",
|
|
||||||
"babel-plugin-transform-object-rest-spread": "^6.26.0",
|
|
||||||
"babel-preset-env": "^1.7.0",
|
|
||||||
"babel-preset-react": "^6.24.1",
|
|
||||||
"codecov": "^3.6.1",
|
"codecov": "^3.6.1",
|
||||||
"css-loader": "^0.28.11",
|
|
||||||
"enzyme": "^3.10.0",
|
|
||||||
"enzyme-adapter-react-16": "^1.14.0",
|
"enzyme-adapter-react-16": "^1.14.0",
|
||||||
"es-check": "^2.3.0",
|
"es-check": "^2.3.0",
|
||||||
"eslint-config-edx": "^4.0.4",
|
|
||||||
"fetch-mock": "^6.5.2",
|
"fetch-mock": "^6.5.2",
|
||||||
"file-loader": "^1.1.9",
|
"husky": "2.7.0",
|
||||||
"html-webpack-harddisk-plugin": "^0.2.0",
|
|
||||||
"html-webpack-plugin": "^3.2.0",
|
|
||||||
"husky": "^0.14.3",
|
|
||||||
"identity-obj-proxy": "^3.0.0",
|
"identity-obj-proxy": "^3.0.0",
|
||||||
"image-webpack-loader": "^4.2.0",
|
"jest": "24.9.0",
|
||||||
"jest": "^22.4.4",
|
|
||||||
"mini-css-extract-plugin": "^0.4.0",
|
|
||||||
"node-sass": "^4.12.0",
|
|
||||||
"postcss-loader": "^3.0.0",
|
|
||||||
"react-dev-utils": "^5.0.3",
|
"react-dev-utils": "^5.0.3",
|
||||||
"react-test-renderer": "^16.10.1",
|
"react-test-renderer": "^16.10.1",
|
||||||
|
"reactifex": "1.1.1",
|
||||||
"redux-mock-store": "^1.5.3",
|
"redux-mock-store": "^1.5.3",
|
||||||
"sass-loader": "^6.0.6",
|
"semantic-release": "^17.2.3",
|
||||||
"semantic-release": "^15.13.24",
|
"travis-deploy-once": "^5.0.11"
|
||||||
"style-loader": "^0.20.3",
|
|
||||||
"travis-deploy-once": "^5.0.11",
|
|
||||||
"webpack": "^4.41.0",
|
|
||||||
"webpack-cli": "^3.3.9",
|
|
||||||
"webpack-dev-server": "^3.8.2",
|
|
||||||
"webpack-merge": "^4.2.2"
|
|
||||||
},
|
|
||||||
"jest": {
|
|
||||||
"setupFiles": [
|
|
||||||
"./src/setupTest.js"
|
|
||||||
],
|
|
||||||
"moduleNameMapper": {
|
|
||||||
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/__mocks__/fileMock.js",
|
|
||||||
"\\.(css|scss)$": "identity-obj-proxy"
|
|
||||||
},
|
|
||||||
"collectCoverageFrom": [
|
|
||||||
"src/**/*.{js,jsx}"
|
|
||||||
],
|
|
||||||
"coveragePathIgnorePatterns": [
|
|
||||||
"/node_modules/",
|
|
||||||
"src/setupTest.js",
|
|
||||||
"src/index.js",
|
|
||||||
"/tests/"
|
|
||||||
],
|
|
||||||
"transformIgnorePatterns": [
|
|
||||||
"/node_modules/(?!(@edx/paragon)/).*/"
|
|
||||||
],
|
|
||||||
"testURL": "http://localhost"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en-us">
|
<html lang="en-us">
|
||||||
<head>
|
<head>
|
||||||
<title>Gradebook | edX</title>
|
<title>Gradebook | <%= process.env.SITE_NAME %></title>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
36
src/App.jsx
Executable file
36
src/App.jsx
Executable file
@@ -0,0 +1,36 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
|
||||||
|
import { Provider } from 'react-redux';
|
||||||
|
import { IntlProvider } from 'react-intl';
|
||||||
|
|
||||||
|
import Footer from '@edx/frontend-component-footer';
|
||||||
|
|
||||||
|
import { routePath } from 'data/constants/app';
|
||||||
|
import store from 'data/store';
|
||||||
|
import GradebookPage from 'containers/GradebookPage';
|
||||||
|
import EdxHeader from 'components/EdxHeader';
|
||||||
|
import './App.scss';
|
||||||
|
|
||||||
|
const App = () => (
|
||||||
|
<IntlProvider locale="en">
|
||||||
|
<Provider store={store}>
|
||||||
|
<Router>
|
||||||
|
<div>
|
||||||
|
<EdxHeader />
|
||||||
|
<main>
|
||||||
|
<Switch>
|
||||||
|
<Route
|
||||||
|
exact
|
||||||
|
path={routePath}
|
||||||
|
component={GradebookPage}
|
||||||
|
/>
|
||||||
|
</Switch>
|
||||||
|
</main>
|
||||||
|
<Footer logo={process.env.LOGO_POWERED_BY_OPEN_EDX_URL_SVG} />
|
||||||
|
</div>
|
||||||
|
</Router>
|
||||||
|
</Provider>
|
||||||
|
</IntlProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default App;
|
||||||
15
src/App.scss
15
src/App.scss
@@ -1,13 +1,16 @@
|
|||||||
|
// frontend-app-*/src/index.scss
|
||||||
@import "~@edx/paragon/scss/edx/theme.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";
|
$fa-font-path: "~font-awesome/fonts";
|
||||||
@import "~font-awesome/scss/font-awesome";
|
@import "~font-awesome/scss/font-awesome";
|
||||||
|
|
||||||
$input-focus-box-shadow: $input-box-shadow; // hack to get upgrade to paragon 4.0.0 to work
|
$input-focus-box-shadow: $input-box-shadow; // hack to get upgrade to paragon 4.0.0 to work
|
||||||
|
|
||||||
@import "~@edx/frontend-component-footer/src/lib/scss/site-footer";
|
@import "~@edx/frontend-component-footer/dist/_footer";
|
||||||
|
|
||||||
@import "./components/Gradebook/gradebook";
|
|
||||||
@import "./components/Drawer/Drawer";
|
|
||||||
|
|
||||||
|
@import "./components/GradesTab/GradesTab";
|
||||||
|
@import "./components/WithSidebar/WithSidebar";
|
||||||
|
@import "./components/GradebookFilters/GradebookFilters";
|
||||||
|
|||||||
86
src/App.test.jsx
Normal file
86
src/App.test.jsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { shallow } from 'enzyme';
|
||||||
|
|
||||||
|
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
|
||||||
|
import { Provider } from 'react-redux';
|
||||||
|
import { IntlProvider } from 'react-intl';
|
||||||
|
|
||||||
|
import Footer from '@edx/frontend-component-footer';
|
||||||
|
|
||||||
|
import { routePath } from 'data/constants/app';
|
||||||
|
import store from 'data/store';
|
||||||
|
import GradebookPage from 'containers/GradebookPage';
|
||||||
|
import EdxHeader from 'components/EdxHeader';
|
||||||
|
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
jest.mock('react-router-dom', () => ({
|
||||||
|
BrowserRouter: () => 'BrowserRouter',
|
||||||
|
Route: () => 'Route',
|
||||||
|
Switch: () => 'Switch',
|
||||||
|
}));
|
||||||
|
jest.mock('react-redux', () => ({
|
||||||
|
Provider: () => 'Provider',
|
||||||
|
}));
|
||||||
|
jest.mock('react-intl', () => ({
|
||||||
|
IntlProvider: () => 'IntlProvider',
|
||||||
|
}));
|
||||||
|
jest.mock('data/constants/app', () => ({
|
||||||
|
routePath: '/:courseId',
|
||||||
|
}));
|
||||||
|
jest.mock('@edx/frontend-component-footer', () => 'Footer');
|
||||||
|
jest.mock('data/store', () => 'testStore');
|
||||||
|
jest.mock('containers/GradebookPage', () => 'GradebookPage');
|
||||||
|
jest.mock('components/EdxHeader', () => 'EdxHeader');
|
||||||
|
|
||||||
|
const logo = 'fakeLogo.png';
|
||||||
|
let el;
|
||||||
|
let router;
|
||||||
|
|
||||||
|
describe('App router component', () => {
|
||||||
|
test('snapshot', () => {
|
||||||
|
expect(shallow(<App />)).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
describe('component', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.LOGO_POWERED_BY_OPEN_EDX_URL_SVG = logo;
|
||||||
|
el = shallow(<App />);
|
||||||
|
router = el.childAt(0).childAt(0);
|
||||||
|
});
|
||||||
|
describe('IntlProvider', () => {
|
||||||
|
test('outer-wrapper component', () => {
|
||||||
|
expect(el.type()).toBe(IntlProvider);
|
||||||
|
});
|
||||||
|
test('"en" locale', () => {
|
||||||
|
expect(el.props().locale).toEqual('en');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('Provider, inside IntlProvider', () => {
|
||||||
|
test('first child, passed the redux store props', () => {
|
||||||
|
expect(el.childAt(0).type()).toBe(Provider);
|
||||||
|
expect(el.childAt(0).props().store).toEqual(store);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('Router', () => {
|
||||||
|
test('first child of Provider', () => {
|
||||||
|
expect(router.type()).toBe(Router);
|
||||||
|
});
|
||||||
|
test('EdxHeader is above/outside-of the routing', () => {
|
||||||
|
expect(router.childAt(0).childAt(0).type()).toBe(EdxHeader);
|
||||||
|
expect(router.childAt(0).childAt(1).type()).toBe('main');
|
||||||
|
});
|
||||||
|
test('Routing - GradebookPage is only route', () => {
|
||||||
|
expect(router.find('main')).toEqual(shallow(
|
||||||
|
<main>
|
||||||
|
<Switch>
|
||||||
|
<Route exact path={routePath} component={GradebookPage} />
|
||||||
|
</Switch>
|
||||||
|
</main>,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
test('Footer logo drawn from env variable', () => {
|
||||||
|
expect(router.find(Footer).props().logo).toEqual(logo);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
27
src/__snapshots__/App.test.jsx.snap
Normal file
27
src/__snapshots__/App.test.jsx.snap
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`App router component snapshot 1`] = `
|
||||||
|
<IntlProvider
|
||||||
|
locale="en"
|
||||||
|
>
|
||||||
|
<Provider
|
||||||
|
store="testStore"
|
||||||
|
>
|
||||||
|
<BrowserRouter>
|
||||||
|
<div>
|
||||||
|
<EdxHeader />
|
||||||
|
<main>
|
||||||
|
<Switch>
|
||||||
|
<Route
|
||||||
|
component="GradebookPage"
|
||||||
|
exact={true}
|
||||||
|
path="/:courseId"
|
||||||
|
/>
|
||||||
|
</Switch>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
</BrowserRouter>
|
||||||
|
</Provider>
|
||||||
|
</IntlProvider>
|
||||||
|
`;
|
||||||
54
src/components/BulkManagementTab/BulkManagementAlerts.jsx
Normal file
54
src/components/BulkManagementTab/BulkManagementAlerts.jsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
/* eslint-disable react/sort-comp, react/button-has-type */
|
||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
|
import { Alert } from '@edx/paragon';
|
||||||
|
|
||||||
|
import selectors from 'data/selectors';
|
||||||
|
import messages from './messages';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <BulkManagementAlerts />
|
||||||
|
* Alerts to display at the top of the BulkManagement tab
|
||||||
|
*/
|
||||||
|
export const BulkManagementAlerts = ({
|
||||||
|
bulkImportError,
|
||||||
|
uploadSuccess,
|
||||||
|
}) => (
|
||||||
|
<>
|
||||||
|
<Alert
|
||||||
|
variant="danger"
|
||||||
|
show={!!bulkImportError}
|
||||||
|
dismissible={false}
|
||||||
|
>
|
||||||
|
{bulkImportError}
|
||||||
|
</Alert>
|
||||||
|
<Alert
|
||||||
|
variant="success"
|
||||||
|
show={uploadSuccess}
|
||||||
|
dismissible={false}
|
||||||
|
>
|
||||||
|
<FormattedMessage {...messages.successDialog} />
|
||||||
|
</Alert>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
BulkManagementAlerts.defaultProps = {
|
||||||
|
bulkImportError: '',
|
||||||
|
uploadSuccess: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
BulkManagementAlerts.propTypes = {
|
||||||
|
// redux
|
||||||
|
bulkImportError: PropTypes.string,
|
||||||
|
uploadSuccess: PropTypes.bool,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mapStateToProps = (state) => ({
|
||||||
|
bulkImportError: selectors.grades.bulkImportError(state),
|
||||||
|
uploadSuccess: selectors.grades.uploadSuccess(state),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default connect(mapStateToProps)(BulkManagementAlerts);
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { shallow } from 'enzyme';
|
||||||
|
import { Alert } from '@edx/paragon';
|
||||||
|
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
|
import selectors from 'data/selectors';
|
||||||
|
import messages from './messages';
|
||||||
|
|
||||||
|
import { BulkManagementAlerts, mapStateToProps } from './BulkManagementAlerts';
|
||||||
|
|
||||||
|
jest.mock('@edx/frontend-platform/i18n', () => ({
|
||||||
|
defineMessages: m => m,
|
||||||
|
FormattedMessage: () => 'FormattedMessage',
|
||||||
|
}));
|
||||||
|
jest.mock('@edx/paragon', () => ({
|
||||||
|
Alert: () => 'Alert',
|
||||||
|
}));
|
||||||
|
jest.mock('data/selectors', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
grades: {
|
||||||
|
bulkImportError: (state) => ({ bulkImportError: state }),
|
||||||
|
uploadSuccess: (state) => ({ uploadSuccess: state }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const errorMessage = 'Oh noooooo';
|
||||||
|
|
||||||
|
describe('BulkManagementAlerts', () => {
|
||||||
|
describe('component', () => {
|
||||||
|
let el;
|
||||||
|
describe('no errer, no upload success', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
el = shallow(<BulkManagementAlerts />);
|
||||||
|
});
|
||||||
|
test('snapshot - bulkImportError closed, success closed', () => {
|
||||||
|
expect(el).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
test('closed danger alert', () => {
|
||||||
|
expect(el.childAt(0).is(Alert)).toEqual(true);
|
||||||
|
expect(el.childAt(0).props().show).toEqual(false);
|
||||||
|
expect(el.childAt(0).props().variant).toEqual('danger');
|
||||||
|
});
|
||||||
|
test('closed success alert', () => {
|
||||||
|
expect(el.childAt(1).is(Alert)).toEqual(true);
|
||||||
|
expect(el.childAt(1).props().show).toEqual(false);
|
||||||
|
expect(el.childAt(1).props().variant).toEqual('success');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('no errer, no upload success', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
el = shallow(<BulkManagementAlerts uploadSuccess bulkImportError={errorMessage} />);
|
||||||
|
});
|
||||||
|
const assertions = [
|
||||||
|
'danger alert open with bulkImportError',
|
||||||
|
'success alert open with messages.successDialog',
|
||||||
|
];
|
||||||
|
test(`snapshot - ${assertions.join(', ')}`, () => {
|
||||||
|
expect(el).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
test('open danger alert with bulkImportError content', () => {
|
||||||
|
expect(el.childAt(0).is(Alert)).toEqual(true);
|
||||||
|
expect(el.childAt(0).children().text()).toEqual(errorMessage);
|
||||||
|
expect(el.childAt(0).props().show).toEqual(true);
|
||||||
|
});
|
||||||
|
test('open success alert with messages.successDialog content', () => {
|
||||||
|
expect(el.childAt(1).is(Alert)).toEqual(true);
|
||||||
|
expect(el.childAt(1).children().getElement()).toEqual(
|
||||||
|
<FormattedMessage {...messages.successDialog} />,
|
||||||
|
);
|
||||||
|
expect(el.childAt(1).props().show).toEqual(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('mapStateToProps', () => {
|
||||||
|
let mapped;
|
||||||
|
const testState = { a: 'puppy', named: 'Ember' };
|
||||||
|
beforeEach(() => {
|
||||||
|
mapped = mapStateToProps(testState);
|
||||||
|
});
|
||||||
|
test('bulkImportError from grades.bulkImportError', () => {
|
||||||
|
expect(mapped.bulkImportError).toEqual(selectors.grades.bulkImportError(testState));
|
||||||
|
});
|
||||||
|
test('uploadSuccess from grades.uploadSuccess', () => {
|
||||||
|
expect(mapped.uploadSuccess).toEqual(selectors.grades.uploadSuccess(testState));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
95
src/components/BulkManagementTab/FileUploadForm.jsx
Normal file
95
src/components/BulkManagementTab/FileUploadForm.jsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
/* eslint-disable react/button-has-type, import/no-named-as-default */
|
||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
|
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormGroup,
|
||||||
|
} from '@edx/paragon';
|
||||||
|
|
||||||
|
import selectors from 'data/selectors';
|
||||||
|
import thunkActions from 'data/thunkActions';
|
||||||
|
import messages from './messages';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <FileUploadForm />
|
||||||
|
* File-type input wrapped with hidden control such that when a valid file is
|
||||||
|
* added, it is automattically uploaded.
|
||||||
|
*/
|
||||||
|
export class FileUploadForm extends React.Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.fileInputRef = React.createRef();
|
||||||
|
this.handleClickImportGrades = this.handleClickImportGrades.bind(this);
|
||||||
|
this.handleFileInputChange = this.handleFileInputChange.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
get fileInput() {
|
||||||
|
return this.fileInputRef.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
get formData() {
|
||||||
|
const data = new FormData();
|
||||||
|
data.append('csv', this.fileInput.files[0]);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasFile() {
|
||||||
|
return this.fileInput && this.fileInput.files[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
handleClickImportGrades() {
|
||||||
|
if (this.fileInput) { this.fileInput.click(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFileInputChange() {
|
||||||
|
return this.hasFile && (
|
||||||
|
this.props.submitFileUploadFormData(this.formData).then(
|
||||||
|
() => { this.fileInput.value = null; },
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { gradeExportUrl } = this.props;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Form action={gradeExportUrl} method="post">
|
||||||
|
<FormGroup controlId="csv">
|
||||||
|
<FormControl
|
||||||
|
className="d-none"
|
||||||
|
type="file"
|
||||||
|
label={<FormattedMessage {...messages.csvUploadLabel} />}
|
||||||
|
onChange={this.handleFileInputChange}
|
||||||
|
ref={this.fileInputRef}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<Button variant="primary" onClick={this.handleClickImportGrades}>
|
||||||
|
<FormattedMessage {...messages.importBtnText} />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FileUploadForm.propTypes = {
|
||||||
|
// redux
|
||||||
|
gradeExportUrl: PropTypes.string.isRequired,
|
||||||
|
submitFileUploadFormData: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mapStateToProps = (state) => ({
|
||||||
|
gradeExportUrl: selectors.root.gradeExportUrl(state),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const mapDispatchToProps = {
|
||||||
|
submitFileUploadFormData: thunkActions.grades.submitFileUploadFormData,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(mapStateToProps, mapDispatchToProps)(FileUploadForm);
|
||||||
214
src/components/BulkManagementTab/FileUploadForm.test.jsx
Normal file
214
src/components/BulkManagementTab/FileUploadForm.test.jsx
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
/* eslint-disable import/no-named-as-default */
|
||||||
|
import React from 'react';
|
||||||
|
import { shallow } from 'enzyme';
|
||||||
|
import TestRenderer from 'react-test-renderer';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormGroup,
|
||||||
|
} from '@edx/paragon';
|
||||||
|
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
|
import selectors from 'data/selectors';
|
||||||
|
import thunkActions from 'data/thunkActions';
|
||||||
|
import { FileUploadForm, mapStateToProps, mapDispatchToProps } from './FileUploadForm';
|
||||||
|
|
||||||
|
import messages from './messages';
|
||||||
|
|
||||||
|
jest.mock('@edx/frontend-platform/i18n', () => ({
|
||||||
|
defineMessages: m => m,
|
||||||
|
FormattedMessage: () => 'FormattedMessage',
|
||||||
|
}));
|
||||||
|
jest.mock('data/selectors', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
grades: {
|
||||||
|
bulkImportError: jest.fn(state => ({ bulkImportError: state })),
|
||||||
|
},
|
||||||
|
root: {
|
||||||
|
gradeExportUrl: jest.fn(state => ({ gradeExportUrl: state })),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
jest.mock('data/thunkActions', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
grades: { submitFileUploadFormData: jest.fn() },
|
||||||
|
},
|
||||||
|
|
||||||
|
}));
|
||||||
|
jest.mock('./BulkManagementAlerts', () => 'BulkManagementAlerts');
|
||||||
|
jest.mock('./ResultsSummary', () => 'ResultsSummary');
|
||||||
|
|
||||||
|
const mockRef = { click: jest.fn(), files: [] };
|
||||||
|
|
||||||
|
describe('FileUploadForm', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockRef.click.mockClear();
|
||||||
|
});
|
||||||
|
describe('component', () => {
|
||||||
|
let props;
|
||||||
|
let el;
|
||||||
|
let inst;
|
||||||
|
beforeEach(() => {
|
||||||
|
props = {
|
||||||
|
gradeExportUrl: 'fakeUrl',
|
||||||
|
submitFileUploadFormData: jest.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
describe('snapshot', () => {
|
||||||
|
const snapshotSegments = [
|
||||||
|
'export form w/ alerts and file input',
|
||||||
|
'import btn',
|
||||||
|
];
|
||||||
|
test(`snapshot - loads ${snapshotSegments.join(', ')}`, () => {
|
||||||
|
jest.mock('@edx/paragon', () => ({
|
||||||
|
Button: () => 'Button',
|
||||||
|
Form: () => 'Form',
|
||||||
|
FormControl: () => 'FormControl',
|
||||||
|
FormGroup: () => 'FormGroup',
|
||||||
|
}));
|
||||||
|
el = shallow(<FileUploadForm {...props} />);
|
||||||
|
el.instance().handleFileInputChange = jest.fn().mockName('this.handleFileInputChange');
|
||||||
|
el.instance().fileInputRef = jest.fn().mockName('this.fileInputRef');
|
||||||
|
el.instance().handleClickImportGrades = jest.fn().mockName('this.handleClickImportGrades');
|
||||||
|
el.instance().formatHistoryRow = jest.fn(entry => entry.originalFilename);
|
||||||
|
expect(el.instance().render()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('render', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
el = TestRenderer.create(
|
||||||
|
<FileUploadForm {...props} />,
|
||||||
|
{ createNodeMock: () => mockRef },
|
||||||
|
);
|
||||||
|
inst = el.root;
|
||||||
|
});
|
||||||
|
describe('alert form', () => {
|
||||||
|
let form;
|
||||||
|
beforeEach(() => {
|
||||||
|
form = inst.findByType(Form);
|
||||||
|
});
|
||||||
|
test('post action points to gradeExportUrl', () => {
|
||||||
|
expect(form.props.action).toEqual(props.gradeExportUrl);
|
||||||
|
expect(form.props.method).toEqual('post');
|
||||||
|
});
|
||||||
|
describe('file input', () => {
|
||||||
|
let formGroup;
|
||||||
|
beforeEach(() => {
|
||||||
|
formGroup = inst.findByType(FormGroup);
|
||||||
|
});
|
||||||
|
test('group with controlId="csv"', () => {
|
||||||
|
expect(formGroup.props.controlId).toEqual('csv');
|
||||||
|
});
|
||||||
|
test('file control with onChange from handleFileInputChange', () => {
|
||||||
|
const control = inst.findByType(FormControl);
|
||||||
|
expect(
|
||||||
|
control.props.onChange,
|
||||||
|
).toEqual(el.getInstance().handleFileInputChange);
|
||||||
|
});
|
||||||
|
test('fileInputRef points to control', () => {
|
||||||
|
expect(
|
||||||
|
// eslint-disable-next-line no-underscore-dangle
|
||||||
|
inst.findByType(FormControl)._fiber.ref,
|
||||||
|
).toEqual(el.getInstance().fileInputRef);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('import button', () => {
|
||||||
|
let btn;
|
||||||
|
beforeEach(() => {
|
||||||
|
btn = inst.findByType(Button);
|
||||||
|
});
|
||||||
|
test('handleClickImportGrade on click', () => {
|
||||||
|
expect(btn.props.onClick).toEqual(el.getInstance().handleClickImportGrades);
|
||||||
|
});
|
||||||
|
test('text from messages.importBtn', () => {
|
||||||
|
const messageEl = btn.findByType(FormattedMessage);
|
||||||
|
expect(messageEl.props).toEqual(messages.importBtnText);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('fileInput helper', () => {
|
||||||
|
test('links to fileInputRef.current', () => {
|
||||||
|
el = TestRenderer.create(
|
||||||
|
<FileUploadForm {...props} />,
|
||||||
|
{ createNodeMock: () => mockRef },
|
||||||
|
);
|
||||||
|
expect(el.getInstance().fileInput).not.toEqual(undefined);
|
||||||
|
expect(el.getInstance().fileInput).toEqual(el.getInstance().fileInputRef.current);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('behavior', () => {
|
||||||
|
let fileInput;
|
||||||
|
beforeEach(() => {
|
||||||
|
el = TestRenderer.create(
|
||||||
|
<FileUploadForm {...props} />,
|
||||||
|
{ createNodeMock: () => mockRef },
|
||||||
|
);
|
||||||
|
fileInput = jest.spyOn(el.getInstance(), 'fileInput', 'get');
|
||||||
|
});
|
||||||
|
describe('handleFileInputChange', () => {
|
||||||
|
it('does nothing (does not fail) if fileInput has not loaded', () => {
|
||||||
|
fileInput.mockReturnValue(null);
|
||||||
|
el.getInstance().handleClickImportGrades();
|
||||||
|
expect(mockRef.click).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it('calls fileInput.click if is loaded', () => {
|
||||||
|
el.getInstance().handleClickImportGrades();
|
||||||
|
expect(mockRef.click).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('handleClickImportGrades', () => {
|
||||||
|
it('does nothing if file input has not loaded with files', () => {
|
||||||
|
fileInput.mockReturnValue(null);
|
||||||
|
el.getInstance().handleFileInputChange();
|
||||||
|
expect(props.submitFileUploadFormData).not.toHaveBeenCalled();
|
||||||
|
fileInput.mockReturnValue({ files: [] });
|
||||||
|
el.getInstance().handleFileInputChange();
|
||||||
|
expect(props.submitFileUploadFormData).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it('calls submitFileUploadFormData and then clears fileInput if has files', () => {
|
||||||
|
fileInput.mockReturnValue({ files: ['some', 'files'], value: 'a value' });
|
||||||
|
const formData = { fake: 'form data' };
|
||||||
|
jest.spyOn(el.getInstance(), 'formData', 'get').mockReturnValue(formData);
|
||||||
|
const submit = jest.fn(() => ({ then: (thenCB) => { thenCB(); } }));
|
||||||
|
el.update(<FileUploadForm {...props} submitFileUploadFormData={submit} />);
|
||||||
|
el.getInstance().handleFileInputChange();
|
||||||
|
expect(submit).toHaveBeenCalledWith(formData);
|
||||||
|
expect(el.getInstance().fileInput.value).toEqual(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('formData', () => {
|
||||||
|
test('returns FormData object with csv value from fileInput.files[0]', () => {
|
||||||
|
const file = { a: 'fake file' };
|
||||||
|
const value = 'aValue';
|
||||||
|
fileInput.mockReturnValue({ files: [file], value });
|
||||||
|
const expected = new FormData();
|
||||||
|
expected.append('csv', file);
|
||||||
|
expect([...el.getInstance().formData.entries()]).toEqual([...expected.entries()]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mapStateToProps', () => {
|
||||||
|
const testState = { a: 'simple', test: 'state' };
|
||||||
|
let mapped;
|
||||||
|
beforeEach(() => {
|
||||||
|
mapped = mapStateToProps(testState);
|
||||||
|
});
|
||||||
|
test('gradeExportUrl from root.gradeExportUrl', () => {
|
||||||
|
expect(mapped.gradeExportUrl).toEqual(selectors.root.gradeExportUrl(testState));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mapDispatchToProps', () => {
|
||||||
|
test('submitFileUploadFormData from thunkActions.grades', () => {
|
||||||
|
expect(
|
||||||
|
mapDispatchToProps.submitFileUploadFormData,
|
||||||
|
).toEqual(thunkActions.grades.submitFileUploadFormData);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
70
src/components/BulkManagementTab/HistoryTable.jsx
Normal file
70
src/components/BulkManagementTab/HistoryTable.jsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
/* eslint-disable react/button-has-type, import/no-named-as-default */
|
||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
|
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||||
|
import { Table } from '@edx/paragon';
|
||||||
|
|
||||||
|
import { bulkManagementColumns } from 'data/constants/app';
|
||||||
|
import selectors from 'data/selectors';
|
||||||
|
|
||||||
|
import ResultsSummary from './ResultsSummary';
|
||||||
|
import messages from './messages';
|
||||||
|
|
||||||
|
export const mapHistoryRows = ({
|
||||||
|
resultsSummary,
|
||||||
|
originalFilename,
|
||||||
|
user,
|
||||||
|
...rest
|
||||||
|
}) => ({
|
||||||
|
resultsSummary: (<ResultsSummary {...resultsSummary} />),
|
||||||
|
filename: (<span className="wrap-text-in-cell">{originalFilename}</span>),
|
||||||
|
user: (<span className="wrap-text-in-cell">{user}</span>),
|
||||||
|
...rest,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <HistoryTable />
|
||||||
|
* Table with history of bulk management uploads, including a results summary which
|
||||||
|
* displays total, skipped, and failed uploads
|
||||||
|
*/
|
||||||
|
export const HistoryTable = ({
|
||||||
|
bulkManagementHistory,
|
||||||
|
}) => (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
<FormattedMessage {...messages.hint1} />
|
||||||
|
<br />
|
||||||
|
<FormattedMessage {...messages.hint2} />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
data={bulkManagementHistory.map(mapHistoryRows)}
|
||||||
|
hasFixedColumnWidths
|
||||||
|
columns={bulkManagementColumns}
|
||||||
|
className="table-striped"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
HistoryTable.defaultProps = {
|
||||||
|
bulkManagementHistory: [],
|
||||||
|
};
|
||||||
|
HistoryTable.propTypes = {
|
||||||
|
// redux
|
||||||
|
bulkManagementHistory: PropTypes.arrayOf(PropTypes.shape({
|
||||||
|
originalFilename: PropTypes.string.isRequired,
|
||||||
|
user: PropTypes.string.isRequired,
|
||||||
|
timeUploaded: PropTypes.string.isRequired,
|
||||||
|
resultsSummary: PropTypes.shape({
|
||||||
|
rowId: PropTypes.number.isRequired,
|
||||||
|
text: PropTypes.string.isRequired,
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mapStateToProps = (state) => ({
|
||||||
|
bulkManagementHistory: selectors.grades.bulkManagementHistoryEntries(state),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default connect(mapStateToProps)(HistoryTable);
|
||||||
121
src/components/BulkManagementTab/HistoryTable.test.jsx
Normal file
121
src/components/BulkManagementTab/HistoryTable.test.jsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
/* eslint-disable import/no-named-as-default */
|
||||||
|
import React from 'react';
|
||||||
|
import { shallow } from 'enzyme';
|
||||||
|
import { Table } from '@edx/paragon';
|
||||||
|
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
|
import selectors from 'data/selectors';
|
||||||
|
import { bulkManagementColumns } from 'data/constants/app';
|
||||||
|
|
||||||
|
import ResultsSummary from './ResultsSummary';
|
||||||
|
import { HistoryTable, mapStateToProps } from './HistoryTable';
|
||||||
|
import messages from './messages';
|
||||||
|
|
||||||
|
jest.mock('@edx/frontend-platform/i18n', () => ({
|
||||||
|
defineMessages: m => m,
|
||||||
|
FormattedMessage: () => 'FormattedMessage',
|
||||||
|
}));
|
||||||
|
jest.mock('@edx/paragon', () => ({
|
||||||
|
Table: () => 'Table',
|
||||||
|
}));
|
||||||
|
jest.mock('data/selectors', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
grades: {
|
||||||
|
bulkManagementHistoryEntries: jest.fn(state => ({ historyEntries: state })),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
jest.mock('./ResultsSummary', () => 'ResultsSummary');
|
||||||
|
|
||||||
|
describe('HistoryTable', () => {
|
||||||
|
describe('component', () => {
|
||||||
|
const entry1 = {
|
||||||
|
originalFilename: 'blue.png',
|
||||||
|
user: 'Eifel',
|
||||||
|
timeUploaded: '65',
|
||||||
|
resultsSummary: {
|
||||||
|
rowId: 12,
|
||||||
|
courseId: 'Da Bu Dee',
|
||||||
|
text: 'Da ba daa',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const entry2 = {
|
||||||
|
originalFilename: 'allStar.jpg',
|
||||||
|
user: 'Smashmouth',
|
||||||
|
timeUploaded: '2000s?',
|
||||||
|
resultsSummary: {
|
||||||
|
courseId: 'rockstar',
|
||||||
|
rowId: 2,
|
||||||
|
text: 'all that glitters is gold',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const props = {
|
||||||
|
bulkManagementHistory: [entry1, entry2],
|
||||||
|
};
|
||||||
|
let el;
|
||||||
|
describe('snapshot', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
el = shallow(<HistoryTable {...props} />);
|
||||||
|
});
|
||||||
|
const snapshotSegments = [
|
||||||
|
'hints display',
|
||||||
|
'formatted table',
|
||||||
|
];
|
||||||
|
test(`snapshot - loads ${snapshotSegments.join(', ')}`, () => {
|
||||||
|
expect(el).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
test('hints with break in between', () => {
|
||||||
|
const hints = el.find('p');
|
||||||
|
expect(hints.childAt(0).getElement()).toEqual(<FormattedMessage {...messages.hint1} />);
|
||||||
|
expect(hints.childAt(1).is('br')).toEqual(true);
|
||||||
|
expect(hints.childAt(2).getElement()).toEqual(<FormattedMessage {...messages.hint2} />);
|
||||||
|
});
|
||||||
|
describe('history table', () => {
|
||||||
|
let table;
|
||||||
|
beforeEach(() => {
|
||||||
|
table = el.find(Table);
|
||||||
|
});
|
||||||
|
describe('data (from bulkManagementHistory.map(this.formatHistoryRow)', () => {
|
||||||
|
const fieldAssertions = [
|
||||||
|
'maps resultsSummay to ResultsSummary',
|
||||||
|
'wraps filename and user',
|
||||||
|
'forwards the rest',
|
||||||
|
];
|
||||||
|
test(`snapshot: ${fieldAssertions.join(', ')}`, () => {
|
||||||
|
expect(table.props().data).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
test(fieldAssertions.join(', '), () => {
|
||||||
|
const rows = table.props().data;
|
||||||
|
expect(rows[0].resultsSummary).toEqual(<ResultsSummary {...entry1.resultsSummary} />);
|
||||||
|
expect(rows[0].user).toEqual(<span className="wrap-text-in-cell">{entry1.user}</span>);
|
||||||
|
expect(
|
||||||
|
rows[0].filename,
|
||||||
|
).toEqual(<span className="wrap-text-in-cell">{entry1.originalFilename}</span>);
|
||||||
|
expect(rows[1].resultsSummary).toEqual(<ResultsSummary {...entry2.resultsSummary} />);
|
||||||
|
expect(rows[1].user).toEqual(<span className="wrap-text-in-cell">{entry2.user}</span>);
|
||||||
|
expect(
|
||||||
|
rows[1].filename,
|
||||||
|
).toEqual(<span className="wrap-text-in-cell">{entry2.originalFilename}</span>);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
test('columns from bulkManagementColumns', () => {
|
||||||
|
expect(table.props().columns).toEqual(bulkManagementColumns);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mapStateToProps', () => {
|
||||||
|
const testState = { a: 'simple', test: 'state' };
|
||||||
|
let mapped;
|
||||||
|
beforeEach(() => {
|
||||||
|
mapped = mapStateToProps(testState);
|
||||||
|
});
|
||||||
|
test('bulkManagementHistory from grades.bulkManagementHistoryEntries', () => {
|
||||||
|
expect(
|
||||||
|
mapped.bulkManagementHistory,
|
||||||
|
).toEqual(selectors.grades.bulkManagementHistoryEntries(testState));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
38
src/components/BulkManagementTab/ResultsSummary.jsx
Normal file
38
src/components/BulkManagementTab/ResultsSummary.jsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
/* eslint-disable react/sort-comp, react/button-has-type */
|
||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
import { Hyperlink, Icon } from '@edx/paragon';
|
||||||
|
import { Download } from '@edx/paragon/icons';
|
||||||
|
|
||||||
|
import lms from 'data/services/lms';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <ResultsSummary {...{ courseId, rowId, text }} />
|
||||||
|
* displays a result summary cell for a single bulk management upgrade history entry.
|
||||||
|
* @param {string} courseId - course identifier
|
||||||
|
* @param {number} rowId - row/error identifier
|
||||||
|
* @param {string} text - summary string
|
||||||
|
*/
|
||||||
|
const ResultsSummary = ({
|
||||||
|
rowId,
|
||||||
|
text,
|
||||||
|
}) => (
|
||||||
|
<Hyperlink
|
||||||
|
href={lms.urls.bulkGradesUrlByRow(rowId)}
|
||||||
|
destination="www.edx.org"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
showLaunchIcon={false}
|
||||||
|
>
|
||||||
|
<Icon src={Download} className="d-inline-block" />
|
||||||
|
{text}
|
||||||
|
</Hyperlink>
|
||||||
|
);
|
||||||
|
|
||||||
|
ResultsSummary.propTypes = {
|
||||||
|
rowId: PropTypes.number.isRequired,
|
||||||
|
text: PropTypes.string.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ResultsSummary;
|
||||||
53
src/components/BulkManagementTab/ResultsSummary.test.jsx
Normal file
53
src/components/BulkManagementTab/ResultsSummary.test.jsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { shallow } from 'enzyme';
|
||||||
|
|
||||||
|
import { Icon } from '@edx/paragon';
|
||||||
|
import { Download } from '@edx/paragon/icons';
|
||||||
|
|
||||||
|
import lms from 'data/services/lms';
|
||||||
|
import ResultsSummary from './ResultsSummary';
|
||||||
|
|
||||||
|
jest.mock('@edx/paragon', () => ({
|
||||||
|
Hyperlink: () => 'Hyperlink',
|
||||||
|
Icon: () => 'Icon',
|
||||||
|
}));
|
||||||
|
jest.mock('@edx/paragon/icons', () => ({
|
||||||
|
Download: 'DownloadIcon',
|
||||||
|
}));
|
||||||
|
jest.mock('data/services/lms', () => ({
|
||||||
|
urls: {
|
||||||
|
bulkGradesUrlByRow: jest.fn((rowId) => ({ url: { rowId } })),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('ResultsSummary component', () => {
|
||||||
|
const props = {
|
||||||
|
rowId: 42,
|
||||||
|
text: 'texty',
|
||||||
|
};
|
||||||
|
let el;
|
||||||
|
const assertions = [
|
||||||
|
'safe hyperlink with bulkGradesUrl with course and row id',
|
||||||
|
'download icon',
|
||||||
|
'results text',
|
||||||
|
];
|
||||||
|
beforeEach(() => {
|
||||||
|
el = shallow(<ResultsSummary {...props} />);
|
||||||
|
});
|
||||||
|
test(`snapshot - ${assertions.join(', ')}`, () => {
|
||||||
|
expect(el).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
test('Hyperlink has target="_blank" and rel="noopener noreferrer"', () => {
|
||||||
|
expect(el.props().target).toEqual('_blank');
|
||||||
|
expect(el.props().rel).toEqual('noopener noreferrer');
|
||||||
|
});
|
||||||
|
test('Hyperlink has href to bulkGradesUrl', () => {
|
||||||
|
expect(el.props().href).toEqual(lms.urls.bulkGradesUrlByRow(props.rowId));
|
||||||
|
});
|
||||||
|
test('displays Download Icon and text', () => {
|
||||||
|
const icon = el.childAt(0);
|
||||||
|
expect(icon.is(Icon)).toEqual(true);
|
||||||
|
expect(icon.props().src).toEqual(Download);
|
||||||
|
expect(el.childAt(1).text()).toEqual(props.text);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`BulkManagementAlerts component no errer, no upload success snapshot - bulkImportError closed, success closed 1`] = `
|
||||||
|
<Fragment>
|
||||||
|
<Alert
|
||||||
|
dismissible={false}
|
||||||
|
show={false}
|
||||||
|
variant="danger"
|
||||||
|
/>
|
||||||
|
<Alert
|
||||||
|
dismissible={false}
|
||||||
|
show={false}
|
||||||
|
variant="success"
|
||||||
|
>
|
||||||
|
<FormattedMessage
|
||||||
|
defaultMessage="CSV processing. File uploads may take several minutes to complete."
|
||||||
|
description="Success Dialog message in BulkManagement Tab File Upload Form"
|
||||||
|
id="gradebook.BulkManagementTab.successDialog"
|
||||||
|
/>
|
||||||
|
</Alert>
|
||||||
|
</Fragment>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`BulkManagementAlerts component no errer, no upload success snapshot - danger alert open with bulkImportError, success alert open with messages.successDialog 1`] = `
|
||||||
|
<Fragment>
|
||||||
|
<Alert
|
||||||
|
dismissible={false}
|
||||||
|
show={true}
|
||||||
|
variant="danger"
|
||||||
|
>
|
||||||
|
Oh noooooo
|
||||||
|
</Alert>
|
||||||
|
<Alert
|
||||||
|
dismissible={false}
|
||||||
|
show={true}
|
||||||
|
variant="success"
|
||||||
|
>
|
||||||
|
<FormattedMessage
|
||||||
|
defaultMessage="CSV processing. File uploads may take several minutes to complete."
|
||||||
|
description="Success Dialog message in BulkManagement Tab File Upload Form"
|
||||||
|
id="gradebook.BulkManagementTab.successDialog"
|
||||||
|
/>
|
||||||
|
</Alert>
|
||||||
|
</Fragment>
|
||||||
|
`;
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`FileUploadForm component snapshot snapshot - loads export form w/ alerts and file input, import btn 1`] = `
|
||||||
|
<React.Fragment>
|
||||||
|
<Form
|
||||||
|
action="fakeUrl"
|
||||||
|
inline={false}
|
||||||
|
method="post"
|
||||||
|
>
|
||||||
|
<FormGroup
|
||||||
|
as="div"
|
||||||
|
controlId="csv"
|
||||||
|
isInvalid={false}
|
||||||
|
isValid={false}
|
||||||
|
>
|
||||||
|
<ForwardRef
|
||||||
|
as="input"
|
||||||
|
className="d-none"
|
||||||
|
label={
|
||||||
|
<FormattedMessage
|
||||||
|
defaultMessage="Upload Grade CSV"
|
||||||
|
description="Button in BulkManagementTab Alerts"
|
||||||
|
id="gradebook.BulkManagementTab.csvUploadLabel"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onChange={[MockFunction this.handleFileInputChange]}
|
||||||
|
plaintext={false}
|
||||||
|
type="file"
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
</Form>
|
||||||
|
<ForwardRef
|
||||||
|
active={false}
|
||||||
|
disabled={false}
|
||||||
|
onClick={[MockFunction this.handleClickImportGrades]}
|
||||||
|
variant="primary"
|
||||||
|
>
|
||||||
|
<FormattedMessage
|
||||||
|
defaultMessage="Import Grades"
|
||||||
|
description="Button in BulkManagement Tab File Upload Form"
|
||||||
|
id="gradebook.BulkManagementTab.importBtnText"
|
||||||
|
/>
|
||||||
|
</ForwardRef>
|
||||||
|
</React.Fragment>
|
||||||
|
`;
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`HistoryTable component snapshot history table data (from bulkManagementHistory.map(this.formatHistoryRow) snapshot: maps resultsSummay to ResultsSummary, wraps filename and user, forwards the rest 1`] = `
|
||||||
|
Array [
|
||||||
|
Object {
|
||||||
|
"filename": <span
|
||||||
|
className="wrap-text-in-cell"
|
||||||
|
>
|
||||||
|
blue.png
|
||||||
|
</span>,
|
||||||
|
"resultsSummary": <ResultsSummary
|
||||||
|
courseId="Da Bu Dee"
|
||||||
|
rowId={12}
|
||||||
|
text="Da ba daa"
|
||||||
|
/>,
|
||||||
|
"timeUploaded": "65",
|
||||||
|
"user": <span
|
||||||
|
className="wrap-text-in-cell"
|
||||||
|
>
|
||||||
|
Eifel
|
||||||
|
</span>,
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"filename": <span
|
||||||
|
className="wrap-text-in-cell"
|
||||||
|
>
|
||||||
|
allStar.jpg
|
||||||
|
</span>,
|
||||||
|
"resultsSummary": <ResultsSummary
|
||||||
|
courseId="rockstar"
|
||||||
|
rowId={2}
|
||||||
|
text="all that glitters is gold"
|
||||||
|
/>,
|
||||||
|
"timeUploaded": "2000s?",
|
||||||
|
"user": <span
|
||||||
|
className="wrap-text-in-cell"
|
||||||
|
>
|
||||||
|
Smashmouth
|
||||||
|
</span>,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`HistoryTable component snapshot snapshot - loads hints display, formatted table 1`] = `
|
||||||
|
<Fragment>
|
||||||
|
<p>
|
||||||
|
<FormattedMessage
|
||||||
|
defaultMessage="Results appear in the table below."
|
||||||
|
description="Hint text on BulkManagement Tab History Table"
|
||||||
|
id="gradebook.BulkManagementTab.hint1"
|
||||||
|
/>
|
||||||
|
<br />
|
||||||
|
<FormattedMessage
|
||||||
|
defaultMessage="Grade processing may take a few seconds."
|
||||||
|
description="Hint text on BulkManagement Tab History Table"
|
||||||
|
id="gradebook.BulkManagementTab.hint2"
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
<Table
|
||||||
|
className="table-striped"
|
||||||
|
columns={
|
||||||
|
Array [
|
||||||
|
Object {
|
||||||
|
"columnSortable": false,
|
||||||
|
"key": "filename",
|
||||||
|
"label": "Gradebook",
|
||||||
|
"width": "col-5",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"columnSortable": false,
|
||||||
|
"key": "resultsSummary",
|
||||||
|
"label": "Download Summary",
|
||||||
|
"width": "col",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"columnSortable": false,
|
||||||
|
"key": "user",
|
||||||
|
"label": "Who",
|
||||||
|
"width": "col-1",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"columnSortable": false,
|
||||||
|
"key": "timeUploaded",
|
||||||
|
"label": "When",
|
||||||
|
"width": "col",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
data={
|
||||||
|
Array [
|
||||||
|
Object {
|
||||||
|
"filename": <span
|
||||||
|
className="wrap-text-in-cell"
|
||||||
|
>
|
||||||
|
blue.png
|
||||||
|
</span>,
|
||||||
|
"resultsSummary": <ResultsSummary
|
||||||
|
courseId="Da Bu Dee"
|
||||||
|
rowId={12}
|
||||||
|
text="Da ba daa"
|
||||||
|
/>,
|
||||||
|
"timeUploaded": "65",
|
||||||
|
"user": <span
|
||||||
|
className="wrap-text-in-cell"
|
||||||
|
>
|
||||||
|
Eifel
|
||||||
|
</span>,
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"filename": <span
|
||||||
|
className="wrap-text-in-cell"
|
||||||
|
>
|
||||||
|
allStar.jpg
|
||||||
|
</span>,
|
||||||
|
"resultsSummary": <ResultsSummary
|
||||||
|
courseId="rockstar"
|
||||||
|
rowId={2}
|
||||||
|
text="all that glitters is gold"
|
||||||
|
/>,
|
||||||
|
"timeUploaded": "2000s?",
|
||||||
|
"user": <span
|
||||||
|
className="wrap-text-in-cell"
|
||||||
|
>
|
||||||
|
Smashmouth
|
||||||
|
</span>,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
hasFixedColumnWidths={true}
|
||||||
|
/>
|
||||||
|
</Fragment>
|
||||||
|
`;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`ResultsSummary component snapshot - safe hyperlink with bulkGradesUrl with course and row id, download icon, results text 1`] = `
|
||||||
|
<Hyperlink
|
||||||
|
destination="www.edx.org"
|
||||||
|
href={
|
||||||
|
Object {
|
||||||
|
"url": Object {
|
||||||
|
"rowId": 42,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
showLaunchIcon={false}
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
className="d-inline-block"
|
||||||
|
src="DownloadIcon"
|
||||||
|
/>
|
||||||
|
texty
|
||||||
|
</Hyperlink>
|
||||||
|
`;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`BulkManagementTab component snapshot snapshot - loads heading from messages.BulkManagementTab.heading, <BulkManagementAlerts />, <FileUploadForm />, <HistoryTable /> 1`] = `
|
||||||
|
<div>
|
||||||
|
<h4>
|
||||||
|
<FormattedMessage
|
||||||
|
defaultMessage="Use this feature by downloading a CSV for bulk management, overriding grades locally, and coming back here to upload."
|
||||||
|
description="Heading text for BulkManagement Tab"
|
||||||
|
id="gradebook.BulkManagementTab.heading"
|
||||||
|
/>
|
||||||
|
</h4>
|
||||||
|
<BulkManagementAlerts />
|
||||||
|
<FileUploadForm />
|
||||||
|
<HistoryTable />
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
23
src/components/BulkManagementTab/index.jsx
Normal file
23
src/components/BulkManagementTab/index.jsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/* eslint-disable react/button-has-type, import/no-named-as-default */
|
||||||
|
import React from 'react';
|
||||||
|
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
|
import messages from './messages';
|
||||||
|
import BulkManagementAlerts from './BulkManagementAlerts';
|
||||||
|
import FileUploadForm from './FileUploadForm';
|
||||||
|
import HistoryTable from './HistoryTable';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <BulkManagementTab />
|
||||||
|
* top-level view for managing uploads of bulk management override csvs.
|
||||||
|
*/
|
||||||
|
export const BulkManagementTab = () => (
|
||||||
|
<div>
|
||||||
|
<h4><FormattedMessage {...(messages.heading)} /></h4>
|
||||||
|
<BulkManagementAlerts />
|
||||||
|
<FileUploadForm />
|
||||||
|
<HistoryTable />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default BulkManagementTab;
|
||||||
48
src/components/BulkManagementTab/index.test.jsx
Normal file
48
src/components/BulkManagementTab/index.test.jsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/* eslint-disable import/no-named-as-default */
|
||||||
|
import React from 'react';
|
||||||
|
import { shallow } from 'enzyme';
|
||||||
|
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
|
import { BulkManagementTab } from '.';
|
||||||
|
import BulkManagementAlerts from './BulkManagementAlerts';
|
||||||
|
import FileUploadForm from './FileUploadForm';
|
||||||
|
import HistoryTable from './HistoryTable';
|
||||||
|
import messages from './messages';
|
||||||
|
|
||||||
|
jest.mock('./BulkManagementAlerts', () => 'BulkManagementAlerts');
|
||||||
|
jest.mock('./FileUploadForm', () => 'FileUploadForm');
|
||||||
|
jest.mock('./HistoryTable', () => 'HistoryTable');
|
||||||
|
|
||||||
|
describe('BulkManagementTab', () => {
|
||||||
|
describe('component', () => {
|
||||||
|
let el;
|
||||||
|
beforeEach(() => {
|
||||||
|
el = shallow(<BulkManagementTab />);
|
||||||
|
});
|
||||||
|
describe('snapshot', () => {
|
||||||
|
const snapshotSegments = [
|
||||||
|
'heading from messages.BulkManagementTab.heading',
|
||||||
|
'<BulkManagementAlerts />',
|
||||||
|
'<FileUploadForm />',
|
||||||
|
'<HistoryTable />',
|
||||||
|
];
|
||||||
|
test(`snapshot - loads ${snapshotSegments.join(', ')}`, () => {
|
||||||
|
expect(el).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
test('heading - h4 loaded from messages', () => {
|
||||||
|
const heading = el.find('h4');
|
||||||
|
expect(heading.getElement()).toEqual((
|
||||||
|
<h4>
|
||||||
|
<FormattedMessage {...messages.heading} />
|
||||||
|
</h4>
|
||||||
|
));
|
||||||
|
});
|
||||||
|
test('heading, then alerts, then upload form, then table', () => {
|
||||||
|
expect(el.childAt(0).is('h4')).toEqual(true);
|
||||||
|
expect(el.childAt(1).is(BulkManagementAlerts)).toEqual(true);
|
||||||
|
expect(el.childAt(2).is(FileUploadForm)).toEqual(true);
|
||||||
|
expect(el.childAt(3).is(HistoryTable)).toEqual(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
36
src/components/BulkManagementTab/messages.js
Normal file
36
src/components/BulkManagementTab/messages.js
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
csvUploadLabel: {
|
||||||
|
id: 'gradebook.BulkManagementTab.csvUploadLabel',
|
||||||
|
defaultMessage: 'Upload Grade CSV',
|
||||||
|
description: 'Button in BulkManagementTab Alerts',
|
||||||
|
},
|
||||||
|
heading: {
|
||||||
|
id: 'gradebook.BulkManagementTab.heading',
|
||||||
|
defaultMessage: 'Use this feature by downloading a CSV for bulk management, overriding grades locally, and coming back here to upload.',
|
||||||
|
description: 'Heading text for BulkManagement Tab',
|
||||||
|
},
|
||||||
|
hint1: {
|
||||||
|
id: 'gradebook.BulkManagementTab.hint1',
|
||||||
|
defaultMessage: 'Results appear in the table below.',
|
||||||
|
description: 'Hint text on BulkManagement Tab History Table',
|
||||||
|
},
|
||||||
|
hint2: {
|
||||||
|
id: 'gradebook.BulkManagementTab.hint2',
|
||||||
|
defaultMessage: 'Grade processing may take a few seconds.',
|
||||||
|
description: 'Hint text on BulkManagement Tab History Table',
|
||||||
|
},
|
||||||
|
importBtnText: {
|
||||||
|
id: 'gradebook.BulkManagementTab.importBtnText',
|
||||||
|
defaultMessage: 'Import Grades',
|
||||||
|
description: 'Button in BulkManagement Tab File Upload Form',
|
||||||
|
},
|
||||||
|
successDialog: {
|
||||||
|
id: 'gradebook.BulkManagementTab.successDialog',
|
||||||
|
defaultMessage: 'CSV processing. File uploads may take several minutes to complete.',
|
||||||
|
description: 'Success Dialog message in BulkManagement Tab File Upload Form',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default messages;
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
$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%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
deferToNextRepaint(callback) {
|
|
||||||
window.requestAnimationFrame(() =>
|
|
||||||
window.setTimeout(callback, 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
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 });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
23
src/components/EdxHeader/__snapshots__/test.jsx.snap
Normal file
23
src/components/EdxHeader/__snapshots__/test.jsx.snap
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`Header snapshot - has edx link with logo url 1`] = `
|
||||||
|
<div
|
||||||
|
className="mb-3"
|
||||||
|
>
|
||||||
|
<header
|
||||||
|
className="d-flex justify-content-center align-items-center p-3 border-bottom-blue"
|
||||||
|
>
|
||||||
|
<Hyperlink
|
||||||
|
destination="undefined/dashboard"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
alt="edX logo"
|
||||||
|
height="30"
|
||||||
|
src="www.ourLogo.url"
|
||||||
|
width="60"
|
||||||
|
/>
|
||||||
|
</Hyperlink>
|
||||||
|
<div />
|
||||||
|
</header>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
21
src/components/EdxHeader/index.jsx
Normal file
21
src/components/EdxHeader/index.jsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Hyperlink } from '@edx/paragon';
|
||||||
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <EdxHeader />
|
||||||
|
* Gradebook MFE app header.
|
||||||
|
* Displays edx logo, linked to lms dashboard
|
||||||
|
*/
|
||||||
|
const EdxHeader = () => (
|
||||||
|
<div className="mb-3">
|
||||||
|
<header className="d-flex justify-content-center align-items-center p-3 border-bottom-blue">
|
||||||
|
<Hyperlink destination={`${getConfig().LMS_BASE_URL}/dashboard`}>
|
||||||
|
<img src={getConfig().LOGO_URL} alt="edX logo" height="30" width="60" />
|
||||||
|
</Hyperlink>
|
||||||
|
<div />
|
||||||
|
</header>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default EdxHeader;
|
||||||
21
src/components/EdxHeader/test.jsx
Normal file
21
src/components/EdxHeader/test.jsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { shallow } from 'enzyme';
|
||||||
|
|
||||||
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
|
|
||||||
|
import Header from '.';
|
||||||
|
|
||||||
|
jest.mock('@edx/paragon', () => ({
|
||||||
|
Hyperlink: () => 'Hyperlink',
|
||||||
|
}));
|
||||||
|
jest.mock('@edx/frontend-platform', () => ({
|
||||||
|
getConfig: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('Header', () => {
|
||||||
|
test('snapshot - has edx link with logo url', () => {
|
||||||
|
const url = 'www.ourLogo.url';
|
||||||
|
getConfig.mockReturnValue({ LOGO_URL: url });
|
||||||
|
expect(shallow(<Header />)).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
|
|
||||||
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 }) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<span className="badge badge-info">
|
|
||||||
<span>{`${name}: ${value}`}</span>
|
|
||||||
<button type="button" className="btn-info" aria-label="Close" onClick={onClick}>
|
|
||||||
<span aria-hidden="true">×</span>
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
<br />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RangeFilterBadge({
|
|
||||||
displayName,
|
|
||||||
filterName1,
|
|
||||||
filterValue1,
|
|
||||||
filterName2,
|
|
||||||
filterValue2,
|
|
||||||
handleBadgeClose,
|
|
||||||
}) {
|
|
||||||
return ((filterValue1 !== initialFilters[filterName1]) ||
|
|
||||||
(filterValue2 !== initialFilters[filterName2]))
|
|
||||||
&&
|
|
||||||
<FilterBadge
|
|
||||||
name={displayName}
|
|
||||||
value={`${filterValue1} - ${filterValue2}`}
|
|
||||||
onClick={handleBadgeClose}
|
|
||||||
/>;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function SingleValueFilterBadge({
|
|
||||||
displayName, filterName, filterValue, handleBadgeClose,
|
|
||||||
}) {
|
|
||||||
return (filterValue !== initialFilters[filterName]) &&
|
|
||||||
<FilterBadge
|
|
||||||
name={displayName}
|
|
||||||
value={filterValue}
|
|
||||||
onClick={handleBadgeClose}
|
|
||||||
/>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function FilterBadges({
|
|
||||||
assignment,
|
|
||||||
assignmentType,
|
|
||||||
track,
|
|
||||||
cohort,
|
|
||||||
assignmentGradeMin,
|
|
||||||
assignmentGradeMax,
|
|
||||||
courseGradeMin,
|
|
||||||
courseGradeMax,
|
|
||||||
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="track"
|
|
||||||
filterValue={cohort}
|
|
||||||
handleBadgeClose={handleFilterBadgeClose(['cohort'])}
|
|
||||||
/>
|
|
||||||
</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,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const ConnectedFilterBadges = connect(mapStateToProps)(FilterBadges);
|
|
||||||
export default ConnectedFilterBadges;
|
|
||||||
|
|
||||||
FilterBadge.propTypes = {
|
|
||||||
name: PropTypes.string.isRequired,
|
|
||||||
value: PropTypes.string.isRequired,
|
|
||||||
onClick: PropTypes.func.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
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,
|
|
||||||
handleFilterBadgeClose: PropTypes.func.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
@@ -1,1066 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Collapsible,
|
|
||||||
Icon,
|
|
||||||
InputSelect,
|
|
||||||
InputText,
|
|
||||||
Modal,
|
|
||||||
SearchField,
|
|
||||||
StatefulButton,
|
|
||||||
StatusAlert,
|
|
||||||
Table,
|
|
||||||
Tabs,
|
|
||||||
} from '@edx/paragon';
|
|
||||||
import queryString from 'query-string';
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import { faDownload, faSpinner, faFilter } from '@fortawesome/free-solid-svg-icons';
|
|
||||||
import { configuration } from '../../config';
|
|
||||||
import PageButtons from '../PageButtons';
|
|
||||||
import Drawer from '../Drawer';
|
|
||||||
import { formatDateForDisplay } from '../../data/actions/utils';
|
|
||||||
import initialFilters from '../../data/constants/filters';
|
|
||||||
import ConnectedFilterBadges from '../FilterBadges';
|
|
||||||
|
|
||||||
|
|
||||||
const DECIMAL_PRECISION = 2;
|
|
||||||
const GRADE_OVERRIDE_HISTORY_COLUMNS = [{ label: 'Date', key: 'date' }, { label: 'Grader', key: 'grader' },
|
|
||||||
{ label: 'Reason', key: 'reason' },
|
|
||||||
{ label: 'Adjusted grade', key: 'adjustedGrade' }];
|
|
||||||
|
|
||||||
export default class Gradebook extends React.Component {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
this.state = {
|
|
||||||
filterValue: '',
|
|
||||||
courseGradeMin: '0',
|
|
||||||
courseGradeMax: '100',
|
|
||||||
modalOpen: false,
|
|
||||||
adjustedGradeValue: 0,
|
|
||||||
updateModuleId: null,
|
|
||||||
updateUserId: null,
|
|
||||||
reasonForChange: '',
|
|
||||||
assignmentGradeMin: '0',
|
|
||||||
assignmentGradeMax: '100',
|
|
||||||
isMinCourseGradeFilterValid: true,
|
|
||||||
isMaxCourseGradeFilterValid: true,
|
|
||||||
};
|
|
||||||
this.fileFormRef = React.createRef();
|
|
||||||
this.fileInputRef = React.createRef();
|
|
||||||
this.myRef = React.createRef();
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
const urlQuery = queryString.parse(this.props.location.search);
|
|
||||||
this.props.initializeFilters(urlQuery);
|
|
||||||
this.props.getRoles(this.props.courseId);
|
|
||||||
this.overrideReasonInput.focus();
|
|
||||||
|
|
||||||
const newStateFields = {};
|
|
||||||
['assignmentGradeMin', 'assignmentGradeMax', 'courseGradeMin', 'courseGradeMax'].forEach((attr) => {
|
|
||||||
if (urlQuery[attr]) {
|
|
||||||
newStateFields[attr] = urlQuery[attr];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.setState(newStateFields);
|
|
||||||
}
|
|
||||||
|
|
||||||
onChange(e) {
|
|
||||||
this.setState({ [e.target.name]: e.target.value });
|
|
||||||
}
|
|
||||||
|
|
||||||
setNewModalState = (userEntry, subsection) => {
|
|
||||||
this.props.fetchGradeOverrideHistory(
|
|
||||||
subsection.module_id,
|
|
||||||
userEntry.user_id,
|
|
||||||
);
|
|
||||||
|
|
||||||
let adjustedGradePossible = '';
|
|
||||||
if (subsection.attempted) {
|
|
||||||
adjustedGradePossible = subsection.score_possible;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setState({
|
|
||||||
modalAssignmentName: `${subsection.subsection_name}`,
|
|
||||||
modalOpen: true,
|
|
||||||
updateModuleId: subsection.module_id,
|
|
||||||
updateUserId: userEntry.user_id,
|
|
||||||
updateUserName: userEntry.username,
|
|
||||||
todaysDate: formatDateForDisplay(new Date()),
|
|
||||||
adjustedGradePossible,
|
|
||||||
reasonForChange: '',
|
|
||||||
adjustedGradeValue: '',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
getLearnerInformation = entry => (
|
|
||||||
<div>
|
|
||||||
<div>{entry.username}</div>
|
|
||||||
{entry.external_user_key && <div className="student-key">{entry.external_user_key}</div>}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
getActiveTabs = () => {
|
|
||||||
if (this.props.showBulkManagement) {
|
|
||||||
return ['Grades', 'Bulk Management'];
|
|
||||||
}
|
|
||||||
return ['Grades'];
|
|
||||||
};
|
|
||||||
|
|
||||||
getAssignmentFilterOptions = () => [
|
|
||||||
{ label: 'All', value: '' },
|
|
||||||
...this.props.assignmentFilterOptions.map((assignment) => {
|
|
||||||
const { label, subsectionLabel } = assignment;
|
|
||||||
return {
|
|
||||||
label: `${label}: ${subsectionLabel}`,
|
|
||||||
value: label,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
getCourseGradeFilterAlertDialog = () => {
|
|
||||||
let dialog = '';
|
|
||||||
|
|
||||||
if (!this.state.isMinCourseGradeFilterValid) {
|
|
||||||
dialog += 'Minimum course grade value must be between 0 and 100. ';
|
|
||||||
}
|
|
||||||
if (!this.state.isMaxCourseGradeFilterValid) {
|
|
||||||
dialog += 'Maximum course grade value must be between 0 and 100. ';
|
|
||||||
}
|
|
||||||
return dialog;
|
|
||||||
};
|
|
||||||
|
|
||||||
handleAdjustedGradeClick = () => {
|
|
||||||
this.props.updateGrades(
|
|
||||||
this.props.courseId, [
|
|
||||||
{
|
|
||||||
user_id: this.state.updateUserId,
|
|
||||||
usage_id: this.state.updateModuleId,
|
|
||||||
grade: {
|
|
||||||
earned_graded_override: this.state.adjustedGradeValue,
|
|
||||||
comment: this.state.reasonForChange,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
this.state.filterValue,
|
|
||||||
this.props.selectedCohort,
|
|
||||||
this.props.selectedTrack,
|
|
||||||
);
|
|
||||||
|
|
||||||
this.closeAssignmentModal();
|
|
||||||
}
|
|
||||||
|
|
||||||
closeAssignmentModal = () => {
|
|
||||||
this.props.doneViewingAssignment();
|
|
||||||
this.setState({
|
|
||||||
adjustedGradePossible: '',
|
|
||||||
adjustedGradeValue: '',
|
|
||||||
modalOpen: false,
|
|
||||||
reasonForChange: '',
|
|
||||||
updateModuleId: null,
|
|
||||||
updateUserId: null,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
handleAssignmentFilterChange = (assignment) => {
|
|
||||||
const selectedFilterOption = this.props.assignmentFilterOptions.find(assig =>
|
|
||||||
assig.label === assignment);
|
|
||||||
const { type, id } = selectedFilterOption || {};
|
|
||||||
const typedValue = { label: assignment, type, id };
|
|
||||||
this.props.updateAssignmentFilter(typedValue);
|
|
||||||
this.updateQueryParams({ assignment: id });
|
|
||||||
this.props.updateGradesIfAssignmentGradeFiltersSet(
|
|
||||||
this.props.courseId,
|
|
||||||
this.props.selectedCohort,
|
|
||||||
this.props.selectedTrack,
|
|
||||||
this.props.selectedAssignmentType,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
updateQueryParams = (queryParams) => {
|
|
||||||
const parsed = queryString.parse(this.props.location.search);
|
|
||||||
Object.keys(queryParams).forEach((key) => {
|
|
||||||
if (queryParams[key]) {
|
|
||||||
parsed[key] = queryParams[key];
|
|
||||||
} else {
|
|
||||||
delete parsed[key];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.props.history.push(`?${queryString.stringify(parsed)}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
mapAssignmentTypeEntries = (entries) => {
|
|
||||||
const mapped = entries.map(entry => ({
|
|
||||||
id: entry,
|
|
||||||
label: entry,
|
|
||||||
}));
|
|
||||||
mapped.unshift({ id: 0, label: 'All', value: '' });
|
|
||||||
return mapped;
|
|
||||||
};
|
|
||||||
|
|
||||||
mapCohortsEntries = (entries) => {
|
|
||||||
const mapped = entries.map(entry => ({
|
|
||||||
id: entry.id,
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
formatHistoryRow = (row) => {
|
|
||||||
const {
|
|
||||||
summaryOfRowsProcessed: {
|
|
||||||
total,
|
|
||||||
successfullyProcessed,
|
|
||||||
failed,
|
|
||||||
skipped,
|
|
||||||
},
|
|
||||||
unique_id: courseId,
|
|
||||||
originalFilename,
|
|
||||||
id,
|
|
||||||
user: username,
|
|
||||||
...rest
|
|
||||||
} = row;
|
|
||||||
const resultsText = [
|
|
||||||
`${total} Students: ${successfullyProcessed} processed`,
|
|
||||||
...(skipped > 0 ? [`${skipped} skipped`] : []),
|
|
||||||
...(failed > 0 ? [`${failed} failed`] : []),
|
|
||||||
].join(', ');
|
|
||||||
const resultsSummary = (
|
|
||||||
<a
|
|
||||||
href={`${configuration.LMS_BASE_URL}/api/bulk_grades/course/${courseId}/?error_id=${id}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faDownload} />
|
|
||||||
{resultsText}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
const filename = (
|
|
||||||
<span className="wrap-text-in-cell">
|
|
||||||
{originalFilename}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
const user = (
|
|
||||||
<span className="wrap-text-in-cell">
|
|
||||||
{username}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
resultsSummary,
|
|
||||||
filename,
|
|
||||||
user,
|
|
||||||
...rest,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
updateAssignmentTypes = (assignmentType) => {
|
|
||||||
this.props.filterAssignmentType(assignmentType);
|
|
||||||
this.updateQueryParams({ assignmentType });
|
|
||||||
}
|
|
||||||
|
|
||||||
updateTracks = (event) => {
|
|
||||||
const selectedTrackItem = this.props.tracks.find(x => x.name === event);
|
|
||||||
let selectedTrackSlug = null;
|
|
||||||
if (selectedTrackItem) {
|
|
||||||
selectedTrackSlug = selectedTrackItem.slug;
|
|
||||||
}
|
|
||||||
this.props.getUserGrades(
|
|
||||||
this.props.courseId,
|
|
||||||
this.props.selectedCohort,
|
|
||||||
selectedTrackSlug,
|
|
||||||
this.props.selectedAssignmentType,
|
|
||||||
);
|
|
||||||
this.updateQueryParams({ track: selectedTrackSlug });
|
|
||||||
};
|
|
||||||
|
|
||||||
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.courseId,
|
|
||||||
selectedCohortId,
|
|
||||||
this.props.selectedTrack,
|
|
||||||
this.props.selectedAssignmentType,
|
|
||||||
);
|
|
||||||
this.updateQueryParams({ cohort: selectedCohortId });
|
|
||||||
};
|
|
||||||
|
|
||||||
// At present, we don't store label and value in google analytics. By setting the label
|
|
||||||
// property of the below events, I want to verify that we can set the label of google anlatyics
|
|
||||||
// The following properties of a google analytics event are:
|
|
||||||
// category (used), name(used), lavel(not used), value(not used)
|
|
||||||
handleClickExportGrades = () => {
|
|
||||||
this.props.downloadBulkGradesReport(this.props.courseId);
|
|
||||||
window.location = this.props.gradeExportUrl;
|
|
||||||
};
|
|
||||||
|
|
||||||
handleClickDownloadInterventions = () => {
|
|
||||||
this.props.downloadInterventionReport(this.props.courseId);
|
|
||||||
window.location = this.props.interventionExportUrl;
|
|
||||||
};
|
|
||||||
|
|
||||||
handleClickImportGrades = () => {
|
|
||||||
const fileInput = this.fileInputRef.current;
|
|
||||||
if (fileInput) {
|
|
||||||
fileInput.click();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleFileInputChange = (event) => {
|
|
||||||
const fileInput = event.target;
|
|
||||||
const file = fileInput.files[0];
|
|
||||||
const form = this.fileFormRef.current;
|
|
||||||
if (file && form) {
|
|
||||||
const formData = new FormData(form);
|
|
||||||
this.props.submitFileUploadFormData(this.props.courseId, formData).then(() => {
|
|
||||||
fileInput.value = null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleSubmitAssignmentGrade = (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const {
|
|
||||||
assignmentGradeMin,
|
|
||||||
assignmentGradeMax,
|
|
||||||
} = this.state;
|
|
||||||
|
|
||||||
this.props.updateAssignmentLimits(assignmentGradeMin, assignmentGradeMax);
|
|
||||||
this.props.getUserGrades(
|
|
||||||
this.props.courseId,
|
|
||||||
this.props.selectedCohort,
|
|
||||||
this.props.selectedTrack,
|
|
||||||
this.props.selectedAssignmentType,
|
|
||||||
);
|
|
||||||
this.updateQueryParams({ assignmentGradeMin, assignmentGradeMax });
|
|
||||||
};
|
|
||||||
|
|
||||||
handleMinAssigGradeChange = assignmentGradeMin => this.setState({ assignmentGradeMin });
|
|
||||||
|
|
||||||
handleMaxAssigGradeChange = assignmentGradeMax => this.setState({ assignmentGradeMax });
|
|
||||||
|
|
||||||
mapSelectedCohortEntry = (entry) => {
|
|
||||||
const selectedCohortEntry = this.props.cohorts.find(x => x.id === parseInt(entry, 10));
|
|
||||||
if (selectedCohortEntry) {
|
|
||||||
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 || 0).toFixed(DECIMAL_PRECISION));
|
|
||||||
|
|
||||||
formatter = {
|
|
||||||
percent: (entries, areGradesFrozen) => entries.map((entry) => {
|
|
||||||
const learnerInformation = this.getLearnerInformation(entry);
|
|
||||||
const results = {
|
|
||||||
Username: (
|
|
||||||
<div><span className="wrap-text-in-cell">{learnerInformation}</span></div>
|
|
||||||
),
|
|
||||||
Email: (
|
|
||||||
<span className="wrap-text-in-cell">{entry.email}</span>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
const assignments = entry.section_breakdown
|
|
||||||
.reduce((acc, subsection) => {
|
|
||||||
if (areGradesFrozen) {
|
|
||||||
acc[subsection.label] = `${this.roundGrade(subsection.percent * 100)} %`;
|
|
||||||
} else {
|
|
||||||
acc[subsection.label] = (
|
|
||||||
<button
|
|
||||||
className="btn btn-header link-style grade-button"
|
|
||||||
onClick={() => this.setNewModalState(entry, subsection)}
|
|
||||||
>
|
|
||||||
{this.roundGrade(subsection.percent * 100)}%
|
|
||||||
</button>);
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
const totals = { Total: `${this.roundGrade(entry.percent * 100)}%` };
|
|
||||||
return Object.assign(results, assignments, totals);
|
|
||||||
}),
|
|
||||||
|
|
||||||
absolute: (entries, areGradesFrozen) => entries.map((entry) => {
|
|
||||||
const learnerInformation = this.getLearnerInformation(entry);
|
|
||||||
const results = {
|
|
||||||
Username: (
|
|
||||||
<div><span className="wrap-text-in-cell">{learnerInformation}</span></div>
|
|
||||||
),
|
|
||||||
Email: (
|
|
||||||
<span className="wrap-text-in-cell">{entry.email}</span>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
const assignments = entry.section_breakdown
|
|
||||||
.reduce((acc, subsection) => {
|
|
||||||
const scoreEarned = this.roundGrade(subsection.score_earned);
|
|
||||||
const scorePossible = this.roundGrade(subsection.score_possible);
|
|
||||||
let label = `${scoreEarned}`;
|
|
||||||
if (subsection.attempted) {
|
|
||||||
label = `${scoreEarned}/${scorePossible}`;
|
|
||||||
}
|
|
||||||
if (areGradesFrozen) {
|
|
||||||
acc[subsection.label] = label;
|
|
||||||
} else {
|
|
||||||
acc[subsection.label] = (
|
|
||||||
<button
|
|
||||||
className="btn btn-header link-style"
|
|
||||||
onClick={() => this.setNewModalState(entry, subsection)}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
const totals = { Total: `${this.roundGrade(entry.percent * 100)}/100` };
|
|
||||||
return Object.assign(results, assignments, totals);
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
lmsInstructorDashboardUrl = courseId => `${configuration.LMS_BASE_URL}/courses/${courseId}/instructor`;
|
|
||||||
|
|
||||||
formatHeadings = () => {
|
|
||||||
let headings = [...this.props.headings];
|
|
||||||
|
|
||||||
if (headings.length > 0) {
|
|
||||||
const userInformationHeadingLabel = (
|
|
||||||
<div>
|
|
||||||
<div>Username</div>
|
|
||||||
<div className="font-weight-normal student-key">Student Key*</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
const emailHeadingLabel = 'Email*';
|
|
||||||
|
|
||||||
headings = headings.map(heading => ({ label: heading, key: heading, width: 'col' }));
|
|
||||||
|
|
||||||
// replace username heading label to include additional user data
|
|
||||||
headings[0].label = userInformationHeadingLabel;
|
|
||||||
headings[0].width = 'col-2';
|
|
||||||
headings[1].label = emailHeadingLabel;
|
|
||||||
headings[1].width = 'col-2';
|
|
||||||
}
|
|
||||||
|
|
||||||
return headings;
|
|
||||||
}
|
|
||||||
|
|
||||||
handleCourseGradeFilterChange = (type, value) => {
|
|
||||||
const filterValue = value;
|
|
||||||
|
|
||||||
if (type === 'min') {
|
|
||||||
this.setState({
|
|
||||||
courseGradeMin: filterValue,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.setState({
|
|
||||||
courseGradeMax: filterValue,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleCourseGradeFilterApplyButtonClick = () => {
|
|
||||||
const { courseGradeMin, courseGradeMax } = this.state;
|
|
||||||
const isMinValid = this.isGradeFilterValueInRange(courseGradeMin);
|
|
||||||
const isMaxValid = this.isGradeFilterValueInRange(courseGradeMax);
|
|
||||||
|
|
||||||
this.setState({
|
|
||||||
isMinCourseGradeFilterValid: isMinValid,
|
|
||||||
isMaxCourseGradeFilterValid: isMaxValid,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isMinValid && isMaxValid) {
|
|
||||||
this.props.updateCourseGradeFilter(
|
|
||||||
courseGradeMin,
|
|
||||||
courseGradeMax,
|
|
||||||
this.props.courseId,
|
|
||||||
);
|
|
||||||
this.props.getUserGrades(
|
|
||||||
this.props.courseId,
|
|
||||||
this.props.selectedCohort,
|
|
||||||
this.props.selectedTrack,
|
|
||||||
this.props.selectedAssignmentType,
|
|
||||||
{
|
|
||||||
courseGradeMin,
|
|
||||||
courseGradeMax,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
this.updateQueryParams({ courseGradeMin, courseGradeMax });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isGradeFilterValueInRange = (value) => {
|
|
||||||
const valueAsInt = parseInt(value, 10);
|
|
||||||
return valueAsInt >= 0 && valueAsInt <= 100;
|
|
||||||
};
|
|
||||||
|
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<Drawer
|
|
||||||
mainContent={toggleFilterDrawer => (
|
|
||||||
<div className="px-3 gradebook-content">
|
|
||||||
<a
|
|
||||||
href={this.lmsInstructorDashboardUrl(this.props.courseId)}
|
|
||||||
className="mb-3"
|
|
||||||
>
|
|
||||||
<span aria-hidden="true">{'<< '}</span> {'Back to Dashboard'}
|
|
||||||
</a>
|
|
||||||
<h1>Gradebook</h1>
|
|
||||||
<h3> {this.props.courseId}</h3>
|
|
||||||
{this.props.areGradesFrozen &&
|
|
||||||
<div className="alert alert-warning" role="alert" >
|
|
||||||
The grades for this course are now frozen. Editing of grades is no longer allowed.
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
{(this.props.canUserViewGradebook === false) &&
|
|
||||||
<div className="alert alert-warning" role="alert" >
|
|
||||||
You are not authorized to view the gradebook for this course.
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<Tabs labels={this.getActiveTabs()}>
|
|
||||||
<div>
|
|
||||||
<h4>Step 1: Filter the Grade Report</h4>
|
|
||||||
<div className="d-flex justify-content-between" >
|
|
||||||
{this.props.showSpinner && <div className="spinner-overlay"><Icon className="fa fa-spinner fa-spin fa-5x color-black" /></div>}
|
|
||||||
<Button className="btn-primary align-self-start" onClick={toggleFilterDrawer}><FontAwesomeIcon icon={faFilter} /> Edit Filters</Button>
|
|
||||||
<div>
|
|
||||||
<SearchField
|
|
||||||
onSubmit={value =>
|
|
||||||
this.props.searchForUser(
|
|
||||||
this.props.courseId,
|
|
||||||
value,
|
|
||||||
this.props.selectedCohort,
|
|
||||||
this.props.selectedTrack,
|
|
||||||
this.props.selectedAssignmentType,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
inputLabel="Search for a learner"
|
|
||||||
onChange={filterValue => this.setState({ filterValue })}
|
|
||||||
onClear={() =>
|
|
||||||
this.props.getUserGrades(
|
|
||||||
this.props.courseId,
|
|
||||||
this.props.selectedCohort,
|
|
||||||
this.props.selectedTrack,
|
|
||||||
this.props.selectedAssignmentType,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
value={this.state.filterValue}
|
|
||||||
/>
|
|
||||||
<small className="form-text text-muted search-help-text">Search by username, email, or student key</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ConnectedFilterBadges
|
|
||||||
handleFilterBadgeClose={this.handleFilterBadgeClose}
|
|
||||||
/>
|
|
||||||
<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.closeBanner()}
|
|
||||||
open={this.props.showSuccess}
|
|
||||||
/>
|
|
||||||
<StatusAlert
|
|
||||||
alertType="danger"
|
|
||||||
dialog={this.getCourseGradeFilterAlertDialog()}
|
|
||||||
dismissible={false}
|
|
||||||
open={
|
|
||||||
!this.state.isMinCourseGradeFilterValid ||
|
|
||||||
!this.state.isMaxCourseGradeFilterValid
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<h4>Step 2: View or Modify Individual Grades</h4>
|
|
||||||
{this.props.totalUsersCount ?
|
|
||||||
<div>
|
|
||||||
Showing
|
|
||||||
<span className="font-weight-bold"> {this.props.filteredUsersCount} </span>
|
|
||||||
of
|
|
||||||
<span className="font-weight-bold"> {this.props.totalUsersCount} </span>
|
|
||||||
total learners
|
|
||||||
</div> :
|
|
||||||
null
|
|
||||||
}
|
|
||||||
<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 && (
|
|
||||||
<div>
|
|
||||||
<StatefulButton
|
|
||||||
buttonType="outline-primary"
|
|
||||||
onClick={this.handleClickExportGrades}
|
|
||||||
state={this.props.showSpinner ? 'pending' : 'default'}
|
|
||||||
labels={{
|
|
||||||
default: 'Bulk Management',
|
|
||||||
pending: 'Bulk Management',
|
|
||||||
}}
|
|
||||||
icons={{
|
|
||||||
default: <FontAwesomeIcon className="mr-2" icon={faDownload} />,
|
|
||||||
pending: <FontAwesomeIcon className="fa-spin mr-2" icon={faSpinner} />,
|
|
||||||
}}
|
|
||||||
disabledStates={['pending']}
|
|
||||||
/>
|
|
||||||
<StatefulButton
|
|
||||||
buttonType="outline-primary"
|
|
||||||
onClick={this.handleClickDownloadInterventions}
|
|
||||||
state={this.props.showSpinner ? 'pending' : 'default'}
|
|
||||||
className="ml-2"
|
|
||||||
labels={{
|
|
||||||
default: 'Interventions*',
|
|
||||||
pending: 'Interventions*',
|
|
||||||
}}
|
|
||||||
icons={{
|
|
||||||
default: <FontAwesomeIcon className="mr-2" icon={faDownload} />,
|
|
||||||
pending: <FontAwesomeIcon className="fa-spin mr-2" icon={faSpinner} />,
|
|
||||||
}}
|
|
||||||
disabledStates={['pending']}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="gradebook-container">
|
|
||||||
<div className="gbook">
|
|
||||||
<Table
|
|
||||||
columns={this.formatHeadings()}
|
|
||||||
data={this.formatter[this.props.format](
|
|
||||||
this.props.grades,
|
|
||||||
this.props.areGradesFrozen,
|
|
||||||
)}
|
|
||||||
rowHeaderColumnKey="username"
|
|
||||||
hasFixedColumnWidths
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{PageButtons(this.props)}
|
|
||||||
<p>* available for learners in the Master's track only</p>
|
|
||||||
<Modal
|
|
||||||
open={this.state.modalOpen}
|
|
||||||
title="Edit Grades"
|
|
||||||
closeText="Cancel"
|
|
||||||
body={(
|
|
||||||
<div>
|
|
||||||
<div>
|
|
||||||
<div className="grade-history-header grade-history-assignment">Assignment: </div> <div>{this.state.modalAssignmentName}</div>
|
|
||||||
<div className="grade-history-header grade-history-student">Student: </div> <div>{this.state.updateUserName}</div>
|
|
||||||
<div className="grade-history-header grade-history-original-grade">Original Grade: </div> <div>{this.props.gradeOriginalEarnedGraded}</div>
|
|
||||||
<div className="grade-history-header grade-history-current-grade">Current Grade: </div> <div>{this.props.gradeOverrideCurrentEarnedGradedOverride}</div>
|
|
||||||
</div>
|
|
||||||
<StatusAlert
|
|
||||||
alertType="danger"
|
|
||||||
dialog="Error retrieving grade override history."
|
|
||||||
open={this.props.errorFetchingGradeOverrideHistory}
|
|
||||||
dismissible={false}
|
|
||||||
/>
|
|
||||||
{!this.props.errorFetchingGradeOverrideHistory && (
|
|
||||||
<Table
|
|
||||||
columns={GRADE_OVERRIDE_HISTORY_COLUMNS}
|
|
||||||
data={[...this.props.gradeOverrides, {
|
|
||||||
date: this.state.todaysDate,
|
|
||||||
reason: (<input
|
|
||||||
type="text"
|
|
||||||
name="reasonForChange"
|
|
||||||
value={this.state.reasonForChange}
|
|
||||||
onChange={value => this.onChange(value)}
|
|
||||||
ref={(input) => { this.overrideReasonInput = input; }}
|
|
||||||
/>),
|
|
||||||
adjustedGrade: (
|
|
||||||
<span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="adjustedGradeValue"
|
|
||||||
value={this.state.adjustedGradeValue}
|
|
||||||
onChange={value => this.onChange(value)}
|
|
||||||
/>
|
|
||||||
{(this.state.adjustedGradePossible
|
|
||||||
|| this.props.gradeOriginalPossibleGraded)
|
|
||||||
&& ' / '}
|
|
||||||
{this.state.adjustedGradePossible
|
|
||||||
|| this.props.gradeOriginalPossibleGraded}
|
|
||||||
</span>),
|
|
||||||
}]}
|
|
||||||
/>)}
|
|
||||||
|
|
||||||
<div>Showing most recent actions (max 5). To see more, please contact
|
|
||||||
support.
|
|
||||||
</div>
|
|
||||||
<div>Note: Once you save, your changes will be visible to students.</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
buttons={[
|
|
||||||
<Button
|
|
||||||
buttonType="primary"
|
|
||||||
onClick={this.handleAdjustedGradeClick}
|
|
||||||
>
|
|
||||||
Save Grade
|
|
||||||
</Button>,
|
|
||||||
]}
|
|
||||||
onClose={this.closeAssignmentModal}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{this.props.showBulkManagement && (
|
|
||||||
<div>
|
|
||||||
<h4>Use this feature by downloading a CSV for bulk management,
|
|
||||||
overriding grades locally, and coming back here to upload.
|
|
||||||
</h4>
|
|
||||||
<form ref={this.fileFormRef} action={this.props.gradeExportUrl} method="post">
|
|
||||||
<StatusAlert
|
|
||||||
alertType="danger"
|
|
||||||
dialog={this.props.bulkImportError}
|
|
||||||
open={this.props.bulkImportError}
|
|
||||||
dismissible={false}
|
|
||||||
/>
|
|
||||||
<StatusAlert
|
|
||||||
alertType="success"
|
|
||||||
dialog="CSV processing. File uploads may take several minutes to complete"
|
|
||||||
open={this.props.uploadSuccess}
|
|
||||||
dismissible={false}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
className="d-none"
|
|
||||||
type="file"
|
|
||||||
name="csv"
|
|
||||||
label="Upload Grade CSV"
|
|
||||||
onChange={this.handleFileInputChange}
|
|
||||||
ref={this.fileInputRef}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
<Button
|
|
||||||
buttonType="primary"
|
|
||||||
onClick={this.handleClickImportGrades}
|
|
||||||
>
|
|
||||||
Import Grades
|
|
||||||
</Button>
|
|
||||||
<p>
|
|
||||||
Results appear in the table below.<br />
|
|
||||||
Grade processing may take a few seconds.
|
|
||||||
</p>
|
|
||||||
<Table
|
|
||||||
data={this.props.bulkManagementHistory.map(this.formatHistoryRow)}
|
|
||||||
hasFixedColumnWidths
|
|
||||||
columns={[
|
|
||||||
{
|
|
||||||
key: 'filename',
|
|
||||||
label: 'Gradebook',
|
|
||||||
columnSortable: false,
|
|
||||||
width: 'col-5',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'resultsSummary',
|
|
||||||
label: 'Download Summary',
|
|
||||||
columnSortable: false,
|
|
||||||
width: 'col',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'user',
|
|
||||||
label: 'Who',
|
|
||||||
columnSortable: false,
|
|
||||||
width: 'col-1',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'timeUploaded',
|
|
||||||
label: 'When',
|
|
||||||
columnSortable: false,
|
|
||||||
width: 'col',
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
className="table-striped"
|
|
||||||
/>
|
|
||||||
</div>)}
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
initiallyOpen={false}
|
|
||||||
title={
|
|
||||||
<React.Fragment>
|
|
||||||
<FontAwesomeIcon icon={faFilter} /> Filter By...
|
|
||||||
</React.Fragment>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Collapsible title="Assignments" isOpen className="filter-group mb-3">
|
|
||||||
<div>
|
|
||||||
<div className="student-filters">
|
|
||||||
<span className="label">
|
|
||||||
Assignment Types:
|
|
||||||
</span>
|
|
||||||
<InputSelect
|
|
||||||
name="assignment-types"
|
|
||||||
aria-label="Assignment Types"
|
|
||||||
value={this.props.selectedAssignmentType}
|
|
||||||
options={this.mapAssignmentTypeEntries(this.props.assignmentTypes)}
|
|
||||||
onChange={this.updateAssignmentTypes}
|
|
||||||
disabled={this.props.assignmentFilterOptions.length === 0}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="student-filters">
|
|
||||||
<span className="label">
|
|
||||||
Assignment:
|
|
||||||
</span>
|
|
||||||
<InputSelect
|
|
||||||
name="assignment"
|
|
||||||
aria-label="Assignment"
|
|
||||||
value={this.props.selectedAssignment}
|
|
||||||
options={this.getAssignmentFilterOptions()}
|
|
||||||
onChange={this.handleAssignmentFilterChange}
|
|
||||||
disabled={this.props.assignmentFilterOptions.length === 0}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p>Grade Range (0% - 100%)</p>
|
|
||||||
<form className="d-flex justify-content-between align-items-center" onSubmit={this.handleSubmitAssignmentGrade}>
|
|
||||||
<InputText
|
|
||||||
label="Min Grade"
|
|
||||||
name="assignmentGradeMin"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
step={1}
|
|
||||||
value={this.state.assignmentGradeMin}
|
|
||||||
disabled={!this.props.selectedAssignment}
|
|
||||||
onChange={this.handleMinAssigGradeChange}
|
|
||||||
/>
|
|
||||||
<span className="input-percent-label">%</span>
|
|
||||||
<InputText
|
|
||||||
label="Max Grade"
|
|
||||||
name="assignmentGradeMax"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
step={1}
|
|
||||||
value={this.state.assignmentGradeMax}
|
|
||||||
disabled={!this.props.selectedAssignment}
|
|
||||||
onChange={this.handleMaxAssigGradeChange}
|
|
||||||
/>
|
|
||||||
<span className="input-percent-label">%</span>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
className="btn-outline-secondary"
|
|
||||||
name="assignmentGradeMinMax"
|
|
||||||
disabled={!this.props.selectedAssignment}
|
|
||||||
>
|
|
||||||
Apply
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</Collapsible>
|
|
||||||
<Collapsible title="Overall Grade" isOpen className="filter-group mb-3">
|
|
||||||
<div className="d-flex justify-content-between align-items-center">
|
|
||||||
<InputText
|
|
||||||
value={this.state.courseGradeMin}
|
|
||||||
name="minimum-grade"
|
|
||||||
label="Min Grade"
|
|
||||||
onChange={value => this.handleCourseGradeFilterChange('min', value)}
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
/>
|
|
||||||
<span className="input-percent-label">%</span>
|
|
||||||
<InputText
|
|
||||||
value={this.state.courseGradeMax}
|
|
||||||
name="max-grade"
|
|
||||||
label="Max Grade"
|
|
||||||
onChange={value => this.handleCourseGradeFilterChange('max', value)}
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
/>
|
|
||||||
<span className="input-percent-label">%</span>
|
|
||||||
<Button
|
|
||||||
buttonType="outline-secondary"
|
|
||||||
onClick={this.handleCourseGradeFilterApplyButtonClick}
|
|
||||||
>
|
|
||||||
Apply
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Collapsible>
|
|
||||||
<Collapsible title="Student Groups" isOpen className="filter-group mb-3">
|
|
||||||
<InputSelect
|
|
||||||
name="Tracks"
|
|
||||||
aria-label="Tracks"
|
|
||||||
disabled={this.props.tracks.length === 0}
|
|
||||||
value={this.mapSelectedTrackEntry(this.props.selectedTrack)}
|
|
||||||
options={this.mapTracksEntries(this.props.tracks)}
|
|
||||||
onChange={this.updateTracks}
|
|
||||||
/>
|
|
||||||
<InputSelect
|
|
||||||
name="Cohorts"
|
|
||||||
aria-label="Cohorts"
|
|
||||||
disabled={this.props.cohorts.length === 0}
|
|
||||||
value={this.mapSelectedCohortEntry(this.props.selectedCohort)}
|
|
||||||
options={this.mapCohortsEntries(this.props.cohorts)}
|
|
||||||
onChange={this.updateCohorts}
|
|
||||||
/>
|
|
||||||
</Collapsible>
|
|
||||||
</Drawer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Gradebook.defaultProps = {
|
|
||||||
areGradesFrozen: false,
|
|
||||||
assignmentTypes: [],
|
|
||||||
assignmentFilterOptions: [],
|
|
||||||
canUserViewGradebook: false,
|
|
||||||
cohorts: [],
|
|
||||||
grades: [],
|
|
||||||
gradeOverrides: [],
|
|
||||||
gradeOverrideCurrentEarnedGradedOverride: null,
|
|
||||||
gradeOriginalEarnedGraded: null,
|
|
||||||
gradeOriginalPossibleGraded: null,
|
|
||||||
location: {
|
|
||||||
search: '',
|
|
||||||
},
|
|
||||||
courseId: '',
|
|
||||||
selectedCohort: null,
|
|
||||||
selectedTrack: null,
|
|
||||||
selectedAssignmentType: '',
|
|
||||||
selectedAssignment: '',
|
|
||||||
showSpinner: false,
|
|
||||||
tracks: [],
|
|
||||||
bulkImportError: '',
|
|
||||||
uploadSuccess: false,
|
|
||||||
showBulkManagement: false,
|
|
||||||
bulkManagementHistory: [],
|
|
||||||
errorFetchingGradeOverrideHistory: false,
|
|
||||||
totalUsersCount: null,
|
|
||||||
filteredUsersCount: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
Gradebook.propTypes = {
|
|
||||||
areGradesFrozen: PropTypes.bool,
|
|
||||||
assignmentTypes: PropTypes.arrayOf(PropTypes.string),
|
|
||||||
assignmentFilterOptions: PropTypes.arrayOf(PropTypes.shape({
|
|
||||||
label: PropTypes.string,
|
|
||||||
subsectionLabel: PropTypes.string,
|
|
||||||
})),
|
|
||||||
canUserViewGradebook: PropTypes.bool,
|
|
||||||
cohorts: PropTypes.arrayOf(PropTypes.shape({
|
|
||||||
name: PropTypes.string,
|
|
||||||
id: PropTypes.number,
|
|
||||||
})),
|
|
||||||
filterAssignmentType: PropTypes.func.isRequired,
|
|
||||||
updateAssignmentFilter: PropTypes.func.isRequired,
|
|
||||||
updateAssignmentLimits: PropTypes.func.isRequired,
|
|
||||||
format: PropTypes.string.isRequired,
|
|
||||||
getRoles: PropTypes.func.isRequired,
|
|
||||||
getUserGrades: PropTypes.func.isRequired,
|
|
||||||
fetchGradeOverrideHistory: PropTypes.func.isRequired,
|
|
||||||
grades: PropTypes.arrayOf(PropTypes.shape({
|
|
||||||
percent: PropTypes.number,
|
|
||||||
section_breakdown: PropTypes.arrayOf(PropTypes.shape({
|
|
||||||
attempted: PropTypes.bool,
|
|
||||||
category: PropTypes.string,
|
|
||||||
label: PropTypes.string,
|
|
||||||
module_id: PropTypes.string,
|
|
||||||
percent: PropTypes.number,
|
|
||||||
scoreEarned: PropTypes.number,
|
|
||||||
scorePossible: PropTypes.number,
|
|
||||||
subsection_name: PropTypes.string,
|
|
||||||
})),
|
|
||||||
user_id: PropTypes.number,
|
|
||||||
user_name: PropTypes.string,
|
|
||||||
})),
|
|
||||||
gradeOverrides: PropTypes.arrayOf(PropTypes.shape({
|
|
||||||
date: PropTypes.string,
|
|
||||||
grader: PropTypes.string,
|
|
||||||
reason: PropTypes.string,
|
|
||||||
adjustedGrade: PropTypes.number,
|
|
||||||
})),
|
|
||||||
gradeOverrideCurrentEarnedGradedOverride: PropTypes.number,
|
|
||||||
gradeOriginalEarnedGraded: PropTypes.number,
|
|
||||||
gradeOriginalPossibleGraded: PropTypes.number,
|
|
||||||
doneViewingAssignment: PropTypes.func.isRequired,
|
|
||||||
headings: PropTypes.arrayOf(PropTypes.string).isRequired,
|
|
||||||
history: PropTypes.shape({
|
|
||||||
push: PropTypes.func,
|
|
||||||
}).isRequired,
|
|
||||||
location: PropTypes.shape({
|
|
||||||
search: PropTypes.string,
|
|
||||||
}),
|
|
||||||
courseId: PropTypes.string,
|
|
||||||
searchForUser: PropTypes.func.isRequired,
|
|
||||||
selectedAssignmentType: PropTypes.string,
|
|
||||||
selectedAssignment: PropTypes.string,
|
|
||||||
selectedCohort: PropTypes.string,
|
|
||||||
selectedTrack: PropTypes.string,
|
|
||||||
resetFilters: PropTypes.func.isRequired,
|
|
||||||
showSpinner: PropTypes.bool,
|
|
||||||
showSuccess: PropTypes.bool.isRequired,
|
|
||||||
toggleFormat: PropTypes.func.isRequired,
|
|
||||||
tracks: PropTypes.arrayOf(PropTypes.shape({
|
|
||||||
name: PropTypes.string,
|
|
||||||
})),
|
|
||||||
closeBanner: PropTypes.func.isRequired,
|
|
||||||
updateGrades: PropTypes.func.isRequired,
|
|
||||||
gradeExportUrl: PropTypes.string.isRequired,
|
|
||||||
interventionExportUrl: PropTypes.string.isRequired,
|
|
||||||
submitFileUploadFormData: PropTypes.func.isRequired,
|
|
||||||
bulkImportError: PropTypes.string,
|
|
||||||
uploadSuccess: PropTypes.bool,
|
|
||||||
errorFetchingGradeOverrideHistory: PropTypes.bool,
|
|
||||||
showBulkManagement: PropTypes.bool,
|
|
||||||
bulkManagementHistory: PropTypes.arrayOf(PropTypes.shape({
|
|
||||||
originalFilename: PropTypes.string.isRequired,
|
|
||||||
user: PropTypes.string.isRequired,
|
|
||||||
timeUploaded: PropTypes.string.isRequired,
|
|
||||||
summaryOfRowsProcessed: PropTypes.shape({
|
|
||||||
total: PropTypes.number.isRequired,
|
|
||||||
successfullyProcessed: PropTypes.number.isRequired,
|
|
||||||
failed: PropTypes.number.isRequired,
|
|
||||||
skipped: PropTypes.number.isRequired,
|
|
||||||
}).isRequired,
|
|
||||||
})),
|
|
||||||
totalUsersCount: PropTypes.number,
|
|
||||||
filteredUsersCount: PropTypes.number,
|
|
||||||
initializeFilters: PropTypes.func.isRequired,
|
|
||||||
updateGradesIfAssignmentGradeFiltersSet: PropTypes.func.isRequired,
|
|
||||||
updateCourseGradeFilter: PropTypes.func.isRequired,
|
|
||||||
downloadBulkGradesReport: PropTypes.func.isRequired,
|
|
||||||
downloadInterventionReport: PropTypes.func.isRequired,
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`AssignmentFilter Component snapshots basic snapshot 1`] = `
|
||||||
|
<div
|
||||||
|
className="student-filters"
|
||||||
|
>
|
||||||
|
<SelectGroup
|
||||||
|
disabled={false}
|
||||||
|
id="assignment"
|
||||||
|
label={
|
||||||
|
<FormattedMessage
|
||||||
|
defaultMessage="Assignment"
|
||||||
|
description="Assignment filter select label in Gradebook Filters"
|
||||||
|
id="gradebook.GradebookFilters.assignmentFilterLabel"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onChange={[MockFunction handleChange]}
|
||||||
|
options={
|
||||||
|
Array [
|
||||||
|
<option
|
||||||
|
value=""
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</option>,
|
||||||
|
<option
|
||||||
|
value="assgN1"
|
||||||
|
>
|
||||||
|
assgN1
|
||||||
|
:
|
||||||
|
subLabel1
|
||||||
|
</option>,
|
||||||
|
<option
|
||||||
|
value="assgN2"
|
||||||
|
>
|
||||||
|
assgN2
|
||||||
|
:
|
||||||
|
subLabel2
|
||||||
|
</option>,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
value="assgN1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
98
src/components/GradebookFilters/AssignmentFilter/index.jsx
Normal file
98
src/components/GradebookFilters/AssignmentFilter/index.jsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
/* eslint-disable react/sort-comp, react/button-has-type */
|
||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
|
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
|
import selectors from 'data/selectors';
|
||||||
|
import actions from 'data/actions';
|
||||||
|
import thunkActions from 'data/thunkActions';
|
||||||
|
|
||||||
|
import messages from '../messages';
|
||||||
|
import SelectGroup from '../SelectGroup';
|
||||||
|
|
||||||
|
const { fetchGradesIfAssignmentGradeFiltersSet } = thunkActions.grades;
|
||||||
|
|
||||||
|
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(
|
||||||
|
({ label }) => label === assignment,
|
||||||
|
);
|
||||||
|
const { type, id } = selectedFilterOption || {};
|
||||||
|
const typedValue = { label: assignment, type, id };
|
||||||
|
this.props.updateAssignmentFilter(typedValue);
|
||||||
|
this.props.updateQueryParams({ assignment: id });
|
||||||
|
this.props.fetchGradesIfAssignmentGradeFiltersSet();
|
||||||
|
}
|
||||||
|
|
||||||
|
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={<FormattedMessage {...messages.assignment} />}
|
||||||
|
value={this.props.selectedAssignment}
|
||||||
|
onChange={this.handleChange}
|
||||||
|
disabled={this.props.assignmentFilterOptions.length === 0}
|
||||||
|
options={this.options}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AssignmentFilter.defaultProps = {
|
||||||
|
assignmentFilterOptions: [],
|
||||||
|
selectedAssignment: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
AssignmentFilter.propTypes = {
|
||||||
|
updateQueryParams: PropTypes.func.isRequired,
|
||||||
|
// redux
|
||||||
|
assignmentFilterOptions: PropTypes.arrayOf(PropTypes.shape({
|
||||||
|
label: PropTypes.string,
|
||||||
|
subsectionLabel: PropTypes.string,
|
||||||
|
type: PropTypes.string,
|
||||||
|
id: PropTypes.string,
|
||||||
|
})),
|
||||||
|
selectedAssignment: PropTypes.string,
|
||||||
|
fetchGradesIfAssignmentGradeFiltersSet: 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: actions.filters.update.assignment,
|
||||||
|
fetchGradesIfAssignmentGradeFiltersSet,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(mapStateToProps, mapDispatchToProps)(AssignmentFilter);
|
||||||
162
src/components/GradebookFilters/AssignmentFilter/test.jsx
Normal file
162
src/components/GradebookFilters/AssignmentFilter/test.jsx
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { mount, shallow } from 'enzyme';
|
||||||
|
|
||||||
|
import selectors from 'data/selectors';
|
||||||
|
import actions from 'data/actions';
|
||||||
|
import { fetchGradesIfAssignmentGradeFiltersSet } from 'data/thunkActions/grades';
|
||||||
|
import {
|
||||||
|
AssignmentFilter,
|
||||||
|
mapStateToProps,
|
||||||
|
mapDispatchToProps,
|
||||||
|
} from '.';
|
||||||
|
|
||||||
|
jest.mock('data/thunkActions/grades', () => ({
|
||||||
|
updateGradesIfAssignmentGradeFiltersSet: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
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 = {
|
||||||
|
assignmentFilterOptions: [
|
||||||
|
{
|
||||||
|
label: 'assgN1',
|
||||||
|
subsectionLabel: 'subLabel1',
|
||||||
|
type: 'assgn_Type1',
|
||||||
|
id: 'assgn_iD1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'assgN2',
|
||||||
|
subsectionLabel: 'subLabel2',
|
||||||
|
type: 'assgn_Type2',
|
||||||
|
id: 'assgn_iD2',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectedAssignment: 'assgN1',
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
props = {
|
||||||
|
...props,
|
||||||
|
updateQueryParams: jest.fn(),
|
||||||
|
fetchGradesIfAssignmentGradeFiltersSet: 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.fetchGradesIfAssignmentGradeFiltersSet', () => {
|
||||||
|
const method = props.fetchGradesIfAssignmentGradeFiltersSet;
|
||||||
|
expect(method).toHaveBeenCalledWith();
|
||||||
|
});
|
||||||
|
describe('no selected option', () => {
|
||||||
|
const value = 'fake';
|
||||||
|
beforeEach(() => {
|
||||||
|
el = mount(<AssignmentFilter {...props} />);
|
||||||
|
el.instance().handleChange({ target: { value } });
|
||||||
|
});
|
||||||
|
it('calls props.updateAssignmentFilter with selection', () => {
|
||||||
|
expect(props.updateAssignmentFilter).toHaveBeenCalledWith({
|
||||||
|
label: value,
|
||||||
|
type: undefined,
|
||||||
|
id: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('calls props.updateQueryParams with selected assignment id',
|
||||||
|
() => {
|
||||||
|
expect(props.updateQueryParams).toHaveBeenCalledWith({
|
||||||
|
assignment: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('calls props.fetchGradesIfAssignmentGradeFiltersSet', () => {
|
||||||
|
const method = props.fetchGradesIfAssignmentGradeFiltersSet;
|
||||||
|
expect(method).toHaveBeenCalledWith();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
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('mapDispatchToProps', () => {
|
||||||
|
test('updateAssignmentFilter', () => {
|
||||||
|
expect(mapDispatchToProps.updateAssignmentFilter).toEqual(
|
||||||
|
actions.filters.update.assignment,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test('fetchGradesIfAsssignmentGradeFiltersSet', () => {
|
||||||
|
const prop = mapDispatchToProps.fetchGradesIfAssignmentGradeFiltersSet;
|
||||||
|
expect(prop).toEqual(fetchGradesIfAssignmentGradeFiltersSet);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// 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={
|
||||||
|
<FormattedMessage
|
||||||
|
defaultMessage="Min Grade"
|
||||||
|
description="Min-grade filter select label in Gradebook Filters"
|
||||||
|
id="gradebook.GradebookFilters.minGradeFilterLabel"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onChange={[MockFunction handleSetMin]}
|
||||||
|
value="2"
|
||||||
|
/>
|
||||||
|
<PercentGroup
|
||||||
|
disabled={true}
|
||||||
|
id="assignmentGradeMax"
|
||||||
|
label={
|
||||||
|
<FormattedMessage
|
||||||
|
defaultMessage="Max Grade"
|
||||||
|
description="Max-grade filter select label in Gradebook Filters"
|
||||||
|
id="gradebook.GradebookFilters.maxGradeFilterLabel"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onChange={[MockFunction handleSetMax]}
|
||||||
|
value="98"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="grade-filter-action"
|
||||||
|
>
|
||||||
|
<ForwardRef
|
||||||
|
active={false}
|
||||||
|
disabled={true}
|
||||||
|
name="assignmentGradeMinMax"
|
||||||
|
onClick={[MockFunction handleSubmit]}
|
||||||
|
type="submit"
|
||||||
|
variant="outline-secondary"
|
||||||
|
>
|
||||||
|
Apply
|
||||||
|
</ForwardRef>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`AssignmentGradeFilter Component snapshots smoke test 1`] = `
|
||||||
|
<div
|
||||||
|
className="grade-filter-inputs"
|
||||||
|
>
|
||||||
|
<PercentGroup
|
||||||
|
disabled={false}
|
||||||
|
id="assignmentGradeMin"
|
||||||
|
label={
|
||||||
|
<FormattedMessage
|
||||||
|
defaultMessage="Min Grade"
|
||||||
|
description="Min-grade filter select label in Gradebook Filters"
|
||||||
|
id="gradebook.GradebookFilters.minGradeFilterLabel"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onChange={[MockFunction handleSetMin]}
|
||||||
|
value="2"
|
||||||
|
/>
|
||||||
|
<PercentGroup
|
||||||
|
disabled={false}
|
||||||
|
id="assignmentGradeMax"
|
||||||
|
label={
|
||||||
|
<FormattedMessage
|
||||||
|
defaultMessage="Max Grade"
|
||||||
|
description="Max-grade filter select label in Gradebook Filters"
|
||||||
|
id="gradebook.GradebookFilters.maxGradeFilterLabel"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onChange={[MockFunction handleSetMax]}
|
||||||
|
value="98"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="grade-filter-action"
|
||||||
|
>
|
||||||
|
<ForwardRef
|
||||||
|
active={false}
|
||||||
|
disabled={false}
|
||||||
|
name="assignmentGradeMinMax"
|
||||||
|
onClick={[MockFunction handleSubmit]}
|
||||||
|
type="submit"
|
||||||
|
variant="outline-secondary"
|
||||||
|
>
|
||||||
|
Apply
|
||||||
|
</ForwardRef>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
103
src/components/GradebookFilters/AssignmentGradeFilter/index.jsx
Normal file
103
src/components/GradebookFilters/AssignmentGradeFilter/index.jsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
/* eslint-disable react/sort-comp, react/button-has-type */
|
||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
|
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||||
|
import { Button } from '@edx/paragon';
|
||||||
|
|
||||||
|
import selectors from 'data/selectors';
|
||||||
|
import actions from 'data/actions';
|
||||||
|
import thunkActions from 'data/thunkActions';
|
||||||
|
|
||||||
|
import messages from '../messages';
|
||||||
|
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() {
|
||||||
|
this.props.updateAssignmentLimits(this.props.localAssignmentLimits);
|
||||||
|
this.props.fetchGrades();
|
||||||
|
this.props.updateQueryParams(this.props.localAssignmentLimits);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSetMax({ target: { value } }) {
|
||||||
|
this.props.setFilter({ assignmentGradeMax: value });
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSetMin({ target: { value } }) {
|
||||||
|
this.props.setFilter({ assignmentGradeMin: value });
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
localAssignmentLimits: { assignmentGradeMax, assignmentGradeMin },
|
||||||
|
} = this.props;
|
||||||
|
return (
|
||||||
|
<div className="grade-filter-inputs">
|
||||||
|
<PercentGroup
|
||||||
|
id="assignmentGradeMin"
|
||||||
|
label={<FormattedMessage {...messages.minGrade} />}
|
||||||
|
value={assignmentGradeMin}
|
||||||
|
disabled={!this.props.selectedAssignment}
|
||||||
|
onChange={this.handleSetMin}
|
||||||
|
/>
|
||||||
|
<PercentGroup
|
||||||
|
id="assignmentGradeMax"
|
||||||
|
label={<FormattedMessage {...messages.maxGrade} />}
|
||||||
|
value={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: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
AssignmentGradeFilter.propTypes = {
|
||||||
|
updateQueryParams: PropTypes.func.isRequired,
|
||||||
|
|
||||||
|
// redux
|
||||||
|
fetchGrades: PropTypes.func.isRequired,
|
||||||
|
localAssignmentLimits: PropTypes.shape({
|
||||||
|
assignmentGradeMax: PropTypes.string,
|
||||||
|
assignmentGradeMin: PropTypes.string,
|
||||||
|
}).isRequired,
|
||||||
|
selectedAssignment: PropTypes.string,
|
||||||
|
setFilter: PropTypes.func.isRequired,
|
||||||
|
updateAssignmentLimits: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mapStateToProps = (state) => ({
|
||||||
|
localAssignmentLimits: selectors.app.assignmentGradeLimits(state),
|
||||||
|
selectedAssignment: selectors.filters.selectedAssignmentLabel(state),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const mapDispatchToProps = {
|
||||||
|
fetchGrades: thunkActions.grades.fetchGrades,
|
||||||
|
setFilter: actions.app.setLocalFilter,
|
||||||
|
updateAssignmentLimits: actions.filters.update.assignmentLimits,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(mapStateToProps, mapDispatchToProps)(AssignmentGradeFilter);
|
||||||
143
src/components/GradebookFilters/AssignmentGradeFilter/test.jsx
Normal file
143
src/components/GradebookFilters/AssignmentGradeFilter/test.jsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { mount, shallow } from 'enzyme';
|
||||||
|
|
||||||
|
import actions from 'data/actions';
|
||||||
|
import selectors from 'data/selectors';
|
||||||
|
import { fetchGrades } from 'data/thunkActions/grades';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AssignmentGradeFilter,
|
||||||
|
mapStateToProps,
|
||||||
|
mapDispatchToProps,
|
||||||
|
} from '.';
|
||||||
|
|
||||||
|
jest.mock('data/selectors', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
app: {},
|
||||||
|
filters: {},
|
||||||
|
grades: {},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('data/thunkActions/grades', () => ({
|
||||||
|
fetchGrades: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('AssignmentGradeFilter', () => {
|
||||||
|
let props = {};
|
||||||
|
beforeEach(() => {
|
||||||
|
props = {
|
||||||
|
...props,
|
||||||
|
updateQueryParams: jest.fn(),
|
||||||
|
fetchGrades: jest.fn(),
|
||||||
|
localAssignmentLimits: {
|
||||||
|
assignmentGradeMax: '98',
|
||||||
|
assignmentGradeMin: '2',
|
||||||
|
},
|
||||||
|
selectedAssignment: 'Potions 101.5',
|
||||||
|
setFilter: jest.fn(),
|
||||||
|
updateAssignmentLimits: jest.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Component', () => {
|
||||||
|
describe('behavior', () => {
|
||||||
|
let el;
|
||||||
|
beforeEach(() => {
|
||||||
|
el = mount(<AssignmentGradeFilter {...props} />);
|
||||||
|
});
|
||||||
|
describe('handleSubmit', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
el.instance().handleSubmit();
|
||||||
|
});
|
||||||
|
it('calls props.updateAssignmentLimits with local assignment limits', () => {
|
||||||
|
expect(props.updateAssignmentLimits).toHaveBeenCalledWith(props.localAssignmentLimits);
|
||||||
|
});
|
||||||
|
it('calls fetchUserGrades', () => {
|
||||||
|
expect(props.fetchGrades).toHaveBeenCalledWith();
|
||||||
|
});
|
||||||
|
it('updates queryParams with assignment grade min and max', () => {
|
||||||
|
expect(props.updateQueryParams).toHaveBeenCalledWith(props.localAssignmentLimits);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('handleSetMin', () => {
|
||||||
|
it('calls setFilters for assignmentGradeMin', () => {
|
||||||
|
const testVal = 23;
|
||||||
|
el.instance().handleSetMin({ target: { value: testVal } });
|
||||||
|
expect(props.setFilter).toHaveBeenCalledWith({
|
||||||
|
assignmentGradeMin: testVal,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('handleSetMax', () => {
|
||||||
|
it('calls setFilters for assignmentGradeMax', () => {
|
||||||
|
const testVal = 92;
|
||||||
|
el.instance().handleSetMax({ target: { value: testVal } });
|
||||||
|
expect(props.setFilter).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 testState = { belle: 'in', the: 'castle' };
|
||||||
|
let mappedProps;
|
||||||
|
beforeEach(() => {
|
||||||
|
selectors.app.assignmentGradeLimits = jest.fn((state) => ({ gradeLimits: state }));
|
||||||
|
selectors.filters.selectedAssignmentLabel = jest.fn((state) => ({ assignmentLabel: state }));
|
||||||
|
mappedProps = mapStateToProps(testState);
|
||||||
|
});
|
||||||
|
describe('localAssignmentLimits', () => {
|
||||||
|
it('returns selectors.app.assignmentGradeLimits', () => {
|
||||||
|
expect(
|
||||||
|
mappedProps.localAssignmentLimits,
|
||||||
|
).toEqual(selectors.app.assignmentGradeLimits(testState));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('selectedAsssignment', () => {
|
||||||
|
it('returns selectors.filters.selectedAssignmentLabel', () => {
|
||||||
|
expect(
|
||||||
|
mappedProps.selectedAssignment,
|
||||||
|
).toEqual(selectors.filters.selectedAssignmentLabel(testState));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('mapDispatchToProps', () => {
|
||||||
|
test('fetchGrades', () => {
|
||||||
|
expect(mapDispatchToProps.fetchGrades).toEqual(fetchGrades);
|
||||||
|
});
|
||||||
|
test('setFilters', () => {
|
||||||
|
expect(mapDispatchToProps.setFilter).toEqual(actions.app.setLocalFilter);
|
||||||
|
});
|
||||||
|
test('updateAssignmentLimits', () => {
|
||||||
|
expect(
|
||||||
|
mapDispatchToProps.updateAssignmentLimits,
|
||||||
|
).toEqual(
|
||||||
|
actions.filters.update.assignmentLimits,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// 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={
|
||||||
|
<FormattedMessage
|
||||||
|
defaultMessage="Assignment Types"
|
||||||
|
description="Assignment Types filter select label in Gradebook Filters"
|
||||||
|
id="gradebook.GradebookFilters.assignmentTypesLabel"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
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={
|
||||||
|
<FormattedMessage
|
||||||
|
defaultMessage="Assignment Types"
|
||||||
|
description="Assignment Types filter select label in Gradebook Filters"
|
||||||
|
id="gradebook.GradebookFilters.assignmentTypesLabel"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
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,81 @@
|
|||||||
|
/* eslint-disable react/sort-comp, react/button-has-type */
|
||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
|
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
|
import selectors from 'data/selectors';
|
||||||
|
import actions from 'data/actions';
|
||||||
|
|
||||||
|
import SelectGroup from '../SelectGroup';
|
||||||
|
import messages from '../messages';
|
||||||
|
|
||||||
|
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={<FormattedMessage {...messages.assignmentTypes} />}
|
||||||
|
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: actions.filters.update.assignmentType,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(mapStateToProps, mapDispatchToProps)(AssignmentTypeFilter);
|
||||||
135
src/components/GradebookFilters/AssignmentTypeFilter/test.jsx
Normal file
135
src/components/GradebookFilters/AssignmentTypeFilter/test.jsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { shallow } from 'enzyme';
|
||||||
|
|
||||||
|
import selectors from 'data/selectors';
|
||||||
|
import actions from 'data/actions';
|
||||||
|
|
||||||
|
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(
|
||||||
|
actions.filters.update.assignmentType,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`CourseGradeFilter Component snapshots basic snapshot 1`] = `
|
||||||
|
<React.Fragment>
|
||||||
|
<div
|
||||||
|
className="grade-filter-inputs"
|
||||||
|
>
|
||||||
|
<PercentGroup
|
||||||
|
id="minimum-grade"
|
||||||
|
label={
|
||||||
|
<FormattedMessage
|
||||||
|
defaultMessage="Min Grade"
|
||||||
|
description="Min-grade filter select label in Gradebook Filters"
|
||||||
|
id="gradebook.GradebookFilters.minGradeFilterLabel"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onChange={[MockFunction handleUpdateMin]}
|
||||||
|
value="5"
|
||||||
|
/>
|
||||||
|
<PercentGroup
|
||||||
|
id="maximum-grade"
|
||||||
|
label={
|
||||||
|
<FormattedMessage
|
||||||
|
defaultMessage="Max Grade"
|
||||||
|
description="Max-grade filter select label in Gradebook Filters"
|
||||||
|
id="gradebook.GradebookFilters.maxGradeFilterLabel"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onChange={[MockFunction handleUpdateMax]}
|
||||||
|
value="92"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="grade-filter-action"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
onClick={[MockFunction handleApplyClick]}
|
||||||
|
variant="outline-secondary"
|
||||||
|
>
|
||||||
|
Apply
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</React.Fragment>
|
||||||
|
`;
|
||||||
103
src/components/GradebookFilters/CourseGradeFilter/index.jsx
Normal file
103
src/components/GradebookFilters/CourseGradeFilter/index.jsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
/* 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 { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
|
import selectors from 'data/selectors';
|
||||||
|
import actions from 'data/actions';
|
||||||
|
import thunkActions from 'data/thunkActions';
|
||||||
|
|
||||||
|
import messages from '../messages';
|
||||||
|
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() {
|
||||||
|
if (this.props.areLimitsValid) {
|
||||||
|
this.updateCourseGradeFilters();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleUpdateMin({ target: { value } }) {
|
||||||
|
this.props.setLocalFilter({ courseGradeMin: value });
|
||||||
|
}
|
||||||
|
|
||||||
|
handleUpdateMax({ target: { value } }) {
|
||||||
|
this.props.setLocalFilter({ courseGradeMax: value });
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCourseGradeFilters() {
|
||||||
|
this.props.updateFilter(this.props.localCourseLimits);
|
||||||
|
this.props.fetchGrades();
|
||||||
|
this.props.updateQueryParams(this.props.localCourseLimits);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
localCourseLimits: { courseGradeMin, courseGradeMax },
|
||||||
|
} = this.props;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="grade-filter-inputs">
|
||||||
|
<PercentGroup
|
||||||
|
id="minimum-grade"
|
||||||
|
label={<FormattedMessage {...messages.minGrade} />}
|
||||||
|
value={courseGradeMin}
|
||||||
|
onChange={this.handleUpdateMin}
|
||||||
|
/>
|
||||||
|
<PercentGroup
|
||||||
|
id="maximum-grade"
|
||||||
|
label={<FormattedMessage {...messages.maxGrade} />}
|
||||||
|
value={courseGradeMax}
|
||||||
|
onChange={this.handleUpdateMax}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grade-filter-action">
|
||||||
|
<Button
|
||||||
|
variant="outline-secondary"
|
||||||
|
onClick={this.handleApplyClick}
|
||||||
|
>
|
||||||
|
Apply
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CourseGradeFilter.propTypes = {
|
||||||
|
updateQueryParams: PropTypes.func.isRequired,
|
||||||
|
|
||||||
|
// Redux
|
||||||
|
areLimitsValid: PropTypes.bool.isRequired,
|
||||||
|
fetchGrades: PropTypes.func.isRequired,
|
||||||
|
localCourseLimits: PropTypes.shape({
|
||||||
|
courseGradeMin: PropTypes.string.isRequired,
|
||||||
|
courseGradeMax: PropTypes.string.isRequired,
|
||||||
|
}).isRequired,
|
||||||
|
setLocalFilter: PropTypes.func.isRequired,
|
||||||
|
updateFilter: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mapStateToProps = (state) => ({
|
||||||
|
areLimitsValid: selectors.app.areCourseGradeFiltersValid(state),
|
||||||
|
localCourseLimits: selectors.app.courseGradeLimits(state),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const mapDispatchToProps = {
|
||||||
|
fetchGrades: thunkActions.grades.fetchGrades,
|
||||||
|
setLocalFilter: actions.app.setLocalFilter,
|
||||||
|
updateFilter: actions.filters.update.courseGradeLimits,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(mapStateToProps, mapDispatchToProps)(CourseGradeFilter);
|
||||||
150
src/components/GradebookFilters/CourseGradeFilter/test.jsx
Normal file
150
src/components/GradebookFilters/CourseGradeFilter/test.jsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
/* eslint-disable import/no-named-as-default */
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { shallow } from 'enzyme';
|
||||||
|
|
||||||
|
import actions from 'data/actions';
|
||||||
|
import selectors from 'data/selectors';
|
||||||
|
import { fetchGrades } from 'data/thunkActions/grades';
|
||||||
|
import {
|
||||||
|
CourseGradeFilter,
|
||||||
|
mapStateToProps,
|
||||||
|
mapDispatchToProps,
|
||||||
|
} from '.';
|
||||||
|
|
||||||
|
jest.mock('@edx/paragon', () => ({
|
||||||
|
Button: () => 'Button',
|
||||||
|
}));
|
||||||
|
jest.mock('../PercentGroup', () => 'PercentGroup');
|
||||||
|
|
||||||
|
jest.mock('data/thunkActions/grades', () => ({
|
||||||
|
fetchGrades: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('data/selectors', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
app: {
|
||||||
|
areCourseGradeFiltersValid: jest.fn(state => ({ areCourseGradeFiltersValid: state })),
|
||||||
|
courseGradeLimits: jest.fn(state => ({ courseGradeLimits: state })),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('CourseGradeFilter', () => {
|
||||||
|
let props = {
|
||||||
|
localCourseLimits: {
|
||||||
|
courseGradeMin: '5',
|
||||||
|
courseGradeMax: '92',
|
||||||
|
},
|
||||||
|
areLimitsValid: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
props = {
|
||||||
|
...props,
|
||||||
|
fetchGrades: jest.fn(),
|
||||||
|
setLocalFilter: 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 updateCourseGradeFilters is limits are valid', () => {
|
||||||
|
el.instance().handleApplyClick();
|
||||||
|
expect(el.instance().updateCourseGradeFilters).toHaveBeenCalledWith();
|
||||||
|
});
|
||||||
|
it('does not call updateCourseGradeFilters if limits are not valid', () => {
|
||||||
|
el.setProps({ areLimitsValid: false });
|
||||||
|
el.instance().handleApplyClick();
|
||||||
|
expect(el.instance().updateCourseGradeFilters).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('updateCourseGradeFilters', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
el.instance().updateCourseGradeFilters();
|
||||||
|
});
|
||||||
|
it('calls props.updateFilter with selection', () => {
|
||||||
|
expect(props.updateFilter).toHaveBeenCalledWith(props.localCourseLimits);
|
||||||
|
});
|
||||||
|
it('calls props.getUserGrades with selection', () => {
|
||||||
|
expect(props.fetchGrades).toHaveBeenCalledWith();
|
||||||
|
});
|
||||||
|
it('updates query params with courseGradeMin and courseGradeMax', () => {
|
||||||
|
expect(props.updateQueryParams).toHaveBeenCalledWith(props.localCourseLimits);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('handleUpdateMin', () => {
|
||||||
|
it('calls props.setCourseGradeMin with event value', () => {
|
||||||
|
el.instance().handleUpdateMin(
|
||||||
|
{ target: { value: testVal } },
|
||||||
|
);
|
||||||
|
expect(props.setLocalFilter).toHaveBeenCalledWith({
|
||||||
|
courseGradeMin: testVal,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('handleUpdateMax', () => {
|
||||||
|
it('calls props.setCourseGradeMax with event value', () => {
|
||||||
|
el.instance().handleUpdateMax(
|
||||||
|
{ target: { value: testVal } },
|
||||||
|
);
|
||||||
|
expect(props.setLocalFilter).toHaveBeenCalledWith({
|
||||||
|
courseGradeMax: testVal,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('mapStateToProps', () => {
|
||||||
|
const testState = { peanut: 'butter', jelly: 'time' };
|
||||||
|
let mapped;
|
||||||
|
beforeEach(() => {
|
||||||
|
mapped = mapStateToProps(testState);
|
||||||
|
});
|
||||||
|
test('areLimitsValid from app.areCourseGradeFiltersValid', () => {
|
||||||
|
expect(mapped.areLimitsValid).toEqual(selectors.app.areCourseGradeFiltersValid(testState));
|
||||||
|
});
|
||||||
|
test('localCourseLimits from app.courseGradeLimits', () => {
|
||||||
|
expect(mapped.localCourseLimits).toEqual(selectors.app.courseGradeLimits(testState));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('mapDispatchToProps', () => {
|
||||||
|
test('fetchGrades from thunkActions.grades.fetchGrades', () => {
|
||||||
|
expect(mapDispatchToProps.fetchGrades).toEqual(fetchGrades);
|
||||||
|
});
|
||||||
|
test('setLocalFilter from actions.app.setLocalFilter', () => {
|
||||||
|
expect(mapDispatchToProps.setLocalFilter).toEqual(actions.app.setLocalFilter);
|
||||||
|
});
|
||||||
|
test('updateFilter from actions.filters.update.courseGradeLimits', () => {
|
||||||
|
expect(mapDispatchToProps.updateFilter).toEqual(actions.filters.update.courseGradeLimits);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
6
src/components/GradebookFilters/GradebookFilters.scss
Normal file
6
src/components/GradebookFilters/GradebookFilters.scss
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
.filter-sidebar-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
39
src/components/GradebookFilters/PercentGroup.jsx
Normal file
39
src/components/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.node.isRequired,
|
||||||
|
value: PropTypes.string.isRequired,
|
||||||
|
onChange: PropTypes.func.isRequired,
|
||||||
|
disabled: PropTypes.bool,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PercentGroup;
|
||||||
33
src/components/GradebookFilters/PercentGroup.test.jsx
Normal file
33
src/components/GradebookFilters/PercentGroup.test.jsx
Normal file
@@ -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/GradebookFilters/SelectGroup.jsx
Normal file
36
src/components/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.node.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;
|
||||||
38
src/components/GradebookFilters/SelectGroup.test.jsx
Normal file
38
src/components/GradebookFilters/SelectGroup.test.jsx
Normal file
@@ -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,190 @@
|
|||||||
|
// 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="cohorT3"
|
||||||
|
/>
|
||||||
|
</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>,
|
||||||
|
]
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`StudentGroupsFilter optionFactory returns a list of options with a default first entry 1`] = `
|
||||||
|
Array [
|
||||||
|
<option
|
||||||
|
value="All-Ponies"
|
||||||
|
>
|
||||||
|
All-Ponies
|
||||||
|
</option>,
|
||||||
|
<option
|
||||||
|
value="RDash"
|
||||||
|
>
|
||||||
|
RDash
|
||||||
|
</option>,
|
||||||
|
<option
|
||||||
|
value="PPie"
|
||||||
|
>
|
||||||
|
PPie
|
||||||
|
</option>,
|
||||||
|
]
|
||||||
|
`;
|
||||||
152
src/components/GradebookFilters/StudentGroupsFilter/index.jsx
Normal file
152
src/components/GradebookFilters/StudentGroupsFilter/index.jsx
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
/* 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 { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
|
import actions from 'data/actions';
|
||||||
|
import selectors from 'data/selectors';
|
||||||
|
import thunkActions from 'data/thunkActions';
|
||||||
|
|
||||||
|
import messages from '../messages';
|
||||||
|
import SelectGroup from '../SelectGroup';
|
||||||
|
|
||||||
|
export const optionFactory = ({ data, defaultOption, key }) => [
|
||||||
|
<option value={defaultOption} key="0">{defaultOption}</option>,
|
||||||
|
...data.map(
|
||||||
|
entry => (<option key={entry[key]} value={entry.name}>{entry.name}</option>),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
export class StudentGroupsFilter extends React.Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.mapCohortsEntries = this.mapCohortsEntries.bind(this);
|
||||||
|
this.mapTracksEntries = this.mapTracksEntries.bind(this);
|
||||||
|
this.updateCohorts = this.updateCohorts.bind(this);
|
||||||
|
this.updateTracks = this.updateTracks.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
mapCohortsEntries() {
|
||||||
|
return optionFactory({
|
||||||
|
data: this.props.cohorts,
|
||||||
|
defaultOption: this.translate(messages.cohortAll),
|
||||||
|
key: 'id',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
mapTracksEntries() {
|
||||||
|
return optionFactory({
|
||||||
|
data: this.props.tracks,
|
||||||
|
defaultOption: this.translate(messages.trackAll),
|
||||||
|
key: 'slug',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedTrackSlugFromEvent({ target: { value } }) {
|
||||||
|
const selectedTrackItem = this.props.tracksByName[value];
|
||||||
|
return selectedTrackItem ? selectedTrackItem.slug : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedCohortIdFromEvent({ target: { value } }) {
|
||||||
|
const selectedCohortItem = this.props.cohortsByName[value];
|
||||||
|
return selectedCohortItem ? selectedCohortItem.id.toString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTracks(event) {
|
||||||
|
const track = this.selectedTrackSlugFromEvent(event);
|
||||||
|
this.props.updateQueryParams({ track });
|
||||||
|
this.props.updateTrack(track);
|
||||||
|
this.props.fetchGrades();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCohorts(event) {
|
||||||
|
const cohort = this.selectedCohortIdFromEvent(event);
|
||||||
|
this.props.updateQueryParams({ cohort });
|
||||||
|
this.props.updateCohort(cohort);
|
||||||
|
this.props.fetchGrades();
|
||||||
|
}
|
||||||
|
|
||||||
|
translate(message) {
|
||||||
|
return this.props.intl.formatMessage(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SelectGroup
|
||||||
|
id="Tracks"
|
||||||
|
label={this.translate(messages.tracks)}
|
||||||
|
value={this.props.selectedTrackEntry.name}
|
||||||
|
onChange={this.updateTracks}
|
||||||
|
options={this.mapTracksEntries()}
|
||||||
|
/>
|
||||||
|
<SelectGroup
|
||||||
|
id="Cohorts"
|
||||||
|
label={this.translate(messages.cohorts)}
|
||||||
|
value={this.props.selectedCohortEntry.name}
|
||||||
|
disabled={this.props.cohorts.length === 0}
|
||||||
|
onChange={this.updateCohorts}
|
||||||
|
options={this.mapCohortsEntries()}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StudentGroupsFilter.defaultProps = {
|
||||||
|
cohorts: [],
|
||||||
|
cohortsByName: {},
|
||||||
|
selectedCohortEntry: { name: '' },
|
||||||
|
selectedTrackEntry: { name: '' },
|
||||||
|
tracks: [],
|
||||||
|
tracksByName: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
StudentGroupsFilter.propTypes = {
|
||||||
|
updateQueryParams: PropTypes.func.isRequired,
|
||||||
|
|
||||||
|
// injected
|
||||||
|
intl: intlShape.isRequired,
|
||||||
|
|
||||||
|
// redux
|
||||||
|
cohorts: PropTypes.arrayOf(PropTypes.shape({
|
||||||
|
name: PropTypes.string,
|
||||||
|
id: PropTypes.number,
|
||||||
|
})),
|
||||||
|
cohortsByName: PropTypes.objectOf(PropTypes.shape({
|
||||||
|
name: PropTypes.string,
|
||||||
|
id: PropTypes.number,
|
||||||
|
})),
|
||||||
|
fetchGrades: PropTypes.func.isRequired,
|
||||||
|
selectedTrackEntry: PropTypes.shape({ name: PropTypes.string }),
|
||||||
|
selectedCohortEntry: PropTypes.shape({ name: PropTypes.string }),
|
||||||
|
tracks: PropTypes.arrayOf(PropTypes.shape({
|
||||||
|
name: PropTypes.string,
|
||||||
|
slug: PropTypes.string,
|
||||||
|
})),
|
||||||
|
tracksByName: PropTypes.objectOf(PropTypes.shape({
|
||||||
|
name: PropTypes.string,
|
||||||
|
slug: PropTypes.string,
|
||||||
|
})),
|
||||||
|
updateCohort: PropTypes.func.isRequired,
|
||||||
|
updateTrack: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mapStateToProps = (state) => ({
|
||||||
|
cohorts: selectors.cohorts.allCohorts(state),
|
||||||
|
cohortsByName: selectors.cohorts.cohortsByName(state),
|
||||||
|
selectedCohortEntry: selectors.root.selectedCohortEntry(state),
|
||||||
|
selectedTrackEntry: selectors.root.selectedTrackEntry(state),
|
||||||
|
tracks: selectors.tracks.allTracks(state),
|
||||||
|
tracksByName: selectors.tracks.tracksByName(state),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const mapDispatchToProps = {
|
||||||
|
fetchGrades: thunkActions.grades.fetchGrades,
|
||||||
|
updateCohort: actions.filters.update.cohort,
|
||||||
|
updateTrack: actions.filters.update.track,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(StudentGroupsFilter));
|
||||||
239
src/components/GradebookFilters/StudentGroupsFilter/test.jsx
Normal file
239
src/components/GradebookFilters/StudentGroupsFilter/test.jsx
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
/* eslint-disable import/no-named-as-default */
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { shallow } from 'enzyme';
|
||||||
|
|
||||||
|
import { fetchGrades } from 'data/thunkActions/grades';
|
||||||
|
import actions from 'data/actions';
|
||||||
|
import selectors from 'data/selectors';
|
||||||
|
import {
|
||||||
|
optionFactory,
|
||||||
|
StudentGroupsFilter,
|
||||||
|
mapStateToProps,
|
||||||
|
mapDispatchToProps,
|
||||||
|
} from '.';
|
||||||
|
|
||||||
|
jest.mock('data/selectors', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
root: {
|
||||||
|
selectedCohortEntry: jest.fn(state => ({ selectedCohortEntry: state })),
|
||||||
|
selectedTrackEntry: jest.fn(state => ({ selectedTrackEntry: state })),
|
||||||
|
},
|
||||||
|
cohorts: {
|
||||||
|
allCohorts: jest.fn(state => ({ allCohorts: state })),
|
||||||
|
cohortsByName: jest.fn(state => ({ cohortsByName: state })),
|
||||||
|
},
|
||||||
|
tracks: {
|
||||||
|
allTracks: jest.fn(state => ({ allTracks: state })),
|
||||||
|
tracksByName: jest.fn(state => ({ tracksByName: state })),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('data/thunkActions/grades', () => ({
|
||||||
|
fetchGrades: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('StudentGroupsFilter', () => {
|
||||||
|
let props = {
|
||||||
|
cohorts: [
|
||||||
|
{ name: 'cohorT1', id: 8001 },
|
||||||
|
{ name: 'cohorT2', id: 8002 },
|
||||||
|
{ name: 'cohorT3', id: 8003 },
|
||||||
|
],
|
||||||
|
tracks: [
|
||||||
|
{ name: 'TracK1', slug: 'TracK1_slug' },
|
||||||
|
{ name: 'TracK2', slug: 'TracK2_slug' },
|
||||||
|
{ name: 'TRACK3', slug: 'TRACK3_slug' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('optionFactory', () => {
|
||||||
|
it('returns a list of options with a default first entry', () => {
|
||||||
|
const data = [{ cMark: 'rainbow', name: 'RDash' }, { cMark: 'balloons', name: 'PPie' }];
|
||||||
|
const defaultOption = 'All-Ponies';
|
||||||
|
const key = 'cMark';
|
||||||
|
const options = optionFactory({ data, defaultOption, key });
|
||||||
|
expect(options).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Component', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
props = {
|
||||||
|
...props,
|
||||||
|
intl: { formatMessage: (msg) => msg.defaultMessage },
|
||||||
|
cohortsByName: {
|
||||||
|
[props.cohorts[0].name]: props.cohorts[0],
|
||||||
|
[props.cohorts[1].name]: props.cohorts[1],
|
||||||
|
[props.cohorts[2].name]: props.cohorts[2],
|
||||||
|
},
|
||||||
|
tracksByName: {
|
||||||
|
[props.tracks[0].name]: props.tracks[0],
|
||||||
|
[props.tracks[1].name]: props.tracks[1],
|
||||||
|
[props.tracks[2].name]: props.tracks[2],
|
||||||
|
},
|
||||||
|
fetchGrades: jest.fn(),
|
||||||
|
selectedCohortEntry: props.cohorts[2],
|
||||||
|
selectedTrackEntry: props.tracks[1],
|
||||||
|
updateQueryParams: jest.fn(),
|
||||||
|
updateCohort: jest.fn().mockName('updateCohort'),
|
||||||
|
updateTrack: jest.fn().mockName('updateTrack'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
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('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 updateTrack with new value', () => {
|
||||||
|
expect(props.updateTrack).toHaveBeenCalledWith(selectedSlug);
|
||||||
|
});
|
||||||
|
it('calls fetchGrades', () => {
|
||||||
|
expect(props.fetchGrades).toHaveBeenCalledWith();
|
||||||
|
});
|
||||||
|
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 updateCohort with new value', () => {
|
||||||
|
expect(props.updateCohort).toHaveBeenCalledWith(selectedId);
|
||||||
|
});
|
||||||
|
it('calls fetchGrades', () => {
|
||||||
|
expect(props.fetchGrades).toHaveBeenCalledWith();
|
||||||
|
});
|
||||||
|
it('updates queryParams with cohort value', () => {
|
||||||
|
expect(props.updateQueryParams).toHaveBeenCalledWith({
|
||||||
|
cohort: selectedId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('mapStateToProps', () => {
|
||||||
|
const testState = { h: 'e', l: 'l', o: 'oooooooooo' };
|
||||||
|
let mappedProps;
|
||||||
|
beforeAll(() => {
|
||||||
|
mappedProps = mapStateToProps(testState);
|
||||||
|
});
|
||||||
|
test('cohorts from selectors.cohorts.allCohorts', () => {
|
||||||
|
expect(mappedProps.cohorts).toEqual(selectors.cohorts.allCohorts(testState));
|
||||||
|
});
|
||||||
|
test('cohortsByName from selectors.cohorts.cohortsByName', () => {
|
||||||
|
expect(mappedProps.cohortsByName).toEqual(selectors.cohorts.cohortsByName(testState));
|
||||||
|
});
|
||||||
|
test('selectedCohortEntry from selectors.root.selectedCohortEntry', () => {
|
||||||
|
expect(
|
||||||
|
mappedProps.selectedCohortEntry,
|
||||||
|
).toEqual(selectors.root.selectedCohortEntry(testState));
|
||||||
|
});
|
||||||
|
test('selectedTrackEntry from selectors.root.selectedTrackEntry', () => {
|
||||||
|
expect(
|
||||||
|
mappedProps.selectedTrackEntry,
|
||||||
|
).toEqual(selectors.root.selectedTrackEntry(testState));
|
||||||
|
});
|
||||||
|
test('tracks from selectors.tracks.allTracks', () => {
|
||||||
|
expect(mappedProps.tracks).toEqual(selectors.tracks.allTracks(testState));
|
||||||
|
});
|
||||||
|
test('tracksByName from selectors.tracks.tracksByName', () => {
|
||||||
|
expect(mappedProps.tracksByName).toEqual(selectors.tracks.tracksByName(testState));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('mapDispatchToProps', () => {
|
||||||
|
test('fetchGrades from thunkActions.grades.fetchGrades', () => {
|
||||||
|
expect(mapDispatchToProps.fetchGrades).toEqual(fetchGrades);
|
||||||
|
});
|
||||||
|
test('updateCohort from actions.filters.update.cohort', () => {
|
||||||
|
expect(mapDispatchToProps.updateCohort).toEqual(actions.filters.update.cohort);
|
||||||
|
});
|
||||||
|
test('updateTrack from actions.filters.update.track', () => {
|
||||||
|
expect(mapDispatchToProps.updateTrack).toEqual(actions.filters.update.track);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
`;
|
||||||
98
src/components/GradebookFilters/__snapshots__/test.jsx.snap
Normal file
98
src/components/GradebookFilters/__snapshots__/test.jsx.snap
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`GradebookFilters Component snapshots basic snapshot 1`] = `
|
||||||
|
<React.Fragment>
|
||||||
|
<div
|
||||||
|
className="filter-sidebar-header"
|
||||||
|
>
|
||||||
|
<h2>
|
||||||
|
<Icon
|
||||||
|
className="fa fa-filter"
|
||||||
|
/>
|
||||||
|
</h2>
|
||||||
|
<IconButton
|
||||||
|
alt="Close Filters"
|
||||||
|
aria-label="Close Filters"
|
||||||
|
className="p-1"
|
||||||
|
iconAs="Icon"
|
||||||
|
onClick={[MockFunction this.props.closeMenu]}
|
||||||
|
src="paragon.icons.Close"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Collapsible
|
||||||
|
className="filter-group mb-3"
|
||||||
|
defaultOpen={true}
|
||||||
|
title={
|
||||||
|
<FormattedMessage
|
||||||
|
defaultMessage="Assignments"
|
||||||
|
description="Assignment filter group label in Gradebook Filters"
|
||||||
|
id="gradebook.GradebookFilters.assignmentsFilterLabel"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Connect(AssignmentTypeFilter)
|
||||||
|
updateQueryParams={[MockFunction]}
|
||||||
|
/>
|
||||||
|
<Connect(AssignmentFilter)
|
||||||
|
updateQueryParams={[MockFunction]}
|
||||||
|
/>
|
||||||
|
<Connect(AssignmentGradeFilter)
|
||||||
|
updateQueryParams={[MockFunction]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Collapsible>
|
||||||
|
<Collapsible
|
||||||
|
className="filter-group mb-3"
|
||||||
|
defaultOpen={true}
|
||||||
|
title={
|
||||||
|
<FormattedMessage
|
||||||
|
defaultMessage="Overall Grade"
|
||||||
|
description="Overall Grade filter group label in Gradebook Filters"
|
||||||
|
id="gradebook.GradebookFilters.overallGradeFilterLabel"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Connect(CourseGradeFilter)
|
||||||
|
updateQueryParams={[MockFunction]}
|
||||||
|
/>
|
||||||
|
</Collapsible>
|
||||||
|
<Collapsible
|
||||||
|
className="filter-group mb-3"
|
||||||
|
defaultOpen={true}
|
||||||
|
title={
|
||||||
|
<FormattedMessage
|
||||||
|
defaultMessage="Student Groups"
|
||||||
|
description="Student Groups filter group label in Gradebook Filters"
|
||||||
|
id="gradebook.GradebookFilters.studentGroupsFilterLabel"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<InjectIntl(ShimmedIntlComponent)
|
||||||
|
updateQueryParams={[MockFunction]}
|
||||||
|
/>
|
||||||
|
</Collapsible>
|
||||||
|
<Collapsible
|
||||||
|
className="filter-group mb-3"
|
||||||
|
defaultOpen={true}
|
||||||
|
title={
|
||||||
|
<FormattedMessage
|
||||||
|
defaultMessage="Include Course Team Members"
|
||||||
|
description="Include Course Team Members filter label in Gradebook Filters"
|
||||||
|
id="gradebook.GradebookFilters.includeCourseTeamMembersFilterLabel"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={true}
|
||||||
|
onChange={[MockFunction handleIncludeTeamMembersChange]}
|
||||||
|
>
|
||||||
|
<FormattedMessage
|
||||||
|
defaultMessage="Include Course Team Members"
|
||||||
|
description="Include Course Team Members filter label in Gradebook Filters"
|
||||||
|
id="gradebook.GradebookFilters.includeCourseTeamMembersFilterLabel"
|
||||||
|
/>
|
||||||
|
</Checkbox>
|
||||||
|
</Collapsible>
|
||||||
|
</React.Fragment>
|
||||||
|
`;
|
||||||
124
src/components/GradebookFilters/index.jsx
Normal file
124
src/components/GradebookFilters/index.jsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
/* 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,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Form,
|
||||||
|
} from '@edx/paragon';
|
||||||
|
import { Close } from '@edx/paragon/icons';
|
||||||
|
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
|
import actions from 'data/actions';
|
||||||
|
import selectors from 'data/selectors';
|
||||||
|
import thunkActions from 'data/thunkActions';
|
||||||
|
|
||||||
|
import messages from './messages';
|
||||||
|
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.fetchGrades();
|
||||||
|
this.props.updateQueryParams({ includeCourseRoleMembers });
|
||||||
|
}
|
||||||
|
|
||||||
|
collapsibleGroup = (title, content) => (
|
||||||
|
<Collapsible
|
||||||
|
title={<FormattedMessage {...title} />}
|
||||||
|
defaultOpen
|
||||||
|
className="filter-group mb-3"
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
intl,
|
||||||
|
updateQueryParams,
|
||||||
|
} = this.props;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="filter-sidebar-header">
|
||||||
|
<h2><Icon className="fa fa-filter" /></h2>
|
||||||
|
<IconButton
|
||||||
|
className="p-1"
|
||||||
|
onClick={this.props.closeMenu}
|
||||||
|
iconAs={Icon}
|
||||||
|
src={Close}
|
||||||
|
alt={intl.formatMessage(messages.closeFilters)}
|
||||||
|
aria-label={intl.formatMessage(messages.closeFilters)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{this.collapsibleGroup(messages.assignments, (
|
||||||
|
<div>
|
||||||
|
<AssignmentTypeFilter updateQueryParams={updateQueryParams} />
|
||||||
|
<AssignmentFilter updateQueryParams={updateQueryParams} />
|
||||||
|
<AssignmentGradeFilter updateQueryParams={updateQueryParams} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{this.collapsibleGroup(messages.overallGrade, (
|
||||||
|
<CourseGradeFilter updateQueryParams={updateQueryParams} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{this.collapsibleGroup(messages.studentGroups, (
|
||||||
|
<StudentGroupsFilter updateQueryParams={updateQueryParams} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{this.collapsibleGroup(messages.includeCourseTeamMembers, (
|
||||||
|
<Form.Checkbox
|
||||||
|
checked={this.state.includeCourseRoleMembers}
|
||||||
|
onChange={this.handleIncludeTeamMembersChange}
|
||||||
|
>
|
||||||
|
<FormattedMessage {...messages.includeCourseTeamMembers} />
|
||||||
|
</Form.Checkbox>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GradebookFilters.defaultProps = {
|
||||||
|
includeCourseRoleMembers: false,
|
||||||
|
};
|
||||||
|
GradebookFilters.propTypes = {
|
||||||
|
updateQueryParams: PropTypes.func.isRequired,
|
||||||
|
// injected
|
||||||
|
intl: intlShape.isRequired,
|
||||||
|
// redux
|
||||||
|
closeMenu: PropTypes.func.isRequired,
|
||||||
|
fetchGrades: PropTypes.func.isRequired,
|
||||||
|
includeCourseRoleMembers: PropTypes.bool,
|
||||||
|
updateIncludeCourseRoleMembers: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mapStateToProps = (state) => ({
|
||||||
|
includeCourseRoleMembers: selectors.filters.includeCourseRoleMembers(state),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const mapDispatchToProps = {
|
||||||
|
closeMenu: thunkActions.app.filterMenu.close,
|
||||||
|
fetchGrades: thunkActions.grades.fetchGrades,
|
||||||
|
updateIncludeCourseRoleMembers: actions.filters.update.includeCourseRoleMembers,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(GradebookFilters));
|
||||||
71
src/components/GradebookFilters/messages.js
Normal file
71
src/components/GradebookFilters/messages.js
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
assignments: {
|
||||||
|
id: 'gradebook.GradebookFilters.assignmentsFilterLabel',
|
||||||
|
defaultMessage: 'Assignments',
|
||||||
|
description: 'Assignment filter group label in Gradebook Filters',
|
||||||
|
},
|
||||||
|
overallGrade: {
|
||||||
|
id: 'gradebook.GradebookFilters.overallGradeFilterLabel',
|
||||||
|
defaultMessage: 'Overall Grade',
|
||||||
|
description: 'Overall Grade filter group label in Gradebook Filters',
|
||||||
|
},
|
||||||
|
studentGroups: {
|
||||||
|
id: 'gradebook.GradebookFilters.studentGroupsFilterLabel',
|
||||||
|
defaultMessage: 'Student Groups',
|
||||||
|
description: 'Student Groups filter group label in Gradebook Filters',
|
||||||
|
},
|
||||||
|
includeCourseTeamMembers: {
|
||||||
|
id: 'gradebook.GradebookFilters.includeCourseTeamMembersFilterLabel',
|
||||||
|
defaultMessage: 'Include Course Team Members',
|
||||||
|
description: 'Include Course Team Members filter label in Gradebook Filters',
|
||||||
|
},
|
||||||
|
assignment: {
|
||||||
|
id: 'gradebook.GradebookFilters.assignmentFilterLabel',
|
||||||
|
defaultMessage: 'Assignment',
|
||||||
|
description: 'Assignment filter select label in Gradebook Filters',
|
||||||
|
},
|
||||||
|
assignmentTypes: {
|
||||||
|
id: 'gradebook.GradebookFilters.assignmentTypesLabel',
|
||||||
|
defaultMessage: 'Assignment Types',
|
||||||
|
description: 'Assignment Types filter select label in Gradebook Filters',
|
||||||
|
},
|
||||||
|
maxGrade: {
|
||||||
|
id: 'gradebook.GradebookFilters.maxGradeFilterLabel',
|
||||||
|
defaultMessage: 'Max Grade',
|
||||||
|
description: 'Max-grade filter select label in Gradebook Filters',
|
||||||
|
},
|
||||||
|
minGrade: {
|
||||||
|
id: 'gradebook.GradebookFilters.minGradeFilterLabel',
|
||||||
|
defaultMessage: 'Min Grade',
|
||||||
|
description: 'Min-grade filter select label in Gradebook Filters',
|
||||||
|
},
|
||||||
|
cohorts: {
|
||||||
|
id: 'gradebook.GradebookFilters.cohorts',
|
||||||
|
defaultMessage: 'Cohorts',
|
||||||
|
description: 'Cohorts filter select label in Gradebook Filters',
|
||||||
|
},
|
||||||
|
cohortAll: {
|
||||||
|
id: 'gradebook.GradebookFilters.cohortsAll',
|
||||||
|
defaultMessage: 'Cohort-All',
|
||||||
|
description: 'Cohorts filter select default in Gradebook Filters',
|
||||||
|
},
|
||||||
|
tracks: {
|
||||||
|
id: 'gradebook.GradebookFilters.tracks',
|
||||||
|
defaultMessage: 'Tracks',
|
||||||
|
description: 'Tracks filter select label in Gradebook Filters',
|
||||||
|
},
|
||||||
|
trackAll: {
|
||||||
|
id: 'gradebook.GradebookFilters.trackAll',
|
||||||
|
defaultMessage: 'Track-All',
|
||||||
|
description: 'Tracks filter select default in Gradebook Filters',
|
||||||
|
},
|
||||||
|
closeFilters: {
|
||||||
|
id: 'gradebook.GradebookFilters.closeFilters',
|
||||||
|
defaultMessage: 'Close Filters',
|
||||||
|
description: 'Button label for Close button in Gradebook Filters',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default messages;
|
||||||
121
src/components/GradebookFilters/test.jsx
Normal file
121
src/components/GradebookFilters/test.jsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { shallow } from 'enzyme';
|
||||||
|
|
||||||
|
import actions from 'data/actions';
|
||||||
|
import selectors from 'data/selectors';
|
||||||
|
import thunkActions from 'data/thunkActions';
|
||||||
|
|
||||||
|
import {
|
||||||
|
GradebookFilters,
|
||||||
|
mapStateToProps,
|
||||||
|
mapDispatchToProps,
|
||||||
|
} from '.';
|
||||||
|
|
||||||
|
jest.mock('@edx/paragon', () => ({
|
||||||
|
Collapsible: 'Collapsible',
|
||||||
|
Form: {
|
||||||
|
Checkbox: 'Checkbox',
|
||||||
|
},
|
||||||
|
Icon: 'Icon',
|
||||||
|
IconButton: 'IconButton',
|
||||||
|
}));
|
||||||
|
jest.mock('@edx/paragon/icons', () => ({
|
||||||
|
Close: 'paragon.icons.Close',
|
||||||
|
}));
|
||||||
|
jest.mock('data/selectors', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
filters: {
|
||||||
|
includeCourseRoleMembers: jest.fn((state) => ({ includeCourseRoleMembers: state })),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
jest.mock('data/thunkActions', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
app: { filterMenu: { close: jest.fn() } },
|
||||||
|
grades: { fetchGrades: jest.fn() },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('GradebookFilters', () => {
|
||||||
|
let props = {
|
||||||
|
includeCourseRoleMembers: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
props = {
|
||||||
|
...props,
|
||||||
|
intl: { formatMessage: (msg) => msg.defaultMessage },
|
||||||
|
closeMenu: jest.fn().mockName('this.props.closeMenu'),
|
||||||
|
fetchGrades: jest.fn(),
|
||||||
|
updateIncludeCourseRoleMembers: jest.fn(),
|
||||||
|
updateQueryParams: 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 testState = { A: 'laska' };
|
||||||
|
test('includeCourseRoleMembers from filters.includeCourseRoleMembers', () => {
|
||||||
|
expect(
|
||||||
|
mapStateToProps(testState).includeCourseRoleMembers,
|
||||||
|
).toEqual(selectors.filters.includeCourseRoleMembers(testState));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('mapDispatchToProps', () => {
|
||||||
|
test('fetchGrades from thunkActions.grades.fetchGrades', () => {
|
||||||
|
expect(mapDispatchToProps.fetchGrades).toEqual(thunkActions.grades.fetchGrades);
|
||||||
|
});
|
||||||
|
describe('updateIncludeCourseRoleMembers', () => {
|
||||||
|
test('from actions.filters.update.includeCourseRoleMembers', () => {
|
||||||
|
expect(mapDispatchToProps.updateIncludeCourseRoleMembers).toEqual(
|
||||||
|
actions.filters.update.includeCourseRoleMembers,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
137
src/components/GradebookHeader/__snapshots__/test.jsx.snap
Normal file
137
src/components/GradebookHeader/__snapshots__/test.jsx.snap
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`GradebookHeader component snapshots default values (grades frozen, cannot view). unauthorized warning, but no grades frozen warning 1`] = `
|
||||||
|
<div
|
||||||
|
className="gradebook-header"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
className="mb-3"
|
||||||
|
href="http://localhost:18000/courses/fakeID/instructor"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<<
|
||||||
|
</span>
|
||||||
|
<FormattedMessage
|
||||||
|
defaultMessage="Back to Dashboard"
|
||||||
|
description="Button text to take user back to LMS dashboard in Gradebook Header"
|
||||||
|
id="gradebook.GradebookHeader.backButton"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
<h1>
|
||||||
|
<FormattedMessage
|
||||||
|
defaultMessage="Gradebook"
|
||||||
|
description="Top-level app title in Gradebook Header component"
|
||||||
|
id="gradebook.GradebookHeader.appLabel"
|
||||||
|
/>
|
||||||
|
</h1>
|
||||||
|
<h3>
|
||||||
|
fakeID
|
||||||
|
</h3>
|
||||||
|
<div
|
||||||
|
className="alert alert-warning"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<FormattedMessage
|
||||||
|
defaultMessage="You are not authorized to view the gradebook for this course."
|
||||||
|
description="Warning message in Gradebook Header when user is not allowed to view the app"
|
||||||
|
id="gradebook.GradebookHeader.unauthorizedWarning"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`GradebookHeader component snapshots grades frozen, can view. grades frozen warning but no unauthorized warning 1`] = `
|
||||||
|
<div
|
||||||
|
className="gradebook-header"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
className="mb-3"
|
||||||
|
href="http://localhost:18000/courses/fakeID/instructor"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<<
|
||||||
|
</span>
|
||||||
|
<FormattedMessage
|
||||||
|
defaultMessage="Back to Dashboard"
|
||||||
|
description="Button text to take user back to LMS dashboard in Gradebook Header"
|
||||||
|
id="gradebook.GradebookHeader.backButton"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
<h1>
|
||||||
|
<FormattedMessage
|
||||||
|
defaultMessage="Gradebook"
|
||||||
|
description="Top-level app title in Gradebook Header component"
|
||||||
|
id="gradebook.GradebookHeader.appLabel"
|
||||||
|
/>
|
||||||
|
</h1>
|
||||||
|
<h3>
|
||||||
|
fakeID
|
||||||
|
</h3>
|
||||||
|
<div
|
||||||
|
className="alert alert-warning"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<FormattedMessage
|
||||||
|
defaultMessage="The grades for this course are now frozen. Editing of grades is no longer allowed."
|
||||||
|
description="Warning message in Gradebook Header for frozen messages"
|
||||||
|
id="gradebook.GradebookHeader.frozenWarning"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`GradebookHeader component snapshots grades frozen, cannot view unauthorized warning, and grades frozen warning. 1`] = `
|
||||||
|
<div
|
||||||
|
className="gradebook-header"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
className="mb-3"
|
||||||
|
href="http://localhost:18000/courses/fakeID/instructor"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<<
|
||||||
|
</span>
|
||||||
|
<FormattedMessage
|
||||||
|
defaultMessage="Back to Dashboard"
|
||||||
|
description="Button text to take user back to LMS dashboard in Gradebook Header"
|
||||||
|
id="gradebook.GradebookHeader.backButton"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
<h1>
|
||||||
|
<FormattedMessage
|
||||||
|
defaultMessage="Gradebook"
|
||||||
|
description="Top-level app title in Gradebook Header component"
|
||||||
|
id="gradebook.GradebookHeader.appLabel"
|
||||||
|
/>
|
||||||
|
</h1>
|
||||||
|
<h3>
|
||||||
|
fakeID
|
||||||
|
</h3>
|
||||||
|
<div
|
||||||
|
className="alert alert-warning"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<FormattedMessage
|
||||||
|
defaultMessage="The grades for this course are now frozen. Editing of grades is no longer allowed."
|
||||||
|
description="Warning message in Gradebook Header for frozen messages"
|
||||||
|
id="gradebook.GradebookHeader.frozenWarning"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="alert alert-warning"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<FormattedMessage
|
||||||
|
defaultMessage="You are not authorized to view the gradebook for this course."
|
||||||
|
description="Warning message in Gradebook Header when user is not allowed to view the app"
|
||||||
|
id="gradebook.GradebookHeader.unauthorizedWarning"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
67
src/components/GradebookHeader/index.jsx
Normal file
67
src/components/GradebookHeader/index.jsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
|
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
|
import { configuration } from 'config';
|
||||||
|
import selectors from 'data/selectors';
|
||||||
|
|
||||||
|
import messages from './messages';
|
||||||
|
|
||||||
|
export class GradebookHeader extends React.Component {
|
||||||
|
lmsInstructorDashboardUrl = courseId => (
|
||||||
|
`${configuration.LMS_BASE_URL}/courses/${courseId}/instructor`
|
||||||
|
);
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<div className="gradebook-header">
|
||||||
|
<a
|
||||||
|
href={this.lmsInstructorDashboardUrl(this.props.courseId)}
|
||||||
|
className="mb-3"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">{'<< '}</span>
|
||||||
|
<FormattedMessage {...messages.backToDashboard} />
|
||||||
|
</a>
|
||||||
|
<h1>
|
||||||
|
<FormattedMessage {...messages.gradebook} />
|
||||||
|
</h1>
|
||||||
|
<h3>{this.props.courseId}</h3>
|
||||||
|
{this.props.areGradesFrozen
|
||||||
|
&& (
|
||||||
|
<div className="alert alert-warning" role="alert">
|
||||||
|
<FormattedMessage {...messages.frozenWarning} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(this.props.canUserViewGradebook === false) && (
|
||||||
|
<div className="alert alert-warning" role="alert">
|
||||||
|
<FormattedMessage {...messages.unauthorizedWarning} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GradebookHeader.defaultProps = {
|
||||||
|
// redux
|
||||||
|
courseId: '',
|
||||||
|
areGradesFrozen: false,
|
||||||
|
canUserViewGradebook: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
GradebookHeader.propTypes = {
|
||||||
|
// redux
|
||||||
|
courseId: PropTypes.string,
|
||||||
|
areGradesFrozen: PropTypes.bool,
|
||||||
|
canUserViewGradebook: PropTypes.bool,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mapStateToProps = (state) => ({
|
||||||
|
courseId: selectors.app.courseId(state),
|
||||||
|
areGradesFrozen: selectors.assignmentTypes.areGradesFrozen(state),
|
||||||
|
canUserViewGradebook: selectors.roles.canUserViewGradebook(state),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default connect(mapStateToProps)(GradebookHeader);
|
||||||
26
src/components/GradebookHeader/messages.js
Normal file
26
src/components/GradebookHeader/messages.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
backToDashboard: {
|
||||||
|
id: 'gradebook.GradebookHeader.backButton',
|
||||||
|
defaultMessage: 'Back to Dashboard',
|
||||||
|
description: 'Button text to take user back to LMS dashboard in Gradebook Header',
|
||||||
|
},
|
||||||
|
gradebook: {
|
||||||
|
id: 'gradebook.GradebookHeader.appLabel',
|
||||||
|
defaultMessage: 'Gradebook',
|
||||||
|
description: 'Top-level app title in Gradebook Header component',
|
||||||
|
},
|
||||||
|
frozenWarning: {
|
||||||
|
id: 'gradebook.GradebookHeader.frozenWarning',
|
||||||
|
defaultMessage: 'The grades for this course are now frozen. Editing of grades is no longer allowed.',
|
||||||
|
description: 'Warning message in Gradebook Header for frozen messages',
|
||||||
|
},
|
||||||
|
unauthorizedWarning: {
|
||||||
|
id: 'gradebook.GradebookHeader.unauthorizedWarning',
|
||||||
|
defaultMessage: 'You are not authorized to view the gradebook for this course.',
|
||||||
|
description: 'Warning message in Gradebook Header when user is not allowed to view the app',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default messages;
|
||||||
63
src/components/GradebookHeader/test.jsx
Normal file
63
src/components/GradebookHeader/test.jsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { shallow } from 'enzyme';
|
||||||
|
|
||||||
|
import selectors from 'data/selectors';
|
||||||
|
import { GradebookHeader, mapStateToProps } from '.';
|
||||||
|
|
||||||
|
jest.mock('@edx/frontend-platform/i18n', () => ({
|
||||||
|
defineMessages: messages => messages,
|
||||||
|
FormattedMessage: 'FormattedMessage',
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('data/selectors', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
app: { courseId: jest.fn(state => ({ courseId: state })) },
|
||||||
|
assignmentTypes: { areGradesFrozen: jest.fn(state => ({ areGradesFrozen: state })) },
|
||||||
|
roles: { canUserViewGradebook: jest.fn(state => ({ canUserViewGradebook: state })) },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const courseId = 'fakeID';
|
||||||
|
describe('GradebookHeader component', () => {
|
||||||
|
describe('snapshots', () => {
|
||||||
|
describe('default values (grades frozen, cannot view).', () => {
|
||||||
|
test('unauthorized warning, but no grades frozen warning', () => {
|
||||||
|
const props = { courseId, areGradesFrozen: false, canUserViewGradebook: false };
|
||||||
|
expect(shallow(<GradebookHeader {...props} />)).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('grades frozen, cannot view', () => {
|
||||||
|
test('unauthorized warning, and grades frozen warning.', () => {
|
||||||
|
const props = { courseId, areGradesFrozen: true, canUserViewGradebook: false };
|
||||||
|
expect(shallow(<GradebookHeader {...props} />)).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('grades frozen, can view.', () => {
|
||||||
|
test('grades frozen warning but no unauthorized warning', () => {
|
||||||
|
const props = { courseId, areGradesFrozen: true, canUserViewGradebook: true };
|
||||||
|
expect(shallow(<GradebookHeader {...props} />)).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('mapStateToProps', () => {
|
||||||
|
let mapped;
|
||||||
|
const testState = { a: 'test', example: 'state' };
|
||||||
|
beforeEach(() => {
|
||||||
|
mapped = mapStateToProps(testState);
|
||||||
|
});
|
||||||
|
test('courseId from app.courseId', () => {
|
||||||
|
expect(mapped.courseId).toEqual(selectors.app.courseId(testState));
|
||||||
|
});
|
||||||
|
test('areGradesFrozen from assignmentTypes selector', () => {
|
||||||
|
expect(
|
||||||
|
mapped.areGradesFrozen,
|
||||||
|
).toEqual(selectors.assignmentTypes.areGradesFrozen(testState));
|
||||||
|
});
|
||||||
|
test('canUserViewGradebook from roles selector', () => {
|
||||||
|
expect(
|
||||||
|
mapped.canUserViewGradebook,
|
||||||
|
).toEqual(selectors.roles.canUserViewGradebook(testState));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
108
src/components/GradesTab/BulkManagementControls.jsx
Normal file
108
src/components/GradesTab/BulkManagementControls.jsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
/* 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, Icon } from '@edx/paragon';
|
||||||
|
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
|
import { StrictDict } from 'utils';
|
||||||
|
import actions from 'data/actions';
|
||||||
|
import selectors from 'data/selectors';
|
||||||
|
|
||||||
|
import messages from './messages';
|
||||||
|
|
||||||
|
export const basicButtonProps = () => ({
|
||||||
|
variant: 'outline-primary',
|
||||||
|
icons: {
|
||||||
|
default: <Icon className="fa fa-download mr-2" />,
|
||||||
|
pending: <Icon className="fa fa-spinner fa-spin mr-2" />,
|
||||||
|
},
|
||||||
|
disabledStates: ['pending'],
|
||||||
|
className: 'ml-2',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const buttonStates = StrictDict({
|
||||||
|
pending: 'pending',
|
||||||
|
default: 'default',
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <BulkManagementControls />
|
||||||
|
* Provides download buttons for Bulk Management and Intervention reports, only if
|
||||||
|
* showBulkManagement is set in redus.
|
||||||
|
*/
|
||||||
|
export class BulkManagementControls extends React.Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.buttonProps = this.buttonProps.bind(this);
|
||||||
|
this.handleClickDownloadInterventions = this.handleClickDownloadInterventions.bind(this);
|
||||||
|
this.handleClickExportGrades = this.handleClickExportGrades.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
buttonProps(label) {
|
||||||
|
return {
|
||||||
|
labels: { default: label, pending: label },
|
||||||
|
state: this.props.showSpinner ? 'pending' : 'default',
|
||||||
|
...basicButtonProps(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
handleClickDownloadInterventions() {
|
||||||
|
this.props.downloadInterventionReport();
|
||||||
|
window.location.assign(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), label(not used), value(not used)
|
||||||
|
handleClickExportGrades() {
|
||||||
|
this.props.downloadBulkGradesReport();
|
||||||
|
window.location.assign(this.props.gradeExportUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return this.props.showBulkManagement && (
|
||||||
|
<div>
|
||||||
|
<StatefulButton
|
||||||
|
{...this.buttonProps(<FormattedMessage {...messages.bulkManagement} />)}
|
||||||
|
onClick={this.handleClickExportGrades}
|
||||||
|
/>
|
||||||
|
<StatefulButton
|
||||||
|
{...this.buttonProps(<FormattedMessage {...messages.interventions} />)}
|
||||||
|
onClick={this.handleClickDownloadInterventions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BulkManagementControls.defaultProps = {
|
||||||
|
showBulkManagement: false,
|
||||||
|
showSpinner: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
BulkManagementControls.propTypes = {
|
||||||
|
// redux
|
||||||
|
downloadBulkGradesReport: PropTypes.func.isRequired,
|
||||||
|
downloadInterventionReport: PropTypes.func.isRequired,
|
||||||
|
gradeExportUrl: PropTypes.string.isRequired,
|
||||||
|
interventionExportUrl: PropTypes.string.isRequired,
|
||||||
|
showSpinner: PropTypes.bool,
|
||||||
|
showBulkManagement: PropTypes.bool,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mapStateToProps = (state) => ({
|
||||||
|
gradeExportUrl: selectors.root.gradeExportUrl(state),
|
||||||
|
interventionExportUrl: selectors.root.interventionExportUrl(state),
|
||||||
|
showBulkManagement: selectors.root.showBulkManagement(state),
|
||||||
|
showSpinner: selectors.root.shouldShowSpinner(state),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const mapDispatchToProps = {
|
||||||
|
downloadBulkGradesReport: actions.grades.downloadReport.bulkGrades,
|
||||||
|
downloadInterventionReport: actions.grades.downloadReport.intervention,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(mapStateToProps, mapDispatchToProps)(BulkManagementControls);
|
||||||
168
src/components/GradesTab/BulkManagementControls.test.jsx
Normal file
168
src/components/GradesTab/BulkManagementControls.test.jsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { shallow } from 'enzyme';
|
||||||
|
|
||||||
|
import actions from 'data/actions';
|
||||||
|
import selectors from 'data/selectors';
|
||||||
|
|
||||||
|
import {
|
||||||
|
BulkManagementControls,
|
||||||
|
basicButtonProps,
|
||||||
|
buttonStates,
|
||||||
|
mapStateToProps,
|
||||||
|
mapDispatchToProps,
|
||||||
|
} from './BulkManagementControls';
|
||||||
|
|
||||||
|
jest.mock('data/selectors', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
root: {
|
||||||
|
gradeExportUrl: (state) => ({ gradeExportUrl: state }),
|
||||||
|
interventionExportUrl: (state) => ({ interventionExportUrl: state }),
|
||||||
|
showBulkManagement: (state) => ({ showBulkManagement: state }),
|
||||||
|
shouldShowSpinner: (state) => ({ showSpinner: state }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
jest.mock('data/actions', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
grades: {
|
||||||
|
downloadReport: {
|
||||||
|
bulkGrades: jest.fn(),
|
||||||
|
intervention: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('BulkManagementControls', () => {
|
||||||
|
describe('component', () => {
|
||||||
|
let el;
|
||||||
|
let props = {
|
||||||
|
gradeExportUrl: 'gradesGoHere',
|
||||||
|
interventionExportUrl: 'interventionsGoHere',
|
||||||
|
};
|
||||||
|
beforeEach(() => {
|
||||||
|
props = {
|
||||||
|
...props,
|
||||||
|
downloadBulkGradesReport: jest.fn(),
|
||||||
|
downloadInterventionReport: jest.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
test('snapshot - empty if showBulkManagement is not truthy', () => {
|
||||||
|
expect(shallow(<BulkManagementControls {...props} />)).toEqual({});
|
||||||
|
});
|
||||||
|
test('snapshot - buttonProps for each button ("Bulk Management" and "Interventions")', () => {
|
||||||
|
el = shallow(<BulkManagementControls {...props} showBulkManagement />);
|
||||||
|
jest.spyOn(el.instance(), 'buttonProps').mockImplementation(
|
||||||
|
value => ({ buttonProps: value }),
|
||||||
|
);
|
||||||
|
jest.spyOn(el.instance(), 'handleClickExportGrades').mockName('this.handleClickExportGrades');
|
||||||
|
jest.spyOn(
|
||||||
|
el.instance(),
|
||||||
|
'handleClickDownloadInterventions',
|
||||||
|
).mockName('this.handleClickDownloadInterventions');
|
||||||
|
});
|
||||||
|
describe('behavior', () => {
|
||||||
|
const oldWindowLocation = window.location;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
delete window.location;
|
||||||
|
window.location = Object.defineProperties(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
...Object.getOwnPropertyDescriptors(oldWindowLocation),
|
||||||
|
assign: {
|
||||||
|
configurable: true,
|
||||||
|
value: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
beforeEach(() => {
|
||||||
|
window.location.assign.mockReset();
|
||||||
|
el = shallow(<BulkManagementControls {...props} showBulkManagement />);
|
||||||
|
});
|
||||||
|
afterAll(() => {
|
||||||
|
// restore `window.location` to the `jsdom` `Location` object
|
||||||
|
window.location = oldWindowLocation;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buttonProps', () => {
|
||||||
|
test('loads default and pending labels based on passed string', () => {
|
||||||
|
const label = 'Fake Label';
|
||||||
|
const { labels, state, ...rest } = el.instance().buttonProps(label);
|
||||||
|
expect(rest).toEqual(basicButtonProps());
|
||||||
|
expect(labels).toEqual({ default: label, pending: label });
|
||||||
|
});
|
||||||
|
test('loads pending state if props.showSpinner', () => {
|
||||||
|
const label = 'Fake Label';
|
||||||
|
el.setProps({ showSpinner: true });
|
||||||
|
const { labels, state, ...rest } = el.instance().buttonProps(label);
|
||||||
|
expect(state).toEqual(buttonStates.pending);
|
||||||
|
expect(rest).toEqual(basicButtonProps());
|
||||||
|
});
|
||||||
|
test('loads default state if not props.showSpinner', () => {
|
||||||
|
const label = 'Fake Label';
|
||||||
|
const { labels, state, ...rest } = el.instance().buttonProps(label);
|
||||||
|
expect(state).toEqual(buttonStates.default);
|
||||||
|
expect(rest).toEqual(basicButtonProps());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('handleClickDownloadInterventions', () => {
|
||||||
|
const assertions = [
|
||||||
|
'calls props.downloadInterventionReport',
|
||||||
|
'sets location to props.interventionsExportUrl',
|
||||||
|
];
|
||||||
|
it(assertions.join(' and '), () => {
|
||||||
|
el.instance().handleClickDownloadInterventions();
|
||||||
|
expect(props.downloadInterventionReport).toHaveBeenCalled();
|
||||||
|
expect(window.location.assign).toHaveBeenCalledWith(props.interventionExportUrl);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('handleClickExportGrades', () => {
|
||||||
|
const assertions = [
|
||||||
|
'calls props.downloadBulkGradesReport',
|
||||||
|
'sets location to props.gradeExportUrl',
|
||||||
|
];
|
||||||
|
it(assertions.join(' and '), () => {
|
||||||
|
el.instance().handleClickExportGrades();
|
||||||
|
expect(props.downloadBulkGradesReport).toHaveBeenCalled();
|
||||||
|
expect(window.location.assign).toHaveBeenCalledWith(props.gradeExportUrl);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mapStateToProps', () => {
|
||||||
|
let mapped;
|
||||||
|
const testState = { do: 'not', test: 'me' };
|
||||||
|
beforeEach(() => {
|
||||||
|
mapped = mapStateToProps(testState);
|
||||||
|
});
|
||||||
|
test('gradeExportUrl from root.gradeExportUrl', () => {
|
||||||
|
expect(mapped.gradeExportUrl).toEqual(selectors.root.gradeExportUrl(testState));
|
||||||
|
});
|
||||||
|
test('interventionExportUrl from root.interventionExportUrl', () => {
|
||||||
|
expect(mapped.interventionExportUrl).toEqual(selectors.root.interventionExportUrl(testState));
|
||||||
|
});
|
||||||
|
test('showBulkManagement from root.showBulkManagement', () => {
|
||||||
|
expect(mapped.showBulkManagement).toEqual(selectors.root.showBulkManagement(testState));
|
||||||
|
});
|
||||||
|
test('showSpinner from root.shouldShowSpinner', () => {
|
||||||
|
expect(mapped.showSpinner).toEqual(selectors.root.shouldShowSpinner(testState));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('mapDispatchToProps', () => {
|
||||||
|
test('downloadBulkGradesReport from actions.grades.downloadReport.bulkGrades', () => {
|
||||||
|
expect(
|
||||||
|
mapDispatchToProps.downloadBulkGradesReport,
|
||||||
|
).toEqual(actions.grades.downloadReport.bulkGrades);
|
||||||
|
});
|
||||||
|
test('downloadInterventionReport from actions.grades.downloadReport.invervention', () => {
|
||||||
|
expect(
|
||||||
|
mapDispatchToProps.downloadInterventionReport,
|
||||||
|
).toEqual(actions.grades.downloadReport.intervention);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
26
src/components/GradesTab/EditModal/HistoryHeader.jsx
Normal file
26
src/components/GradesTab/EditModal/HistoryHeader.jsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HistoryHeader
|
||||||
|
* simple display container for an individual history table header
|
||||||
|
* @param {string} id - header id
|
||||||
|
* @param {string} label - header label
|
||||||
|
* @param {string} value - header value
|
||||||
|
*/
|
||||||
|
const HistoryHeader = ({ id, label, value }) => (
|
||||||
|
<div>
|
||||||
|
<div className={`grade-history-header grade-history-${id}`}>{label}: </div>
|
||||||
|
<div>{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
HistoryHeader.defaultProps = {
|
||||||
|
value: null,
|
||||||
|
};
|
||||||
|
HistoryHeader.propTypes = {
|
||||||
|
id: PropTypes.string.isRequired,
|
||||||
|
label: PropTypes.node.isRequired,
|
||||||
|
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HistoryHeader;
|
||||||
17
src/components/GradesTab/EditModal/HistoryHeader.test.jsx
Normal file
17
src/components/GradesTab/EditModal/HistoryHeader.test.jsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { shallow } from 'enzyme';
|
||||||
|
|
||||||
|
import HistoryHeader from './HistoryHeader';
|
||||||
|
|
||||||
|
describe('HistoryHeader', () => {
|
||||||
|
const props = {
|
||||||
|
id: 'water',
|
||||||
|
label: 'Brita',
|
||||||
|
value: 'hydration',
|
||||||
|
};
|
||||||
|
describe('Component', () => {
|
||||||
|
test('snapshot', () => {
|
||||||
|
expect(shallow(<HistoryHeader {...props} />)).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
68
src/components/GradesTab/EditModal/ModalHeaders.jsx
Normal file
68
src/components/GradesTab/EditModal/ModalHeaders.jsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
|
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
|
import selectors from 'data/selectors';
|
||||||
|
|
||||||
|
import messages from './messages';
|
||||||
|
import HistoryHeader from './HistoryHeader';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <ModalHeaders />
|
||||||
|
* Provides a list of HistoryHeaders for the student name, assignment,
|
||||||
|
* original grade, and current override grade.
|
||||||
|
*/
|
||||||
|
export const ModalHeaders = ({
|
||||||
|
modalState,
|
||||||
|
originalGrade,
|
||||||
|
currentGrade,
|
||||||
|
}) => (
|
||||||
|
<div>
|
||||||
|
<HistoryHeader
|
||||||
|
id="assignment"
|
||||||
|
label={<FormattedMessage {...messages.assignmentHeader} />}
|
||||||
|
value={modalState.assignmentName}
|
||||||
|
/>
|
||||||
|
<HistoryHeader
|
||||||
|
id="student"
|
||||||
|
label={<FormattedMessage {...messages.studentHeader} />}
|
||||||
|
value={modalState.updateUserName}
|
||||||
|
/>
|
||||||
|
<HistoryHeader
|
||||||
|
id="original-grade"
|
||||||
|
label={<FormattedMessage {...messages.originalGradeHeader} />}
|
||||||
|
value={originalGrade}
|
||||||
|
/>
|
||||||
|
<HistoryHeader
|
||||||
|
id="current-grade"
|
||||||
|
label={<FormattedMessage {...messages.currentGradeHeader} />}
|
||||||
|
value={currentGrade}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
ModalHeaders.defaultProps = {
|
||||||
|
currentGrade: null,
|
||||||
|
originalGrade: null,
|
||||||
|
};
|
||||||
|
ModalHeaders.propTypes = {
|
||||||
|
// redux
|
||||||
|
currentGrade: PropTypes.number,
|
||||||
|
originalGrade: PropTypes.number,
|
||||||
|
modalState: PropTypes.shape({
|
||||||
|
assignmentName: PropTypes.string.isRequired,
|
||||||
|
updateUserName: PropTypes.string,
|
||||||
|
}).isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mapStateToProps = (state) => ({
|
||||||
|
modalState: {
|
||||||
|
assignmentName: selectors.app.modalState.assignmentName(state),
|
||||||
|
updateUserName: selectors.app.modalState.updateUserName(state),
|
||||||
|
},
|
||||||
|
currentGrade: selectors.grades.gradeOverrideCurrentEarnedGradedOverride(state),
|
||||||
|
originalGrade: selectors.grades.gradeOriginalEarnedGraded(state),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default connect(mapStateToProps)(ModalHeaders);
|
||||||
94
src/components/GradesTab/EditModal/ModalHeaders.test.jsx
Normal file
94
src/components/GradesTab/EditModal/ModalHeaders.test.jsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { shallow } from 'enzyme';
|
||||||
|
|
||||||
|
import selectors from 'data/selectors';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ModalHeaders,
|
||||||
|
mapStateToProps,
|
||||||
|
} from './ModalHeaders';
|
||||||
|
|
||||||
|
jest.mock('./HistoryHeader', () => 'HistoryHeader');
|
||||||
|
|
||||||
|
jest.mock('data/selectors', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
app: {
|
||||||
|
editUpdateData: jest.fn(state => ({ editUpdateData: state })),
|
||||||
|
modalState: {
|
||||||
|
assignmentName: jest.fn(state => ({ assignmentName: state })),
|
||||||
|
updateUserName: jest.fn(state => ({ updateUserName: state })),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
grades: {
|
||||||
|
gradeOverrideCurrentEarnedGradedOverride: jest.fn(state => ({ currentGrade: state })),
|
||||||
|
gradeOriginalEarnedGraded: jest.fn(state => ({ originalGrade: state })),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
describe('ModalHeaders', () => {
|
||||||
|
let el;
|
||||||
|
const props = {
|
||||||
|
currentGrade: 2,
|
||||||
|
originalGrade: 20,
|
||||||
|
modalState: {
|
||||||
|
assignmentName: 'Qwerty',
|
||||||
|
updateUserName: 'Uiop',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Component', () => {
|
||||||
|
describe('snapshots', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
});
|
||||||
|
describe('gradeOverrideHistoryError is and empty and open is true', () => {
|
||||||
|
test('modal open and StatusAlert showing', () => {
|
||||||
|
el = shallow(<ModalHeaders {...props} />);
|
||||||
|
expect(el).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('gradeOverrideHistoryError is empty and open is false', () => {
|
||||||
|
test('modal closed and StatusAlert closed', () => {
|
||||||
|
el = shallow(
|
||||||
|
<ModalHeaders {...props} open={false} gradeOverrideHistoryError="" />,
|
||||||
|
);
|
||||||
|
expect(el).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mapStateToProps', () => {
|
||||||
|
const testState = { he: 'lives in a', pineapple: 'under the sea' };
|
||||||
|
let mapped;
|
||||||
|
beforeEach(() => {
|
||||||
|
mapped = mapStateToProps(testState);
|
||||||
|
});
|
||||||
|
describe('modalState', () => {
|
||||||
|
test('assignmentName from app.modalState.assignmentName', () => {
|
||||||
|
expect(
|
||||||
|
mapped.modalState.assignmentName,
|
||||||
|
).toEqual(selectors.app.modalState.assignmentName(testState));
|
||||||
|
});
|
||||||
|
test('updateUserName from app.modalState.updateUserName', () => {
|
||||||
|
expect(
|
||||||
|
mapped.modalState.updateUserName,
|
||||||
|
).toEqual(selectors.app.modalState.updateUserName(testState));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('originalGrade', () => {
|
||||||
|
test('from grades.gradeOverrideCurrentEarnedGradedOverride', () => {
|
||||||
|
expect(mapped.currentGrade).toEqual(
|
||||||
|
selectors.grades.gradeOverrideCurrentEarnedGradedOverride(testState),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('originalGrade', () => {
|
||||||
|
test('from grades.gradeOriginalEarnedGrades', () => {
|
||||||
|
expect(mapped.originalGrade).toEqual(
|
||||||
|
selectors.grades.gradeOriginalEarnedGraded(testState),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/* eslint-disable react/sort-comp, react/button-has-type */
|
||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
|
import { Form } from '@edx/paragon';
|
||||||
|
|
||||||
|
import selectors from 'data/selectors';
|
||||||
|
import actions from 'data/actions';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <AdjustedGradeInput />
|
||||||
|
* Input control for adjusting the grade of a unit
|
||||||
|
* displays an "/ ${possibleGrade} if there is one in the data model.
|
||||||
|
*/
|
||||||
|
export class AdjustedGradeInput extends React.Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.onChange = this.onChange.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange = ({ target }) => {
|
||||||
|
this.props.setModalState({ adjustedGradeValue: target.value });
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
<Form.Control
|
||||||
|
type="text"
|
||||||
|
name="adjustedGradeValue"
|
||||||
|
value={this.props.value}
|
||||||
|
onChange={this.onChange}
|
||||||
|
/>
|
||||||
|
{this.props.possibleGrade && ` / ${this.props.possibleGrade}`}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AdjustedGradeInput.defaultProps = {
|
||||||
|
possibleGrade: null,
|
||||||
|
};
|
||||||
|
AdjustedGradeInput.propTypes = {
|
||||||
|
value: PropTypes.oneOfType([
|
||||||
|
PropTypes.string,
|
||||||
|
PropTypes.number,
|
||||||
|
]).isRequired,
|
||||||
|
possibleGrade: PropTypes.oneOfType([
|
||||||
|
PropTypes.string,
|
||||||
|
PropTypes.number,
|
||||||
|
]),
|
||||||
|
setModalState: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mapStateToProps = (state) => ({
|
||||||
|
possibleGrade: selectors.root.editModalPossibleGrade(state),
|
||||||
|
value: selectors.app.modalState.adjustedGradeValue(state),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const mapDispatchToProps = {
|
||||||
|
setModalState: actions.app.setModalState,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(mapStateToProps, mapDispatchToProps)(AdjustedGradeInput);
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { shallow } from 'enzyme';
|
||||||
|
|
||||||
|
import actions from 'data/actions';
|
||||||
|
import selectors from 'data/selectors';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AdjustedGradeInput,
|
||||||
|
mapStateToProps,
|
||||||
|
mapDispatchToProps,
|
||||||
|
} from './AdjustedGradeInput';
|
||||||
|
|
||||||
|
jest.mock('@edx/paragon', () => ({
|
||||||
|
Form: { Control: () => 'Form.Control' },
|
||||||
|
}));
|
||||||
|
jest.mock('data/selectors', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
root: {
|
||||||
|
editModalPossibleGrade: jest.fn(state => ({ updateUserName: state })),
|
||||||
|
},
|
||||||
|
app: {
|
||||||
|
modalState: { adjustedGradeValue: jest.fn(state => ({ adjustedGradeValue: state })) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
jest.mock('data/actions', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
app: { setModalState: jest.fn() },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
describe('AdjustedGradeInput', () => {
|
||||||
|
let el;
|
||||||
|
let props = {
|
||||||
|
value: 1,
|
||||||
|
possibleGrade: 5,
|
||||||
|
};
|
||||||
|
beforeEach(() => {
|
||||||
|
props = {
|
||||||
|
...props,
|
||||||
|
setModalState: jest.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
describe('Component', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
el = shallow(<AdjustedGradeInput {...props} />);
|
||||||
|
});
|
||||||
|
describe('snapshots', () => {
|
||||||
|
test('displays input control and "out of possible grade" label', () => {
|
||||||
|
el.instance().onChange = jest.fn().mockName('this.onChange');
|
||||||
|
expect(el.instance().render()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('behavior', () => {
|
||||||
|
describe('onChange', () => {
|
||||||
|
it('calls props.setModalState event target value', () => {
|
||||||
|
const value = 42;
|
||||||
|
el.instance().onChange({ target: { value } });
|
||||||
|
expect(props.setModalState).toHaveBeenCalledWith({
|
||||||
|
adjustedGradeValue: value,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mapStateToProps', () => {
|
||||||
|
const testState = { like: 'no one', ever: 'was' };
|
||||||
|
let mapped;
|
||||||
|
beforeEach(() => {
|
||||||
|
mapped = mapStateToProps(testState);
|
||||||
|
});
|
||||||
|
describe('modalState', () => {
|
||||||
|
test('possibleGrade from root.editModalPossibleGrade', () => {
|
||||||
|
expect(
|
||||||
|
mapped.possibleGrade,
|
||||||
|
).toEqual(selectors.root.editModalPossibleGrade(testState));
|
||||||
|
});
|
||||||
|
test('updateUserName from app.modalState.updateUserName', () => {
|
||||||
|
expect(
|
||||||
|
mapped.value,
|
||||||
|
).toEqual(selectors.app.modalState.adjustedGradeValue(testState));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('mapDispatchToProps', () => {
|
||||||
|
test('setModalState from actions.app.setModalState', () => {
|
||||||
|
expect(mapDispatchToProps.setModalState).toEqual(actions.app.setModalState);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
|
import { Form } from '@edx/paragon';
|
||||||
|
|
||||||
|
import selectors from 'data/selectors';
|
||||||
|
import actions from 'data/actions';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <ReasonInput />
|
||||||
|
* Input control for the "reason for change" field in the Edit modal.
|
||||||
|
*/
|
||||||
|
export class ReasonInput extends React.Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.ref = React.createRef();
|
||||||
|
this.onChange = this.onChange.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.ref.current.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange = (event) => {
|
||||||
|
this.props.setModalState({ reasonForChange: event.target.value });
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<Form.Control
|
||||||
|
type="text"
|
||||||
|
name="reasonForChange"
|
||||||
|
value={this.props.value}
|
||||||
|
onChange={this.onChange}
|
||||||
|
ref={this.ref}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ReasonInput.propTypes = {
|
||||||
|
// redux
|
||||||
|
setModalState: PropTypes.func.isRequired,
|
||||||
|
value: PropTypes.string.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mapStateToProps = (state) => ({
|
||||||
|
value: selectors.app.modalState.reasonForChange(state),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const mapDispatchToProps = {
|
||||||
|
setModalState: actions.app.setModalState,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(mapStateToProps, mapDispatchToProps)(ReasonInput);
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { shallow } from 'enzyme';
|
||||||
|
|
||||||
|
import actions from 'data/actions';
|
||||||
|
import selectors from 'data/selectors';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ReasonInput,
|
||||||
|
mapStateToProps,
|
||||||
|
mapDispatchToProps,
|
||||||
|
} from './ReasonInput';
|
||||||
|
|
||||||
|
jest.mock('@edx/paragon', () => ({
|
||||||
|
Form: { Control: () => 'Form.Control' },
|
||||||
|
}));
|
||||||
|
jest.mock('data/selectors', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
app: {
|
||||||
|
modalState: { reasonForChange: jest.fn(state => ({ reasonForChange: state })) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
jest.mock('data/actions', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
app: { setModalState: jest.fn() },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
describe('ReasonInput', () => {
|
||||||
|
let el;
|
||||||
|
let props = {
|
||||||
|
value: 'did not answer the question',
|
||||||
|
};
|
||||||
|
beforeEach(() => {
|
||||||
|
props = {
|
||||||
|
...props,
|
||||||
|
setModalState: jest.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
describe('Component', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
el = shallow(<ReasonInput {...props} />, { disableLifecycleMethods: true });
|
||||||
|
});
|
||||||
|
describe('snapshots', () => {
|
||||||
|
test('displays reason for change input control', () => {
|
||||||
|
el.instance().onChange = jest.fn().mockName('this.onChange');
|
||||||
|
expect(el.instance().render()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('behavior', () => {
|
||||||
|
describe('onChange', () => {
|
||||||
|
it('calls props.setModalState event target value', () => {
|
||||||
|
const value = 42;
|
||||||
|
el.instance().onChange({ target: { value } });
|
||||||
|
expect(props.setModalState).toHaveBeenCalledWith({
|
||||||
|
reasonForChange: value,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('componentDidMount', () => {
|
||||||
|
it('focuses the input ref', () => {
|
||||||
|
const focus = jest.fn();
|
||||||
|
expect(el.instance().ref).toEqual({ current: null });
|
||||||
|
el.instance().ref.current = { focus };
|
||||||
|
el.instance().componentDidMount();
|
||||||
|
expect(el.instance().ref.current.focus).toHaveBeenCalledWith();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mapStateToProps', () => {
|
||||||
|
const testState = { to: { catchThem: 'my real test', trainThem: 'my cause!' } };
|
||||||
|
let mapped;
|
||||||
|
beforeEach(() => {
|
||||||
|
mapped = mapStateToProps(testState);
|
||||||
|
});
|
||||||
|
describe('modalState', () => {
|
||||||
|
test('value from app.modalState.reasonForChange', () => {
|
||||||
|
expect(mapped.value).toEqual(selectors.app.modalState.reasonForChange(testState));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('mapDispatchToProps', () => {
|
||||||
|
test('setModalState from actions.app.setModalState', () => {
|
||||||
|
expect(mapDispatchToProps.setModalState).toEqual(actions.app.setModalState);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`AdjustedGradeInput Component snapshots displays input control and "out of possible grade" label 1`] = `
|
||||||
|
<span>
|
||||||
|
<Control
|
||||||
|
name="adjustedGradeValue"
|
||||||
|
onChange={[MockFunction this.onChange]}
|
||||||
|
type="text"
|
||||||
|
value={1}
|
||||||
|
/>
|
||||||
|
/ 5
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`ReasonInput Component snapshots displays reason for change input control 1`] = `
|
||||||
|
<Control
|
||||||
|
name="reasonForChange"
|
||||||
|
onChange={[MockFunction this.onChange]}
|
||||||
|
type="text"
|
||||||
|
value="did not answer the question"
|
||||||
|
/>
|
||||||
|
`;
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`OverrideTable Component snapshots basic snapshot shows a row for each entry and one editable row 1`] = `
|
||||||
|
<Table
|
||||||
|
columns={
|
||||||
|
Array [
|
||||||
|
Object {
|
||||||
|
"key": "date",
|
||||||
|
"label": <FormattedMessage
|
||||||
|
defaultMessage="Date"
|
||||||
|
description="Edit Modal Override Table Date column header"
|
||||||
|
id="gradebook.GradesTab.EditModal.Overrides.dateHeader"
|
||||||
|
/>,
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"key": "grader",
|
||||||
|
"label": <FormattedMessage
|
||||||
|
defaultMessage="Grader"
|
||||||
|
description="Edit Modal Override Table Grader column header"
|
||||||
|
id="gradebook.GradesTab.EditModal.Overrides.graderHeader"
|
||||||
|
/>,
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"key": "reason",
|
||||||
|
"label": <FormattedMessage
|
||||||
|
defaultMessage="Reason"
|
||||||
|
description="Edit Modal Override Table Reason column header"
|
||||||
|
id="gradebook.GradesTab.EditModal.Overrides.reasonHeader"
|
||||||
|
/>,
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"key": "adjustedGrade",
|
||||||
|
"label": <FormattedMessage
|
||||||
|
defaultMessage="Adjusted grade"
|
||||||
|
description="Edit Modal Override Table Adjusted grade column header"
|
||||||
|
id="gradebook.GradesTab.EditModal.Overrides.adjustedGradeHeader"
|
||||||
|
/>,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
data={
|
||||||
|
Array [
|
||||||
|
Object {
|
||||||
|
"adjustedGrade": 0,
|
||||||
|
"date": "yesterday",
|
||||||
|
"grader": "me",
|
||||||
|
"reason": "you ate my sandwich",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"adjustedGrade": 20,
|
||||||
|
"date": "today",
|
||||||
|
"grader": "me",
|
||||||
|
"reason": "you brought me a new sandwich",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"adjustedGrade": <AdjustedGradeInput />,
|
||||||
|
"date": "todaaaaaay",
|
||||||
|
"reason": <ReasonInput />,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
`;
|
||||||
72
src/components/GradesTab/EditModal/OverrideTable/index.jsx
Normal file
72
src/components/GradesTab/EditModal/OverrideTable/index.jsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
/* eslint-disable react/sort-comp, react/button-has-type, import/no-named-as-default */
|
||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
|
import { Table } from '@edx/paragon';
|
||||||
|
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
|
import { gradeOverrideHistoryColumns as columns } from 'data/constants/app';
|
||||||
|
import selectors from 'data/selectors';
|
||||||
|
|
||||||
|
import messages from './messages';
|
||||||
|
import ReasonInput from './ReasonInput';
|
||||||
|
import AdjustedGradeInput from './AdjustedGradeInput';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <OverrideTable />
|
||||||
|
* Table containing previous grade override entries, and an "edit" row
|
||||||
|
* with todays date, an AdjustedGradeInput and a ReasonInput
|
||||||
|
*/
|
||||||
|
export const OverrideTable = ({
|
||||||
|
hide,
|
||||||
|
gradeOverrides,
|
||||||
|
todaysDate,
|
||||||
|
}) => {
|
||||||
|
if (hide) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Table
|
||||||
|
columns={[
|
||||||
|
{ label: <FormattedMessage {...messages.dateHeader} />, key: columns.date },
|
||||||
|
{ label: <FormattedMessage {...messages.graderHeader} />, key: columns.grader },
|
||||||
|
{ label: <FormattedMessage {...messages.reasonHeader} />, key: columns.reason },
|
||||||
|
{
|
||||||
|
label: <FormattedMessage {...messages.adjustedGradeHeader} />,
|
||||||
|
key: columns.adjustedGrade,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
data={[
|
||||||
|
...gradeOverrides,
|
||||||
|
{
|
||||||
|
adjustedGrade: <AdjustedGradeInput />,
|
||||||
|
date: todaysDate,
|
||||||
|
reason: <ReasonInput />,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
OverrideTable.defaultProps = {
|
||||||
|
gradeOverrides: [],
|
||||||
|
};
|
||||||
|
OverrideTable.propTypes = {
|
||||||
|
// redux
|
||||||
|
gradeOverrides: PropTypes.arrayOf(PropTypes.shape({
|
||||||
|
date: PropTypes.string,
|
||||||
|
grader: PropTypes.string,
|
||||||
|
reason: PropTypes.string,
|
||||||
|
adjustedGrade: PropTypes.number,
|
||||||
|
})),
|
||||||
|
hide: PropTypes.bool.isRequired,
|
||||||
|
todaysDate: PropTypes.string.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mapStateToProps = (state) => ({
|
||||||
|
hide: selectors.grades.hasOverrideErrors(state),
|
||||||
|
gradeOverrides: selectors.grades.gradeOverrides(state),
|
||||||
|
todaysDate: selectors.app.modalState.todaysDate(state),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default connect(mapStateToProps)(OverrideTable);
|
||||||
26
src/components/GradesTab/EditModal/OverrideTable/messages.js
Normal file
26
src/components/GradesTab/EditModal/OverrideTable/messages.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
adjustedGradeHeader: {
|
||||||
|
id: 'gradebook.GradesTab.EditModal.Overrides.adjustedGradeHeader',
|
||||||
|
defaultMessage: 'Adjusted grade',
|
||||||
|
description: 'Edit Modal Override Table Adjusted grade column header',
|
||||||
|
},
|
||||||
|
dateHeader: {
|
||||||
|
id: 'gradebook.GradesTab.EditModal.Overrides.dateHeader',
|
||||||
|
defaultMessage: 'Date',
|
||||||
|
description: 'Edit Modal Override Table Date column header',
|
||||||
|
},
|
||||||
|
graderHeader: {
|
||||||
|
id: 'gradebook.GradesTab.EditModal.Overrides.graderHeader',
|
||||||
|
defaultMessage: 'Grader',
|
||||||
|
description: 'Edit Modal Override Table Grader column header',
|
||||||
|
},
|
||||||
|
reasonHeader: {
|
||||||
|
id: 'gradebook.GradesTab.EditModal.Overrides.reasonHeader',
|
||||||
|
defaultMessage: 'Reason',
|
||||||
|
description: 'Edit Modal Override Table Reason column header',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default messages;
|
||||||
81
src/components/GradesTab/EditModal/OverrideTable/test.jsx
Normal file
81
src/components/GradesTab/EditModal/OverrideTable/test.jsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { shallow } from 'enzyme';
|
||||||
|
|
||||||
|
import selectors from 'data/selectors';
|
||||||
|
|
||||||
|
import {
|
||||||
|
OverrideTable,
|
||||||
|
mapStateToProps,
|
||||||
|
} from '.';
|
||||||
|
|
||||||
|
jest.mock('@edx/paragon', () => ({ Table: () => 'Table' }));
|
||||||
|
jest.mock('./ReasonInput', () => 'ReasonInput');
|
||||||
|
jest.mock('./AdjustedGradeInput', () => 'AdjustedGradeInput');
|
||||||
|
|
||||||
|
jest.mock('data/selectors', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
app: {
|
||||||
|
modalState: {
|
||||||
|
todaysDate: jest.fn(state => ({ todaysDate: state })),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
grades: {
|
||||||
|
hasOverrideErrors: jest.fn(state => ({ hasOverrideErrors: state })),
|
||||||
|
gradeOverrides: jest.fn(state => ({ gradeOverrides: state })),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('OverrideTable', () => {
|
||||||
|
const props = {
|
||||||
|
gradeOverrides: [
|
||||||
|
{
|
||||||
|
date: 'yesterday',
|
||||||
|
grader: 'me',
|
||||||
|
reason: 'you ate my sandwich',
|
||||||
|
adjustedGrade: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: 'today',
|
||||||
|
grader: 'me',
|
||||||
|
reason: 'you brought me a new sandwich',
|
||||||
|
adjustedGrade: 20,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
hide: false,
|
||||||
|
todaysDate: 'todaaaaaay',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Component', () => {
|
||||||
|
describe('snapshots', () => {
|
||||||
|
it('returns null if hide is true', () => {
|
||||||
|
expect(shallow(<OverrideTable {...props} hide />)).toEqual({});
|
||||||
|
});
|
||||||
|
describe('basic snapshot', () => {
|
||||||
|
test('shows a row for each entry and one editable row', () => {
|
||||||
|
expect(shallow(<OverrideTable {...props} />)).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mapStateToProps', () => {
|
||||||
|
const testState = { I: 'wanna', be: 'the', very: 'best' };
|
||||||
|
let mapped;
|
||||||
|
beforeEach(() => {
|
||||||
|
mapped = mapStateToProps(testState);
|
||||||
|
});
|
||||||
|
describe('modalState', () => {
|
||||||
|
test('hide from grades.hasOverrideErrors', () => {
|
||||||
|
expect(mapped.hide).toEqual(selectors.grades.hasOverrideErrors(testState));
|
||||||
|
});
|
||||||
|
test('gradeOverrides from grades.gradeOverrides', () => {
|
||||||
|
expect(mapped.gradeOverrides).toEqual(selectors.grades.gradeOverrides(testState));
|
||||||
|
});
|
||||||
|
test('todaysData from app.modalState.todaysDate', () => {
|
||||||
|
expect(mapped.todaysDate).toEqual(selectors.app.modalState.todaysDate(testState));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user