diff --git a/.eslintrc b/.eslintrc
index 9e1d0b7..f643f69 100755
--- a/.eslintrc
+++ b/.eslintrc
@@ -16,7 +16,10 @@
"jsx-a11y/anchor-is-valid": [ "error", {
"components": [ "Link" ],
"specialLink": [ "to" ]
- }]
+ }],
+ // https://github.com/yannickcr/eslint-plugin-react/issues/1754#issuecomment-378838053
+ // tl;dr: this rule is no longer going to cause any user-facing visual weirdness, its original motivation
+ "react/no-did-mount-set-state": "off"
},
"env": {
"jest": true
diff --git a/src/App.scss b/src/App.scss
index 121e335..61c7c71 100755
--- a/src/App.scss
+++ b/src/App.scss
@@ -6,6 +6,7 @@ $fa-font-path: "~font-awesome/fonts";
$input-focus-box-shadow: $input-box-shadow; // hack to get upgrade to paragon 4.0.0 to work
@import "~@edx/paragon/src/SearchField/SearchField";
+@import "~@edx/paragon/src/Collapsible/Collapsible";
@import "~@edx/frontend-component-footer/src/lib/scss/site-footer";
diff --git a/src/components/Gradebook/index.jsx b/src/components/Gradebook/index.jsx
index c31f54f..3650490 100644
--- a/src/components/Gradebook/index.jsx
+++ b/src/components/Gradebook/index.jsx
@@ -2,13 +2,15 @@ import React from 'react';
import PropTypes from 'prop-types';
import {
Button,
- StatefulButton,
+ Collapsible,
+ Icon,
InputSelect,
+ InputText,
Modal,
SearchField,
+ StatefulButton,
StatusAlert,
Table,
- Icon,
Tabs,
} from '@edx/paragon';
import queryString from 'query-string';
@@ -33,6 +35,8 @@ export default class Gradebook extends React.Component {
updateModuleId: null,
updateUserId: null,
reasonForChange: '',
+ assignmentGradeMin: '',
+ assignmentGradeMax: '',
};
this.fileFormRef = React.createRef();
this.fileInputRef = React.createRef();
@@ -44,6 +48,8 @@ export default class Gradebook extends React.Component {
this.props.initializeFilters(urlQuery);
this.props.getRoles(this.props.courseId);
this.overrideReasonInput.focus();
+ const { assignmentGradeMin, assignmentGradeMax } = urlQuery;
+ this.setState({ assignmentGradeMin, assignmentGradeMax });
}
onChange(e) {
@@ -131,18 +137,25 @@ export default class Gradebook extends React.Component {
const { type, id } = selectedFilterOption || {};
const typedValue = { label: assignment, type, id };
this.props.updateAssignmentFilter(typedValue);
- const updatedQueryStrings = this.updateQueryParams('assignment', assignment);
- this.props.history.push(updatedQueryStrings);
- }
+ this.updateQueryParams({ assignment: id });
+ this.props.updateGradesIfAssigGradeFiltersSet(
+ this.props.courseId,
+ this.props.selectedCohort,
+ this.props.selectedTrack,
+ this.props.selectedAssignmentType,
+ );
+ };
- updateQueryParams = (queryKey, queryValue) => {
+ updateQueryParams = (queryParams) => {
const parsed = queryString.parse(this.props.location.search);
- if (queryValue) {
- parsed[queryKey] = queryValue;
- } else {
- delete parsed[queryKey];
- }
- return `?${queryString.stringify(parsed)}`;
+ Object.keys(queryParams).forEach((key) => {
+ if (queryParams[key]) {
+ parsed[key] = queryParams[key];
+ } else {
+ delete parsed[key];
+ }
+ });
+ this.props.history.push(`?${queryString.stringify(parsed)}`);
};
mapAssignmentTypeEntries = (entries) => {
@@ -210,8 +223,7 @@ export default class Gradebook extends React.Component {
updateAssignmentTypes = (assignmentType) => {
this.props.filterAssignmentType(assignmentType);
- const updatedQueryStrings = this.updateQueryParams('assignmentType', assignmentType);
- this.props.history.push(updatedQueryStrings);
+ this.updateQueryParams({ assignmentType });
}
updateTracks = (event) => {
@@ -226,8 +238,7 @@ export default class Gradebook extends React.Component {
selectedTrackSlug,
this.props.selectedAssignmentType,
);
- const updatedQueryStrings = this.updateQueryParams('track', selectedTrackSlug);
- this.props.history.push(updatedQueryStrings);
+ this.updateQueryParams({ track: selectedTrackSlug });
};
updateCohorts = (event) => {
@@ -242,8 +253,7 @@ export default class Gradebook extends React.Component {
this.props.selectedTrack,
this.props.selectedAssignmentType,
);
- const updatedQueryStrings = this.updateQueryParams('cohort', selectedCohortId);
- this.props.history.push(updatedQueryStrings);
+ this.updateQueryParams({ cohort: selectedCohortId });
};
handleClickExportGrades = () => {
@@ -274,6 +284,26 @@ export default class Gradebook extends React.Component {
}
};
+ handleSubmitAssignmentGrade = (event) => {
+ event.preventDefault();
+ const formContents = new FormData(event.target);
+ const assignmentGradeMin = formContents.get('assignmentGradeMin');
+ const assignmentGradeMax = formContents.get('assignmentGradeMax');
+
+ this.props.updateAssignmentLimits(assignmentGradeMin, assignmentGradeMax);
+ this.props.getUserGrades(
+ this.props.courseId,
+ this.props.selectedCohort,
+ this.props.selectedTrack,
+ this.props.selectedAssignmentType,
+ );
+ this.updateQueryParams({ assignmentGradeMin, assignmentGradeMax });
+ };
+
+ handleMinAssigGradeChange = assignmentGradeMin => this.setState({ assignmentGradeMin });
+
+ handleMaxAssigGradeChange = assignmentGradeMax => this.setState({ assignmentGradeMax });
+
mapSelectedCohortEntry = (entry) => {
const selectedCohortEntry = this.props.cohorts.find(x => x.id === parseInt(entry, 10));
if (selectedCohortEntry) {
@@ -406,32 +436,70 @@ export default class Gradebook extends React.Component {
options={[{ label: 'Percent', value: 'percent' }, { label: 'Absolute', value: 'absolute' }]}
onChange={this.props.toggleFormat}
/>
-
-
- Assignment Types:
-
-
-
-
-
- Assignment:
-
-
-
+
+
+
+
+ Assignment Types:
+
+
+
+
+
+ Assignment:
+
+
+
+
Grade Range (0% - 100%)
+
+
+
Student Groups:
@@ -723,6 +791,7 @@ Gradebook.propTypes = {
})),
filterAssignmentType: PropTypes.func.isRequired,
updateAssignmentFilter: PropTypes.func.isRequired,
+ updateAssignmentLimits: PropTypes.func.isRequired,
format: PropTypes.string.isRequired,
getRoles: PropTypes.func.isRequired,
getUserGrades: PropTypes.func.isRequired,
@@ -793,4 +862,5 @@ Gradebook.propTypes = {
filteredUsersCount: PropTypes.number,
showDownloadButtons: PropTypes.bool,
initializeFilters: PropTypes.func.isRequired,
+ updateGradesIfAssigGradeFiltersSet: PropTypes.func.isRequired,
};
diff --git a/src/containers/GradebookPage/index.jsx b/src/containers/GradebookPage/index.jsx
index 978dcfd..0ea92ce 100644
--- a/src/containers/GradebookPage/index.jsx
+++ b/src/containers/GradebookPage/index.jsx
@@ -2,21 +2,22 @@ import { connect } from 'react-redux';
import Gradebook from '../../components/Gradebook';
import {
- fetchGrades,
+ closeBanner,
fetchGradeOverrideHistory,
+ fetchGrades,
fetchMatchingUserGrades,
fetchPrevNextGrades,
- updateGrades,
- toggleGradeFormat,
filterAssignmentType,
- closeBanner,
submitFileUploadFormData,
+ toggleGradeFormat,
+ updateGrades,
+ updateGradesIfAssigGradeFiltersSet,
} from '../../data/actions/grades';
import { fetchCohorts } from '../../data/actions/cohorts';
import { fetchTracks } from '../../data/actions/tracks';
-import { initializeFilters, updateAssignmentFilter } from '../../data/actions/filters';
+import { initializeFilters, updateAssignmentFilter, updateAssignmentLimits } from '../../data/actions/filters';
import stateHasMastersTrack from '../../data/selectors/tracks';
-import { getBulkManagementHistory, getHeadings } from '../../data/selectors/grades';
+import { getBulkManagementHistory, getHeadings, formatMinAssigGrade, formatMaxAssigGrade } from '../../data/selectors/grades';
import { selectableAssignmentLabels } from '../../data/selectors/filters';
import { getCohortNameById } from '../../data/selectors/cohorts';
import { fetchAssignmentTypes } from '../../data/actions/assignmentTypes';
@@ -50,6 +51,8 @@ const mapStateToProps = (state, ownProps) => (
selectedCohort: state.filters.cohort,
selectedAssignmentType: state.filters.assignmentType,
selectedAssignment: (state.filters.assignment || {}).label,
+ selectedMinAssigGrade: state.filters.assignmentGradeMin || 0,
+ selectedMaxAssigGrade: state.filters.assignmentGradeMax || 100,
format: state.grades.gradeFormat,
showSuccess: state.grades.showSuccess,
errorFetchingGradeOverrideHistory: state.grades.errorFetchingOverrideHistory,
@@ -65,6 +68,16 @@ const mapStateToProps = (state, ownProps) => (
track: state.filters.track,
assignment: (state.filters.assignment || {}).id,
assignmentType: state.filters.assignmentType,
+ assignmentGradeMin: formatMinAssigGrade(
+ state,
+ (state.filters.assignment || {}).id,
+ state.filters.assignmentGradeMin,
+ ),
+ assignmentGradeMax: formatMaxAssigGrade(
+ state,
+ (state.filters.assignment || {}).id,
+ state.filters.assignmentGradeMax,
+ ),
}),
interventionExportUrl:
LmsApiService.getInterventionExportCsvUrl(ownProps.match.params.courseId),
@@ -98,6 +111,8 @@ const mapDispatchToProps = {
submitFileUploadFormData,
initializeFilters,
updateAssignmentFilter,
+ updateAssignmentLimits,
+ updateGradesIfAssigGradeFiltersSet,
};
const GradebookPage = connect(
diff --git a/src/data/actions/filters.js b/src/data/actions/filters.js
index e1eaa0e..89133b2 100644
--- a/src/data/actions/filters.js
+++ b/src/data/actions/filters.js
@@ -1,17 +1,21 @@
-import { INITIALIZE_FILTERS, UPDATE_ASSIGNMENT_FILTER } from '../constants/actionTypes/filters';
+import { INITIALIZE_FILTERS, UPDATE_ASSIGNMENT_FILTER, UPDATE_ASSIGNMENT_LIMITS } from '../constants/actionTypes/filters';
const initializeFilters = ({
assignment = '',
assignmentType = '',
track = '',
cohort = '',
+ assignmentGradeMin = '',
+ assignmentGradeMax = '',
}) => ({
type: INITIALIZE_FILTERS,
data: {
- assignment: { label: assignment },
+ assignment: { id: assignment },
assignmentType,
track,
cohort,
+ assignmentGradeMin,
+ assignmentGradeMax,
},
});
@@ -20,4 +24,9 @@ const updateAssignmentFilter = assignment => ({
data: assignment,
});
-export { initializeFilters, updateAssignmentFilter };
+const updateAssignmentLimits = (minGrade, maxGrade) => ({
+ type: UPDATE_ASSIGNMENT_LIMITS,
+ data: { minGrade, maxGrade },
+});
+
+export { initializeFilters, updateAssignmentFilter, updateAssignmentLimits };
diff --git a/src/data/actions/grades.js b/src/data/actions/grades.js
index 751243b..9ceaf21 100644
--- a/src/data/actions/grades.js
+++ b/src/data/actions/grades.js
@@ -20,6 +20,8 @@ import {
} from '../constants/actionTypes/grades';
import LmsApiService from '../services/LmsApiService';
import { sortAlphaAsc, formatDateForDisplay } from './utils';
+import { formatMaxAssigGrade, formatMinAssigGrade } from '../selectors/grades';
+import { getFilters } from '../selectors/filters';
import apiClient from '../apiClient';
const defaultAssignmentFilter = 'All';
@@ -102,9 +104,27 @@ const fetchGrades = (
assignmentType,
options = {},
) => (
- (dispatch) => {
+ (dispatch, getState) => {
dispatch(startedFetchingGrades());
- return LmsApiService.fetchGradebookData(courseId, options.searchText || null, cohort, track)
+ const {
+ assignment,
+ assignmentGradeMax: assigMax,
+ assignmentGradeMin: assigMin,
+ } = getFilters(getState());
+ const { id: assignmentId } = assignment || {};
+ const assignmentGradeMax = formatMaxAssigGrade(getState(), assignmentId, assigMax);
+ const assignmentGradeMin = formatMinAssigGrade(getState(), assignmentId, assigMin);
+ return LmsApiService.fetchGradebookData(
+ courseId,
+ options.searchText || null,
+ cohort,
+ track,
+ {
+ assignment: assignmentId,
+ assignmentGradeMax,
+ assignmentGradeMin,
+ },
+ )
.then(response => response.data)
.then((data) => {
dispatch(gotGrades({
@@ -244,6 +264,24 @@ const fetchBulkUpgradeHistory = courseId => (
}).catch(() => dispatch(bulkHistoryError()))
);
+const updateGradesIfAssigGradeFiltersSet = (
+ courseId,
+ cohort,
+ track,
+ assignmentType,
+) => (dispatch, getState) => {
+ const { filters } = getState();
+ const hasAssigGradeFiltersSet = filters.assignmentGradeMax || filters.assignmentGradeMin;
+ if (hasAssigGradeFiltersSet) {
+ dispatch(fetchGrades(
+ courseId,
+ cohort,
+ track,
+ assignmentType,
+ ));
+ }
+};
+
export {
startedFetchingGrades,
finishedFetchingGrades,
@@ -262,4 +300,5 @@ export {
submitFileUploadFormData,
fetchBulkUpgradeHistory,
fetchGradeOverrideHistory,
+ updateGradesIfAssigGradeFiltersSet,
};
diff --git a/src/data/constants/actionTypes/filters.js b/src/data/constants/actionTypes/filters.js
index 9d14480..e5ef029 100644
--- a/src/data/constants/actionTypes/filters.js
+++ b/src/data/constants/actionTypes/filters.js
@@ -1,4 +1,5 @@
const INITIALIZE_FILTERS = 'INITIALIZE_FILTERS';
const UPDATE_ASSIGNMENT_FILTER = 'UPDATE_ASSIGNMENT_FILTER';
+const UPDATE_ASSIGNMENT_LIMITS = 'UPDATE_ASSIGNMENT_LIMITS';
-export { INITIALIZE_FILTERS, UPDATE_ASSIGNMENT_FILTER };
+export { INITIALIZE_FILTERS, UPDATE_ASSIGNMENT_FILTER, UPDATE_ASSIGNMENT_LIMITS };
diff --git a/src/data/reducers/filters.js b/src/data/reducers/filters.js
index 9d8f34f..404d639 100644
--- a/src/data/reducers/filters.js
+++ b/src/data/reducers/filters.js
@@ -1,6 +1,6 @@
import { GOT_GRADES, FILTER_BY_ASSIGNMENT_TYPE } from '../constants/actionTypes/grades';
-import { INITIALIZE_FILTERS, UPDATE_ASSIGNMENT_FILTER } from '../constants/actionTypes/filters';
+import { INITIALIZE_FILTERS, UPDATE_ASSIGNMENT_FILTER, UPDATE_ASSIGNMENT_LIMITS } from '../constants/actionTypes/filters';
import { getAssignmentsFromResultsSubstate, chooseRelevantAssignmentData } from '../selectors/filters';
@@ -24,11 +24,11 @@ const reducer = (state = initialState, action) => {
};
case GOT_GRADES: {
const { assignment } = state;
- const { label, type } = assignment || {};
+ const { id, type } = assignment || {};
if (!type) {
const relevantAssignment = getAssignmentsFromResultsSubstate(action.grades)
.map(chooseRelevantAssignmentData)
- .find(assig => assig.label === label);
+ .find(assig => assig.id === id);
return {
...state,
track: action.track,
@@ -47,6 +47,12 @@ const reducer = (state = initialState, action) => {
...state,
assignment: action.data,
};
+ case UPDATE_ASSIGNMENT_LIMITS:
+ return {
+ ...state,
+ assignmentGradeMin: action.data.minGrade,
+ assignmentGradeMax: action.data.maxGrade,
+ };
default:
return state;
}
diff --git a/src/data/selectors/grades.js b/src/data/selectors/grades.js
index 792bfee..172484c 100644
--- a/src/data/selectors/grades.js
+++ b/src/data/selectors/grades.js
@@ -68,16 +68,31 @@ 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 = (state.grades.results[0] || {}).section_breakdown || [];
+ const assignments = getExampleSectionBreakdown(state);
const type = selectedAssignmentType || 'All';
const assignment = (selectedAssignment || {}).label || 'All';
return headingMapper(type, assignment)(assignments);
};
-export { getBulkManagementHistory, getHeadings };
+const formatMaxAssigGrade = (state, assignmentId, percentGrade) => {
+ if (percentGrade === '100') {
+ return null;
+ }
+ return percentGrade;
+};
+const formatMinAssigGrade = (state, assignmentId, percentGrade) => {
+ if (percentGrade === '0') {
+ return null;
+ }
+ return percentGrade;
+};
+
+export { getBulkManagementHistory, getHeadings, formatMinAssigGrade, formatMaxAssigGrade };
diff --git a/src/data/services/LmsApiService.js b/src/data/services/LmsApiService.js
index c20b3ad..d9db32c 100644
--- a/src/data/services/LmsApiService.js
+++ b/src/data/services/LmsApiService.js
@@ -5,19 +5,36 @@ class LmsApiService {
static baseUrl = configuration.LMS_BASE_URL;
static pageSize = 25
- static fetchGradebookData(courseId, searchText, cohort, track) {
- let gradebookUrl = `${LmsApiService.baseUrl}/api/grades/v1/gradebook/${courseId}/`;
-
- gradebookUrl += `?page_size=${LmsApiService.pageSize}&`;
+ static fetchGradebookData(courseId, searchText, cohort, track, options = {}) {
+ const queryParams = {};
+ queryParams.page_size = LmsApiService.pageSize;
if (searchText) {
- gradebookUrl += `user_contains=${searchText}&`;
+ queryParams.user_contains = searchText;
}
if (cohort) {
- gradebookUrl += `cohort_id=${cohort}&`;
+ queryParams.cohort_id = cohort;
}
if (track) {
- gradebookUrl += `enrollment_mode=${track}`;
+ queryParams.enrollment_mode = track;
}
+ if (options.assignmentGradeMax || options.assignmentGradeMin) {
+ if (!options.assignment) {
+ throw new Error('Gradebook LMS API requires assignment to be set to filter by min/max assig. grade');
+ }
+ queryParams.assignment = options.assignment;
+ if (options.assignmentGradeMin) {
+ queryParams.assignment_grade_min = options.assignmentGradeMin;
+ }
+ if (options.assignmentGradeMax) {
+ queryParams.assignment_grade_max = options.assignmentGradeMax;
+ }
+ }
+
+ const queryParamString = Object.keys(queryParams)
+ .map(attr => `${attr}=${encodeURIComponent(queryParams[attr])}`)
+ .join('&');
+ const gradebookUrl = `${LmsApiService.baseUrl}/api/grades/v1/gradebook/${courseId}/?${queryParamString}`;
+
return apiClient.get(gradebookUrl);
}
@@ -70,7 +87,7 @@ class LmsApiService {
}
static getGradeExportCsvUrl(courseId, options = {}) {
- const queryParams = ['track', 'cohort', 'assignment', 'assignmentType']
+ const queryParams = ['track', 'cohort', 'assignment', 'assignmentType', 'assignmentGradeMax', 'assignmentGradeMin']
.filter(opt => options[opt] &&
options[opt] !== 'All')
.map(opt => `${opt}=${encodeURIComponent(options[opt])}`)