Compare commits

..

66 Commits

Author SHA1 Message Date
Ben Warzeski
6ae048d9f1 update version to 1.4.29 2021-05-25 15:06:02 -04:00
Ben Warzeski
f0f3212843 some cleanup 2021-05-25 10:31:03 -04:00
Ben Warzeski
3e7a79c3e1 fix assignment filtering 2021-05-25 10:31:03 -04:00
Ben Warzeski
0e4541b7e3 docstrings 2021-05-25 10:31:03 -04:00
Ben Warzeski
452b39ddc5 docstrings for test utils 2021-05-25 10:31:02 -04:00
Ben Warzeski
126787b50f strict selector export 2021-05-25 10:31:02 -04:00
Ben Warzeski
c295207ed2 add a bit of test coverage 2021-05-25 10:31:02 -04:00
Ben Warzeski
c0d4e3a8f3 update package-lock.json 2021-05-25 10:30:59 -04:00
Ben Warzeski
e7bfdb7c8d fix store action reference 2021-05-25 10:28:39 -04:00
Ben Warzeski
0736c44d80 selectors cleanup 2021-05-25 10:28:39 -04:00
Ben Warzeski
2335f4b9e6 actions testing and cleanup 2021-05-25 10:28:39 -04:00
Ben Warzeski
1385b1a31a assignment type actions tests 2021-05-25 10:28:38 -04:00
Ben Warzeski
7e0e286efe fix tests 2021-05-25 10:28:38 -04:00
Ben Warzeski
92035af5d7 a little bit of doc and syntax cleanup 2021-05-25 10:28:38 -04:00
Ben Warzeski
0220efcc0b linting 2021-05-25 10:28:38 -04:00
Ben Warzeski
4be9ba9aa4 component thunkAction reference updates 2021-05-25 10:28:38 -04:00
Ben Warzeski
9d6cf2e06b thunkActions tests 2021-05-25 10:28:38 -04:00
Ben Warzeski
38324a0fc9 testing 2021-05-25 10:28:38 -04:00
Ben Warzeski
ced162356a update actions to use redux toolkit action creators 2021-05-25 10:28:38 -04:00
Leangseu Kim
e8aca4fde2 remove unnecessary testing data
update
2021-05-25 10:28:38 -04:00
Leangseu Kim
b7004e6e86 reorder the test reducer's handler 2021-05-25 10:28:38 -04:00
Leangseu Kim
12a85abb96 update unit test 2021-05-25 10:28:38 -04:00
Leangseu Kim
dfe6dbae8f export reducers from initial state 2021-05-25 10:28:38 -04:00
Leangseu Kim
21ec5fbbe5 update unit testing for reducers 2021-05-25 10:28:38 -04:00
Leangseu Kim
83701acc16 update testing for reducers 2021-05-25 10:28:38 -04:00
Ben Warzeski
b6b431dc37 ready for testing 2021-05-25 10:28:38 -04:00
Ben Warzeski
751d6f4a42 add StrictDict 2021-05-25 10:28:38 -04:00
Ben Warzeski
b2a737e936 update actions to use redux toolkit action creators 2021-05-25 10:28:38 -04:00
Ben Warzeski
1f93215648 add redux-toolkit 2021-05-25 10:28:37 -04:00
Nathan Sprenkle
3bc2511cc1 fix: bad prop causing bulk mgmt errors to not show (#184) 2021-05-18 11:04:57 -04:00
leangseu-edx
f60e3c1188 fix:csv override previous fail state on success (#183)
On success and set uploadSuccess to true every time. This is important for unit testing.
[EDUCATOR-5796]
2021-05-17 14:29:40 -04:00
Nathan Sprenkle
807a57d947 Add tests for Redux selectors (#180)
* test: add selector tests

* refactor: remove unused typeOfSelectedAssignment

* chore: bump version to 1.4.26

Co-authored-by: Ben Warzeski <bwarzeski@edx.org>
2021-05-11 14:04:05 -04:00
Sarina Canelake
0c242ab6f0 Merge pull request #177 from pdpinch/patch-1
docs: correct link to bulk grade management
2021-05-07 16:01:08 -04:00
Nathan Sprenkle
ee2c573017 Clean up MFE/Redux usage (#179)
* refactor: clean up/standardize selector usage

* fix: fix eslint errors

* chore: bump version to 1.4.25

Co-authored-by: Ben Warzeski <bwarzeski@edx.org>
2021-05-07 12:44:00 -04:00
David Joy
4fdc541992 fix: use SITE_NAME env var for index.html title (#178)
We currently have a hard-coded “edX” string in Gradebook’s index.html file.  This replaces that string with the value of the SITE_NAME environment variable, which is set at build time from .env.development for dev, and from the build process at production build-time.
2021-05-04 13:40:08 -04:00
Peter Pinch
658b45136e docs: correct link to bulk grade management
a.k.a. Override Learner Subsection Scores in Bulk
2021-05-01 11:32:07 -04:00
Jansen Kantor
61fdb31316 feat: always show total as a pct, add tooltip (#170) 2021-04-29 09:54:00 -04:00
Jansen Kantor
93f45d0784 break out status alerts into their own component (#175)
* refactor: break out status alerts into their own component
2021-04-28 18:07:00 -04:00
Ben Warzeski
6c88291626 Merge pull request #176 from muselesscreator/assignment_tests
reafactor: separate and test GradebookFilters from Gradebook component
2021-04-28 17:50:24 -04:00
Ben Warzeski
621c297f1a version 1.4.22 2021-04-28 17:48:31 -04:00
Ben Warzeski
76b349e377 clean up snapshots and tests 2021-04-28 15:48:59 -04:00
Ben Warzeski
d88475aab5 fix: revert changes for eslint editor compatibility 2021-04-28 15:37:31 -04:00
Ben Warzeski
ddad9d9513 merge cleanup 2021-04-28 14:03:00 -04:00
Ben Warzeski
9f7e29ed76 remove redirected props 2021-04-28 13:50:13 -04:00
Ben Warzeski
539202f511 fix tracks select option 2021-04-28 13:50:13 -04:00
Ben Warzeski
c42a995b11 clean up tests and propTypes 2021-04-28 13:50:13 -04:00
Ben Warzeski
78644daf26 fix filters for includeCourseRoleMembers and cohorts 2021-04-28 13:50:12 -04:00
Ben Warzeski
7fd38dbcf1 remove moved methods 2021-04-28 13:50:12 -04:00
Ben Warzeski
62aad2aa2f add default value for courseRoleMembers flag 2021-04-28 13:50:12 -04:00
Ben Warzeski
12d32efe08 group filters under GradebookFilters sub-view 2021-04-28 13:50:10 -04:00
Ben Warzeski
c60358941e add tests for StudentGroupsFilters and CourseGradeFilters 2021-04-28 13:49:39 -04:00
Ben Warzeski
1345666e53 assignment filter tests 2021-04-28 13:49:14 -04:00
Ben Warzeski
c4bd8dc416 tell lint how to read module paths 2021-04-28 13:49:14 -04:00
Ben Warzeski
83986ea994 Assignment testing breakout pt1 2021-04-28 13:49:13 -04:00
Ben Warzeski
f891f90f77 update testing env 2021-04-28 13:49:11 -04:00
Ben Warzeski
313840fa10 fix: make gradebook filters update URL 2021-04-28 13:47:15 -04:00
Nathan Sprenkle
84a7531530 Split SearchControls into separate component (#174)
* Split SearchControls into separate component

* Add testing config

* Add webpack config

* Add snapshot tests for Search Controls

* bump version and update package-lock format
2021-04-28 13:27:02 -04:00
Nathan Sprenkle
27296449b4 Gradebook Test Plan (#171)
Add basic testing setup/instructions
2021-04-28 11:21:15 -04:00
Ben Warzeski
2b37919222 Merge pull request #173 from muselesscreator/fix_filters2
Fix filters2
2021-04-21 16:42:44 -04:00
Ben Warzeski
384d6cc296 fix: all filters now update queryParams 2021-04-21 15:34:26 -04:00
Ben Warzeski
a0943b3946 updateQueryParams fix for filters 2021-04-21 14:38:42 -04:00
Jansen Kantor
8bc1fc82f2 Add Show Course Staff option and exclude all course roles by default (#168)
* Show Course Role Members

* add option to hide FilterBadge value for boolean filters

* chore: bump package to 1.4.20

Co-authored-by: Nathan Sprenkle <nsprenkle@edx.org>
2021-04-16 11:06:45 -04:00
Jansen Kantor
1c26aa1d71 fix: typo preventing display of assignment name (#169)
* fix: typo preventing display of assignment name

* bump version
2021-03-23 10:08:06 -04:00
Michael Roytman
582b6cb1c5 Merge pull request #166 from edx/mroytman/update-openedx-yaml-file
update openedx.yaml to use current best practices
2021-02-02 16:12:12 -05:00
Michael Roytman
bc04f6d86f update openedx.yaml to use current best practices 2021-02-02 15:46:57 -05:00
Kyle McCormick
84f1efefb3 Allow special access to bulk management tools (#165)
Access is configured on a per-course-run basis
via the new setting:
`BULK_MANAGEMENT_SPECIAL_ACCESS_COURSE_IDS`

TNL-7901
2021-01-25 17:20:21 -05:00
129 changed files with 13141 additions and 5448 deletions

4
.env
View File

@@ -13,7 +13,7 @@ ACCESS_TOKEN_COOKIE_NAME=null,
CSRF_COOKIE_NAME='csrftoken',
NEW_RELIC_APP_ID=null,
NEW_RELIC_LICENSE_KEY=null,
SITE_NAME=null,
SITE_NAME='',
MARKETING_SITE_BASE_URL=null,
SUPPORT_URL=null,
CONTACT_URL=null,
@@ -31,3 +31,5 @@ ENTERPRISE_MARKETING_URL=null,
ENTERPRISE_MARKETING_UTM_SOURCE=null,
ENTERPRISE_MARKETING_UTM_CAMPAIGN=null,
ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM=null,
BULK_MANAGEMENT_SPECIAL_ACCESS_COURSE_IDS=null,

View File

@@ -13,7 +13,7 @@ CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
USER_INFO_COOKIE_NAME='edx-user-info'
SITE_NAME='edX'
SITE_NAME=localhost
DATA_API_BASE_URL='http://localhost:8000'
// LMS_CLIENT_ID should match the lms DOT client application id your LMS containe
@@ -38,3 +38,5 @@ ENTERPRISE_MARKETING_URL='http://example.com'
ENTERPRISE_MARKETING_UTM_SOURCE='example.com'
ENTERPRISE_MARKETING_UTM_CAMPAIGN='example.com Referral'
ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM='Footer'
BULK_MANAGEMENT_SPECIAL_ACCESS_COURSE_IDS=null

View File

@@ -1,3 +1,14 @@
const { createConfig } = require('@edx/frontend-build');
module.exports = createConfig('eslint');
const config = createConfig('eslint');
config.settings = {
"import/resolver": {
node: {
paths: ["src", "node_modules"],
extensions: [".js", ".jsx"],
},
},
};
module.exports = config;

View File

@@ -10,6 +10,7 @@ JIRA: [JIRA-XXXX](https://openedx.atlassian.net/browse/JIRA-XXXX)
**Developer Checklist**
- [ ] Test suites passing
- [ ] Documentation and test plan updated, if applicable
- [ ] Received code-owner approving review
- [ ] Bumped version number [package.json](../package.json)

View File

@@ -16,7 +16,7 @@ Jump to:
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#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?

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

View File

@@ -0,0 +1,52 @@
# Test Setup
Instructions for setting up environments and data for testing Gradebook.
## Set up a course with graded content
A course with graded content is the first prerequisite to testing. Use an existing course (e.g. the DemoX Demonstration Course in Devstack) or see [Building and Running an edX Course > Developing Your Course](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/developing_course/index.html) for notes on how to develop a course from scratch.
Notably, the course needs a grading policy and subsections with scoreable content.
After creating subsections with content, they need to be configured with an "Assignment Type" to be included in grading.
Suggested resources:
- [Establishing a Grading Policy For Your Course](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/grading/index.html)
- [Adding Exercises and Tools](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/grading/index.html)
- [Set the Assignment Type and Due Date for a Subsection](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/developing_course/course_subsections.html#set-the-assignment-type-and-due-date-for-a-subsection)
## Enable Gradebook and feature toggles for course
See README.md #Quickstart for more detailed instructions.
As an admin user, visit Django Admin (`{lms-url}/admin`) to modify features.
- In Grades > Persistent Grades Enabled flag, click "Add persistent grades enabled flag"
- [ ] Enable the flag globally or for the course and click "Save"
- In Django-Waffle > Switches, click "Add switch"
- [ ] Set name to `grades.assume_zero_grade_if_absent`, select "Active", and click "Save"
- In Waffle_Utils > Waffle flag course overrides:
- [ ] Add a new flag called `grades.writeable_gradebook`, select "Force On", and enable it for your course
- [ ] Add a new flag called `grades.bulk_management`, select "Force On", and enable it for your course
## Create a Master's track for testing Master's-only features
[source](https://openedx.atlassian.net/wiki/spaces/MS/pages/1453818012/Add+a+learner+into+a+master+s+track)
Add a Master's track in your course:
- As an admin user, go to Django Admin (`{lms-url}/admin`) > Course Modes and add a new course mode
- Set the Mode to "Master's"
- Set any valid price and currency values
- Click "Save"
Enroll a student in the Master's track:
- As a staff/admin user, go to `{lms-url}/support/enrollment`
- Search for the username or email of student to enroll
- In the results table row matching the user/course, click the "Change Enrollment" button
- Select the "Master's" enrollment mode and click "Submit enrollment change"
## Setup different types of students in course
To fully test features the course should have at least:
- [ ] An audit-track student
- [ ] A master's-track student
- [ ] A staff member
- [ ] A non-staff user

11
jest.config.js Normal file
View File

@@ -0,0 +1,11 @@
const { createConfig } = require('@edx/frontend-build');
module.exports = createConfig('jest', {
setupFilesAfterEnv: [
'<rootDir>/src/setupTest.js',
],
modulePaths: ['<rootDir>/src/'],
snapshotSerializers: [
'enzyme-to-json/serializer',
],
});

View File

@@ -1,13 +1,9 @@
# This file describes this Open edX repo, as described in OEP-2:
# http://open-edx-proposals.readthedocs.io/en/latest/oeps/oep-0002.html#specification
nick: grbk
tags:
- frontend-app
- masters
oeps:
oep-2: true # Repository metadata
openedx-release: {ref: master}
owner:
type: team
team: edx/masters-devs-gta

8047
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@edx/frontend-app-gradebook",
"version": "1.4.18",
"version": "1.4.29",
"description": "edx editable gradebook-ui to manipulate grade overrides on subsections",
"repository": {
"type": "git",
@@ -29,15 +29,18 @@
"@edx/brand": "npm:@edx/brand-edx.org@^1.3.2",
"@edx/frontend-component-footer": "10.1.1",
"@edx/frontend-platform": "1.8.1",
"@edx/paragon": "12.4.1",
"@edx/paragon": "14.6.1",
"@fortawesome/fontawesome-svg-core": "^1.2.25",
"@fortawesome/free-brands-svg-icons": "^5.11.2",
"@fortawesome/free-solid-svg-icons": "^5.11.2",
"@fortawesome/react-fontawesome": "^0.1.5",
"@redux-beacon/segment": "^1.0.0",
"@reduxjs/toolkit": "^1.5.1",
"classnames": "^2.2.6",
"core-js": "3.6.5",
"email-prop-type": "^1.1.7",
"enzyme": "^3.10.0",
"enzyme-to-json": "^3.6.2",
"font-awesome": "4.7.0",
"history": "4.10.1",
"node-sass": "^4.14.1",
@@ -56,6 +59,7 @@
"redux-logger": "3.0.6",
"redux-thunk": "2.3.0",
"regenerator-runtime": "^0.13.7",
"util": "^0.12.3",
"whatwg-fetch": "^2.0.4"
},
"devDependencies": {
@@ -63,12 +67,12 @@
"axios": "0.21.1",
"axios-mock-adapter": "^1.17.0",
"codecov": "^3.6.1",
"enzyme": "^3.10.0",
"enzyme-adapter-react-16": "^1.14.0",
"es-check": "^2.3.0",
"fetch-mock": "^6.5.2",
"husky": "2.7.0",
"identity-obj-proxy": "^3.0.0",
"jest": "24.9.0",
"react-dev-utils": "^5.0.3",
"react-test-renderer": "^16.10.1",
"redux-mock-store": "^1.5.3",

View File

@@ -1,9 +1,9 @@
<!doctype html>
<html lang="en-us">
<head>
<title>Gradebook | edX</title>
<title>Gradebook | <%= process.env.SITE_NAME %></title>
<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>
<body>

View File

@@ -4,11 +4,13 @@ import React from 'react';
import PropTypes from 'prop-types';
import initialFilters from '../../data/constants/filters';
function FilterBadge({ name, value, onClick }) {
function FilterBadge({
name, value, onClick, showValue,
}) {
return (
<div>
<span className="badge badge-info">
<span>{`${name}: ${value}`}</span>
<span>{name}{showValue && `: ${value}`}</span>
<button type="button" className="btn-info" aria-label="Close" onClick={onClick}>
<span aria-hidden="true">&times;</span>
</button>
@@ -18,6 +20,20 @@ function FilterBadge({ name, value, onClick }) {
);
}
FilterBadge.defaultProps = {
showValue: true,
};
FilterBadge.propTypes = {
name: PropTypes.string.isRequired,
value: PropTypes.oneOfType([
PropTypes.string,
PropTypes.bool,
]).isRequired,
onClick: PropTypes.func.isRequired,
showValue: PropTypes.bool,
};
function RangeFilterBadge({
displayName,
filterName1,
@@ -46,7 +62,7 @@ RangeFilterBadge.propTypes = {
};
function SingleValueFilterBadge({
displayName, filterName, filterValue, handleBadgeClose,
displayName, filterName, filterValue, handleBadgeClose, showValue,
}) {
return (filterValue !== initialFilters[filterName])
&& (
@@ -54,14 +70,24 @@ function SingleValueFilterBadge({
name={displayName}
value={filterValue}
onClick={handleBadgeClose}
showValue={showValue}
/>
);
}
SingleValueFilterBadge.defaultProps = {
showValue: true,
};
SingleValueFilterBadge.propTypes = {
displayName: PropTypes.string.isRequired,
filterName: PropTypes.string.isRequired,
filterValue: PropTypes.string.isRequired,
filterValue: PropTypes.oneOfType([
PropTypes.string,
PropTypes.bool,
]).isRequired,
handleBadgeClose: PropTypes.func.isRequired,
showValue: PropTypes.bool,
};
function FilterBadges({
@@ -73,6 +99,7 @@ function FilterBadges({
assignmentGradeMax,
courseGradeMin,
courseGradeMax,
includeCourseRoleMembers,
handleFilterBadgeClose,
}) {
return (
@@ -113,10 +140,17 @@ function FilterBadges({
/>
<SingleValueFilterBadge
displayName="Cohort"
filterName="track"
filterName="cohort"
filterValue={cohort}
handleBadgeClose={handleFilterBadgeClose(['cohort'])}
/>
<SingleValueFilterBadge
displayName="Including Course Team Members"
filterName="includeCourseRoleMembers"
filterValue={includeCourseRoleMembers}
showValue={false}
handleBadgeClose={handleFilterBadgeClose(['includeCourseRoleMembers'])}
/>
</div>
);
}
@@ -131,18 +165,13 @@ const mapStateToProps = state => (
assignmentGradeMax: state.filters.assignmentGradeMax,
courseGradeMin: state.filters.courseGradeMin,
courseGradeMax: state.filters.courseGradeMax,
includeCourseRoleMembers: state.filters.includeCourseRoleMembers,
}
);
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,
@@ -152,6 +181,7 @@ FilterBadges.defaultProps = {
assignmentGradeMax: initialFilters.assignmentGradeMax,
courseGradeMin: initialFilters.courseGradeMin,
courseGradeMax: initialFilters.courseGradeMax,
includeCourseRoleMembers: initialFilters.includeCourseRoleMembers,
};
FilterBadges.propTypes = {
@@ -163,5 +193,6 @@ FilterBadges.propTypes = {
assignmentGradeMax: PropTypes.string,
courseGradeMin: PropTypes.string,
courseGradeMax: PropTypes.string,
includeCourseRoleMembers: PropTypes.bool,
handleFilterBadgeClose: PropTypes.func.isRequired,
};

View File

@@ -1,199 +0,0 @@
/* eslint-disable react/sort-comp, react/button-has-type */
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import {
Button,
Collapsible,
InputSelect,
InputText,
} from '@edx/paragon';
import { selectableAssignmentLabels } from '../../data/selectors/filters';
import {
filterAssignmentType,
fetchGrades,
updateGradesIfAssignmentGradeFiltersSet,
} from '../../data/actions/grades';
import {
updateAssignmentFilter,
updateAssignmentLimits,
} from '../../data/actions/filters';
export class Assignments extends React.Component {
getAssignmentFilterOptions = () => [
{ label: 'All', value: '' },
...this.props.assignmentFilterOptions.map(({ label, subsectionLabel }) => ({
label: `${label}: ${subsectionLabel}`,
value: label,
})),
];
handleAssignmentFilterChange = (assignment) => {
const selectedFilterOption = this.props.assignmentFilterOptions.find(assig => assig.label === assignment);
const { type, id } = selectedFilterOption || {};
const typedValue = { label: assignment, type, id };
this.props.updateAssignmentFilter(typedValue);
this.updateQueryParams({ assignment: id });
this.props.updateGradesIfAssignmentGradeFiltersSet(
this.props.courseId,
this.props.selectedCohort,
this.props.selectedTrack,
this.props.selectedAssignmentType,
);
};
handleSubmitAssignmentGrade = (event) => {
event.preventDefault();
const {
assignmentGradeMin,
assignmentGradeMax,
} = this.props;
this.props.updateAssignmentLimits(assignmentGradeMin, assignmentGradeMax);
this.props.getUserGrades(
this.props.courseId,
this.props.selectedCohort,
this.props.selectedTrack,
this.props.selectedAssignmentType,
);
this.props.updateQueryParams({ assignmentGradeMin, assignmentGradeMax });
};
mapAssignmentTypeEntries = (entries) => {
const mapped = [
{ id: 0, label: 'All', value: '' },
...entries.map(entry => ({ id: entry, label: entry })),
];
return mapped;
};
updateAssignmentTypes = (assignmentType) => {
this.props.filterAssignmentType(assignmentType);
this.updateQueryParams({ assignmentType });
}
render() {
return (
<Collapsible title="Assignments" defaultOpen className="filter-group mb-3">
<div>
<div className="student-filters">
<InputSelect
label="Assignment Types"
name="assignment-types"
aria-label="Assignment Types"
value={this.props.selectedAssignmentType}
options={this.mapAssignmentTypeEntries(this.props.assignmentTypes)}
onChange={this.updateAssignmentTypes}
disabled={this.props.assignmentFilterOptions.length === 0}
/>
</div>
<div className="student-filters">
<InputSelect
label="Assignment"
name="assignment"
aria-label="Assignment"
value={this.props.selectedAssignment}
options={this.getAssignmentFilterOptions()}
onChange={this.handleAssignmentFilterChange}
disabled={this.props.assignmentFilterOptions.length === 0}
/>
</div>
<form className="grade-filter-inputs" onSubmit={this.handleSubmitAssignmentGrade}>
<div className="percent-group">
<InputText
label="Min Grade"
name="assignmentGradeMin"
type="number"
min={0}
max={100}
step={1}
value={this.props.assignmentGradeMin}
disabled={!this.props.selectedAssignment}
onChange={this.props.setAssignmentGradeMin}
/>
<span className="input-percent-label">%</span>
</div>
<div className="percent-group">
<InputText
label="Max Grade"
name="assignmentGradeMax"
type="number"
min={0}
max={100}
step={1}
value={this.props.assignmentGradeMax}
disabled={!this.props.selectedAssignment}
onChange={this.props.setAssignmentGradeMax}
/>
<span className="input-percent-label">%</span>
</div>
<div className="grade-filter-action">
<Button
type="submit"
className="btn-outline-secondary"
name="assignmentGradeMinMax"
disabled={!this.props.selectedAssignment}
>
Apply
</Button>
</div>
</form>
</div>
</Collapsible>
);
}
}
Assignments.defaultProps = {
assignmentTypes: [],
assignmentFilterOptions: [],
selectedAssignment: '',
selectedAssignmentType: '',
selectedCohort: null,
selectedTrack: null,
};
Assignments.propTypes = {
assignmentGradeMin: PropTypes.string.isRequired,
assignmentGradeMax: PropTypes.string.isRequired,
courseId: PropTypes.string.isRequired,
setAssignmentGradeMin: PropTypes.func.isRequired,
setAssignmentGradeMax: PropTypes.func.isRequired,
updateQueryParams: PropTypes.func.isRequired,
// redux
assignmentTypes: PropTypes.arrayOf(PropTypes.string),
assignmentFilterOptions: PropTypes.arrayOf(PropTypes.shape({
label: PropTypes.string,
subsectionLabel: PropTypes.string,
})),
filterAssignmentType: PropTypes.func.isRequired,
getUserGrades: PropTypes.func.isRequired,
selectedAssignmentType: PropTypes.string,
selectedAssignment: PropTypes.string,
selectedCohort: PropTypes.string,
selectedTrack: PropTypes.string,
updateGradesIfAssignmentGradeFiltersSet: PropTypes.func.isRequired,
updateAssignmentFilter: PropTypes.func.isRequired,
updateAssignmentLimits: PropTypes.func.isRequired,
};
export const mapStateToProps = (state) => ({
assignmentTypes: state.assignmentTypes.results,
assignmentFilterOptions: selectableAssignmentLabels(state),
selectedAssignment: (state.filters.assignment || {}).label,
selectedAssignmentTypes: state.filters.assignmentType,
selectedCohort: state.filters.cohort,
selectedTrack: state.filters.track,
});
export const mapDispatchToProps = {
getUserGrades: fetchGrades,
filterAssignmentType,
updateAssignmentFilter,
updateAssignmentLimits,
updateGradesIfAssignmentGradeFiltersSet,
};
export default connect(mapStateToProps, mapDispatchToProps)(Assignments);

View File

@@ -10,10 +10,10 @@ import {
} from '@edx/paragon';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faDownload } from '@fortawesome/free-solid-svg-icons';
import { configuration } from '../../config';
import { submitFileUploadFormData } from '../../data/actions/grades';
import { getBulkManagementHistory } from '../../data/selectors/grades';
import selectors from 'data/selectors';
import thunkActions from 'data/thunkActions';
import { configuration } from 'config';
export class BulkManagement extends React.Component {
constructor(props) {
@@ -91,7 +91,7 @@ export class BulkManagement extends React.Component {
<StatusAlert
alertType="danger"
dialog={this.props.bulkImportError}
isOpen={this.props.bulkImportError}
open={!!this.props.bulkImportError}
dismissible={false}
/>
<StatusAlert
@@ -183,17 +183,17 @@ BulkManagement.propTypes = {
uploadSuccess: PropTypes.bool,
};
export const mapStateToProps = (state) => ({
bulkImportError: state.grades.bulkManagement
&& state.grades.bulkManagement.errorMessages
? `Errors while processing: ${state.grades.bulkManagement.errorMessages.join(', ')}`
: '',
bulkManagementHistory: getBulkManagementHistory(state),
uploadSuccess: !!(state.grades.bulkManagement && state.grades.bulkManagement.uploadSuccess),
});
export const mapStateToProps = (state) => {
const { grades } = selectors;
return {
bulkImportError: grades.bulkImportError(state),
bulkManagementHistory: grades.bulkManagementHistoryEntries(state),
uploadSuccess: grades.uploadSuccess(state),
};
};
export const mapDispatchToProps = {
submitFileUploadFormData,
submitFileUploadFormData: thunkActions.grades.submitFileUploadFormData,
};
export default connect(mapStateToProps, mapDispatchToProps)(BulkManagement);

View File

@@ -7,10 +7,7 @@ import { StatefulButton } from '@edx/paragon';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faDownload, faSpinner } from '@fortawesome/free-solid-svg-icons';
import {
downloadBulkGradesReport,
downloadInterventionReport,
} from '../../data/actions/grades';
import actions from 'data/actions';
export class BulkManagementControls extends React.Component {
handleClickDownloadInterventions = () => {
@@ -83,8 +80,8 @@ BulkManagementControls.propTypes = {
export const mapStateToProps = () => ({ });
export const mapDispatchToProps = {
downloadBulkGradesReport,
downloadInterventionReport,
downloadBulkGradesReport: actions.grades.downloadReport.bulkGrades,
downloadInterventionReport: actions.grades.downloadReport.intervention,
};
export default connect(mapStateToProps, mapDispatchToProps)(BulkManagementControls);

View File

@@ -10,14 +10,16 @@ import {
Table,
} from '@edx/paragon';
import {
doneViewingAssignment,
updateGrades,
} from '../../data/actions/grades';
import selectors from 'data/selectors';
import actions from 'data/actions';
import thunkActions from 'data/thunkActions';
const GRADE_OVERRIDE_HISTORY_COLUMNS = [{ label: 'Date', key: 'date' }, { label: 'Grader', key: 'grader' },
const GRADE_OVERRIDE_HISTORY_COLUMNS = [
{ label: 'Date', key: 'date' },
{ label: 'Grader', key: 'grader' },
{ label: 'Reason', key: 'reason' },
{ label: 'Adjusted grade', key: 'adjustedGrade' }];
{ label: 'Adjusted grade', key: 'adjustedGrade' },
];
export class EditModal extends React.Component {
constructor(props) {
@@ -185,19 +187,22 @@ EditModal.propTypes = {
updateGrades: PropTypes.func.isRequired,
};
export const mapStateToProps = (state) => ({
gradeOverrides: state.grades.gradeOverrideHistoryResults,
gradeOverrideCurrentEarnedGradedOverride: state.grades.gradeOverrideCurrentEarnedGradedOverride,
gradeOverrideHistoryError: state.grades.gradeOverrideHistoryError,
gradeOriginalEarnedGraded: state.grades.gradeOriginalEarnedGraded,
grdaeOriginalPossibleGraded: state.grades.grdaeOriginalPossibleGraded,
selectedCohort: state.filters.cohort,
selectedTrack: state.filters.track,
});
export const mapStateToProps = (state) => {
const { filters, grades } = selectors;
return {
gradeOverrides: grades.gradeOverrides(state),
gradeOverrideCurrentEarnedGradedOverride: grades.gradeOverrideCurrentEarnedGradedOverride(state),
gradeOverrideHistoryError: grades.gradeOverrideHistoryError(state),
gradeOriginalEarnedGraded: grades.gradeOriginalEarnedGraded(state),
gradeOriginalPossibleGraded: grades.gradeOriginalPossibleGraded(state),
selectedCohort: filters.cohort(state),
selectedTrack: filters.track(state),
};
};
export const mapDispatchToProps = {
doneViewingAssignment,
updateGrades,
doneViewingAssignment: actions.grades.doneViewingAssignment,
updateGrades: thunkActions.grades.updateGrades,
};
export default connect(mapStateToProps, mapDispatchToProps)(EditModal);

View File

@@ -0,0 +1,38 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AssignmentFilter Component snapshots basic snapshot 1`] = `
<div
className="student-filters"
>
<SelectGroup
disabled={false}
id="assignment"
label="Assignment"
onChange={[MockFunction handleChange]}
options={
Array [
<option
value=""
>
All
</option>,
<option
value="assgN1"
>
assgN1
:
subLabel1
</option>,
<option
value="assgN2"
>
assgN2
:
subLabel2
</option>,
]
}
value="assgN1"
/>
</div>
`;

View File

@@ -0,0 +1,106 @@
/* eslint-disable react/sort-comp, react/button-has-type */
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import selectors from 'data/selectors';
import actions from 'data/actions';
import thunkActions from 'data/thunkActions';
import SelectGroup from '../SelectGroup';
const { updateGradesIfAssignmentGradeFiltersSet } = 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(assig => assig.label === assignment);
const { type, id } = selectedFilterOption || {};
const typedValue = { label: assignment, type, id };
this.props.updateAssignmentFilter(typedValue);
this.props.updateQueryParams({ assignment: id });
this.props.updateGradesIfAssignmentGradeFiltersSet(
this.props.courseId,
this.props.selectedCohort,
this.props.selectedTrack,
this.props.selectedAssignmentType,
);
}
get options() {
const mapper = ({ label, subsectionLabel }) => (
<option key={label} value={label}>
{label}: {subsectionLabel}
</option>
);
return ([
<option key="0" value="">All</option>,
...this.props.assignmentFilterOptions.map(mapper),
]);
}
render() {
return (
<div className="student-filters">
<SelectGroup
id="assignment"
label="Assignment"
value={this.props.selectedAssignment}
onChange={this.handleChange}
disabled={this.props.assignmentFilterOptions.length === 0}
options={this.options}
/>
</div>
);
}
}
AssignmentFilter.defaultProps = {
assignmentFilterOptions: [],
selectedAssignment: '',
selectedAssignmentType: '',
selectedCohort: null,
selectedTrack: null,
};
AssignmentFilter.propTypes = {
courseId: PropTypes.string.isRequired,
updateQueryParams: PropTypes.func.isRequired,
// redux
assignmentFilterOptions: PropTypes.arrayOf(PropTypes.shape({
label: PropTypes.string,
subsectionLabel: PropTypes.string,
type: PropTypes.string,
id: PropTypes.string,
})),
selectedAssignmentType: PropTypes.string,
selectedAssignment: PropTypes.string,
selectedCohort: PropTypes.string,
selectedTrack: PropTypes.string,
updateGradesIfAssignmentGradeFiltersSet: PropTypes.func.isRequired,
updateAssignmentFilter: PropTypes.func.isRequired,
};
export const mapStateToProps = (state) => {
const { filters } = selectors;
return {
assignmentFilterOptions: filters.selectableAssignmentLabels(state),
selectedAssignment: filters.selectedAssignmentLabel(state),
selectedAssignmentType: filters.assignmentType(state),
selectedCohort: filters.cohort(state),
selectedTrack: filters.track(state),
};
};
export const mapDispatchToProps = {
updateAssignmentFilter: actions.filters.update.assignment,
updateGradesIfAssignmentGradeFiltersSet,
};
export default connect(mapStateToProps, mapDispatchToProps)(AssignmentFilter);

View File

@@ -0,0 +1,175 @@
import React from 'react';
import { mount, shallow } from 'enzyme';
import selectors from 'data/selectors';
import actions from 'data/actions';
import { updateGradesIfAssignmentGradeFiltersSet } 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 = {
courseId: '12345',
assignmentFilterOptions: [
{
label: 'assgN1',
subsectionLabel: 'subLabel1',
type: 'assgn_Type1',
id: 'assgn_iD1',
},
{
label: 'assgN2',
subsectionLabel: 'subLabel2',
type: 'assgn_Type2',
id: 'assgn_iD2',
},
],
selectedAssignmentType: 'assgnFilterLabel1',
selectedAssignment: 'assgN1',
selectedCohort: 'a cohort',
selectedTrack: 'a track',
};
beforeEach(() => {
props = {
...props,
updateQueryParams: jest.fn(),
updateGradesIfAssignmentGradeFiltersSet: jest.fn(),
updateAssignmentFilter: jest.fn(),
};
});
describe('Component', () => {
describe('behavior', () => {
describe('handleChange', () => {
let el;
const newAssgn = 'assgN1';
const event = { target: { value: newAssgn } };
const selected = props.assignmentFilterOptions[0];
beforeEach(() => {
el = mount(<AssignmentFilter {...props} />);
el.instance().handleChange(event);
});
it('calls props.updateAssignmentFilter with selection', () => {
expect(props.updateAssignmentFilter).toHaveBeenCalledWith({
label: newAssgn,
type: selected.type,
id: selected.id,
});
});
it('calls props.updateQueryParams with selected assignment id',
() => {
expect(props.updateQueryParams).toHaveBeenCalledWith({
assignment: selected.id,
});
});
it('calls props.updateGradesIfAssignmentGradeFiltersSet', () => {
const method = props.updateGradesIfAssignmentGradeFiltersSet;
expect(method).toHaveBeenCalledWith(
props.courseId,
props.selectedCohort,
props.selectedTrack,
props.selectedAssignmentType,
);
});
});
});
describe('snapshots', () => {
test('basic snapshot', () => {
const el = shallow(<AssignmentFilter {...props} />);
el.instance().handleChange = jest.fn().mockName('handleChange');
expect(el.instance().render()).toMatchSnapshot();
});
});
});
describe('mapStateToProps', () => {
const state = {
filters: {
assignment: { label: 'assigNment' },
assignmentType: 'assignMentType',
cohort: 'COhort',
track: 'traCK',
},
};
describe('assignmentFilterOptions', () => {
it('is selected from filters.selectableAssignmentLabels', () => {
expect(
mapStateToProps(state).assignmentFilterOptions,
).toEqual(
selectors.filters.selectableAssignmentLabels(state),
);
});
});
describe('selectedAssignment', () => {
it('is selected from filters.selectedAssignmentLabel', () => {
expect(
mapStateToProps(state).selectedAssignment,
).toEqual(
selectors.filters.selectedAssignmentLabel(state),
);
});
});
describe('selectedAssignmentType', () => {
it('is selected from filters.assignmentType', () => {
expect(
mapStateToProps(state).selectedAssignmentType,
).toEqual(
selectors.filters.assignmentType(state),
);
});
});
describe('selectedCohort', () => {
it('is selected from filters.cohort', () => {
expect(
mapStateToProps(state).selectedCohort,
).toEqual(
selectors.filters.cohort(state),
);
});
});
describe('selectedTrack', () => {
it('is selected from filters.track', () => {
expect(
mapStateToProps(state).selectedTrack,
).toEqual(
selectors.filters.track(state),
);
});
});
});
describe('mapDispatchToProps', () => {
test('updateAssignmentFilter', () => {
expect(mapDispatchToProps.updateAssignmentFilter).toEqual(
actions.filters.update.assignment,
);
});
test('updateGradesIfAsssignmentGradeFiltersSet', () => {
const prop = mapDispatchToProps.updateGradesIfAssignmentGradeFiltersSet;
expect(prop).toEqual(updateGradesIfAssignmentGradeFiltersSet);
});
});
});

View File

@@ -0,0 +1,71 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AssignmentGradeFilter Component snapshots buttons and groups disabled if no selected assignment 1`] = `
<div
className="grade-filter-inputs"
>
<PercentGroup
disabled={true}
id="assignmentGradeMin"
label="Min Grade"
onChange={[MockFunction handleSetMin]}
value="1"
/>
<PercentGroup
disabled={true}
id="assignmentGradeMax"
label="Max Grade"
onChange={[MockFunction handleSetMax]}
value="100"
/>
<div
className="grade-filter-action"
>
<Button
active={false}
disabled={true}
name="assignmentGradeMinMax"
onClick={[MockFunction handleSubmit]}
type="submit"
variant="outline-secondary"
>
Apply
</Button>
</div>
</div>
`;
exports[`AssignmentGradeFilter Component snapshots smoke test 1`] = `
<div
className="grade-filter-inputs"
>
<PercentGroup
disabled={false}
id="assignmentGradeMin"
label="Min Grade"
onChange={[MockFunction handleSetMin]}
value="1"
/>
<PercentGroup
disabled={false}
id="assignmentGradeMax"
label="Max Grade"
onChange={[MockFunction handleSetMax]}
value="100"
/>
<div
className="grade-filter-action"
>
<Button
active={false}
disabled={false}
name="assignmentGradeMinMax"
onClick={[MockFunction handleSubmit]}
type="submit"
variant="outline-secondary"
>
Apply
</Button>
</div>
</div>
`;

View File

@@ -0,0 +1,125 @@
/* eslint-disable react/sort-comp, react/button-has-type */
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { Button } from '@edx/paragon';
import selectors from 'data/selectors';
import actions from 'data/actions';
import thunkActions from 'data/thunkActions';
import PercentGroup from '../PercentGroup';
export class AssignmentGradeFilter extends React.Component {
constructor(props) {
super(props);
this.handleSubmit = this.handleSubmit.bind(this);
this.handleSetMax = this.handleSetMax.bind(this);
this.handleSetMin = this.handleSetMin.bind(this);
}
handleSubmit() {
const {
assignmentGradeMin,
assignmentGradeMax,
} = this.props.filterValues;
this.props.updateAssignmentLimits({
maxGrade: assignmentGradeMax,
minGrade: assignmentGradeMin,
});
this.props.getUserGrades(
this.props.courseId,
this.props.selectedCohort,
this.props.selectedTrack,
this.props.selectedAssignmentType,
);
this.props.updateQueryParams({
assignmentGradeMin,
assignmentGradeMax,
});
}
handleSetMax(event) {
this.props.setFilters({ assignmentGradeMax: event.target.value });
}
handleSetMin(event) {
this.props.setFilters({ assignmentGradeMin: event.target.value });
}
render() {
return (
<div className="grade-filter-inputs">
<PercentGroup
id="assignmentGradeMin"
label="Min Grade"
value={this.props.filterValues.assignmentGradeMin}
disabled={!this.props.selectedAssignment}
onChange={this.handleSetMin}
/>
<PercentGroup
id="assignmentGradeMax"
label="Max Grade"
value={this.props.filterValues.assignmentGradeMax}
disabled={!this.props.selectedAssignment}
onChange={this.handleSetMax}
/>
<div className="grade-filter-action">
<Button
type="submit"
variant="outline-secondary"
name="assignmentGradeMinMax"
disabled={!this.props.selectedAssignment}
onClick={this.handleSubmit}
>
Apply
</Button>
</div>
</div>
);
}
}
AssignmentGradeFilter.defaultProps = {
selectedAssignment: '',
selectedAssignmentType: '',
selectedCohort: null,
selectedTrack: null,
};
AssignmentGradeFilter.propTypes = {
courseId: PropTypes.string.isRequired,
filterValues: PropTypes.shape({
assignmentGradeMin: PropTypes.string.isRequired,
assignmentGradeMax: PropTypes.string.isRequired,
}).isRequired,
setFilters: PropTypes.func.isRequired,
updateQueryParams: PropTypes.func.isRequired,
// redux
getUserGrades: PropTypes.func.isRequired,
selectedAssignmentType: PropTypes.string,
selectedAssignment: PropTypes.string,
selectedCohort: PropTypes.string,
selectedTrack: PropTypes.string,
updateAssignmentLimits: PropTypes.func.isRequired,
};
export const mapStateToProps = (state) => {
const { filters } = selectors;
return {
selectedAssignment: filters.selectedAssignmentLabel(state),
selectedAssignmentType: filters.assignmentType(state),
selectedCohort: filters.cohort(state),
selectedTrack: filters.track(state),
};
};
export const mapDispatchToProps = {
getUserGrades: thunkActions.grades.fetchGrades,
updateAssignmentLimits: actions.filters.update.assignmentLimits,
};
export default connect(mapStateToProps, mapDispatchToProps)(AssignmentGradeFilter);

View File

@@ -0,0 +1,176 @@
import React from 'react';
import { mount, shallow } from 'enzyme';
import actions from 'data/actions';
import { fetchGrades } from 'data/thunkActions/grades';
import {
AssignmentGradeFilter,
mapStateToProps,
mapDispatchToProps,
} from '.';
describe('AssignmentGradeFilter', () => {
let props = {
filterValues: {
assignmentGradeMin: '1',
assignmentGradeMax: '100',
},
courseId: '12345',
selectedAssignmentType: 'assgnFilterLabel1',
selectedAssignment: 'assgN1',
selectedCohort: 'a cohort',
selectedTrack: 'a track',
};
beforeEach(() => {
props = {
...props,
setFilters: jest.fn(),
updateQueryParams: jest.fn(),
getUserGrades: jest.fn(),
updateAssignmentLimits: jest.fn(),
};
});
describe('Component', () => {
describe('behavior', () => {
describe('handleSubmit', () => {
let el;
beforeEach(() => {
el = mount(<AssignmentGradeFilter {...props} />);
el.instance().handleSubmit();
});
it('calls props.updateAssignmentLimits with min and max', () => {
expect(
props.updateAssignmentLimits,
).toHaveBeenCalledWith({
maxGrade: props.filterValues.assignmentGradeMax,
minGrade: props.filterValues.assignmentGradeMin,
});
});
it('calls getUserGrades w/ selection', () => {
expect(props.getUserGrades).toHaveBeenCalledWith(
props.courseId,
props.selectedCohort,
props.selectedTrack,
props.selectedAssignmentType,
);
});
it('updates queryParams with assignment grade min and max', () => {
expect(props.updateQueryParams).toHaveBeenCalledWith({
assignmentGradeMin: props.filterValues.assignmentGradeMin,
assignmentGradeMax: props.filterValues.assignmentGradeMax,
});
});
});
describe('handleSetMin', () => {
it('calls setFilters for assignmentGradeMin', () => {
const testVal = 23;
const el = mount(<AssignmentGradeFilter {...props} />);
el.instance().handleSetMin({ target: { value: testVal } });
expect(props.setFilters).toHaveBeenCalledWith({
assignmentGradeMin: testVal,
});
});
});
describe('handleSetMax', () => {
it('calls setFilters for assignmentGradeMax', () => {
const testVal = 92;
const el = mount(<AssignmentGradeFilter {...props} />);
el.instance().handleSetMax({ target: { value: testVal } });
expect(props.setFilters).toHaveBeenCalledWith({
assignmentGradeMax: testVal,
});
});
});
});
describe('snapshots', () => {
let el;
const mockMethods = () => {
el.instance().handleSubmit = jest.fn().mockName('handleSubmit');
el.instance().handleSetMax = jest.fn().mockName('handleSetMax');
el.instance().handleSetMin = jest.fn().mockName('handleSetMin');
};
test('smoke test', () => {
el = shallow(<AssignmentGradeFilter {...props} />);
mockMethods(el);
expect(el.instance().render()).toMatchSnapshot();
});
test('buttons and groups disabled if no selected assignment', () => {
el = shallow(<AssignmentGradeFilter
{...props}
selectedAssignment={undefined}
/>);
mockMethods(el);
expect(el.instance().render()).toMatchSnapshot();
});
});
});
describe('mapStateToProps', () => {
const state = {
filters: {
assignment: { label: 'assigNment' },
assignmentType: 'assignMentType',
cohort: 'COhort',
track: 'traCK',
},
};
describe('selectedAsssignment', () => {
it('is undefined if no assignment is passed', () => {
expect(
mapStateToProps({ filters: {} }).selectedAssignment,
).toEqual(undefined);
});
it('returns the label of selected assignment if there is one', () => {
expect(
mapStateToProps(state).selectedAssignment,
).toEqual(
state.filters.assignment.label,
);
});
});
describe('selectedAssignmentType', () => {
it('is drawn from state.filters.assignmentType', () => {
expect(
mapStateToProps(state).selectedAssignmentType,
).toEqual(
state.filters.assignmentType,
);
});
});
describe('selectedCohort', () => {
it('is drawn from state.filters.cohort', () => {
expect(
mapStateToProps(state).selectedCohort,
).toEqual(
state.filters.cohort,
);
});
});
describe('selectedTrack', () => {
it('is drawn from state.filters.track', () => {
expect(
mapStateToProps(state).selectedTrack,
).toEqual(
state.filters.track,
);
});
});
});
describe('mapDispatchToProps', () => {
test('getUserGrades', () => {
expect(mapDispatchToProps.getUserGrades).toEqual(
fetchGrades,
);
});
test('updateAssignmentLimits', () => {
expect(
mapDispatchToProps.updateAssignmentLimits,
).toEqual(
actions.filters.update.assignmentLimits,
);
});
});
});

View File

@@ -0,0 +1,67 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AssignmentTypeFilter Component snapshots SelectGroup disabled if no assignmentFilterOptions 1`] = `
<div
className="student-filters"
>
<SelectGroup
disabled={true}
id="assignment-types"
label="Assignment Types"
onChange={[MockFunction handleChange]}
options={
Array [
<option
value=""
>
All
</option>,
<option
value="assignMentType1"
>
assignMentType1
</option>,
<option
value="AssigNmentType2"
>
AssigNmentType2
</option>,
]
}
value="assigNmentType2"
/>
</div>
`;
exports[`AssignmentTypeFilter Component snapshots smoke test 1`] = `
<div
className="student-filters"
>
<SelectGroup
disabled={false}
id="assignment-types"
label="Assignment Types"
onChange={[MockFunction handleChange]}
options={
Array [
<option
value=""
>
All
</option>,
<option
value="assignMentType1"
>
assignMentType1
</option>,
<option
value="AssigNmentType2"
>
AssigNmentType2
</option>,
]
}
value="assigNmentType2"
/>
</div>
`;

View File

@@ -0,0 +1,77 @@
/* eslint-disable react/sort-comp, react/button-has-type */
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import selectors from 'data/selectors';
import actions from 'data/actions';
import SelectGroup from '../SelectGroup';
export class AssignmentTypeFilter extends React.Component {
constructor(props) {
super(props);
this.handleChange = this.handleChange.bind(this);
}
handleChange(event) {
const assignmentType = event.target.value;
this.props.filterAssignmentType(assignmentType);
this.props.updateQueryParams({ assignmentType });
}
get options() {
const mapper = (entry) => (
<option key={entry} value={entry}>{entry}</option>
);
return [
<option key="0" value="">All</option>,
...this.props.assignmentTypes.map(mapper),
];
}
render() {
return (
<div className="student-filters">
<SelectGroup
id="assignment-types"
label="Assignment Types"
value={this.props.selectedAssignmentType}
onChange={this.handleChange}
disabled={this.props.assignmentFilterOptions.length === 0}
options={this.options}
/>
</div>
);
}
}
AssignmentTypeFilter.defaultProps = {
assignmentTypes: [],
assignmentFilterOptions: [],
selectedAssignmentType: '',
};
AssignmentTypeFilter.propTypes = {
updateQueryParams: PropTypes.func.isRequired,
// redux
assignmentTypes: PropTypes.arrayOf(PropTypes.string),
assignmentFilterOptions: PropTypes.arrayOf(PropTypes.shape({
label: PropTypes.string,
subsectionLabel: PropTypes.string,
})),
filterAssignmentType: PropTypes.func.isRequired,
selectedAssignmentType: PropTypes.string,
};
export const mapStateToProps = (state) => ({
assignmentTypes: selectors.assignmentTypes.allAssignmentTypes(state),
assignmentFilterOptions: selectors.filters.selectableAssignmentLabels(state),
selectedAssignmentType: selectors.filters.assignmentType(state),
});
export const mapDispatchToProps = {
filterAssignmentType: actions.filters.update.assignmentType,
};
export default connect(mapStateToProps, mapDispatchToProps)(AssignmentTypeFilter);

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

View File

@@ -0,0 +1,34 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CourseGradeFilter Component snapshots basic snapshot 1`] = `
<React.Fragment>
<div
className="grade-filter-inputs"
>
<PercentGroup
disabled={false}
id="minimum-grade"
label="Min Grade"
onChange={[MockFunction handleUpdateMin]}
value="5"
/>
<PercentGroup
disabled={false}
id="maximum-grade"
label="Max Grade"
onChange={[MockFunction handleUpdateMax]}
value="92"
/>
</div>
<div
className="grade-filter-action"
>
<Button
onClick={[MockFunction handleApplyClick]}
variant="outline-secondary"
>
Apply
</Button>
</div>
</React.Fragment>
`;

View File

@@ -0,0 +1,136 @@
/* eslint-disable react/sort-comp, react/button-has-type, import/no-named-as-default */
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import {
Button,
} from '@edx/paragon';
import selectors from 'data/selectors';
import actions from 'data/actions';
import thunkActions from 'data/thunkActions';
import PercentGroup from '../PercentGroup';
export class CourseGradeFilter extends React.Component {
constructor(props) {
super(props);
this.handleApplyClick = this.handleApplyClick.bind(this);
this.handleUpdateMin = this.handleUpdateMin.bind(this);
this.handleUpdateMax = this.handleUpdateMax.bind(this);
this.updateCourseGradeFilters = this.updateCourseGradeFilters.bind(this);
}
handleApplyClick() {
const { courseGradeMin, courseGradeMax } = this.props.filterValues;
const isMinValid = this.isGradeFilterValueInRange(courseGradeMin);
const isMaxValid = this.isGradeFilterValueInRange(courseGradeMax);
this.props.setFilters({
isMinCourseGradeFilterValid: isMinValid,
isMaxCourseGradeFilterValid: isMaxValid,
});
if (isMinValid && isMaxValid) {
this.updateCourseGradeFilters();
}
}
updateCourseGradeFilters() {
const { courseGradeMin, courseGradeMax } = this.props.filterValues;
this.props.updateFilter({
courseGradeMin,
courseGradeMax,
courseId: this.props.courseId,
});
this.props.getUserGrades(
this.props.courseId,
this.props.selectedCohort,
this.props.selectedTrack,
this.props.selectedAssignmentType,
{ courseGradeMin, courseGradeMax },
);
this.props.updateQueryParams({ courseGradeMin, courseGradeMax });
}
handleUpdateMin(event) {
this.props.setFilters({ courseGradeMin: event.target.value });
}
handleUpdateMax(event) {
this.props.setFilters({ courseGradeMax: event.target.value });
}
isGradeFilterValueInRange = (value) => {
const valueAsInt = parseInt(value, 10);
return valueAsInt >= 0 && valueAsInt <= 100;
};
render() {
return (
<>
<div className="grade-filter-inputs">
<PercentGroup
id="minimum-grade"
label="Min Grade"
value={this.props.filterValues.courseGradeMin}
onChange={this.handleUpdateMin}
/>
<PercentGroup
id="maximum-grade"
label="Max Grade"
value={this.props.filterValues.courseGradeMax}
onChange={this.handleUpdateMax}
/>
</div>
<div className="grade-filter-action">
<Button
variant="outline-secondary"
onClick={this.handleApplyClick}
>
Apply
</Button>
</div>
</>
);
}
}
CourseGradeFilter.defaultProps = {
courseId: '',
selectedAssignmentType: '',
selectedCohort: null,
selectedTrack: null,
};
CourseGradeFilter.propTypes = {
courseId: PropTypes.string,
filterValues: PropTypes.shape({
courseGradeMin: PropTypes.string.isRequired,
courseGradeMax: PropTypes.string.isRequired,
}).isRequired,
setFilters: PropTypes.func.isRequired,
updateQueryParams: PropTypes.func.isRequired,
// Redux
getUserGrades: PropTypes.func.isRequired,
selectedAssignmentType: PropTypes.string,
selectedCohort: PropTypes.string,
selectedTrack: PropTypes.string,
updateFilter: PropTypes.func.isRequired,
};
export const mapStateToProps = (state) => {
const { filters } = selectors;
return {
selectedCohort: filters.cohort(state),
selectedTrack: filters.track(state),
selectedAssignmentType: filters.assignmentType(state),
};
};
export const mapDispatchToProps = {
updateFilter: actions.filters.update.courseGradeLimits,
getUserGrades: thunkActions.grades.fetchGrades,
};
export default connect(mapStateToProps, mapDispatchToProps)(CourseGradeFilter);

View File

@@ -0,0 +1,196 @@
/* eslint-disable import/no-named-as-default */
import React from 'react';
import { shallow } from 'enzyme';
import actions from 'data/actions';
import { fetchGrades } from 'data/thunkActions/grades';
import {
CourseGradeFilter,
mapStateToProps,
mapDispatchToProps,
} from '.';
jest.mock('@edx/paragon', () => ({
Button: 'Button',
Collapsible: 'Collapsible',
}));
describe('CourseGradeFilter', () => {
let props = {
filterValues: {
courseGradeMin: '5',
courseGradeMax: '92',
},
courseId: '12345',
selectedAssignmentType: 'assignMent type 1',
selectedCohort: 'COHort',
selectedTrack: 'TracK',
};
beforeEach(() => {
props = {
...props,
getUserGrades: jest.fn(),
setFilters: jest.fn(),
updateQueryParams: jest.fn(),
updateFilter: jest.fn(),
};
});
describe('Component', () => {
describe('snapshots', () => {
test('basic snapshot', () => {
const el = shallow(<CourseGradeFilter {...props} />);
el.instance().handleUpdateMin = jest.fn().mockName(
'handleUpdateMin',
);
el.instance().handleUpdateMax = jest.fn().mockName(
'handleUpdateMax',
);
el.instance().handleApplyClick = jest.fn().mockName(
'handleApplyClick',
);
expect(el.instance().render()).toMatchSnapshot();
});
});
describe('behavior', () => {
let el;
const testVal = 'TESTvalue';
beforeEach(() => {
el = shallow(<CourseGradeFilter {...props} />);
});
describe('handleApplyClick', () => {
beforeEach(() => {
el.instance().updateCourseGradeFilters = jest.fn();
});
it('calls setFilters for isMin(Max)CourseGradeFilterValid', () => {
el.instance().isGradeFilterValueInRange = jest.fn().mockImplementation(v => v >= 50);
el.instance().handleApplyClick();
expect(props.setFilters).toHaveBeenCalledWith({
isMinCourseGradeFilterValid: false,
isMaxCourseGradeFilterValid: true,
});
});
it('calls updateCourseGradeFilters only if both min and max are valid', () => {
const isValid = jest.fn().mockImplementation(v => v >= 50);
el.instance().isGradeFilterValueInRange = isValid;
el.instance().handleApplyClick();
expect(el.instance().updateCourseGradeFilters).not.toHaveBeenCalled();
isValid.mockImplementation(v => v <= 50);
el.instance().handleApplyClick();
expect(el.instance().updateCourseGradeFilters).not.toHaveBeenCalled();
isValid.mockImplementation(v => v >= 0);
el.instance().handleApplyClick();
expect(el.instance().updateCourseGradeFilters).toHaveBeenCalled();
});
});
describe('updateCourseGradeFilters', () => {
beforeEach(() => {
el.instance().updateCourseGradeFilters();
});
it('calls props.updateFilter with selection', () => {
expect(props.updateFilter).toHaveBeenCalledWith({
courseGradeMin: props.filterValues.courseGradeMin,
courseGradeMax: props.filterValues.courseGradeMax,
courseId: props.courseId,
});
});
it('calls props.getUserGrades with selection', () => {
expect(props.getUserGrades).toHaveBeenCalledWith(
props.courseId,
props.selectedCohort,
props.selectedTrack,
props.selectedAssignmentType,
{
courseGradeMin: props.filterValues.courseGradeMin,
courseGradeMax: props.filterValues.courseGradeMax,
},
);
});
it('updates query params with courseGradeMin and courseGradeMax', () => {
expect(props.updateQueryParams).toHaveBeenCalledWith({
courseGradeMin: props.filterValues.courseGradeMin,
courseGradeMax: props.filterValues.courseGradeMax,
});
});
});
describe('handleUpdateMin', () => {
it('calls props.setCourseGradeMin with event value', () => {
el.instance().handleUpdateMin(
{ target: { value: testVal } },
);
expect(props.setFilters).toHaveBeenCalledWith({
courseGradeMin: testVal,
});
});
});
describe('handleUpdateMax', () => {
it('calls props.setCourseGradeMax with event value', () => {
el.instance().handleUpdateMax(
{ target: { value: testVal } },
);
expect(props.setFilters).toHaveBeenCalledWith({
courseGradeMax: testVal,
});
});
});
describe('isFilterValueInRange', () => {
it('returns true for values between 0 and 100', () => {
expect(el.instance().isGradeFilterValueInRange('0')).toEqual(true);
expect(el.instance().isGradeFilterValueInRange(1.1)).toEqual(true);
expect(el.instance().isGradeFilterValueInRange('43')).toEqual(true);
expect(el.instance().isGradeFilterValueInRange(98.6)).toEqual(true);
expect(el.instance().isGradeFilterValueInRange(100)).toEqual(true);
});
it('returns false for values below 0 and above 100', () => {
expect(el.instance().isGradeFilterValueInRange(-1)).toEqual(false);
expect(el.instance().isGradeFilterValueInRange(101)).toEqual(false);
});
});
});
});
describe('mapStateToProps', () => {
const state = {
filters: {
cohort: 'COHort',
track: 'TRacK',
assignmentType: 'TYPe',
},
};
describe('selectedAssignmentType', () => {
test('drawn from filters.assignmentType', () => {
expect(mapStateToProps(state).selectedAssignmentType).toEqual(
state.filters.assignmentType,
);
});
});
describe('selectedCohort', () => {
test('drawn from filters.cohort', () => {
expect(mapStateToProps(state).selectedCohort).toEqual(
state.filters.cohort,
);
});
});
describe('selectedTrack', () => {
test('drawn from filters.track', () => {
expect(mapStateToProps(state).selectedTrack).toEqual(
state.filters.track,
);
});
});
});
describe('mapDispatchToProps', () => {
describe('updateFilter', () => {
test('from updateCourseGradeFilter', () => {
expect(mapDispatchToProps.updateFilter).toEqual(actions.filters.update.courseGradeLimits);
});
});
describe('getUserGrades', () => {
test('from fetchGrades', () => {
expect(mapDispatchToProps.getUserGrades).toEqual(fetchGrades);
});
});
});
});

View File

@@ -0,0 +1,39 @@
/* eslint-disable react/sort-comp */
import React from 'react';
import PropTypes from 'prop-types';
import { Form } from '@edx/paragon';
const PercentGroup = ({
id,
label,
value,
disabled,
onChange,
}) => (
<div className="percent-group">
<Form.Group controlId={id}>
<Form.Label>{label}</Form.Label>
<Form.Control
type="number"
min={0}
max={100}
step={1}
{...{ value, disabled, onChange }}
/>
</Form.Group>
<span className="input-percent-label">%</span>
</div>
);
PercentGroup.defaultProps = {
disabled: false,
};
PercentGroup.propTypes = {
id: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
value: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
disabled: PropTypes.bool,
};
export default PercentGroup;

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

View File

@@ -0,0 +1,36 @@
/* eslint-disable react/sort-comp, react/button-has-type */
import React from 'react';
import PropTypes from 'prop-types';
import { Form } from '@edx/paragon';
const SelectGroup = ({
id,
label,
value,
onChange,
disabled,
options,
}) => (
<div className="student-filters">
<Form.Group controlId={id}>
<Form.Label>{label}</Form.Label>
<Form.Control as="select" {...{ value, onChange, disabled }}>
{options}
</Form.Control>
</Form.Group>
</div>
);
SelectGroup.propTypes = {
id: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
value: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
disabled: PropTypes.bool,
options: PropTypes.arrayOf(PropTypes.node).isRequired,
};
SelectGroup.defaultProps = {
disabled: false,
};
export default SelectGroup;

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

View File

@@ -0,0 +1,170 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`StudentGroupsFilter Component snapshots Cohorts group disabled if no cohorts 1`] = `
<React.Fragment>
<SelectGroup
disabled={false}
id="Tracks"
label="Tracks"
onChange={[Function]}
options={
Array [
<option
value="Track-All"
>
Track-All
</option>,
<option
value="TracK1"
>
TracK1
</option>,
<option
value="TracK2"
>
TracK2
</option>,
<option
value="TRACK3"
>
TRACK3
</option>,
]
}
value="TracK2"
/>
<SelectGroup
disabled={true}
id="Cohorts"
label="Cohorts"
onChange={[Function]}
options={
Array [
<option
value="Cohort-All"
>
Cohort-All
</option>,
]
}
value="Cohorts"
/>
</React.Fragment>
`;
exports[`StudentGroupsFilter Component snapshots basic snapshot 1`] = `
<React.Fragment>
<SelectGroup
disabled={false}
id="Tracks"
label="Tracks"
onChange={[MockFunction updateTracks]}
options={
Array [
<option
value="Track-All"
>
Track-All
</option>,
<option
value="TracK1"
>
TracK1
</option>,
<option
value="TracK2"
>
TracK2
</option>,
<option
value="TRACK3"
>
TRACK3
</option>,
]
}
value="TracK2"
/>
<SelectGroup
disabled={false}
id="Cohorts"
label="Cohorts"
onChange={[MockFunction updateCohorts]}
options={
Array [
<option
value="Cohort-All"
>
Cohort-All
</option>,
<option
value="cohorT1"
>
cohorT1
</option>,
<option
value="cohorT2"
>
cohorT2
</option>,
<option
value="cohorT3"
>
cohorT3
</option>,
]
}
value="cohorT3"
/>
</React.Fragment>
`;
exports[`StudentGroupsFilter Component snapshots mapCohortsEntries cohort options: [Cohort-All, <{slug, name}...>] 1`] = `
Array [
<option
value="Cohort-All"
>
Cohort-All
</option>,
<option
value="cohorT1"
>
cohorT1
</option>,
<option
value="cohorT2"
>
cohorT2
</option>,
<option
value="cohorT3"
>
cohorT3
</option>,
]
`;
exports[`StudentGroupsFilter Component snapshots mapTracksEntries cohort options: [Track-All, <{id, name}...>] 1`] = `
Array [
<option
value="Track-All"
>
Track-All
</option>,
<option
value="TracK1"
>
TracK1
</option>,
<option
value="TracK2"
>
TracK2
</option>,
<option
value="TRACK3"
>
TRACK3
</option>,
]
`;

View File

@@ -0,0 +1,154 @@
/* 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 selectors from 'data/selectors';
import thunkActions from 'data/thunkActions';
import SelectGroup from '../SelectGroup';
export class StudentGroupsFilter extends React.Component {
constructor(props) {
super(props);
this.updateCohorts = this.updateCohorts.bind(this);
this.updateTracks = this.updateTracks.bind(this);
}
mapCohortsEntries = () => {
const mapper = ({ id, name }) => (
<option key={id} value={name}>{name}</option>
);
return [
<option value="Cohort-All" key="0">Cohort-All</option>,
...this.props.cohorts.map(mapper),
];
};
mapTracksEntries = () => {
const mapper = ({ slug, name }) => (
<option key={slug} value={name}>{name}</option>
);
return [
<option value="Track-All" key="0">Track-All</option>,
...this.props.tracks.map(mapper),
];
};
mapSelectedCohortEntry = () => {
const selectedCohortEntry = this.props.cohorts.find(
(x) => x.id === parseInt(this.props.selectedCohort, 10),
);
return selectedCohortEntry ? selectedCohortEntry.name : 'Cohorts';
};
mapSelectedTrackEntry = () => {
const selectedTrackEntry = this.props.tracks.find(
({ slug }) => slug === this.props.selectedTrack,
);
return selectedTrackEntry ? selectedTrackEntry.name : 'Tracks';
};
selectedTrackSlugFromEvent(event) {
const selectedTrackItem = this.props.tracks.find(
({ name }) => name === event.target.value,
);
return selectedTrackItem ? selectedTrackItem.slug : null;
}
selectedCohortIdFromEvent(event) {
const selectedCohortItem = this.props.cohorts.find(
x => x.name === event.target.value,
);
return selectedCohortItem ? selectedCohortItem.id.toString() : null;
}
updateTracks(event) {
const selectedTrackSlug = this.selectedTrackSlugFromEvent(event);
this.props.getUserGrades(
this.props.courseId,
this.props.selectedCohort,
selectedTrackSlug,
this.props.selectedAssignmentType,
);
this.props.updateQueryParams({ track: selectedTrackSlug });
}
updateCohorts(event) {
const selectedCohortId = this.selectedCohortIdFromEvent(event);
this.props.getUserGrades(
this.props.courseId,
selectedCohortId,
this.props.selectedTrack,
this.props.selectedAssignmentType,
);
this.props.updateQueryParams({ cohort: selectedCohortId });
}
render() {
return (
<>
<SelectGroup
id="Tracks"
label="Tracks"
value={this.mapSelectedTrackEntry()}
onChange={this.updateTracks}
options={this.mapTracksEntries()}
/>
<SelectGroup
id="Cohorts"
label="Cohorts"
value={this.mapSelectedCohortEntry()}
disabled={this.props.cohorts.length === 0}
onChange={this.updateCohorts}
options={this.mapCohortsEntries()}
/>
</>
);
}
}
StudentGroupsFilter.defaultProps = {
cohorts: [],
courseId: '',
selectedAssignmentType: '',
selectedCohort: null,
selectedTrack: null,
tracks: [],
};
StudentGroupsFilter.propTypes = {
courseId: PropTypes.string,
updateQueryParams: PropTypes.func.isRequired,
// redux
cohorts: PropTypes.arrayOf(PropTypes.shape({
name: PropTypes.string,
id: PropTypes.number,
})),
getUserGrades: PropTypes.func.isRequired,
selectedAssignmentType: PropTypes.string,
selectedCohort: PropTypes.string,
selectedTrack: PropTypes.string,
tracks: PropTypes.arrayOf(PropTypes.shape({
name: PropTypes.string,
slug: PropTypes.string,
})),
};
export const mapStateToProps = (state) => {
const { filters, cohorts, tracks } = selectors;
return {
cohorts: cohorts.allCohorts(state),
selectedAssignmentType: filters.assignmentType(state),
selectedCohort: filters.cohort(state),
selectedTrack: filters.track(state),
tracks: tracks.allTracks(state),
};
};
export const mapDispatchToProps = {
getUserGrades: thunkActions.grades.fetchGrades,
};
export default connect(mapStateToProps, mapDispatchToProps)(StudentGroupsFilter);

View File

@@ -0,0 +1,238 @@
/* eslint-disable import/no-named-as-default */
import React from 'react';
import { shallow } from 'enzyme';
import { fetchGrades } from 'data/thunkActions/grades';
import {
StudentGroupsFilter,
mapStateToProps,
mapDispatchToProps,
} from '.';
describe('StudentGroupsFilter', () => {
let props = {
courseId: '12345',
cohorts: [
{ name: 'cohorT1', id: 8001 },
{ name: 'cohorT2', id: 8002 },
{ name: 'cohorT3', id: 8003 },
],
selectedAssignmentType: 'assignMent type 1',
selectedCohort: '8003',
selectedTrack: 'TracK2_slug',
tracks: [
{ name: 'TracK1', slug: 'TracK1_slug' },
{ name: 'TracK2', slug: 'TracK2_slug' },
{ name: 'TRACK3', slug: 'TRACK3_slug' },
],
};
beforeEach(() => {
props = {
...props,
getUserGrades: jest.fn(),
updateQueryParams: jest.fn(),
};
});
describe('Component', () => {
describe('snapshots', () => {
let el;
beforeEach(() => {
el = shallow(<StudentGroupsFilter {...props} />);
});
test('basic snapshot', () => {
el.instance().updateTracks = jest.fn().mockName(
'updateTracks',
);
el.instance().updateCohorts = jest.fn().mockName(
'updateCohorts',
);
expect(el.instance().render()).toMatchSnapshot();
});
test('Cohorts group disabled if no cohorts', () => {
el.setProps({ cohorts: [] });
expect(el.instance().render()).toMatchSnapshot();
});
describe('mapCohortsEntries', () => {
test('cohort options: [Cohort-All, <{slug, name}...>]', () => {
expect(el.instance().mapCohortsEntries()).toMatchSnapshot();
});
});
describe('mapTracksEntries', () => {
test('cohort options: [Track-All, <{id, name}...>]', () => {
expect(el.instance().mapTracksEntries()).toMatchSnapshot();
});
});
});
describe('behavior', () => {
let el;
beforeEach(() => {
el = shallow(<StudentGroupsFilter {...props} />);
});
describe('mapSelectedCohortEntry', () => {
it('returns the name of the cohort with the same numerical id', () => {
// Because selectedCohort is the id of cohorts[2]
expect(el.instance().mapSelectedCohortEntry()).toEqual(
props.cohorts[2].name,
);
});
it('returns "Cohorts" if no cohort is found', () => {
el.setProps({ selectedCohort: '999' });
expect(el.instance().mapSelectedCohortEntry()).toEqual(
'Cohorts',
);
});
});
describe('mapSelectedTrackEntry', () => {
it('returns the name of the track with the selected slug', () => {
// Because selectedTrack is the slug of tracks[1]
expect(el.instance().mapSelectedTrackEntry()).toEqual(
props.tracks[1].name,
);
});
it('returns "Tracks" if no track is found', () => {
el.setProps({ selectedTrack: 'FAKE' });
expect(el.instance().mapSelectedTrackEntry()).toEqual(
'Tracks',
);
});
});
describe('selectedCohortIdFromEvent', () => {
it('returns the id of the cohort with the name matching the event', () => {
expect(
el.instance().selectedCohortIdFromEvent(
{ target: { value: props.cohorts[1].name } },
),
).toEqual(props.cohorts[1].id.toString());
});
it('returns null if no matching cohort is found', () => {
expect(
el.instance().selectedCohortIdFromEvent(
{ target: { value: 'FAKE' } },
),
).toEqual(null);
});
});
describe('selectedTrackSlugFromEvent', () => {
it('returns the slug of the track with the name matching the event', () => {
expect(
el.instance().selectedTrackSlugFromEvent(
{ target: { value: props.tracks[1].name } },
),
).toEqual(props.tracks[1].slug);
});
it('returns null if no matching track is found', () => {
expect(
el.instance().selectedTrackSlugFromEvent(
{ target: { value: 'FAKE' } },
),
).toEqual(null);
});
});
describe('updateTracks', () => {
const selectedSlug = 'SLUG';
beforeEach(() => {
el = shallow(<StudentGroupsFilter {...props} />);
jest.spyOn(
el.instance(),
'selectedTrackSlugFromEvent',
).mockReturnValue(selectedSlug);
el.instance().updateTracks({ target: {} });
});
it('calls getUserGrades with selection', () => {
expect(props.getUserGrades).toHaveBeenCalledWith(
props.courseId,
props.selectedCohort,
selectedSlug,
props.selectedAssignmentType,
);
});
it('updates queryParams with track value', () => {
expect(props.updateQueryParams).toHaveBeenCalledWith({
track: selectedSlug,
});
});
});
describe('updateCohorts', () => {
const selectedId = 23;
beforeEach(() => {
el = shallow(<StudentGroupsFilter {...props} />);
jest.spyOn(
el.instance(),
'selectedCohortIdFromEvent',
).mockReturnValue(selectedId);
el.instance().updateCohorts({ target: {} });
});
it('calls getUserGrades with selection', () => {
expect(props.getUserGrades).toHaveBeenCalledWith(
props.courseId,
selectedId,
props.selectedTrack,
props.selectedAssignmentType,
);
});
it('updates queryParams with cohort value', () => {
expect(props.updateQueryParams).toHaveBeenCalledWith({
cohort: selectedId,
});
});
});
});
});
describe('mapStateToProps', () => {
const state = {
cohorts: { results: ['some', 'cohorts'] },
filters: {
cohort: 'COHort',
track: 'TRacK',
assignmentType: 'TYPe',
},
tracks: { results: ['a', 'few', 'tracks'] },
};
describe('cohorts', () => {
test('drawn from cohorts.results', () => {
expect(mapStateToProps(state).cohorts).toEqual(
state.cohorts.results,
);
});
});
describe('selectedAssignmentType', () => {
test('drawn from filters.assignmentType', () => {
expect(mapStateToProps(state).selectedAssignmentType).toEqual(
state.filters.assignmentType,
);
});
});
describe('selectedCohort', () => {
test('drawn from filters.cohort', () => {
expect(mapStateToProps(state).selectedCohort).toEqual(
state.filters.cohort,
);
});
});
describe('selectedTrack', () => {
test('drawn from filters.track', () => {
expect(mapStateToProps(state).selectedTrack).toEqual(
state.filters.track,
);
});
});
describe('tracks', () => {
test('drawn from tracks.results', () => {
expect(mapStateToProps(state).tracks).toEqual(
state.tracks.results,
);
});
});
});
describe('mapDispatchToProps', () => {
describe('getUserGrades', () => {
test('from fetchGrades', () => {
expect(mapDispatchToProps.getUserGrades).toEqual(fetchGrades);
});
});
});
});

View File

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

View File

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

View File

@@ -0,0 +1,75 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`GradebookFilters Component snapshots basic snapshot 1`] = `
<React.Fragment>
<Collapsible
className="filter-group mb-3"
defaultOpen={true}
title="Assignments"
>
<div>
<Connect(AssignmentTypeFilter)
updateQueryParams={[MockFunction]}
/>
<Connect(AssignmentFilter)
courseId="12345"
updateQueryParams={[MockFunction]}
/>
<Connect(AssignmentGradeFilter)
courseId="12345"
filterValues={
Object {
"assignmentGradeMax": "90",
"assignmentGradeMin": "10",
"courseGradeMax": "80",
"courseGradeMin": "20",
}
}
setFilters={[MockFunction]}
updateQueryParams={[MockFunction]}
/>
</div>
</Collapsible>
<Collapsible
className="filter-group mb-3"
defaultOpen={true}
title="Overall Grade"
>
<Connect(CourseGradeFilter)
courseId="12345"
filterValues={
Object {
"assignmentGradeMax": "90",
"assignmentGradeMin": "10",
"courseGradeMax": "80",
"courseGradeMin": "20",
}
}
setFilters={[MockFunction]}
updateQueryParams={[MockFunction]}
/>
</Collapsible>
<Collapsible
className="filter-group mb-3"
defaultOpen={true}
title="Student Groups"
>
<Connect(StudentGroupsFilter)
courseId="12345"
updateQueryParams={[MockFunction]}
/>
</Collapsible>
<Collapsible
className="filter-group mb-3"
defaultOpen={true}
title="Include Course Team Members"
>
<Checkbox
checked={true}
onChange={[MockFunction handleIncludeTeamMembersChange]}
>
Include Course Team Members
</Checkbox>
</Collapsible>
</React.Fragment>
`;

View File

@@ -0,0 +1,120 @@
/* eslint-disable react/sort-comp, import/no-named-as-default */
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { Collapsible, Form } from '@edx/paragon';
import actions from 'data/actions';
import selectors from 'data/selectors';
import AssignmentTypeFilter from './AssignmentTypeFilter';
import AssignmentFilter from './AssignmentFilter';
import AssignmentGradeFilter from './AssignmentGradeFilter';
import CourseGradeFilter from './CourseGradeFilter';
import StudentGroupsFilter from './StudentGroupsFilter';
export class GradebookFilters extends React.Component {
constructor(props) {
super(props);
this.state = {
includeCourseRoleMembers: this.props.includeCourseRoleMembers,
};
this.handleIncludeTeamMembersChange = this.handleIncludeTeamMembersChange.bind(this);
}
handleIncludeTeamMembersChange(event) {
const includeCourseRoleMembers = event.target.checked;
this.setState({ includeCourseRoleMembers });
this.props.updateIncludeCourseRoleMembers(includeCourseRoleMembers);
this.props.updateQueryParams({ includeCourseRoleMembers });
}
collapsibleGroup = (title, content) => (
<Collapsible title={title} defaultOpen className="filter-group mb-3">
{content}
</Collapsible>
);
render() {
const {
courseId,
filterValues,
setFilters,
updateQueryParams,
} = this.props;
return (
<>
{this.collapsibleGroup('Assignments', (
<div>
<AssignmentTypeFilter
updateQueryParams={updateQueryParams}
/>
<AssignmentFilter
courseId={courseId}
updateQueryParams={updateQueryParams}
/>
<AssignmentGradeFilter
{...{
courseId,
filterValues,
setFilters,
updateQueryParams,
}}
/>
</div>
))}
{this.collapsibleGroup('Overall Grade', (
<CourseGradeFilter
{...{
filterValues,
setFilters,
courseId,
updateQueryParams,
}}
/>
))}
{this.collapsibleGroup('Student Groups', (
<StudentGroupsFilter
courseId={courseId}
updateQueryParams={updateQueryParams}
/>
))}
{this.collapsibleGroup('Include Course Team Members', (
<Form.Checkbox
checked={this.state.includeCourseRoleMembers}
onChange={this.handleIncludeTeamMembersChange}
>
Include Course Team Members
</Form.Checkbox>
))}
</>
);
}
}
GradebookFilters.defaultProps = {
includeCourseRoleMembers: false,
};
GradebookFilters.propTypes = {
courseId: PropTypes.string.isRequired,
filterValues: PropTypes.shape({
assignmentGradeMin: PropTypes.string,
assignmentGradeMax: PropTypes.string,
courseGradeMin: PropTypes.string,
courseGradeMax: PropTypes.string,
}).isRequired,
setFilters: PropTypes.func.isRequired,
includeCourseRoleMembers: PropTypes.bool,
updateIncludeCourseRoleMembers: PropTypes.func.isRequired,
updateQueryParams: PropTypes.func.isRequired,
};
export const mapStateToProps = (state) => ({
includeCourseRoleMembers: selectors.filters.includeCourseRoleMembers(state),
});
export const mapDispatchToProps = {
updateIncludeCourseRoleMembers: actions.filters.update.includeCourseRoleMembers,
};
export default connect(mapStateToProps, mapDispatchToProps)(GradebookFilters);

View File

@@ -0,0 +1,105 @@
import React from 'react';
import { shallow } from 'enzyme';
import actions from 'data/actions';
import {
GradebookFilters,
mapStateToProps,
mapDispatchToProps,
} from '.';
jest.mock('@edx/paragon', () => ({
Collapsible: 'Collapsible',
Form: {
Checkbox: 'Checkbox',
},
}));
describe('GradebookFilters', () => {
let props = {
courseId: '12345',
filterValues: {
assignmentGradeMin: '10',
assignmentGradeMax: '90',
courseGradeMin: '20',
courseGradeMax: '80',
},
includeCourseRoleMembers: true,
};
beforeEach(() => {
props = {
...props,
updateQueryParams: jest.fn(),
updateIncludeCourseRoleMembers: jest.fn(),
setFilters: jest.fn(),
};
});
describe('Component', () => {
describe('behavior', () => {
describe('handleIncludeTeamMembersChange', () => {
let el;
beforeEach(() => {
el = shallow(<GradebookFilters {...props} />);
el.instance().setState = jest.fn();
});
it('calls setState with newVal', () => {
el.instance().handleIncludeTeamMembersChange(
{ target: { checked: true } },
);
expect(
el.instance().setState,
).toHaveBeenCalledWith({ includeCourseRoleMembers: true });
});
it('calls props.updateIncludeCourseRoleMembers with newVal', () => {
el.instance().handleIncludeTeamMembersChange(
{ target: { checked: false } },
);
expect(
props.updateIncludeCourseRoleMembers,
).toHaveBeenCalledWith(false);
});
it('calls props.updateQueryParams with newVal', () => {
el.instance().handleIncludeTeamMembersChange(
{ target: { checked: true } },
);
expect(
props.updateQueryParams,
).toHaveBeenCalledWith({ includeCourseRoleMembers: true });
});
});
});
describe('snapshots', () => {
test('basic snapshot', () => {
const el = shallow(<GradebookFilters {...props} />);
el.instance().handleIncludeTeamMembersChange = jest.fn().mockName(
'handleIncludeTeamMembersChange',
);
expect(el.instance().render()).toMatchSnapshot();
});
});
});
describe('mapStateToProps', () => {
const state = {
filters: {
includeCourseRoleMembers: 'plz do',
},
};
describe('includeCourseRoleMembers', () => {
it('is drawn from filters.includeCourseRoleMembers', () => {
expect(mapStateToProps(state).includeCourseRoleMembers).toEqual(
state.filters.includeCourseRoleMembers,
);
});
});
});
describe('mapDispatchToProps', () => {
test('updateIncludeCourseRoleMembers', () => {
expect(mapDispatchToProps.updateIncludeCourseRoleMembers).toEqual(
actions.filters.update.includeCourseRoleMembers,
);
});
});
});

View File

@@ -3,11 +3,14 @@ import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { Table } from '@edx/paragon';
import {
Table, OverlayTrigger, Tooltip, Icon,
} from '@edx/paragon';
import { formatDateForDisplay } from '../../data/actions/utils';
import { getHeadings } from '../../data/selectors/grades';
import { fetchGradeOverrideHistory } from '../../data/actions/grades';
import { EMAIL_HEADING, TOTAL_COURSE_GRADE_HEADING, USERNAME_HEADING } from 'data/constants/grades';
import selectors from 'data/selectors';
import { formatDateForDisplay } from 'data/actions/utils';
import thunkActions from 'data/thunkActions';
const DECIMAL_PRECISION = 2;
@@ -49,10 +52,10 @@ export class GradebookTable extends React.Component {
percent: (entries, areGradesFrozen) => entries.map((entry) => {
const learnerInformation = this.getLearnerInformation(entry);
const results = {
Username: (
[USERNAME_HEADING]: (
<div><span className="wrap-text-in-cell">{learnerInformation}</span></div>
),
Email: (
[EMAIL_HEADING]: (
<span className="wrap-text-in-cell">{entry.email}</span>
),
};
@@ -73,17 +76,17 @@ export class GradebookTable extends React.Component {
}
return acc;
}, {});
const totals = { Total: `${this.roundGrade(entry.percent * 100)}%` };
const totals = { [TOTAL_COURSE_GRADE_HEADING]: `${this.roundGrade(entry.percent * 100)}%` };
return Object.assign(results, assignments, totals);
}),
absolute: (entries, areGradesFrozen) => entries.map((entry) => {
const learnerInformation = this.getLearnerInformation(entry);
const results = {
Username: (
[USERNAME_HEADING]: (
<div><span className="wrap-text-in-cell">{learnerInformation}</span></div>
),
Email: (
[EMAIL_HEADING]: (
<span className="wrap-text-in-cell">{entry.email}</span>
),
};
@@ -111,7 +114,10 @@ export class GradebookTable extends React.Component {
return acc;
}, {});
const totals = { Total: `${this.roundGrade(entry.percent * 100)}/100` };
// Show this as a percent no matter what the other setting is. The data
// we're getting gives the final grade as a percentage so making it appear
// to be "out of" 100 is misleading.
const totals = { [TOTAL_COURSE_GRADE_HEADING]: `${this.roundGrade(entry.percent * 100)}%` };
return Object.assign(results, assignments, totals);
}),
};
@@ -120,22 +126,44 @@ export class GradebookTable extends React.Component {
let headings = [...this.props.headings];
if (headings.length > 0) {
const userInformationHeadingLabel = (
const headerLabelReplacements = {};
headerLabelReplacements[USERNAME_HEADING] = (
<div>
<div>Username</div>
<div className="font-weight-normal student-key">Student Key*</div>
</div>
);
const emailHeadingLabel = 'Email*';
headerLabelReplacements[EMAIL_HEADING] = 'Email*';
headings = headings.map(heading => ({
label: heading,
key: heading,
}));
const totalGradePercentageMessage = 'Total Grade values are always displayed as a percentage.';
headerLabelReplacements[TOTAL_COURSE_GRADE_HEADING] = (
<div>
<OverlayTrigger
trigger={['hover', 'focus']}
key="left-basic"
placement="left"
overlay={(<Tooltip id="course-grade-tooltip">{totalGradePercentageMessage}</Tooltip>)}
>
<div>
{TOTAL_COURSE_GRADE_HEADING}
<div id="courseGradeTooltipIcon">
<Icon className="fa fa-info-circle" screenReaderText={totalGradePercentageMessage} />
</div>
</div>
</OverlayTrigger>
</div>
);
// replace username heading label to include additional user data
headings[0].label = userInformationHeadingLabel;
headings[1].label = emailHeadingLabel;
headings = headings.map(heading => {
const result = {
label: heading,
key: heading,
};
if (headerLabelReplacements[heading] !== undefined) {
result.label = headerLabelReplacements[heading];
}
return result;
});
}
return headings;
@@ -189,15 +217,18 @@ GradebookTable.propTypes = {
fetchGradeOverrideHistory: PropTypes.func.isRequired,
};
export const mapStateToProps = (state) => ({
areGradesFrozen: state.assignmentTypes.areGradesFrozen,
format: state.grades.gradeFormat,
grades: state.grades.results,
headings: getHeadings(state),
});
export const mapStateToProps = (state) => {
const { assignmentTypes, grades, root } = selectors;
return {
areGradesFrozen: assignmentTypes.areGradesFrozen(state),
format: grades.gradeFormat(state),
grades: grades.allGrades(state),
headings: root.getHeadings(state),
};
};
export const mapDispatchToProps = {
fetchGradeOverrideHistory,
fetchGradeOverrideHistory: thunkActions.grades.fetchGradeOverrideHistory,
};
export default connect(mapStateToProps, mapDispatchToProps)(GradebookTable);

View File

@@ -0,0 +1,111 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { Button, Icon, SearchField } from '@edx/paragon';
import selectors from 'data/selectors';
import thunkActions from 'data/thunkActions';
/**
* Controls for filtering the GradebookTable. Contains the "Edit Filters" button for opening the filter drawer
* as well as the search box for searching by username/email.
*/
export class SearchControls extends React.Component {
constructor(props) {
super(props);
this.onSubmit = this.onSubmit.bind(this);
this.onChange = this.onChange.bind(this);
this.onClear = this.onClear.bind(this);
}
/** Submitting searches for user matching the username/email in `value` */
onSubmit(value) {
this.props.searchForUser(
this.props.courseId,
value,
this.props.selectedCohort,
this.props.selectedTrack,
this.props.selectedAssignmentType,
);
}
/** Changing the search value stores the key in Gradebook. Currently unused */
onChange(filterValue) {
this.props.setFilterValue(filterValue);
}
/** Clearing the search box falls back to showing students with already applied filters */
onClear() {
this.props.getUserGrades(
this.props.courseId,
this.props.selectedCohort,
this.props.selectedTrack,
this.props.selectedAssignmentType,
);
}
render() {
return (
<>
<h4>Step 1: Filter the Grade Report</h4>
<div className="d-flex justify-content-between">
<Button
id="edit-filters-btn"
className="btn-primary align-self-start"
onClick={this.props.toggleFilterDrawer}
>
<Icon className="fa fa-filter" /> Edit Filters
</Button>
<div>
<SearchField
onSubmit={this.onSubmit}
inputLabel="Search for a learner"
onChange={this.onChange}
onClear={this.onClear}
value={this.props.filterValue}
/>
<small className="form-text text-muted search-help-text">Search by username, email, or student key</small>
</div>
</div>
</>
);
}
}
SearchControls.defaultProps = {
courseId: '',
filterValue: '',
selectedAssignmentType: '',
selectedCohort: null,
selectedTrack: null,
};
SearchControls.propTypes = {
courseId: PropTypes.string,
filterValue: PropTypes.string,
setFilterValue: PropTypes.func.isRequired,
toggleFilterDrawer: PropTypes.func.isRequired,
// From Redux
getUserGrades: PropTypes.func.isRequired,
searchForUser: PropTypes.func.isRequired,
selectedAssignmentType: PropTypes.string,
selectedCohort: PropTypes.string,
selectedTrack: PropTypes.string,
};
export const mapStateToProps = (state) => {
const { filters } = selectors;
return {
selectedAssignmentType: filters.assignmentType(state),
selectedTrack: filters.track(state),
selectedCohort: filters.cohort(state),
};
};
export const mapDispatchToProps = {
getUserGrades: thunkActions.grades.fetchGrades,
searchForUser: thunkActions.grades.fetchMatchingUserGrades,
};
export default connect(mapStateToProps, mapDispatchToProps)(SearchControls);

View File

@@ -0,0 +1,118 @@
import React from 'react';
import { shallow } from 'enzyme';
import {
fetchGrades,
fetchMatchingUserGrades,
} from '../../data/thunkActions/grades';
import { mapDispatchToProps, mapStateToProps, SearchControls } from './SearchControls';
jest.mock('@edx/paragon', () => ({
Icon: 'Icon',
Button: 'Button',
SearchField: 'SearchField',
}));
describe('SearchControls', () => {
let props;
beforeEach(() => {
jest.resetAllMocks();
props = {
courseId: 'course-v1:edX+DEV101+T1',
filterValue: 'alice',
selectedAssignmentType: 'homework',
selectedCohort: 'spring term',
selectedTrack: 'masters',
getUserGrades: jest.fn(),
searchForUser: jest.fn(),
setFilterValue: jest.fn(),
toggleFilterDrawer: jest.fn().mockName('toggleFilterDrawer'),
};
});
const searchControls = (overriddenProps) => {
props = { ...props, ...overriddenProps };
return shallow(<SearchControls {...props} />);
};
describe('Component', () => {
describe('onSubmit', () => {
it('calls props.searchForUser with correct data', () => {
const wrapper = searchControls();
wrapper.instance().onSubmit('bob');
expect(props.searchForUser).toHaveBeenCalledWith(
props.courseId,
'bob',
props.selectedCohort,
props.selectedTrack,
props.selectedAssignmentType,
);
});
});
describe('onChange', () => {
it('saves the changed search value to Gradebook state', () => {
const wrapper = searchControls();
wrapper.instance().onChange('bob');
expect(props.setFilterValue).toHaveBeenCalledWith('bob');
});
});
describe('onClear', () => {
it('re-runs search with existing filters', () => {
const wrapper = searchControls();
wrapper.instance().onClear();
expect(props.getUserGrades).toHaveBeenCalledWith(
props.courseId,
props.selectedCohort,
props.selectedTrack,
props.selectedAssignmentType,
);
});
});
describe('mapStateToProps', () => {
const state = {
filters: {
assignmentType: 'labs',
track: 'honor',
cohort: 'fall term',
},
};
it('maps assignment type filter correctly', () => {
expect(mapStateToProps(state).selectedAssignmentType).toEqual(state.filters.assignmentType);
});
it('maps track filter correctly', () => {
expect(mapStateToProps(state).selectedTrack).toEqual(state.filters.track);
});
it('maps cohort filter correctly', () => {
expect(mapStateToProps(state).selectedCohort).toEqual(state.filters.cohort);
});
});
describe('mapDispatchToProps', () => {
test('getUserGrades', () => {
expect(mapDispatchToProps.getUserGrades).toEqual(fetchGrades);
});
test('searchForUser', () => {
expect(mapDispatchToProps.searchForUser).toEqual(fetchMatchingUserGrades);
});
});
describe('Snapshots', () => {
test('basic snapshot', () => {
const wrapper = searchControls();
wrapper.instance().onChange = jest.fn().mockName('onChange');
wrapper.instance().onClear = jest.fn().mockName('onClear');
wrapper.instance().onSubmit = jest.fn().mockName('onSubmit');
expect(wrapper.instance().render()).toMatchSnapshot();
});
});
});
});

View File

@@ -0,0 +1,72 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { StatusAlert } from '@edx/paragon';
import selectors from 'data/selectors';
import actions from 'data/actions';
export const maxCourseGradeInvalidMessage = 'Maximum course grade value must be between 0 and 100. ';
export const minCourseGradeInvalidMessage = 'Minimum course grade value must be between 0 and 100. ';
export class StatusAlerts extends React.Component {
get isCourseGradeFilterAlertOpen() {
const r = !this.props.isMinCourseGradeFilterValid
|| !this.props.isMaxCourseGradeFilterValid;
return r;
}
get courseGradeFilterAlertDialogText() {
let dialogText = '';
if (!this.props.isMinCourseGradeFilterValid) {
dialogText += minCourseGradeInvalidMessage;
}
if (!this.props.isMaxCourseGradeFilterValid) {
dialogText += maxCourseGradeInvalidMessage;
}
return dialogText;
}
render() {
return (
<>
<StatusAlert
alertType="success"
dialog="The grade has been successfully edited. You may see a slight delay before updates appear in the Gradebook."
onClose={this.props.handleCloseSuccessBanner}
open={this.props.showSuccessBanner}
/>
<StatusAlert
alertType="danger"
dialog={this.courseGradeFilterAlertDialogText}
dismissible={false}
open={this.isCourseGradeFilterAlertOpen}
/>
</>
);
}
}
StatusAlerts.defaultProps = {
isMinCourseGradeFilterValid: true,
isMaxCourseGradeFilterValid: true,
};
StatusAlerts.propTypes = {
isMinCourseGradeFilterValid: PropTypes.bool,
isMaxCourseGradeFilterValid: PropTypes.bool,
// redux
handleCloseSuccessBanner: PropTypes.func.isRequired,
showSuccessBanner: PropTypes.bool.isRequired,
};
export const mapStateToProps = (state) => ({
showSuccessBanner: selectors.grades.showSuccess(state),
});
export const mapDispatchToProps = {
handleCloseSuccessBanner: actions.grades.banner.close,
};
export default connect(mapStateToProps, mapDispatchToProps)(StatusAlerts);

View File

@@ -0,0 +1,100 @@
import React from 'react';
import { shallow } from 'enzyme';
import actions from 'data/actions';
import {
StatusAlerts,
mapDispatchToProps,
mapStateToProps,
maxCourseGradeInvalidMessage,
minCourseGradeInvalidMessage,
} from './StatusAlerts';
jest.mock('@edx/paragon', () => ({
StatusAlert: 'StatusAlert',
}));
describe('StatusAlerts', () => {
let props = {
showSuccessBanner: true,
isMaxCourseGradeFilterValid: true,
isMinCourseGradeFilterValid: true,
};
beforeEach(() => {
props = {
...props,
handleCloseSuccessBanner: jest.fn().mockName('handleCloseSuccessBanner'),
};
});
describe('snapshots', () => {
let el;
it('basic snapshot', () => {
el = shallow(<StatusAlerts {...props} />);
const courseGradeFilterAlertDialogText = 'the quiCk brown does somEthing or other';
jest.spyOn(
el.instance(),
'courseGradeFilterAlertDialogText',
'get',
).mockReturnValue(courseGradeFilterAlertDialogText);
expect(el.instance().render()).toMatchSnapshot();
});
});
describe('behavior', () => {
it.each([
[false, false],
[false, true],
[true, false],
[true, true],
])('min + max course grade validity', (isMinCourseGradeFilterValid, isMaxCourseGradeFilterValid) => {
props = {
...props,
isMinCourseGradeFilterValid,
isMaxCourseGradeFilterValid,
};
const el = shallow(<StatusAlerts {...props} />);
expect(
el.instance().isCourseGradeFilterAlertOpen,
).toEqual(
!isMinCourseGradeFilterValid || !isMaxCourseGradeFilterValid,
);
if (!isMaxCourseGradeFilterValid) {
expect(
el.instance().courseGradeFilterAlertDialogText,
).toEqual(
expect.stringContaining(maxCourseGradeInvalidMessage),
);
}
if (!isMinCourseGradeFilterValid) {
expect(
el.instance().courseGradeFilterAlertDialogText,
).toEqual(
expect.stringContaining(minCourseGradeInvalidMessage),
);
}
});
});
describe('mapStateToProps', () => {
it('showSuccessBanner', () => {
const arbitraryValue = 'AppleBananaCucumber';
const state = {
grades: {
showSuccess: arbitraryValue,
},
};
expect(mapStateToProps(state).showSuccessBanner).toBe(arbitraryValue);
});
});
describe('handleCloseSuccessBanner', () => {
test('handleCloseSuccessBanner', () => {
expect(
mapDispatchToProps.handleCloseSuccessBanner,
).toEqual(
actions.grades.banner.close,
);
});
});
});

View File

@@ -0,0 +1,37 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SearchControls Component Snapshots basic snapshot 1`] = `
<React.Fragment>
<h4>
Step 1: Filter the Grade Report
</h4>
<div
className="d-flex justify-content-between"
>
<Button
className="btn-primary align-self-start"
id="edit-filters-btn"
onClick={[MockFunction toggleFilterDrawer]}
>
<Icon
className="fa fa-filter"
/>
Edit Filters
</Button>
<div>
<SearchField
inputLabel="Search for a learner"
onChange={[MockFunction onChange]}
onClear={[MockFunction onClear]}
onSubmit={[MockFunction onSubmit]}
value="alice"
/>
<small
className="form-text text-muted search-help-text"
>
Search by username, email, or student key
</small>
</div>
</div>
</React.Fragment>
`;

View File

@@ -0,0 +1,18 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`StatusAlerts snapshots basic snapshot 1`] = `
<React.Fragment>
<StatusAlert
alertType="success"
dialog="The grade has been successfully edited. You may see a slight delay before updates appear in the Gradebook."
onClose={[MockFunction handleCloseSuccessBanner]}
open={true}
/>
<StatusAlert
alertType="danger"
dialog="the quiCk brown does somEthing or other"
dismissible={false}
open={false}
/>
</React.Fragment>
`;

View File

@@ -72,7 +72,10 @@
.student-key {
font-size: 14px;
}
#courseGradeTooltipIcon {
float: right;
}
.table thead tr {
min-height: 60px;
@@ -111,6 +114,9 @@
td:nth-child(2) {
width: 240px;
}
th:nth-last-of-type(1) {
width: 150px;
}
th, td {
width: 120px;
}
@@ -150,7 +156,7 @@
}
.form-group {
.form-group, .pgn__form-group {
label {
font-weight: bold;
}
@@ -160,7 +166,7 @@
.grade-filter-inputs {
.percent-group {
display: inline-block;
.form-group {
.form-group, .pgn__form-group {
width: 115px;
display: inline-block;
}

View File

@@ -2,13 +2,8 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
Button,
Collapsible,
Icon,
InputSelect,
InputText,
SearchField,
StatusAlert,
Tab,
Tabs,
} from '@edx/paragon';
@@ -21,11 +16,13 @@ import Drawer from '../Drawer';
import initialFilters from '../../data/constants/filters';
import ConnectedFilterBadges from '../FilterBadges';
import Assignments from './Assignments';
import BulkManagement from './BulkManagement';
import BulkManagementControls from './BulkManagementControls';
import EditModal from './EditModal';
import GradebookFilters from './GradebookFilters';
import GradebookTable from './GradebookTable';
import SearchControls from './SearchControls';
import StatusAlerts from './StatusAlerts';
export default class Gradebook extends React.Component {
constructor(props) {
@@ -76,18 +73,6 @@ export default class Gradebook extends React.Component {
return ['Grades'];
};
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;
};
updateQueryParams = (queryParams) => {
const parsed = queryString.parse(this.props.location.search);
Object.keys(queryParams).forEach((key) => {
@@ -100,121 +85,8 @@ export default class Gradebook extends React.Component {
this.props.history.push(`?${queryString.stringify(parsed)}`);
};
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;
};
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 });
};
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';
};
lmsInstructorDashboardUrl = courseId => `${configuration.LMS_BASE_URL}/courses/${courseId}/instructor`;
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 = {};
@@ -252,7 +124,8 @@ export default class Gradebook extends React.Component {
safeSetState = this.createLimitedSetter(
'adjustedGradePossible',
'adjustedGradeValue',
'assignmnentName',
'assignmentName',
'filterValue',
'modalOpen',
'reasonForChange',
'todaysDate',
@@ -261,6 +134,22 @@ export default class Gradebook extends React.Component {
'updateUserName',
);
setFilters = this.createLimitedSetter(
'assignmentGradeMin',
'assignmentGradeMax',
'courseGradeMin',
'courseGradeMax',
'isMinCourseGradeFilterValid',
'isMaxCourseGradeFilterValid',
);
filterValues = () => ({
assignmentGradeMin: this.state.assignmentGradeMin,
assignmentGradeMax: this.state.assignmentGradeMax,
courseGradeMin: this.state.courseGradeMin,
courseGradeMax: this.state.courseGradeMax,
});
render() {
return (
<Drawer
@@ -288,49 +177,23 @@ export default class Gradebook extends React.Component {
)}
<Tabs defaultActiveKey="grades">
<Tab eventKey="grades" title="Grades">
<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>
{this.props.showSpinner && (
<div className="spinner-overlay">
<Icon className="fa fa-spinner fa-spin fa-5x color-black" />
</div>
</div>
)}
<SearchControls
courseId={this.props.courseId}
filterValue={this.state.filterValue}
setFilterValue={this.createStateFieldSetter('filterValue')}
toggleFilterDrawer={toggleFilterDrawer}
/>
<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
}
<StatusAlerts
isMinCourseGradeFilterValid={this.state.isMinCourseGradeFilterValid}
isMaxCourseGradeFilterValid={this.state.isMaxCourseGradeFilterValid}
/>
<h4>Step 2: View or Modify Individual Grades</h4>
{this.props.totalUsersCount
@@ -402,70 +265,12 @@ export default class Gradebook extends React.Component {
</>
)}
>
<Assignments
assignmentGradeMin={this.state.assignmentGradeMin}
assignmentGradeMax={this.state.assignmentGradeMax}
courseId={this.props.courseId}
setAssignmentGradeMin={this.createStateFieldSetter('assignmentGradeMin')}
setAssignmentGradeMax={this.createStateFieldSetter('assignmentGradeMax')}
<GradebookFilters
setFilters={this.setFilters}
filterValues={this.filterValues()}
updateQueryParams={this.updateQueryParams}
courseId={this.props.courseId}
/>
<Collapsible title="Overall Grade" defaultOpen className="filter-group mb-3">
<div className="grade-filter-inputs">
<div className="percent-group">
<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>
</div>
<div className="percent-group">
<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>
</div>
</div>
<div className="grade-filter-action">
<Button
variant="outline-secondary"
onClick={this.handleCourseGradeFilterApplyButtonClick}
>
Apply
</Button>
</div>
</Collapsible>
<Collapsible title="Student Groups" defaultOpen className="filter-group mb-3">
<InputSelect
label="Tracks"
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"
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>
);
}
@@ -474,7 +279,6 @@ export default class Gradebook extends React.Component {
Gradebook.defaultProps = {
areGradesFrozen: false,
canUserViewGradebook: false,
cohorts: [],
courseId: '',
filteredUsersCount: null,
location: {
@@ -486,17 +290,11 @@ Gradebook.defaultProps = {
showBulkManagement: false,
showSpinner: false,
totalUsersCount: null,
tracks: [],
};
Gradebook.propTypes = {
areGradesFrozen: PropTypes.bool,
canUserViewGradebook: PropTypes.bool,
closeBanner: PropTypes.func.isRequired,
cohorts: PropTypes.arrayOf(PropTypes.shape({
name: PropTypes.string,
id: PropTypes.number,
})),
courseId: PropTypes.string,
filteredUsersCount: PropTypes.number,
getRoles: PropTypes.func.isRequired,
@@ -511,17 +309,11 @@ Gradebook.propTypes = {
search: PropTypes.string,
}),
resetFilters: PropTypes.func.isRequired,
searchForUser: PropTypes.func.isRequired,
selectedAssignmentType: PropTypes.string,
selectedCohort: PropTypes.string,
selectedTrack: PropTypes.string,
showBulkManagement: PropTypes.bool,
showSpinner: PropTypes.bool,
showSuccess: PropTypes.bool.isRequired,
toggleFormat: PropTypes.func.isRequired,
totalUsersCount: PropTypes.number,
tracks: PropTypes.arrayOf(PropTypes.shape({
name: PropTypes.string,
})),
updateCourseGradeFilter: PropTypes.func.isRequired,
};

View File

@@ -1,132 +1,67 @@
import { connect } from 'react-redux';
import Gradebook from '../../components/Gradebook';
import {
closeBanner,
fetchGradeOverrideHistory,
fetchGrades,
fetchMatchingUserGrades,
fetchPrevNextGrades,
filterAssignmentType,
submitFileUploadFormData,
toggleGradeFormat,
downloadBulkGradesReport,
downloadInterventionReport,
} from '../../data/actions/grades';
import { fetchCohorts } from '../../data/actions/cohorts';
import { fetchTracks } from '../../data/actions/tracks';
import {
initializeFilters, resetFilters, updateAssignmentFilter, updateAssignmentLimits, updateCourseGradeFilter,
} from '../../data/actions/filters';
import stateHasMastersTrack from '../../data/selectors/tracks';
import {
getBulkManagementHistory,
getHeadings,
formatMinAssignmentGrade,
formatMaxAssignmentGrade,
formatMinCourseGrade,
formatMaxCourseGrade,
} from '../../data/selectors/grades';
import { selectableAssignmentLabels } from '../../data/selectors/filters';
import { getCohortNameById } from '../../data/selectors/cohorts';
import { fetchAssignmentTypes } from '../../data/actions/assignmentTypes';
import { getRoles } from '../../data/actions/roles';
import LmsApiService from '../../data/services/LmsApiService';
import thunkActions from 'data/thunkActions';
import actions from 'data/actions';
import selectors from 'data/selectors';
function shouldShowSpinner(state) {
if (state.roles.canUserViewGradebook === true) {
return state.grades.showSpinner;
} if (state.roles.canUserViewGradebook === false) {
return false;
} // canUserViewGradebook === null
return true;
}
import Gradebook from 'components/Gradebook';
const mapStateToProps = (state, ownProps) => (
{
areGradesFrozen: state.assignmentTypes.areGradesFrozen,
assignmentTypes: state.assignmentTypes.results,
assignmentFilterOptions: selectableAssignmentLabels(state),
bulkImportError: state.grades.bulkManagement
&& state.grades.bulkManagement.errorMessages
? `Errors while processing: ${state.grades.bulkManagement.errorMessages.join(', ')}`
: '',
bulkManagementHistory: getBulkManagementHistory(state),
cohorts: state.cohorts.results,
courseId: ownProps.match.params.courseId,
canUserViewGradebook: state.roles.canUserViewGradebook,
filteredUsersCount: state.grades.filteredUsersCount,
format: state.grades.gradeFormat,
gradeExportUrl: LmsApiService.getGradeExportCsvUrl(ownProps.match.params.courseId, {
cohort: getCohortNameById(state, state.filters.cohort),
track: state.filters.track,
assignment: (state.filters.assignment || {}).id,
assignmentType: state.filters.assignmentType,
assignmentGradeMin: formatMinAssignmentGrade(
state.filters.assignmentGradeMin,
{ assignmentId: (state.filters.assignment || {}).id },
),
assignmentGradeMax: formatMaxAssignmentGrade(
state.filters.assignmentGradeMax,
{ assignmentId: (state.filters.assignment || {}).id },
),
courseGradeMin: formatMinCourseGrade(state.filters.courseGradeMin),
courseGradeMax: formatMaxCourseGrade(state.filters.courseGradeMax),
}),
grades: state.grades.results,
headings: getHeadings(state),
interventionExportUrl:
LmsApiService.getInterventionExportCsvUrl(ownProps.match.params.courseId, {
cohort: getCohortNameById(state, state.filters.cohort),
assignment: (state.filters.assignment || {}).id,
assignmentType: state.filters.assignmentType,
assignmentGradeMin: formatMinAssignmentGrade(
state.filters.assignmentGradeMin,
{ assignmentId: (state.filters.assignment || {}).id },
),
assignmentGradeMax: formatMaxAssignmentGrade(
state.filters.assignmentGradeMax,
{ assignmentId: (state.filters.assignment || {}).id },
),
courseGradeMin: formatMinCourseGrade(state.filters.courseGradeMin),
courseGradeMax: formatMaxCourseGrade(state.filters.courseGradeMax),
}),
const mapStateToProps = (state, ownProps) => {
const {
root,
assignmentTypes,
filters,
grades,
roles,
} = selectors;
const { courseId } = ownProps.match.params;
return {
courseId,
areGradesFrozen: assignmentTypes.areGradesFrozen(state),
assignmentTypes: assignmentTypes.allAssignmentTypes(state),
assignmentFilterOptions: filters.selectableAssignmentLabels(state),
bulkImportError: grades.bulkImportError(state),
bulkManagementHistory: grades.bulkManagementHistoryEntries(state),
canUserViewGradebook: roles.canUserViewGradebook(state),
filteredUsersCount: grades.filteredUsersCount(state),
format: grades.gradeFormat(state),
gradeExportUrl: root.gradeExportUrl(state, { courseId }),
grades: grades.allGrades(state),
headings: root.getHeadings(state),
interventionExportUrl: root.interventionExportUrl(state, { courseId }),
nextPage: state.grades.nextPage,
prevPage: state.grades.prevPage,
selectedTrack: state.filters.track,
selectedCohort: state.filters.cohort,
selectedAssignmentType: state.filters.assignmentType,
selectedAssignment: (state.filters.assignment || {}).label,
showBulkManagement: stateHasMastersTrack(state) && state.config.bulkManagementAvailable,
showSpinner: shouldShowSpinner(state),
showSuccess: state.grades.showSuccess,
totalUsersCount: state.grades.totalUsersCount,
tracks: state.tracks.results,
uploadSuccess: !!(state.grades.bulkManagement
&& state.grades.bulkManagement.uploadSuccess),
}
);
selectedTrack: filters.track(state),
selectedCohort: filters.cohort(state),
selectedAssignmentType: filters.assignmentType(state),
selectedAssignment: filters.selectedAssignmentLabel(state),
showBulkManagement: root.showBulkManagement(state, { courseId }),
showSpinner: root.shouldShowSpinner(state),
totalUsersCount: grades.totalUsersCount(state),
uploadSuccess: grades.uploadSuccess(state),
};
};
const mapDispatchToProps = {
closeBanner,
downloadBulkGradesReport,
downloadInterventionReport,
fetchGradeOverrideHistory,
filterAssignmentType,
getAssignmentTypes: fetchAssignmentTypes,
getCohorts: fetchCohorts,
getPrevNextGrades: fetchPrevNextGrades,
getRoles,
getTracks: fetchTracks,
getUserGrades: fetchGrades,
initializeFilters,
resetFilters,
searchForUser: fetchMatchingUserGrades,
submitFileUploadFormData,
toggleFormat: toggleGradeFormat,
updateAssignmentFilter,
updateAssignmentLimits,
updateCourseGradeFilter,
downloadBulkGradesReport: actions.grades.downloadReport.bulkGrades,
downloadInterventionReport: actions.grades.downloadReport.intervention,
toggleFormat: actions.grades.toggleGradeFormat,
filterAssignmentType: actions.filters.update.assignmentType,
initializeFilters: actions.filters.initialize,
resetFilters: actions.filters.reset,
updateAssignmentFilter: actions.filters.update.assignment,
updateAssignmentLimits: actions.filters.update.assignmentLimits,
fetchGradeOverrideHistory: thunkActions.grades.fetchGradeOverrideHistory,
getAssignmentTypes: thunkActions.assignmentTypes.fetchAssignmentTypes,
getCohorts: thunkActions.cohorts.fetchCohorts,
getPrevNextGrades: thunkActions.grades.fetchPrevNextGrades,
getRoles: thunkActions.roles.fetchRoles,
getTracks: thunkActions.tracks.fetchTracks,
getUserGrades: thunkActions.grades.fetchGrades,
submitFileUploadFormData: thunkActions.grades.submitFileUploadFormData,
};
const GradebookPage = connect(

View File

@@ -1,40 +1,17 @@
import {
STARTED_FETCHING_ASSIGNMENT_TYPES,
GOT_ASSIGNMENT_TYPES,
ERROR_FETCHING_ASSIGNMENT_TYPES,
GOT_ARE_GRADES_FROZEN,
} from '../constants/actionTypes/assignmentTypes';
import GOT_BULK_MANAGEMENT_CONFIG from '../constants/actionTypes/config';
import LmsApiService from '../services/LmsApiService';
import { StrictDict } from 'utils';
import { createActionFactory } from './utils';
const startedFetchingAssignmentTypes = () => ({ type: STARTED_FETCHING_ASSIGNMENT_TYPES });
const errorFetchingAssignmentTypes = () => ({ type: ERROR_FETCHING_ASSIGNMENT_TYPES });
const gotAssignmentTypes = assignmentTypes => ({ type: GOT_ASSIGNMENT_TYPES, assignmentTypes });
const gotGradesFrozen = areGradesFrozen => ({ type: GOT_ARE_GRADES_FROZEN, areGradesFrozen });
const gotBulkManagementConfig = bulkManagementEnabled => ({
type: GOT_BULK_MANAGEMENT_CONFIG,
data: bulkManagementEnabled,
});
export const dataKey = 'assignmentTypes';
const createAction = createActionFactory(dataKey);
const fetchAssignmentTypes = courseId => (
(dispatch) => {
dispatch(startedFetchingAssignmentTypes());
return LmsApiService.fetchAssignmentTypes(courseId)
.then(response => response.data)
.then((data) => {
dispatch(gotAssignmentTypes(Object.keys(data.assignment_types)));
dispatch(gotGradesFrozen(data.grades_frozen));
dispatch(gotBulkManagementConfig(data.can_see_bulk_management));
})
.catch(() => {
dispatch(errorFetchingAssignmentTypes());
});
}
);
export {
fetchAssignmentTypes,
startedFetchingAssignmentTypes,
gotAssignmentTypes,
errorFetchingAssignmentTypes,
const fetching = {
error: createAction('fetching/error'),
started: createAction('fetching/started'),
received: createAction('fetching/received'),
};
const gotGradesFrozen = createAction('gotGradesFrozen');
export default StrictDict({
fetching: StrictDict(fetching),
gotGradesFrozen,
});

View File

@@ -1,101 +1,22 @@
import axios from 'axios';
import configureMockStore from 'redux-mock-store';
import MockAdapter from 'axios-mock-adapter';
import thunk from 'redux-thunk';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { configuration } from '../../config';
import { fetchAssignmentTypes } from './assignmentTypes';
import {
STARTED_FETCHING_ASSIGNMENT_TYPES,
GOT_ASSIGNMENT_TYPES,
ERROR_FETCHING_ASSIGNMENT_TYPES,
GOT_ARE_GRADES_FROZEN,
} from '../constants/actionTypes/assignmentTypes';
import GOT_BULK_MANAGEMENT_CONFIG from '../constants/actionTypes/config';
const mockStore = configureMockStore([thunk]);
jest.mock('@edx/frontend-platform/auth');
const axiosMock = new MockAdapter(axios);
getAuthenticatedHttpClient.mockReturnValue(axios);
axios.isAccessTokenExpired = jest.fn();
axios.isAccessTokenExpired.mockReturnValue(false);
import actions, { dataKey } from './assignmentTypes';
import { testAction, testActionTypes } from './testUtils';
describe('actions', () => {
afterEach(() => {
axiosMock.reset();
describe('action types', () => {
const actionTypes = [
actions.fetching.error,
actions.fetching.started,
actions.fetching.received,
actions.gotGradesFrozen,
].map(action => action.toString());
testActionTypes(actionTypes, dataKey);
});
describe('fetchAssignmentTypes', () => {
const courseId = 'course-v1:edX+DemoX+Demo_Course';
const responseData = {
assignment_types: {
Exam: {
drop_count: 0,
min_count: 1,
short_label: 'Exam',
type: 'Exam',
weight: 0.25,
},
Homework: {
drop_count: 1,
min_count: 3,
short_label: 'Ex',
type: 'Homework',
weight: 0.75,
},
},
grades_frozen: false,
can_see_bulk_management: true,
};
it('dispatches success action after fetching fetchAssignmentTypes', () => {
const expectedActions = [
{ type: STARTED_FETCHING_ASSIGNMENT_TYPES },
{ type: GOT_ASSIGNMENT_TYPES, assignmentTypes: Object.keys(responseData.assignment_types) },
{ type: GOT_ARE_GRADES_FROZEN, areGradesFrozen: responseData.grades_frozen },
{ type: GOT_BULK_MANAGEMENT_CONFIG, data: true },
];
const store = mockStore();
axiosMock.onGet(`${configuration.LMS_BASE_URL}/api/grades/v1/gradebook/${courseId}/grading-info?graded_only=true`)
.replyOnce(200, JSON.stringify(responseData));
return store.dispatch(fetchAssignmentTypes(courseId)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it('dispatches failure action after fetching cohorts', () => {
const expectedActions = [
{ type: STARTED_FETCHING_ASSIGNMENT_TYPES },
{ type: ERROR_FETCHING_ASSIGNMENT_TYPES },
];
const store = mockStore();
axiosMock.onGet(`${configuration.LMS_BASE_URL}/api/grades/v1/gradebook/${courseId}/grading-info?graded_only=true`)
.replyOnce(500, JSON.stringify({}));
return store.dispatch(fetchAssignmentTypes(courseId)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it('dispatches frozen grade action with True value after fetching', () => {
const expectedActions = [
{ type: STARTED_FETCHING_ASSIGNMENT_TYPES },
{ type: GOT_ASSIGNMENT_TYPES, assignmentTypes: Object.keys(responseData.assignment_types) },
{ type: GOT_ARE_GRADES_FROZEN, areGradesFrozen: true },
{ type: GOT_BULK_MANAGEMENT_CONFIG, data: true },
];
const store = mockStore();
responseData.grades_frozen = true;
axiosMock.onGet(`${configuration.LMS_BASE_URL}/api/grades/v1/gradebook/${courseId}/grading-info?graded_only=true`)
.replyOnce(200, JSON.stringify(responseData));
return store.dispatch(fetchAssignmentTypes(courseId)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
describe('actions provided', () => {
describe('fetching actions', () => {
test('error action', () => testAction(actions.fetching.error));
test('started action', () => testAction(actions.fetching.started));
test('received action', () => testAction(actions.fetching.received));
});
test('gotGradesFrozen action', () => testAction(actions.gotGradesFrozen));
});
});

View File

@@ -1,31 +1,15 @@
import {
STARTED_FETCHING_COHORTS,
GOT_COHORTS,
ERROR_FETCHING_COHORTS,
} from '../constants/actionTypes/cohorts';
import LmsApiService from '../services/LmsApiService';
import { StrictDict } from 'utils';
import { createActionFactory } from './utils';
const startedFetchingCohorts = () => ({ type: STARTED_FETCHING_COHORTS });
const errorFetchingCohorts = () => ({ type: ERROR_FETCHING_COHORTS });
const gotCohorts = cohorts => ({ type: GOT_COHORTS, cohorts });
export const dataKey = 'cohorts';
const createAction = createActionFactory(dataKey);
const fetchCohorts = courseId => (
(dispatch) => {
dispatch(startedFetchingCohorts());
return LmsApiService.fetchCohorts(courseId)
.then(response => response.data)
.then((data) => {
dispatch(gotCohorts(data.cohorts));
})
.catch(() => {
dispatch(errorFetchingCohorts());
});
}
);
export {
fetchCohorts,
startedFetchingCohorts,
gotCohorts,
errorFetchingCohorts,
const fetching = {
started: createAction('fetching/started'),
error: createAction('fetching/error'),
received: createAction('fetching/received'),
};
export default StrictDict({
fetching: StrictDict(fetching),
});

View File

@@ -1,80 +1,20 @@
import axios from 'axios';
import configureMockStore from 'redux-mock-store';
import MockAdapter from 'axios-mock-adapter';
import thunk from 'redux-thunk';
import actions, { dataKey } from './cohorts';
import { testAction, testActionTypes } from './testUtils';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { configuration } from '../../config';
import { fetchCohorts } from './cohorts';
import {
STARTED_FETCHING_COHORTS,
GOT_COHORTS,
ERROR_FETCHING_COHORTS,
} from '../constants/actionTypes/cohorts';
const mockStore = configureMockStore([thunk]);
jest.mock('@edx/frontend-platform/auth');
const axiosMock = new MockAdapter(axios);
getAuthenticatedHttpClient.mockReturnValue(axios);
axios.isAccessTokenExpired = jest.fn();
axios.isAccessTokenExpired.mockReturnValue(false);
describe('actions', () => {
afterEach(() => {
axiosMock.reset();
describe('actions.cohorts', () => {
describe('action types', () => {
const actionTypes = [
actions.fetching.error,
actions.fetching.started,
actions.fetching.received,
].map(action => action.toString());
testActionTypes(actionTypes, dataKey);
});
describe('fetchCohorts', () => {
const courseId = 'course-v1:edX+DemoX+Demo_Course';
it('dispatches success action after fetching cohorts', () => {
const responseData = {
cohorts: [
{
assignment_type: 'manual',
group_id: null,
id: 1,
name: 'default_group',
user_count: 2,
user_partition_id: null,
},
{
assignment_type: 'auto',
group_id: null,
id: 2,
name: 'auto_group',
user_count: 5,
user_partition_id: null,
}],
};
const expectedActions = [
{ type: STARTED_FETCHING_COHORTS },
{ type: GOT_COHORTS, cohorts: responseData.cohorts },
];
const store = mockStore();
axiosMock.onGet(`${configuration.LMS_BASE_URL}/courses/${courseId}/cohorts/`)
.replyOnce(200, JSON.stringify(responseData));
return store.dispatch(fetchCohorts(courseId)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it('dispatches failure action after fetching cohorts', () => {
const expectedActions = [
{ type: STARTED_FETCHING_COHORTS },
{ type: ERROR_FETCHING_COHORTS },
];
const store = mockStore();
axiosMock.onGet(`${configuration.LMS_BASE_URL}/courses/${courseId}/cohorts/`)
.replyOnce(500, JSON.stringify({}));
return store.dispatch(fetchCohorts(courseId)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
describe('actions provided', () => {
describe('fecthing actions', () => {
test('error action', () => testAction(actions.fetching.error));
test('started action', () => testAction(actions.fetching.started));
test('received action', () => testAction(actions.fetching.received));
});
});
});

View File

@@ -0,0 +1,11 @@
import { StrictDict } from 'utils';
import { createActionFactory } from './utils';
export const dataKey = 'config';
const createAction = createActionFactory(dataKey);
const gotBulkManagementConfig = createAction('gotBulkManagement');
export default StrictDict({
gotBulkManagementConfig,
});

View File

@@ -0,0 +1,14 @@
import actions, { dataKey } from './config';
import { testAction, testActionTypes } from './testUtils';
describe('actions.cohorts', () => {
describe('action types', () => {
const actionTypes = [
actions.gotBulkManagementConfig,
].map(action => action.toString());
testActionTypes(actionTypes, dataKey);
});
describe('actions provided', () => {
test('gotBulkManagementConfig action', () => testAction(actions.gotBulkManagementConfig));
});
});

View File

@@ -1,9 +1,11 @@
import { StrictDict } from 'utils';
import initialFilters from '../constants/filters';
import {
INITIALIZE_FILTERS, RESET_FILTERS, UPDATE_ASSIGNMENT_FILTER, UPDATE_ASSIGNMENT_LIMITS, UPDATE_COURSE_GRADE_LIMITS,
} from '../constants/actionTypes/filters';
import { createActionFactory } from './utils';
const initializeFilters = ({
export const dataKey = 'filters';
const createAction = createActionFactory(dataKey);
const initialize = createAction('initialize', ({
assignment = initialFilters.assignment,
assignmentType = initialFilters.assignmentType,
track = initialFilters.track,
@@ -12,9 +14,9 @@ const initializeFilters = ({
assignmentGradeMax = initialFilters.assignmentGradeMax,
courseGradeMin = initialFilters.courseGradeMin,
courseGradeMax = initialFilters.assignmentGradeMax,
includeCourseRoleMembers = initialFilters.includeCourseRoleMembers,
}) => ({
type: INITIALIZE_FILTERS,
data: {
payload: {
assignment: { id: assignment },
assignmentType,
track,
@@ -23,34 +25,21 @@ const initializeFilters = ({
assignmentGradeMax,
courseGradeMin,
courseGradeMax,
includeCourseRoleMembers: Boolean(includeCourseRoleMembers),
},
}));
const reset = createAction('reset');
const update = StrictDict({
assignment: createAction('update/assignment'),
assignmentType: createAction('update/assignmentType'),
assignmentLimits: createAction('update/assignmentLimits'),
courseGradeLimits: createAction('update/courseGradeLimits'),
includeCourseRoleMembers: createAction('update/includeCourseRoleMembers'),
});
const resetFilters = filterNames => ({
type: RESET_FILTERS,
filterNames,
export default StrictDict({
initialize,
reset,
update: StrictDict(update),
});
const updateAssignmentFilter = assignment => ({
type: UPDATE_ASSIGNMENT_FILTER,
data: assignment,
});
const updateAssignmentLimits = (minGrade, maxGrade) => ({
type: UPDATE_ASSIGNMENT_LIMITS,
data: { minGrade, maxGrade },
});
const updateCourseGradeFilter = (courseGradeMin, courseGradeMax, courseId) => ({
type: UPDATE_COURSE_GRADE_LIMITS,
data: {
courseGradeMin,
courseGradeMax,
courseId,
},
});
export {
initializeFilters, resetFilters, updateAssignmentFilter,
updateAssignmentLimits, updateCourseGradeFilter,
};

View File

@@ -0,0 +1,68 @@
import actions, { dataKey } from './filters';
import initialFilters from '../constants/filters';
import { testAction, testActionTypes } from './testUtils';
describe('actions.filters', () => {
describe('action types', () => {
const actionTypes = [
actions.initialize,
actions.reset,
actions.update.assignment,
actions.update.assignmentType,
actions.update.assignmentLimits,
actions.update.courseGradeLimits,
actions.update.includeCourseRoleMembers,
].map(action => action.toString());
testActionTypes(actionTypes, dataKey);
});
describe('actions provided', () => {
describe('initialize action', () => {
it('sets initialFilters values for missing args', () => {
testAction(actions.initialize, {}, {
assignment: { id: initialFilters.assignment },
assignmentType: initialFilters.assignmentType,
cohort: initialFilters.cohort,
track: initialFilters.track,
assignmentGradeMin: initialFilters.assignmentGradeMin,
assignmentGradeMax: initialFilters.assignmentGradeMax,
courseGradeMin: initialFilters.courseGradeMin,
courseGradeMax: initialFilters.courseGradeMax,
includeCourseRoleMembers: initialFilters.includeCourseRoleMembers,
});
});
it('loads filters from args', () => {
const expected = {
assignment: { id: 'assIGNmentId' },
assignmentType: 'aType',
track: 'masters',
cohort: 3,
assignmentGradeMin: 23,
assignmentGradeMax: 98,
courseGradeMin: 11,
courseGradeMax: 87,
includeCourseRoleMembers: true,
};
const args = { ...expected, assignment: expected.assignment.id, also: 'other stuff' };
testAction(actions.initialize, args, expected);
});
});
test('reset action', () => testAction(actions.reset));
describe('update actions', () => {
test('update.assignment action', () => (
testAction(actions.update.assignment)
));
test('update.assignmentType action', () => (
testAction(actions.update.assignmentType)
));
test('update.assignmentLimits action', () => (
testAction(actions.update.assignmentLimits)
));
test('update.courseGradeLimits action', () => (
testAction(actions.update.courseGradeLimits)
));
test('update.includeCourseRoleMembers action', () => (
testAction(actions.update.includeCourseRoleMembers)
));
});
});
});

View File

@@ -1,351 +1,103 @@
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import {
STARTED_FETCHING_GRADES,
FINISHED_FETCHING_GRADES,
ERROR_FETCHING_GRADES,
GOT_GRADES,
GRADE_UPDATE_REQUEST,
GRADE_UPDATE_SUCCESS,
GRADE_UPDATE_FAILURE,
TOGGLE_GRADE_FORMAT,
FILTER_BY_ASSIGNMENT_TYPE,
OPEN_BANNER,
CLOSE_BANNER,
START_UPLOAD,
UPLOAD_COMPLETE,
UPLOAD_ERR,
GOT_BULK_HISTORY,
BULK_HISTORY_ERR,
GOT_GRADE_OVERRIDE_HISTORY,
DONE_VIEWING_ASSIGNMENT,
ERROR_FETCHING_GRADE_OVERRIDE_HISTORY,
UPLOAD_OVERRIDE,
UPLOAD_OVERRIDE_ERROR,
BULK_GRADE_REPORT_DOWNLOADED,
INTERVENTION_REPORT_DOWNLOADED,
} from '../constants/actionTypes/grades';
import GRADE_OVERRIDE_HISTORY_ERROR_DEFAULT_MSG from '../constants/errors';
import LmsApiService from '../services/LmsApiService';
import { sortAlphaAsc, formatDateForDisplay } from './utils';
import {
formatMaxAssignmentGrade, formatMinAssignmentGrade, formatMaxCourseGrade, formatMinCourseGrade,
} from '../selectors/grades';
import { getFilters } from '../selectors/filters';
import { StrictDict } from 'utils';
import { createActionFactory } from './utils';
const defaultAssignmentFilter = 'All';
export const dataKey = 'grades';
const createAction = createActionFactory(dataKey);
const startedCsvUpload = () => ({ type: START_UPLOAD });
const finishedCsvUpload = () => ({ type: UPLOAD_COMPLETE });
const csvUploadError = data => ({ type: UPLOAD_ERR, data });
const gotBulkHistory = data => ({ type: GOT_BULK_HISTORY, data });
const bulkHistoryError = () => ({ type: BULK_HISTORY_ERR });
const banner = {
open: createAction('banner/open'),
close: createAction('banner/close'),
};
const startedFetchingGrades = () => ({ type: STARTED_FETCHING_GRADES });
const finishedFetchingGrades = () => ({ type: FINISHED_FETCHING_GRADES });
const errorFetchingGrades = () => ({ type: ERROR_FETCHING_GRADES });
const errorFetchingGradeOverrideHistory = errorMessage => ({
type: ERROR_FETCHING_GRADE_OVERRIDE_HISTORY,
errorMessage,
});
const bulkHistory = {
received: createAction('bulkHistory/received'),
error: createAction('bulkHistory/error'),
};
const gotGrades = ({
grades, cohort, track, assignmentType, headings, prev,
next, courseId, totalUsersCount, filteredUsersCount,
}) => ({
type: GOT_GRADES,
grades,
cohort,
track,
assignmentType,
headings,
prev,
next,
courseId,
totalUsersCount,
filteredUsersCount,
});
const csvUpload = {
started: createAction('csvUpload/started'),
finished: createAction('csvUpload/finished'),
error: createAction('csvUpload/error'),
};
const gotGradeOverrideHistory = ({
overrideHistory, currentEarnedAllOverride, currentPossibleAllOverride,
currentEarnedGradedOverride, currentPossibleGradedOverride,
originalGradeEarnedAll, originalGradePossibleAll, originalGradeEarnedGraded,
originalGradePossibleGraded,
}) => ({
type: GOT_GRADE_OVERRIDE_HISTORY,
overrideHistory,
currentEarnedAllOverride,
currentPossibleAllOverride,
currentEarnedGradedOverride,
currentPossibleGradedOverride,
originalGradeEarnedAll,
originalGradePossibleAll,
originalGradeEarnedGraded,
originalGradePossibleGraded,
});
const doneViewingAssignment = createAction('doneViewingAssignment');
const gradeUpdateRequest = () => ({ type: GRADE_UPDATE_REQUEST });
const gradeUpdateSuccess = (courseId, responseData) => ({
type: GRADE_UPDATE_SUCCESS,
courseId,
payload: { responseData },
});
const gradeUpdateFailure = (courseId, error) => ({
type: GRADE_UPDATE_FAILURE,
courseId,
payload: { error },
});
const uploadOverrideSuccess = courseId => ({
type: UPLOAD_OVERRIDE,
courseId,
});
// This action for google analytics only. Doesn't change redux state.
const downloadBulkGradesReport = courseId => ({
type: BULK_GRADE_REPORT_DOWNLOADED,
courseId,
});
// This action for google analytics only. Doesn't change redux state.
const downloadInterventionReport = courseId => ({
type: INTERVENTION_REPORT_DOWNLOADED,
courseId,
});
const uploadOverrideFailure = (courseId, error) => ({
type: UPLOAD_OVERRIDE_ERROR,
courseId,
payload: { error },
});
// for segment tracking
const downloadReport = {
bulkGrades: createAction('downloadReport/bulkGrades'),
intervention: createAction('downloadReport/intervention'),
};
const toggleGradeFormat = formatType => ({ type: TOGGLE_GRADE_FORMAT, formatType });
const filterAssignmentType = filterType => (
dispatch => dispatch({
type: FILTER_BY_ASSIGNMENT_TYPE,
filterType,
})
);
const openBanner = () => ({ type: OPEN_BANNER });
const closeBanner = () => ({ type: CLOSE_BANNER });
const fetchGrades = (
courseId,
cohort,
track,
assignmentType,
options = {},
) => (
(dispatch, getState) => {
dispatch(startedFetchingGrades());
const {
assignment,
assignmentGradeMax: assignmentMax,
assignmentGradeMin: assignmentMin,
courseGradeMin,
courseGradeMax,
} = getFilters(getState());
const { id: assignmentId } = assignment || {};
const assignmentGradeMax = formatMaxAssignmentGrade(assignmentMax, { assignmentId });
const assignmentGradeMin = formatMinAssignmentGrade(assignmentMin, { assignmentId });
const courseGradeMinFormatted = formatMinCourseGrade(courseGradeMin);
const courseGradeMaxFormatted = formatMaxCourseGrade(courseGradeMax);
return LmsApiService.fetchGradebookData(
courseId,
options.searchText || null,
cohort,
track,
{
assignment: assignmentId,
assignmentGradeMax,
assignmentGradeMin,
courseGradeMin: courseGradeMinFormatted,
courseGradeMax: courseGradeMaxFormatted,
const fetching = {
started: createAction('fetching/started'),
finished: createAction('fetching/finished'),
error: createAction('fetching/error'),
// for segment tracking
received: createAction(
'fetching/received',
(data) => ({
payload: {
grades: data.grades,
cohort: data.cohort,
track: data.track,
assignmentType: data.assignmentType,
headings: data.headings,
prev: data.prev,
next: data.next,
courseId: data.courseId,
totalUsersCount: data.totalUsersCount,
filteredUsersCount: data.filteredUsersCount,
},
)
.then(response => response.data)
.then((data) => {
dispatch(gotGrades({
grades: data.results.sort(sortAlphaAsc),
cohort,
track,
assignmentType,
prev: data.previous,
next: data.next,
courseId,
totalUsersCount: data.total_users_count,
filteredUsersCount: data.filtered_users_count,
}));
dispatch(finishedFetchingGrades());
if (options.showSuccess) {
dispatch(openBanner());
}
})
.catch(() => {
dispatch(errorFetchingGrades());
});
}
);
const formatGradeOverrideForDisplay = historyArray => historyArray.map(item => ({
date: formatDateForDisplay(new Date(item.history_date)),
grader: item.history_user,
reason: item.override_reason,
adjustedGrade: item.earned_graded_override,
}));
const doneViewingAssignment = () => dispatch => dispatch({
type: DONE_VIEWING_ASSIGNMENT,
});
const fetchGradeOverrideHistory = (subsectionId, userId) => (
dispatch => LmsApiService.fetchGradeOverrideHistory(subsectionId, userId)
.then(response => response.data)
.then((data) => {
if (data.success) {
dispatch(gotGradeOverrideHistory({
overrideHistory: formatGradeOverrideForDisplay(data.history),
currentEarnedAllOverride: data.override ? data.override.earned_all_override : null,
currentPossibleAllOverride: data.override ? data.override.possible_all_override : null,
currentEarnedGradedOverride: data.override ? data.override.earned_graded_override : null,
currentPossibleGradedOverride: data.override
? data.override.possible_graded_override : null,
originalGradeEarnedAll: data.original_grade ? data.original_grade.earned_all : null,
originalGradePossibleAll: data.original_grade ? data.original_grade.possible_all : null,
originalGradeEarnedGraded: data.original_grade
? data.original_grade.earned_graded : null,
originalGradePossibleGraded: data.original_grade
? data.original_grade.possible_graded : null,
}));
} else {
dispatch(errorFetchingGradeOverrideHistory(data.error_message));
}
})
.catch(() => {
dispatch(errorFetchingGradeOverrideHistory(GRADE_OVERRIDE_HISTORY_ERROR_DEFAULT_MSG));
})
);
const fetchMatchingUserGrades = (
courseId,
searchText,
cohort,
track,
assignmentType,
showSuccess,
options = {},
) => {
const newOptions = { ...options, searchText, showSuccess };
return fetchGrades(courseId, cohort, track, assignmentType, newOptions);
}),
),
};
const fetchPrevNextGrades = (endpoint, courseId, cohort, track, assignmentType) => (
(dispatch) => {
dispatch(startedFetchingGrades());
return getAuthenticatedHttpClient().get(endpoint)
.then(response => response.data)
.then((data) => {
dispatch(gotGrades({
grades: data.results.sort(sortAlphaAsc),
cohort,
track,
assignmentType,
prev: data.previous,
next: data.next,
courseId,
totalUsersCount: data.total_users_count,
filteredUsersCount: data.filtered_users_count,
}));
dispatch(finishedFetchingGrades());
})
.catch(() => {
dispatch(errorFetchingGrades());
});
}
);
const updateGrades = (courseId, updateData, searchText, cohort, track) => (
(dispatch) => {
dispatch(gradeUpdateRequest());
return LmsApiService.updateGradebookData(courseId, updateData)
.then(response => response.data)
.then((data) => {
dispatch(gradeUpdateSuccess(courseId, data));
dispatch(fetchMatchingUserGrades(
courseId,
searchText,
cohort,
track,
defaultAssignmentFilter,
true,
{ searchText },
));
})
.catch((error) => {
dispatch(gradeUpdateFailure(courseId, error));
});
}
);
const submitFileUploadFormData = (courseId, formData) => (
(dispatch) => {
dispatch(startedCsvUpload());
return LmsApiService.uploadGradeCsv(courseId, formData).then(() => {
dispatch(finishedCsvUpload());
dispatch(uploadOverrideSuccess(courseId));
}).catch((err) => {
dispatch(uploadOverrideFailure(courseId, err));
if (err.status === 200 && err.data.error_messages.length) {
const { error_messages: errorMessages, saved, total } = err.data;
return dispatch(csvUploadError({ errorMessages, saved, total }));
}
return dispatch(csvUploadError({ errorMessages: ['Unknown error.'] }));
});
}
);
const fetchBulkUpgradeHistory = courseId => (
// todo add loading effect
dispatch => LmsApiService.fetchGradeBulkOperationHistory(courseId).then(
(response) => { dispatch(gotBulkHistory(response)); },
).catch(() => dispatch(bulkHistoryError()))
);
const updateGradesIfAssignmentGradeFiltersSet = (
courseId,
cohort,
track,
assignmentType,
) => (dispatch, getState) => {
const { filters } = getState();
const hasAssignmentGradeFiltersSet = filters.assignmentGradeMax || filters.assignmentGradeMin;
if (hasAssignmentGradeFiltersSet) {
dispatch(fetchGrades(
courseId,
cohort,
track,
assignmentType,
));
}
const overrideHistory = {
error: createAction('overrideHistory/errorFetching'),
received: createAction(
'overrideHistory/received',
(data) => ({
payload: {
overrideHistory: data.overrideHistory,
currentEarnedAllOverride: data.currentEarnedAllOverride,
currentPossibleAllOverride: data.currentPossibleAllOverride,
currentEarnedGradedOverride: data.currentEarnedGradedOverride,
currentPossibleGradedOverride: data.currentPossibleGradedOverride,
originalGradeEarnedAll: data.originalGradeEarnedAll,
originalGradePossibleAll: data.originalGradePossibleAll,
originalGradeEarnedGraded: data.originalGradeEarnedGraded,
originalGradePossibleGraded: data.originalGradePossibleGraded,
},
}),
),
};
export {
startedFetchingGrades,
finishedFetchingGrades,
errorFetchingGrades,
gotGrades,
fetchGrades,
fetchMatchingUserGrades,
fetchPrevNextGrades,
gradeUpdateRequest,
gradeUpdateSuccess,
gradeUpdateFailure,
updateGrades,
toggleGradeFormat,
filterAssignmentType,
closeBanner,
submitFileUploadFormData,
fetchBulkUpgradeHistory,
const toggleGradeFormat = createAction('toggleGradeFormat');
const update = {
request: createAction('update/request'),
success: createAction('update/success'),
failure: createAction('update/failure', (courseId, error) => ({
payload: { courseId, error },
})),
};
const uploadOverride = {
success: createAction('uploadOverride/success'),
failure: createAction('uploadOverride/failure', (courseId, error) => ({
payload: { courseId, error },
})),
};
export default StrictDict({
banner: StrictDict(banner),
bulkHistory: StrictDict(bulkHistory),
csvUpload: StrictDict(csvUpload),
doneViewingAssignment,
fetchGradeOverrideHistory,
updateGradesIfAssignmentGradeFiltersSet,
downloadBulkGradesReport,
downloadInterventionReport,
};
downloadReport: StrictDict(downloadReport),
fetching: StrictDict(fetching),
overrideHistory: StrictDict(overrideHistory),
toggleGradeFormat,
update: StrictDict(update),
uploadOverride: StrictDict(uploadOverride),
});

View File

@@ -1,274 +1,115 @@
import axios from 'axios';
import configureMockStore from 'redux-mock-store';
import MockAdapter from 'axios-mock-adapter';
import thunk from 'redux-thunk';
import actions, { dataKey } from './grades';
import { testAction, testActionTypes } from './testUtils';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { configuration } from '../../config';
import { fetchGrades, fetchGradeOverrideHistory } from './grades';
import {
STARTED_FETCHING_GRADES,
FINISHED_FETCHING_GRADES,
ERROR_FETCHING_GRADES,
GOT_GRADES,
GOT_GRADE_OVERRIDE_HISTORY,
ERROR_FETCHING_GRADE_OVERRIDE_HISTORY,
} from '../constants/actionTypes/grades';
import GRADE_OVERRIDE_HISTORY_ERROR_DEFAULT_MSG from '../constants/errors';
import { sortAlphaAsc } from './utils';
import LmsApiService from '../services/LmsApiService';
const mockStore = configureMockStore([thunk]);
jest.mock('@edx/frontend-platform/auth');
const axiosMock = new MockAdapter(axios);
getAuthenticatedHttpClient.mockReturnValue(axios);
axios.isAccessTokenExpired = jest.fn();
axios.isAccessTokenExpired.mockReturnValue(false);
describe('actions', () => {
afterEach(() => {
axiosMock.reset();
describe('actions.grades', () => {
describe('action types', () => {
const actionTypes = [
actions.banner.open,
actions.banner.close,
actions.bulkHistory.received,
actions.bulkHistory.error,
actions.csvUpload.started,
actions.csvUpload.finished,
actions.csvUpload.error,
actions.doneViewingAssignment,
actions.downloadReport.bulkGrades,
actions.downloadReport.intervention,
actions.fetching.started,
actions.fetching.finished,
actions.fetching.error,
actions.fetching.received,
actions.overrideHistory.error,
actions.overrideHistory.received,
actions.toggleGradeFormat,
actions.update.request,
actions.update.success,
actions.update.failure,
actions.uploadOverride.success,
actions.uploadOverride.failure,
].map(action => action.toString());
testActionTypes(actionTypes, dataKey);
});
describe('fetchGrades', () => {
const courseId = 'course-v1:edX+DemoX+Demo_Course';
const expectedCohort = 1;
const expectedTrack = 'verified';
const expectedAssignmentType = 'Exam';
const fetchGradesURL = `${configuration.LMS_BASE_URL}/api/grades/v1/gradebook/${courseId}/?page_size=25&cohort_id=${expectedCohort}&enrollment_mode=${expectedTrack}`;
const responseData = {
next: `${fetchGradesURL}&cursor=2344fda`,
previous: null,
results: [
{
course_id: courseId,
email: 'user1@example.com',
username: 'user1',
user_id: 1,
percent: 0.5,
letter_grade: null,
section_breakdown: [
{
subsection_name: 'Demo Course Overview',
score_earned: 0,
score_possible: 0,
percent: 0,
displayed_value: '0.00',
grade_description: '(0.00/0.00)',
},
{
subsection_name: 'Example Week 1: Getting Started',
score_earned: 1,
score_possible: 1,
percent: 1,
displayed_value: '1.00',
grade_description: '(0.00/0.00)',
},
],
},
{
course_id: courseId,
email: 'user22@example.com',
username: 'user22',
user_id: 22,
percent: 0,
letter_grade: null,
section_breakdown: [
{
subsection_name: 'Demo Course Overview',
score_earned: 0,
score_possible: 0,
percent: 0,
displayed_value: '0.00',
grade_description: '(0.00/0.00)',
},
{
subsection_name: 'Example Week 1: Getting Started',
score_earned: 1,
score_possible: 1,
percent: 0,
displayed_value: '0.00',
grade_description: '(0.00/0.00)',
},
],
}],
};
it('dispatches success action after fetching grades', () => {
const expectedActions = [
{ type: STARTED_FETCHING_GRADES },
{
type: GOT_GRADES,
grades: responseData.results.sort(sortAlphaAsc),
cohort: expectedCohort,
track: expectedTrack,
assignmentType: expectedAssignmentType,
prev: responseData.previous,
next: responseData.next,
courseId,
},
{ type: FINISHED_FETCHING_GRADES },
];
const store = mockStore();
axiosMock.onGet(fetchGradesURL)
.replyOnce(200, JSON.stringify(responseData));
return store.dispatch(fetchGrades(
courseId,
expectedCohort,
expectedTrack,
expectedAssignmentType,
false,
)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
describe('actions provided', () => {
describe('banner', () => {
test('open action', () => testAction(actions.banner.open));
test('close action', () => testAction(actions.banner.close));
});
it('dispatches failure action after fetching grades', () => {
const expectedActions = [
{ type: STARTED_FETCHING_GRADES },
{ type: ERROR_FETCHING_GRADES },
];
const store = mockStore();
axiosMock.onGet(fetchGradesURL)
.replyOnce(500, JSON.stringify({}));
return store.dispatch(fetchGrades(
courseId,
expectedCohort,
expectedTrack,
expectedAssignmentType,
false,
)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
describe('bulkHistory', () => {
test('received action', () => testAction(actions.bulkHistory.received));
test('error action', () => testAction(actions.bulkHistory.error));
});
it('dispatches success action on empty response after fetching grades', () => {
const emptyResponseData = {
next: responseData.next,
previous: responseData.previous,
results: [],
};
const expectedActions = [
{ type: STARTED_FETCHING_GRADES },
{
type: GOT_GRADES,
grades: [],
cohort: expectedCohort,
track: expectedTrack,
assignmentType: expectedAssignmentType,
prev: responseData.previous,
next: responseData.next,
courseId,
},
{ type: FINISHED_FETCHING_GRADES },
];
const store = mockStore();
axiosMock.onGet(fetchGradesURL)
.replyOnce(200, JSON.stringify(emptyResponseData));
return store.dispatch(fetchGrades(
courseId,
expectedCohort,
expectedTrack,
expectedAssignmentType,
false,
)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
describe('csvUpload', () => {
test('started action', () => testAction(actions.csvUpload.started));
test('finished action', () => testAction(actions.csvUpload.finished));
test('error action', () => testAction(actions.csvUpload.error));
});
});
describe('fetchGradeOverridHistory', () => {
const subsectionId = 'subsectionId-11111';
const userId = 'user-id-11111';
const fetchOverridesURL = `${LmsApiService.baseUrl}/api/grades/v1/subsection/${subsectionId}/?user_id=${userId}&history_record_limit=5`;
const originalGrade = {
earned_all: 1.0,
possible_all: 12.0,
earned_graded: 3.0,
possible_graded: 8.0,
};
const override = {
earned_all_override: 13.0,
possible_all_override: 13.0,
earned_graded_override: 10.0,
possible_graded_override: 10.0,
};
it('dispatches success action after successfully getting override info', () => {
const responseData = {
success: true,
original_grade: originalGrade,
history: [],
override,
};
axiosMock.onGet(fetchOverridesURL)
.replyOnce(200, JSON.stringify(responseData));
const expectedActions = [
{
type: GOT_GRADE_OVERRIDE_HISTORY,
overrideHistory: [],
currentEarnedAllOverride: override.earned_all_override,
currentPossibleAllOverride: override.possible_all_override,
currentEarnedGradedOverride: override.earned_graded_override,
currentPossibleGradedOverride: override.possible_graded_override,
originalGradeEarnedAll: originalGrade.earned_all,
originalGradePossibleAll: originalGrade.possible_all,
originalGradeEarnedGraded: originalGrade.earned_graded,
originalGradePossibleGraded: originalGrade.possible_graded,
},
];
const store = mockStore();
return store.dispatch(fetchGradeOverrideHistory(subsectionId, userId)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
test('doneViewingAssignment', () => testAction(actions.doneViewingAssignment));
describe('downloadReport', () => {
test('bulkGrades action', () => testAction(actions.downloadReport.bulkGrades));
test('intervention action', () => testAction(actions.downloadReport.intervention));
});
describe('dispatches failure action with expected message', () => {
test('on failure response', () => {
const responseData = {
success: false,
error_message: 'There was an error!!!!!!!!!',
};
axiosMock.onGet(fetchOverridesURL).replyOnce(200, JSON.stringify(responseData));
const expectedActions = [{
type: ERROR_FETCHING_GRADE_OVERRIDE_HISTORY,
errorMessage: responseData.error_message,
}];
const store = mockStore();
return store.dispatch(fetchGradeOverrideHistory(subsectionId, userId)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
describe('fetching', () => {
test('started action', () => testAction(actions.fetching.started));
test('finished action', () => testAction(actions.fetching.finished));
test('error action', () => testAction(actions.fetching.error));
describe('received', () => {
it('loads grades data from data', () => {
const data = {
grades: ['some', 'grades'],
cohort: 2,
track: 'summoners',
assignmentType: 'potion',
headings: ['H', 'E', 'a', 'd', 'Ing', 'sssss'],
prev: 'prEEEV',
next: 'NEEEExt',
courseId: 'fake ID',
totalUsersCount: 2,
filteredUsersCount: 999,
};
testAction(actions.fetching.received, { ...data, other: 'fields' }, data);
});
});
test('on 500 error', () => {
axiosMock.onGet(fetchOverridesURL).replyOnce(500);
const expectedActions = [{
type: ERROR_FETCHING_GRADE_OVERRIDE_HISTORY,
errorMessage: GRADE_OVERRIDE_HISTORY_ERROR_DEFAULT_MSG,
}];
const store = mockStore();
return store.dispatch(fetchGradeOverrideHistory(subsectionId, userId)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
describe('overrideHistory', () => {
test('error action', () => testAction(actions.overrideHistory.error));
describe('received', () => {
it('loads override history from data', () => {
const data = {
overrideHistory: 'some History',
currentEarnedAllOverride: 123,
currentPossibleAllOverride: 243,
currentEarnedGradedOverride: 1236,
currentPossibleGradedOverride: 52,
originalGradeEarnedAll: 323,
originalGradePossibleAll: 6223,
originalGradeEarnedGraded: 1232,
originalGradePossibleGraded: 512,
};
testAction(actions.overrideHistory.received, { ...data, other: 'fields' }, data);
});
});
});
test('toggleGradeFormat', () => testAction(actions.toggleGradeFormat));
describe('update', () => {
const courseId = 'fake ID';
const error = 'Try Again??';
test('request action', () => testAction(actions.update.request));
test('success action', () => testAction(actions.update.success));
test('failure action', () => testAction(
actions.update.failure,
[courseId, error],
{ courseId, error },
));
});
describe('uploadOverride', () => {
const courseId = 'fake ID';
const error = 'Try Again??';
test('success action', () => testAction(actions.uploadOverride.success));
test('failure action', () => testAction(
actions.uploadOverride.failure,
[courseId, error],
{ courseId, error },
));
});
});
});

19
src/data/actions/index.js Normal file
View File

@@ -0,0 +1,19 @@
import { StrictDict } from 'utils';
import assignmentTypes from './assignmentTypes';
import cohorts from './cohorts';
import config from './config';
import filters from './filters';
import grades from './grades';
import roles from './roles';
import tracks from './tracks';
export default StrictDict({
assignmentTypes,
cohorts,
config,
filters,
grades,
roles,
tracks,
});

View File

@@ -1,44 +1,14 @@
import {
GOT_ROLES,
ERROR_FETCHING_ROLES,
} from '../constants/actionTypes/roles';
import { fetchGrades } from './grades';
import { fetchTracks } from './tracks';
import { fetchCohorts } from './cohorts';
import { fetchAssignmentTypes } from './assignmentTypes';
import { getFilters } from '../selectors/filters';
import LmsApiService from '../services/LmsApiService';
import { StrictDict } from 'utils';
import { createActionFactory } from './utils';
const allowedRoles = ['staff', 'instructor', 'support'];
export const dataKey = 'roles';
const createAction = createActionFactory(dataKey);
const gotRoles = (canUserViewGradebook, courseId) => ({
type: GOT_ROLES,
canUserViewGradebook,
courseId,
});
const errorFetchingRoles = () => ({ type: ERROR_FETCHING_ROLES });
const getRoles = courseId => (
(dispatch, getState) => LmsApiService.fetchUserRoles(courseId)
.then(response => response.data)
.then((response) => {
const canUserViewGradebook = response.is_staff
|| (response.roles.some(role => (role.course_id === courseId)
&& allowedRoles.includes(role.role)));
dispatch(gotRoles(canUserViewGradebook, courseId));
const { cohort, track, assignmentType } = getFilters(getState());
if (canUserViewGradebook) {
dispatch(fetchGrades(courseId, cohort, track, assignmentType));
dispatch(fetchTracks(courseId));
dispatch(fetchCohorts(courseId));
dispatch(fetchAssignmentTypes(courseId));
}
})
.catch(() => {
dispatch(errorFetchingRoles());
}));
export {
getRoles,
errorFetchingRoles,
const fetching = {
error: createAction('fetching/error'),
received: createAction('fetching/received'),
};
export default StrictDict({
fetching: StrictDict(fetching),
});

View File

@@ -1,166 +1,18 @@
import axios from 'axios';
import configureMockStore from 'redux-mock-store';
import MockAdapter from 'axios-mock-adapter';
import thunk from 'redux-thunk';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import actions, { dataKey } from './roles';
import { testAction, testActionTypes } from './testUtils';
import { configuration } from '../../config';
import { getRoles } from './roles';
import {
GOT_ROLES,
ERROR_FETCHING_ROLES,
} from '../constants/actionTypes/roles';
import { STARTED_FETCHING_GRADES } from '../constants/actionTypes/grades';
import { STARTED_FETCHING_TRACKS } from '../constants/actionTypes/tracks';
import { STARTED_FETCHING_COHORTS } from '../constants/actionTypes/cohorts';
import { STARTED_FETCHING_ASSIGNMENT_TYPES } from '../constants/actionTypes/assignmentTypes';
const mockStore = configureMockStore([thunk]);
jest.mock('@edx/frontend-platform/auth');
const axiosMock = new MockAdapter(axios);
getAuthenticatedHttpClient.mockReturnValue(axios);
axios.isAccessTokenExpired = jest.fn();
axios.isAccessTokenExpired.mockReturnValue(false);
const course1Id = 'course-v1:edX+DemoX+Demo_Course';
const course2Id = 'course-v1:edX+DemoX+Demo_Course_2';
const rolesUrl = `${configuration.LMS_BASE_URL}/api/enrollment/v1/roles/?course_id=${encodeURIComponent(course1Id)}`;
function makeRoleListObj(roles, isGlobalStaff) {
return {
roles,
is_staff: isGlobalStaff,
};
}
function makeRoleObj(courseId, role) {
return {
course_id: courseId,
role,
};
}
const course1StaffRole = makeRoleObj(course1Id, 'staff');
const course1DummyRole = makeRoleObj(course1Id, 'dummy');
const course2StaffRole = makeRoleObj(course2Id, 'staff');
const course2DummyRole = makeRoleObj(course2Id, 'dummy');
const urlParams = { cohort: null, track: null };
describe('actions', () => {
afterEach(() => {
axiosMock.reset();
describe('actions.roles', () => {
describe('action types', () => {
const actionTypes = [
actions.fetching.error,
actions.fetching.received,
].map(action => action.toString());
testActionTypes(actionTypes, dataKey);
});
describe('getRoles', () => {
it('dispatches got_roles action and subsequent actions after fetching role that allows gradebook', () => {
const expectedActions = [
{ type: GOT_ROLES, canUserViewGradebook: true, courseId: course1Id },
{ type: STARTED_FETCHING_GRADES },
{ type: STARTED_FETCHING_TRACKS },
{ type: STARTED_FETCHING_COHORTS },
{ type: STARTED_FETCHING_ASSIGNMENT_TYPES },
];
const store = mockStore();
axiosMock.onGet(rolesUrl)
.replyOnce(
200,
JSON.stringify(makeRoleListObj([course1StaffRole, course2DummyRole], false)),
);
return store.dispatch(getRoles(course1Id, urlParams)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it('dispatches got_roles action and other actions after fetching irrelevent roles but user is global staff', () => {
const expectedActions = [
{ type: GOT_ROLES, canUserViewGradebook: true, courseId: course1Id },
{ type: STARTED_FETCHING_GRADES },
{ type: STARTED_FETCHING_TRACKS },
{ type: STARTED_FETCHING_COHORTS },
{ type: STARTED_FETCHING_ASSIGNMENT_TYPES },
];
const store = mockStore();
axiosMock.onGet(rolesUrl)
.replyOnce(
200,
JSON.stringify(makeRoleListObj([course1DummyRole, course2DummyRole], true)),
);
return store.dispatch(getRoles(course1Id, urlParams)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it('dispatches got_roles action and no other actions after fetching role that disallows gradebook', () => {
const expectedActions = [
{
type: GOT_ROLES, canUserViewGradebook: false, courseId: course1Id,
},
];
const store = mockStore();
axiosMock.onGet(rolesUrl)
.replyOnce(
200,
JSON.stringify(makeRoleListObj([course1DummyRole, course2StaffRole], false)),
);
return store.dispatch(getRoles(course1Id, urlParams)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it('dispatches got_roles action and no other actions after fetching empty roles', () => {
const expectedActions = [
{ type: GOT_ROLES, canUserViewGradebook: false, courseId: course1Id },
];
const store = mockStore();
axiosMock.onGet(rolesUrl)
.replyOnce(
200,
JSON.stringify(makeRoleListObj([], false)),
);
return store.dispatch(getRoles(course1Id, urlParams)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it('dispatches got_roles action and other actions after fetching empty roles but user is global staff', () => {
const expectedActions = [
{ type: GOT_ROLES, canUserViewGradebook: true, courseId: course1Id },
{ type: STARTED_FETCHING_GRADES },
{ type: STARTED_FETCHING_TRACKS },
{ type: STARTED_FETCHING_COHORTS },
{ type: STARTED_FETCHING_ASSIGNMENT_TYPES },
];
const store = mockStore();
axiosMock.onGet(rolesUrl)
.replyOnce(
200,
JSON.stringify(makeRoleListObj([], true)),
);
return store.dispatch(getRoles(course1Id, urlParams)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it('dispatches error action after getting an error when trying to get roles', () => {
const expectedActions = [
{ type: ERROR_FETCHING_ROLES },
];
const store = mockStore();
axiosMock.onGet(rolesUrl).replyOnce(400);
return store.dispatch(getRoles(course1Id, urlParams)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
describe('actions provided', () => {
describe('fecthing actions', () => {
test('error action', () => testAction(actions.fetching.error));
test('received action', () => testAction(actions.fetching.received));
});
});
});

View File

@@ -0,0 +1,48 @@
/**
* testActionTypes(actionTypes, dataKey)
* Takes a list of actionTypes and a module dataKey, and verifies that
* * all actionTypes are unique
* * all actionTypes begin with the dataKey
* @param {string[]} actionTypes - list of action types
* @param {string} dataKey - module data key
*/
export const testActionTypes = (actionTypes, dataKey) => {
test('all types are unique', () => {
expect(actionTypes.length).toEqual((new Set(actionTypes)).size);
});
test('all types begin with the module dataKey', () => {
actionTypes.forEach(type => {
expect(type.startsWith(dataKey)).toEqual(true);
});
});
};
/**
* testAction(action, args, expectedPayload)
* Multi-purpose action creator test function.
* If args/expectedPayload are passed, verifies that it produces the expected output when called
* with the given args.
* If none are passed, (for action creators with basic definition) it tests against a default
* test payload.
* @param {object} action - action creator object/method
* @param {[object]} args - optional payload argument
* @param {[object]} expectedPayload - optional expected payload.
*/
export const testAction = (action, args, expectedPayload) => {
const type = action.toString();
if (args) {
if (Array.isArray(args)) {
expect(action(...args)).toEqual({ type, payload: expectedPayload });
} else {
expect(action(args)).toEqual({ type, payload: expectedPayload });
}
} else {
const payload = { test: 'PAYload' };
expect(action(payload)).toEqual({ type, payload });
}
};
export default {
testAction,
testActionTypes,
};

View File

@@ -1,36 +1,15 @@
import {
STARTED_FETCHING_TRACKS,
GOT_TRACKS,
ERROR_FETCHING_TRACKS,
} from '../constants/actionTypes/tracks';
import { hasMastersTrack } from '../selectors/tracks';
import { fetchBulkUpgradeHistory } from './grades';
import LmsApiService from '../services/LmsApiService';
import { StrictDict } from 'utils';
import { createActionFactory } from './utils';
const startedFetchingTracks = () => ({ type: STARTED_FETCHING_TRACKS });
const errorFetchingTracks = () => ({ type: ERROR_FETCHING_TRACKS });
const gotTracks = tracks => ({ type: GOT_TRACKS, tracks });
export const dataKey = 'tracks';
const createAction = createActionFactory(dataKey);
const fetchTracks = courseId => (
(dispatch) => {
dispatch(startedFetchingTracks());
return LmsApiService.fetchTracks(courseId)
.then(response => response.data)
.then((data) => {
dispatch(gotTracks(data.course_modes));
if (hasMastersTrack(data.course_modes)) {
dispatch(fetchBulkUpgradeHistory(courseId));
}
})
.catch(() => {
dispatch(errorFetchingTracks());
});
}
);
export {
fetchTracks,
startedFetchingTracks,
gotTracks,
errorFetchingTracks,
const fetching = {
started: createAction('fetching/started'),
error: createAction('fetching/error'),
received: createAction('fetching/received'),
};
export default StrictDict({
fetching: StrictDict(fetching),
});

View File

@@ -1,87 +1,20 @@
import axios from 'axios';
import configureMockStore from 'redux-mock-store';
import MockAdapter from 'axios-mock-adapter';
import thunk from 'redux-thunk';
import actions, { dataKey } from './tracks';
import { testAction, testActionTypes } from './testUtils';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { configuration } from '../../config';
import { fetchTracks } from './tracks';
import {
STARTED_FETCHING_TRACKS,
GOT_TRACKS,
ERROR_FETCHING_TRACKS,
} from '../constants/actionTypes/tracks';
const mockStore = configureMockStore([thunk]);
jest.mock('@edx/frontend-platform/auth');
const axiosMock = new MockAdapter(axios);
getAuthenticatedHttpClient.mockReturnValue(axios);
axios.isAccessTokenExpired = jest.fn();
axios.isAccessTokenExpired.mockReturnValue(false);
describe('actions', () => {
afterEach(() => {
axiosMock.reset();
describe('actions.tracks', () => {
describe('action types', () => {
const actionTypes = [
actions.fetching.error,
actions.fetching.started,
actions.fetching.received,
].map(action => action.toString());
testActionTypes(actionTypes, dataKey);
});
describe('fetchTracks', () => {
const courseId = 'course-v1:edX+DemoX+Demo_Course';
const trackUrl = `${configuration.LMS_BASE_URL}/api/enrollment/v1/course/${courseId}?include_expired=1`;
it('dispatches success action after fetching tracks', () => {
const responseData = {
course_modes: [
{
slug: 'audit',
name: 'Audit',
min_price: 0,
suggested_prices: '',
currency: 'usd',
expiration_datetime: null,
description: null,
sku: '68EFFFF',
bulk_sku: null,
},
{
slug: 'verified',
name: 'Verified Certificate',
min_price: 100,
suggested_prices: '',
currency: 'usd',
expiration_datetime: '2021-05-04T18:08:12.644361Z',
description: null,
sku: '8CF08E5',
bulk_sku: 'A5B6DBE',
}],
};
const expectedActions = [
{ type: STARTED_FETCHING_TRACKS },
{ type: GOT_TRACKS, tracks: responseData.course_modes },
];
const store = mockStore();
axiosMock.onGet(trackUrl)
.replyOnce(200, JSON.stringify(responseData));
return store.dispatch(fetchTracks(courseId)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it('dispatches failure action after fetching tracks', () => {
const expectedActions = [
{ type: STARTED_FETCHING_TRACKS },
{ type: ERROR_FETCHING_TRACKS },
];
const store = mockStore();
axiosMock.onGet(trackUrl)
.replyOnce(500, JSON.stringify({}));
return store.dispatch(fetchTracks(courseId)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
describe('actions provided', () => {
describe('fecthing actions', () => {
test('error action', () => testAction(actions.fetching.error));
test('started action', () => testAction(actions.fetching.started));
test('received action', () => testAction(actions.fetching.received));
});
});
});

View File

@@ -1,3 +1,5 @@
import { createAction } from '@reduxjs/toolkit';
const formatDateForDisplay = (inputDate) => {
const options = {
year: 'numeric',
@@ -26,4 +28,12 @@ const sortAlphaAsc = (gradeRowA, gradeRowB) => {
return 0;
};
export { sortAlphaAsc, formatDateForDisplay };
const createActionFactory = (dataKey) => (actionKey, ...args) => (
createAction(`${dataKey}/${actionKey}`, ...args)
);
export {
createActionFactory,
sortAlphaAsc,
formatDateForDisplay,
};

View File

@@ -1,11 +0,0 @@
const STARTED_FETCHING_ASSIGNMENT_TYPES = 'STARTED_FETCHING_ASSIGNMENT_TYPES';
const GOT_ASSIGNMENT_TYPES = 'GOT_ASSIGNMENT_TYPES';
const ERROR_FETCHING_ASSIGNMENT_TYPES = 'ERROR_FETCHING_ASSIGNMENT_TYPES';
const GOT_ARE_GRADES_FROZEN = 'GOT_ARE_GRADES_FROZEN';
export {
STARTED_FETCHING_ASSIGNMENT_TYPES,
GOT_ASSIGNMENT_TYPES,
ERROR_FETCHING_ASSIGNMENT_TYPES,
GOT_ARE_GRADES_FROZEN,
};

View File

@@ -1,9 +0,0 @@
const STARTED_FETCHING_COHORTS = 'STARTED_FETCHING_COHORTS';
const GOT_COHORTS = 'GOT_COHORTS';
const ERROR_FETCHING_COHORTS = 'ERROR_FETCHING_COHORTS';
export {
STARTED_FETCHING_COHORTS,
GOT_COHORTS,
ERROR_FETCHING_COHORTS,
};

View File

@@ -1,3 +0,0 @@
const GOT_BULK_MANAGEMENT_CONFIG = 'GOT_BULK_MANAGEMENT_CONFIG';
export default GOT_BULK_MANAGEMENT_CONFIG;

View File

@@ -1,9 +0,0 @@
const INITIALIZE_FILTERS = 'INITIALIZE_FILTERS';
const RESET_FILTERS = 'RESET_FILTERS';
const UPDATE_ASSIGNMENT_FILTER = 'UPDATE_ASSIGNMENT_FILTER';
const UPDATE_ASSIGNMENT_LIMITS = 'UPDATE_ASSIGNMENT_LIMITS';
const UPDATE_COURSE_GRADE_LIMITS = 'UPDATE_COURSE_GRADE_LIMITS';
export {
INITIALIZE_FILTERS, RESET_FILTERS, UPDATE_ASSIGNMENT_FILTER,
UPDATE_ASSIGNMENT_LIMITS, UPDATE_COURSE_GRADE_LIMITS,
};

View File

@@ -1,59 +0,0 @@
const STARTED_FETCHING_GRADES = 'STARTED_FETCHING_GRADES';
const FINISHED_FETCHING_GRADES = 'FINISHED_FETCHING_GRADES';
const ERROR_FETCHING_GRADES = 'ERROR_FETCHING_GRADES';
const GOT_GRADES = 'GOT_GRADES';
const DONE_VIEWING_ASSIGNMENT = 'DONE_VIEWING_ASSIGNMENT';
const GOT_GRADE_OVERRIDE_HISTORY = 'GOT_GRADE_OVERRIDE_HISTORY';
const ERROR_FETCHING_GRADE_OVERRIDE_HISTORY = 'ERROR_FETCHING_GRADE_OVERRIDE_HISTORY';
const FILTER_SELECTED = 'FILTER_SELECTED';
const GRADE_OVERRIDE = 'GRADE_OVERRIDE';
const REPORT_DOWNLOADED = 'REPORT_DOWNLOADED';
const UPLOAD_OVERRIDE = 'UPLOAD_OVERRIDE';
const UPLOAD_OVERRIDE_ERROR = 'UPLOAD_OVERRIDE_ERROR';
const GRADE_UPDATE_REQUEST = 'GRADE_UPDATE_REQUEST';
const GRADE_UPDATE_SUCCESS = 'GRADE_UPDATE_SUCCESS';
const GRADE_UPDATE_FAILURE = 'GRADE_UPDATE_FAILURE';
const TOGGLE_GRADE_FORMAT = 'TOGGLE_GRADE_FORMAT';
const FILTER_BY_ASSIGNMENT_TYPE = 'FILTER_BY_ASSIGNMENT_TYPE';
const CLOSE_BANNER = 'CLOSE_BANNER';
const OPEN_BANNER = 'OPEN_BANNER';
const START_UPLOAD = 'START_UPLOAD';
const UPLOAD_COMPLETE = 'UPLOAD_COMPLETE';
const UPLOAD_ERR = 'UPLOAD_ERR';
const GOT_BULK_HISTORY = 'GOT_BULK_HISTORY';
const BULK_HISTORY_ERR = 'BULK_HISTORY_ERR';
const BULK_GRADE_REPORT_DOWNLOADED = 'BULK_GRADE_REPORT_DOWNLOADED';
const INTERVENTION_REPORT_DOWNLOADED = 'INTERVENTION_REPORT_DOWNLOADED';
export {
STARTED_FETCHING_GRADES,
FINISHED_FETCHING_GRADES,
ERROR_FETCHING_GRADES,
GOT_GRADES,
GRADE_UPDATE_REQUEST,
GRADE_UPDATE_SUCCESS,
GRADE_UPDATE_FAILURE,
TOGGLE_GRADE_FORMAT,
FILTER_BY_ASSIGNMENT_TYPE,
OPEN_BANNER,
CLOSE_BANNER,
START_UPLOAD,
UPLOAD_COMPLETE,
UPLOAD_ERR,
GOT_BULK_HISTORY,
BULK_HISTORY_ERR,
DONE_VIEWING_ASSIGNMENT,
GOT_GRADE_OVERRIDE_HISTORY,
ERROR_FETCHING_GRADE_OVERRIDE_HISTORY,
FILTER_SELECTED,
GRADE_OVERRIDE,
REPORT_DOWNLOADED,
UPLOAD_OVERRIDE,
UPLOAD_OVERRIDE_ERROR,
BULK_GRADE_REPORT_DOWNLOADED,
INTERVENTION_REPORT_DOWNLOADED,
};

View File

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

View File

@@ -1,9 +0,0 @@
const STARTED_FETCHING_TRACKS = 'STARTED_FETCHING_TRACKS';
const GOT_TRACKS = 'GOT_TRACKS';
const ERROR_FETCHING_TRACKS = 'ERROR_FETCHING_TRACKS';
export {
STARTED_FETCHING_TRACKS,
GOT_TRACKS,
ERROR_FETCHING_TRACKS,
};

View File

@@ -7,6 +7,7 @@ const initialFilters = {
assignmentGradeMax: '100',
courseGradeMin: '0',
courseGradeMax: '100',
includeCourseRoleMembers: false,
};
export default initialFilters;

View File

@@ -0,0 +1,5 @@
const EMAIL_HEADING = 'Email';
const TOTAL_COURSE_GRADE_HEADING = 'Total Grade (%)';
const USERNAME_HEADING = 'Username';
export { EMAIL_HEADING, TOTAL_COURSE_GRADE_HEADING, USERNAME_HEADING };

View File

@@ -1,9 +1,4 @@
import {
STARTED_FETCHING_ASSIGNMENT_TYPES,
ERROR_FETCHING_ASSIGNMENT_TYPES,
GOT_ASSIGNMENT_TYPES,
GOT_ARE_GRADES_FROZEN,
} from '../constants/actionTypes/assignmentTypes';
import actions from '../actions/assignmentTypes';
const initialState = {
results: [],
@@ -11,30 +6,30 @@ const initialState = {
errorFetching: false,
};
const assignmentTypes = (state = initialState, action) => {
switch (action.type) {
case GOT_ASSIGNMENT_TYPES:
return {
...state,
results: action.assignmentTypes,
errorFetching: false,
finishedFetching: true,
};
case STARTED_FETCHING_ASSIGNMENT_TYPES:
const assignmentTypes = (state = initialState, { type, payload }) => {
switch (type) {
case actions.fetching.started.toString():
return {
...state,
startedFetching: true,
};
case ERROR_FETCHING_ASSIGNMENT_TYPES:
case actions.fetching.received.toString():
return {
...state,
results: payload,
errorFetching: false,
finishedFetching: true,
};
case actions.fetching.error.toString():
return {
...state,
finishedFetching: true,
errorFetching: true,
};
case GOT_ARE_GRADES_FROZEN:
case actions.gotGradesFrozen.toString():
return {
...state,
areGradesFrozen: action.areGradesFrozen,
areGradesFrozen: payload,
errorFetching: false,
finishedFetching: true,
};
@@ -43,4 +38,5 @@ const assignmentTypes = (state = initialState, action) => {
}
};
export { initialState };
export default assignmentTypes;

View File

@@ -1,68 +1,71 @@
import assignmentTypes from './assignmentTypes';
import {
STARTED_FETCHING_ASSIGNMENT_TYPES,
ERROR_FETCHING_ASSIGNMENT_TYPES,
GOT_ASSIGNMENT_TYPES,
GOT_ARE_GRADES_FROZEN,
} from '../constants/actionTypes/assignmentTypes';
import assignmentTypes, { initialState } from './assignmentTypes';
import actions from '../actions/assignmentTypes';
const initialState = {
results: [],
startedFetching: false,
errorFetching: false,
const testingState = {
...initialState,
results: ['Exam', 'Homework'],
arbitraryField: 'arbitrary',
};
const assignmentTypesData = ['Exam', 'Homework'];
describe('assignmentTypes reducer', () => {
it('has initial state', () => {
expect(assignmentTypes(undefined, {})).toEqual(initialState);
expect(
assignmentTypes(undefined, {}),
).toEqual(initialState);
});
it('updates fetch assignmentTypes request state', () => {
const expected = {
...initialState,
startedFetching: true,
};
expect(assignmentTypes(undefined, {
type: STARTED_FETCHING_ASSIGNMENT_TYPES,
})).toEqual(expected);
describe('handling actions.fetching.started', () => {
it('sets startedFetching=true', () => {
const expected = {
...testingState,
startedFetching: true,
};
expect(
assignmentTypes(testingState, actions.fetching.started()),
).toEqual(expected);
});
});
it('updates fetch assignmentTypes success state', () => {
const expected = {
...initialState,
results: assignmentTypesData,
errorFetching: false,
finishedFetching: true,
};
expect(assignmentTypes(undefined, {
type: GOT_ASSIGNMENT_TYPES,
assignmentTypes: assignmentTypesData,
})).toEqual(expected);
describe('handling actions.fetching.received', () => {
it('loads the results and sets finishedFetching=true and errorFetching=false', () => {
const expectedResults = ['Exam'];
const expected = {
...testingState,
results: expectedResults,
errorFetching: false,
finishedFetching: true,
};
expect(
assignmentTypes(testingState, actions.fetching.received(expectedResults)),
).toEqual(expected);
});
});
it('updates fetch assignmentTypes failure state', () => {
const expected = {
...initialState,
errorFetching: true,
finishedFetching: true,
};
expect(assignmentTypes(undefined, {
type: ERROR_FETCHING_ASSIGNMENT_TYPES,
})).toEqual(expected);
describe('handling actions.fetching.error', () => {
it('sets errorFetching=true and finishedFetching=true', () => {
const expected = {
...testingState,
errorFetching: true,
finishedFetching: true,
};
expect(
assignmentTypes(testingState, actions.fetching.error()),
).toEqual(expected);
});
});
it('updates areGradesFrozen success state', () => {
const expected = {
...initialState,
errorFetching: false,
finishedFetching: true,
areGradesFrozen: true,
};
expect(assignmentTypes(undefined, {
type: GOT_ARE_GRADES_FROZEN,
areGradesFrozen: true,
})).toEqual(expected);
describe('handling actions.gotGradesFrozen', () => {
it('loads areGradesFrozen and sets errorFetching=false and finishedFetching=true', () => {
const expectedAreGradesFrozen = true;
const expected = {
...testingState,
errorFetching: false,
finishedFetching: true,
areGradesFrozen: expectedAreGradesFrozen,
};
expect(
assignmentTypes(testingState, actions.gotGradesFrozen(expectedAreGradesFrozen)),
).toEqual(expected);
});
});
});

View File

@@ -1,8 +1,4 @@
import {
STARTED_FETCHING_COHORTS,
ERROR_FETCHING_COHORTS,
GOT_COHORTS,
} from '../constants/actionTypes/cohorts';
import actions from '../actions/cohorts';
const initialState = {
results: [],
@@ -12,19 +8,19 @@ const initialState = {
const cohorts = (state = initialState, action) => {
switch (action.type) {
case GOT_COHORTS:
return {
...state,
results: action.cohorts,
finishedFetching: true,
errorFetching: false,
};
case STARTED_FETCHING_COHORTS:
case actions.fetching.started.toString():
return {
...state,
startedFetching: true,
};
case ERROR_FETCHING_COHORTS:
case actions.fetching.received.toString():
return {
...state,
results: action.payload,
finishedFetching: true,
errorFetching: false,
};
case actions.fetching.error.toString():
return {
...state,
finishedFetching: true,
@@ -35,4 +31,5 @@ const cohorts = (state = initialState, action) => {
}
};
export { initialState };
export default cohorts;

View File

@@ -1,70 +1,61 @@
import cohorts from './cohorts';
import {
STARTED_FETCHING_COHORTS,
ERROR_FETCHING_COHORTS,
GOT_COHORTS,
} from '../constants/actionTypes/cohorts';
const initialState = {
results: [],
startedFetching: false,
errorFetching: false,
};
import cohorts, { initialState } from './cohorts';
import actions from '../actions/cohorts';
const cohortsData = [
{
assignment_type: 'manual',
group_id: null,
id: 1,
name: 'default_group',
user_count: 2,
user_partition_id: null,
},
{
assignment_type: 'auto',
group_id: null,
id: 2,
name: 'auto_group',
user_count: 5,
user_partition_id: null,
}];
{ arbitraryCohortField: 'some data' },
{ anotherArbitraryCohortField: 'some data' },
];
const testingState = {
...initialState,
results: cohortsData,
arbitraryField: 'arbitrary',
};
describe('cohorts reducer', () => {
it('has initial state', () => {
expect(cohorts(undefined, {})).toEqual(initialState);
expect(
cohorts(undefined, {}),
).toEqual(initialState);
});
it('updates fetch cohorts request state', () => {
const expected = {
...initialState,
startedFetching: true,
};
expect(cohorts(undefined, {
type: STARTED_FETCHING_COHORTS,
})).toEqual(expected);
describe('handling actions.fetching.started', () => {
it('sets startedFetching=true', () => {
const expected = {
...testingState,
startedFetching: true,
};
expect(
cohorts(testingState, actions.fetching.started()),
).toEqual(expected);
});
});
it('updates fetch cohorts success state', () => {
const expected = {
...initialState,
results: cohortsData,
errorFetching: false,
finishedFetching: true,
};
expect(cohorts(undefined, {
type: GOT_COHORTS,
cohorts: cohortsData,
})).toEqual(expected);
describe('handling actions.fetching.received', () => {
it('loads results and sets finishedFetching=true and errorFetching=false', () => {
const newCohortData = [{ newResultFields: 'recieved data' }];
const expected = {
...testingState,
results: newCohortData,
errorFetching: false,
finishedFetching: true,
};
expect(
cohorts(testingState, actions.fetching.received(newCohortData)),
).toEqual(expected);
});
});
it('updates fetch cohorts failure state', () => {
const expected = {
...initialState,
errorFetching: true,
finishedFetching: true,
};
expect(cohorts(undefined, {
type: ERROR_FETCHING_COHORTS,
})).toEqual(expected);
describe('handling actions.fetching.error', () => {
it('sets finishedFetching=true and errorFetching=true', () => {
const expected = {
...testingState,
errorFetching: true,
finishedFetching: true,
};
expect(
cohorts(testingState, actions.fetching.error()),
).toEqual(expected);
});
});
});

View File

@@ -1,15 +1,18 @@
import GOT_BULK_MANAGEMENT_CONFIG from '../constants/actionTypes/config';
import actions from '../actions/config';
const reducer = (state = {}, action) => {
const initialState = {};
const reducer = (state = initialState, action) => {
switch (action.type) {
case GOT_BULK_MANAGEMENT_CONFIG:
case actions.gotBulkManagementConfig.toString():
return {
...state,
bulkManagementAvailable: action.data,
bulkManagementAvailable: action.payload,
};
default:
return state;
}
};
export { initialState };
export default reducer;

View File

@@ -0,0 +1,25 @@
import config, { initialState } from './config';
import actions from '../actions/config';
const testingState = {
abitraryField: 'abitrary',
};
describe('config reducer', () => {
it('has initial state', () => {
expect(
config(undefined, {}),
).toEqual(initialState);
});
it('loads bulkManagementAvailable from payload', () => {
const expectedBulkManagementAvailable = true;
const expected = {
...testingState,
bulkManagementAvailable: expectedBulkManagementAvailable,
};
expect(
config(testingState, actions.gotBulkManagementConfig(expectedBulkManagementAvailable)),
).toEqual(expected);
});
});

View File

@@ -1,78 +1,83 @@
import { GOT_GRADES, FILTER_BY_ASSIGNMENT_TYPE } from '../constants/actionTypes/grades';
import {
INITIALIZE_FILTERS, UPDATE_ASSIGNMENT_FILTER, UPDATE_ASSIGNMENT_LIMITS, UPDATE_COURSE_GRADE_LIMITS, RESET_FILTERS,
} from '../constants/actionTypes/filters';
import selectors from 'data/selectors';
import actions from '../actions/filters';
import gradeActions from '../actions/grades';
import initialFilters from '../constants/filters';
import { getAssignmentsFromResultsSubstate, chooseRelevantAssignmentData } from '../selectors/filters';
const initialState = {};
const reducer = (state = initialState, action) => {
switch (action.type) {
case FILTER_BY_ASSIGNMENT_TYPE:
const reducer = (state = initialState, { type: actionType, payload }) => {
switch (actionType) {
case actions.initialize.toString():
return {
...state,
assignmentType: action.filterType,
...payload,
};
case actions.reset.toString(): {
const result = { ...state };
payload.forEach((filterName) => {
result[filterName] = initialFilters[filterName];
});
return result;
}
case actions.update.assignment.toString():
return {
...state,
assignment: payload,
};
case actions.update.assignmentLimits.toString():
return {
...state,
assignmentGradeMin: payload.minGrade,
assignmentGradeMax: payload.maxGrade,
};
case actions.update.assignmentType.toString():
return {
...state,
assignmentType: payload,
assignment: (
action.filterType !== ''
&& (state.assignment || {}).type !== action.filterType)
? '' : state.assignment,
(
payload !== ''
&& (state.assignment || {}).type !== payload
) ? '' : state.assignment
),
};
case INITIALIZE_FILTERS:
case actions.update.courseGradeLimits.toString():
return {
...state,
...action.data,
courseGradeMin: payload.courseGradeMin,
courseGradeMax: payload.courseGradeMax,
};
case GOT_GRADES: {
case actions.update.includeCourseRoleMembers.toString():
return {
...state,
includeCourseRoleMembers: payload,
};
case gradeActions.fetching.received.toString(): {
const { assignment } = state;
const { id, type } = assignment || {};
if (!type) {
const relevantAssignment = getAssignmentsFromResultsSubstate(action.grades)
.map(chooseRelevantAssignmentData)
.find(assig => assig.id === id);
if (id && !type) {
const { relevantAssignmentDataFromResults } = selectors.filters;
const relevantAssignment = relevantAssignmentDataFromResults(
payload.grades,
id,
);
return {
...state,
track: action.track,
cohort: action.cohort,
track: payload.track,
cohort: payload.cohort,
assignment: relevantAssignment,
};
}
return {
...state,
track: action.track,
cohort: action.cohort,
track: payload.track,
cohort: payload.cohort,
};
}
case RESET_FILTERS: {
const result = { ...state };
action.filterNames.forEach((filterName) => {
result[filterName] = initialFilters[filterName];
});
return result;
}
case UPDATE_ASSIGNMENT_FILTER:
return {
...state,
assignment: action.data,
};
case UPDATE_ASSIGNMENT_LIMITS:
return {
...state,
assignmentGradeMin: action.data.minGrade,
assignmentGradeMax: action.data.maxGrade,
};
case UPDATE_COURSE_GRADE_LIMITS:
return {
...state,
courseGradeMin: action.data.courseGradeMin,
courseGradeMax: action.data.courseGradeMax,
};
default:
return state;
}
};
export { initialState };
export default reducer;

View File

@@ -0,0 +1,201 @@
import selectors from 'data/selectors';
import filter, { initialState } from './filters';
import actions from '../actions/filters';
import gradeActions from '../actions/grades';
import initialFilters from '../constants/filters';
jest.mock('data/selectors', () => ({
__esModule: true,
default: {
filters: {
relevantAssignmentDataFromResults: jest.fn(),
},
},
}));
const expectedFilterType = 'homework';
const expectedAssignmentId = 'assignment 1';
const expectedAssignment = {
type: expectedFilterType,
id: expectedAssignmentId,
};
const testingState = {
...initialState,
arbitraryField: 'arbirary',
assignmentType: 'exam',
assignment: { ...expectedAssignment },
};
describe('filter reducer', () => {
it('has initial state', () => {
expect(
filter(undefined, {}),
).toEqual(initialState);
});
describe('handling actions.initialize', () => {
it('replaces all passed fields', () => {
const payload = {
assignment: { ...expectedAssignment },
assignmentType: expectedFilterType,
track: 'verified',
cohort: 5,
assignmentGradeMin: 50,
assignmentGradeMax: 100,
courseGradeMin: 50,
courseGradeMax: 100,
includeCourseRoleMembers: true,
};
const action = { type: actions.initialize.toString(), payload };
expect(filter(testingState, action)).toEqual({ ...testingState, ...payload });
});
it('only replaces passed fields', () => {
const payload = { otherField: 'some data' };
const action = { type: actions.initialize.toString(), payload };
expect(filter(testingState, action)).toEqual({ ...testingState, ...payload });
});
});
describe('handling actions.reset', () => {
it('resets the all attribute existed in filter to initial filter', () => {
expect(
filter(testingState, actions.reset(Object.keys(initialFilters))),
).toEqual({ ...testingState, ...initialFilters });
});
it('only resets keys passed in the action', () => {
const payload = ['assignment', 'assignmentType'];
expect(filter(testingState, actions.reset(payload))).toEqual({
...testingState,
[payload[0]]: initialFilters[payload[0]],
[payload[1]]: initialFilters[payload[1]],
});
});
});
describe('handle actions.update.assignment', () => {
it('loads assignment from payload', () => {
expect(
filter(testingState, actions.update.assignment(expectedAssignment)),
).toEqual({ ...testingState, assignment: expectedAssignment });
});
});
describe('handle actions.update.assignmentLimits', () => {
it('loads assignmentGrade[Min/Max] from payload [min/max]grade', () => {
const expectedMinGrade = 50;
const expectedMaxGrade = 100;
expect(
filter(testingState, actions.update.assignmentLimits({
minGrade: expectedMinGrade,
maxGrade: expectedMaxGrade,
})),
).toEqual({
...testingState,
assignmentGradeMin: expectedMinGrade,
assignmentGradeMax: expectedMaxGrade,
});
});
});
describe('handle actions.update.assignmentType', () => {
const action = actions.update.assignmentType;
describe('new non-empty type', () => {
const newType = 'new ASsignment TYpe';
it('loads assignmentType and clears assignment', () => {
expect(
filter(testingState, action(newType)),
).toEqual({
...testingState,
assignmentType: newType,
assignment: '',
});
});
});
describe('empty string type', () => {
it('does not clear assignment if the type is empty', () => {
expect(
filter(testingState, action('')),
).toEqual({ ...testingState, assignmentType: '' });
});
});
describe('matching type', () => {
it('does not clear the assignment if the type still matches the assignment', () => {
expect(
filter(testingState, action(testingState.assignment.type)),
).toEqual({
...testingState,
assignmentType: testingState.assignment.type,
});
});
});
});
describe('handling actions.update.courseGradeLimits', () => {
it('updates courseGrade[Min/Max]', () => {
const payload = {
courseGradeMin: 20,
courseGradeMax: 70,
};
expect(
filter(initialState, actions.update.courseGradeLimits(payload)),
).toEqual({ ...initialState, ...payload });
});
});
describe('handling actions.update.includeCourseRoleMembers', () => {
it('updates includeCourseRoleMembers from payload', () => {
const includeCourseRoleMembers = true;
expect(
filter(initialState, actions.update.includeCourseRoleMembers(includeCourseRoleMembers)),
).toEqual({ ...initialState, includeCourseRoleMembers });
});
});
describe('handling gradeActions.fetching.received', () => {
const mockSelector = (val) => {
selectors.filters.relevantAssignmentDataFromResults.mockImplementation(
(...args) => ({ args, val }),
);
};
const assignmentId = 'fake ID';
const action = gradeActions.fetching.received;
const payload = {
cohort: 'aCohoRT',
track: 'ATRacK',
grades: 'someGrades',
};
const relevantAssignment = { relevant: 'assignment' };
describe('with non-typed assignment filter', () => {
const state = { ...testingState, assignment: { id: assignmentId } };
it('loads relevant assignment data by id with track and cohort from payload', () => {
mockSelector(relevantAssignment);
expect(filter(state, action(payload))).toEqual({
...state,
cohort: payload.cohort,
track: payload.track,
assignment: { args: [payload.grades, assignmentId], val: relevantAssignment },
});
});
});
describe('with empty assignment filter', () => {
const state = { ...testingState, assignment: '' };
it('loads cohort and track from payload', () => {
expect(filter(state, action(payload))).toEqual({
...state,
cohort: payload.cohort,
track: payload.track,
});
});
});
describe('with typed assignment filter', () => {
const state = { ...testingState, assignment: { id: assignmentId, type: 'homework' } };
it('loads cohort and track from payload', () => {
expect(filter(state, action(payload))).toEqual({
...state,
cohort: payload.cohort,
track: payload.track,
});
});
});
});
});

View File

@@ -1,19 +1,5 @@
import {
STARTED_FETCHING_GRADES,
ERROR_FETCHING_GRADES,
GOT_GRADES,
TOGGLE_GRADE_FORMAT,
FILTER_BY_ASSIGNMENT_TYPE,
OPEN_BANNER,
CLOSE_BANNER,
START_UPLOAD,
UPLOAD_COMPLETE,
UPLOAD_ERR,
GOT_BULK_HISTORY,
DONE_VIEWING_ASSIGNMENT,
GOT_GRADE_OVERRIDE_HISTORY,
ERROR_FETCHING_GRADE_OVERRIDE_HISTORY,
} from '../constants/actionTypes/grades';
import actions from '../actions/grades';
import filterActions from '../actions/filters';
const initialState = {
results: [],
@@ -41,23 +27,53 @@ const initialState = {
filteredUsersCount: 0,
};
const grades = (state = initialState, action) => {
switch (action.type) {
case GOT_GRADES:
const grades = (state = initialState, { type, payload }) => {
switch (type) {
case actions.banner.open.toString():
return {
...state,
results: action.grades,
headings: action.headings,
finishedFetching: true,
errorFetching: false,
prevPage: action.prev,
nextPage: action.next,
showSpinner: false,
courseId: action.courseId,
totalUsersCount: action.totalUsersCount,
filteredUsersCount: action.filteredUsersCount,
showSuccess: true,
};
case DONE_VIEWING_ASSIGNMENT: {
case actions.banner.close.toString():
return {
...state,
showSuccess: false,
};
case actions.bulkHistory.received.toString():
return {
...state,
bulkManagement: {
...state.bulkManagement,
history: payload,
},
};
case actions.csvUpload.started.toString(): {
const { errorMessages, uploadSuccess, ...rest } = state.bulkManagement;
return {
...state,
showSpinner: true,
bulkManagement: rest,
};
}
case actions.csvUpload.finished.toString():
return {
...state,
showSpinner: false,
bulkManagement: {
...state.bulkManagement,
uploadSuccess: true,
},
};
case actions.csvUpload.error.toString():
return {
...state,
showSpinner: false,
bulkManagement: {
...state.bulkManagement,
...payload,
},
};
case actions.doneViewingAssignment.toString(): {
const {
gradeOverrideHistoryResults,
gradeOverrideCurrentEarnedAllOverride,
@@ -72,92 +88,63 @@ const grades = (state = initialState, action) => {
} = state;
return rest;
}
case GOT_GRADE_OVERRIDE_HISTORY:
return {
...state,
gradeOverrideHistoryResults: action.overrideHistory,
gradeOverrideCurrentEarnedAllOverride: action.currentEarnedAllOverride,
gradeOverrideCurrentPossibleAllOverride: action.currentPossibleAllOverride,
gradeOverrideCurrentEarnedGradedOverride: action.currentEarnedGradedOverride,
gradeOverrideCurrentPossibleGradedOverride: action.currentPossibleGradedOverride,
gradeOriginalEarnedAll: action.originalGradeEarnedAll,
gradeOriginalPossibleAll: action.originalGradePossibleAll,
gradeOriginalEarnedGraded: action.originalGradeEarnedGraded,
gradeOriginalPossibleGraded: action.originalGradePossibleGraded,
overrideHistoryError: '',
};
case ERROR_FETCHING_GRADE_OVERRIDE_HISTORY:
return {
...state,
finishedFetchingOverrideHistory: true,
overrideHistoryError: action.errorMessage,
};
case STARTED_FETCHING_GRADES:
case actions.fetching.started.toString():
return {
...state,
startedFetching: true,
finishedFetching: false,
showSpinner: true,
};
case ERROR_FETCHING_GRADES:
case actions.fetching.error.toString():
return {
...state,
finishedFetching: true,
errorFetching: true,
};
case TOGGLE_GRADE_FORMAT:
return {
...state,
gradeFormat: action.formatType,
};
case FILTER_BY_ASSIGNMENT_TYPE:
return {
...state,
selectedAssignmentType: action.filterType,
headings: action.headings,
};
case OPEN_BANNER:
return {
...state,
showSuccess: true,
};
case CLOSE_BANNER:
return {
...state,
showSuccess: false,
};
case START_UPLOAD: {
const { errorMessages, uploadSuccess, ...rest } = state.bulkManagement;
return {
...state,
showSpinner: true,
bulkManagement: rest,
};
}
case UPLOAD_COMPLETE:
case actions.fetching.received.toString():
return {
...state,
results: payload.grades,
headings: payload.headings,
finishedFetching: true,
errorFetching: false,
prevPage: payload.prev,
nextPage: payload.next,
showSpinner: false,
bulkManagement: { uploadSuccess: true, ...state.bulkManagement },
courseId: payload.courseId,
totalUsersCount: payload.totalUsersCount,
filteredUsersCount: payload.filteredUsersCount,
};
case UPLOAD_ERR:
case actions.overrideHistory.received.toString():
return {
...state,
showSpinner: false,
bulkManagement: {
...state.bulkManagement,
...action.data,
},
gradeOverrideHistoryResults: payload.overrideHistory,
gradeOverrideCurrentEarnedAllOverride: payload.currentEarnedAllOverride,
gradeOverrideCurrentPossibleAllOverride: payload.currentPossibleAllOverride,
gradeOverrideCurrentEarnedGradedOverride: payload.currentEarnedGradedOverride,
gradeOverrideCurrentPossibleGradedOverride: payload.currentPossibleGradedOverride,
gradeOriginalEarnedAll: payload.originalGradeEarnedAll,
gradeOriginalPossibleAll: payload.originalGradePossibleAll,
gradeOriginalEarnedGraded: payload.originalGradeEarnedGraded,
gradeOriginalPossibleGraded: payload.originalGradePossibleGraded,
overrideHistoryError: '',
};
case GOT_BULK_HISTORY:
case actions.overrideHistory.error.toString():
return {
...state,
bulkManagement: {
...state.bulkManagement,
history: action.data,
},
finishedFetchingOverrideHistory: true,
overrideHistoryError: payload,
};
case actions.toggleGradeFormat.toString():
return {
...state,
gradeFormat: payload,
};
case filterActions.update.assignmentType.toString():
return {
...state,
selectedAssignmentType: payload.filterType,
headings: payload.headings,
};
default:
return state;

View File

@@ -1,178 +1,248 @@
import grades, { initialGradesState as initialState } from './grades';
import {
STARTED_FETCHING_GRADES,
ERROR_FETCHING_GRADES,
GOT_GRADES,
TOGGLE_GRADE_FORMAT,
FILTER_BY_ASSIGNMENT_TYPE,
OPEN_BANNER,
ERROR_FETCHING_GRADE_OVERRIDE_HISTORY,
} from '../constants/actionTypes/grades';
import actions from '../actions/grades';
import filterActions from '../actions/filters';
const courseId = 'course-v1:edX+DemoX+Demo_Course';
const headingsData = [
{ name: 'exam' },
{ name: 'homework2' },
];
const gradesData = [
{
course_id: courseId,
email: 'user1@example.com',
username: 'user1',
user_id: 1,
percent: 0.5,
letter_grade: null,
section_breakdown: [
{
subsection_name: 'Demo Course Overview',
score_earned: 0,
score_possible: 0,
percent: 0,
displayed_value: '0.00',
grade_description: '(0.00/0.00)',
},
{
subsection_name: 'Example Week 1: Getting Started',
score_earned: 1,
score_possible: 1,
percent: 1,
displayed_value: '1.00',
grade_description: '(0.00/0.00)',
},
],
const testingState = {
...initialState,
bulkManagement: {
errorMessages: 'some error message',
uploadSuccess: false,
},
{
course_id: courseId,
email: 'user22@example.com',
username: 'user22',
user_id: 22,
percent: 0,
letter_grade: null,
section_breakdown: [
{
subsection_name: 'Demo Course Overview',
score_earned: 0,
score_possible: 0,
percent: 0,
displayed_value: '0.00',
grade_description: '(0.00/0.00)',
},
{
subsection_name: 'Example Week 1: Getting Started',
score_earned: 1,
score_possible: 1,
percent: 0,
displayed_value: '0.00',
grade_description: '(0.00/0.00)',
},
],
}];
arbitraryField: 'abitrary',
};
describe('grades reducer', () => {
it('has initial state', () => {
expect(grades(undefined, {})).toEqual(initialState);
expect(
grades(undefined, {}),
).toEqual(initialState);
});
it('updates fetch grades request state', () => {
const expected = {
...initialState,
startedFetching: true,
showSpinner: true,
};
expect(grades(undefined, {
type: STARTED_FETCHING_GRADES,
})).toEqual(expected);
});
describe('action handlers', () => {
describe('actions.banner.open', () => {
it('sets showSuccess to true', () => {
expect(
grades(undefined, actions.banner.open()),
).toEqual({ ...initialState, showSuccess: true });
});
});
describe('actions.banner.close', () => {
it('set showSuccess to false', () => {
expect(
grades(undefined, actions.banner.close()),
).toEqual({ ...initialState, showSuccess: false });
});
});
it('updates fetch grades success state', () => {
const expectedPrev = 'testPrevUrl';
const expectedNext = 'testNextUrl';
const expectedTrack = 'verified';
const expectedCohortId = 2;
const expected = {
...initialState,
results: gradesData,
headings: headingsData,
errorFetching: false,
finishedFetching: true,
prevPage: expectedPrev,
nextPage: expectedNext,
showSpinner: false,
courseId,
totalUsersCount: 4,
filteredUsersCount: 2,
};
expect(grades(undefined, {
type: GOT_GRADES,
grades: gradesData,
headings: headingsData,
prev: expectedPrev,
next: expectedNext,
track: expectedTrack,
cohort: expectedCohortId,
showSpinner: true,
courseId,
totalUsersCount: 4,
filteredUsersCount: 2,
})).toEqual(expected);
});
describe('actions.bulkHistory.received', () => {
it('loads payload into bulkManagement.history', () => {
const history = 'HIstory';
expect(
grades(testingState, actions.bulkHistory.received(history)),
).toEqual({
...testingState,
bulkManagement: { ...testingState.bulkManagement, history },
});
});
});
it('updates toggle grade format state success', () => {
const formatTypeData = 'percent';
const expected = {
...initialState,
gradeFormat: formatTypeData,
};
expect(grades(undefined, {
type: TOGGLE_GRADE_FORMAT,
formatType: formatTypeData,
})).toEqual(expected);
});
describe('actions.csvUpload.started', () => {
it(
'sets showSpinner=true and removes errorMessages and uploadSuccess from bulkManagement',
() => {
const {
errorMessages,
uploadSuccess,
...expectedBulkManagement
} = testingState.bulkManagement;
expect(
grades(testingState, actions.csvUpload.started()),
).toEqual({
...testingState,
showSpinner: true,
bulkManagement: expectedBulkManagement,
});
},
);
});
describe('handling actions.csvUpload.finished', () => {
it('sets showSpinner=false and sets bulkManagement.uploadSuccess=true', () => {
expect(
grades(testingState, actions.csvUpload.finished()),
).toEqual({
...testingState,
showSpinner: false,
bulkManagement: { ...testingState.bulkManagement, uploadSuccess: true },
});
});
});
describe('handling actions.csvUpload.error', () => {
it('loads errorMessage to bulkManagement from payload and sets showSpinner=false', () => {
const errorMessage = 'This is a new error message';
expect(
grades(testingState, actions.csvUpload.error({
errorMessage,
uploadSuccess: false,
})),
).toEqual({
...testingState,
showSpinner: false,
bulkManagement: { errorMessage, ...testingState.bulkManagement },
});
});
});
it('updates filter columns state success', () => {
const expectedHeadings = headingsData;
const expected = {
...initialState,
headings: expectedHeadings,
};
expect(grades(undefined, {
type: FILTER_BY_ASSIGNMENT_TYPE,
headings: expectedHeadings,
})).toEqual(expected);
});
describe('actions.doneViewingAssignment', () => {
it('removes gradeOverride* and gradeOriginal* from existing state', () => {
const {
gradeOverrideHistoryResults,
gradeOverrideCurrentEarnedAllOverride,
gradeOverrideCurrentPossibleAllOverride,
gradeOverrideCurrentEarnedGradedOverride,
gradeOverrideCurrentPossibleGradedOverride,
gradeOriginalEarnedAll,
gradeOriginalPossibleAll,
gradeOriginalEarnedGraded,
gradeOriginalPossibleGraded,
...expected
} = testingState;
expect(
grades(testingState, actions.doneViewingAssignment()),
).toEqual(expected);
});
});
it('updates update_banner state success', () => {
const expectedShowSuccess = true;
const expected = {
...initialState,
showSuccess: expectedShowSuccess,
};
expect(grades(undefined, {
type: OPEN_BANNER,
})).toEqual(expected);
});
describe('actions.fetching.started', () => {
it('sets startedFetching and showSpinner to true', () => {
expect(
grades(testingState, actions.fetching.started()),
).toEqual({
...testingState,
startedFetching: true,
showSpinner: true,
});
});
});
describe('actions.fetching.received', () => {
it(
'loads payload and sets finishedFetching:true, errorFetching:false, showSpinner:false',
() => {
const payload = {
courseId: 'course-v1:edX+DemoX+Demo_Course',
headings: 'some Headings',
prev: 'testPrevUrl',
next: 'testNextUrl',
track: 'verified',
cohortId: 2,
totalUsersCount: 4,
filteredUsersCount: 2,
assignmentType: 'Homework',
grades: { somethingArbitrary: 'some data' },
};
expect(
grades(testingState, actions.fetching.received(payload)),
).toEqual({
...testingState,
results: payload.grades,
headings: payload.headings,
prevPage: payload.prev,
nextPage: payload.next,
courseId: payload.courseId,
totalUsersCount: payload.totalUsersCount,
filteredUsersCount: payload.filteredUsersCount,
errorFetching: false,
finishedFetching: true,
showSpinner: false,
});
},
);
});
describe('actions.fetching.error', () => {
it('sets finishedFetching and errorFetching to true', () => {
expect(
grades(testingState, actions.fetching.error()),
).toEqual({
...testingState,
errorFetching: true,
finishedFetching: true,
});
});
});
it('updates fetch grades failure state', () => {
const expected = {
...initialState,
errorFetching: true,
finishedFetching: true,
};
expect(grades(undefined, {
type: ERROR_FETCHING_GRADES,
})).toEqual(expected);
});
describe('actions.overrideHistory.received', () => {
it('loads payload and clears overrideHistoryError', () => {
const payload = {
overrideHistory: true,
currentEarnedAllOverride: false,
currentPossibleAllOverride: 'red',
currentEarnedGradedOverride: 'green',
currentPossibleGradedOverride: 'blue',
originalGradeEarnedAll: 'other',
originalGradePossibleAll: 'sparrow',
originalGradeEarnedGraded: 'crow',
originalGradePossibleGraded: 'raven',
};
expect(
grades(testingState, actions.overrideHistory.received(payload)),
).toEqual({
...testingState,
gradeOverrideHistoryResults: payload.overrideHistory,
gradeOverrideCurrentEarnedAllOverride: payload.currentEarnedAllOverride,
gradeOverrideCurrentPossibleAllOverride: payload.currentPossibleAllOverride,
gradeOverrideCurrentEarnedGradedOverride: payload.currentEarnedGradedOverride,
gradeOverrideCurrentPossibleGradedOverride: payload.currentPossibleGradedOverride,
gradeOriginalEarnedAll: payload.originalGradeEarnedAll,
gradeOriginalPossibleAll: payload.originalGradePossibleAll,
gradeOriginalEarnedGraded: payload.originalGradeEarnedGraded,
gradeOriginalPossibleGraded: payload.originalGradePossibleGraded,
overrideHistoryError: '',
});
});
});
describe('actions.overrideHistory.error', () => {
it(
'sets finishedFetchingOverrideHistory=true and loads overrideHistoryError from payload',
() => {
const errorMessage = 'This is the error message';
expect(
grades(testingState, actions.overrideHistory.error(errorMessage)),
).toEqual({
...testingState,
finishedFetchingOverrideHistory: true,
overrideHistoryError: errorMessage,
});
},
);
});
it('updates fetch grade override history failure state', () => {
const errorMessage = 'This is the error message';
const expected = {
...initialState,
finishedFetchingOverrideHistory: true,
overrideHistoryError: errorMessage,
};
expect(grades(undefined, {
type: ERROR_FETCHING_GRADE_OVERRIDE_HISTORY,
errorMessage,
})).toEqual(expected);
describe('handling actions.toggleGradeFormat', () => {
it('updates grade format attribute', () => {
const formatTypeData = 'percent';
expect(
grades(undefined, actions.toggleGradeFormat(formatTypeData)),
).toEqual({ ...initialState, gradeFormat: formatTypeData });
});
});
describe('handling filterActions.update.assignmentType', () => {
it('loads assignmentType and headings from the payload', () => {
const expectedSelectedAssignmentType = 'selected assignment type';
expect(
grades(testingState, filterActions.update.assignmentType({
headings: headingsData,
filterType: expectedSelectedAssignmentType,
})),
).toEqual({
...testingState,
selectedAssignmentType: expectedSelectedAssignmentType,
headings: headingsData,
});
});
});
});
});

View File

@@ -1,7 +1,4 @@
import {
GOT_ROLES,
ERROR_FETCHING_ROLES,
} from '../constants/actionTypes/roles';
import actions from '../actions/roles';
const initialState = {
canUserViewGradebook: null,
@@ -9,12 +6,12 @@ const initialState = {
const roles = (state = initialState, action) => {
switch (action.type) {
case GOT_ROLES:
case actions.fetching.received.toString():
return {
...state,
canUserViewGradebook: action.canUserViewGradebook,
canUserViewGradebook: action.payload,
};
case ERROR_FETCHING_ROLES:
case actions.fetching.error.toString():
return {
...state,
canUserViewGradebook: false,
@@ -24,4 +21,5 @@ const roles = (state = initialState, action) => {
}
};
export { initialState };
export default roles;

View File

@@ -1,47 +1,38 @@
import roles from './roles';
import {
ERROR_FETCHING_ROLES,
GOT_ROLES,
} from '../constants/actionTypes/roles';
import roles, { initialState } from './roles';
import actions from '../actions/roles';
const initialState = {
canUserViewGradebook: null,
const testingState = {
...initialState,
arbitraryField: 'arbitrary',
};
describe('tracks reducer', () => {
describe('roles reducer', () => {
it('has initial state', () => {
expect(roles(undefined, {})).toEqual(initialState);
expect(
roles(undefined, {}),
).toEqual(initialState);
});
it('updates canUserViewGradebook to true', () => {
const expected = {
...initialState,
canUserViewGradebook: true,
};
expect(roles(undefined, {
type: GOT_ROLES,
canUserViewGradebook: true,
})).toEqual(expected);
describe('handling actions.received', () => {
it('updates canUserViewGradebook to the received payload', () => {
const expectedCanUserViewGradebook = true;
expect(
roles(testingState, actions.fetching.received(expectedCanUserViewGradebook)),
).toEqual({
...testingState,
canUserViewGradebook: expectedCanUserViewGradebook,
});
});
});
it('updates canUserViewGradebook to false', () => {
const expected = {
...initialState,
canUserViewGradebook: false,
};
expect(roles(undefined, {
type: GOT_ROLES,
canUserViewGradebook: false,
})).toEqual(expected);
});
it('updates fetch roles failure state', () => {
const expected = {
...initialState,
canUserViewGradebook: false,
};
expect(roles(undefined, {
type: ERROR_FETCHING_ROLES,
})).toEqual(expected);
describe('handling actions.errorFetching', () => {
it('sets canUserViewGradebook to false', () => {
expect(
roles(testingState, actions.fetching.error()),
).toEqual({
...testingState,
canUserViewGradebook: false,
});
});
});
});

View File

@@ -1,8 +1,4 @@
import {
STARTED_FETCHING_TRACKS,
ERROR_FETCHING_TRACKS,
GOT_TRACKS,
} from '../constants/actionTypes/tracks';
import actions from '../actions/tracks';
const initialState = {
results: [],
@@ -12,19 +8,19 @@ const initialState = {
const tracks = (state = initialState, action) => {
switch (action.type) {
case GOT_TRACKS:
return {
...state,
results: action.tracks,
errorFetching: false,
finishedFetching: true,
};
case STARTED_FETCHING_TRACKS:
case actions.fetching.started.toString():
return {
...state,
startedFetching: true,
};
case ERROR_FETCHING_TRACKS:
case actions.fetching.received.toString():
return {
...state,
results: action.payload,
errorFetching: false,
finishedFetching: true,
};
case actions.fetching.error.toString():
return {
...state,
finishedFetching: true,
@@ -35,4 +31,5 @@ const tracks = (state = initialState, action) => {
}
};
export { initialState };
export default tracks;

View File

@@ -1,76 +1,58 @@
import tracks from './tracks';
import {
STARTED_FETCHING_TRACKS,
ERROR_FETCHING_TRACKS,
GOT_TRACKS,
} from '../constants/actionTypes/tracks';
const initialState = {
results: [],
startedFetching: false,
errorFetching: false,
};
import tracks, { initialState } from './tracks';
import actions from '../actions/tracks';
const tracksData = [
{
slug: 'audit',
name: 'Audit',
min_price: 0,
suggested_prices: '',
currency: 'usd',
expiration_datetime: null,
description: null,
sku: '68EFFFF',
bulk_sku: null,
},
{
slug: 'verified',
name: 'Verified Certificate',
min_price: 100,
suggested_prices: '',
currency: 'usd',
expiration_datetime: '2021-05-04T18:08:12.644361Z',
description: null,
sku: '8CF08E5',
bulk_sku: 'A5B6DBE',
}];
{ someArbitraryField: 'arbitrary data' },
{ anotherArbitraryField: 'more arbitrary data' },
];
const testingState = {
...initialState,
results: tracksData,
arbitraryField: 'arbitrary',
};
describe('tracks reducer', () => {
it('has initial state', () => {
expect(tracks(undefined, {})).toEqual(initialState);
expect(
tracks(undefined, {}),
).toEqual(initialState);
});
it('updates fetch tracks request state', () => {
const expected = {
...initialState,
startedFetching: true,
};
expect(tracks(undefined, {
type: STARTED_FETCHING_TRACKS,
})).toEqual(expected);
describe('handling actions.fetching.started', () => {
it('set start fetching to true. Preserve results if existed', () => {
expect(
tracks(testingState, actions.fetching.started()),
).toEqual({
...testingState,
startedFetching: true,
});
});
});
it('updates fetch tracks success state', () => {
const expected = {
...initialState,
results: tracksData,
errorFetching: false,
finishedFetching: true,
};
expect(tracks(undefined, {
type: GOT_TRACKS,
tracks: tracksData,
})).toEqual(expected);
describe('handling actions.fetching.received', () => {
it('replace results then set finish fetching to true and error to false', () => {
const newTracksData = [{ receivedData: 'new data' }];
expect(
tracks(testingState, actions.fetching.received(newTracksData)),
).toEqual({
...testingState,
results: newTracksData,
errorFetching: false,
finishedFetching: true,
});
});
});
it('updates fetch tracks failure state', () => {
const expected = {
...initialState,
errorFetching: true,
finishedFetching: true,
};
expect(tracks(undefined, {
type: ERROR_FETCHING_TRACKS,
})).toEqual(expected);
describe('handling actions.fetching.error', () => {
it('set finish fetch and error to true. Preserve results if existed.', () => {
expect(
tracks(testingState, actions.fetching.error()),
).toEqual({
...testingState,
errorFetching: true,
finishedFetching: true,
});
});
});
});

View File

@@ -0,0 +1,9 @@
import { StrictDict } from 'utils';
const areGradesFrozen = ({ assignmentTypes }) => assignmentTypes.areGradesFrozen;
const allAssignmentTypes = ({ assignmentTypes }) => assignmentTypes.results;
export default StrictDict({
areGradesFrozen,
allAssignmentTypes,
});

View File

@@ -0,0 +1,21 @@
import selectors from './assignmentTypes';
describe('assignmentType selectors', () => {
describe('areGradesFrozen', () => {
it('selects areGradesFrozen from state', () => {
const testValue = 'THX 1138';
expect(
selectors.areGradesFrozen({ assignmentTypes: { areGradesFrozen: testValue } }),
).toEqual(testValue);
});
});
describe('allAssignmentTypes', () => {
it('returns assignment types', () => {
const testAssignmentTypes = ['assignment', 'labs'];
expect(
selectors.allAssignmentTypes({ assignmentTypes: { results: testAssignmentTypes } }),
).toEqual(testAssignmentTypes);
});
});
});

View File

@@ -1,10 +1,34 @@
const getCohorts = state => state.cohorts.results || [];
import { StrictDict } from 'utils';
/**
* allCohorts(state)
* returns top-level cohorts results data
* @param {object} state - redux state
* @return {object[]} - list of cohort objects from fetch results
*/
const allCohorts = (state) => state.cohorts.results || [];
/**
* getCohortById(state, selectedCohortId)
* returns cohort with given id
* @param {object} state - redux state
* @param {number} selectedCohortId - id of cohort to return
* @return {object} cohort with given id.
*/
const getCohortById = (state, selectedCohortId) => {
const cohort = getCohorts(state).find(coh => coh.id === selectedCohortId);
const cohort = allCohorts(state).find(({ id }) => id === selectedCohortId);
return cohort;
};
/**
* getCohortNameById(state, selectedCohortId)
* @param {object} state - redux state
* @param {number} selectedCohortId - id of cohort whose name is requested
* @return {string} - name of cohort with the given id
*/
const getCohortNameById = (state, selectedCohortId) => (getCohortById(state, selectedCohortId) || {}).name;
export { getCohortById, getCohortNameById, getCohorts };
export default StrictDict({
getCohortById,
getCohortNameById,
allCohorts,
});

View File

@@ -0,0 +1,49 @@
import selectors from './cohorts';
const testCohorts = [
{
id: '1',
name: 'Cohort 1',
},
{
id: '9000',
name: 'Cohort 9000',
},
];
const nonMatchingId = '9001';
const testState = { cohorts: { results: testCohorts } };
describe('getCohortById', () => {
it('returns cohort when a match is found', () => {
const cohort = selectors.getCohortById(testState, testCohorts[0].id);
expect(cohort).toEqual(testCohorts[0]);
});
it('returns undefined when no match is found', () => {
const cohort = selectors.getCohortById(testState, nonMatchingId);
expect(cohort).toEqual(undefined);
});
});
describe('getCohortNameById', () => {
it('returns a cohort name when cohort matching ID is found', () => {
const cohortName = selectors.getCohortNameById(testState, testCohorts[1].id);
expect(cohortName).toEqual(testCohorts[1].name);
});
it('returns undefined when no matching cohort is found', () => {
const cohortName = selectors.getCohortNameById(testState, nonMatchingId);
expect(cohortName).toEqual(undefined);
});
});
describe('allCohorts', () => {
it('selects cohorts from state', () => {
const allCohorts = selectors.allCohorts(testState);
expect(allCohorts).toEqual(testCohorts);
});
it('returns an empty array when no cohort results present in state', () => {
const allCohorts = selectors.allCohorts({ cohorts: {} });
expect(allCohorts).toEqual([]);
});
});

View File

@@ -1,38 +1,121 @@
const getFilters = state => state.filters || {};
/* eslint-disable import/no-self-import */
import { StrictDict } from 'utils';
import * as module from './filters';
import simpleSelectorFactory from '../utils';
const getAssignmentsFromResultsSubstate = results => (results[0] || {}).section_breakdown || [];
// Transformers
/**
* chooseRelevantAssignmentData(assignment)
* formats the assignment api data for an assignment object for consumption
* @param {object} assignment - assignment data to prepare
* @return {object} - formatted data ({ label, subsectionLabel, type, id })
*/
export const chooseRelevantAssignmentData = ({
label,
subsection_name: subsectionLabel,
category: type,
module_id: id,
}) => ({
label, subsectionLabel, type, id,
});
const selectableAssignments = (state) => {
const selectedAssignmentType = getFilters(state).assignmentType;
/**
* getAssignmentsFromResultsSubstate(results)
* returns the section_breakdown of the first results entry
* defaulting to an empty list.
* @param {[object[]]} results - list of result entries from grades fetch
* @return {object} - section_breakdown of first grade entry
*/
export const getAssignmentsFromResultsSubstate = (results) => (
(results[0] || {}).section_breakdown || []
);
/**
* relevantAssignmentDataFromResults
* returns assignment info from grades results for the assignment with the given id
* @param {object} grades - grades fetch result
* @param {string} id - selected assignment id from assignment filter
* @return {object} assignment data with type, label, and subsectionLabel
*/
export const relevantAssignmentDataFromResults = (grades, id) => (
module.getAssignmentsFromResultsSubstate(grades)
.map(module.chooseRelevantAssignmentData)
.find(assignment => assignment.id === id)
);
// Selectors
/**
* allFilters(state)
* returns the top-level filter state.
* @param {object} state - redux state
* @return {object} - filter substate from redux state
*/
export const allFilters = (state) => state.filters || {};
/**
* selectableAssignments(state)
* @param {object} state - redux state
* @return {object[]} - list of selectable assignment objects, filtered if there is an
* assignmentType filter selected.
*/
export const selectableAssignments = (state) => {
const selectedAssignmentType = allFilters(state).assignmentType;
const needToFilter = selectedAssignmentType && selectedAssignmentType !== 'All';
const allAssignments = getAssignmentsFromResultsSubstate(state.grades.results);
if (needToFilter) {
return allAssignments.filter(assignment => assignment.category === selectedAssignmentType);
return allAssignments.filter(
(assignment) => assignment.category === selectedAssignmentType,
);
}
return allAssignments;
};
const chooseRelevantAssignmentData = assignment => ({
label: assignment.label,
subsectionLabel: assignment.subsection_name,
type: assignment.category,
id: assignment.module_id,
});
/**
* Returns the relevant assignment data for all selectable assignments
* @param {object} state - redux state
* @return {object[]} - object of assignment data entries [({ label, subsectionLabel, type, id })]
*/
export const selectableAssignmentLabels = (state) => (
selectableAssignments(state).map(chooseRelevantAssignmentData)
);
const selectableAssignmentLabels = state => selectableAssignments(state).map(chooseRelevantAssignmentData);
export const simpleSelectors = simpleSelectorFactory(
({ filters }) => filters,
[
'assignment',
'assignmentGradeMax',
'assignmentGradeMin',
'assignmentType',
'cohort',
'courseGradeMax',
'courseGradeMin',
'track',
'includeCourseRoleMembers',
],
);
/**
* Returns the id of the selected assignment filter
* @param {object} state - redux state
* @return {string} - assignment id
*/
export const selectedAssignmentId = (state) => (simpleSelectors.assignment(state) || {}).id;
const typeOfSelectedAssignment = (state) => {
const selectedAssignmentLabel = getFilters(state).assignment;
const sectionBreakdown = (state.grades.results[0] || {}).section_breakdown || [];
const selectedAssignment = sectionBreakdown.find(section => section.label === selectedAssignmentLabel);
return selectedAssignment && selectedAssignment.category;
};
/**
* selectedAssignmentLabel(state)
* Returns the label of the selected assignment filter
* @param {object} state - redux state
* @return {string} - assignment label
*/
export const selectedAssignmentLabel = (state) => (simpleSelectors.assignment(state) || {}).label;
export {
export default StrictDict({
...simpleSelectors,
relevantAssignmentDataFromResults,
selectedAssignmentId,
selectedAssignmentLabel,
selectableAssignmentLabels,
selectableAssignments,
getFilters,
typeOfSelectedAssignment,
allFilters,
chooseRelevantAssignmentData,
getAssignmentsFromResultsSubstate,
};
});

View File

@@ -0,0 +1,189 @@
// import * in order to mock in-file references
import * as selectors from './filters';
// import default export in order to test simpleSelectors not exported individually
import exportedSelectors from './filters';
const selectedAssignmentInfo = {
type: 'Homework',
id: 'block-v1:edX+type@sequential+block@abcde',
label: 'HW 01',
subsectionLabel: 'Example Week 1: Getting Started',
};
const filters = {
assignment: selectedAssignmentInfo,
assignmentGradeMax: '100',
assignmentGradeMin: '0',
assignmentType: 'Homework',
cohort: 'Spring Term',
courseGradeMax: '100',
courseGradeMin: '0',
includeCourseRoleMembers: false,
track: 'masters',
};
const noFilters = {
assignment: undefined,
assignmentGradeMax: '100',
assignmentGradeMin: '0',
assignmentType: 'All',
cohort: '',
courseGradeMax: '100',
courseGradeMin: '0',
includeCourseRoleMembers: false,
track: '',
};
const sectionBreakdowns = [
{
subsection_name: 'Example Week 1: Getting Started',
score_earned: 1,
score_possible: 1,
percent: 1,
displayed_value: '1.00',
grade_description: '(0.00/0.00)',
module_id: 'block-v1:edX+type@sequential+block@abcde',
label: 'HW 01',
category: 'Homework',
},
{
subsection_name: 'Example Week 2: Going Deeper',
score_earned: 1,
score_possible: 42,
percent: 0,
displayed_value: '0.02',
grade_description: '(0.00/0.00)',
module_id: 'block-v1:edX+type@sequential+block@bcdef',
label: 'LAB 01',
category: 'Labs',
},
];
const gradesData = { results: [{ section_breakdown: sectionBreakdowns }] };
const testState = {
filters,
grades: gradesData,
};
describe('filters selectors', () => {
describe('chooseRelevantAssignmentData', () => {
it('maps label, subsection, category, and ID from assignment data', () => {
const assignmentData = selectors.chooseRelevantAssignmentData(sectionBreakdowns[0]);
expect(assignmentData).toEqual(selectedAssignmentInfo);
});
});
describe('getAssignmentsFromResultsSubstate', () => {
it('gets section breakdowns from state', () => {
const assignments = selectors.getAssignmentsFromResultsSubstate(gradesData.results);
expect(assignments).toEqual(sectionBreakdowns);
});
it('returns an empty array when results are not supplied', () => {
const assignments = selectors.getAssignmentsFromResultsSubstate([]);
expect(assignments).toEqual([]);
});
it('returns an empty array when section breakdowns are not supplied', () => {
const assignments = selectors.getAssignmentsFromResultsSubstate([{}]);
expect(assignments).toEqual([]);
});
});
describe('relevantAssignmentDataFromResults', () => {
it('grabs relevant assignment data from the grades substate with matching id', () => {
const ids = ['some', 'fake', 'ids'];
const grades = { gradeIds: ids };
const getFromSubstate = selectors.getAssignmentsFromResultsSubstate;
selectors.getAssignmentsFromResultsSubstate = jest.fn(
({ gradeIds }) => gradeIds,
);
const mapper = selectors.chooseRelevantAssignmentData;
selectors.chooseRelevantAssignmentData = jest.fn((id) => ({ id }));
expect(selectors.relevantAssignmentDataFromResults(grades, ids[2])).toEqual(
{ id: ids[2] },
);
selectors.getAssignmentsFromResultsSubstate = getFromSubstate;
selectors.chooseRelevantAssignmentData = mapper;
});
});
describe('allFilters', () => {
it('selects all filters from state', () => {
const allFilters = selectors.allFilters(testState);
expect(allFilters).toEqual(filters);
});
it('returns an empty object when no filters are in state', () => {
const allFilters = selectors.allFilters({});
expect(allFilters).toEqual({});
});
});
describe('selectedAssignmentId', () => {
it('gets filtered assignment ID when available', () => {
const assignmentId = selectors.selectedAssignmentId(testState);
expect(assignmentId).toEqual(filters.assignment.id);
});
it('returns undefined when assignment ID unavailable', () => {
const assignmentId = selectors.selectedAssignmentId({ filters: { assignment: undefined } });
expect(assignmentId).toEqual(undefined);
});
});
describe('selectedAssignmentLabel', () => {
it('gets filtered assignment label when available', () => {
const assignmentLabel = selectors.selectedAssignmentLabel(testState);
expect(assignmentLabel).toEqual(filters.assignment.label);
});
it('returns undefined when assignment label is unavailable', () => {
const assignmentLabel = selectors.selectedAssignmentLabel({ filters: { assignment: undefined } });
expect(assignmentLabel).toEqual(undefined);
});
});
describe('selectableAssignmentLabels', () => {
it('gets assignment data for sections matching selected type filters', () => {
const selectableAssignmentLabels = selectors.selectableAssignmentLabels(testState);
expect(selectableAssignmentLabels).toEqual([filters.assignment]);
});
});
describe('selectableAssignments', () => {
it('returns all graded assignments when no assignment type filtering is applied', () => {
const selectableAssignments = selectors.selectableAssignments({ grades: gradesData, filters: noFilters });
expect(selectableAssignments).toEqual(sectionBreakdowns);
});
it('gets assignments of the selected category when assignment type filtering is applied', () => {
const selectableAssignments = selectors.selectableAssignments(testState);
expect(selectableAssignments).toEqual([sectionBreakdowns[0]]);
});
});
describe('simpleSelectors', () => {
const testVal = 'some Test Value';
const testSimpleSelector = (key) => {
test(key, () => {
expect(exportedSelectors[key]({ filters: { [key]: testVal } })).toEqual(testVal);
});
};
testSimpleSelector('assignment');
testSimpleSelector('assignmentGradeMax');
testSimpleSelector('assignmentGradeMin');
testSimpleSelector('assignmentType');
testSimpleSelector('cohort');
testSimpleSelector('courseGradeMax');
testSimpleSelector('courseGradeMin');
testSimpleSelector('track');
testSimpleSelector('includeCourseRoleMembers');
test('selectedAssignmentId', () => {
expect(
selectors.selectedAssignmentId({ filters: { assignment: { id: testVal } } }),
).toEqual(testVal);
});
test('selectedAssignmentLabel', () => {
expect(
selectors.selectedAssignmentLabel({ filters: { assignment: { label: testVal } } }),
).toEqual(testVal);
});
});
});

View File

@@ -1,42 +1,89 @@
/* eslint-disable import/no-self-import */
import { StrictDict } from 'utils';
import { formatDateForDisplay } from '../actions/utils';
import { getFilters } from './filters';
import simpleSelectorFactory from '../utils';
import { EMAIL_HEADING, TOTAL_COURSE_GRADE_HEADING, USERNAME_HEADING } from '../constants/grades';
import * as module from './grades';
const getRowsProcessed = (data) => {
const {
processed_rows: processed,
saved_rows: saved,
total_rows: total,
} = data;
return {
total,
successfullyProcessed: saved,
failed: processed - saved,
skipped: total - processed,
};
};
export const getRowsProcessed = ({
processed_rows: processed,
saved_rows: saved,
total_rows: total,
}) => ({
total,
successfullyProcessed: saved,
failed: processed - saved,
skipped: total - processed,
});
const transformHistoryEntry = (historyRow) => {
const {
modified,
original_filename: originalFilename,
data,
...rest
} = historyRow;
/**
* formatGradeOverrideForDisplay(historyArray)
* returns the grade override history results in display format.
* @param {object[]} historyArray - array of gradeOverrideHistoryResults
* @return {object[]} - display-formatted history results ({ date, grader, reason, adjustedGrade })
*/
export const formatGradeOverrideForDisplay = historyArray => historyArray.map(item => ({
date: formatDateForDisplay(new Date(item.history_date)),
grader: item.history_user,
reason: item.override_reason,
adjustedGrade: item.earned_graded_override,
}));
const timeUploaded = formatDateForDisplay(new Date(modified));
const summaryOfRowsProcessed = getRowsProcessed(data);
export const minGrade = '0';
export const maxGrade = '100';
return {
timeUploaded,
originalFilename,
summaryOfRowsProcessed,
...rest,
};
};
const getBulkManagementHistoryFromState = state => state.grades.bulkManagement.history || [];
const getBulkManagementHistory = state => getBulkManagementHistoryFromState(state).map(transformHistoryEntry);
/**
* formatMaxCourseGrade(percentGrade)
* Takes a percent grade and returns it unless it is equal to the max grade
* @param {string} percentGrade - grade percentage
* @return {string} percent grade or null
*/
export const formatMaxCourseGrade = (percentGrade) => (
(percentGrade === maxGrade) ? null : percentGrade
);
/**
* formatMinCourseGrade(percentGrade)
* Takes a percent grade and returns it unless it is equal to the min grade
* @param {string} percentGrade - grade percentage
* @return {string} percent grade or null
*/
export const formatMinCourseGrade = (percentGrade) => (
(percentGrade === minGrade) ? null : percentGrade
);
const headingMapper = (category, label = 'All') => {
/**
* formatMaxAssignmentGrade(percentGrade, options)
* Takes a percent grade and returns it unless it is equal to the max grade or
* the assignment id is set.
* @param {string} percentGrade - grade percentage
* @param {object} options - options object ({ assignmentId });
* @return {string} percent grade or null
*/
export const formatMaxAssignmentGrade = (percentGrade, options) => (
(percentGrade === maxGrade || !options.assignmentId) ? null : percentGrade
);
/**
* formatMinAssignmentGrade(percentGrade, options)
* Takes a percent grade and returns it unless it is equal to the min grade or
* the assignment id is set.
* @param {string} percentGrade - grade percentage
* @param {object} options - options object ({ assignmentId });
* @return {string} percent grade or null
*/
export const formatMinAssignmentGrade = (percentGrade, options) => (
(percentGrade === minGrade || !options.assignmentId) ? null : percentGrade
);
/**
* headingMapper(category, label='All')
* Takes category and label filters and returns a method that will take a section breakdown
* and return the appropriate table headings.
* @param {string} category - assignment filter type
* @param {string} label - assignment filter label
* @return {string[]} - list of table headers
*/
export const headingMapper = (category, label = 'All') => {
const filters = {
all: section => section.label,
byCategory: section => section.label && section.category === category,
@@ -45,76 +92,141 @@ const headingMapper = (category, label = 'All') => {
let filter;
if (label === 'All') {
filter = category === 'All' ? 'all' : 'byCategory';
filter = category === 'All' ? filters.all : filters.byCategory;
} else {
filter = 'byLabel';
filter = filters.byLabel;
}
return (entry) => {
if (entry) {
const results = ['Username', 'Email'];
const assignmentHeadings = entry
.filter(filters[filter])
.map(s => s.label);
const totals = ['Total'];
return results.concat(assignmentHeadings).concat(totals);
return [
USERNAME_HEADING,
EMAIL_HEADING,
...entry.filter(filter).map(s => s.label),
TOTAL_COURSE_GRADE_HEADING,
];
}
return [];
};
};
const getExampleSectionBreakdown = state => (state.grades.results[0] || {}).section_breakdown || [];
/**
* transformHistoryEntry(rawEntry)
* Takes a raw bulkManagementHistory entry and formats it for consumption
* @param {object} rawEntry - raw history entry to transform
* @return {object} - transformed history entry
* ({ timeUploaded, originalFilename, summaryOfRowsProcessed, ... })
*/
export const transformHistoryEntry = ({
modified,
original_filename: originalFilename,
data,
...rest
}) => ({
timeUploaded: formatDateForDisplay(new Date(modified)),
originalFilename,
summaryOfRowsProcessed: module.getRowsProcessed(data),
...rest,
});
const getHeadings = (state) => {
const filters = getFilters(state) || {};
const {
assignmentType: selectedAssignmentType,
assignment: selectedAssignment,
} = filters;
const assignments = getExampleSectionBreakdown(state);
const type = selectedAssignmentType || 'All';
const assignment = (selectedAssignment || {}).label || 'All';
return headingMapper(type, assignment)(assignments);
};
// Selectors
/**
* allGrades(state)
* returns the top-level redux grades state.
* @param {object} state - redux state
* @return {object} - redux grades state
*/
export const allGrades = ({ grades: { results } }) => results;
const composeFilters = (...predicates) => (percentGrade, options = {}) => predicates.reduce((accum, predicate) => {
if (predicate(percentGrade, options)) {
return null;
}
return accum;
}, percentGrade);
const percentGradeIsMax = percentGrade => (
percentGrade === '100'
/**
* bulkImportError(state)
* returns the stringified bulkManagement import error messages.
* @param {object} state - redux state
* @return {string} - bulk import error messages joined into a display form
* (or empty string if there are none)
*/
export const bulkImportError = ({ grades: { bulkManagement } }) => (
(!!bulkManagement && bulkManagement.errorMessages)
? `Errors while processing: ${bulkManagement.errorMessages.join(', ')}`
: ''
);
const percentGradeIsMin = percentGrade => (
percentGrade === '0'
/**
* bulkManagementHistory(state)
* returns the bulkManagement history entries from the grades state
* @param {object} state - redux state
* @return {object[]} - list of bulkManagement history entries
*/
export const bulkManagementHistory = ({ grades: { bulkManagement } }) => (
(bulkManagement.history || [])
);
const assignmentIdIsDefined = (percentGrade, { assignmentId }) => (
!assignmentId
/**
* bulkManagementHistoryEntries(state)
* returns transformed history entries from bulkManagement grades data.
* @param {object} state - redux state
* @return {object[]} - list of transformed bulkManagement history entries
*/
export const bulkManagementHistoryEntries = (state) => (
module.bulkManagementHistory(state).map(module.transformHistoryEntry)
);
const formatMaxCourseGrade = composeFilters(percentGradeIsMax);
const formatMinCourseGrade = composeFilters(percentGradeIsMin);
const formatMaxAssignmentGrade = composeFilters(
percentGradeIsMax,
assignmentIdIsDefined,
);
const formatMinAssignmentGrade = composeFilters(
percentGradeIsMin,
assignmentIdIsDefined,
/**
* getExampleSectionBreakdown(state)
* returns section breakdown of first grades result.
* @param {object} state - redux state
* @return {object[]} - section breakdown of first grades result.
*/
export const getExampleSectionBreakdown = ({ grades }) => (
(grades.results[0] || {}).section_breakdown || []
);
export {
getBulkManagementHistory,
getHeadings,
/**
* gradeOverrides(state)
* returns the gradeOverride history results
* @param {object} state - redux state
* @return {object[]} - grade override history result entries
*/
export const gradeOverrides = ({ grades }) => grades.gradeOverrideHistoryResults;
/**
* uploadSuccess(state)
* @param {object} state - redux state
* @return {bool} - is bulkManagement.uploadSuccess true?
*/
export const uploadSuccess = ({ grades: { bulkManagement } }) => (
!!bulkManagement && bulkManagement.uploadSuccess
);
const simpleSelectors = simpleSelectorFactory(
({ grades }) => grades,
[
'courseId',
'filteredUsersCount',
'totalUsersCount',
'gradeFormat',
'showSpinner',
'gradeOverrideCurrentEarnedGradedOverride',
'gradeOverrideHistoryError',
'gradeOriginalEarnedGraded',
'gradeOriginalPossibleGraded',
'showSuccess',
],
);
export default StrictDict({
bulkImportError,
formatGradeOverrideForDisplay,
formatMinAssignmentGrade,
formatMaxAssignmentGrade,
formatMaxCourseGrade,
formatMinCourseGrade,
};
headingMapper,
...simpleSelectors,
allGrades,
bulkManagementHistoryEntries,
getExampleSectionBreakdown,
gradeOverrides,
uploadSuccess,
});

View File

@@ -1,72 +1,297 @@
import { getBulkManagementHistory } from './grades';
import { EMAIL_HEADING, TOTAL_COURSE_GRADE_HEADING, USERNAME_HEADING } from '../constants/grades';
import { formatDateForDisplay } from '../actions/utils';
import * as selectors from './grades';
import exportedSelectors from './grades';
const genericHistoryRow = {
id: 5,
class_name: 'bulk_grades.api.GradeCSVProcessor',
unique_id: 'course-v1:google+goog101+2018_spring',
operation: 'commit',
user: 'edx',
modified: '2019-07-16T20:25:46.700802Z',
original_filename: '',
data: {
total_rows: 5,
processed_rows: 3,
saved_rows: 3,
const { minGrade, maxGrade } = selectors;
const genericResultsRows = [
{
attempted: true,
category: 'Homework',
label: 'HW 01',
module_id: 'block-v1:edX+Term+type@sequential+block@1',
percent: 1,
score_earned: 1,
score_possible: 1,
subsection_name: 'Week 1',
},
};
{
attempted: true,
category: 'Homework',
label: 'HW 02',
module_id: 'block-v1:edX+Term+type@sequential+block@2',
percent: 1,
score_earned: 1,
score_possible: 1,
subsection_name: 'Week 2',
},
{
attempted: false,
category: 'Lab',
label: 'Lab 01',
module_id: 'block-v1:edX+Term+type@sequential+block@3',
percent: 0,
score_earned: 0,
score_possible: 0,
subsection_name: 'Week 3',
},
];
describe('getBulkManagementHistory', () => {
it('handles history being as-yet unloaded', () => {
const result = getBulkManagementHistory({ grades: { bulkManagement: {} } });
expect(result).toEqual([]);
});
it('formats dates for us', () => {
const result = getBulkManagementHistory({
grades: {
bulkManagement: {
history: [
genericHistoryRow,
],
},
},
});
const [{ timeUploaded }] = result;
expect(timeUploaded).not.toMatch(/Z$/);
expect(timeUploaded).toContain(' at ');
});
const exerciseGetRowsProcessed = (input, expectation) => {
const result = getBulkManagementHistory({
grades: {
bulkManagement: {
history: [
{ ...genericHistoryRow, data: input },
],
},
},
});
const [{ summaryOfRowsProcessed }] = result;
expect(summaryOfRowsProcessed).toEqual(expect.objectContaining(expectation));
};
it('calculates skippage', () => {
exerciseGetRowsProcessed({
total_rows: 100,
processed_rows: 10,
describe('grades selectors', () => {
// Transformers
describe('getRowsProcessed', () => {
const data = {
processed_rows: 20,
saved_rows: 10,
}, {
skipped: 90,
total_rows: 50,
};
expect(selectors.getRowsProcessed(data)).toEqual({
total: data.total_rows,
successfullyProcessed: data.saved_rows,
failed: data.processed_rows - data.saved_rows,
skipped: data.total_rows - data.processed_rows,
});
});
it('calculates failures', () => {
exerciseGetRowsProcessed({
total_rows: 10,
processed_rows: 100,
saved_rows: 10,
}, {
failed: 90,
describe('grade formatters', () => {
const selectedAssignment = { assignmentId: 'block-v1:edX+type@sequential+block@abcde' };
describe('formatMinAssignmentGrade', () => {
const modifiedGrade = '1';
const selector = selectors.formatMinAssignmentGrade;
it('passes grade through when not min (0) and assignment is supplied', () => {
expect(selector(modifiedGrade, selectedAssignment)).toEqual(modifiedGrade);
});
it('returns null for min grade', () => {
expect(selector(minGrade, selectedAssignment)).toEqual(null);
});
it('returns null when assignment is not supplied', () => {
expect(selector(modifiedGrade, {})).toEqual(null);
});
});
describe('formatMaxAssignmentGrade', () => {
const modifiedGrade = '99';
const selector = selectors.formatMaxAssignmentGrade;
it('passes grade through when not max (100) and assignment is supplied', () => {
expect(selector(modifiedGrade, selectedAssignment)).toEqual(modifiedGrade);
});
it('returns null for max grade', () => {
expect(selector(maxGrade, selectedAssignment)).toEqual(null);
});
it('returns null when assignment is not supplied', () => {
expect(selector(modifiedGrade, {})).toEqual(null);
});
});
describe('formatMinCourseGrade', () => {
const modifiedGrade = '37';
const selector = selectors.formatMinCourseGrade;
it('passes grades through when not min (0) and assignment is supplied', () => {
expect(selector(modifiedGrade, selectedAssignment)).toEqual(modifiedGrade);
});
it('returns null for min grade', () => {
expect(selector(minGrade, selectedAssignment)).toEqual(null);
});
});
describe('formatMaxCourseGrade', () => {
const modifiedGrade = '42';
const selector = selectors.formatMaxCourseGrade;
it('passes grades through when not max and assignment is supplied', () => {
expect(selector(modifiedGrade, selectedAssignment)).toEqual(modifiedGrade);
});
it('returns null for max grade', () => {
expect(selector(maxGrade, selectedAssignment)).toEqual(null);
});
});
});
describe('headingMapper', () => {
const expectedHeaders = (subsectionLabels) => ([
USERNAME_HEADING,
EMAIL_HEADING,
...subsectionLabels,
TOTAL_COURSE_GRADE_HEADING,
]);
const rows = genericResultsRows;
const selector = selectors.headingMapper;
it('creates headers for all assignments when no filtering is applied', () => {
expect(selector('All')(genericResultsRows)).toEqual(
expectedHeaders([rows[0].label, rows[1].label, rows[2].label]),
);
});
it('creates headers for only matching assignment types when type filter is applied', () => {
expect(
selector('Homework')(genericResultsRows),
).toEqual(
expectedHeaders([rows[0].label, rows[1].label]),
);
});
it('creates headers for only matching assignment when label filter is applied', () => {
expect(selector('Homework', rows[1].label)(rows)).toEqual(
expectedHeaders([rows[1].label]),
);
});
it('returns an empty array when no entries are passed', () => {
expect(selector('all')(undefined)).toEqual([]);
});
});
describe('transformHistoryEntry', () => {
let getRowsProcessed;
let output;
const rowsProcessed = ['some', 'fake', 'rows'];
const rawEntry = {
modified: 'Jan 10 2021',
original_filename: 'fileName',
data: { some: 'data' },
also: 'some',
other: 'fields',
};
beforeEach(() => {
getRowsProcessed = selectors.getRowsProcessed;
selectors.getRowsProcessed = jest.fn(data => ({ data, rowsProcessed }));
output = selectors.transformHistoryEntry(rawEntry);
});
afterEach(() => {
selectors.getRowsProcessed = getRowsProcessed;
});
it('transforms modified into timeUploaded', () => {
expect(output.timeUploaded).toEqual(formatDateForDisplay(new Date(rawEntry.modified)));
});
it('forwards filename', () => {
expect(output.originalFilename).toEqual(rawEntry.original_filename);
});
it('summarizes processed rows', () => {
expect(output.summaryOfRowsProcessed).toEqual(selectors.getRowsProcessed(rawEntry.data));
});
});
// Selectors
describe('allGrades', () => {
it('returns the grades results from redux state', () => {
const results = ['some', 'fake', 'results'];
expect(selectors.allGrades({ grades: { results } })).toEqual(results);
});
});
describe('bulkImportError', () => {
it('returns an empty string when bulkManagement not run', () => {
expect(
selectors.bulkImportError({ grades: { bulkManagement: null } }),
).toEqual('');
});
it('returns an empty string when bulkManagement runs without error', () => {
expect(
selectors.bulkImportError({ grades: { bulkManagement: { uploadSuccess: true } } }),
).toEqual('');
});
it('returns error string when bulkManagement encounters an error', () => {
const errorMessages = ['error1', 'also error2'];
expect(
selectors.bulkImportError({ grades: { bulkManagement: { errorMessages } } }),
).toEqual(
`Errors while processing: ${errorMessages[0]}, ${errorMessages[1]}`,
);
});
});
describe('bulkManagementHistory', () => {
const selector = selectors.bulkManagementHistory;
it('returns history entries from grades.bulkManagement in redux store', () => {
const history = ['a', 'few', 'history', 'entries'];
expect(
selector({ grades: { bulkManagement: { history } } }),
).toEqual(history);
});
it('returns an empty list if not set', () => {
expect(
selector({ grades: { bulkManagement: {} } }),
).toEqual([]);
});
});
describe('bulkManagementHistoryEntries', () => {
let bulkManagementHistory;
let transformHistoryEntry;
const listFn = (state) => state.entries;
const mapFn = (entry) => ([entry]);
const entries = ['some', 'entries', 'for', 'testing'];
beforeEach(() => {
bulkManagementHistory = selectors.bulkManagementHistory;
transformHistoryEntry = selectors.transformHistoryEntry;
selectors.bulkManagementHistory = jest.fn(listFn);
selectors.transformHistoryEntry = jest.fn(mapFn);
});
afterEach(() => {
selectors.bulkManagementHistory = bulkManagementHistory;
selectors.transformHistoryEntry = transformHistoryEntry;
});
it('returns history entries mapped to transformer', () => {
expect(
selectors.bulkManagementHistoryEntries({ entries }),
).toEqual(entries.map(mapFn));
});
});
describe('getExampleSectionBreakdown', () => {
const selector = selectors.getExampleSectionBreakdown;
it('returns an empty array when results are unavailable', () => {
expect(selector({ grades: { results: [] } })).toEqual([]);
});
it('returns an empty array when breakdowns are unavailable', () => {
expect(selector({ grades: { results: [{ foo: 'bar' }] } })).toEqual([]);
});
it('gets section breakdown when available', () => {
const sectionBreakdown = { fake: 'section', breakdown: 'data' };
expect(
selector({ grades: { results: [{ section_breakdown: sectionBreakdown }] } }),
).toEqual(sectionBreakdown);
});
});
describe('gradeOverrides', () => {
it('returns grades.gradeOverrideHistoryResults from redux state', () => {
const testVal = 'Temp Test VALUE';
expect(
selectors.gradeOverrides({ grades: { gradeOverrideHistoryResults: testVal } }),
).toEqual(testVal);
});
});
describe('uploadSuccess', () => {
const selector = selectors.uploadSuccess;
it('shows upload success when bulkManagement data returned/completed successfully', () => {
expect(selector({ grades: { bulkManagement: { uploadSuccess: true } } })).toEqual(true);
});
it('returns false when bulk management data not returned', () => {
expect(selector({ grades: {} })).toEqual(false);
});
});
describe('simpleSelectors', () => {
const testVal = 'some TEST value';
const testSimpleSelector = (key) => {
test(key, () => {
expect(
exportedSelectors[key]({ grades: { [key]: testVal } }),
).toEqual(testVal);
});
};
testSimpleSelector('courseId');
testSimpleSelector('filteredUsersCount');
testSimpleSelector('totalUsersCount');
testSimpleSelector('gradeFormat');
testSimpleSelector('showSpinner');
testSimpleSelector('gradeOverrideCurrentEarnedGradedOverride');
testSimpleSelector('gradeOverrideHistoryError');
testSimpleSelector('gradeOriginalEarnedGraded');
testSimpleSelector('gradeOriginalPossibleGraded');
testSimpleSelector('showSuccess');
});
});

115
src/data/selectors/index.js Normal file
View File

@@ -0,0 +1,115 @@
/* eslint-disable import/no-named-as-default-member, import/no-self-import */
import { StrictDict } from 'utils';
import LmsApiService from 'data/services/LmsApiService';
import * as module from '.';
import assignmentTypes from './assignmentTypes';
import cohorts from './cohorts';
import filters from './filters';
import grades from './grades';
import roles from './roles';
import special from './special';
import tracks from './tracks';
/**
* lmsApiServiceArgs(state)
* Returns common lms api service request args.
* @param {object} state - redux state
* @return {object} lms api query params object
*/
export const lmsApiServiceArgs = (state) => ({
cohort: cohorts.getCohortNameById(state, filters.cohort(state)),
assignment: filters.selectedAssignmentId(state),
assignmentType: filters.assignmentType(state),
assignmentGradeMin: grades.formatMinAssignmentGrade(
filters.assignmentGradeMin(state),
{ assignmentId: filters.selectedAssignmentId(state) },
),
assignmentGradeMax: grades.formatMaxAssignmentGrade(
filters.assignmentGradeMax(state),
{ assignmentId: filters.selectedAssignmentId(state) },
),
courseGradeMin: grades.formatMinCourseGrade(filters.courseGradeMin(state)),
courseGradeMax: grades.formatMaxCourseGrade(filters.courseGradeMax(state)),
});
/**
* gradeExportUrl(state, options)
* Returns the output of getGradeExportCsvUrl, applying the current includeCourseRoleMembers
* filter.
* @param {object} state - redux state
* @param {object} options - options object of the form ({ courseId })
* @return {string} - generated grade export url
*/
export const gradeExportUrl = (state, { courseId }) => (
LmsApiService.getGradeExportCsvUrl(courseId, {
...module.lmsApiServiceArgs(state),
excludeCourseRoles: filters.includeCourseRoleMembers(state) ? '' : 'all',
})
);
/**
* interventionExportUrl(state, options)
* Returns the output of getInterventionExportUrl.
* @param {object} state - redux state
* @param {object} options - options object of the form ({ courseId })
* @return {string} - generated intervention export url
*/
export const interventionExportUrl = (state, { courseId }) => (
LmsApiService.getInterventionExportCsvUrl(
courseId,
module.lmsApiServiceArgs(state),
)
);
/**
* getHeadings(state)
* Returns the table headings given the current assignmentType and assignmentLabel filters.
* @param {object} state - redux state
* @return {string[]} - array of table headings
*/
export const getHeadings = (state) => grades.headingMapper(
filters.assignmentType(state) || 'All',
filters.selectedAssignmentLabel(state) || 'All',
)(grades.getExampleSectionBreakdown(state));
/**
* showBulkManagement(state, options)
* Returns true iff the user has special access or bulk management is configured to be available
* and the course has a masters track.
* @param {object} state - redux state
* @param {object} options - options object of the form ({ courseId })
* @return {bool} - should show bulk management controls?
*/
export const showBulkManagement = (state, { courseId }) => (
special.hasSpecialBulkManagementAccess(courseId)
|| (tracks.stateHasMastersTrack(state) && state.config.bulkManagementAvailable)
);
/**
* shouldShowSpinner(state)
* Returns true iff the user can view the gradebook and grades.showSpinner is true.
* @param {object} state - redux state
* @return {bool} - should show spinner?
*/
export const shouldShowSpinner = (state) => (
roles.canUserViewGradebook(state)
&& grades.showSpinner(state)
);
export default StrictDict({
root: {
getHeadings,
gradeExportUrl,
interventionExportUrl,
showBulkManagement,
shouldShowSpinner,
},
assignmentTypes,
cohorts,
filters,
grades,
roles,
special,
tracks,
});

Some files were not shown because too many files have changed in this diff Show More