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