Added bulk management tab for CSV export/import

The bulk management tab will only be shown for masters courses,
i.e. those containing a masters track

JIRA:EDUCATOR-4343
JIRA:EDUCATOR-4431
This commit is contained in:
Matt Hughes
2019-05-28 15:22:55 -04:00
parent 1a0fe945a5
commit dee42eee7e
10 changed files with 270 additions and 148 deletions

View File

@@ -15,6 +15,7 @@
"semantic-release": "semantic-release",
"start": "NODE_ENV=development BABEL_ENV=development node_modules/.bin/webpack-dev-server --config=config/webpack.dev.config.js --progress",
"test": "jest --coverage --passWithNoTests",
"watch-tests": "jest --watch",
"travis-deploy-once": "travis-deploy-once"
},
"author": "edX",

View File

@@ -8,6 +8,7 @@ import {
StatusAlert,
Table,
Icon,
Tabs,
} from '@edx/paragon';
import queryString from 'query-string';
import { configuration } from '../../config';
@@ -26,11 +27,13 @@ export default class Gradebook extends React.Component {
updateModuleId: null,
updateUserId: null,
};
this.fileFormRef = React.createRef();
this.fileInputRef = React.createRef();
}
componentDidMount() {
const urlQuery = queryString.parse(this.props.location.search);
this.props.getRoles(this.props.match.params.courseId, urlQuery);
this.props.getRoles(this.props.courseId, urlQuery);
}
setNewModalState = (userEntry, subsection) => {
@@ -68,9 +71,16 @@ export default class Gradebook extends React.Component {
</div>
)
getActiveTabs = () => {
if (this.props.showBulkManagement) {
return ['Grades', 'Bulk Management'];
}
return ['Grades'];
};
handleAdjustedGradeClick = () => {
this.props.updateGrades(
this.props.match.params.courseId, [
this.props.courseId, [
{
user_id: this.state.updateUserId,
usage_id: this.state.updateModuleId,
@@ -138,7 +148,7 @@ export default class Gradebook extends React.Component {
selectedTrackSlug = selectedTrackItem.slug;
}
this.props.getUserGrades(
this.props.match.params.courseId,
this.props.courseId,
this.props.selectedCohort,
selectedTrackSlug,
this.props.selectedAssignmentType,
@@ -154,7 +164,7 @@ export default class Gradebook extends React.Component {
selectedCohortId = selectedCohortItem.id;
}
this.props.getUserGrades(
this.props.match.params.courseId,
this.props.courseId,
selectedCohortId,
this.props.selectedTrack,
this.props.selectedAssignmentType,
@@ -162,6 +172,29 @@ export default class Gradebook extends React.Component {
this.updateQueryParams('cohort', selectedCohortId);
};
handleClickExportGrades = () => {
window.location = this.props.gradeExportUrl;
};
handleClickImportGrades = () => {
const fileInput = this.fileInputRef.current;
if (fileInput) {
fileInput.click();
}
};
handleFileInputChange = (event) => {
const fileInput = event.target;
const file = fileInput.files[0];
const form = this.fileFormRef.current;
if (file && form) {
const formData = new FormData(form);
this.props.submitFileUploadFormData(this.props.courseId, formData).then(() => {
fileInput.value = null;
});
}
};
mapSelectedCohortEntry = (entry) => {
const selectedCohortEntry = this.props.cohorts.find(x => x.id === parseInt(entry, 10));
if (selectedCohortEntry) {
@@ -265,13 +298,13 @@ export default class Gradebook extends React.Component {
<div className="gradebook-container">
<div>
<a
href={this.lmsInstructorDashboardUrl(this.props.match.params.courseId)}
href={this.lmsInstructorDashboardUrl(this.props.courseId)}
className="mb-3"
>
<span aria-hidden="true">{'<< '}</span> {'Back to Dashboard'}
</a>
<h1>Gradebook</h1>
<h3> {this.props.match.params.courseId}</h3>
<h3> {this.props.courseId}</h3>
{ this.props.areGradesFrozen &&
<div className="alert alert-warning" role="alert" >
The grades for this course are now frozen. Editing of grades is no longer allowed.
@@ -352,13 +385,10 @@ export default class Gradebook extends React.Component {
</div>
</div>
<div>
<div style={{ marginLeft: '10px', marginBottom: '10px' }}>
<a className="btn btn-outline-primary mb-85" href={`${this.lmsInstructorDashboardUrl(this.props.match.params.courseId)}#view-data_download`}>Generate Grade Report</a>
</div>
<SearchField
onSubmit={value =>
this.props.searchForUser(
this.props.match.params.courseId,
this.props.courseId,
value,
this.props.selectedCohort,
this.props.selectedTrack,
@@ -369,7 +399,7 @@ export default class Gradebook extends React.Component {
onChange={filterValue => this.setState({ filterValue })}
onClear={() =>
this.props.getUserGrades(
this.props.match.params.courseId,
this.props.courseId,
this.props.selectedCohort,
this.props.selectedTrack,
this.props.selectedAssignmentType,
@@ -380,55 +410,85 @@ export default class Gradebook extends React.Component {
<small className="form-text text-muted search-help-text">Search by username, email, or student key</small>
</div>
</div>
<br />
<StatusAlert
alertType="success"
dialog="The grade has been successfully edited."
onClose={() => this.props.updateBanner(false)}
open={this.props.showSuccess}
/>
{PageButtons(this.props)}
<div className="gbook">
<Table
className={['table-striped']}
columns={this.formatHeadings()}
data={this.formatter[this.props.format](
this.props.grades,
this.props.areGradesFrozen,
)}
rowHeaderColumnKey="username"
/>
</div>
{PageButtons(this.props)}
<Modal
open={this.state.modalOpen}
title="Edit Grades"
closeText="Cancel"
body={(
<div>
<h3>{this.state.modalModel[0].assignmentName}</h3>
<Tabs labels={this.getActiveTabs()}>
<div>
<StatusAlert
alertType="success"
dialog="The grade has been successfully edited."
onClose={() => this.props.closeBanner()}
open={this.props.showSuccess}
/>
<div className="gbook">
<Table
columns={[{ label: 'Username', key: 'username' }, { label: 'Current grade', key: 'currentGrade' }, { label: 'Adjusted grade', key: 'adjustedGrade' }]}
data={this.state.modalModel}
columns={this.formatHeadings()}
data={this.formatter[this.props.format](
this.props.grades,
this.props.areGradesFrozen,
)}
rowHeaderColumnKey="username"
/>
<div>Note: Once you save, your changes will be visible to students.</div>
</div>
)}
buttons={[
<Button
label="Save Grade"
buttonType="primary"
onClick={this.handleAdjustedGradeClick}
/>,
]}
onClose={() => this.setState({
modalOpen: false,
modalModel: [{}],
updateVal: 0,
updateModuleId: null,
updateUserId: null,
})}
/>
{PageButtons(this.props)}
<Modal
open={this.state.modalOpen}
title="Edit Grades"
closeText="Cancel"
body={(
<div>
<h3>{this.state.modalModel[0].assignmentName}</h3>
<Table
columns={[{ label: 'Username', key: 'username' }, { label: 'Current grade', key: 'currentGrade' }, { label: 'Adjusted grade', key: 'adjustedGrade' }]}
data={this.state.modalModel}
/>
<div>Note: Once you save, your changes will be visible to students.</div>
</div>
)}
buttons={[
<Button
label="Save Grade"
buttonType="primary"
onClick={this.handleAdjustedGradeClick}
/>,
]}
onClose={() => this.setState({
modalOpen: false,
modalModel: [{}],
updateVal: 0,
updateModuleId: null,
updateUserId: null,
})}
/>
</div>
{this.props.showBulkManagement && (
<div>
<form ref={this.fileFormRef} action={this.props.gradeExportUrl} method="post">
<StatusAlert
alertType="danger"
dialog={this.props.bulkImportError}
open={this.props.bulkImportError}
dismissible={false}
/>
<input
className="d-none"
type="file"
name="csv"
label="Upload Grade CSV"
onChange={this.handleFileInputChange}
ref={this.fileInputRef}
/>
</form>
<Button
label="Export Grades"
buttonType="primary"
onClick={this.handleClickExportGrades}
/>
<Button
label="Import Grades"
buttonType="primary"
onClick={this.handleClickImportGrades}
/>
</div>)}
</Tabs>
</div>
</div>
</div>
@@ -445,16 +505,14 @@ Gradebook.defaultProps = {
location: {
search: '',
},
match: {
params: {
courseId: '',
},
},
courseId: '',
selectedCohort: null,
selectedTrack: null,
selectedAssignmentType: 'All',
showSpinner: false,
tracks: [],
bulkImportError: '',
showBulkManagement: false,
};
Gradebook.propTypes = {
@@ -488,11 +546,7 @@ Gradebook.propTypes = {
location: PropTypes.shape({
search: PropTypes.string,
}),
match: PropTypes.shape({
params: PropTypes.shape({
courseId: PropTypes.string,
}),
}),
courseId: PropTypes.string,
searchForUser: PropTypes.func.isRequired,
selectedAssignmentType: PropTypes.string,
selectedCohort: PropTypes.shape({
@@ -505,6 +559,10 @@ Gradebook.propTypes = {
tracks: PropTypes.arrayOf(PropTypes.shape({
name: PropTypes.string,
})),
updateBanner: PropTypes.func.isRequired,
closeBanner: PropTypes.func.isRequired,
updateGrades: PropTypes.func.isRequired,
gradeExportUrl: PropTypes.string.isRequired,
submitFileUploadFormData: PropTypes.func.isRequired,
bulkImportError: PropTypes.string,
showBulkManagement: PropTypes.bool,
};

View File

@@ -8,12 +8,15 @@ import {
updateGrades,
toggleGradeFormat,
filterColumns,
updateBanner,
closeBanner,
submitFileUploadFormData,
} from '../../data/actions/grades';
import { fetchCohorts } from '../../data/actions/cohorts';
import { fetchTracks } from '../../data/actions/tracks';
import hasMastersTrack from '../../data/selectors/tracks';
import { fetchAssignmentTypes } from '../../data/actions/assignmentTypes';
import { getRoles } from '../../data/actions/roles';
import LmsApiService from '../../data/services/LmsApiService';
function shouldShowSpinner(state) {
if (state.roles.canUserViewGradebook === true) {
@@ -24,8 +27,9 @@ function shouldShowSpinner(state) {
return true;
}
const mapStateToProps = state => (
const mapStateToProps = (state, ownProps) => (
{
courseId: ownProps.match.params.courseId,
grades: state.grades.results,
headings: state.grades.headings,
tracks: state.tracks.results,
@@ -41,46 +45,32 @@ const mapStateToProps = 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,
}),
bulkImportError: state.grades.bulkManagement &&
state.grades.bulkManagement.errorMessages ?
`Errors while processing: ${state.grades.bulkManagement.errorMessages.join(', ')}` :
'',
showBulkManagement: hasMastersTrack(state),
}
);
const mapDispatchToProps = dispatch => (
{
getUserGrades: (courseId, cohort, track, assignmentType) => {
dispatch(fetchGrades(courseId, cohort, track, assignmentType));
},
searchForUser: (courseId, searchText, cohort, track, assignmentType) => {
dispatch(fetchMatchingUserGrades(courseId, searchText, cohort, track, assignmentType, false));
},
getPrevNextGrades: (endpoint, courseId, cohort, track, assignmentType) => {
dispatch(fetchPrevNextGrades(endpoint, courseId, cohort, track, assignmentType));
},
getCohorts: (courseId) => {
dispatch(fetchCohorts(courseId));
},
getTracks: (courseId) => {
dispatch(fetchTracks(courseId));
},
getAssignmentTypes: (courseId) => {
dispatch(fetchAssignmentTypes(courseId));
},
updateGrades: (courseId, updateData, searchText, cohort, track) => {
dispatch(updateGrades(courseId, updateData, searchText, cohort, track));
},
toggleFormat: (formatType) => {
dispatch(toggleGradeFormat(formatType));
},
filterColumns: (filterType, exampleUser) => {
dispatch(filterColumns(filterType, exampleUser));
},
updateBanner: (showSuccess) => {
dispatch(updateBanner(showSuccess));
},
getRoles: (matchParams, urlQuery) => {
dispatch(getRoles(matchParams, urlQuery));
},
}
);
const mapDispatchToProps = {
getUserGrades: fetchGrades,
searchForUser: fetchMatchingUserGrades,
getPrevNextGrades: fetchPrevNextGrades,
getCohorts: fetchCohorts,
getTracks: fetchTracks,
getAssignmentTypes: fetchAssignmentTypes,
updateGrades,
toggleFormat: toggleGradeFormat,
filterColumns,
closeBanner,
getRoles,
submitFileUploadFormData,
};
const GradebookPage = connect(
mapStateToProps,

View File

@@ -8,7 +8,11 @@ import {
GRADE_UPDATE_FAILURE,
TOGGLE_GRADE_FORMAT,
FILTER_COLUMNS,
UPDATE_BANNER,
OPEN_BANNER,
CLOSE_BANNER,
START_UPLOAD,
UPLOAD_COMPLETE,
UPLOAD_ERR,
} from '../constants/actionTypes/grades';
import LmsApiService from '../services/LmsApiService';
import { headingMapper, sortAlphaAsc } from './utils';
@@ -16,6 +20,10 @@ import apiClient from '../apiClient';
const defaultAssignmentFilter = 'All';
const startedCsvUpload = () => ({ type: START_UPLOAD });
const finishedCsvUpload = () => ({ type: UPLOAD_COMPLETE });
const csvUploadError = data => ({ type: UPLOAD_ERR, data });
const startedFetchingGrades = () => ({ type: STARTED_FETCHING_GRADES });
const finishedFetchingGrades = () => ({ type: FINISHED_FETCHING_GRADES });
const errorFetchingGrades = () => ({ type: ERROR_FETCHING_GRADES });
@@ -53,12 +61,19 @@ const filterColumns = (filterType, exampleUser) => (
})
);
const updateBanner = showSuccess => ({ type: UPDATE_BANNER, showSuccess });
const openBanner = () => ({ type: OPEN_BANNER });
const closeBanner = () => ({ type: CLOSE_BANNER });
const fetchGrades = (courseId, cohort, track, assignmentType, showSuccess) => (
const fetchGrades = (
courseId,
cohort,
track,
assignmentType,
options = {},
) => (
(dispatch) => {
dispatch(startedFetchingGrades());
return LmsApiService.fetchGradebookData(courseId, null, cohort, track)
return LmsApiService.fetchGradebookData(courseId, options.searchText || null, cohort, track)
.then(response => response.data)
.then((data) => {
dispatch(gotGrades(
@@ -72,7 +87,9 @@ const fetchGrades = (courseId, cohort, track, assignmentType, showSuccess) => (
courseId,
));
dispatch(finishedFetchingGrades());
dispatch(updateBanner(!!showSuccess));
if (options.showSuccess) {
dispatch(openBanner());
}
})
.catch(() => {
dispatch(errorFetchingGrades());
@@ -87,30 +104,11 @@ const fetchMatchingUserGrades = (
track,
assignmentType,
showSuccess,
) => (
(dispatch) => {
dispatch(startedFetchingGrades());
return LmsApiService.fetchGradebookData(courseId, searchText, cohort, track)
.then(response => response.data)
.then((data) => {
dispatch(gotGrades(
data.results.sort(sortAlphaAsc),
cohort,
track,
assignmentType,
headingMapper(assignmentType || defaultAssignmentFilter)(data.results[0]),
data.previous,
data.next,
courseId,
));
dispatch(finishedFetchingGrades());
dispatch(updateBanner(showSuccess));
})
.catch(() => {
dispatch(errorFetchingGrades());
});
}
);
options = {},
) => {
const newOptions = { ...options, searchText, showSuccess };
return fetchGrades(courseId, cohort, track, assignmentType, newOptions);
};
const fetchPrevNextGrades = (endpoint, courseId, cohort, track, assignmentType) => (
(dispatch) => {
@@ -150,6 +148,7 @@ const updateGrades = (courseId, updateData, searchText, cohort, track) => (
track,
defaultAssignmentFilter,
true,
{ searchText },
));
})
.catch((error) => {
@@ -158,6 +157,21 @@ const updateGrades = (courseId, updateData, searchText, cohort, track) => (
}
);
const submitFileUploadFormData = (courseId, formData) => (
(dispatch) => {
dispatch(startedCsvUpload());
return LmsApiService.uploadGradeCsv(courseId, formData).then(() => (
dispatch(finishedCsvUpload())
)).catch((err) => {
if (err.status === 200 && err.data.error_messages.length) {
const { error_messages: errorMessages, saved, total } = err.data;
return dispatch(csvUploadError({ errorMessages, saved, total }));
}
return dispatch(csvUploadError({ errorMessages: ['Unknown error.'] }));
});
}
);
export {
startedFetchingGrades,
finishedFetchingGrades,
@@ -172,5 +186,6 @@ export {
updateGrades,
toggleGradeFormat,
filterColumns,
updateBanner,
closeBanner,
submitFileUploadFormData,
};

View File

@@ -10,7 +10,6 @@ import {
FINISHED_FETCHING_GRADES,
ERROR_FETCHING_GRADES,
GOT_GRADES,
UPDATE_BANNER,
} from '../constants/actionTypes/grades';
import { sortAlphaAsc } from './utils';
@@ -108,7 +107,6 @@ describe('actions', () => {
courseId,
},
{ type: FINISHED_FETCHING_GRADES },
{ type: UPDATE_BANNER, showSuccess: false },
];
const store = mockStore();
@@ -167,7 +165,6 @@ describe('actions', () => {
courseId,
},
{ type: FINISHED_FETCHING_GRADES },
{ type: UPDATE_BANNER, showSuccess: false },
];
const store = mockStore();

View File

@@ -9,7 +9,12 @@ const GRADE_UPDATE_FAILURE = 'GRADE_UPDATE_FAILURE';
const TOGGLE_GRADE_FORMAT = 'TOGGLE_GRADE_FORMAT';
const FILTER_COLUMNS = 'FILTER_COLUMNS';
const UPDATE_BANNER = 'UPDATE_BANNER';
const CLOSE_BANNER = 'CLOSE_BANNER';
const OPEN_BANNER = 'OPEN_BANNER';
const START_UPLOAD = 'START_UPLOAD';
const UPLOAD_COMPLETE = 'UPLOAD_COMPLETE';
const UPLOAD_ERR = 'UPLOAD_ERR';
export {
STARTED_FETCHING_GRADES,
@@ -21,5 +26,9 @@ export {
GRADE_UPDATE_FAILURE,
TOGGLE_GRADE_FORMAT,
FILTER_COLUMNS,
UPDATE_BANNER,
OPEN_BANNER,
CLOSE_BANNER,
START_UPLOAD,
UPLOAD_COMPLETE,
UPLOAD_ERR,
};

View File

@@ -4,7 +4,11 @@ import {
GOT_GRADES,
TOGGLE_GRADE_FORMAT,
FILTER_COLUMNS,
UPDATE_BANNER,
OPEN_BANNER,
CLOSE_BANNER,
START_UPLOAD,
UPLOAD_COMPLETE,
UPLOAD_ERR,
} from '../constants/actionTypes/grades';
const initialState = {
@@ -60,10 +64,35 @@ const grades = (state = initialState, action) => {
...state,
headings: action.headings,
};
case UPDATE_BANNER:
case OPEN_BANNER:
return {
...state,
showSuccess: action.showSuccess,
showSuccess: true,
};
case CLOSE_BANNER:
return {
...state,
showSuccess: false,
};
case START_UPLOAD:
return {
...state,
showSpinner: true,
};
case UPLOAD_COMPLETE:
return {
...state,
showSpinner: false,
bulkManagement: {},
};
case UPLOAD_ERR:
return {
...state,
showSpinner: false,
bulkManagement: {
...(state.bulkManagement || {}),
...action.data,
},
};
default:
return state;

View File

@@ -5,7 +5,7 @@ import {
GOT_GRADES,
TOGGLE_GRADE_FORMAT,
FILTER_COLUMNS,
UPDATE_BANNER,
OPEN_BANNER,
} from '../constants/actionTypes/grades';
const initialState = {
@@ -158,8 +158,7 @@ describe('grades reducer', () => {
showSuccess: expectedShowSuccess,
};
expect(grades(undefined, {
type: UPDATE_BANNER,
showSuccess: expectedShowSuccess,
type: OPEN_BANNER,
})).toEqual(expected);
});

View File

@@ -0,0 +1,4 @@
const getTracks = state => state.tracks.results || [];
const hasMastersTrack = state => getTracks(state).some(track => track.slug === 'masters');
export default hasMastersTrack;

View File

@@ -67,6 +67,26 @@ class LmsApiService {
const rolesUrl = `${LmsApiService.baseUrl}/api/enrollment/v1/roles/?course_id=${encodeURIComponent(courseId)}`;
return apiClient.get(rolesUrl);
}
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;
}
static getGradeImportCsvUrl = LmsApiService.getGradeExportCsvUrl;
static uploadGradeCsv(courseId, formData) {
const fileUploadUrl = LmsApiService.getGradeImportCsvUrl(courseId);
return apiClient.post(fileUploadUrl, formData).then((result) => {
if (result.status === 200 && !result.data.error_messages.length) {
return result.data;
}
return Promise.reject(result);
});
}
}
export default LmsApiService;