diff --git a/package.json b/package.json
index d865b4b..7c00322 100755
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/components/Gradebook/index.jsx b/src/components/Gradebook/index.jsx
index 52374c4..7253000 100644
--- a/src/components/Gradebook/index.jsx
+++ b/src/components/Gradebook/index.jsx
@@ -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 {
)
+ 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 {
{'<< '} {'Back to Dashboard'}
Gradebook
-
{this.props.match.params.courseId}
+
{this.props.courseId}
{ this.props.areGradesFrozen &&
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 {
-
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 {
Search by username, email, or student key
-
- this.props.updateBanner(false)}
- open={this.props.showSuccess}
- />
- {PageButtons(this.props)}
-
- {PageButtons(this.props)}
-
- {this.state.modalModel[0].assignmentName}
+
+
+
this.props.closeBanner()}
+ open={this.props.showSuccess}
+ />
+
-
Note: Once you save, your changes will be visible to students.
- )}
- buttons={[
- ,
- ]}
- onClose={() => this.setState({
- modalOpen: false,
- modalModel: [{}],
- updateVal: 0,
- updateModuleId: null,
- updateUserId: null,
- })}
- />
+ {PageButtons(this.props)}
+
+ {this.state.modalModel[0].assignmentName}
+
+ Note: Once you save, your changes will be visible to students.
+
+ )}
+ buttons={[
+ ,
+ ]}
+ onClose={() => this.setState({
+ modalOpen: false,
+ modalModel: [{}],
+ updateVal: 0,
+ updateModuleId: null,
+ updateUserId: null,
+ })}
+ />
+
+ {this.props.showBulkManagement && (
+
+
+
+
+
)}
+
@@ -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,
};
diff --git a/src/containers/GradebookPage/index.jsx b/src/containers/GradebookPage/index.jsx
index ef68cc0..76524e6 100644
--- a/src/containers/GradebookPage/index.jsx
+++ b/src/containers/GradebookPage/index.jsx
@@ -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,
diff --git a/src/data/actions/grades.js b/src/data/actions/grades.js
index 926a1f7..20a98a5 100644
--- a/src/data/actions/grades.js
+++ b/src/data/actions/grades.js
@@ -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,
};
diff --git a/src/data/actions/grades.test.js b/src/data/actions/grades.test.js
index a9bcc58..8c9904b 100644
--- a/src/data/actions/grades.test.js
+++ b/src/data/actions/grades.test.js
@@ -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();
diff --git a/src/data/constants/actionTypes/grades.js b/src/data/constants/actionTypes/grades.js
index 6bd179b..77921f7 100644
--- a/src/data/constants/actionTypes/grades.js
+++ b/src/data/constants/actionTypes/grades.js
@@ -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,
};
diff --git a/src/data/reducers/grades.js b/src/data/reducers/grades.js
index c5543ee..e6f7339 100644
--- a/src/data/reducers/grades.js
+++ b/src/data/reducers/grades.js
@@ -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;
diff --git a/src/data/reducers/grades.test.js b/src/data/reducers/grades.test.js
index d935d25..4a8ecb2 100644
--- a/src/data/reducers/grades.test.js
+++ b/src/data/reducers/grades.test.js
@@ -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);
});
diff --git a/src/data/selectors/tracks.js b/src/data/selectors/tracks.js
new file mode 100644
index 0000000..6d755f6
--- /dev/null
+++ b/src/data/selectors/tracks.js
@@ -0,0 +1,4 @@
+const getTracks = state => state.tracks.results || [];
+const hasMastersTrack = state => getTracks(state).some(track => track.slug === 'masters');
+
+export default hasMastersTrack;
diff --git a/src/data/services/LmsApiService.js b/src/data/services/LmsApiService.js
index 279db67..b8852e0 100644
--- a/src/data/services/LmsApiService.js
+++ b/src/data/services/LmsApiService.js
@@ -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;