Compare commits

..

14 Commits

Author SHA1 Message Date
Simon Chen
e9bc4cebe4 Merge pull request #43 from edx/schen/fix_local
fix(auth): fix locally running gradebook auth refresh issue
2018-12-07 14:15:03 -05:00
Simon Chen
559180592c fix(auth): fix locally running gradebook auth refresh issue 2018-12-07 14:09:50 -05:00
Richard I Reilly
0f1f0ae89d Merge pull request #41 from edx/rir/header
Add super simple header
2018-12-07 14:04:14 -05:00
Rick Reilly
2e725e0441 Add super simple header 2018-12-07 11:48:26 -05:00
Simon Chen
a1c2ccc539 Merge pull request #40 from edx/schen/tests2
Add more unit tests on actions and reducers
2018-12-07 10:59:02 -05:00
Simon Chen
a70ddd79f6 Add more unit tests on actions and reducers 2018-12-07 10:12:13 -05:00
Simon Chen
dd82054bbc Merge pull request #39 from edx/schen/setup-test
feat(test): Setup unit testing
2018-12-05 14:20:58 -05:00
Jansen Kantor
6a4bc67841 Merge pull request #38 from edx/encoding
fix(UI) specify utf8 to avoid incorrect character rendering
2018-12-05 14:15:51 -05:00
Simon Chen
adfefac85d feat(test): Setup unit testing 2018-12-05 13:52:33 -05:00
jkantor
c92144c436 fix(UI) specify utf8 to avoid incorrect character rendering 2018-12-05 13:35:28 -05:00
Jansen Kantor
ca0156ea4c Merge pull request #36 from edx/rounded-percents
fix(UI) rounded percentages to two decimal places
2018-12-05 13:33:38 -05:00
jkantor
61c4bc11bd fix(UI) rounded percentages to two decimal places 2018-12-05 10:53:55 -05:00
Jansen Kantor
db25a18f9d Merge pull request #35 from edx/updateMessage-filter
fix(UI) box should appear after editing grade
2018-12-04 14:01:05 -05:00
jkantor
0d7fa18acd fix(UI) box should appear after editing grade 2018-12-04 13:38:27 -05:00
22 changed files with 511 additions and 23 deletions

View File

@@ -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

BIN
assets/edx-sm.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -80,7 +80,7 @@ module.exports = Merge.smart(commonConfig, {
LOGIN_URL: 'http://localhost:18000/login',
LOGOUT_URL: 'http://localhost:18000/login',
CSRF_TOKEN_API_PATH: '/csrf/api/v1/token',
REFRESH_ACCESS_TOKEN_ENDPOINT: 'http://localhost:18000/login',
REFRESH_ACCESS_TOKEN_ENDPOINT: 'http://localhost:18000/login_refresh',
DATA_API_BASE_URL: 'http://localhost:8000',
// LMS_CLIENT_ID should match the lms DOT client application id your LMS container
LMS_CLIENT_ID: 'login-service-client-id',

9
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -1,6 +1,8 @@
<!doctype html>
<html>
<head></head>
<head>
<meta charset="utf-8">
</head>
<body>
<div id="root"></div>
</body>

View File

@@ -1,7 +1,8 @@
.spinner-overlay {
position: absolute;
position: fixed;
height: 100%;
width: 100%;
top: 0;
background-color: #999;
opacity: 0.5;
z-index: 99999;
@@ -28,9 +29,6 @@
}
}
.back-link{
float:right;
}
.student-filters{
display: flex;
.label{

View File

@@ -11,6 +11,8 @@ import {
import queryString from 'query-string';
import { configuration } from '../../config';
const DECIMAL_PRECISION = 2;
export default class Gradebook extends React.Component {
constructor(props) {
super(props);
@@ -174,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 };
@@ -185,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);
}),
@@ -219,13 +223,13 @@ export default class Gradebook extends React.Component {
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">
<div className="gradebook-container">
<div>
<a
href={this.lmsInstructorDashboardUrl(this.props.match.params.courseId)}
className="back-link"
className="mb-3"
>
Back to Dashboard
{'<< Back to Dashboard'}
</a>
<h1>Gradebook</h1>
<h3> {this.props.match.params.courseId}</h3>

View File

@@ -0,0 +1,22 @@
import React from 'react';
import { Hyperlink } from '@edx/paragon';
export default class Header extends React.Component {
constructor(props) {
super(props);
this.state = {
mobileNavOpen: false,
};
}
render() {
return (
<div className="mb-3">
<header className="d-flex justify-content-center align-items-center p-3 border-bottom-blue">
<Hyperlink content={<img src="./assets/edx-sm.png" alt="The edX logo" height="30" width="60" />} destination="https://www.edx.org" />
<div />
</header>
</div>
);
}
}

View File

@@ -37,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));

View File

@@ -17,7 +17,7 @@ const fetchAssignmentTypes = courseId => (
.then((data) => {
dispatch(gotAssignmentTypes(Object.keys(data.assignment_types)));
})
.catch((error) => {
.catch(() => {
dispatch(errorFetchingAssignmentTypes());
});
}

View 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 { configuration } from '../../config';
import { fetchAssignmentTypes } from './assignmentTypes';
import {
STARTED_FETCHING_ASSIGNMENT_TYPES,
GOT_ASSIGNMENT_TYPES,
ERROR_FETCHING_ASSIGNMENT_TYPES,
} from '../constants/actionTypes/assignmentTypes';
const mockStore = configureMockStore([thunk]);
const axiosMock = new MockAdapter(apiClient);
describe('actions', () => {
afterEach(() => {
axiosMock.reset();
});
describe('fetchAssignmentTypes', () => {
const courseId = 'course-v1:edX+DemoX+Demo_Course';
it('dispatches success action after fetching fetchAssignmentTypes', () => {
const responseData = {
assignment_types: {
Exam: {
drop_count: 0,
min_count: 1,
short_label: 'Exam',
type: 'Exam',
weight: 0.25,
},
Homework: {
drop_count: 1,
min_count: 3,
short_label: 'Ex',
type: 'Homework',
weight: 0.75,
},
},
};
const expectedActions = [
{ type: STARTED_FETCHING_ASSIGNMENT_TYPES },
{ type: GOT_ASSIGNMENT_TYPES, assignmentTypes: Object.keys(responseData.assignment_types) },
];
const store = mockStore();
axiosMock.onGet(`${configuration.LMS_BASE_URL}/api/grades/v1/gradebook/${courseId}/grading-info?graded_only=true`)
.replyOnce(200, JSON.stringify(responseData));
return store.dispatch(fetchAssignmentTypes(courseId)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it('dispatches failure action after fetching cohorts', () => {
const expectedActions = [
{ type: STARTED_FETCHING_ASSIGNMENT_TYPES },
{ type: ERROR_FETCHING_ASSIGNMENT_TYPES },
];
const store = mockStore();
axiosMock.onGet(`${configuration.LMS_BASE_URL}/api/grades/v1/gradebook/${courseId}/grading-info?graded_only=true`)
.replyOnce(500, JSON.stringify({}));
return store.dispatch(fetchAssignmentTypes(courseId)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
});
});

View File

@@ -17,7 +17,7 @@ const fetchCohorts = courseId => (
.then((data) => {
dispatch(gotCohorts(data.cohorts));
})
.catch((error) => {
.catch(() => {
dispatch(errorFetchingCohorts());
});
}

View File

@@ -0,0 +1,74 @@
import configureMockStore from 'redux-mock-store';
import MockAdapter from 'axios-mock-adapter';
import thunk from 'redux-thunk';
import apiClient from '../apiClient';
import { configuration } from '../../config';
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(`${configuration.LMS_BASE_URL}/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(`${configuration.LMS_BASE_URL}/courses/${courseId}/cohorts/`)
.replyOnce(500, JSON.stringify({}));
return store.dispatch(fetchCohorts(courseId)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
});
});

View File

@@ -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());
@@ -139,7 +140,7 @@ const updateGrades = (courseId, updateData, searchText, cohort, track) => (
.then(response => response.data)
.then((data) => {
dispatch(gradeUpdateSuccess(data));
dispatch(fetchMatchingUserGrades(courseId, searchText, cohort, track));
dispatch(fetchMatchingUserGrades(courseId, searchText, cohort, track, true));
})
.catch((error) => {
dispatch(gradeUpdateFailure(error));

View File

@@ -0,0 +1,142 @@
import configureMockStore from 'redux-mock-store';
import MockAdapter from 'axios-mock-adapter';
import thunk from 'redux-thunk';
import apiClient from '../apiClient';
import { configuration } from '../../config';
import { fetchGrades } from './grades';
import {
STARTED_FETCHING_GRADES,
FINISHED_FETCHING_GRADES,
ERROR_FETCHING_GRADES,
GOT_GRADES,
UPDATE_BANNER,
} from '../constants/actionTypes/grades';
import { sortAlphaAsc } from './utils';
const mockStore = configureMockStore([thunk]);
const axiosMock = new MockAdapter(apiClient);
describe('actions', () => {
afterEach(() => {
axiosMock.reset();
});
describe('fetchGrades', () => {
const courseId = 'course-v1:edX+DemoX+Demo_Course';
const expectedCohort = 1;
const expectedTrack = 'verified';
const fetchGradesURL = `${configuration.LMS_BASE_URL}/api/grades/v1/gradebook/${courseId}/?page_size=10&cohort_id=${expectedCohort}&enrollment_mode=${expectedTrack}`;
const responseData = {
next: `${fetchGradesURL}&cursor=2344fda`,
previous: null,
results: [
{
course_id: courseId,
email: 'user1@example.com',
username: 'user1',
user_id: 1,
percent: 0.5,
letter_grade: null,
section_breakdown: [
{
subsection_name: 'Demo Course Overview',
score_earned: 0,
score_possible: 0,
percent: 0,
displayed_value: '0.00',
grade_description: '(0.00/0.00)',
},
{
subsection_name: 'Example Week 1: Getting Started',
score_earned: 1,
score_possible: 1,
percent: 1,
displayed_value: '1.00',
grade_description: '(0.00/0.00)',
},
],
},
{
course_id: courseId,
email: 'user22@example.com',
username: 'user22',
user_id: 22,
percent: 0,
letter_grade: null,
section_breakdown: [
{
subsection_name: 'Demo Course Overview',
score_earned: 0,
score_possible: 0,
percent: 0,
displayed_value: '0.00',
grade_description: '(0.00/0.00)',
},
{
subsection_name: 'Example Week 1: Getting Started',
score_earned: 1,
score_possible: 1,
percent: 0,
displayed_value: '0.00',
grade_description: '(0.00/0.00)',
},
],
}],
};
it('dispatches success action after fetching grades', () => {
const expectedActions = [
{ type: STARTED_FETCHING_GRADES },
{
type: GOT_GRADES,
grades: responseData.results.sort(sortAlphaAsc),
cohort: expectedCohort,
track: expectedTrack,
headings: [
{
columnSortable: true,
key: 'username',
label: 'Username',
onSort: expect.anything(),
},
{
columnSortable: true,
key: 'total',
label: 'Total',
onSort: expect.anything(),
},
],
prev: responseData.previous,
next: responseData.next,
},
{ type: FINISHED_FETCHING_GRADES },
{ type: UPDATE_BANNER, showSuccess: false },
];
const store = mockStore();
axiosMock.onGet(fetchGradesURL)
.replyOnce(200, JSON.stringify(responseData));
return store.dispatch(fetchGrades(courseId, expectedCohort, expectedTrack, false)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it('dispatches failure action after fetching grades', () => {
const expectedActions = [
{ type: STARTED_FETCHING_GRADES },
{ type: ERROR_FETCHING_GRADES },
];
const store = mockStore();
axiosMock.onGet(fetchGradesURL)
.replyOnce(500, JSON.stringify({}));
return store.dispatch(fetchGrades(courseId, expectedCohort, expectedTrack, false)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
});
});

View File

@@ -17,7 +17,7 @@ const fetchTracks = courseId => (
.then((data) => {
dispatch(gotTracks(data.course_modes));
})
.catch((error) => {
.catch(() => {
dispatch(errorFetchingTracks());
});
}

View File

@@ -0,0 +1,80 @@
import configureMockStore from 'redux-mock-store';
import MockAdapter from 'axios-mock-adapter';
import thunk from 'redux-thunk';
import apiClient from '../apiClient';
import { configuration } from '../../config';
import { fetchTracks } from './tracks';
import {
STARTED_FETCHING_TRACKS,
GOT_TRACKS,
ERROR_FETCHING_TRACKS,
} from '../constants/actionTypes/tracks';
const mockStore = configureMockStore([thunk]);
const axiosMock = new MockAdapter(apiClient);
describe('actions', () => {
afterEach(() => {
axiosMock.reset();
});
describe('fetchTracks', () => {
const courseId = 'course-v1:edX+DemoX+Demo_Course';
it('dispatches success action after fetching tracks', () => {
const responseData = {
course_modes: [
{
slug: 'audit',
name: 'Audit',
min_price: 0,
suggested_prices: '',
currency: 'usd',
expiration_datetime: null,
description: null,
sku: '68EFFFF',
bulk_sku: null,
},
{
slug: 'verified',
name: 'Verified Certificate',
min_price: 100,
suggested_prices: '',
currency: 'usd',
expiration_datetime: '2021-05-04T18:08:12.644361Z',
description: null,
sku: '8CF08E5',
bulk_sku: 'A5B6DBE',
}],
};
const expectedActions = [
{ type: STARTED_FETCHING_TRACKS },
{ type: GOT_TRACKS, tracks: responseData.course_modes },
];
const store = mockStore();
axiosMock.onGet(`${configuration.LMS_BASE_URL}/api/enrollment/v1/course/${courseId}`)
.replyOnce(200, JSON.stringify(responseData));
return store.dispatch(fetchTracks(courseId)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it('dispatches failure action after fetching tracks', () => {
const expectedActions = [
{ type: STARTED_FETCHING_TRACKS },
{ type: ERROR_FETCHING_TRACKS },
];
const store = mockStore();
axiosMock.onGet(`${configuration.LMS_BASE_URL}/api/enrollment/v1/course/${courseId}`)
.replyOnce(500, JSON.stringify({}));
return store.dispatch(fetchTracks(courseId)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
});
});

View File

@@ -17,6 +17,7 @@ const cohorts = (state = initialState, action) => {
return {
...state,
results: action.cohorts,
finishedFetching: true,
errorFetching: false,
};
case STARTED_FETCHING_COHORTS:

View File

@@ -0,0 +1,70 @@
import cohorts from './cohorts';
import {
STARTED_FETCHING_COHORTS,
ERROR_FETCHING_COHORTS,
GOT_COHORTS,
} from '../constants/actionTypes/cohorts';
const initialState = {
results: [],
startedFetching: false,
errorFetching: false,
};
const cohortsData = [
{
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,
}];
describe('dashboardAnalytics reducer', () => {
it('has initial state', () => {
expect(cohorts(undefined, {})).toEqual(initialState);
});
it('updates fetch cohorts request state', () => {
const expected = {
...initialState,
startedFetching: true,
};
expect(cohorts(undefined, {
type: STARTED_FETCHING_COHORTS,
})).toEqual(expected);
});
it('updates fetch cohorts success state', () => {
const expected = {
...initialState,
results: cohortsData,
errorFetching: false,
finishedFetching: true,
};
expect(cohorts(undefined, {
type: GOT_COHORTS,
cohorts: cohortsData,
})).toEqual(expected);
});
it('updates fetch cohorts failure state', () => {
const expected = {
...initialState,
errorFetching: true,
finishedFetching: true,
};
expect(cohorts(undefined, {
type: ERROR_FETCHING_COHORTS,
})).toEqual(expected);
});
});

View File

@@ -6,17 +6,21 @@ import { Provider } from 'react-redux';
import apiClient from './data/apiClient';
import GradebookPage from './containers/GradebookPage';
import Header from './components/Header';
import store from './data/store';
import './App.scss';
const App = () => (
<Provider store={store}>
<Router>
<main>
<Switch>
<Route exact path="/:courseId" component={GradebookPage} />
</Switch>
</main>
<div>
<Header />
<main>
<Switch>
<Route exact path="/:courseId" component={GradebookPage} />
</Switch>
</main>
</div>
</Router>
</Provider>
);

View File

@@ -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';