Add filters for assignment grades

When assignment grade filters are set, changing which assignment is
chosen will also cause the grades to be refreshed from the
server (since the subset of learners being viewed may be different).

JIRA:EDUCATOR-4541
This commit is contained in:
Matt Hughes
2019-08-09 17:24:59 -04:00
parent d77737f132
commit e787d3a697
10 changed files with 246 additions and 70 deletions

View File

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

View File

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

View File

@@ -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}
/>
<div className="student-filters">
<span className="label">
Assignment Types:
</span>
<InputSelect
name="assignment-types"
ariaLabel="Assignment Types"
value={this.props.selectedAssignmentType}
options={this.mapAssignmentTypeEntries(this.props.assignmentTypes)}
onChange={this.updateAssignmentTypes}
disabled={this.props.assignmentFilterOptions.length === 0}
/>
</div>
<div className="student-filters">
<span className="label">
Assignment:
</span>
<InputSelect
name="assignment"
ariaLabel="Assignment"
value={this.props.selectedAssignment}
options={this.getAssignmentFilterOptions()}
onChange={this.handleAssignmentFilterChange}
disabled={this.props.assignmentFilterOptions.length === 0}
/>
</div>
<Collapsible title="Assignments" isOpen>
<div>
<div className="student-filters">
<span className="label">
Assignment Types:
</span>
<InputSelect
name="assignment-types"
ariaLabel="Assignment Types"
value={this.props.selectedAssignmentType}
options={this.mapAssignmentTypeEntries(this.props.assignmentTypes)}
onChange={this.updateAssignmentTypes}
disabled={this.props.assignmentFilterOptions.length === 0}
/>
</div>
<div className="student-filters">
<span className="label">
Assignment:
</span>
<InputSelect
name="assignment"
ariaLabel="Assignment"
value={this.props.selectedAssignment}
options={this.getAssignmentFilterOptions()}
onChange={this.handleAssignmentFilterChange}
disabled={this.props.assignmentFilterOptions.length === 0}
/>
</div>
<p>Grade Range (0% - 100%)</p>
<form className="d-flex justify-content-between" onSubmit={this.handleSubmitAssignmentGrade}>
<InputText
label="Min Grade"
name="assignmentGradeMin"
type="number"
min={0}
max={100}
step={1}
value={this.state.assignmentGradeMin}
disabled={!this.props.selectedAssignment}
onChange={this.handleMinAssigGradeChange}
/>
<InputText
label="Max Grade"
name="assignmentGradeMax"
type="number"
min={0}
max={100}
step={1}
value={this.state.assignmentGradeMax}
disabled={!this.props.selectedAssignment}
onChange={this.handleMaxAssigGradeChange}
/>
<div className="d-flex align-items-center">
<Button
type="submit"
className="btn-outline-secondary"
label="Apply"
name="assignmentGradeMinMax"
disabled={!this.props.selectedAssignment}
/>
</div>
</form>
</div>
</Collapsible>
<div className="student-filters">
<span className="label">
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,
};

View File

@@ -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(

View File

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

View File

@@ -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,
};

View File

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

View File

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

View File

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

View File

@@ -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])}`)