Merge pull request #105 from edx/matthugs/EDUCATOR-4435
Add download of row-by-row status CSV echo for bulk operation history table
This commit is contained in:
11288
package-lock.json
generated
11288
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -28,9 +28,10 @@
|
||||
"@edx/edx-bootstrap": "^0.4.3",
|
||||
"@edx/frontend-auth": "^4.0.0",
|
||||
"@edx/frontend-component-footer": "^4.1.4",
|
||||
"@edx/paragon": "^3.8.3",
|
||||
"@edx/paragon": "^4.2.3",
|
||||
"@fortawesome/fontawesome-svg-core": "^1.2.18",
|
||||
"@fortawesome/free-brands-svg-icons": "^5.8.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^5.9.0",
|
||||
"@fortawesome/react-fontawesome": "^0.1.4",
|
||||
"@redux-beacon/segment": "^1.0.0",
|
||||
"babel-polyfill": "^6.26.0",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
$fa-font-path: "~font-awesome/fonts";
|
||||
@import "~font-awesome/scss/font-awesome";
|
||||
|
||||
$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/frontend-component-footer/src/lib/scss/site-footer";
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background-color: #999;
|
||||
opacity: 0.5;
|
||||
z-index: 99999;
|
||||
@@ -31,6 +32,10 @@
|
||||
.search-help-text {
|
||||
margin-left: 20px;
|
||||
}
|
||||
h4 {
|
||||
font-weight: bold;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.student-filters{
|
||||
@@ -125,4 +130,9 @@
|
||||
|
||||
.modal-dialog {
|
||||
max-width: 1000px;
|
||||
}
|
||||
}
|
||||
|
||||
.original-filename {
|
||||
overflow-wrap: break-word;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
Button,
|
||||
StatefulButton,
|
||||
InputSelect,
|
||||
Modal,
|
||||
SearchField,
|
||||
@@ -11,6 +12,8 @@ import {
|
||||
Tabs,
|
||||
} from '@edx/paragon';
|
||||
import queryString from 'query-string';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faDownload, faSpinner } from '@fortawesome/free-solid-svg-icons';
|
||||
import { configuration } from '../../config';
|
||||
import PageButtons from '../PageButtons';
|
||||
import { formatDateForDisplay } from '../../data/actions/utils';
|
||||
@@ -143,6 +146,42 @@ export default class Gradebook extends React.Component {
|
||||
return mapped;
|
||||
};
|
||||
|
||||
formatHistoryRow = (row) => {
|
||||
const {
|
||||
summaryOfRowsProcessed: {
|
||||
total,
|
||||
successfullyProcessed,
|
||||
failed,
|
||||
skipped,
|
||||
},
|
||||
unique_id: courseId,
|
||||
originalFilename,
|
||||
id,
|
||||
...rest
|
||||
} = row;
|
||||
const resultsText = [
|
||||
`${total} Students: ${successfullyProcessed} processed`,
|
||||
...(skipped > 0 ? [`${skipped} skipped`] : []),
|
||||
...(failed > 0 ? [`${failed} failed`] : []),
|
||||
].join(', ');
|
||||
const resultsSummary = (
|
||||
<a
|
||||
href={`${configuration.LMS_BASE_URL}/api/bulk_grades/course/${courseId}/?error_id=${id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<FontAwesomeIcon icon={faDownload} />
|
||||
{resultsText}
|
||||
</a>
|
||||
);
|
||||
const filename = (
|
||||
<span className="original-filename">
|
||||
{originalFilename}
|
||||
</span>
|
||||
);
|
||||
return { resultsSummary, filename, ...rest };
|
||||
};
|
||||
|
||||
updateAssignmentTypes = (event) => {
|
||||
this.props.filterColumns(event, this.props.grades[0]);
|
||||
const updatedQueryStrings = this.updateQueryParams('assignmentType', event);
|
||||
@@ -302,7 +341,6 @@ export default class Gradebook extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<div className="d-flex justify-content-center">
|
||||
{this.props.showSpinner && <div className="spinner-overlay"><Icon className={['fa', 'fa-spinner', 'fa-spin', 'fa-5x', 'color-black']} /></div>}
|
||||
<div className="gradebook-container">
|
||||
<div>
|
||||
<a
|
||||
@@ -314,112 +352,114 @@ export default class Gradebook extends React.Component {
|
||||
<h1>Gradebook</h1>
|
||||
<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.
|
||||
</div>
|
||||
<div className="alert alert-warning" role="alert" >
|
||||
The grades for this course are now frozen. Editing of grades is no longer allowed.
|
||||
</div>
|
||||
}
|
||||
{(this.props.canUserViewGradebook === false) &&
|
||||
<div className="alert alert-warning" role="alert" >
|
||||
You are not authorized to view the gradebook for this course.
|
||||
</div>
|
||||
}
|
||||
<hr />
|
||||
<div className="d-flex justify-content-between" >
|
||||
<div>
|
||||
<div role="radiogroup" aria-labelledby="score-view-group-label">
|
||||
<span id="score-view-group-label">Score View:</span>
|
||||
<span>
|
||||
<label className="mr-2" htmlFor="score-view-percent">
|
||||
<input
|
||||
id="score-view-percent"
|
||||
className="ml-2 mr-1"
|
||||
type="radio"
|
||||
name="score-view"
|
||||
value="percent"
|
||||
defaultChecked
|
||||
onClick={() => this.props.toggleFormat('percent')}
|
||||
/>
|
||||
Percent
|
||||
</label>
|
||||
</span>
|
||||
<span>
|
||||
<label htmlFor="score-view-absolute">
|
||||
<input
|
||||
id="score-view-absolute"
|
||||
type="radio"
|
||||
name="score-view"
|
||||
value="absolute"
|
||||
className="mr-1"
|
||||
onClick={() => this.props.toggleFormat('absolute')}
|
||||
/>
|
||||
Absolute
|
||||
</label>
|
||||
</span>
|
||||
</div>
|
||||
{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">
|
||||
Student Groups:
|
||||
</span>
|
||||
<InputSelect
|
||||
name="Tracks"
|
||||
ariaLabel="Tracks"
|
||||
disabled={this.props.tracks.length === 0}
|
||||
value={this.mapSelectedTrackEntry(this.props.selectedTrack)}
|
||||
options={this.mapTracksEntries(this.props.tracks)}
|
||||
onChange={this.updateTracks}
|
||||
/>
|
||||
<InputSelect
|
||||
name="Cohorts"
|
||||
ariaLabel="Cohorts"
|
||||
disabled={this.props.cohorts.length === 0}
|
||||
value={this.mapSelectedCohortEntry(this.props.selectedCohort)}
|
||||
options={this.mapCohortsEntries(this.props.cohorts)}
|
||||
onChange={this.updateCohorts}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<SearchField
|
||||
onSubmit={value =>
|
||||
this.props.searchForUser(
|
||||
this.props.courseId,
|
||||
value,
|
||||
this.props.selectedCohort,
|
||||
this.props.selectedTrack,
|
||||
this.props.selectedAssignmentType,
|
||||
)
|
||||
}
|
||||
inputLabel="Search for a learner"
|
||||
onChange={filterValue => this.setState({ filterValue })}
|
||||
onClear={() =>
|
||||
this.props.getUserGrades(
|
||||
this.props.courseId,
|
||||
this.props.selectedCohort,
|
||||
this.props.selectedTrack,
|
||||
this.props.selectedAssignmentType,
|
||||
)
|
||||
}
|
||||
value={this.state.filterValue}
|
||||
/>
|
||||
<small className="form-text text-muted search-help-text">Search by username, email, or student key</small>
|
||||
</div>
|
||||
<div className="alert alert-warning" role="alert" >
|
||||
You are not authorized to view the gradebook for this course.
|
||||
</div>
|
||||
}
|
||||
<Tabs labels={this.getActiveTabs()}>
|
||||
<div>
|
||||
<div className="d-flex justify-content-between" >
|
||||
{this.props.showSpinner && <div className="spinner-overlay"><Icon className={['fa', 'fa-spinner', 'fa-spin', 'fa-5x', 'color-black']} /></div>}
|
||||
|
||||
<div>
|
||||
<div role="radiogroup" aria-labelledby="score-view-group-label">
|
||||
<span id="score-view-group-label">Score View:</span>
|
||||
<span>
|
||||
<label className="mr-2" htmlFor="score-view-percent">
|
||||
<input
|
||||
id="score-view-percent"
|
||||
className="ml-2 mr-1"
|
||||
type="radio"
|
||||
name="score-view"
|
||||
value="percent"
|
||||
defaultChecked
|
||||
onClick={() => this.props.toggleFormat('percent')}
|
||||
/>
|
||||
Percent
|
||||
</label>
|
||||
</span>
|
||||
<span>
|
||||
<label htmlFor="score-view-absolute">
|
||||
<input
|
||||
id="score-view-absolute"
|
||||
type="radio"
|
||||
name="score-view"
|
||||
value="absolute"
|
||||
className="mr-1"
|
||||
onClick={() => this.props.toggleFormat('absolute')}
|
||||
/>
|
||||
Absolute
|
||||
</label>
|
||||
</span>
|
||||
</div>
|
||||
{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">
|
||||
Student Groups:
|
||||
</span>
|
||||
<InputSelect
|
||||
name="Tracks"
|
||||
ariaLabel="Tracks"
|
||||
disabled={this.props.tracks.length === 0}
|
||||
value={this.mapSelectedTrackEntry(this.props.selectedTrack)}
|
||||
options={this.mapTracksEntries(this.props.tracks)}
|
||||
onChange={this.updateTracks}
|
||||
/>
|
||||
<InputSelect
|
||||
name="Cohorts"
|
||||
ariaLabel="Cohorts"
|
||||
disabled={this.props.cohorts.length === 0}
|
||||
value={this.mapSelectedCohortEntry(this.props.selectedCohort)}
|
||||
options={this.mapCohortsEntries(this.props.cohorts)}
|
||||
onChange={this.updateCohorts}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<SearchField
|
||||
onSubmit={value =>
|
||||
this.props.searchForUser(
|
||||
this.props.courseId,
|
||||
value,
|
||||
this.props.selectedCohort,
|
||||
this.props.selectedTrack,
|
||||
this.props.selectedAssignmentType,
|
||||
)
|
||||
}
|
||||
inputLabel="Search for a learner"
|
||||
onChange={filterValue => this.setState({ filterValue })}
|
||||
onClear={() =>
|
||||
this.props.getUserGrades(
|
||||
this.props.courseId,
|
||||
this.props.selectedCohort,
|
||||
this.props.selectedTrack,
|
||||
this.props.selectedAssignmentType,
|
||||
)
|
||||
}
|
||||
value={this.state.filterValue}
|
||||
/>
|
||||
<small className="form-text text-muted search-help-text">Search by username, email, or student key</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StatusAlert
|
||||
alertType="success"
|
||||
dialog="The grade has been successfully edited."
|
||||
@@ -503,6 +543,43 @@ export default class Gradebook extends React.Component {
|
||||
</div>
|
||||
{this.props.showBulkManagement && (
|
||||
<div>
|
||||
<h4>Step 1: Filter the Grade Report</h4>
|
||||
{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">
|
||||
Student Groups:
|
||||
</span>
|
||||
<InputSelect
|
||||
name="Tracks"
|
||||
ariaLabel="Tracks"
|
||||
disabled={this.props.tracks.length === 0}
|
||||
value={this.mapSelectedTrackEntry(this.props.selectedTrack)}
|
||||
options={this.mapTracksEntries(this.props.tracks)}
|
||||
onChange={this.updateTracks}
|
||||
/>
|
||||
<InputSelect
|
||||
name="Cohorts"
|
||||
ariaLabel="Cohorts"
|
||||
disabled={this.props.cohorts.length === 0}
|
||||
value={this.mapSelectedCohortEntry(this.props.selectedCohort)}
|
||||
options={this.mapCohortsEntries(this.props.cohorts)}
|
||||
onChange={this.updateCohorts}
|
||||
/>
|
||||
</div>
|
||||
<h4>Step 2: Download the gradebook to edit grades locally</h4>
|
||||
<form ref={this.fileFormRef} action={this.props.gradeExportUrl} method="post">
|
||||
<StatusAlert
|
||||
alertType="danger"
|
||||
@@ -510,6 +587,13 @@ export default class Gradebook extends React.Component {
|
||||
open={this.props.bulkImportError}
|
||||
dismissible={false}
|
||||
/>
|
||||
<StatusAlert
|
||||
alertType="success"
|
||||
dialog="CSV successfully uploaded. Refresh the page to review results."
|
||||
open={this.props.uploadSuccess}
|
||||
dismissible={false}
|
||||
/>
|
||||
|
||||
<input
|
||||
className="d-none"
|
||||
type="file"
|
||||
@@ -519,23 +603,60 @@ export default class Gradebook extends React.Component {
|
||||
ref={this.fileInputRef}
|
||||
/>
|
||||
</form>
|
||||
<Button
|
||||
label="Export Grades"
|
||||
<StatefulButton
|
||||
buttonType="primary"
|
||||
onClick={this.handleClickExportGrades}
|
||||
state={this.props.showSpinner ? 'pending' : 'default'}
|
||||
labels={{
|
||||
default: 'Download Gradebook',
|
||||
pending: 'Download Gradebook',
|
||||
}}
|
||||
icons={{
|
||||
default: <FontAwesomeIcon icon={faDownload} />,
|
||||
pending: <FontAwesomeIcon className="fa-spin" icon={faSpinner} />,
|
||||
}}
|
||||
disabledStates={['pending']}
|
||||
/>
|
||||
<h4>Step 3: Upload CSV to Process Grades in Bulk</h4>
|
||||
<Button
|
||||
label="Import Grades"
|
||||
buttonType="primary"
|
||||
onClick={this.handleClickImportGrades}
|
||||
/>
|
||||
<p>
|
||||
Results appear in the table below.<br />
|
||||
Grade processing may take a few seconds.
|
||||
</p>
|
||||
<Table
|
||||
data={this.props.bulkManagementHistory}
|
||||
data={this.props.bulkManagementHistory.map(this.formatHistoryRow)}
|
||||
hasFixedColumnWidths
|
||||
columns={[
|
||||
{ key: 'user', label: 'Uploaded By', columnSortable: false },
|
||||
{ key: 'operation', label: 'Operation', columnSortable: false },
|
||||
{ key: 'modified', label: 'Uploaded Date', columnSortable: false },
|
||||
{
|
||||
key: 'filename',
|
||||
label: 'Gradebook',
|
||||
columnSortable: false,
|
||||
width: 'col-5',
|
||||
},
|
||||
{
|
||||
key: 'resultsSummary',
|
||||
label: 'Download Summary',
|
||||
columnSortable: false,
|
||||
width: 'col',
|
||||
},
|
||||
{
|
||||
key: 'user',
|
||||
label: 'Who',
|
||||
columnSortable: false,
|
||||
width: 'col-1',
|
||||
},
|
||||
{
|
||||
key: 'timeUploaded',
|
||||
label: 'When',
|
||||
columnSortable: false,
|
||||
width: 'col',
|
||||
},
|
||||
]}
|
||||
className="table-striped"
|
||||
/>
|
||||
</div>)}
|
||||
</Tabs>
|
||||
@@ -565,6 +686,7 @@ Gradebook.defaultProps = {
|
||||
showSpinner: false,
|
||||
tracks: [],
|
||||
bulkImportError: '',
|
||||
uploadSuccess: false,
|
||||
showBulkManagement: false,
|
||||
bulkManagementHistory: [],
|
||||
errorFetchingGradeOverrideHistory: false,
|
||||
@@ -628,11 +750,18 @@ Gradebook.propTypes = {
|
||||
gradeExportUrl: PropTypes.string.isRequired,
|
||||
submitFileUploadFormData: PropTypes.func.isRequired,
|
||||
bulkImportError: PropTypes.string,
|
||||
uploadSuccess: PropTypes.bool,
|
||||
errorFetchingGradeOverrideHistory: PropTypes.bool,
|
||||
showBulkManagement: PropTypes.bool,
|
||||
bulkManagementHistory: PropTypes.arrayOf(PropTypes.shape({
|
||||
operation: PropTypes.oneOf(['commit', 'error']),
|
||||
user: PropTypes.string,
|
||||
modified: PropTypes.string,
|
||||
originalFilename: PropTypes.string.isRequired,
|
||||
user: PropTypes.string.isRequired,
|
||||
timeUploaded: PropTypes.string.isRequired,
|
||||
summaryOfRowsProcessed: PropTypes.shape({
|
||||
total: PropTypes.number.isRequired,
|
||||
successfullyProcessed: PropTypes.number.isRequired,
|
||||
failed: PropTypes.number.isRequired,
|
||||
skipped: PropTypes.number.isRequired,
|
||||
}).isRequired,
|
||||
})),
|
||||
};
|
||||
|
||||
@@ -63,6 +63,8 @@ const mapStateToProps = (state, ownProps) => (
|
||||
state.grades.bulkManagement.errorMessages ?
|
||||
`Errors while processing: ${state.grades.bulkManagement.errorMessages.join(', ')}` :
|
||||
'',
|
||||
uploadSuccess: !!(state.grades.bulkManagement &&
|
||||
state.grades.bulkManagement.uploadSuccess),
|
||||
showBulkManagement: stateHasMastersTrack(state),
|
||||
bulkManagementHistory: getBulkManagementHistory(state),
|
||||
}
|
||||
|
||||
@@ -219,7 +219,7 @@ const submitFileUploadFormData = (courseId, formData) => (
|
||||
return LmsApiService.uploadGradeCsv(courseId, formData).then(() => (
|
||||
dispatch(finishedCsvUpload())
|
||||
)).catch((err) => {
|
||||
if (err.response.status === 200 && err.response.error_messages.length) {
|
||||
if (err.status === 200 && err.data.error_messages.length) {
|
||||
const { error_messages: errorMessages, saved, total } = err.data;
|
||||
return dispatch(csvUploadError({ errorMessages, saved, total }));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
const formatDateForDisplay = (inputDate) => {
|
||||
const options = { year: 'numeric', month: 'long', day: 'numeric' };
|
||||
const timeOptions = { hour: '2-digit', minute: '2-digit' };
|
||||
const options = {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
};
|
||||
const timeOptions = {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'UTC',
|
||||
timeZoneName: 'short',
|
||||
};
|
||||
return `${inputDate.toLocaleDateString('en-US', options)} at ${inputDate.toLocaleTimeString('en-US', timeOptions)}`;
|
||||
};
|
||||
|
||||
|
||||
@@ -109,19 +109,20 @@ const grades = (state = initialState, action) => {
|
||||
...state,
|
||||
showSuccess: false,
|
||||
};
|
||||
case START_UPLOAD:
|
||||
case START_UPLOAD: {
|
||||
const { errorMessages, uploadSuccess, ...rest } = state.bulkManagement;
|
||||
return {
|
||||
...state,
|
||||
showSpinner: true,
|
||||
bulkManagement: rest,
|
||||
};
|
||||
case UPLOAD_COMPLETE: {
|
||||
const { errorMessages, ...rest } = state.bulkManagement;
|
||||
}
|
||||
case UPLOAD_COMPLETE:
|
||||
return {
|
||||
...state,
|
||||
showSpinner: false,
|
||||
bulkManagement: { ...rest },
|
||||
bulkManagement: { uploadSuccess: true, ...state.bulkManagement },
|
||||
};
|
||||
}
|
||||
case UPLOAD_ERR:
|
||||
return {
|
||||
...state,
|
||||
|
||||
@@ -1,3 +1,40 @@
|
||||
const getBulkManagementHistory = state => state.grades.bulkManagement.history;
|
||||
import { formatDateForDisplay } from '../../data/actions/utils';
|
||||
|
||||
const getRowsProcessed = (data) => {
|
||||
const {
|
||||
processed_rows: processed,
|
||||
saved_rows: saved,
|
||||
total_rows: total,
|
||||
} = data;
|
||||
return {
|
||||
total,
|
||||
successfullyProcessed: saved,
|
||||
failed: processed - saved,
|
||||
skipped: total - processed,
|
||||
};
|
||||
};
|
||||
|
||||
const transformHistoryEntry = (historyRow) => {
|
||||
const {
|
||||
modified,
|
||||
original_filename: originalFilename,
|
||||
data,
|
||||
...rest
|
||||
} = historyRow;
|
||||
|
||||
const timeUploaded = formatDateForDisplay(new Date(modified));
|
||||
const summaryOfRowsProcessed = getRowsProcessed(data);
|
||||
|
||||
return {
|
||||
timeUploaded,
|
||||
originalFilename,
|
||||
summaryOfRowsProcessed,
|
||||
...rest,
|
||||
};
|
||||
};
|
||||
const getBulkManagementHistoryFromState = state =>
|
||||
state.grades.bulkManagement.history || [];
|
||||
const getBulkManagementHistory = state =>
|
||||
getBulkManagementHistoryFromState(state).map(transformHistoryEntry);
|
||||
|
||||
export default getBulkManagementHistory;
|
||||
|
||||
72
src/data/selectors/grades.test.js
Normal file
72
src/data/selectors/grades.test.js
Normal file
@@ -0,0 +1,72 @@
|
||||
import getBulkManagementHistory from './grades';
|
||||
|
||||
const genericHistoryRow = {
|
||||
id: 5,
|
||||
class_name: 'bulk_grades.api.GradeCSVProcessor',
|
||||
unique_id: 'course-v1:google+goog101+2018_spring',
|
||||
operation: 'commit',
|
||||
user: 'edx',
|
||||
modified: '2019-07-16T20:25:46.700802Z',
|
||||
original_filename: '',
|
||||
data: {
|
||||
total_rows: 5,
|
||||
processed_rows: 3,
|
||||
saved_rows: 3,
|
||||
},
|
||||
};
|
||||
|
||||
describe('getBulkManagementHistory', () => {
|
||||
it('handles history being as-yet unloaded', () => {
|
||||
const result = getBulkManagementHistory({ grades: { bulkManagement: {} } });
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('formats dates for us', () => {
|
||||
const result = getBulkManagementHistory({
|
||||
grades: {
|
||||
bulkManagement: {
|
||||
history: [
|
||||
genericHistoryRow,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const [{ timeUploaded }] = result;
|
||||
expect(timeUploaded).not.toMatch(/Z$/);
|
||||
expect(timeUploaded).toContain(' at ');
|
||||
});
|
||||
|
||||
const exerciseGetRowsProcessed = (input, expectation) => {
|
||||
const result = getBulkManagementHistory({
|
||||
grades: {
|
||||
bulkManagement: {
|
||||
history: [
|
||||
{ ...genericHistoryRow, data: input },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const [{ summaryOfRowsProcessed }] = result;
|
||||
expect(summaryOfRowsProcessed).toEqual(expect.objectContaining(expectation));
|
||||
};
|
||||
|
||||
it('calculates skippage', () => {
|
||||
exerciseGetRowsProcessed({
|
||||
total_rows: 100,
|
||||
processed_rows: 10,
|
||||
saved_rows: 10,
|
||||
}, {
|
||||
skipped: 90,
|
||||
});
|
||||
});
|
||||
|
||||
it('calculates failures', () => {
|
||||
exerciseGetRowsProcessed({
|
||||
total_rows: 10,
|
||||
processed_rows: 100,
|
||||
saved_rows: 10,
|
||||
}, {
|
||||
failed: 90,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user