Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd82054bbc | ||
|
|
6a4bc67841 | ||
|
|
adfefac85d | ||
|
|
c92144c436 | ||
|
|
ca0156ea4c | ||
|
|
61c4bc11bd | ||
|
|
db25a18f9d | ||
|
|
0d7fa18acd | ||
|
|
012bb3a1f3 | ||
|
|
de233e0285 | ||
|
|
ae7544cd53 | ||
|
|
14df81b312 | ||
|
|
4706cfcd94 | ||
|
|
1f5a2469b2 |
3
Makefile
3
Makefile
@@ -30,3 +30,6 @@ restart-detached:
|
||||
|
||||
validate-no-uncommitted-package-lock-changes:
|
||||
git diff --exit-code package-lock.json
|
||||
|
||||
test:
|
||||
docker exec -it edx.gradebook jest
|
||||
|
||||
9
package-lock.json
generated
9
package-lock.json
generated
@@ -4161,6 +4161,15 @@
|
||||
"is-buffer": "^1.1.5"
|
||||
}
|
||||
},
|
||||
"axios-mock-adapter": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/axios-mock-adapter/-/axios-mock-adapter-1.15.0.tgz",
|
||||
"integrity": "sha1-+8BoJdgwLJXDM00hAju6mWJV1F0=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"deep-equal": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"axobject-query": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-0.1.0.tgz",
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
"whatwg-fetch": "^2.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"axios-mock-adapter": "^1.15.0",
|
||||
"babel-cli": "^6.26.0",
|
||||
"babel-eslint": "^8.2.2",
|
||||
"babel-jest": "^22.4.0",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head></head>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
|
||||
@@ -1,3 +1,20 @@
|
||||
.spinner-overlay {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background-color: #999;
|
||||
opacity: 0.5;
|
||||
z-index: 99999;
|
||||
display:flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 200px;
|
||||
}
|
||||
|
||||
.color-black {
|
||||
color: black;
|
||||
}
|
||||
|
||||
.gradebook-container{
|
||||
width: 500px;
|
||||
@media only screen and (min-width: 640px) {
|
||||
@@ -10,6 +27,7 @@
|
||||
width: 1024px;
|
||||
}
|
||||
}
|
||||
|
||||
.back-link{
|
||||
float:right;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import React from 'react';
|
||||
import { Button, InputSelect, Modal, SearchField, StatusAlert, Table } from '@edx/paragon';
|
||||
import {
|
||||
Button,
|
||||
InputSelect,
|
||||
Modal,
|
||||
SearchField,
|
||||
StatusAlert,
|
||||
Table,
|
||||
Icon,
|
||||
} from '@edx/paragon';
|
||||
import queryString from 'query-string';
|
||||
import { configuration } from '../../config';
|
||||
|
||||
const DECIMAL_PRECISION = 2;
|
||||
|
||||
export default class Gradebook extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
@@ -51,15 +61,20 @@ export default class Gradebook extends React.Component {
|
||||
}
|
||||
|
||||
handleAdjustedGradeClick = () => {
|
||||
this.props.updateGrades(this.props.match.params.courseId, [
|
||||
{
|
||||
user_id: this.state.updateUserId,
|
||||
usage_id: this.state.updateModuleId,
|
||||
grade: {
|
||||
earned_graded_override: this.state.updateVal,
|
||||
this.props.updateGrades(
|
||||
this.props.match.params.courseId, [
|
||||
{
|
||||
user_id: this.state.updateUserId,
|
||||
usage_id: this.state.updateModuleId,
|
||||
grade: {
|
||||
earned_graded_override: this.state.updateVal,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
],
|
||||
this.state.filterValue,
|
||||
this.props.selectedCohort,
|
||||
this.props.selectedTrack,
|
||||
);
|
||||
|
||||
this.setState({
|
||||
modalModel: [{}],
|
||||
@@ -161,6 +176,8 @@ export default class Gradebook extends React.Component {
|
||||
return 'Tracks';
|
||||
};
|
||||
|
||||
roundPercentageGrade = percent => parseFloat(percent.toFixed(DECIMAL_PRECISION));
|
||||
|
||||
formatter = {
|
||||
percent: entries => entries.map((entry) => {
|
||||
const results = { username: entry.username };
|
||||
@@ -172,11 +189,11 @@ export default class Gradebook extends React.Component {
|
||||
className="btn btn-header link-style"
|
||||
onClick={() => this.setNewModalState(entry, subsection)}
|
||||
>
|
||||
{subsection.percent * 100}%
|
||||
{this.roundPercentageGrade(subsection.percent * 100)}%
|
||||
</button>);
|
||||
return acc;
|
||||
}, {});
|
||||
const totals = { total: `${entry.percent * 100}%` };
|
||||
const totals = { total: `${this.roundPercentageGrade(entry.percent * 100)}%` };
|
||||
return Object.assign(results, assignments, totals);
|
||||
}),
|
||||
|
||||
@@ -205,6 +222,7 @@ 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="card gradebook-container">
|
||||
<div className="card-body">
|
||||
<a
|
||||
|
||||
@@ -26,7 +26,8 @@ const mapStateToProps = state => (
|
||||
showSuccess: state.grades.showSuccess,
|
||||
prevPage: state.grades.prevPage,
|
||||
nextPage: state.grades.nextPage,
|
||||
assignmnetTypes: state.assignmentTypes.results || [],
|
||||
assignmnetTypes: state.assignmentTypes.results,
|
||||
showSpinner: state.grades.showSpinner,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -36,7 +37,7 @@ const mapDispatchToProps = dispatch => (
|
||||
dispatch(fetchGrades(courseId, cohort, track));
|
||||
},
|
||||
searchForUser: (courseId, searchText, cohort, track) => {
|
||||
dispatch(fetchMatchingUserGrades(courseId, searchText, cohort, track));
|
||||
dispatch(fetchMatchingUserGrades(courseId, searchText, cohort, track, false));
|
||||
},
|
||||
getPrevNextGrades: (endpoint, cohort, track) => {
|
||||
dispatch(fetchPrevNextGrades(endpoint, cohort, track));
|
||||
@@ -50,8 +51,8 @@ const mapDispatchToProps = dispatch => (
|
||||
getAssignmentTypes: (courseId) => {
|
||||
dispatch(fetchAssignmentTypes(courseId));
|
||||
},
|
||||
updateGrades: (courseId, updateData) => {
|
||||
dispatch(updateGrades(courseId, updateData));
|
||||
updateGrades: (courseId, updateData, searchText, cohort, track) => {
|
||||
dispatch(updateGrades(courseId, updateData, searchText, cohort, track));
|
||||
},
|
||||
toggleFormat: (formatType) => {
|
||||
dispatch(toggleGradeFormat(formatType));
|
||||
|
||||
73
src/data/actions/cohorts.test.js
Normal file
73
src/data/actions/cohorts.test.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import configureMockStore from 'redux-mock-store';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import thunk from 'redux-thunk';
|
||||
|
||||
import apiClient from '../apiClient';
|
||||
import { fetchCohorts } from './cohorts';
|
||||
import {
|
||||
STARTED_FETCHING_COHORTS,
|
||||
GOT_COHORTS,
|
||||
ERROR_FETCHING_COHORTS,
|
||||
} from '../constants/actionTypes/cohorts';
|
||||
|
||||
const mockStore = configureMockStore([thunk]);
|
||||
const axiosMock = new MockAdapter(apiClient);
|
||||
|
||||
describe('actions', () => {
|
||||
afterEach(() => {
|
||||
axiosMock.reset();
|
||||
});
|
||||
|
||||
describe('fetchCohorts', () => {
|
||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
|
||||
it('dispatches success action after fetching cohorts', () => {
|
||||
const responseData = {
|
||||
cohorts: [
|
||||
{
|
||||
assignment_type: 'manual',
|
||||
group_id: null,
|
||||
id: 1,
|
||||
name: 'default_group',
|
||||
user_count: 2,
|
||||
user_partition_id: null,
|
||||
},
|
||||
{
|
||||
assignment_type: 'auto',
|
||||
group_id: null,
|
||||
id: 2,
|
||||
name: 'auto_group',
|
||||
user_count: 5,
|
||||
user_partition_id: null,
|
||||
}],
|
||||
};
|
||||
const expectedActions = [
|
||||
{ type: STARTED_FETCHING_COHORTS },
|
||||
{ type: GOT_COHORTS, cohorts: responseData.cohorts },
|
||||
];
|
||||
const store = mockStore();
|
||||
|
||||
axiosMock.onGet(`http://localhost:18000/courses/${courseId}/cohorts/`)
|
||||
.replyOnce(200, JSON.stringify(responseData));
|
||||
|
||||
return store.dispatch(fetchCohorts(courseId)).then(() => {
|
||||
expect(store.getActions()).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
|
||||
it('dispatches failure action after fetching cohorts', () => {
|
||||
const expectedActions = [
|
||||
{ type: STARTED_FETCHING_COHORTS },
|
||||
{ type: ERROR_FETCHING_COHORTS },
|
||||
];
|
||||
const store = mockStore();
|
||||
|
||||
axiosMock.onGet(`http://localhost:18000/courses/${courseId}/cohorts/`)
|
||||
.replyOnce(500, JSON.stringify({}));
|
||||
|
||||
return store.dispatch(fetchCohorts(courseId)).then(() => {
|
||||
expect(store.getActions()).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -87,7 +87,7 @@ const fetchGrades = (courseId, cohort, track, showSuccess) => (
|
||||
}
|
||||
);
|
||||
|
||||
const fetchMatchingUserGrades = (courseId, searchText, cohort, track) => (
|
||||
const fetchMatchingUserGrades = (courseId, searchText, cohort, track, showSuccess) => (
|
||||
(dispatch) => {
|
||||
dispatch(startedFetchingGrades());
|
||||
return LmsApiService.fetchGradebookData(courseId, searchText, cohort, track)
|
||||
@@ -102,6 +102,7 @@ const fetchMatchingUserGrades = (courseId, searchText, cohort, track) => (
|
||||
data.next,
|
||||
));
|
||||
dispatch(finishedFetchingGrades());
|
||||
dispatch(updateBanner(showSuccess));
|
||||
})
|
||||
.catch(() => {
|
||||
dispatch(errorFetchingGrades());
|
||||
@@ -132,14 +133,14 @@ const fetchPrevNextGrades = (endpoint, cohort, track) => (
|
||||
);
|
||||
|
||||
|
||||
const updateGrades = (courseId, updateData) => (
|
||||
const updateGrades = (courseId, updateData, searchText, cohort, track) => (
|
||||
(dispatch) => {
|
||||
dispatch(gradeUpdateRequest());
|
||||
return LmsApiService.updateGradebookData(courseId, updateData)
|
||||
.then(response => response.data)
|
||||
.then((data) => {
|
||||
dispatch(gradeUpdateSuccess(data));
|
||||
dispatch(fetchGrades(courseId, null, null, true));
|
||||
dispatch(fetchMatchingUserGrades(courseId, searchText, cohort, track, true));
|
||||
})
|
||||
.catch((error) => {
|
||||
dispatch(gradeUpdateFailure(error));
|
||||
|
||||
@@ -19,6 +19,7 @@ const initialState = {
|
||||
showSuccess: false,
|
||||
prevPage: null,
|
||||
nextPage: null,
|
||||
showSpinner: true,
|
||||
};
|
||||
|
||||
const grades = (state = initialState, action) => {
|
||||
@@ -34,12 +35,14 @@ const grades = (state = initialState, action) => {
|
||||
selectedCohort: action.cohort,
|
||||
prevPage: action.prev,
|
||||
nextPage: action.next,
|
||||
showSpinner: false,
|
||||
};
|
||||
case STARTED_FETCHING_GRADES:
|
||||
return {
|
||||
...state,
|
||||
startedFetching: true,
|
||||
finishedFetching: false,
|
||||
showSpinner: true,
|
||||
};
|
||||
case ERROR_FETCHING_GRADES:
|
||||
return {
|
||||
|
||||
@@ -3,12 +3,12 @@ import { configuration } from '../../config';
|
||||
|
||||
class LmsApiService {
|
||||
static baseUrl = configuration.LMS_BASE_URL;
|
||||
static pageSize = 10
|
||||
|
||||
static fetchGradebookData(courseId, searchText, cohort, track) {
|
||||
let gradebookUrl = `${LmsApiService.baseUrl}/api/grades/v1/gradebook/${courseId}/`;
|
||||
if (searchText || track || cohort) {
|
||||
gradebookUrl += '?';
|
||||
}
|
||||
|
||||
gradebookUrl += `?page_size=${LmsApiService.pageSize}&`;
|
||||
if (searchText) {
|
||||
gradebookUrl += `username_contains=${searchText}&`;
|
||||
}
|
||||
|
||||
@@ -4,3 +4,7 @@ import Enzyme from 'enzyme';
|
||||
import Adapter from 'enzyme-adapter-react-16';
|
||||
|
||||
Enzyme.configure({ adapter: new Adapter() });
|
||||
|
||||
// These configuration values are usually set in webpack's EnvironmentPlugin however
|
||||
// Jest does not use webpack so we need to set these so for testing
|
||||
process.env.LMS_BASE_URL = 'http://localhost:18000';
|
||||
|
||||
Reference in New Issue
Block a user