Add new filter for assignments

JIRA:EDUCATOR-4514

- Filter applies both to gradebook view and to CSV export used for bulk
  management.
- Fixes a bug with the cohort filter being applied to bulk management
  CSVs
- Sets us up to add new filters more easily
- New filter interoperates with existing assignment type filter to
  limit options
This commit is contained in:
Matt Hughes
2019-08-02 10:29:38 -04:00
parent 5b16a5dbb2
commit 9ed5c6cb34
18 changed files with 302 additions and 97 deletions

View File

@@ -41,7 +41,8 @@ export default class Gradebook extends React.Component {
componentDidMount() {
const urlQuery = queryString.parse(this.props.location.search);
this.props.getRoles(this.props.courseId, urlQuery);
this.props.initializeFilters(urlQuery);
this.props.getRoles(this.props.courseId);
this.overrideReasonInput.focus();
}
@@ -87,6 +88,17 @@ export default class Gradebook extends React.Component {
return ['Grades'];
};
getAssignmentFilterOptions = () => [
{ label: 'All', value: '' },
...this.props.assignmentFilterOptions.map((assignment) => {
const { label, subsectionLabel } = assignment;
return {
label: `${label}: ${subsectionLabel}`,
value: label,
};
}),
];
handleAdjustedGradeClick = () => {
this.props.updateGrades(
this.props.courseId, [
@@ -113,9 +125,23 @@ export default class Gradebook extends React.Component {
});
}
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);
const updatedQueryStrings = this.updateQueryParams('assignment', assignment);
this.props.history.push(updatedQueryStrings);
}
updateQueryParams = (queryKey, queryValue) => {
const parsed = queryString.parse(this.props.location.search);
parsed[queryKey] = queryValue;
if (queryValue) {
parsed[queryKey] = queryValue;
} else {
delete parsed[queryKey];
}
return `?${queryString.stringify(parsed)}`;
};
@@ -124,7 +150,7 @@ export default class Gradebook extends React.Component {
id: entry,
label: entry,
}));
mapped.unshift({ id: 0, label: 'All' });
mapped.unshift({ id: 0, label: 'All', value: '' });
return mapped;
};
@@ -182,9 +208,9 @@ export default class Gradebook extends React.Component {
return { resultsSummary, filename, ...rest };
};
updateAssignmentTypes = (event) => {
this.props.filterColumns(event, this.props.grades[0]);
const updatedQueryStrings = this.updateQueryParams('assignmentType', event);
updateAssignmentTypes = (assignmentType) => {
this.props.filterAssignmentType(assignmentType);
const updatedQueryStrings = this.updateQueryParams('assignmentType', assignmentType);
this.props.history.push(updatedQueryStrings);
}
@@ -216,7 +242,8 @@ export default class Gradebook extends React.Component {
this.props.selectedTrack,
this.props.selectedAssignmentType,
);
this.updateQueryParams('cohort', selectedCohortId);
const updatedQueryStrings = this.updateQueryParams('cohort', selectedCohortId);
this.props.history.push(updatedQueryStrings);
};
handleClickExportGrades = () => {
@@ -379,20 +406,32 @@ export default class Gradebook extends React.Component {
options={[{ label: 'Percent', value: 'percent' }, { label: 'Absolute', value: 'absolute' }]}
onChange={this.props.toggleFormat}
/>
{this.props.assignmentTypes.length > 0 &&
<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}
/>
</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>
<div className="student-filters">
<span className="label">
Student Groups:
@@ -643,6 +682,7 @@ export default class Gradebook extends React.Component {
Gradebook.defaultProps = {
areGradesFrozen: false,
assignmentTypes: [],
assignmentFilterOptions: [],
canUserViewGradebook: false,
cohorts: [],
grades: [],
@@ -655,7 +695,8 @@ Gradebook.defaultProps = {
courseId: '',
selectedCohort: null,
selectedTrack: null,
selectedAssignmentType: 'All',
selectedAssignmentType: '',
selectedAssignment: '',
showSpinner: false,
tracks: [],
bulkImportError: '',
@@ -671,9 +712,17 @@ Gradebook.defaultProps = {
Gradebook.propTypes = {
areGradesFrozen: PropTypes.bool,
assignmentTypes: PropTypes.arrayOf(PropTypes.string),
assignmentFilterOptions: PropTypes.arrayOf(PropTypes.shape({
label: PropTypes.string,
subsectionLabel: PropTypes.string,
})),
canUserViewGradebook: PropTypes.bool,
cohorts: PropTypes.arrayOf(PropTypes.string),
filterColumns: PropTypes.func.isRequired,
cohorts: PropTypes.arrayOf(PropTypes.shape({
name: PropTypes.string,
id: PropTypes.number,
})),
filterAssignmentType: PropTypes.func.isRequired,
updateAssignmentFilter: PropTypes.func.isRequired,
format: PropTypes.string.isRequired,
getRoles: PropTypes.func.isRequired,
getUserGrades: PropTypes.func.isRequired,
@@ -711,9 +760,8 @@ Gradebook.propTypes = {
courseId: PropTypes.string,
searchForUser: PropTypes.func.isRequired,
selectedAssignmentType: PropTypes.string,
selectedCohort: PropTypes.shape({
name: PropTypes.string,
}),
selectedAssignment: PropTypes.string,
selectedCohort: PropTypes.number,
selectedTrack: PropTypes.string,
showSpinner: PropTypes.bool,
showSuccess: PropTypes.bool.isRequired,
@@ -744,4 +792,5 @@ Gradebook.propTypes = {
totalUsersCount: PropTypes.number,
filteredUsersCount: PropTypes.number,
showDownloadButtons: PropTypes.bool,
initializeFilters: PropTypes.func.isRequired,
};

View File

@@ -8,14 +8,17 @@ import {
fetchPrevNextGrades,
updateGrades,
toggleGradeFormat,
filterColumns,
filterAssignmentType,
closeBanner,
submitFileUploadFormData,
} from '../../data/actions/grades';
import { fetchCohorts } from '../../data/actions/cohorts';
import { fetchTracks } from '../../data/actions/tracks';
import { initializeFilters, updateAssignmentFilter } from '../../data/actions/filters';
import stateHasMastersTrack from '../../data/selectors/tracks';
import getBulkManagementHistory from '../../data/selectors/grades';
import { getBulkManagementHistory, getHeadings } from '../../data/selectors/grades';
import { selectableAssignmentLabels } from '../../data/selectors/filters';
import { getCohortNameById } from '../../data/selectors/cohorts';
import { fetchAssignmentTypes } from '../../data/actions/assignmentTypes';
import { getRoles } from '../../data/actions/roles';
import LmsApiService from '../../data/services/LmsApiService';
@@ -40,24 +43,28 @@ const mapStateToProps = (state, ownProps) => (
gradeOverrideCurrentPossibleGradedOverride:
state.grades.gradeOverrideCurrentPossibleGradedOverride,
gradeOriginalEarnedGraded: state.grades.gradeOriginalEarnedGraded,
headings: state.grades.headings,
headings: getHeadings(state),
tracks: state.tracks.results,
cohorts: state.cohorts.results,
selectedTrack: state.grades.selectedTrack,
selectedCohort: state.grades.selectedCohort,
selectedAssignmentType: state.grades.selectedAssignmentType,
selectedTrack: state.filters.track,
selectedCohort: state.filters.cohort,
selectedAssignmentType: state.filters.assignmentType,
selectedAssignment: (state.filters.assignment || {}).label,
format: state.grades.gradeFormat,
showSuccess: state.grades.showSuccess,
errorFetchingGradeOverrideHistory: state.grades.errorFetchingOverrideHistory,
prevPage: state.grades.prevPage,
nextPage: state.grades.nextPage,
assignmentTypes: state.assignmentTypes.results,
assignmentFilterOptions: selectableAssignmentLabels(state),
areGradesFrozen: state.assignmentTypes.areGradesFrozen,
showSpinner: shouldShowSpinner(state),
canUserViewGradebook: state.roles.canUserViewGradebook,
gradeExportUrl: LmsApiService.getGradeExportCsvUrl(ownProps.match.params.courseId, {
cohort: state.grades.selectedCohort,
track: state.grades.selectedTrack,
cohort: getCohortNameById(state, state.filters.cohort),
track: state.filters.track,
assignment: (state.filters.assignment || {}).id,
assignmentType: state.filters.assignmentType,
}),
interventionExportUrl:
LmsApiService.getInterventionExportCsvUrl(ownProps.match.params.courseId),
@@ -85,10 +92,12 @@ const mapDispatchToProps = {
getAssignmentTypes: fetchAssignmentTypes,
updateGrades,
toggleFormat: toggleGradeFormat,
filterColumns,
filterAssignmentType,
closeBanner,
getRoles,
submitFileUploadFormData,
initializeFilters,
updateAssignmentFilter,
};
const GradebookPage = connect(

View File

@@ -0,0 +1,23 @@
import { INITIALIZE_FILTERS, UPDATE_ASSIGNMENT_FILTER } from '../constants/actionTypes/filters';
const initializeFilters = ({
assignment = '',
assignmentType = '',
track = '',
cohort = '',
}) => ({
type: INITIALIZE_FILTERS,
data: {
assignment: { label: assignment },
assignmentType,
track,
cohort,
},
});
const updateAssignmentFilter = assignment => ({
type: UPDATE_ASSIGNMENT_FILTER,
data: assignment,
});
export { initializeFilters, updateAssignmentFilter };

View File

@@ -7,7 +7,7 @@ import {
GRADE_UPDATE_SUCCESS,
GRADE_UPDATE_FAILURE,
TOGGLE_GRADE_FORMAT,
FILTER_COLUMNS,
FILTER_BY_ASSIGNMENT_TYPE,
OPEN_BANNER,
CLOSE_BANNER,
START_UPLOAD,
@@ -19,7 +19,7 @@ import {
ERROR_FETCHING_GRADE_OVERRIDE_HISTORY,
} from '../constants/actionTypes/grades';
import LmsApiService from '../services/LmsApiService';
import { headingMapper, sortAlphaAsc, formatDateForDisplay } from './utils';
import { sortAlphaAsc, formatDateForDisplay } from './utils';
import apiClient from '../apiClient';
const defaultAssignmentFilter = 'All';
@@ -85,10 +85,10 @@ const gradeUpdateFailure = (courseId, error) => ({
const toggleGradeFormat = formatType => ({ type: TOGGLE_GRADE_FORMAT, formatType });
const filterColumns = (filterType, exampleUser) => (
const filterAssignmentType = filterType => (
dispatch => dispatch({
type: FILTER_COLUMNS,
headings: headingMapper(filterType)(exampleUser),
type: FILTER_BY_ASSIGNMENT_TYPE,
filterType,
})
);
@@ -112,7 +112,6 @@ const fetchGrades = (
cohort,
track,
assignmentType,
headings: headingMapper(assignmentType || defaultAssignmentFilter)(data.results[0]),
prev: data.previous,
next: data.next,
courseId,
@@ -185,7 +184,6 @@ const fetchPrevNextGrades = (endpoint, courseId, cohort, track, assignmentType)
cohort,
track,
assignmentType,
headings: headingMapper(assignmentType || defaultAssignmentFilter)(data.results[0]),
prev: data.previous,
next: data.next,
courseId,
@@ -259,7 +257,7 @@ export {
gradeUpdateFailure,
updateGrades,
toggleGradeFormat,
filterColumns,
filterAssignmentType,
closeBanner,
submitFileUploadFormData,
fetchBulkUpgradeHistory,

View File

@@ -97,11 +97,6 @@ describe('actions', () => {
cohort: expectedCohort,
track: expectedTrack,
assignmentType: expectedAssignmentType,
headings: [
'Username',
'Email',
'Total',
],
prev: responseData.previous,
next: responseData.next,
courseId,
@@ -159,7 +154,6 @@ describe('actions', () => {
cohort: expectedCohort,
track: expectedTrack,
assignmentType: expectedAssignmentType,
headings: [],
prev: responseData.previous,
next: responseData.next,
courseId,

View File

@@ -6,6 +6,7 @@ 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 allowedRoles = ['staff', 'instructor', 'support'];
@@ -17,16 +18,17 @@ const gotRoles = (canUserViewGradebook, courseId) => ({
});
const errorFetchingRoles = () => ({ type: ERROR_FETCHING_ROLES });
const getRoles = (courseId, urlQuery) => (
dispatch => LmsApiService.fetchUserRoles(courseId)
const getRoles = courseId => (
(dispatch, getState) => LmsApiService.fetchUserRoles(courseId)
.then(response => response.data)
.then((response) => {
const canUserViewGradebook = response.is_staff
|| (response.roles.some(role => (role.course_id === courseId)
&& allowedRoles.includes(role.role)));
dispatch(gotRoles(canUserViewGradebook, courseId));
const { cohort, track, assignmentType } = getFilters(getState());
if (canUserViewGradebook) {
dispatch(fetchGrades(courseId, urlQuery.cohort, urlQuery.track, urlQuery.assignmentType));
dispatch(fetchGrades(courseId, cohort, track, assignmentType));
dispatch(fetchTracks(courseId));
dispatch(fetchCohorts(courseId));
dispatch(fetchAssignmentTypes(courseId));

View File

@@ -26,29 +26,5 @@ const sortAlphaAsc = (gradeRowA, gradeRowB) => {
return 0;
};
const headingMapper = (filterKey) => {
const filters = {
all: section => section.label,
some: section => section.label && section.category === filterKey,
};
const filter = filterKey === 'All' ? 'all' : 'some';
return (entry) => {
if (entry) {
const results = ['Username', 'Email'];
const assignmentHeadings = entry.section_breakdown
.filter(filters[filter])
.map(s => s.label);
const totals = ['Total'];
return results.concat(assignmentHeadings).concat(totals);
}
return [];
};
};
export { headingMapper, sortAlphaAsc, formatDateForDisplay };
export { sortAlphaAsc, formatDateForDisplay };

View File

@@ -0,0 +1,4 @@
const INITIALIZE_FILTERS = 'INITIALIZE_FILTERS';
const UPDATE_ASSIGNMENT_FILTER = 'UPDATE_ASSIGNMENT_FILTER';
export { INITIALIZE_FILTERS, UPDATE_ASSIGNMENT_FILTER };

View File

@@ -10,7 +10,7 @@ const GRADE_UPDATE_SUCCESS = 'GRADE_UPDATE_SUCCESS';
const GRADE_UPDATE_FAILURE = 'GRADE_UPDATE_FAILURE';
const TOGGLE_GRADE_FORMAT = 'TOGGLE_GRADE_FORMAT';
const FILTER_COLUMNS = 'FILTER_COLUMNS';
const FILTER_BY_ASSIGNMENT_TYPE = 'FILTER_BY_ASSIGNMENT_TYPE';
const CLOSE_BANNER = 'CLOSE_BANNER';
const OPEN_BANNER = 'OPEN_BANNER';
@@ -29,7 +29,7 @@ export {
GRADE_UPDATE_SUCCESS,
GRADE_UPDATE_FAILURE,
TOGGLE_GRADE_FORMAT,
FILTER_COLUMNS,
FILTER_BY_ASSIGNMENT_TYPE,
OPEN_BANNER,
CLOSE_BANNER,
START_UPLOAD,

View File

@@ -0,0 +1,55 @@
import { GOT_GRADES, FILTER_BY_ASSIGNMENT_TYPE } from '../constants/actionTypes/grades';
import { INITIALIZE_FILTERS, UPDATE_ASSIGNMENT_FILTER } from '../constants/actionTypes/filters';
import { getAssignmentsFromResultsSubstate, chooseRelevantAssignmentData } from '../selectors/filters';
const initialState = {};
const reducer = (state = initialState, action) => {
switch (action.type) {
case FILTER_BY_ASSIGNMENT_TYPE:
return {
...state,
assignmentType: action.filterType,
assignment: (
action.filterType !== '' &&
(state.assignment || {}).type !== action.filterType)
? '' : state.assignment,
};
case INITIALIZE_FILTERS:
return {
...state,
...action.data,
};
case GOT_GRADES: {
const { assignment } = state;
const { label, type } = assignment || {};
if (!type) {
const relevantAssignment = getAssignmentsFromResultsSubstate(action.grades)
.map(chooseRelevantAssignmentData)
.find(assig => assig.label === label);
return {
...state,
track: action.track,
cohort: action.cohort,
assignment: relevantAssignment,
};
}
return {
...state,
track: action.track,
cohort: action.cohort,
};
}
case UPDATE_ASSIGNMENT_FILTER:
return {
...state,
assignment: action.data,
};
default:
return state;
}
};
export default reducer;

View File

@@ -3,7 +3,7 @@ import {
ERROR_FETCHING_GRADES,
GOT_GRADES,
TOGGLE_GRADE_FORMAT,
FILTER_COLUMNS,
FILTER_BY_ASSIGNMENT_TYPE,
OPEN_BANNER,
CLOSE_BANNER,
START_UPLOAD,
@@ -48,9 +48,6 @@ const grades = (state = initialState, action) => {
headings: action.headings,
finishedFetching: true,
errorFetching: false,
selectedTrack: action.track,
selectedCohort: action.cohort,
selectedAssignmentType: action.assignmentType,
prevPage: action.prev,
nextPage: action.next,
showSpinner: false,
@@ -98,9 +95,10 @@ const grades = (state = initialState, action) => {
...state,
gradeFormat: action.formatType,
};
case FILTER_COLUMNS:
case FILTER_BY_ASSIGNMENT_TYPE:
return {
...state,
selectedAssignmentType: action.filterType,
headings: action.headings,
};
case OPEN_BANNER:

View File

@@ -4,7 +4,7 @@ import {
ERROR_FETCHING_GRADES,
GOT_GRADES,
TOGGLE_GRADE_FORMAT,
FILTER_COLUMNS,
FILTER_BY_ASSIGNMENT_TYPE,
OPEN_BANNER,
} from '../constants/actionTypes/grades';
@@ -94,8 +94,6 @@ describe('grades reducer', () => {
headings: headingsData,
errorFetching: false,
finishedFetching: true,
selectedTrack: expectedTrack,
selectedCohort: expectedCohortId,
prevPage: expectedPrev,
nextPage: expectedNext,
showSpinner: false,
@@ -137,7 +135,7 @@ describe('grades reducer', () => {
headings: expectedHeadings,
};
expect(grades(undefined, {
type: FILTER_COLUMNS,
type: FILTER_BY_ASSIGNMENT_TYPE,
headings: expectedHeadings,
})).toEqual(expected);
});

View File

@@ -5,6 +5,7 @@ import grades from './grades';
import tracks from './tracks';
import assignmentTypes from './assignmentTypes';
import roles from './roles';
import filters from './filters';
const rootReducer = combineReducers({
grades,
@@ -12,6 +13,7 @@ const rootReducer = combineReducers({
tracks,
assignmentTypes,
roles,
filters,
});
export default rootReducer;

View File

@@ -0,0 +1,12 @@
const getCohorts = state => state.cohorts.results || [];
const getCohortById = (state, selectedCohortId) => {
const cohort = getCohorts(state).find(coh => coh.id === selectedCohortId);
return cohort;
};
const getCohortNameById = (state, selectedCohortId) =>
(getCohortById(state, selectedCohortId) || {}).name;
export { getCohortById, getCohortNameById, getCohorts };

View File

@@ -0,0 +1,41 @@
const getFilters = state => state.filters || {};
const getAssignmentsFromResultsSubstate = results =>
(results[0] || {}).section_breakdown || [];
const selectableAssignments = (state) => {
const selectedAssignmentType = getFilters(state).assignmentType;
const needToFilter = selectedAssignmentType && selectedAssignmentType !== 'All';
const allAssignments = getAssignmentsFromResultsSubstate(state.grades.results);
if (needToFilter) {
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 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;
};
export {
selectableAssignmentLabels,
selectableAssignments,
getFilters,
typeOfSelectedAssignment,
chooseRelevantAssignmentData,
getAssignmentsFromResultsSubstate,
};

View File

@@ -1,4 +1,5 @@
import { formatDateForDisplay } from '../../data/actions/utils';
import { formatDateForDisplay } from '../actions/utils';
import { getFilters } from './filters';
const getRowsProcessed = (data) => {
const {
@@ -37,4 +38,46 @@ const getBulkManagementHistoryFromState = state =>
const getBulkManagementHistory = state =>
getBulkManagementHistoryFromState(state).map(transformHistoryEntry);
export default getBulkManagementHistory;
const headingMapper = (category, label = 'All') => {
const filters = {
all: section => section.label,
byCategory: section => section.label && section.category === category,
byLabel: section => section.label && section.label === label,
};
let filter;
if (label === 'All') {
filter = category === 'All' ? 'all' : 'byCategory';
} else {
filter = 'byLabel';
}
return (entry) => {
if (entry) {
const results = ['Username', 'Email'];
const assignmentHeadings = entry
.filter(filters[filter])
.map(s => s.label);
const totals = ['Total'];
return results.concat(assignmentHeadings).concat(totals);
}
return [];
};
};
const getHeadings = (state) => {
const filters = getFilters(state) || {};
const {
assignmentType: selectedAssignmentType,
assignment: selectedAssignment,
} = filters;
const assignments = (state.grades.results[0] || {}).section_breakdown || [];
const type = selectedAssignmentType || 'All';
const assignment = (selectedAssignment || {}).label || 'All';
return headingMapper(type, assignment)(assignments);
};
export { getBulkManagementHistory, getHeadings };

View File

@@ -1,4 +1,4 @@
import getBulkManagementHistory from './grades';
import { getBulkManagementHistory } from './grades';
const genericHistoryRow = {
id: 5,

View File

@@ -70,11 +70,12 @@ class LmsApiService {
}
static getGradeExportCsvUrl(courseId, options = {}) {
const trackQueryParam = options.track ? [`track=${options.track}`] : [];
const cohortQueryParam = options.cohort ? [`cohort=${options.cohort}`] : [];
const queryParams = [...trackQueryParam, ...cohortQueryParam].join('&');
const downloadUrl = `${LmsApiService.baseUrl}/api/bulk_grades/course/${courseId}/?${queryParams}`;
return downloadUrl;
const queryParams = ['track', 'cohort', 'assignment', 'assignmentType']
.filter(opt => options[opt] &&
options[opt] !== 'All')
.map(opt => `${opt}=${encodeURIComponent(options[opt])}`)
.join('&');
return `${LmsApiService.baseUrl}/api/bulk_grades/course/${courseId}/?${queryParams}`;
}
static getInterventionExportCsvUrl(courseId) {