From e787d3a6977bbd708eced97539a3f8e7e1838a1e Mon Sep 17 00:00:00 2001 From: Matt Hughes Date: Fri, 9 Aug 2019 17:24:59 -0400 Subject: [PATCH] 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 --- .eslintrc | 5 +- src/App.scss | 1 + src/components/Gradebook/index.jsx | 158 ++++++++++++++++------ src/containers/GradebookPage/index.jsx | 27 +++- src/data/actions/filters.js | 15 +- src/data/actions/grades.js | 43 +++++- src/data/constants/actionTypes/filters.js | 3 +- src/data/reducers/filters.js | 12 +- src/data/selectors/grades.js | 19 ++- src/data/services/LmsApiService.js | 33 +++-- 10 files changed, 246 insertions(+), 70 deletions(-) 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])}`)