Compare commits

..

14 Commits

Author SHA1 Message Date
jansenk
286e194414 change test expectations 2019-01-03 16:55:13 -05:00
jansenk
3deb592d89 remove sorting on columns 2019-01-03 16:48:30 -05:00
Jansen Kantor
67493d1e9e Merge pull request #69 from edx/jkantor/change-message
changed role error message and don't show during loading
2019-01-03 10:59:37 -05:00
jansenk
e5bca7e526 changed role error message and don't show during loading 2019-01-02 16:35:06 -05:00
Jansen Kantor
52c5357ce7 Merge pull request #66 from edx/jkantor/change-pagination-buttons
implement gradebook pagination button feedback
2019-01-02 16:19:43 -05:00
jansenk
d469cc2de7 implement gradebook pagination button feedback
refactor buttons to a pure function component
change labels
disable rather than hide

EDUCATOR-3825
2019-01-02 16:11:01 -05:00
Jansen Kantor
86092f22b3 Merge pull request #65 from edx/jkantor/disable-student-groups
disable rather than hide empty groups and cohorts
2019-01-02 09:22:19 -05:00
Zachary Hancock
c8cb07228f Merge pull request #63 from edx/zhancock/edit-modal
Gradebook edit modal updates
2018-12-27 10:04:57 -05:00
Jansen Kantor
a1946e7bc4 Merge pull request #64 from edx/jkantor/roles-filter
request filtered roles
2018-12-21 15:08:09 -05:00
jansenk
01d80e0fff disable rather than hide empty groups and cohorts
EDUCATOR-3824
2018-12-21 14:23:19 -05:00
jansenk
e6da087e83 request filtered roles 2018-12-21 12:53:07 -05:00
Jansen Kantor
ac5eaed5cb Merge pull request #62 from edx/jkantor/staff
fix(auth) allow global staff to view gradebook
2018-12-21 11:46:02 -05:00
jansenk
88997ca242 fix(auth) allow global staff to view gradebook 2018-12-21 11:34:28 -05:00
Zach Hancock
d5daf9086f gradebook edit modal message 2018-12-21 10:09:35 -05:00
9 changed files with 334 additions and 91 deletions

View File

@@ -10,6 +10,7 @@ import {
} from '@edx/paragon';
import queryString from 'query-string';
import { configuration } from '../../config';
import PageButtons from '../PageButtons';
const DECIMAL_PRECISION = 2;
@@ -252,9 +253,9 @@ export default class Gradebook extends React.Component {
The grades for this course are now frozen. Editing of grades is no longer allowed.
</div>
}
{ !this.props.canUserViewGradebook &&
{ (this.props.canUserViewGradebook === false) &&
<div className="alert alert-warning" role="alert" >
You are not authorized to view the gradebook for this course. If you have a global role, please enroll in this course and try again.
You are not authorized to view the gradebook for this course.
</div>
}
<hr />
@@ -299,29 +300,25 @@ export default class Gradebook extends React.Component {
/>
</div>
}
{(this.props.tracks.length > 0 || this.props.cohorts.length > 0) &&
<div className="student-filters">
<span className="label">
Student Groups:
</span>
{this.props.tracks.length > 0 &&
<InputSelect
name="Tracks"
value={this.mapSelectedTrackEntry(this.props.selectedTrack)}
options={this.mapTracksEntries(this.props.tracks)}
onChange={this.updateTracks}
/>
}
{this.props.cohorts.length > 0 &&
<InputSelect
name="Cohorts"
value={this.mapSelectedCohortEntry(this.props.selectedCohort)}
options={this.mapCohortsEntries(this.props.cohorts)}
onChange={this.updateCohorts}
/>
}
</div>
}
<div className="student-filters">
<span className="label">
Student Groups:
</span>
<InputSelect
name="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"
disabled={this.props.cohorts.length === 0}
value={this.mapSelectedCohortEntry(this.props.selectedCohort)}
options={this.mapCohortsEntries(this.props.cohorts)}
onChange={this.updateCohorts}
/>
</div>
</div>
<div>
<div style={{ marginLeft: '10px', marginBottom: '10px' }}>
@@ -333,21 +330,6 @@ export default class Gradebook extends React.Component {
onClear={() => this.props.getUserGrades(this.props.match.params.courseId, this.props.selectedCohort, this.props.selectedTrack)}
value={this.state.filterValue}
/>
<div className="d-flex justify-content-end" style={{ marginTop: '20px' }}>
<Button
label="Previous"
buttonType="primary"
style={{ visibility: (!this.props.prevPage ? 'hidden' : 'visible') }}
onClick={() => this.props.getPrevNextGrades(this.props.prevPage, this.props.selectedCohort, this.props.selectedTrack)}
/>
<div style={{ width: '10px' }} />
<Button
label="Next"
buttonType="primary"
style={{ visibility: (!this.props.nextPage ? 'hidden' : 'visible') }}
onClick={() => this.props.getPrevNextGrades(this.props.nextPage, this.props.selectedCohort, this.props.selectedTrack)}
/>
</div>
</div>
</div>
<br />
@@ -357,18 +339,18 @@ export default class Gradebook extends React.Component {
onClose={() => this.props.updateBanner(false)}
open={this.props.showSuccess}
/>
{PageButtons(this.props)}
<div className="gbook">
<Table
columns={this.props.headings}
data={this.formatter[this.props.format](this.props.grades, this.props.areGradesFrozen)}
tableSortable
defaultSortDirection="asc"
defaultSortedColumn="username"
/>
</div>
{PageButtons(this.props)}
<Modal
open={this.state.modalOpen}
title="Edit Grades"
closeText="Cancel"
body={(
<div>
<h3>{this.state.modalModel[0].assignmentName}</h3>
@@ -376,11 +358,12 @@ export default class Gradebook extends React.Component {
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="Edit Grade"
label="Save Grade"
buttonType="primary"
onClick={this.handleAdjustedGradeClick}
/>,

View File

@@ -0,0 +1,36 @@
import renderer from 'react-test-renderer';
import PageButtons from '.';
const createInput = function createInput(prevPage, nextPage) {
return {
prevPage,
nextPage,
selectedTrack: 't',
selectedCohort: 'c',
getPrevNextGrades() {},
};
};
describe('PageButtons', () => {
const assertPageButtonsSnapshot = function assertPageButtonsSnapshot(input) {
const pb = renderer.create(PageButtons(input));
const tree = pb.toJSON();
expect(tree).toMatchSnapshot();
};
it('prev null, next null', () => {
assertPageButtonsSnapshot(createInput(null, null));
});
it('prev null, next not null', () => {
assertPageButtonsSnapshot(createInput(null, 'np'));
});
it('prev not null, next null', () => {
assertPageButtonsSnapshot(createInput('pp', null));
});
it('prev not null, next not null', () => {
assertPageButtonsSnapshot(createInput('pp', 'np'));
});
});

View File

@@ -0,0 +1,169 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PageButtons prev not null, next not null 1`] = `
<div
className="d-flex justify-content-center"
style={
Object {
"paddingBottom": "20px",
}
}
>
<button
className="btn btn-primary"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
style={
Object {
"margin": "20px",
}
}
type="button"
>
Previous Page
</button>
<button
className="btn btn-primary"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
style={
Object {
"margin": "20px",
}
}
type="button"
>
Next Page
</button>
</div>
`;
exports[`PageButtons prev not null, next null 1`] = `
<div
className="d-flex justify-content-center"
style={
Object {
"paddingBottom": "20px",
}
}
>
<button
className="btn btn-primary"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
style={
Object {
"margin": "20px",
}
}
type="button"
>
Previous Page
</button>
<button
className="btn btn-primary"
disabled={true}
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
style={
Object {
"margin": "20px",
}
}
type="button"
>
Next Page
</button>
</div>
`;
exports[`PageButtons prev null, next not null 1`] = `
<div
className="d-flex justify-content-center"
style={
Object {
"paddingBottom": "20px",
}
}
>
<button
className="btn btn-primary"
disabled={true}
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
style={
Object {
"margin": "20px",
}
}
type="button"
>
Previous Page
</button>
<button
className="btn btn-primary"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
style={
Object {
"margin": "20px",
}
}
type="button"
>
Next Page
</button>
</div>
`;
exports[`PageButtons prev null, next null 1`] = `
<div
className="d-flex justify-content-center"
style={
Object {
"paddingBottom": "20px",
}
}
>
<button
className="btn btn-primary"
disabled={true}
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
style={
Object {
"margin": "20px",
}
}
type="button"
>
Previous Page
</button>
<button
className="btn btn-primary"
disabled={true}
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
style={
Object {
"margin": "20px",
}
}
type="button"
>
Next Page
</button>
</div>
`;

View File

@@ -0,0 +1,30 @@
import React from 'react';
import {
Button,
} from '@edx/paragon';
export default function PageButtons({prevPage, nextPage, selectedTrack, selectedCohort, getPrevNextGrades}) {
return (
<div
className="d-flex justify-content-center"
style={{ paddingBottom: '20px' }}
>
<Button
label="Previous Page"
style={{ margin: '20px' }}
buttonType="primary"
disabled={!prevPage}
onClick={() => getPrevNextGrades(prevPage, selectedCohort, selectedTrack)}
/>
<Button
label="Next Page"
style={{ margin: '20px' }}
buttonType="primary"
disabled={!nextPage}
onClick={() => getPrevNextGrades(nextPage, selectedCohort, selectedTrack)}
/>
</div>
);
}

View File

@@ -96,16 +96,12 @@ describe('actions', () => {
track: expectedTrack,
headings: [
{
columnSortable: true,
key: 'username',
label: 'Username',
onSort: expect.anything(),
},
{
columnSortable: true,
key: 'total',
label: 'Total',
onSort: expect.anything(),
},
],
prev: responseData.previous,

View File

@@ -1,38 +1,38 @@
import {
GOT_ROLES,
ERROR_FETCHING_ROLES
} from '../constants/actionTypes/roles';
GOT_ROLES,
ERROR_FETCHING_ROLES,
} from '../constants/actionTypes/roles';
import { fetchGrades } from './grades';
import { fetchTracks } from './tracks';
import { fetchCohorts } from './cohorts';
import { fetchAssignmentTypes } from './assignmentTypes';
import LmsApiService from '../services/LmsApiService';
const allowed_roles = ['staff', 'instructor', 'support'];
const allowedRoles = ['staff', 'instructor', 'support'];
const gotRoles = canUserViewGradebook => ({ type: GOT_ROLES, canUserViewGradebook });
const errorFetchingRoles = () => ({type: ERROR_FETCHING_ROLES });
const errorFetchingRoles = () => ({ type: ERROR_FETCHING_ROLES });
const getRoles = (courseId, urlQuery) => (
(dispatch) => {
return LmsApiService.fetchUserRoles(courseId)
.then(response => response.data)
.then(roles => {
var canUserViewGradebook = roles.some(role => (role.course_id === courseId) && allowed_roles.includes(role.role));
dispatch(gotRoles(canUserViewGradebook));
if(canUserViewGradebook){
dispatch(fetchGrades(courseId, urlQuery.cohort, urlQuery.track));
dispatch(fetchTracks(courseId));
dispatch(fetchCohorts(courseId));
dispatch(fetchAssignmentTypes(courseId));
}
})
.catch(() => {
dispatch(errorFetchingRoles())
});
});
dispatch => LmsApiService.fetchUserRoles(courseId)
.then(response => response.data)
.then((response) => {
const canUserViewGradebook = response.is_staff
|| (response.roles.some(role => (role.course_id === courseId)
&& allowedRoles.includes(role.role)));
dispatch(gotRoles(canUserViewGradebook));
if (canUserViewGradebook) {
dispatch(fetchGrades(courseId, urlQuery.cohort, urlQuery.track));
dispatch(fetchTracks(courseId));
dispatch(fetchCohorts(courseId));
dispatch(fetchAssignmentTypes(courseId));
}
})
.catch(() => {
dispatch(errorFetchingRoles());
}));
export {
getRoles,
errorFetchingRoles,
};
};

View File

@@ -18,11 +18,16 @@ import { STARTED_FETCHING_ASSIGNMENT_TYPES } from '../constants/actionTypes/assi
const mockStore = configureMockStore([thunk]);
const axiosMock = new MockAdapter(apiClient);
const rolesUrl = `${configuration.LMS_BASE_URL}/api/enrollment/v1/roles/`;
const course1Id = 'course-v1:edX+DemoX+Demo_Course';
const course2Id = 'course-v1:edX+DemoX+Demo_Course_2';
const rolesUrl = `${configuration.LMS_BASE_URL}/api/enrollment/v1/roles/?course_id=${encodeURIComponent(course1Id)}`;
function makeRoleListObj(roles, isGlobalStaff){
return {
roles: roles,
is_staff: isGlobalStaff,
}
}
function makeRoleObj(courseId, role) {
return {
course_id: courseId,
@@ -52,7 +57,25 @@ describe('actions', () => {
];
const store = mockStore();
axiosMock.onGet(rolesUrl)
.replyOnce(200, JSON.stringify([course1StaffRole, course2DummyRole]));
.replyOnce(200, JSON.stringify(makeRoleListObj([course1StaffRole, course2DummyRole], false)));
return store.dispatch(getRoles(course1Id, urlParams)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it('dispatches got_roles action and other actions after fetching irrelevent roles but user is global staff', () => {
const expectedActions = [
{ type: GOT_ROLES, canUserViewGradebook: true },
{ type: STARTED_FETCHING_GRADES },
{ type: STARTED_FETCHING_TRACKS },
{ type: STARTED_FETCHING_COHORTS },
{ type: STARTED_FETCHING_ASSIGNMENT_TYPES },
];
const store = mockStore();
axiosMock.onGet(rolesUrl)
.replyOnce(200, JSON.stringify(makeRoleListObj([course1DummyRole, course2DummyRole], true)));
return store.dispatch(getRoles(course1Id, urlParams)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
@@ -66,7 +89,7 @@ describe('actions', () => {
const store = mockStore();
axiosMock.onGet(rolesUrl)
.replyOnce(200, JSON.stringify([course1DummyRole, course2StaffRole]));
.replyOnce(200, JSON.stringify(makeRoleListObj([course1DummyRole, course2StaffRole], false)));
return store.dispatch(getRoles(course1Id, urlParams)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
@@ -80,7 +103,25 @@ describe('actions', () => {
const store = mockStore();
axiosMock.onGet(rolesUrl)
.replyOnce(200, JSON.stringify([]));
.replyOnce(200, JSON.stringify(makeRoleListObj([], false)));
return store.dispatch(getRoles(course1Id, urlParams)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it('dispatches got_roles action and other actions after fetching empty roles but user is global staff', () => {
const expectedActions = [
{ type: GOT_ROLES, canUserViewGradebook: true },
{ type: STARTED_FETCHING_GRADES },
{ type: STARTED_FETCHING_TRACKS },
{ type: STARTED_FETCHING_COHORTS },
{ type: STARTED_FETCHING_ASSIGNMENT_TYPES },
];
const store = mockStore();
axiosMock.onGet(rolesUrl)
.replyOnce(200, JSON.stringify(makeRoleListObj([], true)));
return store.dispatch(getRoles(course1Id, urlParams)).then(() => {
expect(store.getActions()).toEqual(expectedActions);

View File

@@ -66,8 +66,6 @@ const headingMapper = (filterKey) => {
const results = [{
label: 'Username',
key: 'username',
columnSortable: true,
onSort: (direction) => { dispatch(sortGrades('username', direction)); },
}];
const assignmentHeadings = entry.section_breakdown
@@ -75,15 +73,11 @@ const headingMapper = (filterKey) => {
.map(s => ({
label: s.label,
key: s.label,
columnSortable: true,
onSort: direction => dispatch(sortGrades(s.label, direction)),
}));
const totals = [{
label: 'Total',
key: 'total',
columnSortable: true,
onSort: direction => dispatch(sortGrades('total', direction)),
}];
return results.concat(assignmentHeadings).concat(totals);
@@ -95,8 +89,6 @@ const headingMapper = (filterKey) => {
const results = [{
label: 'Username',
key: 'username',
columnSortable: true,
onSort: (direction) => { dispatch(sortGrades('username', direction)); },
}];
const assignmentHeadings = entry.section_breakdown
@@ -104,15 +96,11 @@ const headingMapper = (filterKey) => {
.map(s => ({
label: s.label,
key: s.label,
columnSortable: false,
onSort: (direction) => { this.sortNumerically(s.label, direction); },
}));
const totals = [{
label: 'Total',
key: 'total',
columnSortable: true,
onSort: direction => dispatch(sortGrades('total', direction)),
}];
return results.concat(assignmentHeadings).concat(totals);

View File

@@ -60,9 +60,9 @@ class LmsApiService {
return apiClient.get(assignmentTypesUrl);
}
static fetchUserRoles(){
var rolesUrl = `${LmsApiService.baseUrl}/api/enrollment/v1/roles/`;
return apiClient.get(rolesUrl)
static fetchUserRoles(courseId) {
const rolesUrl = `${LmsApiService.baseUrl}/api/enrollment/v1/roles/?course_id=${encodeURIComponent(courseId)}`;
return apiClient.get(rolesUrl);
}
}