Compare commits
33 Commits
open-relea
...
v1.4.28
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bc2511cc1 | ||
|
|
f60e3c1188 | ||
|
|
807a57d947 | ||
|
|
0c242ab6f0 | ||
|
|
ee2c573017 | ||
|
|
4fdc541992 | ||
|
|
658b45136e | ||
|
|
61fdb31316 | ||
|
|
93f45d0784 | ||
|
|
6c88291626 | ||
|
|
621c297f1a | ||
|
|
76b349e377 | ||
|
|
d88475aab5 | ||
|
|
ddad9d9513 | ||
|
|
9f7e29ed76 | ||
|
|
539202f511 | ||
|
|
c42a995b11 | ||
|
|
78644daf26 | ||
|
|
7fd38dbcf1 | ||
|
|
62aad2aa2f | ||
|
|
12d32efe08 | ||
|
|
c60358941e | ||
|
|
1345666e53 | ||
|
|
c4bd8dc416 | ||
|
|
83986ea994 | ||
|
|
f891f90f77 | ||
|
|
313840fa10 | ||
|
|
84a7531530 | ||
|
|
27296449b4 | ||
|
|
2b37919222 | ||
|
|
384d6cc296 | ||
|
|
a0943b3946 | ||
|
|
8bc1fc82f2 |
2
.env
2
.env
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
13
.eslintrc.js
13
.eslintrc.js
@@ -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;
|
||||
|
||||
1
.github/pull_request_template.md
vendored
1
.github/pull_request_template.md
vendored
@@ -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)
|
||||
|
||||
|
||||
@@ -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?
|
||||
|
||||
|
||||
100
documentation/testing/test-plan.md
Normal file
100
documentation/testing/test-plan.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# Test Plan
|
||||
|
||||
Designed to be a catalog of major Gradebook workflows to aid in testing. This should be kept up-to-date with new feature changes.
|
||||
|
||||
## Quickstart
|
||||
|
||||
Check that the items below are complete and continue to [Workflow Tests](#workflow-tests). Otherwise, followed the detailed setup in [test-setup.md](./test-setup.md).
|
||||
|
||||
- [ ] Course set up with graded content.
|
||||
- [ ] Gradebook & feature toggles set up for course.
|
||||
- [ ] Course has a Master's track for testing Master's-only features.
|
||||
- [ ] Different types of students enrolled in course (e.g. Master's, TA's).
|
||||
- [ ] Gradebook running.
|
||||
|
||||
## Workflow Tests
|
||||
|
||||
Visit a course as an instructor/staff then **Instructor** tab > **Student Admin** sub-tab > click **Show Gradebook**. Should navigate to `<root-url>:1994/{course-id}`.
|
||||
|
||||
Confirm the following workflows:
|
||||
|
||||
- [ ] Grades table results can be filtered from the "Filter" panel.
|
||||
- The "Edit Filters" button renders for all courses.
|
||||
- Click the "Edit Filters" button to open the "Filter" panel.
|
||||
- [ ] Filter panel shows the sections: Assignments, Overall Grade, Student Groups, Include Course Team Members.
|
||||
- **Note:** Filters are cumulative and act with other applied filters.
|
||||
- Assignments pane
|
||||
- [ ] Applying the "Assignment Types" filter limits the assignment columns show in the grades table to the selected assignment types.
|
||||
- [ ] Applying an "Assignment" filter shows only the selected assignment column in the grades table.
|
||||
- [ ] With an "Assignment" filter already selected, setting a "Min/Max Grade" filter shows only student rows with grades for the assignment within the filtered range.
|
||||
- Overall Grade pane
|
||||
- [ ] Applying a "Min/Max Grade" filter shows only students with Total Course Grades within the filtered range.
|
||||
- Student Groups pane
|
||||
- [ ] Applying a "Tracks" filter shows only student rows matching the selected track.
|
||||
- [ ] Applying a "Cohorts" filter shows only student rows matching the selected cohort.
|
||||
- Include Course Team Members pane
|
||||
- By default, any user with a course role (e.g. staff, beta testers, TA's) are hidden from the grades table.
|
||||
- [ ] Selecting "Include Course Team Members" shows course team members in the grades table.
|
||||
- [ ] Deselecting "Include Course Team Members" shows only students without course roles in the grades table.
|
||||
|
||||
- [ ] Users can be searched/filtered using the Search box.
|
||||
- The Search Box renders for all courses.
|
||||
- [ ] Entering characters into the Search Box filters students on top of already applied filters.
|
||||
- Note: characters can appear anywhere in a name or email, even though emails are only shown for masters-track students. It doesn't appear that search actually works for student keys.
|
||||
|
||||
- [ ] Grades table "Score View" allows selecting how scores are displayed.
|
||||
- [ ] The "Score View" selector renders with the options: Absolute, Percent.
|
||||
- [ ] Changing the "Score View" dropdown to "Percent" shows scores as percentages in the assignment columns (note that scores can be over 100%).
|
||||
- [ ] Changing the "Score View" dropdown to "Absolute" shows scores as {awarded-points}/{possible-points} values, rounded to 2 decimal points.
|
||||
- [ ] For unattempted problems score shows '0'.
|
||||
- [ ] For attempted problems, score always shows an {awarded-points}/{possible-points} value.
|
||||
- [ ] "Total Course Grade" always shows scores as percentages (including 0% for unattempted).
|
||||
|
||||
- [ ] Grades table displays correctly.
|
||||
- [ ] The grades table shows with columns: Username, Email, {numbered-assignments}, Total.
|
||||
- [ ] Usernames appear in the "Username" column.
|
||||
- [ ] Student external keys (where applicable) also appear in the "Username" column.
|
||||
- [ ] Student emails appear in the "Email" column only for masters-track students.
|
||||
- [ ] Assignment scores show in their respective assignment columns.
|
||||
- [ ] Total course grade shows in the "Total Course Grade" column.
|
||||
|
||||
- [ ] Grade overrides can be applied.
|
||||
- [ ] Clicking on an assignment score in the grades table opens the "Edit Grades" modal.
|
||||
- [ ] "Assignment name", "Student username", "Original grade", and "Current grade" display in the modal.
|
||||
- [ ] A history of grade overrides including "Date", "Grader", "Reason", and "Adjusted Grade" shows (if the subsection was previously overridden).
|
||||
- [ ] An entry with the current time appears in the table with areas to enter adjusted grades and reasons for adjusting.
|
||||
- Enter an "Adjusted Grade" and "Reason" for the override.
|
||||
- [ ] Modal can be navigated away from by clicking outside the modal, clicking the "x" button, or hitting "Cancel".
|
||||
- [ ] Clicking "Save Grade" applies the override, shows the successful "grade has been edited" banner and updates score in grades table (may take a few seconds).
|
||||
- [ ] Opening back up the "Edit Grades" modal shows the change as an entry in the override history table.
|
||||
|
||||
- [ ] *Masters only*: "Bulk Management" allows overriding grades in bulk.
|
||||
- Open a non-masters-track course.
|
||||
- [ ] Verify that the "Bulk Management" tab does not appear.
|
||||
- [ ] Verify that the "Bulk Management" button does not appear.
|
||||
- Open a masters-track course.
|
||||
- [ ] Verify that the "Bulk Management" tab appears to the right of the "Grades" tab.
|
||||
- [ ] Verify that the "Bulk Management" button appears.
|
||||
- Click the "Bulk Management" button. This downloads existing student/assignment info.
|
||||
- [ ] Open the downloaded CSV and verify that students and assignments in the file match applied filters/searches.
|
||||
- Add values in the "new_override-{subsection-short-id}" columns for student grades to be overridden and save the CSV file.
|
||||
- [ ] Clicking the "Bulk Management" tab shows the Bulk Management page.
|
||||
- [ ] The bulk management history table appears with columns: "Gradebook", "Download Summary", "Who", "When".
|
||||
- [ ] Previous bulk management imports (if applicable) appear in the table.
|
||||
- Click the "Import Grades" button and select the modified CSV file.
|
||||
- [ ] Verify that the "CSV processing" banner appears.
|
||||
- Wait for processing to complete and reload the page. (Can take seconds to minutes depending on environment and size of the override.)
|
||||
- Navigate back to the "Bulk Management" tab.
|
||||
- [ ] Verify that a new entry appears in the results table indicating how many students were affected by the bulk grade change.
|
||||
- Click the "Download Summary" link to see the summary of changes from the bulk grade changes.
|
||||
- [ ] Verify that students are shown with modified subsections and actions: "No Action" for unchanged users, "Success" for successful overrides.
|
||||
|
||||
- [ ] *Masters only*: Interventions report shows student activity in the course.
|
||||
- Open a non-masters-track course.
|
||||
- [ ] Verify that the "Interventions" tab does not appear.
|
||||
- [ ] Verify that the "Interventions" button does not appear.
|
||||
- Open a masters-track course.
|
||||
- [ ] Verify that the "Interventions" tab appears to the right of the "Grades" tab.
|
||||
- [ ] Verify that the "Interventions" button appears.
|
||||
- Click on the "Interventions" button to generate a CSV students and activity info.
|
||||
- Open the interventions report and verify student info and activity info appear.
|
||||
52
documentation/testing/test-setup.md
Normal file
52
documentation/testing/test-setup.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# Test Setup
|
||||
|
||||
Instructions for setting up environments and data for testing Gradebook.
|
||||
|
||||
## Set up a course with graded content
|
||||
|
||||
A course with graded content is the first prerequisite to testing. Use an existing course (e.g. the DemoX Demonstration Course in Devstack) or see [Building and Running an edX Course > Developing Your Course](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/developing_course/index.html) for notes on how to develop a course from scratch.
|
||||
|
||||
Notably, the course needs a grading policy and subsections with scoreable content.
|
||||
After creating subsections with content, they need to be configured with an "Assignment Type" to be included in grading.
|
||||
|
||||
Suggested resources:
|
||||
- [Establishing a Grading Policy For Your Course](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/grading/index.html)
|
||||
- [Adding Exercises and Tools](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/grading/index.html)
|
||||
- [Set the Assignment Type and Due Date for a Subsection](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/developing_course/course_subsections.html#set-the-assignment-type-and-due-date-for-a-subsection)
|
||||
|
||||
## Enable Gradebook and feature toggles for course
|
||||
|
||||
See README.md #Quickstart for more detailed instructions.
|
||||
|
||||
As an admin user, visit Django Admin (`{lms-url}/admin`) to modify features.
|
||||
- In Grades > Persistent Grades Enabled flag, click "Add persistent grades enabled flag"
|
||||
- [ ] Enable the flag globally or for the course and click "Save"
|
||||
- In Django-Waffle > Switches, click "Add switch"
|
||||
- [ ] Set name to `grades.assume_zero_grade_if_absent`, select "Active", and click "Save"
|
||||
- In Waffle_Utils > Waffle flag course overrides:
|
||||
- [ ] Add a new flag called `grades.writeable_gradebook`, select "Force On", and enable it for your course
|
||||
- [ ] Add a new flag called `grades.bulk_management`, select "Force On", and enable it for your course
|
||||
|
||||
## Create a Master's track for testing Master's-only features
|
||||
|
||||
[source](https://openedx.atlassian.net/wiki/spaces/MS/pages/1453818012/Add+a+learner+into+a+master+s+track)
|
||||
|
||||
Add a Master's track in your course:
|
||||
- As an admin user, go to Django Admin (`{lms-url}/admin`) > Course Modes and add a new course mode
|
||||
- Set the Mode to "Master's"
|
||||
- Set any valid price and currency values
|
||||
- Click "Save"
|
||||
|
||||
Enroll a student in the Master's track:
|
||||
- As a staff/admin user, go to `{lms-url}/support/enrollment`
|
||||
- Search for the username or email of student to enroll
|
||||
- In the results table row matching the user/course, click the "Change Enrollment" button
|
||||
- Select the "Master's" enrollment mode and click "Submit enrollment change"
|
||||
|
||||
## Setup different types of students in course
|
||||
|
||||
To fully test features the course should have at least:
|
||||
- [ ] An audit-track student
|
||||
- [ ] A master's-track student
|
||||
- [ ] A staff member
|
||||
- [ ] A non-staff user
|
||||
11
jest.config.js
Normal file
11
jest.config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
|
||||
module.exports = createConfig('jest', {
|
||||
setupFilesAfterEnv: [
|
||||
'<rootDir>/src/setupTest.js',
|
||||
],
|
||||
modulePaths: ['<rootDir>/src/'],
|
||||
snapshotSerializers: [
|
||||
'enzyme-to-json/serializer',
|
||||
],
|
||||
});
|
||||
7705
package-lock.json
generated
7705
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@edx/frontend-app-gradebook",
|
||||
"version": "1.4.19",
|
||||
"version": "1.4.28",
|
||||
"description": "edx editable gradebook-ui to manipulate grade overrides on subsections",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -29,7 +29,7 @@
|
||||
"@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",
|
||||
@@ -38,6 +38,8 @@
|
||||
"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",
|
||||
@@ -63,12 +65,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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">×</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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
@@ -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 selectors from 'data/selectors';
|
||||
import { configuration } from '../../config';
|
||||
import { submitFileUploadFormData } from '../../data/actions/grades';
|
||||
import { getBulkManagementHistory } from '../../data/selectors/grades';
|
||||
|
||||
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,14 +183,14 @@ 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,
|
||||
|
||||
@@ -10,14 +10,18 @@ import {
|
||||
Table,
|
||||
} from '@edx/paragon';
|
||||
|
||||
import selectors from 'data/selectors';
|
||||
import {
|
||||
doneViewingAssignment,
|
||||
updateGrades,
|
||||
} from '../../data/actions/grades';
|
||||
|
||||
const GRADE_OVERRIDE_HISTORY_COLUMNS = [{ label: 'Date', key: 'date' }, { label: 'Grader', key: 'grader' },
|
||||
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,15 +189,18 @@ 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,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`AssignmentFilter Component snapshots basic snapshot 1`] = `
|
||||
<div
|
||||
className="student-filters"
|
||||
>
|
||||
<SelectGroup
|
||||
disabled={false}
|
||||
id="assignment"
|
||||
label="Assignment"
|
||||
onChange={[MockFunction handleChange]}
|
||||
options={
|
||||
Array [
|
||||
<option
|
||||
value=""
|
||||
>
|
||||
All
|
||||
</option>,
|
||||
<option
|
||||
value="assgN1"
|
||||
>
|
||||
assgN1
|
||||
:
|
||||
subLabel1
|
||||
</option>,
|
||||
<option
|
||||
value="assgN2"
|
||||
>
|
||||
assgN2
|
||||
:
|
||||
subLabel2
|
||||
</option>,
|
||||
]
|
||||
}
|
||||
value="assgN1"
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,104 @@
|
||||
/* eslint-disable react/sort-comp, react/button-has-type */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import * as gradesActions from 'data/actions/grades';
|
||||
import * as filterActions from 'data/actions/filters';
|
||||
import selectors from 'data/selectors';
|
||||
|
||||
import SelectGroup from '../SelectGroup';
|
||||
|
||||
export class AssignmentFilter extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
}
|
||||
|
||||
handleChange(event) {
|
||||
const assignment = event.target.value;
|
||||
const selectedFilterOption = this.props.assignmentFilterOptions.find(assig => assig.label === assignment);
|
||||
const { type, id } = selectedFilterOption || {};
|
||||
const typedValue = { label: assignment, type, id };
|
||||
this.props.updateAssignmentFilter(typedValue);
|
||||
this.props.updateQueryParams({ assignment: id });
|
||||
this.props.updateGradesIfAssignmentGradeFiltersSet(
|
||||
this.props.courseId,
|
||||
this.props.selectedCohort,
|
||||
this.props.selectedTrack,
|
||||
this.props.selectedAssignmentType,
|
||||
);
|
||||
}
|
||||
|
||||
get options() {
|
||||
const mapper = ({ label, subsectionLabel }) => (
|
||||
<option key={label} value={label}>
|
||||
{label}: {subsectionLabel}
|
||||
</option>
|
||||
);
|
||||
return ([
|
||||
<option key="0" value="">All</option>,
|
||||
...this.props.assignmentFilterOptions.map(mapper),
|
||||
]);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="student-filters">
|
||||
<SelectGroup
|
||||
id="assignment"
|
||||
label="Assignment"
|
||||
value={this.props.selectedAssignment}
|
||||
onChange={this.handleChange}
|
||||
disabled={this.props.assignmentFilterOptions.length === 0}
|
||||
options={this.options}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AssignmentFilter.defaultProps = {
|
||||
assignmentFilterOptions: [],
|
||||
selectedAssignment: '',
|
||||
selectedAssignmentType: '',
|
||||
selectedCohort: null,
|
||||
selectedTrack: null,
|
||||
};
|
||||
|
||||
AssignmentFilter.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
updateQueryParams: PropTypes.func.isRequired,
|
||||
|
||||
// redux
|
||||
assignmentFilterOptions: PropTypes.arrayOf(PropTypes.shape({
|
||||
label: PropTypes.string,
|
||||
subsectionLabel: PropTypes.string,
|
||||
type: PropTypes.string,
|
||||
id: PropTypes.string,
|
||||
})),
|
||||
selectedAssignmentType: PropTypes.string,
|
||||
selectedAssignment: PropTypes.string,
|
||||
selectedCohort: PropTypes.string,
|
||||
selectedTrack: PropTypes.string,
|
||||
updateGradesIfAssignmentGradeFiltersSet: PropTypes.func.isRequired,
|
||||
updateAssignmentFilter: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => {
|
||||
const { filters } = selectors;
|
||||
return {
|
||||
assignmentFilterOptions: filters.selectableAssignmentLabels(state),
|
||||
selectedAssignment: filters.selectedAssignmentLabel(state),
|
||||
selectedAssignmentType: filters.assignmentType(state),
|
||||
selectedCohort: filters.cohort(state),
|
||||
selectedTrack: filters.track(state),
|
||||
};
|
||||
};
|
||||
|
||||
export const mapDispatchToProps = {
|
||||
updateAssignmentFilter: filterActions.updateAssignmentFilter,
|
||||
updateGradesIfAssignmentGradeFiltersSet: gradesActions.updateGradesIfAssignmentGradeFiltersSet,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(AssignmentFilter);
|
||||
@@ -0,0 +1,171 @@
|
||||
import React from 'react';
|
||||
import { mount, shallow } from 'enzyme';
|
||||
|
||||
import selectors from 'data/selectors';
|
||||
import { updateAssignmentFilter } from 'data/actions/filters';
|
||||
import { updateGradesIfAssignmentGradeFiltersSet } from 'data/actions/grades';
|
||||
import {
|
||||
AssignmentFilter,
|
||||
mapStateToProps,
|
||||
mapDispatchToProps,
|
||||
} from '.';
|
||||
|
||||
jest.mock('data/selectors', () => ({
|
||||
/** Mocking to use passed state for validation purposes */
|
||||
filters: {
|
||||
selectableAssignmentLabels: jest.fn(() => ([{
|
||||
label: 'assigNment',
|
||||
subsectionLabel: 'subsection',
|
||||
type: 'assignMentType',
|
||||
id: 'subsectionId',
|
||||
}])),
|
||||
selectedAssignmentLabel: jest.fn(() => 'assigNment'),
|
||||
assignmentType: jest.fn(() => 'assignMentType'),
|
||||
cohort: jest.fn(() => 'COhort'),
|
||||
track: jest.fn(() => 'traCK'),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('AssignmentFilter', () => {
|
||||
let props = {
|
||||
courseId: '12345',
|
||||
assignmentFilterOptions: [
|
||||
{
|
||||
label: 'assgN1',
|
||||
subsectionLabel: 'subLabel1',
|
||||
type: 'assgn_Type1',
|
||||
id: 'assgn_iD1',
|
||||
},
|
||||
{
|
||||
label: 'assgN2',
|
||||
subsectionLabel: 'subLabel2',
|
||||
type: 'assgn_Type2',
|
||||
id: 'assgn_iD2',
|
||||
},
|
||||
],
|
||||
selectedAssignmentType: 'assgnFilterLabel1',
|
||||
selectedAssignment: 'assgN1',
|
||||
selectedCohort: 'a cohort',
|
||||
selectedTrack: 'a track',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
props = {
|
||||
...props,
|
||||
updateQueryParams: jest.fn(),
|
||||
updateGradesIfAssignmentGradeFiltersSet: jest.fn(),
|
||||
updateAssignmentFilter: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Component', () => {
|
||||
describe('behavior', () => {
|
||||
describe('handleChange', () => {
|
||||
let el;
|
||||
const newAssgn = 'assgN1';
|
||||
const event = { target: { value: newAssgn } };
|
||||
const selected = props.assignmentFilterOptions[0];
|
||||
beforeEach(() => {
|
||||
el = mount(<AssignmentFilter {...props} />);
|
||||
el.instance().handleChange(event);
|
||||
});
|
||||
|
||||
it('calls props.updateAssignmentFilter with selection', () => {
|
||||
expect(props.updateAssignmentFilter).toHaveBeenCalledWith({
|
||||
label: newAssgn,
|
||||
type: selected.type,
|
||||
id: selected.id,
|
||||
});
|
||||
});
|
||||
it('calls props.updateQueryParams with selected assignment id',
|
||||
() => {
|
||||
expect(props.updateQueryParams).toHaveBeenCalledWith({
|
||||
assignment: selected.id,
|
||||
});
|
||||
});
|
||||
it('calls props.updateGradesIfAssignmentGradeFiltersSet', () => {
|
||||
const method = props.updateGradesIfAssignmentGradeFiltersSet;
|
||||
expect(method).toHaveBeenCalledWith(
|
||||
props.courseId,
|
||||
props.selectedCohort,
|
||||
props.selectedTrack,
|
||||
props.selectedAssignmentType,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('snapshots', () => {
|
||||
test('basic snapshot', () => {
|
||||
const el = shallow(<AssignmentFilter {...props} />);
|
||||
el.instance().handleChange = jest.fn().mockName('handleChange');
|
||||
expect(el.instance().render()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('mapStateToProps', () => {
|
||||
const state = {
|
||||
filters: {
|
||||
assignment: { label: 'assigNment' },
|
||||
assignmentType: 'assignMentType',
|
||||
cohort: 'COhort',
|
||||
track: 'traCK',
|
||||
},
|
||||
};
|
||||
describe('assignmentFilterOptions', () => {
|
||||
it('is selected from filters.selectableAssignmentLabels', () => {
|
||||
expect(
|
||||
mapStateToProps(state).assignmentFilterOptions,
|
||||
).toEqual(
|
||||
selectors.filters.selectableAssignmentLabels(state),
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('selectedAssignment', () => {
|
||||
it('is selected from filters.selectedAssignmentLabel', () => {
|
||||
expect(
|
||||
mapStateToProps(state).selectedAssignment,
|
||||
).toEqual(
|
||||
selectors.filters.selectedAssignmentLabel(state),
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('selectedAssignmentType', () => {
|
||||
it('is selected from filters.assignmentType', () => {
|
||||
expect(
|
||||
mapStateToProps(state).selectedAssignmentType,
|
||||
).toEqual(
|
||||
selectors.filters.assignmentType(state),
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('selectedCohort', () => {
|
||||
it('is selected from filters.cohort', () => {
|
||||
expect(
|
||||
mapStateToProps(state).selectedCohort,
|
||||
).toEqual(
|
||||
selectors.filters.cohort(state),
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('selectedTrack', () => {
|
||||
it('is selected from filters.track', () => {
|
||||
expect(
|
||||
mapStateToProps(state).selectedTrack,
|
||||
).toEqual(
|
||||
selectors.filters.track(state),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('mapDispatchToProps', () => {
|
||||
test('updateAssignmentFilter', () => {
|
||||
expect(mapDispatchToProps.updateAssignmentFilter).toEqual(
|
||||
updateAssignmentFilter,
|
||||
);
|
||||
});
|
||||
test('updateGradesIfAsssignmentGradeFiltersSet', () => {
|
||||
const prop = mapDispatchToProps.updateGradesIfAssignmentGradeFiltersSet;
|
||||
expect(prop).toEqual(updateGradesIfAssignmentGradeFiltersSet);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`AssignmentGradeFilter Component snapshots buttons and groups disabled if no selected assignment 1`] = `
|
||||
<div
|
||||
className="grade-filter-inputs"
|
||||
>
|
||||
<PercentGroup
|
||||
disabled={true}
|
||||
id="assignmentGradeMin"
|
||||
label="Min Grade"
|
||||
onChange={[MockFunction handleSetMin]}
|
||||
value="1"
|
||||
/>
|
||||
<PercentGroup
|
||||
disabled={true}
|
||||
id="assignmentGradeMax"
|
||||
label="Max Grade"
|
||||
onChange={[MockFunction handleSetMax]}
|
||||
value="100"
|
||||
/>
|
||||
<div
|
||||
className="grade-filter-action"
|
||||
>
|
||||
<Button
|
||||
active={false}
|
||||
disabled={true}
|
||||
name="assignmentGradeMinMax"
|
||||
onClick={[MockFunction handleSubmit]}
|
||||
type="submit"
|
||||
variant="outline-secondary"
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`AssignmentGradeFilter Component snapshots smoke test 1`] = `
|
||||
<div
|
||||
className="grade-filter-inputs"
|
||||
>
|
||||
<PercentGroup
|
||||
disabled={false}
|
||||
id="assignmentGradeMin"
|
||||
label="Min Grade"
|
||||
onChange={[MockFunction handleSetMin]}
|
||||
value="1"
|
||||
/>
|
||||
<PercentGroup
|
||||
disabled={false}
|
||||
id="assignmentGradeMax"
|
||||
label="Max Grade"
|
||||
onChange={[MockFunction handleSetMax]}
|
||||
value="100"
|
||||
/>
|
||||
<div
|
||||
className="grade-filter-action"
|
||||
>
|
||||
<Button
|
||||
active={false}
|
||||
disabled={false}
|
||||
name="assignmentGradeMinMax"
|
||||
onClick={[MockFunction handleSubmit]}
|
||||
type="submit"
|
||||
variant="outline-secondary"
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,125 @@
|
||||
/* eslint-disable react/sort-comp, react/button-has-type */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { Button } from '@edx/paragon';
|
||||
|
||||
import * as gradesActions from 'data/actions/grades';
|
||||
import * as filterActions from 'data/actions/filters';
|
||||
import selectors from 'data/selectors';
|
||||
|
||||
import PercentGroup from '../PercentGroup';
|
||||
|
||||
export class AssignmentGradeFilter extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
this.handleSetMax = this.handleSetMax.bind(this);
|
||||
this.handleSetMin = this.handleSetMin.bind(this);
|
||||
}
|
||||
|
||||
handleSubmit() {
|
||||
const {
|
||||
assignmentGradeMin,
|
||||
assignmentGradeMax,
|
||||
} = this.props.filterValues;
|
||||
|
||||
this.props.updateAssignmentLimits(
|
||||
assignmentGradeMin,
|
||||
assignmentGradeMax,
|
||||
);
|
||||
this.props.getUserGrades(
|
||||
this.props.courseId,
|
||||
this.props.selectedCohort,
|
||||
this.props.selectedTrack,
|
||||
this.props.selectedAssignmentType,
|
||||
);
|
||||
this.props.updateQueryParams({
|
||||
assignmentGradeMin,
|
||||
assignmentGradeMax,
|
||||
});
|
||||
}
|
||||
|
||||
handleSetMax(event) {
|
||||
this.props.setFilters({ assignmentGradeMax: event.target.value });
|
||||
}
|
||||
|
||||
handleSetMin(event) {
|
||||
this.props.setFilters({ assignmentGradeMin: event.target.value });
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="grade-filter-inputs">
|
||||
<PercentGroup
|
||||
id="assignmentGradeMin"
|
||||
label="Min Grade"
|
||||
value={this.props.filterValues.assignmentGradeMin}
|
||||
disabled={!this.props.selectedAssignment}
|
||||
onChange={this.handleSetMin}
|
||||
/>
|
||||
<PercentGroup
|
||||
id="assignmentGradeMax"
|
||||
label="Max Grade"
|
||||
value={this.props.filterValues.assignmentGradeMax}
|
||||
disabled={!this.props.selectedAssignment}
|
||||
onChange={this.handleSetMax}
|
||||
/>
|
||||
<div className="grade-filter-action">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline-secondary"
|
||||
name="assignmentGradeMinMax"
|
||||
disabled={!this.props.selectedAssignment}
|
||||
onClick={this.handleSubmit}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AssignmentGradeFilter.defaultProps = {
|
||||
selectedAssignment: '',
|
||||
selectedAssignmentType: '',
|
||||
selectedCohort: null,
|
||||
selectedTrack: null,
|
||||
};
|
||||
|
||||
AssignmentGradeFilter.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
filterValues: PropTypes.shape({
|
||||
assignmentGradeMin: PropTypes.string.isRequired,
|
||||
assignmentGradeMax: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
setFilters: PropTypes.func.isRequired,
|
||||
updateQueryParams: PropTypes.func.isRequired,
|
||||
|
||||
// redux
|
||||
getUserGrades: PropTypes.func.isRequired,
|
||||
selectedAssignmentType: PropTypes.string,
|
||||
selectedAssignment: PropTypes.string,
|
||||
selectedCohort: PropTypes.string,
|
||||
selectedTrack: PropTypes.string,
|
||||
updateAssignmentLimits: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => {
|
||||
const { filters } = selectors;
|
||||
return {
|
||||
selectedAssignment: filters.selectedAssignmentLabel(state),
|
||||
selectedAssignmentType: filters.assignmentType(state),
|
||||
selectedCohort: filters.cohort(state),
|
||||
selectedTrack: filters.track(state),
|
||||
};
|
||||
};
|
||||
|
||||
export const mapDispatchToProps = {
|
||||
getUserGrades: gradesActions.fetchGrades,
|
||||
updateAssignmentLimits: filterActions.updateAssignmentLimits,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(AssignmentGradeFilter);
|
||||
@@ -0,0 +1,174 @@
|
||||
import React from 'react';
|
||||
import { mount, shallow } from 'enzyme';
|
||||
|
||||
import { updateAssignmentLimits } from 'data/actions/filters';
|
||||
import { fetchGrades } from 'data/actions/grades';
|
||||
|
||||
import {
|
||||
AssignmentGradeFilter,
|
||||
mapStateToProps,
|
||||
mapDispatchToProps,
|
||||
} from '.';
|
||||
|
||||
describe('AssignmentGradeFilter', () => {
|
||||
let props = {
|
||||
filterValues: {
|
||||
assignmentGradeMin: '1',
|
||||
assignmentGradeMax: '100',
|
||||
},
|
||||
courseId: '12345',
|
||||
|
||||
selectedAssignmentType: 'assgnFilterLabel1',
|
||||
selectedAssignment: 'assgN1',
|
||||
selectedCohort: 'a cohort',
|
||||
selectedTrack: 'a track',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
props = {
|
||||
...props,
|
||||
setFilters: jest.fn(),
|
||||
updateQueryParams: jest.fn(),
|
||||
getUserGrades: jest.fn(),
|
||||
updateAssignmentLimits: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Component', () => {
|
||||
describe('behavior', () => {
|
||||
describe('handleSubmit', () => {
|
||||
let el;
|
||||
beforeEach(() => {
|
||||
el = mount(<AssignmentGradeFilter {...props} />);
|
||||
el.instance().handleSubmit();
|
||||
});
|
||||
it('calls props.updateAssignmentLimits with min and max', () => {
|
||||
expect(props.updateAssignmentLimits).toHaveBeenCalledWith(
|
||||
props.filterValues.assignmentGradeMin,
|
||||
props.filterValues.assignmentGradeMax,
|
||||
);
|
||||
});
|
||||
it('calls getUserGrades w/ selection', () => {
|
||||
expect(props.getUserGrades).toHaveBeenCalledWith(
|
||||
props.courseId,
|
||||
props.selectedCohort,
|
||||
props.selectedTrack,
|
||||
props.selectedAssignmentType,
|
||||
);
|
||||
});
|
||||
it('updates queryParams with assignment grade min and max', () => {
|
||||
expect(props.updateQueryParams).toHaveBeenCalledWith({
|
||||
assignmentGradeMin: props.filterValues.assignmentGradeMin,
|
||||
assignmentGradeMax: props.filterValues.assignmentGradeMax,
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('handleSetMin', () => {
|
||||
it('calls setFilters for assignmentGradeMin', () => {
|
||||
const testVal = 23;
|
||||
const el = mount(<AssignmentGradeFilter {...props} />);
|
||||
el.instance().handleSetMin({ target: { value: testVal } });
|
||||
expect(props.setFilters).toHaveBeenCalledWith({
|
||||
assignmentGradeMin: testVal,
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('handleSetMax', () => {
|
||||
it('calls setFilters for assignmentGradeMax', () => {
|
||||
const testVal = 92;
|
||||
const el = mount(<AssignmentGradeFilter {...props} />);
|
||||
el.instance().handleSetMax({ target: { value: testVal } });
|
||||
expect(props.setFilters).toHaveBeenCalledWith({
|
||||
assignmentGradeMax: testVal,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('snapshots', () => {
|
||||
let el;
|
||||
const mockMethods = () => {
|
||||
el.instance().handleSubmit = jest.fn().mockName('handleSubmit');
|
||||
el.instance().handleSetMax = jest.fn().mockName('handleSetMax');
|
||||
el.instance().handleSetMin = jest.fn().mockName('handleSetMin');
|
||||
};
|
||||
test('smoke test', () => {
|
||||
el = shallow(<AssignmentGradeFilter {...props} />);
|
||||
mockMethods(el);
|
||||
expect(el.instance().render()).toMatchSnapshot();
|
||||
});
|
||||
test('buttons and groups disabled if no selected assignment', () => {
|
||||
el = shallow(<AssignmentGradeFilter
|
||||
{...props}
|
||||
selectedAssignment={undefined}
|
||||
/>);
|
||||
mockMethods(el);
|
||||
expect(el.instance().render()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('mapStateToProps', () => {
|
||||
const state = {
|
||||
filters: {
|
||||
assignment: { label: 'assigNment' },
|
||||
assignmentType: 'assignMentType',
|
||||
cohort: 'COhort',
|
||||
track: 'traCK',
|
||||
},
|
||||
};
|
||||
describe('selectedAsssignment', () => {
|
||||
it('is undefined if no assignment is passed', () => {
|
||||
expect(
|
||||
mapStateToProps({ filters: {} }).selectedAssignment,
|
||||
).toEqual(undefined);
|
||||
});
|
||||
it('returns the label of selected assignment if there is one', () => {
|
||||
expect(
|
||||
mapStateToProps(state).selectedAssignment,
|
||||
).toEqual(
|
||||
state.filters.assignment.label,
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('selectedAssignmentType', () => {
|
||||
it('is drawn from state.filters.assignmentType', () => {
|
||||
expect(
|
||||
mapStateToProps(state).selectedAssignmentType,
|
||||
).toEqual(
|
||||
state.filters.assignmentType,
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('selectedCohort', () => {
|
||||
it('is drawn from state.filters.cohort', () => {
|
||||
expect(
|
||||
mapStateToProps(state).selectedCohort,
|
||||
).toEqual(
|
||||
state.filters.cohort,
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('selectedTrack', () => {
|
||||
it('is drawn from state.filters.track', () => {
|
||||
expect(
|
||||
mapStateToProps(state).selectedTrack,
|
||||
).toEqual(
|
||||
state.filters.track,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('mapDispatchToProps', () => {
|
||||
test('getUserGrades', () => {
|
||||
expect(mapDispatchToProps.getUserGrades).toEqual(
|
||||
fetchGrades,
|
||||
);
|
||||
});
|
||||
test('updateAssignmentLimits', () => {
|
||||
expect(
|
||||
mapDispatchToProps.updateAssignmentLimits,
|
||||
).toEqual(
|
||||
updateAssignmentLimits,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`AssignmentTypeFilter Component snapshots SelectGroup disabled if no assignmentFilterOptions 1`] = `
|
||||
<div
|
||||
className="student-filters"
|
||||
>
|
||||
<SelectGroup
|
||||
disabled={true}
|
||||
id="assignment-types"
|
||||
label="Assignment Types"
|
||||
onChange={[MockFunction handleChange]}
|
||||
options={
|
||||
Array [
|
||||
<option
|
||||
value=""
|
||||
>
|
||||
All
|
||||
</option>,
|
||||
<option
|
||||
value="assignMentType1"
|
||||
>
|
||||
assignMentType1
|
||||
</option>,
|
||||
<option
|
||||
value="AssigNmentType2"
|
||||
>
|
||||
AssigNmentType2
|
||||
</option>,
|
||||
]
|
||||
}
|
||||
value="assigNmentType2"
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`AssignmentTypeFilter Component snapshots smoke test 1`] = `
|
||||
<div
|
||||
className="student-filters"
|
||||
>
|
||||
<SelectGroup
|
||||
disabled={false}
|
||||
id="assignment-types"
|
||||
label="Assignment Types"
|
||||
onChange={[MockFunction handleChange]}
|
||||
options={
|
||||
Array [
|
||||
<option
|
||||
value=""
|
||||
>
|
||||
All
|
||||
</option>,
|
||||
<option
|
||||
value="assignMentType1"
|
||||
>
|
||||
assignMentType1
|
||||
</option>,
|
||||
<option
|
||||
value="AssigNmentType2"
|
||||
>
|
||||
AssigNmentType2
|
||||
</option>,
|
||||
]
|
||||
}
|
||||
value="assigNmentType2"
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,78 @@
|
||||
/* eslint-disable react/sort-comp, react/button-has-type */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import * as gradesActions from 'data/actions/grades';
|
||||
import selectors from 'data/selectors';
|
||||
|
||||
import SelectGroup from '../SelectGroup';
|
||||
|
||||
export class AssignmentTypeFilter extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
}
|
||||
|
||||
handleChange(event) {
|
||||
const assignmentType = event.target.value;
|
||||
this.props.filterAssignmentType(assignmentType);
|
||||
this.props.updateQueryParams({ assignmentType });
|
||||
}
|
||||
|
||||
get options() {
|
||||
const mapper = (entry) => (
|
||||
<option key={entry} value={entry}>{entry}</option>
|
||||
);
|
||||
return [
|
||||
<option key="0" value="">All</option>,
|
||||
...this.props.assignmentTypes.map(mapper),
|
||||
];
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="student-filters">
|
||||
<SelectGroup
|
||||
id="assignment-types"
|
||||
label="Assignment Types"
|
||||
value={this.props.selectedAssignmentType}
|
||||
onChange={this.handleChange}
|
||||
disabled={this.props.assignmentFilterOptions.length === 0}
|
||||
options={this.options}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AssignmentTypeFilter.defaultProps = {
|
||||
assignmentTypes: [],
|
||||
assignmentFilterOptions: [],
|
||||
selectedAssignmentType: '',
|
||||
};
|
||||
|
||||
AssignmentTypeFilter.propTypes = {
|
||||
updateQueryParams: PropTypes.func.isRequired,
|
||||
|
||||
// redux
|
||||
assignmentTypes: PropTypes.arrayOf(PropTypes.string),
|
||||
assignmentFilterOptions: PropTypes.arrayOf(PropTypes.shape({
|
||||
label: PropTypes.string,
|
||||
subsectionLabel: PropTypes.string,
|
||||
})),
|
||||
filterAssignmentType: PropTypes.func.isRequired,
|
||||
selectedAssignmentType: PropTypes.string,
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => ({
|
||||
assignmentTypes: selectors.assignmentTypes.allAssignmentTypes(state),
|
||||
assignmentFilterOptions: selectors.filters.selectableAssignmentLabels(state),
|
||||
selectedAssignmentType: selectors.filters.assignmentType(state),
|
||||
});
|
||||
|
||||
export const mapDispatchToProps = {
|
||||
filterAssignmentType: gradesActions.filterAssignmentType,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(AssignmentTypeFilter);
|
||||
@@ -0,0 +1,135 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import selectors from 'data/selectors';
|
||||
import { filterAssignmentType } from 'data/actions/grades';
|
||||
|
||||
import {
|
||||
AssignmentTypeFilter,
|
||||
mapStateToProps,
|
||||
mapDispatchToProps,
|
||||
} from '.';
|
||||
|
||||
jest.mock('data/selectors', () => ({
|
||||
/** Mocking to use passed state for validation purposes */
|
||||
assignmentTypes: {
|
||||
allAssignmentTypes: jest.fn(() => (['assignment', 'labs'])),
|
||||
},
|
||||
filters: {
|
||||
selectableAssignmentLabels: jest.fn(() => ([{
|
||||
label: 'assigNment',
|
||||
subsectionLabel: 'subsection',
|
||||
type: 'assignMentType',
|
||||
id: 'subsectionId',
|
||||
}])),
|
||||
assignmentType: jest.fn(() => 'assignMentType'),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('AssignmentTypeFilter', () => {
|
||||
let props = {
|
||||
assignmentTypes: ['assignMentType1', 'AssigNmentType2'],
|
||||
assignmentFilterOptions: [
|
||||
{ label: 'filterLabel1', subsectionLabel: 'filterSubLabel2' },
|
||||
{ label: 'filterLabel2', subsectionLabel: 'filterSubLabel1' },
|
||||
],
|
||||
selectedAssignmentType: 'assigNmentType2',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
props = {
|
||||
...props,
|
||||
filterAssignmentType: jest.fn(),
|
||||
updateQueryParams: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Component', () => {
|
||||
describe('behavior', () => {
|
||||
describe('handleChange', () => {
|
||||
let el;
|
||||
const newType = 'new Type';
|
||||
const event = { target: { value: newType } };
|
||||
beforeEach(() => {
|
||||
el = shallow(<AssignmentTypeFilter {...props} />);
|
||||
el.instance().handleChange(event);
|
||||
});
|
||||
it('calls props.filterAssignmentType with new type', () => {
|
||||
expect(props.filterAssignmentType).toHaveBeenCalledWith(
|
||||
newType,
|
||||
);
|
||||
});
|
||||
it('updates queryParams with assignmentType', () => {
|
||||
expect(props.updateQueryParams).toHaveBeenCalledWith({
|
||||
assignmentType: newType,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('snapshots', () => {
|
||||
let el;
|
||||
const mockMethods = () => {
|
||||
el.instance().handleChange = jest.fn().mockName('handleChange');
|
||||
};
|
||||
test('smoke test', () => {
|
||||
el = shallow(<AssignmentTypeFilter {...props} />);
|
||||
mockMethods(el);
|
||||
expect(el.instance().render()).toMatchSnapshot();
|
||||
});
|
||||
test('SelectGroup disabled if no assignmentFilterOptions', () => {
|
||||
el = shallow(<AssignmentTypeFilter
|
||||
{...props}
|
||||
assignmentFilterOptions={[]}
|
||||
/>);
|
||||
mockMethods(el);
|
||||
expect(el.instance().render()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('mapStateToProps', () => {
|
||||
const state = {
|
||||
assignmentTypes: {
|
||||
results: ['assignMentType1', 'assignMentType2'],
|
||||
},
|
||||
filters: {
|
||||
assignmentType: 'selectedAssignMent',
|
||||
cohort: 'selectedCOHOrt',
|
||||
track: 'SELectedTrack',
|
||||
},
|
||||
};
|
||||
describe('assignmentTypes', () => {
|
||||
it('is selected from assignmentTypes.allAssignmentTypes', () => {
|
||||
expect(
|
||||
mapStateToProps(state).assignmentTypes,
|
||||
).toEqual(
|
||||
selectors.assignmentTypes.allAssignmentTypes(state),
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('assignmentFilterOptions', () => {
|
||||
it('is selected from filters.selectableAssignmentLabels', () => {
|
||||
expect(
|
||||
mapStateToProps(state).assignmentFilterOptions,
|
||||
).toEqual(
|
||||
selectors.filters.selectableAssignmentLabels(state),
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('selectedAssignmentType', () => {
|
||||
it('is selected from filters.assignmentType', () => {
|
||||
expect(
|
||||
mapStateToProps(state).selectedAssignmentType,
|
||||
).toEqual(
|
||||
selectors.filters.assignmentType(state),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('mapDispatchToProps', () => {
|
||||
test('filterAssignmentType', () => {
|
||||
expect(mapDispatchToProps.filterAssignmentType).toEqual(
|
||||
filterAssignmentType,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`CourseGradeFilter Component snapshots basic snapshot 1`] = `
|
||||
<React.Fragment>
|
||||
<div
|
||||
className="grade-filter-inputs"
|
||||
>
|
||||
<PercentGroup
|
||||
disabled={false}
|
||||
id="minimum-grade"
|
||||
label="Min Grade"
|
||||
onChange={[MockFunction handleUpdateMin]}
|
||||
value="5"
|
||||
/>
|
||||
<PercentGroup
|
||||
disabled={false}
|
||||
id="maximum-grade"
|
||||
label="Max Grade"
|
||||
onChange={[MockFunction handleUpdateMax]}
|
||||
value="92"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="grade-filter-action"
|
||||
>
|
||||
<Button
|
||||
onClick={[MockFunction handleApplyClick]}
|
||||
variant="outline-secondary"
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
`;
|
||||
@@ -0,0 +1,136 @@
|
||||
/* eslint-disable react/sort-comp, react/button-has-type, import/no-named-as-default */
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
Button,
|
||||
} from '@edx/paragon';
|
||||
|
||||
import { updateCourseGradeFilter } from 'data/actions/filters';
|
||||
import { fetchGrades } from 'data/actions/grades';
|
||||
import selectors from 'data/selectors';
|
||||
import PercentGroup from '../PercentGroup';
|
||||
|
||||
export class CourseGradeFilter extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.handleApplyClick = this.handleApplyClick.bind(this);
|
||||
this.handleUpdateMin = this.handleUpdateMin.bind(this);
|
||||
this.handleUpdateMax = this.handleUpdateMax.bind(this);
|
||||
this.updateCourseGradeFilters = this.updateCourseGradeFilters.bind(this);
|
||||
}
|
||||
|
||||
handleApplyClick() {
|
||||
const { courseGradeMin, courseGradeMax } = this.props.filterValues;
|
||||
const isMinValid = this.isGradeFilterValueInRange(courseGradeMin);
|
||||
const isMaxValid = this.isGradeFilterValueInRange(courseGradeMax);
|
||||
|
||||
this.props.setFilters({
|
||||
isMinCourseGradeFilterValid: isMinValid,
|
||||
isMaxCourseGradeFilterValid: isMaxValid,
|
||||
});
|
||||
|
||||
if (isMinValid && isMaxValid) {
|
||||
this.updateCourseGradeFilters();
|
||||
}
|
||||
}
|
||||
|
||||
updateCourseGradeFilters() {
|
||||
const { courseGradeMin, courseGradeMax } = this.props.filterValues;
|
||||
this.props.updateFilter(
|
||||
courseGradeMin,
|
||||
courseGradeMax,
|
||||
this.props.courseId,
|
||||
);
|
||||
this.props.getUserGrades(
|
||||
this.props.courseId,
|
||||
this.props.selectedCohort,
|
||||
this.props.selectedTrack,
|
||||
this.props.selectedAssignmentType,
|
||||
{ courseGradeMin, courseGradeMax },
|
||||
);
|
||||
this.props.updateQueryParams({ courseGradeMin, courseGradeMax });
|
||||
}
|
||||
|
||||
handleUpdateMin(event) {
|
||||
this.props.setFilters({ courseGradeMin: event.target.value });
|
||||
}
|
||||
|
||||
handleUpdateMax(event) {
|
||||
this.props.setFilters({ courseGradeMax: event.target.value });
|
||||
}
|
||||
|
||||
isGradeFilterValueInRange = (value) => {
|
||||
const valueAsInt = parseInt(value, 10);
|
||||
return valueAsInt >= 0 && valueAsInt <= 100;
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<>
|
||||
<div className="grade-filter-inputs">
|
||||
<PercentGroup
|
||||
id="minimum-grade"
|
||||
label="Min Grade"
|
||||
value={this.props.filterValues.courseGradeMin}
|
||||
onChange={this.handleUpdateMin}
|
||||
/>
|
||||
<PercentGroup
|
||||
id="maximum-grade"
|
||||
label="Max Grade"
|
||||
value={this.props.filterValues.courseGradeMax}
|
||||
onChange={this.handleUpdateMax}
|
||||
/>
|
||||
</div>
|
||||
<div className="grade-filter-action">
|
||||
<Button
|
||||
variant="outline-secondary"
|
||||
onClick={this.handleApplyClick}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CourseGradeFilter.defaultProps = {
|
||||
courseId: '',
|
||||
selectedAssignmentType: '',
|
||||
selectedCohort: null,
|
||||
selectedTrack: null,
|
||||
};
|
||||
|
||||
CourseGradeFilter.propTypes = {
|
||||
courseId: PropTypes.string,
|
||||
filterValues: PropTypes.shape({
|
||||
courseGradeMin: PropTypes.string.isRequired,
|
||||
courseGradeMax: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
setFilters: PropTypes.func.isRequired,
|
||||
updateQueryParams: PropTypes.func.isRequired,
|
||||
|
||||
// Redux
|
||||
getUserGrades: PropTypes.func.isRequired,
|
||||
selectedAssignmentType: PropTypes.string,
|
||||
selectedCohort: PropTypes.string,
|
||||
selectedTrack: PropTypes.string,
|
||||
updateFilter: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => {
|
||||
const { filters } = selectors;
|
||||
return {
|
||||
selectedCohort: filters.cohort(state),
|
||||
selectedTrack: filters.track(state),
|
||||
selectedAssignmentType: filters.assignmentType(state),
|
||||
};
|
||||
};
|
||||
|
||||
export const mapDispatchToProps = {
|
||||
updateFilter: updateCourseGradeFilter,
|
||||
getUserGrades: fetchGrades,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(CourseGradeFilter);
|
||||
@@ -0,0 +1,196 @@
|
||||
/* eslint-disable import/no-named-as-default */
|
||||
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { updateCourseGradeFilter } from 'data/actions/filters';
|
||||
import { fetchGrades } from 'data/actions/grades';
|
||||
import {
|
||||
CourseGradeFilter,
|
||||
mapStateToProps,
|
||||
mapDispatchToProps,
|
||||
} from '.';
|
||||
|
||||
jest.mock('@edx/paragon', () => ({
|
||||
Button: 'Button',
|
||||
Collapsible: 'Collapsible',
|
||||
}));
|
||||
|
||||
describe('CourseGradeFilter', () => {
|
||||
let props = {
|
||||
filterValues: {
|
||||
courseGradeMin: '5',
|
||||
courseGradeMax: '92',
|
||||
},
|
||||
courseId: '12345',
|
||||
selectedAssignmentType: 'assignMent type 1',
|
||||
selectedCohort: 'COHort',
|
||||
selectedTrack: 'TracK',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
props = {
|
||||
...props,
|
||||
getUserGrades: jest.fn(),
|
||||
setFilters: jest.fn(),
|
||||
updateQueryParams: jest.fn(),
|
||||
updateFilter: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Component', () => {
|
||||
describe('snapshots', () => {
|
||||
test('basic snapshot', () => {
|
||||
const el = shallow(<CourseGradeFilter {...props} />);
|
||||
el.instance().handleUpdateMin = jest.fn().mockName(
|
||||
'handleUpdateMin',
|
||||
);
|
||||
el.instance().handleUpdateMax = jest.fn().mockName(
|
||||
'handleUpdateMax',
|
||||
);
|
||||
el.instance().handleApplyClick = jest.fn().mockName(
|
||||
'handleApplyClick',
|
||||
);
|
||||
expect(el.instance().render()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe('behavior', () => {
|
||||
let el;
|
||||
const testVal = 'TESTvalue';
|
||||
beforeEach(() => {
|
||||
el = shallow(<CourseGradeFilter {...props} />);
|
||||
});
|
||||
describe('handleApplyClick', () => {
|
||||
beforeEach(() => {
|
||||
el.instance().updateCourseGradeFilters = jest.fn();
|
||||
});
|
||||
it('calls setFilters for isMin(Max)CourseGradeFilterValid', () => {
|
||||
el.instance().isGradeFilterValueInRange = jest.fn().mockImplementation(v => v >= 50);
|
||||
el.instance().handleApplyClick();
|
||||
expect(props.setFilters).toHaveBeenCalledWith({
|
||||
isMinCourseGradeFilterValid: false,
|
||||
isMaxCourseGradeFilterValid: true,
|
||||
});
|
||||
});
|
||||
it('calls updateCourseGradeFilters only if both min and max are valid', () => {
|
||||
const isValid = jest.fn().mockImplementation(v => v >= 50);
|
||||
el.instance().isGradeFilterValueInRange = isValid;
|
||||
el.instance().handleApplyClick();
|
||||
expect(el.instance().updateCourseGradeFilters).not.toHaveBeenCalled();
|
||||
isValid.mockImplementation(v => v <= 50);
|
||||
el.instance().handleApplyClick();
|
||||
expect(el.instance().updateCourseGradeFilters).not.toHaveBeenCalled();
|
||||
isValid.mockImplementation(v => v >= 0);
|
||||
el.instance().handleApplyClick();
|
||||
expect(el.instance().updateCourseGradeFilters).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
describe('updateCourseGradeFilters', () => {
|
||||
beforeEach(() => {
|
||||
el.instance().updateCourseGradeFilters();
|
||||
});
|
||||
it('calls props.updateFilter with selection', () => {
|
||||
expect(props.updateFilter).toHaveBeenCalledWith(
|
||||
props.filterValues.courseGradeMin,
|
||||
props.filterValues.courseGradeMax,
|
||||
props.courseId,
|
||||
);
|
||||
});
|
||||
it('calls props.getUserGrades with selection', () => {
|
||||
expect(props.getUserGrades).toHaveBeenCalledWith(
|
||||
props.courseId,
|
||||
props.selectedCohort,
|
||||
props.selectedTrack,
|
||||
props.selectedAssignmentType,
|
||||
{
|
||||
courseGradeMin: props.filterValues.courseGradeMin,
|
||||
courseGradeMax: props.filterValues.courseGradeMax,
|
||||
},
|
||||
);
|
||||
});
|
||||
it('updates query params with courseGradeMin and courseGradeMax', () => {
|
||||
expect(props.updateQueryParams).toHaveBeenCalledWith({
|
||||
courseGradeMin: props.filterValues.courseGradeMin,
|
||||
courseGradeMax: props.filterValues.courseGradeMax,
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('handleUpdateMin', () => {
|
||||
it('calls props.setCourseGradeMin with event value', () => {
|
||||
el.instance().handleUpdateMin(
|
||||
{ target: { value: testVal } },
|
||||
);
|
||||
expect(props.setFilters).toHaveBeenCalledWith({
|
||||
courseGradeMin: testVal,
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('handleUpdateMax', () => {
|
||||
it('calls props.setCourseGradeMax with event value', () => {
|
||||
el.instance().handleUpdateMax(
|
||||
{ target: { value: testVal } },
|
||||
);
|
||||
expect(props.setFilters).toHaveBeenCalledWith({
|
||||
courseGradeMax: testVal,
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('isFilterValueInRange', () => {
|
||||
it('returns true for values between 0 and 100', () => {
|
||||
expect(el.instance().isGradeFilterValueInRange('0')).toEqual(true);
|
||||
expect(el.instance().isGradeFilterValueInRange(1.1)).toEqual(true);
|
||||
expect(el.instance().isGradeFilterValueInRange('43')).toEqual(true);
|
||||
expect(el.instance().isGradeFilterValueInRange(98.6)).toEqual(true);
|
||||
expect(el.instance().isGradeFilterValueInRange(100)).toEqual(true);
|
||||
});
|
||||
it('returns false for values below 0 and above 100', () => {
|
||||
expect(el.instance().isGradeFilterValueInRange(-1)).toEqual(false);
|
||||
expect(el.instance().isGradeFilterValueInRange(101)).toEqual(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('mapStateToProps', () => {
|
||||
const state = {
|
||||
filters: {
|
||||
cohort: 'COHort',
|
||||
track: 'TRacK',
|
||||
assignmentType: 'TYPe',
|
||||
},
|
||||
};
|
||||
describe('selectedAssignmentType', () => {
|
||||
test('drawn from filters.assignmentType', () => {
|
||||
expect(mapStateToProps(state).selectedAssignmentType).toEqual(
|
||||
state.filters.assignmentType,
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('selectedCohort', () => {
|
||||
test('drawn from filters.cohort', () => {
|
||||
expect(mapStateToProps(state).selectedCohort).toEqual(
|
||||
state.filters.cohort,
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('selectedTrack', () => {
|
||||
test('drawn from filters.track', () => {
|
||||
expect(mapStateToProps(state).selectedTrack).toEqual(
|
||||
state.filters.track,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('mapDispatchToProps', () => {
|
||||
describe('updateFilter', () => {
|
||||
test('from updateCourseGradeFilter', () => {
|
||||
expect(mapDispatchToProps.updateFilter).toEqual(updateCourseGradeFilter);
|
||||
});
|
||||
});
|
||||
describe('getUserGrades', () => {
|
||||
test('from fetchGrades', () => {
|
||||
expect(mapDispatchToProps.getUserGrades).toEqual(fetchGrades);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
39
src/components/Gradebook/GradebookFilters/PercentGroup.jsx
Normal file
39
src/components/Gradebook/GradebookFilters/PercentGroup.jsx
Normal file
@@ -0,0 +1,39 @@
|
||||
/* eslint-disable react/sort-comp */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Form } from '@edx/paragon';
|
||||
|
||||
const PercentGroup = ({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
disabled,
|
||||
onChange,
|
||||
}) => (
|
||||
<div className="percent-group">
|
||||
<Form.Group controlId={id}>
|
||||
<Form.Label>{label}</Form.Label>
|
||||
<Form.Control
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
{...{ value, disabled, onChange }}
|
||||
/>
|
||||
</Form.Group>
|
||||
<span className="input-percent-label">%</span>
|
||||
</div>
|
||||
);
|
||||
PercentGroup.defaultProps = {
|
||||
disabled: false,
|
||||
};
|
||||
PercentGroup.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
label: PropTypes.string.isRequired,
|
||||
value: PropTypes.string.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
disabled: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default PercentGroup;
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import PercentGroup from './PercentGroup';
|
||||
|
||||
describe('PercentGroup', () => {
|
||||
let props = {
|
||||
id: 'group id',
|
||||
label: 'Group Label',
|
||||
value: 'group VALUE',
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
props = {
|
||||
...props,
|
||||
onChange: jest.fn().mockName('props.onChange'),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Component', () => {
|
||||
describe('snapshots', () => {
|
||||
test('basic snapshot', () => {
|
||||
const el = shallow(<PercentGroup {...props} />);
|
||||
expect(el).toMatchSnapshot();
|
||||
});
|
||||
test('disabled', () => {
|
||||
const el = shallow(<PercentGroup {...props} disabled />);
|
||||
expect(el).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
36
src/components/Gradebook/GradebookFilters/SelectGroup.jsx
Normal file
36
src/components/Gradebook/GradebookFilters/SelectGroup.jsx
Normal file
@@ -0,0 +1,36 @@
|
||||
/* eslint-disable react/sort-comp, react/button-has-type */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Form } from '@edx/paragon';
|
||||
|
||||
const SelectGroup = ({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
options,
|
||||
}) => (
|
||||
<div className="student-filters">
|
||||
<Form.Group controlId={id}>
|
||||
<Form.Label>{label}</Form.Label>
|
||||
<Form.Control as="select" {...{ value, onChange, disabled }}>
|
||||
{options}
|
||||
</Form.Control>
|
||||
</Form.Group>
|
||||
</div>
|
||||
);
|
||||
SelectGroup.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
label: PropTypes.string.isRequired,
|
||||
value: PropTypes.string.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
disabled: PropTypes.bool,
|
||||
options: PropTypes.arrayOf(PropTypes.node).isRequired,
|
||||
};
|
||||
SelectGroup.defaultProps = {
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
export default SelectGroup;
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import SelectGroup from './SelectGroup';
|
||||
|
||||
describe('SelectGroup', () => {
|
||||
let props = {
|
||||
id: 'group id',
|
||||
label: 'Group Label',
|
||||
value: 'group VALUE',
|
||||
disabled: false,
|
||||
options: [
|
||||
<option value="opt1" key="opt1">Option 1</option>,
|
||||
<option value="opt2" key="opt2">Option 2</option>,
|
||||
<option value="opt3" key="opt3">Option 3</option>,
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
props = {
|
||||
...props,
|
||||
onChange: jest.fn().mockName('props.onChange'),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Component', () => {
|
||||
describe('snapshots', () => {
|
||||
test('basic snapshot', () => {
|
||||
const el = shallow(<SelectGroup {...props} />);
|
||||
expect(el).toMatchSnapshot();
|
||||
});
|
||||
test('disabled', () => {
|
||||
const el = shallow(<SelectGroup {...props} disabled />);
|
||||
expect(el).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`StudentGroupsFilter Component snapshots Cohorts group disabled if no cohorts 1`] = `
|
||||
<React.Fragment>
|
||||
<SelectGroup
|
||||
disabled={false}
|
||||
id="Tracks"
|
||||
label="Tracks"
|
||||
onChange={[Function]}
|
||||
options={
|
||||
Array [
|
||||
<option
|
||||
value="Track-All"
|
||||
>
|
||||
Track-All
|
||||
</option>,
|
||||
<option
|
||||
value="TracK1"
|
||||
>
|
||||
TracK1
|
||||
</option>,
|
||||
<option
|
||||
value="TracK2"
|
||||
>
|
||||
TracK2
|
||||
</option>,
|
||||
<option
|
||||
value="TRACK3"
|
||||
>
|
||||
TRACK3
|
||||
</option>,
|
||||
]
|
||||
}
|
||||
value="TracK2"
|
||||
/>
|
||||
<SelectGroup
|
||||
disabled={true}
|
||||
id="Cohorts"
|
||||
label="Cohorts"
|
||||
onChange={[Function]}
|
||||
options={
|
||||
Array [
|
||||
<option
|
||||
value="Cohort-All"
|
||||
>
|
||||
Cohort-All
|
||||
</option>,
|
||||
]
|
||||
}
|
||||
value="Cohorts"
|
||||
/>
|
||||
</React.Fragment>
|
||||
`;
|
||||
|
||||
exports[`StudentGroupsFilter Component snapshots basic snapshot 1`] = `
|
||||
<React.Fragment>
|
||||
<SelectGroup
|
||||
disabled={false}
|
||||
id="Tracks"
|
||||
label="Tracks"
|
||||
onChange={[MockFunction updateTracks]}
|
||||
options={
|
||||
Array [
|
||||
<option
|
||||
value="Track-All"
|
||||
>
|
||||
Track-All
|
||||
</option>,
|
||||
<option
|
||||
value="TracK1"
|
||||
>
|
||||
TracK1
|
||||
</option>,
|
||||
<option
|
||||
value="TracK2"
|
||||
>
|
||||
TracK2
|
||||
</option>,
|
||||
<option
|
||||
value="TRACK3"
|
||||
>
|
||||
TRACK3
|
||||
</option>,
|
||||
]
|
||||
}
|
||||
value="TracK2"
|
||||
/>
|
||||
<SelectGroup
|
||||
disabled={false}
|
||||
id="Cohorts"
|
||||
label="Cohorts"
|
||||
onChange={[MockFunction updateCohorts]}
|
||||
options={
|
||||
Array [
|
||||
<option
|
||||
value="Cohort-All"
|
||||
>
|
||||
Cohort-All
|
||||
</option>,
|
||||
<option
|
||||
value="cohorT1"
|
||||
>
|
||||
cohorT1
|
||||
</option>,
|
||||
<option
|
||||
value="cohorT2"
|
||||
>
|
||||
cohorT2
|
||||
</option>,
|
||||
<option
|
||||
value="cohorT3"
|
||||
>
|
||||
cohorT3
|
||||
</option>,
|
||||
]
|
||||
}
|
||||
value="cohorT3"
|
||||
/>
|
||||
</React.Fragment>
|
||||
`;
|
||||
|
||||
exports[`StudentGroupsFilter Component snapshots mapCohortsEntries cohort options: [Cohort-All, <{slug, name}...>] 1`] = `
|
||||
Array [
|
||||
<option
|
||||
value="Cohort-All"
|
||||
>
|
||||
Cohort-All
|
||||
</option>,
|
||||
<option
|
||||
value="cohorT1"
|
||||
>
|
||||
cohorT1
|
||||
</option>,
|
||||
<option
|
||||
value="cohorT2"
|
||||
>
|
||||
cohorT2
|
||||
</option>,
|
||||
<option
|
||||
value="cohorT3"
|
||||
>
|
||||
cohorT3
|
||||
</option>,
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`StudentGroupsFilter Component snapshots mapTracksEntries cohort options: [Track-All, <{id, name}...>] 1`] = `
|
||||
Array [
|
||||
<option
|
||||
value="Track-All"
|
||||
>
|
||||
Track-All
|
||||
</option>,
|
||||
<option
|
||||
value="TracK1"
|
||||
>
|
||||
TracK1
|
||||
</option>,
|
||||
<option
|
||||
value="TracK2"
|
||||
>
|
||||
TracK2
|
||||
</option>,
|
||||
<option
|
||||
value="TRACK3"
|
||||
>
|
||||
TRACK3
|
||||
</option>,
|
||||
]
|
||||
`;
|
||||
@@ -0,0 +1,153 @@
|
||||
/* eslint-disable react/sort-comp, react/button-has-type, import/no-named-as-default */
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { fetchGrades } from 'data/actions/grades';
|
||||
import selectors from 'data/selectors';
|
||||
import SelectGroup from '../SelectGroup';
|
||||
|
||||
export class StudentGroupsFilter extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.updateCohorts = this.updateCohorts.bind(this);
|
||||
this.updateTracks = this.updateTracks.bind(this);
|
||||
}
|
||||
|
||||
mapCohortsEntries = () => {
|
||||
const mapper = ({ id, name }) => (
|
||||
<option key={id} value={name}>{name}</option>
|
||||
);
|
||||
return [
|
||||
<option value="Cohort-All" key="0">Cohort-All</option>,
|
||||
...this.props.cohorts.map(mapper),
|
||||
];
|
||||
};
|
||||
|
||||
mapTracksEntries = () => {
|
||||
const mapper = ({ slug, name }) => (
|
||||
<option key={slug} value={name}>{name}</option>
|
||||
);
|
||||
return [
|
||||
<option value="Track-All" key="0">Track-All</option>,
|
||||
...this.props.tracks.map(mapper),
|
||||
];
|
||||
};
|
||||
|
||||
mapSelectedCohortEntry = () => {
|
||||
const selectedCohortEntry = this.props.cohorts.find(
|
||||
(x) => x.id === parseInt(this.props.selectedCohort, 10),
|
||||
);
|
||||
return selectedCohortEntry ? selectedCohortEntry.name : 'Cohorts';
|
||||
};
|
||||
|
||||
mapSelectedTrackEntry = () => {
|
||||
const selectedTrackEntry = this.props.tracks.find(
|
||||
({ slug }) => slug === this.props.selectedTrack,
|
||||
);
|
||||
return selectedTrackEntry ? selectedTrackEntry.name : 'Tracks';
|
||||
};
|
||||
|
||||
selectedTrackSlugFromEvent(event) {
|
||||
const selectedTrackItem = this.props.tracks.find(
|
||||
({ name }) => name === event.target.value,
|
||||
);
|
||||
return selectedTrackItem ? selectedTrackItem.slug : null;
|
||||
}
|
||||
|
||||
selectedCohortIdFromEvent(event) {
|
||||
const selectedCohortItem = this.props.cohorts.find(
|
||||
x => x.name === event.target.value,
|
||||
);
|
||||
return selectedCohortItem ? selectedCohortItem.id.toString() : null;
|
||||
}
|
||||
|
||||
updateTracks(event) {
|
||||
const selectedTrackSlug = this.selectedTrackSlugFromEvent(event);
|
||||
this.props.getUserGrades(
|
||||
this.props.courseId,
|
||||
this.props.selectedCohort,
|
||||
selectedTrackSlug,
|
||||
this.props.selectedAssignmentType,
|
||||
);
|
||||
this.props.updateQueryParams({ track: selectedTrackSlug });
|
||||
}
|
||||
|
||||
updateCohorts(event) {
|
||||
const selectedCohortId = this.selectedCohortIdFromEvent(event);
|
||||
this.props.getUserGrades(
|
||||
this.props.courseId,
|
||||
selectedCohortId,
|
||||
this.props.selectedTrack,
|
||||
this.props.selectedAssignmentType,
|
||||
);
|
||||
this.props.updateQueryParams({ cohort: selectedCohortId });
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<>
|
||||
<SelectGroup
|
||||
id="Tracks"
|
||||
label="Tracks"
|
||||
value={this.mapSelectedTrackEntry()}
|
||||
onChange={this.updateTracks}
|
||||
options={this.mapTracksEntries()}
|
||||
/>
|
||||
<SelectGroup
|
||||
id="Cohorts"
|
||||
label="Cohorts"
|
||||
value={this.mapSelectedCohortEntry()}
|
||||
disabled={this.props.cohorts.length === 0}
|
||||
onChange={this.updateCohorts}
|
||||
options={this.mapCohortsEntries()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
StudentGroupsFilter.defaultProps = {
|
||||
cohorts: [],
|
||||
courseId: '',
|
||||
selectedAssignmentType: '',
|
||||
selectedCohort: null,
|
||||
selectedTrack: null,
|
||||
tracks: [],
|
||||
};
|
||||
|
||||
StudentGroupsFilter.propTypes = {
|
||||
courseId: PropTypes.string,
|
||||
updateQueryParams: PropTypes.func.isRequired,
|
||||
|
||||
// redux
|
||||
cohorts: PropTypes.arrayOf(PropTypes.shape({
|
||||
name: PropTypes.string,
|
||||
id: PropTypes.number,
|
||||
})),
|
||||
getUserGrades: PropTypes.func.isRequired,
|
||||
selectedAssignmentType: PropTypes.string,
|
||||
selectedCohort: PropTypes.string,
|
||||
selectedTrack: PropTypes.string,
|
||||
tracks: PropTypes.arrayOf(PropTypes.shape({
|
||||
name: PropTypes.string,
|
||||
slug: PropTypes.string,
|
||||
})),
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => {
|
||||
const { filters, cohorts, tracks } = selectors;
|
||||
return {
|
||||
cohorts: cohorts.allCohorts(state),
|
||||
selectedAssignmentType: filters.assignmentType(state),
|
||||
selectedCohort: filters.cohort(state),
|
||||
selectedTrack: filters.track(state),
|
||||
tracks: tracks.allTracks(state),
|
||||
};
|
||||
};
|
||||
|
||||
export const mapDispatchToProps = {
|
||||
getUserGrades: fetchGrades,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(StudentGroupsFilter);
|
||||
@@ -0,0 +1,238 @@
|
||||
/* eslint-disable import/no-named-as-default */
|
||||
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { fetchGrades } from 'data/actions/grades';
|
||||
import {
|
||||
StudentGroupsFilter,
|
||||
mapStateToProps,
|
||||
mapDispatchToProps,
|
||||
} from '.';
|
||||
|
||||
describe('StudentGroupsFilter', () => {
|
||||
let props = {
|
||||
courseId: '12345',
|
||||
cohorts: [
|
||||
{ name: 'cohorT1', id: 8001 },
|
||||
{ name: 'cohorT2', id: 8002 },
|
||||
{ name: 'cohorT3', id: 8003 },
|
||||
],
|
||||
selectedAssignmentType: 'assignMent type 1',
|
||||
selectedCohort: '8003',
|
||||
selectedTrack: 'TracK2_slug',
|
||||
tracks: [
|
||||
{ name: 'TracK1', slug: 'TracK1_slug' },
|
||||
{ name: 'TracK2', slug: 'TracK2_slug' },
|
||||
{ name: 'TRACK3', slug: 'TRACK3_slug' },
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
props = {
|
||||
...props,
|
||||
getUserGrades: jest.fn(),
|
||||
updateQueryParams: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Component', () => {
|
||||
describe('snapshots', () => {
|
||||
let el;
|
||||
beforeEach(() => {
|
||||
el = shallow(<StudentGroupsFilter {...props} />);
|
||||
});
|
||||
test('basic snapshot', () => {
|
||||
el.instance().updateTracks = jest.fn().mockName(
|
||||
'updateTracks',
|
||||
);
|
||||
el.instance().updateCohorts = jest.fn().mockName(
|
||||
'updateCohorts',
|
||||
);
|
||||
expect(el.instance().render()).toMatchSnapshot();
|
||||
});
|
||||
test('Cohorts group disabled if no cohorts', () => {
|
||||
el.setProps({ cohorts: [] });
|
||||
expect(el.instance().render()).toMatchSnapshot();
|
||||
});
|
||||
describe('mapCohortsEntries', () => {
|
||||
test('cohort options: [Cohort-All, <{slug, name}...>]', () => {
|
||||
expect(el.instance().mapCohortsEntries()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
describe('mapTracksEntries', () => {
|
||||
test('cohort options: [Track-All, <{id, name}...>]', () => {
|
||||
expect(el.instance().mapTracksEntries()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('behavior', () => {
|
||||
let el;
|
||||
beforeEach(() => {
|
||||
el = shallow(<StudentGroupsFilter {...props} />);
|
||||
});
|
||||
describe('mapSelectedCohortEntry', () => {
|
||||
it('returns the name of the cohort with the same numerical id', () => {
|
||||
// Because selectedCohort is the id of cohorts[2]
|
||||
expect(el.instance().mapSelectedCohortEntry()).toEqual(
|
||||
props.cohorts[2].name,
|
||||
);
|
||||
});
|
||||
it('returns "Cohorts" if no cohort is found', () => {
|
||||
el.setProps({ selectedCohort: '999' });
|
||||
expect(el.instance().mapSelectedCohortEntry()).toEqual(
|
||||
'Cohorts',
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('mapSelectedTrackEntry', () => {
|
||||
it('returns the name of the track with the selected slug', () => {
|
||||
// Because selectedTrack is the slug of tracks[1]
|
||||
expect(el.instance().mapSelectedTrackEntry()).toEqual(
|
||||
props.tracks[1].name,
|
||||
);
|
||||
});
|
||||
it('returns "Tracks" if no track is found', () => {
|
||||
el.setProps({ selectedTrack: 'FAKE' });
|
||||
expect(el.instance().mapSelectedTrackEntry()).toEqual(
|
||||
'Tracks',
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('selectedCohortIdFromEvent', () => {
|
||||
it('returns the id of the cohort with the name matching the event', () => {
|
||||
expect(
|
||||
el.instance().selectedCohortIdFromEvent(
|
||||
{ target: { value: props.cohorts[1].name } },
|
||||
),
|
||||
).toEqual(props.cohorts[1].id.toString());
|
||||
});
|
||||
it('returns null if no matching cohort is found', () => {
|
||||
expect(
|
||||
el.instance().selectedCohortIdFromEvent(
|
||||
{ target: { value: 'FAKE' } },
|
||||
),
|
||||
).toEqual(null);
|
||||
});
|
||||
});
|
||||
describe('selectedTrackSlugFromEvent', () => {
|
||||
it('returns the slug of the track with the name matching the event', () => {
|
||||
expect(
|
||||
el.instance().selectedTrackSlugFromEvent(
|
||||
{ target: { value: props.tracks[1].name } },
|
||||
),
|
||||
).toEqual(props.tracks[1].slug);
|
||||
});
|
||||
it('returns null if no matching track is found', () => {
|
||||
expect(
|
||||
el.instance().selectedTrackSlugFromEvent(
|
||||
{ target: { value: 'FAKE' } },
|
||||
),
|
||||
).toEqual(null);
|
||||
});
|
||||
});
|
||||
describe('updateTracks', () => {
|
||||
const selectedSlug = 'SLUG';
|
||||
beforeEach(() => {
|
||||
el = shallow(<StudentGroupsFilter {...props} />);
|
||||
jest.spyOn(
|
||||
el.instance(),
|
||||
'selectedTrackSlugFromEvent',
|
||||
).mockReturnValue(selectedSlug);
|
||||
el.instance().updateTracks({ target: {} });
|
||||
});
|
||||
it('calls getUserGrades with selection', () => {
|
||||
expect(props.getUserGrades).toHaveBeenCalledWith(
|
||||
props.courseId,
|
||||
props.selectedCohort,
|
||||
selectedSlug,
|
||||
props.selectedAssignmentType,
|
||||
);
|
||||
});
|
||||
it('updates queryParams with track value', () => {
|
||||
expect(props.updateQueryParams).toHaveBeenCalledWith({
|
||||
track: selectedSlug,
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('updateCohorts', () => {
|
||||
const selectedId = 23;
|
||||
beforeEach(() => {
|
||||
el = shallow(<StudentGroupsFilter {...props} />);
|
||||
jest.spyOn(
|
||||
el.instance(),
|
||||
'selectedCohortIdFromEvent',
|
||||
).mockReturnValue(selectedId);
|
||||
el.instance().updateCohorts({ target: {} });
|
||||
});
|
||||
it('calls getUserGrades with selection', () => {
|
||||
expect(props.getUserGrades).toHaveBeenCalledWith(
|
||||
props.courseId,
|
||||
selectedId,
|
||||
props.selectedTrack,
|
||||
props.selectedAssignmentType,
|
||||
);
|
||||
});
|
||||
it('updates queryParams with cohort value', () => {
|
||||
expect(props.updateQueryParams).toHaveBeenCalledWith({
|
||||
cohort: selectedId,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('mapStateToProps', () => {
|
||||
const state = {
|
||||
cohorts: { results: ['some', 'cohorts'] },
|
||||
filters: {
|
||||
cohort: 'COHort',
|
||||
track: 'TRacK',
|
||||
assignmentType: 'TYPe',
|
||||
},
|
||||
tracks: { results: ['a', 'few', 'tracks'] },
|
||||
};
|
||||
describe('cohorts', () => {
|
||||
test('drawn from cohorts.results', () => {
|
||||
expect(mapStateToProps(state).cohorts).toEqual(
|
||||
state.cohorts.results,
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('selectedAssignmentType', () => {
|
||||
test('drawn from filters.assignmentType', () => {
|
||||
expect(mapStateToProps(state).selectedAssignmentType).toEqual(
|
||||
state.filters.assignmentType,
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('selectedCohort', () => {
|
||||
test('drawn from filters.cohort', () => {
|
||||
expect(mapStateToProps(state).selectedCohort).toEqual(
|
||||
state.filters.cohort,
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('selectedTrack', () => {
|
||||
test('drawn from filters.track', () => {
|
||||
expect(mapStateToProps(state).selectedTrack).toEqual(
|
||||
state.filters.track,
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('tracks', () => {
|
||||
test('drawn from tracks.results', () => {
|
||||
expect(mapStateToProps(state).tracks).toEqual(
|
||||
state.tracks.results,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('mapDispatchToProps', () => {
|
||||
describe('getUserGrades', () => {
|
||||
test('from fetchGrades', () => {
|
||||
expect(mapDispatchToProps.getUserGrades).toEqual(fetchGrades);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`PercentGroup Component snapshots basic snapshot 1`] = `
|
||||
<div
|
||||
className="percent-group"
|
||||
>
|
||||
<FormGroup
|
||||
as="div"
|
||||
controlId="group id"
|
||||
isInvalid={false}
|
||||
isValid={false}
|
||||
>
|
||||
<FormLabel
|
||||
isInline={false}
|
||||
>
|
||||
Group Label
|
||||
</FormLabel>
|
||||
<ForwardRef
|
||||
as="input"
|
||||
disabled={false}
|
||||
max={100}
|
||||
min={0}
|
||||
onChange={[MockFunction props.onChange]}
|
||||
plaintext={false}
|
||||
step={1}
|
||||
type="number"
|
||||
value="group VALUE"
|
||||
/>
|
||||
</FormGroup>
|
||||
<span
|
||||
className="input-percent-label"
|
||||
>
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`PercentGroup Component snapshots disabled 1`] = `
|
||||
<div
|
||||
className="percent-group"
|
||||
>
|
||||
<FormGroup
|
||||
as="div"
|
||||
controlId="group id"
|
||||
isInvalid={false}
|
||||
isValid={false}
|
||||
>
|
||||
<FormLabel
|
||||
isInline={false}
|
||||
>
|
||||
Group Label
|
||||
</FormLabel>
|
||||
<ForwardRef
|
||||
as="input"
|
||||
disabled={true}
|
||||
max={100}
|
||||
min={0}
|
||||
onChange={[MockFunction props.onChange]}
|
||||
plaintext={false}
|
||||
step={1}
|
||||
type="number"
|
||||
value="group VALUE"
|
||||
/>
|
||||
</FormGroup>
|
||||
<span
|
||||
className="input-percent-label"
|
||||
>
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,91 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`SelectGroup Component snapshots basic snapshot 1`] = `
|
||||
<div
|
||||
className="student-filters"
|
||||
>
|
||||
<FormGroup
|
||||
as="div"
|
||||
controlId="group id"
|
||||
isInvalid={false}
|
||||
isValid={false}
|
||||
>
|
||||
<FormLabel
|
||||
isInline={false}
|
||||
>
|
||||
Group Label
|
||||
</FormLabel>
|
||||
<ForwardRef
|
||||
as="select"
|
||||
disabled={false}
|
||||
onChange={[MockFunction props.onChange]}
|
||||
plaintext={false}
|
||||
value="group VALUE"
|
||||
>
|
||||
<option
|
||||
key="opt1"
|
||||
value="opt1"
|
||||
>
|
||||
Option 1
|
||||
</option>
|
||||
<option
|
||||
key="opt2"
|
||||
value="opt2"
|
||||
>
|
||||
Option 2
|
||||
</option>
|
||||
<option
|
||||
key="opt3"
|
||||
value="opt3"
|
||||
>
|
||||
Option 3
|
||||
</option>
|
||||
</ForwardRef>
|
||||
</FormGroup>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`SelectGroup Component snapshots disabled 1`] = `
|
||||
<div
|
||||
className="student-filters"
|
||||
>
|
||||
<FormGroup
|
||||
as="div"
|
||||
controlId="group id"
|
||||
isInvalid={false}
|
||||
isValid={false}
|
||||
>
|
||||
<FormLabel
|
||||
isInline={false}
|
||||
>
|
||||
Group Label
|
||||
</FormLabel>
|
||||
<ForwardRef
|
||||
as="select"
|
||||
disabled={true}
|
||||
onChange={[MockFunction props.onChange]}
|
||||
plaintext={false}
|
||||
value="group VALUE"
|
||||
>
|
||||
<option
|
||||
key="opt1"
|
||||
value="opt1"
|
||||
>
|
||||
Option 1
|
||||
</option>
|
||||
<option
|
||||
key="opt2"
|
||||
value="opt2"
|
||||
>
|
||||
Option 2
|
||||
</option>
|
||||
<option
|
||||
key="opt3"
|
||||
value="opt3"
|
||||
>
|
||||
Option 3
|
||||
</option>
|
||||
</ForwardRef>
|
||||
</FormGroup>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,75 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`GradebookFilters Component snapshots basic snapshot 1`] = `
|
||||
<React.Fragment>
|
||||
<Collapsible
|
||||
className="filter-group mb-3"
|
||||
defaultOpen={true}
|
||||
title="Assignments"
|
||||
>
|
||||
<div>
|
||||
<Connect(AssignmentTypeFilter)
|
||||
updateQueryParams={[MockFunction]}
|
||||
/>
|
||||
<Connect(AssignmentFilter)
|
||||
courseId="12345"
|
||||
updateQueryParams={[MockFunction]}
|
||||
/>
|
||||
<Connect(AssignmentGradeFilter)
|
||||
courseId="12345"
|
||||
filterValues={
|
||||
Object {
|
||||
"assignmentGradeMax": "90",
|
||||
"assignmentGradeMin": "10",
|
||||
"courseGradeMax": "80",
|
||||
"courseGradeMin": "20",
|
||||
}
|
||||
}
|
||||
setFilters={[MockFunction]}
|
||||
updateQueryParams={[MockFunction]}
|
||||
/>
|
||||
</div>
|
||||
</Collapsible>
|
||||
<Collapsible
|
||||
className="filter-group mb-3"
|
||||
defaultOpen={true}
|
||||
title="Overall Grade"
|
||||
>
|
||||
<Connect(CourseGradeFilter)
|
||||
courseId="12345"
|
||||
filterValues={
|
||||
Object {
|
||||
"assignmentGradeMax": "90",
|
||||
"assignmentGradeMin": "10",
|
||||
"courseGradeMax": "80",
|
||||
"courseGradeMin": "20",
|
||||
}
|
||||
}
|
||||
setFilters={[MockFunction]}
|
||||
updateQueryParams={[MockFunction]}
|
||||
/>
|
||||
</Collapsible>
|
||||
<Collapsible
|
||||
className="filter-group mb-3"
|
||||
defaultOpen={true}
|
||||
title="Student Groups"
|
||||
>
|
||||
<Connect(StudentGroupsFilter)
|
||||
courseId="12345"
|
||||
updateQueryParams={[MockFunction]}
|
||||
/>
|
||||
</Collapsible>
|
||||
<Collapsible
|
||||
className="filter-group mb-3"
|
||||
defaultOpen={true}
|
||||
title="Include Course Team Members"
|
||||
>
|
||||
<Checkbox
|
||||
checked={true}
|
||||
onChange={[MockFunction handleIncludeTeamMembersChange]}
|
||||
>
|
||||
Include Course Team Members
|
||||
</Checkbox>
|
||||
</Collapsible>
|
||||
</React.Fragment>
|
||||
`;
|
||||
120
src/components/Gradebook/GradebookFilters/index.jsx
Normal file
120
src/components/Gradebook/GradebookFilters/index.jsx
Normal file
@@ -0,0 +1,120 @@
|
||||
/* eslint-disable react/sort-comp, import/no-named-as-default */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { Collapsible, Form } from '@edx/paragon';
|
||||
|
||||
import * as filterActions from 'data/actions/filters';
|
||||
import selectors from 'data/selectors';
|
||||
|
||||
import AssignmentTypeFilter from './AssignmentTypeFilter';
|
||||
import AssignmentFilter from './AssignmentFilter';
|
||||
import AssignmentGradeFilter from './AssignmentGradeFilter';
|
||||
import CourseGradeFilter from './CourseGradeFilter';
|
||||
import StudentGroupsFilter from './StudentGroupsFilter';
|
||||
|
||||
export class GradebookFilters extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
includeCourseRoleMembers: this.props.includeCourseRoleMembers,
|
||||
};
|
||||
this.handleIncludeTeamMembersChange = this.handleIncludeTeamMembersChange.bind(this);
|
||||
}
|
||||
|
||||
handleIncludeTeamMembersChange(event) {
|
||||
const includeCourseRoleMembers = event.target.checked;
|
||||
this.setState({ includeCourseRoleMembers });
|
||||
this.props.updateIncludeCourseRoleMembers(includeCourseRoleMembers);
|
||||
this.props.updateQueryParams({ includeCourseRoleMembers });
|
||||
}
|
||||
|
||||
collapsibleGroup = (title, content) => (
|
||||
<Collapsible title={title} defaultOpen className="filter-group mb-3">
|
||||
{content}
|
||||
</Collapsible>
|
||||
);
|
||||
|
||||
render() {
|
||||
const {
|
||||
courseId,
|
||||
filterValues,
|
||||
setFilters,
|
||||
updateQueryParams,
|
||||
} = this.props;
|
||||
return (
|
||||
<>
|
||||
{this.collapsibleGroup('Assignments', (
|
||||
<div>
|
||||
<AssignmentTypeFilter
|
||||
updateQueryParams={updateQueryParams}
|
||||
/>
|
||||
<AssignmentFilter
|
||||
courseId={courseId}
|
||||
updateQueryParams={updateQueryParams}
|
||||
/>
|
||||
<AssignmentGradeFilter
|
||||
{...{
|
||||
courseId,
|
||||
filterValues,
|
||||
setFilters,
|
||||
updateQueryParams,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{this.collapsibleGroup('Overall Grade', (
|
||||
<CourseGradeFilter
|
||||
{...{
|
||||
filterValues,
|
||||
setFilters,
|
||||
courseId,
|
||||
updateQueryParams,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{this.collapsibleGroup('Student Groups', (
|
||||
<StudentGroupsFilter
|
||||
courseId={courseId}
|
||||
updateQueryParams={updateQueryParams}
|
||||
/>
|
||||
))}
|
||||
{this.collapsibleGroup('Include Course Team Members', (
|
||||
<Form.Checkbox
|
||||
checked={this.state.includeCourseRoleMembers}
|
||||
onChange={this.handleIncludeTeamMembersChange}
|
||||
>
|
||||
Include Course Team Members
|
||||
</Form.Checkbox>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
GradebookFilters.defaultProps = {
|
||||
includeCourseRoleMembers: false,
|
||||
};
|
||||
GradebookFilters.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
filterValues: PropTypes.shape({
|
||||
assignmentGradeMin: PropTypes.string,
|
||||
assignmentGradeMax: PropTypes.string,
|
||||
courseGradeMin: PropTypes.string,
|
||||
courseGradeMax: PropTypes.string,
|
||||
}).isRequired,
|
||||
setFilters: PropTypes.func.isRequired,
|
||||
includeCourseRoleMembers: PropTypes.bool,
|
||||
updateIncludeCourseRoleMembers: PropTypes.func.isRequired,
|
||||
updateQueryParams: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => ({
|
||||
includeCourseRoleMembers: selectors.filters.includeCourseRoleMembers(state),
|
||||
});
|
||||
|
||||
export const mapDispatchToProps = {
|
||||
updateIncludeCourseRoleMembers: filterActions.updateIncludeCourseRoleMembers,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(GradebookFilters);
|
||||
105
src/components/Gradebook/GradebookFilters/test.jsx
Normal file
105
src/components/Gradebook/GradebookFilters/test.jsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { updateIncludeCourseRoleMembers } from 'data/actions/filters';
|
||||
|
||||
import {
|
||||
GradebookFilters,
|
||||
mapStateToProps,
|
||||
mapDispatchToProps,
|
||||
} from '.';
|
||||
|
||||
jest.mock('@edx/paragon', () => ({
|
||||
Collapsible: 'Collapsible',
|
||||
Form: {
|
||||
Checkbox: 'Checkbox',
|
||||
},
|
||||
}));
|
||||
|
||||
describe('GradebookFilters', () => {
|
||||
let props = {
|
||||
courseId: '12345',
|
||||
filterValues: {
|
||||
assignmentGradeMin: '10',
|
||||
assignmentGradeMax: '90',
|
||||
courseGradeMin: '20',
|
||||
courseGradeMax: '80',
|
||||
},
|
||||
includeCourseRoleMembers: true,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
props = {
|
||||
...props,
|
||||
updateQueryParams: jest.fn(),
|
||||
updateIncludeCourseRoleMembers: jest.fn(),
|
||||
setFilters: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Component', () => {
|
||||
describe('behavior', () => {
|
||||
describe('handleIncludeTeamMembersChange', () => {
|
||||
let el;
|
||||
beforeEach(() => {
|
||||
el = shallow(<GradebookFilters {...props} />);
|
||||
el.instance().setState = jest.fn();
|
||||
});
|
||||
it('calls setState with newVal', () => {
|
||||
el.instance().handleIncludeTeamMembersChange(
|
||||
{ target: { checked: true } },
|
||||
);
|
||||
expect(
|
||||
el.instance().setState,
|
||||
).toHaveBeenCalledWith({ includeCourseRoleMembers: true });
|
||||
});
|
||||
it('calls props.updateIncludeCourseRoleMembers with newVal', () => {
|
||||
el.instance().handleIncludeTeamMembersChange(
|
||||
{ target: { checked: false } },
|
||||
);
|
||||
expect(
|
||||
props.updateIncludeCourseRoleMembers,
|
||||
).toHaveBeenCalledWith(false);
|
||||
});
|
||||
it('calls props.updateQueryParams with newVal', () => {
|
||||
el.instance().handleIncludeTeamMembersChange(
|
||||
{ target: { checked: true } },
|
||||
);
|
||||
expect(
|
||||
props.updateQueryParams,
|
||||
).toHaveBeenCalledWith({ includeCourseRoleMembers: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('snapshots', () => {
|
||||
test('basic snapshot', () => {
|
||||
const el = shallow(<GradebookFilters {...props} />);
|
||||
el.instance().handleIncludeTeamMembersChange = jest.fn().mockName(
|
||||
'handleIncludeTeamMembersChange',
|
||||
);
|
||||
expect(el.instance().render()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('mapStateToProps', () => {
|
||||
const state = {
|
||||
filters: {
|
||||
includeCourseRoleMembers: 'plz do',
|
||||
},
|
||||
};
|
||||
describe('includeCourseRoleMembers', () => {
|
||||
it('is drawn from filters.includeCourseRoleMembers', () => {
|
||||
expect(mapStateToProps(state).includeCourseRoleMembers).toEqual(
|
||||
state.filters.includeCourseRoleMembers,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('mapDispatchToProps', () => {
|
||||
test('updateIncludeCourseRoleMembers', () => {
|
||||
expect(mapDispatchToProps.updateIncludeCourseRoleMembers).toEqual(
|
||||
updateIncludeCourseRoleMembers,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
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,12 +217,15 @@ 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,
|
||||
|
||||
114
src/components/Gradebook/SearchControls.jsx
Normal file
114
src/components/Gradebook/SearchControls.jsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { Button, Icon, SearchField } from '@edx/paragon';
|
||||
|
||||
import selectors from 'data/selectors';
|
||||
import {
|
||||
fetchGrades,
|
||||
fetchMatchingUserGrades,
|
||||
} from '../../data/actions/grades';
|
||||
|
||||
/**
|
||||
* Controls for filtering the GradebookTable. Contains the "Edit Filters" button for opening the filter drawer
|
||||
* as well as the search box for searching by username/email.
|
||||
*/
|
||||
export class SearchControls extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.onSubmit = this.onSubmit.bind(this);
|
||||
this.onChange = this.onChange.bind(this);
|
||||
this.onClear = this.onClear.bind(this);
|
||||
}
|
||||
|
||||
/** Submitting searches for user matching the username/email in `value` */
|
||||
onSubmit(value) {
|
||||
this.props.searchForUser(
|
||||
this.props.courseId,
|
||||
value,
|
||||
this.props.selectedCohort,
|
||||
this.props.selectedTrack,
|
||||
this.props.selectedAssignmentType,
|
||||
);
|
||||
}
|
||||
|
||||
/** Changing the search value stores the key in Gradebook. Currently unused */
|
||||
onChange(filterValue) {
|
||||
this.props.setFilterValue(filterValue);
|
||||
}
|
||||
|
||||
/** Clearing the search box falls back to showing students with already applied filters */
|
||||
onClear() {
|
||||
this.props.getUserGrades(
|
||||
this.props.courseId,
|
||||
this.props.selectedCohort,
|
||||
this.props.selectedTrack,
|
||||
this.props.selectedAssignmentType,
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<>
|
||||
<h4>Step 1: Filter the Grade Report</h4>
|
||||
<div className="d-flex justify-content-between">
|
||||
<Button
|
||||
id="edit-filters-btn"
|
||||
className="btn-primary align-self-start"
|
||||
onClick={this.props.toggleFilterDrawer}
|
||||
>
|
||||
<Icon className="fa fa-filter" /> Edit Filters
|
||||
</Button>
|
||||
<div>
|
||||
<SearchField
|
||||
onSubmit={this.onSubmit}
|
||||
inputLabel="Search for a learner"
|
||||
onChange={this.onChange}
|
||||
onClear={this.onClear}
|
||||
value={this.props.filterValue}
|
||||
/>
|
||||
<small className="form-text text-muted search-help-text">Search by username, email, or student key</small>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SearchControls.defaultProps = {
|
||||
courseId: '',
|
||||
filterValue: '',
|
||||
selectedAssignmentType: '',
|
||||
selectedCohort: null,
|
||||
selectedTrack: null,
|
||||
};
|
||||
|
||||
SearchControls.propTypes = {
|
||||
courseId: PropTypes.string,
|
||||
filterValue: PropTypes.string,
|
||||
setFilterValue: PropTypes.func.isRequired,
|
||||
toggleFilterDrawer: PropTypes.func.isRequired,
|
||||
// From Redux
|
||||
getUserGrades: PropTypes.func.isRequired,
|
||||
searchForUser: PropTypes.func.isRequired,
|
||||
selectedAssignmentType: PropTypes.string,
|
||||
selectedCohort: PropTypes.string,
|
||||
selectedTrack: PropTypes.string,
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => {
|
||||
const { filters } = selectors;
|
||||
return {
|
||||
selectedAssignmentType: filters.assignmentType(state),
|
||||
selectedTrack: filters.track(state),
|
||||
selectedCohort: filters.cohort(state),
|
||||
};
|
||||
};
|
||||
|
||||
export const mapDispatchToProps = {
|
||||
getUserGrades: fetchGrades,
|
||||
searchForUser: fetchMatchingUserGrades,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(SearchControls);
|
||||
118
src/components/Gradebook/SearchControls.test.jsx
Normal file
118
src/components/Gradebook/SearchControls.test.jsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import {
|
||||
fetchGrades,
|
||||
fetchMatchingUserGrades,
|
||||
} from '../../data/actions/grades';
|
||||
import { mapDispatchToProps, mapStateToProps, SearchControls } from './SearchControls';
|
||||
|
||||
jest.mock('@edx/paragon', () => ({
|
||||
Icon: 'Icon',
|
||||
Button: 'Button',
|
||||
SearchField: 'SearchField',
|
||||
}));
|
||||
|
||||
describe('SearchControls', () => {
|
||||
let props;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
props = {
|
||||
courseId: 'course-v1:edX+DEV101+T1',
|
||||
filterValue: 'alice',
|
||||
selectedAssignmentType: 'homework',
|
||||
selectedCohort: 'spring term',
|
||||
selectedTrack: 'masters',
|
||||
getUserGrades: jest.fn(),
|
||||
searchForUser: jest.fn(),
|
||||
setFilterValue: jest.fn(),
|
||||
toggleFilterDrawer: jest.fn().mockName('toggleFilterDrawer'),
|
||||
};
|
||||
});
|
||||
|
||||
const searchControls = (overriddenProps) => {
|
||||
props = { ...props, ...overriddenProps };
|
||||
return shallow(<SearchControls {...props} />);
|
||||
};
|
||||
|
||||
describe('Component', () => {
|
||||
describe('onSubmit', () => {
|
||||
it('calls props.searchForUser with correct data', () => {
|
||||
const wrapper = searchControls();
|
||||
wrapper.instance().onSubmit('bob');
|
||||
|
||||
expect(props.searchForUser).toHaveBeenCalledWith(
|
||||
props.courseId,
|
||||
'bob',
|
||||
props.selectedCohort,
|
||||
props.selectedTrack,
|
||||
props.selectedAssignmentType,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onChange', () => {
|
||||
it('saves the changed search value to Gradebook state', () => {
|
||||
const wrapper = searchControls();
|
||||
wrapper.instance().onChange('bob');
|
||||
expect(props.setFilterValue).toHaveBeenCalledWith('bob');
|
||||
});
|
||||
});
|
||||
|
||||
describe('onClear', () => {
|
||||
it('re-runs search with existing filters', () => {
|
||||
const wrapper = searchControls();
|
||||
wrapper.instance().onClear();
|
||||
expect(props.getUserGrades).toHaveBeenCalledWith(
|
||||
props.courseId,
|
||||
props.selectedCohort,
|
||||
props.selectedTrack,
|
||||
props.selectedAssignmentType,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapStateToProps', () => {
|
||||
const state = {
|
||||
filters: {
|
||||
assignmentType: 'labs',
|
||||
track: 'honor',
|
||||
cohort: 'fall term',
|
||||
},
|
||||
};
|
||||
|
||||
it('maps assignment type filter correctly', () => {
|
||||
expect(mapStateToProps(state).selectedAssignmentType).toEqual(state.filters.assignmentType);
|
||||
});
|
||||
|
||||
it('maps track filter correctly', () => {
|
||||
expect(mapStateToProps(state).selectedTrack).toEqual(state.filters.track);
|
||||
});
|
||||
|
||||
it('maps cohort filter correctly', () => {
|
||||
expect(mapStateToProps(state).selectedCohort).toEqual(state.filters.cohort);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapDispatchToProps', () => {
|
||||
test('getUserGrades', () => {
|
||||
expect(mapDispatchToProps.getUserGrades).toEqual(fetchGrades);
|
||||
});
|
||||
|
||||
test('searchForUser', () => {
|
||||
expect(mapDispatchToProps.searchForUser).toEqual(fetchMatchingUserGrades);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Snapshots', () => {
|
||||
test('basic snapshot', () => {
|
||||
const wrapper = searchControls();
|
||||
wrapper.instance().onChange = jest.fn().mockName('onChange');
|
||||
wrapper.instance().onClear = jest.fn().mockName('onClear');
|
||||
wrapper.instance().onSubmit = jest.fn().mockName('onSubmit');
|
||||
expect(wrapper.instance().render()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
72
src/components/Gradebook/StatusAlerts.jsx
Normal file
72
src/components/Gradebook/StatusAlerts.jsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { StatusAlert } from '@edx/paragon';
|
||||
|
||||
import selectors from 'data/selectors';
|
||||
import { closeBanner } from '../../data/actions/grades';
|
||||
|
||||
export const maxCourseGradeInvalidMessage = 'Maximum course grade value must be between 0 and 100. ';
|
||||
export const minCourseGradeInvalidMessage = 'Minimum course grade value must be between 0 and 100. ';
|
||||
|
||||
export class StatusAlerts extends React.Component {
|
||||
get isCourseGradeFilterAlertOpen() {
|
||||
const r = !this.props.isMinCourseGradeFilterValid
|
||||
|| !this.props.isMaxCourseGradeFilterValid;
|
||||
return r;
|
||||
}
|
||||
|
||||
get courseGradeFilterAlertDialogText() {
|
||||
let dialogText = '';
|
||||
if (!this.props.isMinCourseGradeFilterValid) {
|
||||
dialogText += minCourseGradeInvalidMessage;
|
||||
}
|
||||
if (!this.props.isMaxCourseGradeFilterValid) {
|
||||
dialogText += maxCourseGradeInvalidMessage;
|
||||
}
|
||||
return dialogText;
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<>
|
||||
<StatusAlert
|
||||
alertType="success"
|
||||
dialog="The grade has been successfully edited. You may see a slight delay before updates appear in the Gradebook."
|
||||
onClose={this.props.handleCloseSuccessBanner}
|
||||
open={this.props.showSuccessBanner}
|
||||
/>
|
||||
<StatusAlert
|
||||
alertType="danger"
|
||||
dialog={this.courseGradeFilterAlertDialogText}
|
||||
dismissible={false}
|
||||
open={this.isCourseGradeFilterAlertOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
StatusAlerts.defaultProps = {
|
||||
isMinCourseGradeFilterValid: true,
|
||||
isMaxCourseGradeFilterValid: true,
|
||||
};
|
||||
|
||||
StatusAlerts.propTypes = {
|
||||
isMinCourseGradeFilterValid: PropTypes.bool,
|
||||
isMaxCourseGradeFilterValid: PropTypes.bool,
|
||||
// redux
|
||||
handleCloseSuccessBanner: PropTypes.func.isRequired,
|
||||
showSuccessBanner: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => ({
|
||||
showSuccessBanner: selectors.grades.showSuccess(state),
|
||||
});
|
||||
|
||||
export const mapDispatchToProps = {
|
||||
handleCloseSuccessBanner: closeBanner,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(StatusAlerts);
|
||||
99
src/components/Gradebook/StatusAlerts.test.jsx
Normal file
99
src/components/Gradebook/StatusAlerts.test.jsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import {
|
||||
StatusAlerts,
|
||||
mapDispatchToProps,
|
||||
mapStateToProps,
|
||||
maxCourseGradeInvalidMessage,
|
||||
minCourseGradeInvalidMessage,
|
||||
} from './StatusAlerts';
|
||||
import { closeBanner } from '../../data/actions/grades';
|
||||
|
||||
jest.mock('@edx/paragon', () => ({
|
||||
StatusAlert: 'StatusAlert',
|
||||
}));
|
||||
|
||||
describe('StatusAlerts', () => {
|
||||
let props = {
|
||||
showSuccessBanner: true,
|
||||
isMaxCourseGradeFilterValid: true,
|
||||
isMinCourseGradeFilterValid: true,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
props = {
|
||||
...props,
|
||||
handleCloseSuccessBanner: jest.fn().mockName('handleCloseSuccessBanner'),
|
||||
};
|
||||
});
|
||||
|
||||
describe('snapshots', () => {
|
||||
let el;
|
||||
it('basic snapshot', () => {
|
||||
el = shallow(<StatusAlerts {...props} />);
|
||||
const courseGradeFilterAlertDialogText = 'the quiCk brown does somEthing or other';
|
||||
jest.spyOn(
|
||||
el.instance(),
|
||||
'courseGradeFilterAlertDialogText',
|
||||
'get',
|
||||
).mockReturnValue(courseGradeFilterAlertDialogText);
|
||||
expect(el.instance().render()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe('behavior', () => {
|
||||
it.each([
|
||||
[false, false],
|
||||
[false, true],
|
||||
[true, false],
|
||||
[true, true],
|
||||
])('min + max course grade validity', (isMinCourseGradeFilterValid, isMaxCourseGradeFilterValid) => {
|
||||
props = {
|
||||
...props,
|
||||
isMinCourseGradeFilterValid,
|
||||
isMaxCourseGradeFilterValid,
|
||||
};
|
||||
const el = shallow(<StatusAlerts {...props} />);
|
||||
expect(
|
||||
el.instance().isCourseGradeFilterAlertOpen,
|
||||
).toEqual(
|
||||
!isMinCourseGradeFilterValid || !isMaxCourseGradeFilterValid,
|
||||
);
|
||||
if (!isMaxCourseGradeFilterValid) {
|
||||
expect(
|
||||
el.instance().courseGradeFilterAlertDialogText,
|
||||
).toEqual(
|
||||
expect.stringContaining(maxCourseGradeInvalidMessage),
|
||||
);
|
||||
}
|
||||
if (!isMinCourseGradeFilterValid) {
|
||||
expect(
|
||||
el.instance().courseGradeFilterAlertDialogText,
|
||||
).toEqual(
|
||||
expect.stringContaining(minCourseGradeInvalidMessage),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapStateToProps', () => {
|
||||
it('showSuccessBanner', () => {
|
||||
const arbitraryValue = 'AppleBananaCucumber';
|
||||
const state = {
|
||||
grades: {
|
||||
showSuccess: arbitraryValue,
|
||||
},
|
||||
};
|
||||
expect(mapStateToProps(state).showSuccessBanner).toBe(arbitraryValue);
|
||||
});
|
||||
});
|
||||
describe('handleCloseSuccessBanner', () => {
|
||||
test('handleCloseSuccessBanner', () => {
|
||||
expect(
|
||||
mapDispatchToProps.handleCloseSuccessBanner,
|
||||
).toEqual(
|
||||
closeBanner,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`SearchControls Component Snapshots basic snapshot 1`] = `
|
||||
<React.Fragment>
|
||||
<h4>
|
||||
Step 1: Filter the Grade Report
|
||||
</h4>
|
||||
<div
|
||||
className="d-flex justify-content-between"
|
||||
>
|
||||
<Button
|
||||
className="btn-primary align-self-start"
|
||||
id="edit-filters-btn"
|
||||
onClick={[MockFunction toggleFilterDrawer]}
|
||||
>
|
||||
<Icon
|
||||
className="fa fa-filter"
|
||||
/>
|
||||
Edit Filters
|
||||
</Button>
|
||||
<div>
|
||||
<SearchField
|
||||
inputLabel="Search for a learner"
|
||||
onChange={[MockFunction onChange]}
|
||||
onClear={[MockFunction onClear]}
|
||||
onSubmit={[MockFunction onSubmit]}
|
||||
value="alice"
|
||||
/>
|
||||
<small
|
||||
className="form-text text-muted search-help-text"
|
||||
>
|
||||
Search by username, email, or student key
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
`;
|
||||
@@ -0,0 +1,18 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`StatusAlerts snapshots basic snapshot 1`] = `
|
||||
<React.Fragment>
|
||||
<StatusAlert
|
||||
alertType="success"
|
||||
dialog="The grade has been successfully edited. You may see a slight delay before updates appear in the Gradebook."
|
||||
onClose={[MockFunction handleCloseSuccessBanner]}
|
||||
open={true}
|
||||
/>
|
||||
<StatusAlert
|
||||
alertType="danger"
|
||||
dialog="the quiCk brown does somEthing or other"
|
||||
dismissible={false}
|
||||
open={false}
|
||||
/>
|
||||
</React.Fragment>
|
||||
`;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = {};
|
||||
@@ -253,6 +125,7 @@ export default class Gradebook extends React.Component {
|
||||
'adjustedGradePossible',
|
||||
'adjustedGradeValue',
|
||||
'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,
|
||||
};
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import selectors from 'data/selectors';
|
||||
import Gradebook from '../../components/Gradebook';
|
||||
import {
|
||||
closeBanner,
|
||||
fetchGradeOverrideHistory,
|
||||
fetchGrades,
|
||||
fetchMatchingUserGrades,
|
||||
fetchPrevNextGrades,
|
||||
filterAssignmentType,
|
||||
submitFileUploadFormData,
|
||||
@@ -16,103 +15,52 @@ import {
|
||||
import { fetchCohorts } from '../../data/actions/cohorts';
|
||||
import { fetchTracks } from '../../data/actions/tracks';
|
||||
import {
|
||||
initializeFilters, resetFilters, updateAssignmentFilter, updateAssignmentLimits, updateCourseGradeFilter,
|
||||
initializeFilters,
|
||||
resetFilters,
|
||||
updateAssignmentFilter,
|
||||
updateAssignmentLimits,
|
||||
} 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 { hasSpecialBulkManagementAccess } from '../../data/selectors/special';
|
||||
import { getCohortNameById } from '../../data/selectors/cohorts';
|
||||
import { fetchAssignmentTypes } from '../../data/actions/assignmentTypes';
|
||||
import { getRoles } from '../../data/actions/roles';
|
||||
import LmsApiService from '../../data/services/LmsApiService';
|
||||
|
||||
function shouldShowSpinner(state) {
|
||||
if (state.roles.canUserViewGradebook === true) {
|
||||
return state.grades.showSpinner;
|
||||
} if (state.roles.canUserViewGradebook === false) {
|
||||
return false;
|
||||
} // canUserViewGradebook === null
|
||||
return true;
|
||||
}
|
||||
const mapStateToProps = (state, ownProps) => {
|
||||
const {
|
||||
root,
|
||||
assignmentTypes,
|
||||
filters,
|
||||
grades,
|
||||
roles,
|
||||
} = selectors;
|
||||
|
||||
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 { 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: (
|
||||
hasSpecialBulkManagementAccess(ownProps.match.params.courseId)
|
||||
|| (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,
|
||||
@@ -125,12 +73,10 @@ const mapDispatchToProps = {
|
||||
getUserGrades: fetchGrades,
|
||||
initializeFilters,
|
||||
resetFilters,
|
||||
searchForUser: fetchMatchingUserGrades,
|
||||
submitFileUploadFormData,
|
||||
toggleFormat: toggleGradeFormat,
|
||||
updateAssignmentFilter,
|
||||
updateAssignmentLimits,
|
||||
updateCourseGradeFilter,
|
||||
};
|
||||
|
||||
const GradebookPage = connect(
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import filterSelectors from 'data/selectors/filters';
|
||||
import initialFilters from '../constants/filters';
|
||||
import {
|
||||
INITIALIZE_FILTERS, RESET_FILTERS, UPDATE_ASSIGNMENT_FILTER, UPDATE_ASSIGNMENT_LIMITS, UPDATE_COURSE_GRADE_LIMITS,
|
||||
INITIALIZE_FILTERS,
|
||||
RESET_FILTERS,
|
||||
UPDATE_ASSIGNMENT_FILTER,
|
||||
UPDATE_ASSIGNMENT_LIMITS,
|
||||
UPDATE_COURSE_GRADE_LIMITS,
|
||||
UPDATE_INCLUDE_COURSE_ROLE_MEMBERS,
|
||||
} from '../constants/actionTypes/filters';
|
||||
import { fetchGrades } from './grades';
|
||||
|
||||
const { allFilters } = filterSelectors;
|
||||
|
||||
const initializeFilters = ({
|
||||
assignment = initialFilters.assignment,
|
||||
@@ -12,6 +21,7 @@ const initializeFilters = ({
|
||||
assignmentGradeMax = initialFilters.assignmentGradeMax,
|
||||
courseGradeMin = initialFilters.courseGradeMin,
|
||||
courseGradeMax = initialFilters.assignmentGradeMax,
|
||||
includeCourseRoleMembers = initialFilters.includeCourseRoleMembers,
|
||||
}) => ({
|
||||
type: INITIALIZE_FILTERS,
|
||||
data: {
|
||||
@@ -23,6 +33,7 @@ const initializeFilters = ({
|
||||
assignmentGradeMax,
|
||||
courseGradeMin,
|
||||
courseGradeMax,
|
||||
includeCourseRoleMembers: Boolean(includeCourseRoleMembers),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -50,7 +61,21 @@ const updateCourseGradeFilter = (courseGradeMin, courseGradeMax, courseId) => ({
|
||||
},
|
||||
});
|
||||
|
||||
const updateIncludeCourseRoleMembersFilter = (includeCourseRoleMembers) => ({
|
||||
type: UPDATE_INCLUDE_COURSE_ROLE_MEMBERS,
|
||||
data: {
|
||||
includeCourseRoleMembers,
|
||||
},
|
||||
});
|
||||
|
||||
const updateIncludeCourseRoleMembers = includeCourseRoleMembers => (dispatch, getState) => {
|
||||
dispatch(updateIncludeCourseRoleMembersFilter(includeCourseRoleMembers));
|
||||
const state = getState();
|
||||
const { cohort, track, assignmentType } = allFilters(state);
|
||||
dispatch(fetchGrades(state.grades.courseId, cohort, track, assignmentType));
|
||||
};
|
||||
|
||||
export {
|
||||
initializeFilters, resetFilters, updateAssignmentFilter,
|
||||
updateAssignmentLimits, updateCourseGradeFilter,
|
||||
updateAssignmentLimits, updateCourseGradeFilter, updateIncludeCourseRoleMembers,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import gradesSelectors from 'data/selectors/grades';
|
||||
import filtersSelectors from 'data/selectors/filters';
|
||||
import {
|
||||
STARTED_FETCHING_GRADES,
|
||||
FINISHED_FETCHING_GRADES,
|
||||
@@ -27,10 +29,14 @@ import {
|
||||
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';
|
||||
|
||||
const {
|
||||
formatMaxAssignmentGrade,
|
||||
formatMinAssignmentGrade,
|
||||
formatMaxCourseGrade,
|
||||
formatMinCourseGrade,
|
||||
} = gradesSelectors;
|
||||
const { allFilters } = filtersSelectors;
|
||||
|
||||
const defaultAssignmentFilter = 'All';
|
||||
|
||||
@@ -141,7 +147,8 @@ const fetchGrades = (
|
||||
assignmentGradeMin: assignmentMin,
|
||||
courseGradeMin,
|
||||
courseGradeMax,
|
||||
} = getFilters(getState());
|
||||
includeCourseRoleMembers,
|
||||
} = allFilters(getState());
|
||||
const { id: assignmentId } = assignment || {};
|
||||
const assignmentGradeMax = formatMaxAssignmentGrade(assignmentMax, { assignmentId });
|
||||
const assignmentGradeMin = formatMinAssignmentGrade(assignmentMin, { assignmentId });
|
||||
@@ -158,6 +165,7 @@ const fetchGrades = (
|
||||
assignmentGradeMin,
|
||||
courseGradeMin: courseGradeMinFormatted,
|
||||
courseGradeMax: courseGradeMaxFormatted,
|
||||
includeCourseRoleMembers,
|
||||
},
|
||||
|
||||
)
|
||||
|
||||
@@ -36,7 +36,7 @@ describe('actions', () => {
|
||||
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 fetchGradesURL = `${configuration.LMS_BASE_URL}/api/grades/v1/gradebook/${courseId}/?page_size=25&cohort_id=${expectedCohort}&enrollment_mode=${expectedTrack}&excluded_course_roles=all`;
|
||||
const responseData = {
|
||||
next: `${fetchGradesURL}&cursor=2344fda`,
|
||||
previous: null,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import filtersSelectors from 'data/selectors/filters';
|
||||
import {
|
||||
GOT_ROLES,
|
||||
ERROR_FETCHING_ROLES,
|
||||
@@ -6,9 +7,10 @@ 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';
|
||||
|
||||
const { allFilters } = filtersSelectors;
|
||||
|
||||
const allowedRoles = ['staff', 'instructor', 'support'];
|
||||
|
||||
const gotRoles = (canUserViewGradebook, courseId) => ({
|
||||
@@ -26,7 +28,7 @@ const getRoles = courseId => (
|
||||
|| (response.roles.some(role => (role.course_id === courseId)
|
||||
&& allowedRoles.includes(role.role)));
|
||||
dispatch(gotRoles(canUserViewGradebook, courseId));
|
||||
const { cohort, track, assignmentType } = getFilters(getState());
|
||||
const { cohort, track, assignmentType } = allFilters(getState());
|
||||
if (canUserViewGradebook) {
|
||||
dispatch(fetchGrades(courseId, cohort, track, assignmentType));
|
||||
dispatch(fetchTracks(courseId));
|
||||
|
||||
@@ -1,24 +1,27 @@
|
||||
/* eslint-disable camelcase */
|
||||
import tracksSelectors from 'data/selectors/tracks';
|
||||
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';
|
||||
|
||||
const { hasMastersTrack } = tracksSelectors;
|
||||
|
||||
const startedFetchingTracks = () => ({ type: STARTED_FETCHING_TRACKS });
|
||||
const errorFetchingTracks = () => ({ type: ERROR_FETCHING_TRACKS });
|
||||
const gotTracks = tracks => ({ type: GOT_TRACKS, tracks });
|
||||
const gotTracks = (tracks) => ({ type: GOT_TRACKS, tracks });
|
||||
|
||||
const fetchTracks = courseId => (
|
||||
const fetchTracks = (courseId) => (
|
||||
(dispatch) => {
|
||||
dispatch(startedFetchingTracks());
|
||||
return LmsApiService.fetchTracks(courseId)
|
||||
.then(response => response.data)
|
||||
.then((data) => {
|
||||
dispatch(gotTracks(data.course_modes));
|
||||
if (hasMastersTrack(data.course_modes)) {
|
||||
.then(({ data }) => data)
|
||||
.then(({ course_modes }) => {
|
||||
dispatch(gotTracks(course_modes));
|
||||
if (hasMastersTrack(course_modes)) {
|
||||
dispatch(fetchBulkUpgradeHistory(courseId));
|
||||
}
|
||||
})
|
||||
|
||||
@@ -3,7 +3,8 @@ const RESET_FILTERS = 'RESET_FILTERS';
|
||||
const UPDATE_ASSIGNMENT_FILTER = 'UPDATE_ASSIGNMENT_FILTER';
|
||||
const UPDATE_ASSIGNMENT_LIMITS = 'UPDATE_ASSIGNMENT_LIMITS';
|
||||
const UPDATE_COURSE_GRADE_LIMITS = 'UPDATE_COURSE_GRADE_LIMITS';
|
||||
const UPDATE_INCLUDE_COURSE_ROLE_MEMBERS = 'UPDATE_INCLUDE_COURSE_ROLE_MEMBERS';
|
||||
export {
|
||||
INITIALIZE_FILTERS, RESET_FILTERS, UPDATE_ASSIGNMENT_FILTER,
|
||||
UPDATE_ASSIGNMENT_LIMITS, UPDATE_COURSE_GRADE_LIMITS,
|
||||
UPDATE_ASSIGNMENT_LIMITS, UPDATE_COURSE_GRADE_LIMITS, UPDATE_INCLUDE_COURSE_ROLE_MEMBERS,
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ const initialFilters = {
|
||||
assignmentGradeMax: '100',
|
||||
courseGradeMin: '0',
|
||||
courseGradeMax: '100',
|
||||
includeCourseRoleMembers: false,
|
||||
};
|
||||
|
||||
export default initialFilters;
|
||||
|
||||
5
src/data/constants/grades.js
Normal file
5
src/data/constants/grades.js
Normal file
@@ -0,0 +1,5 @@
|
||||
const EMAIL_HEADING = 'Email';
|
||||
const TOTAL_COURSE_GRADE_HEADING = 'Total Grade (%)';
|
||||
const USERNAME_HEADING = 'Username';
|
||||
|
||||
export { EMAIL_HEADING, TOTAL_COURSE_GRADE_HEADING, USERNAME_HEADING };
|
||||
@@ -1,13 +1,16 @@
|
||||
import filterSelectors from 'data/selectors/filters';
|
||||
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,
|
||||
INITIALIZE_FILTERS,
|
||||
UPDATE_ASSIGNMENT_FILTER,
|
||||
UPDATE_ASSIGNMENT_LIMITS,
|
||||
UPDATE_COURSE_GRADE_LIMITS,
|
||||
RESET_FILTERS,
|
||||
UPDATE_INCLUDE_COURSE_ROLE_MEMBERS,
|
||||
} from '../constants/actionTypes/filters';
|
||||
|
||||
import initialFilters from '../constants/filters';
|
||||
|
||||
import { getAssignmentsFromResultsSubstate, chooseRelevantAssignmentData } from '../selectors/filters';
|
||||
|
||||
const { getAssignmentsFromResultsSubstate, chooseRelevantAssignmentData } = filterSelectors;
|
||||
const initialState = {};
|
||||
|
||||
const reducer = (state = initialState, action) => {
|
||||
@@ -70,6 +73,11 @@ const reducer = (state = initialState, action) => {
|
||||
courseGradeMin: action.data.courseGradeMin,
|
||||
courseGradeMax: action.data.courseGradeMax,
|
||||
};
|
||||
case UPDATE_INCLUDE_COURSE_ROLE_MEMBERS:
|
||||
return {
|
||||
...state,
|
||||
includeCourseRoleMembers: action.data.includeCourseRoleMembers,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -136,12 +136,16 @@ const grades = (state = initialState, action) => {
|
||||
bulkManagement: rest,
|
||||
};
|
||||
}
|
||||
case UPLOAD_COMPLETE:
|
||||
case UPLOAD_COMPLETE: {
|
||||
return {
|
||||
...state,
|
||||
showSpinner: false,
|
||||
bulkManagement: { uploadSuccess: true, ...state.bulkManagement },
|
||||
bulkManagement: {
|
||||
...state.bulkManagement,
|
||||
uploadSuccess: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
case UPLOAD_ERR:
|
||||
return {
|
||||
...state,
|
||||
|
||||
6
src/data/selectors/assignmentTypes.js
Normal file
6
src/data/selectors/assignmentTypes.js
Normal file
@@ -0,0 +1,6 @@
|
||||
const selectors = {
|
||||
areGradesFrozen: ({ assignmentTypes }) => assignmentTypes.areGradesFrozen,
|
||||
allAssignmentTypes: ({ assignmentTypes }) => assignmentTypes.results,
|
||||
};
|
||||
|
||||
export default selectors;
|
||||
17
src/data/selectors/assignmentTypes.test.js
Normal file
17
src/data/selectors/assignmentTypes.test.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import selectors from './assignmentTypes';
|
||||
|
||||
describe('areGradesFrozen', () => {
|
||||
it('selects areGradesFrozen from state', () => {
|
||||
const testValue = 'THX 1138';
|
||||
const areGradesFrozen = selectors.areGradesFrozen({ assignmentTypes: { areGradesFrozen: testValue } });
|
||||
expect(areGradesFrozen).toEqual(testValue);
|
||||
});
|
||||
});
|
||||
|
||||
describe('allAssignmentTypes', () => {
|
||||
it('returns assignment types', () => {
|
||||
const testAssignmentTypes = ['assignment', 'labs'];
|
||||
const allAssignmentTypes = selectors.allAssignmentTypes({ assignmentTypes: { results: testAssignmentTypes } });
|
||||
expect(allAssignmentTypes).toEqual(testAssignmentTypes);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,16 @@
|
||||
const getCohorts = state => state.cohorts.results || [];
|
||||
const allCohorts = state => state.cohorts.results || [];
|
||||
|
||||
const getCohortById = (state, selectedCohortId) => {
|
||||
const cohort = getCohorts(state).find(coh => coh.id === selectedCohortId);
|
||||
const cohort = allCohorts(state).find(coh => coh.id === selectedCohortId);
|
||||
return cohort;
|
||||
};
|
||||
|
||||
const getCohortNameById = (state, selectedCohortId) => (getCohortById(state, selectedCohortId) || {}).name;
|
||||
|
||||
export { getCohortById, getCohortNameById, getCohorts };
|
||||
const selectors = {
|
||||
getCohortById,
|
||||
getCohortNameById,
|
||||
allCohorts,
|
||||
};
|
||||
|
||||
export default selectors;
|
||||
|
||||
49
src/data/selectors/cohorts.test.js
Normal file
49
src/data/selectors/cohorts.test.js
Normal 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([]);
|
||||
});
|
||||
});
|
||||
@@ -1,38 +1,62 @@
|
||||
const getFilters = state => state.filters || {};
|
||||
import simpleSelectorFactory from '../utils';
|
||||
|
||||
const getAssignmentsFromResultsSubstate = results => (results[0] || {}).section_breakdown || [];
|
||||
const allFilters = (state) => state.filters || {};
|
||||
|
||||
const getAssignmentsFromResultsSubstate = (results) => (
|
||||
(results[0] || {}).section_breakdown || []
|
||||
);
|
||||
|
||||
const selectableAssignments = (state) => {
|
||||
const selectedAssignmentType = getFilters(state).assignmentType;
|
||||
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,
|
||||
const chooseRelevantAssignmentData = ({
|
||||
label,
|
||||
subsection_name: subsectionLabel,
|
||||
category,
|
||||
module_id: id,
|
||||
}) => ({
|
||||
label, subsectionLabel, category, id,
|
||||
});
|
||||
|
||||
const selectableAssignmentLabels = state => selectableAssignments(state).map(chooseRelevantAssignmentData);
|
||||
const selectableAssignmentLabels = (state) => (
|
||||
selectableAssignments(state).map(chooseRelevantAssignmentData)
|
||||
);
|
||||
|
||||
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;
|
||||
};
|
||||
const simpleSelectors = simpleSelectorFactory(
|
||||
({ filters }) => filters,
|
||||
[
|
||||
'assignment',
|
||||
'assignmentGradeMax',
|
||||
'assignmentGradeMin',
|
||||
'assignmentType',
|
||||
'cohort',
|
||||
'courseGradeMax',
|
||||
'courseGradeMin',
|
||||
'track',
|
||||
'includeCourseRoleMembers',
|
||||
],
|
||||
);
|
||||
const selectedAssignmentId = (state) => (simpleSelectors.assignment(state) || {}).id;
|
||||
const selectedAssignmentLabel = (state) => (simpleSelectors.assignment(state) || {}).label;
|
||||
|
||||
export {
|
||||
const selectors = {
|
||||
...simpleSelectors,
|
||||
selectedAssignmentId,
|
||||
selectedAssignmentLabel,
|
||||
selectableAssignmentLabels,
|
||||
selectableAssignments,
|
||||
getFilters,
|
||||
typeOfSelectedAssignment,
|
||||
allFilters,
|
||||
chooseRelevantAssignmentData,
|
||||
getAssignmentsFromResultsSubstate,
|
||||
};
|
||||
|
||||
export default selectors;
|
||||
|
||||
137
src/data/selectors/filters.test.js
Normal file
137
src/data/selectors/filters.test.js
Normal file
@@ -0,0 +1,137 @@
|
||||
import selectors from './filters';
|
||||
|
||||
const selectedAssignmentInfo = {
|
||||
category: '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('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('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([]);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
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';
|
||||
|
||||
const getRowsProcessed = (data) => {
|
||||
const {
|
||||
@@ -15,26 +16,25 @@ const getRowsProcessed = (data) => {
|
||||
};
|
||||
};
|
||||
|
||||
const transformHistoryEntry = (historyRow) => {
|
||||
const {
|
||||
modified,
|
||||
original_filename: originalFilename,
|
||||
data,
|
||||
...rest
|
||||
} = historyRow;
|
||||
const transformHistoryEntry = ({
|
||||
modified,
|
||||
original_filename: originalFilename,
|
||||
data,
|
||||
...rest
|
||||
}) => ({
|
||||
timeUploaded: formatDateForDisplay(new Date(modified)),
|
||||
originalFilename,
|
||||
summaryOfRowsProcessed: getRowsProcessed(data),
|
||||
...rest,
|
||||
});
|
||||
|
||||
const timeUploaded = formatDateForDisplay(new Date(modified));
|
||||
const summaryOfRowsProcessed = getRowsProcessed(data);
|
||||
const bulkManagementHistory = ({ grades: { bulkManagement } }) => (
|
||||
(bulkManagement.history || [])
|
||||
);
|
||||
|
||||
return {
|
||||
timeUploaded,
|
||||
originalFilename,
|
||||
summaryOfRowsProcessed,
|
||||
...rest,
|
||||
};
|
||||
};
|
||||
const getBulkManagementHistoryFromState = state => state.grades.bulkManagement.history || [];
|
||||
const getBulkManagementHistory = state => getBulkManagementHistoryFromState(state).map(transformHistoryEntry);
|
||||
const bulkManagementHistoryEntries = (state) => (
|
||||
bulkManagementHistory(state).map(transformHistoryEntry)
|
||||
);
|
||||
|
||||
const headingMapper = (category, label = 'All') => {
|
||||
const filters = {
|
||||
@@ -52,13 +52,13 @@ const headingMapper = (category, label = 'All') => {
|
||||
|
||||
return (entry) => {
|
||||
if (entry) {
|
||||
const results = ['Username', 'Email'];
|
||||
const results = [USERNAME_HEADING, EMAIL_HEADING];
|
||||
|
||||
const assignmentHeadings = entry
|
||||
.filter(filters[filter])
|
||||
.map(s => s.label);
|
||||
|
||||
const totals = ['Total'];
|
||||
const totals = [TOTAL_COURSE_GRADE_HEADING];
|
||||
|
||||
return results.concat(assignmentHeadings).concat(totals);
|
||||
}
|
||||
@@ -66,19 +66,9 @@ const headingMapper = (category, label = 'All') => {
|
||||
};
|
||||
};
|
||||
|
||||
const getExampleSectionBreakdown = state => (state.grades.results[0] || {}).section_breakdown || [];
|
||||
|
||||
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);
|
||||
};
|
||||
const getExampleSectionBreakdown = ({ grades }) => (
|
||||
(grades.results[0] || {}).section_breakdown || []
|
||||
);
|
||||
|
||||
const composeFilters = (...predicates) => (percentGrade, options = {}) => predicates.reduce((accum, predicate) => {
|
||||
if (predicate(percentGrade, options)) {
|
||||
@@ -101,20 +91,56 @@ const assignmentIdIsDefined = (percentGrade, { assignmentId }) => (
|
||||
|
||||
const formatMaxCourseGrade = composeFilters(percentGradeIsMax);
|
||||
const formatMinCourseGrade = composeFilters(percentGradeIsMin);
|
||||
|
||||
const formatMaxAssignmentGrade = composeFilters(
|
||||
percentGradeIsMax,
|
||||
assignmentIdIsDefined,
|
||||
);
|
||||
|
||||
const formatMinAssignmentGrade = composeFilters(
|
||||
percentGradeIsMin,
|
||||
assignmentIdIsDefined,
|
||||
);
|
||||
|
||||
export {
|
||||
getBulkManagementHistory,
|
||||
getHeadings,
|
||||
const simpleSelectors = simpleSelectorFactory(
|
||||
({ grades }) => grades,
|
||||
[
|
||||
'filteredUsersCount',
|
||||
'totalUsersCount',
|
||||
'gradeFormat',
|
||||
'showSpinner',
|
||||
'gradeOverrideCurrentEarnedGradedOverride',
|
||||
'gradeOverrideHistoryError',
|
||||
'gradeOriginalEarnedGraded',
|
||||
'gradeOriginalPossibleGraded',
|
||||
'showSuccess',
|
||||
],
|
||||
);
|
||||
|
||||
const allGrades = ({ grades: { results } }) => results;
|
||||
const uploadSuccess = ({ grades: { bulkManagement } }) => (!!bulkManagement && bulkManagement.uploadSuccess);
|
||||
|
||||
const bulkImportError = ({ grades: { bulkManagement } }) => (
|
||||
(!!bulkManagement && bulkManagement.errorMessages)
|
||||
? `Errors while processing: ${bulkManagement.errorMessages.join(', ')}`
|
||||
: ''
|
||||
);
|
||||
const gradeOverrides = ({ grades }) => grades.gradeOverrideHistoryResults;
|
||||
|
||||
const selectors = {
|
||||
bulkImportError,
|
||||
formatMinAssignmentGrade,
|
||||
formatMaxAssignmentGrade,
|
||||
formatMaxCourseGrade,
|
||||
formatMinCourseGrade,
|
||||
getExampleSectionBreakdown,
|
||||
headingMapper,
|
||||
|
||||
...simpleSelectors,
|
||||
allGrades,
|
||||
uploadSuccess,
|
||||
bulkManagementHistoryEntries,
|
||||
gradeOverrides,
|
||||
};
|
||||
|
||||
export default selectors;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getBulkManagementHistory } from './grades';
|
||||
import selectors from './grades';
|
||||
|
||||
const genericHistoryRow = {
|
||||
id: 5,
|
||||
@@ -15,14 +15,243 @@ const genericHistoryRow = {
|
||||
},
|
||||
};
|
||||
|
||||
describe('getBulkManagementHistory', () => {
|
||||
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('bulkImportError', () => {
|
||||
it('returns an empty string when bulkManagement not run', () => {
|
||||
const result = selectors.bulkImportError({ grades: { bulkManagement: null } });
|
||||
expect(result).toEqual('');
|
||||
});
|
||||
|
||||
it('returns an empty string when bulkManagement runs without error', () => {
|
||||
const result = selectors.bulkImportError({ grades: { bulkManagement: { uploadSuccess: true } } });
|
||||
expect(result).toEqual('');
|
||||
});
|
||||
|
||||
it('returns error string when bulkManagement encounters an error', () => {
|
||||
const errorMessages = ['error1', 'also error2'];
|
||||
const expectedErrorString = `Errors while processing: ${errorMessages[0]}, ${errorMessages[1]}`;
|
||||
const result = selectors.bulkImportError({ grades: { bulkManagement: { errorMessages } } });
|
||||
expect(result).toEqual(expectedErrorString);
|
||||
});
|
||||
});
|
||||
|
||||
describe('grade formatters', () => {
|
||||
const selectedAssignment = { assignmentId: 'block-v1:edX+type@sequential+block@abcde' };
|
||||
|
||||
describe('formatMinAssignmentGrade', () => {
|
||||
const defaultGrade = '0';
|
||||
const modifiedGrade = '1';
|
||||
|
||||
it('passes numbers through when grade is not default (0) and assignment is supplied', () => {
|
||||
const formattedMinAssignmentGrade = selectors.formatMinAssignmentGrade(modifiedGrade, selectedAssignment);
|
||||
expect(formattedMinAssignmentGrade).toEqual(modifiedGrade);
|
||||
});
|
||||
it('ignores grade when unmodified from default (0)', () => {
|
||||
const formattedMinAssignmentGrade = selectors.formatMinAssignmentGrade(defaultGrade, selectedAssignment);
|
||||
expect(formattedMinAssignmentGrade).toEqual(null);
|
||||
});
|
||||
it('ignores grade when an assignment is not supplied', () => {
|
||||
const formattedMinAssignmentGrade = selectors.formatMinAssignmentGrade(modifiedGrade, {});
|
||||
expect(formattedMinAssignmentGrade).toEqual(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatMaxAssignmentGrade', () => {
|
||||
const defaultGrade = '100';
|
||||
const modifiedGrade = '99';
|
||||
|
||||
it('passes numbers through when grade is not default (100) and assignment is supplied', () => {
|
||||
const formattedMaxAssignmentGrade = selectors.formatMaxAssignmentGrade(modifiedGrade, selectedAssignment);
|
||||
expect(formattedMaxAssignmentGrade).toEqual(modifiedGrade);
|
||||
});
|
||||
it('ignores grade when unmodified from default (100)', () => {
|
||||
const formattedMaxAssignmentGrade = selectors.formatMaxAssignmentGrade(defaultGrade, selectedAssignment);
|
||||
expect(formattedMaxAssignmentGrade).toEqual(null);
|
||||
});
|
||||
it('ignores grade when an assignment is not supplied', () => {
|
||||
const formattedMaxAssignmentGrade = selectors.formatMaxAssignmentGrade(modifiedGrade, {});
|
||||
expect(formattedMaxAssignmentGrade).toEqual(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatMinCourseGrade', () => {
|
||||
const defaultGrade = '0';
|
||||
const modifiedGrade = '37';
|
||||
|
||||
it('passes numbers through when grade is not default (0) and assignment is supplied', () => {
|
||||
const formattedMinGrade = selectors.formatMinCourseGrade(modifiedGrade, selectedAssignment);
|
||||
expect(formattedMinGrade).toEqual(modifiedGrade);
|
||||
});
|
||||
it('ignores grade when unmodified from default (0)', () => {
|
||||
const formattedMinGrade = selectors.formatMinCourseGrade(defaultGrade, selectedAssignment);
|
||||
expect(formattedMinGrade).toEqual(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatMaxCourseGrade', () => {
|
||||
const defaultGrade = '100';
|
||||
const modifiedGrade = '42';
|
||||
|
||||
it('passes numbers through when grade is not default (100) and assignment is supplied', () => {
|
||||
const formattedMaxGrade = selectors.formatMaxCourseGrade(modifiedGrade, selectedAssignment);
|
||||
expect(formattedMaxGrade).toEqual(modifiedGrade);
|
||||
});
|
||||
it('ignores unmodified grades', () => {
|
||||
const formattedMaxGrade = selectors.formatMaxCourseGrade(defaultGrade, selectedAssignment);
|
||||
expect(formattedMaxGrade).toEqual(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExampleSectionBreakdown', () => {
|
||||
const gradesData = {
|
||||
next: null,
|
||||
previous: null,
|
||||
results: [
|
||||
{
|
||||
section_breakdown: [
|
||||
{
|
||||
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)',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it('returns an empty array when results are unavailable', () => {
|
||||
const result = selectors.getExampleSectionBreakdown({ grades: { results: [{}] } });
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns an empty array when breakdowns are unavailable', () => {
|
||||
const result = selectors.getExampleSectionBreakdown({ grades: { results: [{ foo: 'bar' }] } });
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('gets section breakdown when available', () => {
|
||||
const result = selectors.getExampleSectionBreakdown({ grades: gradesData });
|
||||
expect(result).toEqual([{
|
||||
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)',
|
||||
}]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('headingMapper', () => {
|
||||
const expectedHeaders = (subsectionLabels) => (['Username', 'Email', ...subsectionLabels, 'Total Grade (%)']);
|
||||
|
||||
it('creates headers for all assignments when no filtering is applied', () => {
|
||||
const allSubsectionLabels = ['HW 01', 'HW 02', 'Lab 01'];
|
||||
const headingMapper = selectors.headingMapper('All');
|
||||
const headers = headingMapper(genericResultsRows);
|
||||
expect(headers).toEqual(expectedHeaders(allSubsectionLabels));
|
||||
});
|
||||
it('creates headers for only matching assignment types when type filter is applied', () => {
|
||||
const homeworkHeaders = ['HW 01', 'HW 02'];
|
||||
const headingMapper = selectors.headingMapper('Homework');
|
||||
const headers = headingMapper(genericResultsRows);
|
||||
expect(headers).toEqual(expectedHeaders(homeworkHeaders));
|
||||
});
|
||||
it('creates headers for only matching assignment when label filter is applied', () => {
|
||||
const homeworkHeader = ['HW 02'];
|
||||
const headingMapper = selectors.headingMapper('Homework', 'HW 02');
|
||||
const headers = headingMapper(genericResultsRows);
|
||||
expect(headers).toEqual(expectedHeaders(homeworkHeader));
|
||||
});
|
||||
it('returns an empty array when no entries are passed', () => {
|
||||
const headingMapper = selectors.headingMapper('All');
|
||||
const headers = headingMapper(undefined);
|
||||
expect(headers).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('simpleSelectors', () => {
|
||||
const simpleSelectorState = {
|
||||
grades: {
|
||||
filteredUsersCount: 9000,
|
||||
totalUsersCount: 9001,
|
||||
gradeFormat: 'percent',
|
||||
showSpinner: false,
|
||||
gradeOverrideCurrentEarnedGradedOverride: null,
|
||||
gradeOverrideHistoryError: null,
|
||||
gradeOriginalEarnedGraded: null,
|
||||
gradeOriginalPossibleGraded: null,
|
||||
showSuccess: false,
|
||||
},
|
||||
};
|
||||
|
||||
it('selects simple data by name from grades state', () => {
|
||||
const expectedFilteredUsers = 9000;
|
||||
const expectedTotalUsers = 9001;
|
||||
const expectedGradeFormat = 'percent';
|
||||
|
||||
// the selector factory is already tested, this just exercises some of these mappings
|
||||
expect(selectors.filteredUsersCount(simpleSelectorState)).toEqual(expectedFilteredUsers);
|
||||
expect(selectors.totalUsersCount(simpleSelectorState)).toEqual(expectedTotalUsers);
|
||||
expect(selectors.gradeFormat(simpleSelectorState)).toEqual(expectedGradeFormat);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadSuccess', () => {
|
||||
it('shows an upload success when bulk management data returned and completed successfully', () => {
|
||||
const uploadSuccess = selectors.uploadSuccess({ grades: { bulkManagement: { uploadSuccess: true } } });
|
||||
expect(uploadSuccess).toEqual(true);
|
||||
});
|
||||
it('returns false when bulk management data not returned', () => {
|
||||
const uploadSuccess = selectors.uploadSuccess({ grades: {} });
|
||||
expect(uploadSuccess).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulkManagementHistoryEntries', () => {
|
||||
it('handles history being as-yet unloaded', () => {
|
||||
const result = getBulkManagementHistory({ grades: { bulkManagement: {} } });
|
||||
const result = selectors.bulkManagementHistoryEntries({ grades: { bulkManagement: {} } });
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('formats dates for us', () => {
|
||||
const result = getBulkManagementHistory({
|
||||
const result = selectors.bulkManagementHistoryEntries({
|
||||
grades: {
|
||||
bulkManagement: {
|
||||
history: [
|
||||
@@ -37,7 +266,7 @@ describe('getBulkManagementHistory', () => {
|
||||
});
|
||||
|
||||
const exerciseGetRowsProcessed = (input, expectation) => {
|
||||
const result = getBulkManagementHistory({
|
||||
const result = selectors.bulkManagementHistoryEntries({
|
||||
grades: {
|
||||
bulkManagement: {
|
||||
history: [
|
||||
@@ -50,7 +279,7 @@ describe('getBulkManagementHistory', () => {
|
||||
expect(summaryOfRowsProcessed).toEqual(expect.objectContaining(expectation));
|
||||
};
|
||||
|
||||
it('calculates skippage', () => {
|
||||
it('calculates skipped rows', () => {
|
||||
exerciseGetRowsProcessed({
|
||||
total_rows: 100,
|
||||
processed_rows: 10,
|
||||
|
||||
71
src/data/selectors/index.js
Normal file
71
src/data/selectors/index.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import LmsApiService from 'data/services/LmsApiService';
|
||||
|
||||
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';
|
||||
|
||||
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)),
|
||||
});
|
||||
|
||||
const gradeExportUrl = (state, { courseId }) => (
|
||||
LmsApiService.getGradeExportCsvUrl(courseId, {
|
||||
...lmsApiServiceArgs(state),
|
||||
excludeCourseRoles: filters.includeCourseRoleMembers(state) ? '' : 'all',
|
||||
})
|
||||
);
|
||||
|
||||
const interventionExportUrl = (state, { courseId }) => (
|
||||
LmsApiService.getInterventionExportCsvUrl(
|
||||
courseId,
|
||||
lmsApiServiceArgs(state),
|
||||
)
|
||||
);
|
||||
|
||||
const showBulkManagement = (state, { courseId }) => (
|
||||
special.hasSpecialBulkManagementAccess(courseId)
|
||||
|| (tracks.stateHasMastersTrack(state) && state.config.bulkManagementAvailable)
|
||||
);
|
||||
|
||||
const shouldShowSpinner = (state) => {
|
||||
const canView = roles.canUserViewGradebook(state);
|
||||
return canView && grades.showSpinner(state);
|
||||
};
|
||||
|
||||
const getHeadings = (state) => grades.headingMapper(
|
||||
filters.assignmentType(state) || 'All',
|
||||
filters.selectedAssignmentLabel(state) || 'All',
|
||||
)(grades.getExampleSectionBreakdown(state));
|
||||
|
||||
export default {
|
||||
root: {
|
||||
getHeadings,
|
||||
gradeExportUrl,
|
||||
interventionExportUrl,
|
||||
showBulkManagement,
|
||||
shouldShowSpinner,
|
||||
},
|
||||
assignmentTypes,
|
||||
cohorts,
|
||||
filters,
|
||||
grades,
|
||||
roles,
|
||||
special,
|
||||
tracks,
|
||||
};
|
||||
156
src/data/selectors/index.test.js
Normal file
156
src/data/selectors/index.test.js
Normal file
@@ -0,0 +1,156 @@
|
||||
import selectors from '.';
|
||||
import LmsApiService from '../services/LmsApiService';
|
||||
|
||||
describe('root', () => {
|
||||
const testCourseId = 'OxfordX+Time+Travel';
|
||||
|
||||
const mockAssignmentId = 'block-v1:edX+Term+type@sequential+block@1';
|
||||
const mockAssignmentType = 'Homework';
|
||||
const mockAssignmentLabel = 'HW 42';
|
||||
const mockCohort = 'test cohort';
|
||||
|
||||
const baseApiArgs = {
|
||||
assignment: mockAssignmentId,
|
||||
assignmentGradeMax: '99',
|
||||
assignmentGradeMin: '1',
|
||||
assignmentType: mockAssignmentType,
|
||||
cohort: mockCohort,
|
||||
courseGradeMax: '98',
|
||||
courseGradeMin: '2',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
selectors.cohorts.getCohortNameById = jest.fn(() => mockCohort);
|
||||
selectors.filters.assignmentType = jest.fn(() => mockAssignmentType);
|
||||
selectors.filters.includeCourseRoleMembers = jest.fn();
|
||||
selectors.filters.selectedAssignmentId = jest.fn(() => mockAssignmentId);
|
||||
selectors.grades.formatMaxAssignmentGrade = jest.fn(() => '99');
|
||||
selectors.grades.formatMinAssignmentGrade = jest.fn(() => '1');
|
||||
selectors.grades.formatMaxCourseGrade = jest.fn(() => '98');
|
||||
selectors.grades.formatMinCourseGrade = jest.fn(() => '2');
|
||||
|
||||
// Internal functions, intentionally left blank
|
||||
selectors.filters.assignmentGradeMax = jest.fn();
|
||||
selectors.filters.assignmentGradeMin = jest.fn();
|
||||
selectors.filters.cohort = jest.fn();
|
||||
selectors.filters.courseGradeMin = jest.fn();
|
||||
selectors.filters.courseGradeMax = jest.fn();
|
||||
});
|
||||
|
||||
describe('getHeadings', () => {
|
||||
const mockHeadingMapper = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
// Note: the mock setup for this is gross which I'd argue speaks to the need for refactoring.
|
||||
mockHeadingMapper.mockReturnValue(() => (() => []));
|
||||
|
||||
selectors.grades.headingMapper = mockHeadingMapper;
|
||||
selectors.filters.selectedAssignmentLabel = jest.fn();
|
||||
selectors.grades.getExampleSectionBreakdown = jest.fn();
|
||||
});
|
||||
|
||||
it('uses all assignment types for creating headings when no type/assignment filters are supplied', () => {
|
||||
selectors.filters.assignmentType.mockReturnValue(undefined);
|
||||
selectors.filters.selectedAssignmentLabel.mockReturnValue(undefined);
|
||||
selectors.root.getHeadings({});
|
||||
expect(mockHeadingMapper).toHaveBeenCalledWith('All', 'All');
|
||||
});
|
||||
|
||||
it('filters headings by assignment type when type filter is applied', () => {
|
||||
selectors.filters.assignmentType.mockReturnValue(mockAssignmentType);
|
||||
selectors.filters.selectedAssignmentLabel.mockReturnValue(undefined);
|
||||
selectors.root.getHeadings({});
|
||||
expect(mockHeadingMapper).toHaveBeenCalledWith(mockAssignmentType, 'All');
|
||||
});
|
||||
|
||||
it('filters headings by assignment when a type and assignment filter are applied', () => {
|
||||
selectors.filters.assignmentType.mockReturnValue(mockAssignmentType);
|
||||
selectors.filters.selectedAssignmentLabel.mockReturnValue(mockAssignmentLabel);
|
||||
selectors.root.getHeadings({});
|
||||
expect(mockHeadingMapper).toHaveBeenCalledWith(mockAssignmentType, mockAssignmentLabel);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gradeExportUrl', () => {
|
||||
it('calls the API service with the right args, excluding all course roles', () => {
|
||||
const testState = {};
|
||||
const mockGetExportUrl = jest.fn();
|
||||
const expectedApiArgs = { ...baseApiArgs, excludeCourseRoles: 'all' };
|
||||
|
||||
LmsApiService.getGradeExportCsvUrl = mockGetExportUrl;
|
||||
|
||||
selectors.root.gradeExportUrl(testState, { courseId: testCourseId });
|
||||
expect(mockGetExportUrl).toHaveBeenCalledWith(testCourseId, expectedApiArgs);
|
||||
});
|
||||
it('calls the API service with the right args, including course roles when the option is selected', () => {
|
||||
const testState = {};
|
||||
const mockGetExportUrl = jest.fn();
|
||||
const expectedApiArgs = { ...baseApiArgs, excludeCourseRoles: '' };
|
||||
selectors.filters.includeCourseRoleMembers.mockReturnValue(true);
|
||||
|
||||
LmsApiService.getGradeExportCsvUrl = mockGetExportUrl;
|
||||
|
||||
selectors.root.gradeExportUrl(testState, { courseId: testCourseId });
|
||||
expect(mockGetExportUrl).toHaveBeenCalledWith(testCourseId, expectedApiArgs);
|
||||
});
|
||||
});
|
||||
|
||||
describe('interventionExportUrl', () => {
|
||||
it('calls the API service with the right args', () => {
|
||||
const testState = {};
|
||||
const mockGetExportUrl = jest.fn();
|
||||
|
||||
LmsApiService.getInterventionExportCsvUrl = mockGetExportUrl;
|
||||
|
||||
selectors.root.interventionExportUrl(testState, { courseId: testCourseId });
|
||||
expect(mockGetExportUrl).toHaveBeenCalledWith(testCourseId, baseApiArgs);
|
||||
});
|
||||
});
|
||||
|
||||
describe('showBulkManagement', () => {
|
||||
let state = {};
|
||||
const mockCourseInfo = { courseId: 'foo' };
|
||||
|
||||
beforeEach(() => {
|
||||
const templateState = { config: { bulkManagementAvailable: true } };
|
||||
state = { ...templateState };
|
||||
|
||||
selectors.special.hasSpecialBulkManagementAccess = jest.fn(() => (false));
|
||||
selectors.tracks.stateHasMastersTrack = jest.fn(() => (false));
|
||||
});
|
||||
|
||||
it('does not show bulk management when the course does not have a masters track', () => {
|
||||
expect(selectors.root.showBulkManagement(state, mockCourseInfo)).toEqual(false);
|
||||
});
|
||||
it('shows bulk management when the course has a masters track', () => {
|
||||
selectors.tracks.stateHasMastersTrack = jest.fn(() => (true));
|
||||
expect(selectors.root.showBulkManagement(state, mockCourseInfo)).toEqual(true);
|
||||
});
|
||||
it('shows bulk management when a course is configured for special access, regardless of other settings', () => {
|
||||
selectors.special.hasSpecialBulkManagementAccess = jest.fn(() => (true));
|
||||
expect(selectors.root.showBulkManagement(state, mockCourseInfo)).toEqual(true);
|
||||
});
|
||||
it('does not show bulk management when bulk management is not available', () => {
|
||||
selectors.tracks.stateHasMastersTrack = jest.fn(() => (true));
|
||||
state.config.bulkManagementAvailable = false;
|
||||
expect(selectors.root.showBulkManagement(state, mockCourseInfo)).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldShowSpinner', () => {
|
||||
it('does not show the spinner if the user cannot view Gradebook', () => {
|
||||
selectors.roles.canUserViewGradebook = jest.fn(() => (false));
|
||||
expect(selectors.root.shouldShowSpinner()).toEqual(false);
|
||||
});
|
||||
it('shows the spinner when a grades task is processing', () => {
|
||||
selectors.roles.canUserViewGradebook = jest.fn(() => (true));
|
||||
selectors.grades.showSpinner = jest.fn(() => (true));
|
||||
expect(selectors.root.shouldShowSpinner()).toEqual(true);
|
||||
});
|
||||
it('stops showing the spinner when a grades task is not processing', () => {
|
||||
selectors.roles.canUserViewGradebook = jest.fn(() => (true));
|
||||
selectors.grades.showSpinner = jest.fn(() => (false));
|
||||
expect(selectors.root.shouldShowSpinner()).toEqual(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
5
src/data/selectors/roles.js
Normal file
5
src/data/selectors/roles.js
Normal file
@@ -0,0 +1,5 @@
|
||||
const selectors = {
|
||||
canUserViewGradebook: ({ roles }) => roles.canUserViewGradebook,
|
||||
};
|
||||
|
||||
export default selectors;
|
||||
23
src/data/selectors/roles.test.js
Normal file
23
src/data/selectors/roles.test.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import selectors from './roles';
|
||||
|
||||
describe('canUserViewGradebook', () => {
|
||||
it('returns true if the user has the canUserViewGradebook role', () => {
|
||||
const canUserViewGradebook = selectors.canUserViewGradebook({
|
||||
roles: {
|
||||
canUserViewGradebook: true,
|
||||
canUserDoTheMonsterMash: false,
|
||||
},
|
||||
});
|
||||
expect(canUserViewGradebook).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns false if the user does not have the canUserViewGradebook role', () => {
|
||||
const canUserViewGradebook = selectors.canUserViewGradebook({
|
||||
roles: {
|
||||
canUserViewGradebook: false,
|
||||
canUserDoTheMonsterMash: true,
|
||||
},
|
||||
});
|
||||
expect(canUserViewGradebook).toBeFalsy();
|
||||
});
|
||||
});
|
||||
@@ -3,10 +3,11 @@
|
||||
// Note that this does not affect whether or not the backend
|
||||
// LMS API will permit usage of the tool.
|
||||
|
||||
const hasSpecialBulkManagementAccess = courseId => {
|
||||
const specialIdList = process.env.BULK_MANAGEMENT_SPECIAL_ACCESS_COURSE_IDS || '';
|
||||
return specialIdList.split(',').includes(courseId);
|
||||
const selectors = {
|
||||
hasSpecialBulkManagementAccess: (courseId) => {
|
||||
const specialIdList = process.env.BULK_MANAGEMENT_SPECIAL_ACCESS_COURSE_IDS || '';
|
||||
return specialIdList.split(',').includes(courseId);
|
||||
},
|
||||
};
|
||||
|
||||
export { hasSpecialBulkManagementAccess };
|
||||
export default hasSpecialBulkManagementAccess;
|
||||
export default selectors;
|
||||
|
||||
27
src/data/selectors/special.test.js
Normal file
27
src/data/selectors/special.test.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import selectors from './special';
|
||||
|
||||
describe('hasSpecialBulkManagementAccess', () => {
|
||||
// Copy & restore process for testing purposes
|
||||
const OLD_ENV = process.env;
|
||||
const allowedCourses = ['edX/DemoX/2021T1', 'edX/DemoX/2021T2'];
|
||||
const nonSpecialAccessCourse = 'edx/normal/course';
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...OLD_ENV };
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env = OLD_ENV;
|
||||
});
|
||||
|
||||
it('returns true if the course has special access to bulk management', () => {
|
||||
process.env.BULK_MANAGEMENT_SPECIAL_ACCESS_COURSE_IDS = `${allowedCourses.join(',')}`;
|
||||
const hasSpecialBulkManagementAccess = selectors.hasSpecialBulkManagementAccess(allowedCourses[0]);
|
||||
expect(hasSpecialBulkManagementAccess).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns false if the course does not have special access to bulk management', () => {
|
||||
const hasSpecialBulkManagementAccess = selectors.hasSpecialBulkManagementAccess(nonSpecialAccessCourse);
|
||||
expect(hasSpecialBulkManagementAccess).toBeFalsy();
|
||||
});
|
||||
});
|
||||
@@ -3,10 +3,16 @@ const compose = (...fns) => {
|
||||
return (...args) => rest.reduce((accum, fn) => fn(accum), firstFunc(...args));
|
||||
};
|
||||
|
||||
const getTracks = state => state.tracks.results || [];
|
||||
const allTracks = state => state.tracks.results || [];
|
||||
const trackIsMasters = track => track.slug === 'masters';
|
||||
const hasMastersTrack = tracks => tracks.some(trackIsMasters);
|
||||
const stateHasMastersTrack = compose(hasMastersTrack, getTracks);
|
||||
const stateHasMastersTrack = compose(hasMastersTrack, allTracks);
|
||||
|
||||
export { hasMastersTrack, trackIsMasters };
|
||||
export default stateHasMastersTrack;
|
||||
const selectors = {
|
||||
allTracks,
|
||||
hasMastersTrack,
|
||||
stateHasMastersTrack,
|
||||
trackIsMasters,
|
||||
};
|
||||
|
||||
export default selectors;
|
||||
|
||||
88
src/data/selectors/tracks.test.js
Normal file
88
src/data/selectors/tracks.test.js
Normal file
@@ -0,0 +1,88 @@
|
||||
import selectors from './tracks';
|
||||
|
||||
const nonMastersTrack = {
|
||||
slug: 'honor',
|
||||
name: 'Honor Code Certificate',
|
||||
min_price: 0,
|
||||
suggested_prices: '',
|
||||
currency: 'usd',
|
||||
expiration_datetime: null,
|
||||
description: null,
|
||||
sku: null,
|
||||
bulk_sku: null,
|
||||
};
|
||||
|
||||
const mastersTrack = {
|
||||
slug: 'masters',
|
||||
name: 'Masters track',
|
||||
min_price: 0,
|
||||
suggested_prices: 'a lot',
|
||||
currency: 'usd',
|
||||
expiration_datetime: null,
|
||||
description: null,
|
||||
sku: null,
|
||||
bulk_sku: null,
|
||||
};
|
||||
|
||||
const exampleTracksWithoutMasters = [nonMastersTrack];
|
||||
const exampleTracksWithMasters = [nonMastersTrack, mastersTrack];
|
||||
|
||||
describe('allTracks', () => {
|
||||
it('returns an empty array if no tracks found', () => {
|
||||
const allTracks = selectors.allTracks({ tracks: {} });
|
||||
expect(allTracks).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns tracks if included in result', () => {
|
||||
const allTracks = selectors.allTracks({ tracks: { results: exampleTracksWithoutMasters } });
|
||||
expect(allTracks).toEqual([
|
||||
{
|
||||
slug: 'honor',
|
||||
name: 'Honor Code Certificate',
|
||||
min_price: 0,
|
||||
suggested_prices: '',
|
||||
currency: 'usd',
|
||||
expiration_datetime: null,
|
||||
description: null,
|
||||
sku: null,
|
||||
bulk_sku: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasMastersTrack', () => {
|
||||
it('returns true if a masters track is present', () => {
|
||||
const hasMastersTrack = selectors.hasMastersTrack(exampleTracksWithMasters);
|
||||
expect(hasMastersTrack).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns false if a masters track is not present', () => {
|
||||
const hasMastersTrack = selectors.hasMastersTrack(exampleTracksWithoutMasters);
|
||||
expect(hasMastersTrack).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('stateHasMastersTrack', () => {
|
||||
it('returns true if a masters track is present', () => {
|
||||
const stateHasMastersTrack = selectors.stateHasMastersTrack({ tracks: { results: exampleTracksWithMasters } });
|
||||
expect(stateHasMastersTrack).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns false if a masters track is not present', () => {
|
||||
const stateHasMastersTrack = selectors.stateHasMastersTrack({ tracks: { results: exampleTracksWithoutMasters } });
|
||||
expect(stateHasMastersTrack).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('trackIsMasters', () => {
|
||||
it('returns true if track is a masters track', () => {
|
||||
const trackIsMasters = selectors.trackIsMasters(mastersTrack);
|
||||
expect(trackIsMasters).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns true if track is not a masters track', () => {
|
||||
const trackIsMasters = selectors.trackIsMasters(nonMastersTrack);
|
||||
expect(trackIsMasters).toBeFalsy();
|
||||
});
|
||||
});
|
||||
@@ -36,6 +36,9 @@ class LmsApiService {
|
||||
if (options.courseGradeMax) {
|
||||
queryParams.course_grade_max = options.courseGradeMax;
|
||||
}
|
||||
if (!options.includeCourseRoleMembers) {
|
||||
queryParams.excluded_course_roles = ['all'];
|
||||
}
|
||||
|
||||
const queryParamString = Object.keys(queryParams)
|
||||
.map(attr => `${attr}=${encodeURIComponent(queryParams[attr])}`)
|
||||
@@ -96,7 +99,7 @@ class LmsApiService {
|
||||
|
||||
static getGradeExportCsvUrl(courseId, options = {}) {
|
||||
const queryParams = ['track', 'cohort', 'assignment', 'assignmentType', 'assignmentGradeMax',
|
||||
'assignmentGradeMin', 'courseGradeMin', 'courseGradeMax']
|
||||
'assignmentGradeMin', 'courseGradeMin', 'courseGradeMax', 'excludedCourseRoles']
|
||||
.filter(opt => options[opt]
|
||||
&& options[opt] !== 'All')
|
||||
.map(opt => `${opt}=${encodeURIComponent(options[opt])}`)
|
||||
|
||||
19
src/data/utils.js
Normal file
19
src/data/utils.js
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Simple selector factory.
|
||||
* Takes a list of string keys, and returns a simple slector for each.
|
||||
*
|
||||
* @function
|
||||
* @param {Object|string[]} keys - If passed as object, Object.keys(keys) is used.
|
||||
* @return {Object} - object of `{[key]: ({key}) => key}`
|
||||
*/
|
||||
const simpleSelectorFactory = (transformer, keys) => {
|
||||
const selKeys = Array.isArray(keys) ? keys : Object.keys(keys);
|
||||
return selKeys.reduce(
|
||||
(obj, key) => ({
|
||||
...obj, [key]: (state) => transformer(state)[key],
|
||||
}),
|
||||
{ root: (state) => transformer(state) },
|
||||
);
|
||||
};
|
||||
|
||||
export default simpleSelectorFactory;
|
||||
27
src/data/utils.test.js
Normal file
27
src/data/utils.test.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import simpleSelectorFactory from './utils';
|
||||
|
||||
describe('Redux utilities - creators', () => {
|
||||
describe('simpleSelectors', () => {
|
||||
const data = { a: 1, b: 2, c: 3 };
|
||||
const state = {
|
||||
testGroup: data,
|
||||
other: 'stuff',
|
||||
};
|
||||
const transformer = ({ testGroup }) => testGroup;
|
||||
|
||||
test('given a list of strings, returns a dict w/ a simple selector per string', () => {
|
||||
const keys = ['a', 'b'];
|
||||
const selectors = simpleSelectorFactory(transformer, keys);
|
||||
expect(Object.keys(selectors)).toEqual(['root', ...keys]);
|
||||
expect(selectors.a(state)).toEqual(data.a);
|
||||
expect(selectors.b(state)).toEqual(data.b);
|
||||
});
|
||||
test('given an object for keys, returns a dict w/ simple selector per key', () => {
|
||||
const selectors = simpleSelectorFactory(transformer, data);
|
||||
expect(Object.keys(selectors)).toEqual(['root', ...Object.keys(data)]);
|
||||
expect(selectors.a(state)).toEqual(data.a);
|
||||
expect(selectors.b(state)).toEqual(data.b);
|
||||
expect(selectors.c(state)).toEqual(data.c);
|
||||
});
|
||||
});
|
||||
});
|
||||
13
webpack.dev.config.js
Normal file
13
webpack.dev.config.js
Normal file
@@ -0,0 +1,13 @@
|
||||
const path = require('path');
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
|
||||
const config = createConfig('webpack-dev');
|
||||
|
||||
config.resolve.modules = [
|
||||
path.resolve(__dirname, './src'),
|
||||
'node_modules',
|
||||
];
|
||||
|
||||
config.module.rules[0].exclude = /node_modules\/(?!(query-string|split-on-first|strict-uri-encode|@edx))/;
|
||||
|
||||
module.exports = config;
|
||||
13
webpack.prod.config.js
Normal file
13
webpack.prod.config.js
Normal file
@@ -0,0 +1,13 @@
|
||||
const path = require('path');
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
|
||||
const config = createConfig('webpack-prod');
|
||||
|
||||
config.resolve.modules = [
|
||||
path.resolve(__dirname, './src'),
|
||||
'node_modules',
|
||||
];
|
||||
|
||||
config.module.rules[0].exclude = /node_modules\/(?!(query-string|split-on-first|strict-uri-encode|@edx))/;
|
||||
|
||||
module.exports = config;
|
||||
Reference in New Issue
Block a user