Compare commits

..

20 Commits

Author SHA1 Message Date
Alex Dusenbery
f0aada7794 Get ws communication working with lms 2019-03-25 17:08:52 -04:00
Richard I Reilly
09e482e893 Merge pull request #87 from edx/rir/remove-sorting
Remove remove all unneeded sorting
2019-02-04 10:45:29 -05:00
Rick Reilly
d4421d47fc Remove remove all unneeded sorting 2019-02-04 10:39:49 -05:00
Kyle McCormick
0ef8e773cc Merge pull request #85 from edx/kdmccormick/page-size
EDUCATOR-3936 Increase users per page from 10 to 25
2019-01-25 11:46:38 -05:00
Kyle McCormick
1dac20b866 EDUCATOR-3936 Increase users per page from 10 to 25 2019-01-25 11:39:38 -05:00
Zachary Hancock
ed2d715ce0 Merge pull request #84 from edx/zhancock/assignment-type-filter
persist assignment type filter
2019-01-24 16:47:22 -05:00
Zach Hancock
c82c49ea59 persist assignment type filter 2019-01-24 16:42:57 -05:00
Richard I Reilly
a9f8aec5f9 Merge pull request #86 from edx/rir/search-affordance
Cosmetic changes to give search more affordance
2019-01-24 16:20:52 -05:00
Rick Reilly
a63e9a5347 Cosmetic changes to give search more affordance 2019-01-24 16:06:53 -05:00
Richard I Reilly
2581812118 Merge pull request #82 from edx/rir/cleanup
Remove the 'is_graded' filter. The api will ensure all subsection gra…
2019-01-23 14:23:55 -05:00
Rick Reilly
c4fe803a95 Remove the 'is_graded' filter. The api will ensure all subsection grades we get are 'is_graded=true' 2019-01-23 13:34:50 -05:00
Richard I Reilly
93be5329ca Merge pull request #79 from edx/rir/lint
fix(lint): Fix all eslint issues and prop validation
2019-01-23 12:21:33 -05:00
Rick Reilly
80ba7e7152 fix(lint): Fix all eslint issues and prop validation 2019-01-23 12:18:32 -05:00
Alex Dusenbery
f88526aa3a Include expired course modes when fetching data from course enrollment API. 2019-01-23 10:16:07 -05:00
Simon Chen
c0f08eee58 Merge pull request #80 from edx/schen/improve_analytics
fix(analytics): Add the proper labels to analytics for gradebook
2019-01-22 13:43:29 -05:00
Simon Chen
ef62ea35dc fix(analytics): Add the proper labels to analytics for gradebook 2019-01-22 13:25:41 -05:00
Simon Chen
34eaa31776 Merge pull request #78 from edx/schen/EDUCATOR-3925
fix(bug): make sure gradebook rounding handle null input
2019-01-17 14:44:23 -05:00
Simon Chen
a7316e6824 fix(bug): make sure gradebook rounding handle null input 2019-01-17 14:37:21 -05:00
Alex Dusenbery
c0ab04f20c Merge pull request #77 from edx/aed/adrs
Add ADRs about API usage and UX.
2019-01-17 11:19:24 -05:00
Alex Dusenbery
ed72e7c203 Add ADRs about API usage and UX. 2019-01-16 17:00:21 -05:00
27 changed files with 482 additions and 252 deletions

View File

@@ -1,4 +1,5 @@
coverage/* coverage/*
dist/ dist/
node_modules/ node_modules/
src/postcss.config.js
src/segment.js src/segment.js

View File

@@ -23,6 +23,7 @@ before_script: greenkeeper-lockfile-update
after_script: greenkeeper-lockfile-upload after_script: greenkeeper-lockfile-upload
script: script:
- make validate-no-uncommitted-package-lock-changes - make validate-no-uncommitted-package-lock-changes
- npm run lint
- npm run test - npm run test
- npm run build - npm run build
after_success: after_success:

View File

@@ -49,6 +49,13 @@ in which you'd like to enable the gradebook. Add a course override flag using a
``grades.writable_gradebook``. Make sure to check the ``enabled`` box. Alternatively, you could add this as a ``grades.writable_gradebook``. Make sure to check the ``enabled`` box. Alternatively, you could add this as a
regular waffle flag to enable the gradebook for all courses. regular waffle flag to enable the gradebook for all courses.
## Running tests
1. Assuming that you're operating in the context of the edX devstack,
run `gradebook-shell` from your devstack directory. This will start a bash shell inside your
running gradebook container.
2. Run `make test` (which executes `npm run test`). This will run all of the gradebook tests.
## Directory Structure ## Directory Structure
* `config` * `config`

View File

@@ -47,7 +47,7 @@ module.exports = Merge.smart(commonConfig, {
minimize: true, minimize: true,
}, },
}, },
'postcss-loader', 'postcss-loader', // for autoprefixing, needs to be before the sass loader, not sure why
{ {
loader: 'sass-loader', // compiles Sass to CSS loader: 'sass-loader', // compiles Sass to CSS
options: { options: {

View File

@@ -0,0 +1,46 @@
Usage of the bulk-update API
============================
Context
=======
The LMS Grades API exposes a set of Gradebook-related endpoints:
https://github.com/edx/edx-platform/blob/master/lms/djangoapps/grades/api/v1/gradebook_views.py
The ``bulk-update`` endpoint defined therein allows for the creation/modification of subsection
grades for multiple users and sections in a single request. This allows clients of the API to limit
the number of network requests made and to more easily manage client-side data. Moreover,
the course grade updates that occur during calls to this API are synchronous - the entire update operation
is completed before a response is given to the client.
For decisions made about the implementation of this API, see:
https://github.com/edx/edx-platform/blob/master/lms/djangoapps/grades/docs/decisions/0001-gradebook-api.rst
Decision
========
The Gradebook front-end will post data about a single subsection and user in a single request
to the ``bulk-update`` API. That is, we currently need only the "update" aspect of this
endpoint, and not the "bulk" aspect, for satisfying the requirements of the current UX.
Status
======
Accepted (circa December 2018)
Consequences
============
This is a scenario in which the implementation of the API is coupled to the
UX that depends on the API. Because the course grade update is synchronous, it means
the API response can contain the updated subsection and course grade data. Because
a response from the API contains this data, the UI can operate in a very familiar way:
- A user clicks a button to submit a request with grade update data to the update endpoint.
- On the server, the subsection and course grades are modified.
- In the meantime, the client-side user looks at a spinner.
- A response is returned with updated data and the spinner goes away.
- Updated data is displayed to the user, along with a message indicative of the update.
If the update becomes asynchronous, the user experience outlined above has to change.
Because a single call to this endpoint updates grades data for only a single user,
the endpoint does not necessarily have to utilize an asynchronous operation at this time.

8
package-lock.json generated
View File

@@ -10469,14 +10469,12 @@
"balanced-match": { "balanced-match": {
"version": "1.0.0", "version": "1.0.0",
"bundled": true, "bundled": true,
"dev": true, "dev": true
"optional": true
}, },
"brace-expansion": { "brace-expansion": {
"version": "1.1.11", "version": "1.1.11",
"bundled": true, "bundled": true,
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"balanced-match": "^1.0.0", "balanced-match": "^1.0.0",
"concat-map": "0.0.1" "concat-map": "0.0.1"
@@ -10496,8 +10494,7 @@
"concat-map": { "concat-map": {
"version": "0.0.1", "version": "0.0.1",
"bundled": true, "bundled": true,
"dev": true, "dev": true
"optional": true
}, },
"console-control-strings": { "console-control-strings": {
"version": "1.1.0", "version": "1.1.0",
@@ -10645,7 +10642,6 @@
"version": "3.0.4", "version": "3.0.4",
"bundled": true, "bundled": true,
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"brace-expansion": "^1.1.7" "brace-expansion": "^1.1.7"
} }

View File

@@ -81,3 +81,6 @@
} }
} }
.mb-85 {
margin-bottom: 85px;
}

View File

@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import { import {
Button, Button,
InputSelect, InputSelect,
@@ -24,12 +25,41 @@ export default class Gradebook extends React.Component {
updateVal: 0, updateVal: 0,
updateModuleId: null, updateModuleId: null,
updateUserId: null, updateUserId: null,
socket: null,
websocketMsg: {
visible: false,
},
}; };
} }
componentDidMount() { componentDidMount() {
const urlQuery = queryString.parse(this.props.location.search); const urlQuery = queryString.parse(this.props.location.search);
this.props.getRoles(this.props.match.params.courseId, urlQuery); this.props.getRoles(this.props.match.params.courseId, urlQuery);
const socket = new WebSocket('ws://localhost:8765/ws/gradebook/course-v1:edX+DemoX+Demo_Course/');
socket.onmessage = this.socketMessageFunction;
}
socketMessageFunction = (event) => {
var data = JSON.parse(event.data);
console.log(data);
const userIndex = this.props.grades.findIndex((entry) => entry.user_id == data.user_id);
const username = this.props.grades[userIndex].username;
const subsectionIndex = this.props.grades[userIndex].section_breakdown.findIndex((entry) => entry.module_id = data.subsection_id);
const subsectionName = this.props.grades[userIndex].section_breakdown[subsectionIndex].label;
let subsectionGrade = this.props.grades[userIndex].section_breakdown[subsectionIndex];
subsectionGrade.score_earned = data.override.earned_graded_override;
subsectionGrade.score_possible = data.override.possible_graded_override;
const updatedMsg = {
visible: true,
username: username,
subsectionName: subsectionName,
};
this.setState({ websocketMsg: updatedMsg });
this.props.gradeUpdateSuccess(this.props.match.params.courseId, this.props.grades);
} }
setNewModalState = (userEntry, subsection) => { setNewModalState = (userEntry, subsection) => {
@@ -119,6 +149,8 @@ export default class Gradebook extends React.Component {
updateAssignmentTypes = (event) => { updateAssignmentTypes = (event) => {
this.props.filterColumns(event, this.props.grades[0]); this.props.filterColumns(event, this.props.grades[0]);
const updatedQueryStrings = this.updateQueryParams('assignmentType', event);
this.props.history.push(updatedQueryStrings);
} }
updateTracks = (event) => { updateTracks = (event) => {
@@ -131,6 +163,7 @@ export default class Gradebook extends React.Component {
this.props.match.params.courseId, this.props.match.params.courseId,
this.props.selectedCohort, this.props.selectedCohort,
selectedTrackSlug, selectedTrackSlug,
this.props.selectedAssignmentType,
); );
const updatedQueryStrings = this.updateQueryParams('track', selectedTrackSlug); const updatedQueryStrings = this.updateQueryParams('track', selectedTrackSlug);
this.props.history.push(updatedQueryStrings); this.props.history.push(updatedQueryStrings);
@@ -146,18 +179,9 @@ export default class Gradebook extends React.Component {
this.props.match.params.courseId, this.props.match.params.courseId,
selectedCohortId, selectedCohortId,
this.props.selectedTrack, this.props.selectedTrack,
this.props.selectedAssignmentType,
); );
const updatedQueryStrings = this.updateQueryParams('cohort', selectedCohortId); this.updateQueryParams('cohort', selectedCohortId);
this.props.history.push(updatedQueryStrings);
};
mapSelectedAssignmentTypeEntry = (entry) => {
const selectedAssignmentTypeEntry = this.props.assignmentTypes
.find(x => x.id === parseInt(entry, 10));
if (selectedAssignmentTypeEntry) {
return selectedAssignmentTypeEntry.name;
}
return 'All';
}; };
mapSelectedCohortEntry = (entry) => { mapSelectedCohortEntry = (entry) => {
@@ -176,13 +200,12 @@ export default class Gradebook extends React.Component {
return 'Tracks'; return 'Tracks';
}; };
roundGrade = percent => parseFloat(percent.toFixed(DECIMAL_PRECISION)); roundGrade = percent => parseFloat((percent || 0).toFixed(DECIMAL_PRECISION));
formatter = { formatter = {
percent: (entries, areGradesFrozen) => entries.map((entry) => { percent: (entries, areGradesFrozen) => entries.map((entry) => {
const results = { username: entry.username }; const results = { username: entry.username };
const assignments = entry.section_breakdown const assignments = entry.section_breakdown
.filter(section => section.is_graded)
.reduce((acc, subsection) => { .reduce((acc, subsection) => {
if (areGradesFrozen) { if (areGradesFrozen) {
acc[subsection.label] = `${this.roundGrade(subsection.percent * 100)} %`; acc[subsection.label] = `${this.roundGrade(subsection.percent * 100)} %`;
@@ -204,7 +227,6 @@ export default class Gradebook extends React.Component {
absolute: (entries, areGradesFrozen) => entries.map((entry) => { absolute: (entries, areGradesFrozen) => entries.map((entry) => {
const results = { username: entry.username }; const results = { username: entry.username };
const assignments = entry.section_breakdown const assignments = entry.section_breakdown
.filter(section => section.is_graded)
.reduce((acc, subsection) => { .reduce((acc, subsection) => {
const scoreEarned = this.roundGrade(subsection.score_earned); const scoreEarned = this.roundGrade(subsection.score_earned);
const scorePossible = this.roundGrade(subsection.score_possible); const scorePossible = this.roundGrade(subsection.score_possible);
@@ -264,30 +286,34 @@ export default class Gradebook extends React.Component {
<div role="radiogroup" aria-labelledby="score-view-group-label"> <div role="radiogroup" aria-labelledby="score-view-group-label">
<span id="score-view-group-label">Score View:</span> <span id="score-view-group-label">Score View:</span>
<span> <span>
<input <label className="mr-2" htmlFor="score-view-percent">
id="score-view-percent" <input
className="ml-2 mr-1" id="score-view-percent"
type="radio" className="ml-2 mr-1"
name="score-view" type="radio"
value="percent" name="score-view"
defaultChecked value="percent"
onClick={() => this.props.toggleFormat('percent')} defaultChecked
/> onClick={() => this.props.toggleFormat('percent')}
<label className="mr-2" htmlFor="score-view-percent">Percent</label> />
Percent
</label>
</span> </span>
<span> <span>
<input <label htmlFor="score-view-absolute">
id="score-view-absolute" <input
type="radio" id="score-view-absolute"
name="score-view" type="radio"
value="absolute" name="score-view"
className="mr-1" value="absolute"
onClick={() => this.props.toggleFormat('absolute')} className="mr-1"
/> onClick={() => this.props.toggleFormat('absolute')}
<label htmlFor="score-view-absolute">Absolute</label> />
Absolute
</label>
</span> </span>
</div> </div>
{ this.props.assignmnetTypes.length > 0 && { this.props.assignmentTypes.length > 0 &&
<div className="student-filters"> <div className="student-filters">
<span className="label"> <span className="label">
Assignment Types: Assignment Types:
@@ -295,8 +321,8 @@ export default class Gradebook extends React.Component {
<InputSelect <InputSelect
name="assignment-types" name="assignment-types"
ariaLabel="Assignment Types" ariaLabel="Assignment Types"
value={this.mapSelectedTrackEntry(this.props.selectedAssignmentType)} value={this.props.selectedAssignmentType}
options={this.mapAssignmentTypeEntries(this.props.assignmnetTypes)} options={this.mapAssignmentTypeEntries(this.props.assignmentTypes)}
onChange={this.updateAssignmentTypes} onChange={this.updateAssignmentTypes}
/> />
</div> </div>
@@ -325,12 +351,28 @@ export default class Gradebook extends React.Component {
</div> </div>
<div> <div>
<div style={{ marginLeft: '10px', marginBottom: '10px' }}> <div style={{ marginLeft: '10px', marginBottom: '10px' }}>
<a href={`${this.lmsInstructorDashboardUrl(this.props.match.params.courseId)}#view-data_download`}>Generate Grade Report</a> <a className="btn btn-outline-primary mb-85" href={`${this.lmsInstructorDashboardUrl(this.props.match.params.courseId)}#view-data_download`}>Generate Grade Report</a>
</div> </div>
<SearchField <SearchField
onSubmit={value => this.props.searchForUser(this.props.match.params.courseId, value, this.props.selectedCohort, this.props.selectedTrack)} onSubmit={value =>
this.props.searchForUser(
this.props.match.params.courseId,
value,
this.props.selectedCohort,
this.props.selectedTrack,
this.props.selectedAssignmentType,
)
}
inputLabel="Search Username:"
onChange={filterValue => this.setState({ filterValue })} onChange={filterValue => this.setState({ filterValue })}
onClear={() => this.props.getUserGrades(this.props.match.params.courseId, this.props.selectedCohort, this.props.selectedTrack)} onClear={() =>
this.props.getUserGrades(
this.props.match.params.courseId,
this.props.selectedCohort,
this.props.selectedTrack,
this.props.selectedAssignmentType,
)
}
value={this.state.filterValue} value={this.state.filterValue}
/> />
</div> </div>
@@ -342,14 +384,20 @@ export default class Gradebook extends React.Component {
onClose={() => this.props.updateBanner(false)} onClose={() => this.props.updateBanner(false)}
open={this.props.showSuccess} open={this.props.showSuccess}
/> />
<StatusAlert
alertType="success"
dialog={`Grade for user ${this.state.websocketMsg.username} in ${this.state.websocketMsg.subsectionName} was updated.`}
onClose={() => this.setState({ websocketMsg : false })}
open={this.state.websocketMsg.visible}
/>
{PageButtons(this.props)} {PageButtons(this.props)}
<div className="gbook"> <div className="gbook">
<Table <Table
columns={this.props.headings} columns={this.props.headings}
data={this.formatter[this.props.format](this.props.grades, this.props.areGradesFrozen)} data={this.formatter[this.props.format](
tableSortable this.props.grades,
defaultSortDirection="asc" this.props.areGradesFrozen,
defaultSortedColumn="username" )}
rowHeaderColumnKey="username" rowHeaderColumnKey="username"
/> />
</div> </div>
@@ -390,3 +438,78 @@ export default class Gradebook extends React.Component {
} }
} }
Gradebook.defaultProps = {
areGradesFrozen: false,
assignmentTypes: [],
canUserViewGradebook: false,
cohorts: [],
grades: [],
location: {
search: '',
},
match: {
params: {
courseId: '',
},
},
selectedCohort: null,
selectedTrack: null,
selectedAssignmentType: 'All',
showSpinner: false,
tracks: [],
};
Gradebook.propTypes = {
areGradesFrozen: PropTypes.bool,
assignmentTypes: PropTypes.arrayOf(PropTypes.string),
canUserViewGradebook: PropTypes.bool,
cohorts: PropTypes.arrayOf(PropTypes.string),
filterColumns: PropTypes.func.isRequired,
format: PropTypes.string.isRequired,
getRoles: PropTypes.func.isRequired,
getUserGrades: PropTypes.func.isRequired,
grades: PropTypes.arrayOf(PropTypes.shape({
percent: PropTypes.number,
section_breakdown: PropTypes.arrayOf(PropTypes.shape({
attempted: PropTypes.bool,
category: PropTypes.string,
label: PropTypes.string,
module_id: PropTypes.string,
percent: PropTypes.number,
scoreEarned: PropTypes.number,
scorePossible: PropTypes.number,
subsection_name: PropTypes.string,
})),
user_id: PropTypes.number,
user_name: PropTypes.string,
})),
headings: PropTypes.arrayOf(PropTypes.shape({
label: PropTypes.string,
key: PropTypes.string,
})).isRequired,
history: PropTypes.shape({
push: PropTypes.func,
}).isRequired,
location: PropTypes.shape({
search: PropTypes.string,
}),
match: PropTypes.shape({
params: PropTypes.shape({
courseId: PropTypes.string,
}),
}),
searchForUser: PropTypes.func.isRequired,
selectedAssignmentType: PropTypes.string,
selectedCohort: PropTypes.shape({
name: PropTypes.string,
}),
selectedTrack: PropTypes.string,
showSpinner: PropTypes.bool,
showSuccess: PropTypes.bool.isRequired,
toggleFormat: PropTypes.func.isRequired,
tracks: PropTypes.arrayOf(PropTypes.shape({
name: PropTypes.string,
})),
updateBanner: PropTypes.func.isRequired,
updateGrades: PropTypes.func.isRequired,
};

View File

@@ -4,13 +4,6 @@ import { Hyperlink } from '@edx/paragon';
import EdxLogo from '../../../assets/edx-sm.png'; import EdxLogo from '../../../assets/edx-sm.png';
export default class Header extends React.Component { export default class Header extends React.Component {
constructor(props) {
super(props);
this.state = {
mobileNavOpen: false,
};
}
renderLogo() { renderLogo() {
return ( return (
<img src={EdxLogo} alt="edX logo" height="30" width="60" /> <img src={EdxLogo} alt="edX logo" height="30" width="60" />

View File

@@ -10,7 +10,7 @@ exports[`PageButtons prev not null, next not null 1`] = `
} }
> >
<button <button
className="btn btn-primary" className="btn btn-outline-primary"
disabled={false} disabled={false}
onBlur={[Function]} onBlur={[Function]}
onClick={[Function]} onClick={[Function]}
@@ -25,7 +25,7 @@ exports[`PageButtons prev not null, next not null 1`] = `
Previous Page Previous Page
</button> </button>
<button <button
className="btn btn-primary" className="btn btn-outline-primary"
disabled={false} disabled={false}
onBlur={[Function]} onBlur={[Function]}
onClick={[Function]} onClick={[Function]}
@@ -52,7 +52,7 @@ exports[`PageButtons prev not null, next null 1`] = `
} }
> >
<button <button
className="btn btn-primary" className="btn btn-outline-primary"
disabled={false} disabled={false}
onBlur={[Function]} onBlur={[Function]}
onClick={[Function]} onClick={[Function]}
@@ -67,7 +67,7 @@ exports[`PageButtons prev not null, next null 1`] = `
Previous Page Previous Page
</button> </button>
<button <button
className="btn btn-primary" className="btn btn-outline-primary"
disabled={true} disabled={true}
onBlur={[Function]} onBlur={[Function]}
onClick={[Function]} onClick={[Function]}
@@ -94,7 +94,7 @@ exports[`PageButtons prev null, next not null 1`] = `
} }
> >
<button <button
className="btn btn-primary" className="btn btn-outline-primary"
disabled={true} disabled={true}
onBlur={[Function]} onBlur={[Function]}
onClick={[Function]} onClick={[Function]}
@@ -109,7 +109,7 @@ exports[`PageButtons prev null, next not null 1`] = `
Previous Page Previous Page
</button> </button>
<button <button
className="btn btn-primary" className="btn btn-outline-primary"
disabled={false} disabled={false}
onBlur={[Function]} onBlur={[Function]}
onClick={[Function]} onClick={[Function]}
@@ -136,7 +136,7 @@ exports[`PageButtons prev null, next null 1`] = `
} }
> >
<button <button
className="btn btn-primary" className="btn btn-outline-primary"
disabled={true} disabled={true}
onBlur={[Function]} onBlur={[Function]}
onClick={[Function]} onClick={[Function]}
@@ -151,7 +151,7 @@ exports[`PageButtons prev null, next null 1`] = `
Previous Page Previous Page
</button> </button>
<button <button
className="btn btn-primary" className="btn btn-outline-primary"
disabled={true} disabled={true}
onBlur={[Function]} onBlur={[Function]}
onClick={[Function]} onClick={[Function]}

View File

@@ -1,10 +1,12 @@
import React from 'react'; import React from 'react';
import { import PropTypes from 'prop-types';
Button, import { Button } from '@edx/paragon';
} from '@edx/paragon';
export default function PageButtons({prevPage, nextPage, selectedTrack, selectedCohort, getPrevNextGrades}) { export default function PageButtons({
prevPage, nextPage, selectedTrack, selectedCohort, selectedAssignmentType,
getPrevNextGrades, match,
}) {
return ( return (
<div <div
className="d-flex justify-content-center" className="d-flex justify-content-center"
@@ -13,18 +15,63 @@ export default function PageButtons({prevPage, nextPage, selectedTrack, selected
<Button <Button
label="Previous Page" label="Previous Page"
style={{ margin: '20px' }} style={{ margin: '20px' }}
buttonType="primary" buttonType="outline-primary"
disabled={!prevPage} disabled={!prevPage}
onClick={() => getPrevNextGrades(prevPage, selectedCohort, selectedTrack)} onClick={() =>
getPrevNextGrades(
prevPage,
match.params.courseId,
selectedCohort,
selectedTrack,
selectedAssignmentType,
)}
/> />
<Button <Button
label="Next Page" label="Next Page"
style={{ margin: '20px' }} style={{ margin: '20px' }}
buttonType="primary" buttonType="outline-primary"
disabled={!nextPage} disabled={!nextPage}
onClick={() => getPrevNextGrades(nextPage, selectedCohort, selectedTrack)} onClick={() =>
getPrevNextGrades(
nextPage,
match.params.courseId,
selectedCohort,
selectedTrack,
selectedAssignmentType,
)}
/> />
</div> </div>
); );
} }
PageButtons.defaultProps = {
match: {
params: {
courseId: '',
},
},
nextPage: '',
prevPage: '',
selectedCohort: null,
selectedTrack: null,
selectedAssignmentType: null,
};
PageButtons.propTypes = {
getPrevNextGrades: PropTypes.func.isRequired,
match: PropTypes.shape({
params: PropTypes.shape({
courseId: PropTypes.string,
}),
}),
nextPage: PropTypes.string,
prevPage: PropTypes.string,
selectedAssignmentType: PropTypes.string,
selectedCohort: PropTypes.shape({
name: PropTypes.string,
}),
selectedTrack: PropTypes.shape({
name: PropTypes.string,
}),
};

View File

@@ -5,6 +5,7 @@ import {
fetchGrades, fetchGrades,
fetchMatchingUserGrades, fetchMatchingUserGrades,
fetchPrevNextGrades, fetchPrevNextGrades,
gradeUpdateSuccess,
updateGrades, updateGrades,
toggleGradeFormat, toggleGradeFormat,
filterColumns, filterColumns,
@@ -15,6 +16,15 @@ import { fetchTracks } from '../../data/actions/tracks';
import { fetchAssignmentTypes } from '../../data/actions/assignmentTypes'; import { fetchAssignmentTypes } from '../../data/actions/assignmentTypes';
import { getRoles } from '../../data/actions/roles'; import { getRoles } from '../../data/actions/roles';
function shouldShowSpinner(state) {
if (state.roles.canUserViewGradebook === true) {
return state.grades.showSpinner;
} else if (state.roles.canUserViewGradebook === false) {
return false;
} // canUserViewGradebook === null
return true;
}
const mapStateToProps = state => ( const mapStateToProps = state => (
{ {
grades: state.grades.results, grades: state.grades.results,
@@ -23,37 +33,28 @@ const mapStateToProps = state => (
cohorts: state.cohorts.results, cohorts: state.cohorts.results,
selectedTrack: state.grades.selectedTrack, selectedTrack: state.grades.selectedTrack,
selectedCohort: state.grades.selectedCohort, selectedCohort: state.grades.selectedCohort,
selectedAssignmentType: state.grades.selectedAssignmentType,
format: state.grades.gradeFormat, format: state.grades.gradeFormat,
showSuccess: state.grades.showSuccess, showSuccess: state.grades.showSuccess,
prevPage: state.grades.prevPage, prevPage: state.grades.prevPage,
nextPage: state.grades.nextPage, nextPage: state.grades.nextPage,
assignmnetTypes: state.assignmentTypes.results, assignmentTypes: state.assignmentTypes.results,
areGradesFrozen: state.assignmentTypes.areGradesFrozen, areGradesFrozen: state.assignmentTypes.areGradesFrozen,
showSpinner: shouldShowSpinner(state), showSpinner: shouldShowSpinner(state),
canUserViewGradebook: state.roles.canUserViewGradebook canUserViewGradebook: state.roles.canUserViewGradebook,
} }
); );
function shouldShowSpinner (state) {
if (state.roles.canUserViewGradebook === true){
return state.grades.showSpinner;
} else if (state.roles.canUserViewGradebook === false){
return false;
} else { // canUserViewGradebook === null
return true;
}
}
const mapDispatchToProps = dispatch => ( const mapDispatchToProps = dispatch => (
{ {
getUserGrades: (courseId, cohort, track) => { getUserGrades: (courseId, cohort, track, assignmentType) => {
dispatch(fetchGrades(courseId, cohort, track)); dispatch(fetchGrades(courseId, cohort, track, assignmentType));
}, },
searchForUser: (courseId, searchText, cohort, track) => { searchForUser: (courseId, searchText, cohort, track, assignmentType) => {
dispatch(fetchMatchingUserGrades(courseId, searchText, cohort, track, false)); dispatch(fetchMatchingUserGrades(courseId, searchText, cohort, track, assignmentType, false));
}, },
getPrevNextGrades: (endpoint, cohort, track) => { getPrevNextGrades: (endpoint, courseId, cohort, track, assignmentType) => {
dispatch(fetchPrevNextGrades(endpoint, cohort, track)); dispatch(fetchPrevNextGrades(endpoint, courseId, cohort, track, assignmentType));
}, },
getCohorts: (courseId) => { getCohorts: (courseId) => {
dispatch(fetchCohorts(courseId)); dispatch(fetchCohorts(courseId));
@@ -79,6 +80,9 @@ const mapDispatchToProps = dispatch => (
getRoles: (matchParams, urlQuery) => { getRoles: (matchParams, urlQuery) => {
dispatch(getRoles(matchParams, urlQuery)); dispatch(getRoles(matchParams, urlQuery));
}, },
gradeUpdateSuccess: (courseId, data) => {
dispatch(gradeUpdateSuccess(courseId, data));
},
} }
); );

View File

@@ -7,48 +7,39 @@ import {
GRADE_UPDATE_SUCCESS, GRADE_UPDATE_SUCCESS,
GRADE_UPDATE_FAILURE, GRADE_UPDATE_FAILURE,
TOGGLE_GRADE_FORMAT, TOGGLE_GRADE_FORMAT,
SORT_GRADES,
FILTER_COLUMNS, FILTER_COLUMNS,
UPDATE_BANNER, UPDATE_BANNER,
} from '../constants/actionTypes/grades'; } from '../constants/actionTypes/grades';
import LmsApiService from '../services/LmsApiService'; import LmsApiService from '../services/LmsApiService';
import store from '../store'; import { headingMapper, sortAlphaAsc } from './utils';
import { headingMapper, gradeSortMap, sortAlphaAsc } from './utils';
import apiClient from '../apiClient'; import apiClient from '../apiClient';
const defaultAssignmentFilter = 'All'; const defaultAssignmentFilter = 'All';
const sortGrades = (columnName, direction) => {
const sortFn = gradeSortMap(columnName, direction);
const { results } = store.getState().grades;
results.sort(sortFn);
/* have to make a copy of results or React wont know there was
* a change and wont trigger a re-render
*/
return ({ type: SORT_GRADES, results: [...results] });
};
const startedFetchingGrades = () => ({ type: STARTED_FETCHING_GRADES }); const startedFetchingGrades = () => ({ type: STARTED_FETCHING_GRADES });
const finishedFetchingGrades = () => ({ type: FINISHED_FETCHING_GRADES }); const finishedFetchingGrades = () => ({ type: FINISHED_FETCHING_GRADES });
const errorFetchingGrades = () => ({ type: ERROR_FETCHING_GRADES }); const errorFetchingGrades = () => ({ type: ERROR_FETCHING_GRADES });
const gotGrades = (grades, cohort, track, headings, prev, next) => ({ const gotGrades = (grades, cohort, track, assignmentType, headings, prev, next, courseId) => ({
type: GOT_GRADES, type: GOT_GRADES,
grades, grades,
cohort, cohort,
track, track,
assignmentType,
headings, headings,
prev, prev,
next, next,
courseId,
}); });
const gradeUpdateRequest = () => ({ type: GRADE_UPDATE_REQUEST }); const gradeUpdateRequest = () => ({ type: GRADE_UPDATE_REQUEST });
const gradeUpdateSuccess = responseData => ({ const gradeUpdateSuccess = (courseId, responseData) => ({
type: GRADE_UPDATE_SUCCESS, type: GRADE_UPDATE_SUCCESS,
courseId,
payload: { responseData }, payload: { responseData },
}); });
const gradeUpdateFailure = error => ({ const gradeUpdateFailure = (courseId, error) => ({
type: GRADE_UPDATE_FAILURE, type: GRADE_UPDATE_FAILURE,
courseId,
payload: { error }, payload: { error },
}); });
@@ -58,13 +49,13 @@ const toggleGradeFormat = formatType => ({ type: TOGGLE_GRADE_FORMAT, formatType
const filterColumns = (filterType, exampleUser) => ( const filterColumns = (filterType, exampleUser) => (
dispatch => dispatch({ dispatch => dispatch({
type: FILTER_COLUMNS, type: FILTER_COLUMNS,
headings: headingMapper(filterType)(dispatch, exampleUser), headings: headingMapper(filterType)(exampleUser),
}) })
); );
const updateBanner = showSuccess => ({ type: UPDATE_BANNER, showSuccess }); const updateBanner = showSuccess => ({ type: UPDATE_BANNER, showSuccess });
const fetchGrades = (courseId, cohort, track, showSuccess) => ( const fetchGrades = (courseId, cohort, track, assignmentType, showSuccess) => (
(dispatch) => { (dispatch) => {
dispatch(startedFetchingGrades()); dispatch(startedFetchingGrades());
return LmsApiService.fetchGradebookData(courseId, null, cohort, track) return LmsApiService.fetchGradebookData(courseId, null, cohort, track)
@@ -74,9 +65,11 @@ const fetchGrades = (courseId, cohort, track, showSuccess) => (
data.results.sort(sortAlphaAsc), data.results.sort(sortAlphaAsc),
cohort, cohort,
track, track,
headingMapper(defaultAssignmentFilter)(dispatch, data.results[0]), assignmentType,
headingMapper(assignmentType || defaultAssignmentFilter)(data.results[0]),
data.previous, data.previous,
data.next, data.next,
courseId,
)); ));
dispatch(finishedFetchingGrades()); dispatch(finishedFetchingGrades());
dispatch(updateBanner(!!showSuccess)); dispatch(updateBanner(!!showSuccess));
@@ -87,7 +80,14 @@ const fetchGrades = (courseId, cohort, track, showSuccess) => (
} }
); );
const fetchMatchingUserGrades = (courseId, searchText, cohort, track, showSuccess) => ( const fetchMatchingUserGrades = (
courseId,
searchText,
cohort,
track,
assignmentType,
showSuccess,
) => (
(dispatch) => { (dispatch) => {
dispatch(startedFetchingGrades()); dispatch(startedFetchingGrades());
return LmsApiService.fetchGradebookData(courseId, searchText, cohort, track) return LmsApiService.fetchGradebookData(courseId, searchText, cohort, track)
@@ -97,9 +97,11 @@ const fetchMatchingUserGrades = (courseId, searchText, cohort, track, showSucces
data.results.sort(sortAlphaAsc), data.results.sort(sortAlphaAsc),
cohort, cohort,
track, track,
headingMapper(defaultAssignmentFilter)(dispatch, data.results[0]), assignmentType,
headingMapper(assignmentType || defaultAssignmentFilter)(data.results[0]),
data.previous, data.previous,
data.next, data.next,
courseId,
)); ));
dispatch(finishedFetchingGrades()); dispatch(finishedFetchingGrades());
dispatch(updateBanner(showSuccess)); dispatch(updateBanner(showSuccess));
@@ -110,7 +112,7 @@ const fetchMatchingUserGrades = (courseId, searchText, cohort, track, showSucces
} }
); );
const fetchPrevNextGrades = (endpoint, cohort, track) => ( const fetchPrevNextGrades = (endpoint, courseId, cohort, track, assignmentType) => (
(dispatch) => { (dispatch) => {
dispatch(startedFetchingGrades()); dispatch(startedFetchingGrades());
return apiClient.get(endpoint) return apiClient.get(endpoint)
@@ -120,9 +122,11 @@ const fetchPrevNextGrades = (endpoint, cohort, track) => (
data.results.sort(sortAlphaAsc), data.results.sort(sortAlphaAsc),
cohort, cohort,
track, track,
headingMapper(defaultAssignmentFilter)(dispatch, data.results[0]), assignmentType,
headingMapper(assignmentType || defaultAssignmentFilter)(data.results[0]),
data.previous, data.previous,
data.next, data.next,
courseId,
)); ));
dispatch(finishedFetchingGrades()); dispatch(finishedFetchingGrades());
}) })
@@ -132,18 +136,24 @@ const fetchPrevNextGrades = (endpoint, cohort, track) => (
} }
); );
const updateGrades = (courseId, updateData, searchText, cohort, track) => ( const updateGrades = (courseId, updateData, searchText, cohort, track) => (
(dispatch) => { (dispatch) => {
dispatch(gradeUpdateRequest()); dispatch(gradeUpdateRequest());
return LmsApiService.updateGradebookData(courseId, updateData) return LmsApiService.updateGradebookData(courseId, updateData)
.then(response => response.data) .then(response => response.data)
.then((data) => { .then((data) => {
dispatch(gradeUpdateSuccess(data)); dispatch(gradeUpdateSuccess(courseId, data));
dispatch(fetchMatchingUserGrades(courseId, searchText, cohort, track, true)); // dispatch(fetchMatchingUserGrades(
// courseId,
// searchText,
// cohort,
// track,
// defaultAssignmentFilter,
// true,
// ));
}) })
.catch((error) => { .catch((error) => {
dispatch(gradeUpdateFailure(error)); dispatch(gradeUpdateFailure(courseId, error));
}); });
} }
); );
@@ -161,7 +171,6 @@ export {
gradeUpdateFailure, gradeUpdateFailure,
updateGrades, updateGrades,
toggleGradeFormat, toggleGradeFormat,
sortGrades,
filterColumns, filterColumns,
updateBanner, updateBanner,
}; };

View File

@@ -27,7 +27,8 @@ describe('actions', () => {
const courseId = 'course-v1:edX+DemoX+Demo_Course'; const courseId = 'course-v1:edX+DemoX+Demo_Course';
const expectedCohort = 1; const expectedCohort = 1;
const expectedTrack = 'verified'; const expectedTrack = 'verified';
const fetchGradesURL = `${configuration.LMS_BASE_URL}/api/grades/v1/gradebook/${courseId}/?page_size=10&cohort_id=${expectedCohort}&enrollment_mode=${expectedTrack}`; const expectedAssignmentType = 'Exam';
const fetchGradesURL = `${configuration.LMS_BASE_URL}/api/grades/v1/gradebook/${courseId}/?page_size=25&cohort_id=${expectedCohort}&enrollment_mode=${expectedTrack}`;
const responseData = { const responseData = {
next: `${fetchGradesURL}&cursor=2344fda`, next: `${fetchGradesURL}&cursor=2344fda`,
previous: null, previous: null,
@@ -94,22 +95,20 @@ describe('actions', () => {
grades: responseData.results.sort(sortAlphaAsc), grades: responseData.results.sort(sortAlphaAsc),
cohort: expectedCohort, cohort: expectedCohort,
track: expectedTrack, track: expectedTrack,
assignmentType: expectedAssignmentType,
headings: [ headings: [
{ {
columnSortable: true,
key: 'username', key: 'username',
label: 'Username', label: 'Username',
onSort: expect.anything(),
}, },
{ {
columnSortable: true,
key: 'total', key: 'total',
label: 'Total', label: 'Total',
onSort: expect.anything(),
}, },
], ],
prev: responseData.previous, prev: responseData.previous,
next: responseData.next, next: responseData.next,
courseId,
}, },
{ type: FINISHED_FETCHING_GRADES }, { type: FINISHED_FETCHING_GRADES },
{ type: UPDATE_BANNER, showSuccess: false }, { type: UPDATE_BANNER, showSuccess: false },
@@ -119,7 +118,13 @@ describe('actions', () => {
axiosMock.onGet(fetchGradesURL) axiosMock.onGet(fetchGradesURL)
.replyOnce(200, JSON.stringify(responseData)); .replyOnce(200, JSON.stringify(responseData));
return store.dispatch(fetchGrades(courseId, expectedCohort, expectedTrack, false)).then(() => { return store.dispatch(fetchGrades(
courseId,
expectedCohort,
expectedTrack,
expectedAssignmentType,
false,
)).then(() => {
expect(store.getActions()).toEqual(expectedActions); expect(store.getActions()).toEqual(expectedActions);
}); });
}); });
@@ -134,7 +139,51 @@ describe('actions', () => {
axiosMock.onGet(fetchGradesURL) axiosMock.onGet(fetchGradesURL)
.replyOnce(500, JSON.stringify({})); .replyOnce(500, JSON.stringify({}));
return store.dispatch(fetchGrades(courseId, expectedCohort, expectedTrack, false)).then(() => { return store.dispatch(fetchGrades(
courseId,
expectedCohort,
expectedTrack,
expectedAssignmentType,
false,
)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it('dispatches success action on empty response after fetching grades', () => {
const emptyResponseData = {
next: responseData.next,
previous: responseData.previous,
results: [],
};
const expectedActions = [
{ type: STARTED_FETCHING_GRADES },
{
type: GOT_GRADES,
grades: [],
cohort: expectedCohort,
track: expectedTrack,
assignmentType: expectedAssignmentType,
headings: [],
prev: responseData.previous,
next: responseData.next,
courseId,
},
{ type: FINISHED_FETCHING_GRADES },
{ type: UPDATE_BANNER, showSuccess: false },
];
const store = mockStore();
axiosMock.onGet(fetchGradesURL)
.replyOnce(200, JSON.stringify(emptyResponseData));
return store.dispatch(fetchGrades(
courseId,
expectedCohort,
expectedTrack,
expectedAssignmentType,
false,
)).then(() => {
expect(store.getActions()).toEqual(expectedActions); expect(store.getActions()).toEqual(expectedActions);
}); });
}); });

View File

@@ -26,7 +26,7 @@ const getRoles = (courseId, urlQuery) => (
&& allowedRoles.includes(role.role))); && allowedRoles.includes(role.role)));
dispatch(gotRoles(canUserViewGradebook, courseId)); dispatch(gotRoles(canUserViewGradebook, courseId));
if (canUserViewGradebook) { if (canUserViewGradebook) {
dispatch(fetchGrades(courseId, urlQuery.cohort, urlQuery.track)); dispatch(fetchGrades(courseId, urlQuery.cohort, urlQuery.track, urlQuery.assignmentType));
dispatch(fetchTracks(courseId)); dispatch(fetchTracks(courseId));
dispatch(fetchCohorts(courseId)); dispatch(fetchCohorts(courseId));
dispatch(fetchAssignmentTypes(courseId)); dispatch(fetchAssignmentTypes(courseId));

View File

@@ -57,7 +57,10 @@ describe('actions', () => {
]; ];
const store = mockStore(); const store = mockStore();
axiosMock.onGet(rolesUrl) axiosMock.onGet(rolesUrl)
.replyOnce(200, JSON.stringify(makeRoleListObj([course1StaffRole, course2DummyRole], false))); .replyOnce(
200,
JSON.stringify(makeRoleListObj([course1StaffRole, course2DummyRole], false)),
);
return store.dispatch(getRoles(course1Id, urlParams)).then(() => { return store.dispatch(getRoles(course1Id, urlParams)).then(() => {
expect(store.getActions()).toEqual(expectedActions); expect(store.getActions()).toEqual(expectedActions);
@@ -75,7 +78,10 @@ describe('actions', () => {
const store = mockStore(); const store = mockStore();
axiosMock.onGet(rolesUrl) axiosMock.onGet(rolesUrl)
.replyOnce(200, JSON.stringify(makeRoleListObj([course1DummyRole, course2DummyRole], true))); .replyOnce(
200,
JSON.stringify(makeRoleListObj([course1DummyRole, course2DummyRole], true)),
);
return store.dispatch(getRoles(course1Id, urlParams)).then(() => { return store.dispatch(getRoles(course1Id, urlParams)).then(() => {
expect(store.getActions()).toEqual(expectedActions); expect(store.getActions()).toEqual(expectedActions);
@@ -91,7 +97,10 @@ describe('actions', () => {
const store = mockStore(); const store = mockStore();
axiosMock.onGet(rolesUrl) axiosMock.onGet(rolesUrl)
.replyOnce(200, JSON.stringify(makeRoleListObj([course1DummyRole, course2StaffRole], false))); .replyOnce(
200,
JSON.stringify(makeRoleListObj([course1DummyRole, course2StaffRole], false)),
);
return store.dispatch(getRoles(course1Id, urlParams)).then(() => { return store.dispatch(getRoles(course1Id, urlParams)).then(() => {
expect(store.getActions()).toEqual(expectedActions); expect(store.getActions()).toEqual(expectedActions);
@@ -105,7 +114,10 @@ describe('actions', () => {
const store = mockStore(); const store = mockStore();
axiosMock.onGet(rolesUrl) axiosMock.onGet(rolesUrl)
.replyOnce(200, JSON.stringify(makeRoleListObj([], false))); .replyOnce(
200,
JSON.stringify(makeRoleListObj([], false)),
);
return store.dispatch(getRoles(course1Id, urlParams)).then(() => { return store.dispatch(getRoles(course1Id, urlParams)).then(() => {
expect(store.getActions()).toEqual(expectedActions); expect(store.getActions()).toEqual(expectedActions);
@@ -123,7 +135,10 @@ describe('actions', () => {
const store = mockStore(); const store = mockStore();
axiosMock.onGet(rolesUrl) axiosMock.onGet(rolesUrl)
.replyOnce(200, JSON.stringify(makeRoleListObj([], true))); .replyOnce(
200,
JSON.stringify(makeRoleListObj([], true)),
);
return store.dispatch(getRoles(course1Id, urlParams)).then(() => { return store.dispatch(getRoles(course1Id, urlParams)).then(() => {
expect(store.getActions()).toEqual(expectedActions); expect(store.getActions()).toEqual(expectedActions);

View File

@@ -21,6 +21,7 @@ describe('actions', () => {
describe('fetchTracks', () => { describe('fetchTracks', () => {
const courseId = 'course-v1:edX+DemoX+Demo_Course'; const courseId = 'course-v1:edX+DemoX+Demo_Course';
const trackUrl = `${configuration.LMS_BASE_URL}/api/enrollment/v1/course/${courseId}?include_expired=1`;
it('dispatches success action after fetching tracks', () => { it('dispatches success action after fetching tracks', () => {
const responseData = { const responseData = {
@@ -54,7 +55,7 @@ describe('actions', () => {
]; ];
const store = mockStore(); const store = mockStore();
axiosMock.onGet(`${configuration.LMS_BASE_URL}/api/enrollment/v1/course/${courseId}`) axiosMock.onGet(trackUrl)
.replyOnce(200, JSON.stringify(responseData)); .replyOnce(200, JSON.stringify(responseData));
return store.dispatch(fetchTracks(courseId)).then(() => { return store.dispatch(fetchTracks(courseId)).then(() => {
@@ -69,7 +70,7 @@ describe('actions', () => {
]; ];
const store = mockStore(); const store = mockStore();
axiosMock.onGet(`${configuration.LMS_BASE_URL}/api/enrollment/v1/course/${courseId}`) axiosMock.onGet(trackUrl)
.replyOnce(500, JSON.stringify({})); .replyOnce(500, JSON.stringify({}));
return store.dispatch(fetchTracks(courseId)).then(() => { return store.dispatch(fetchTracks(courseId)).then(() => {

View File

@@ -1,5 +1,3 @@
import { sortGrades } from './grades';
const sortAlphaAsc = (gradeRowA, gradeRowB) => { const sortAlphaAsc = (gradeRowA, gradeRowB) => {
const a = gradeRowA.username.toUpperCase(); const a = gradeRowA.username.toUpperCase();
const b = gradeRowB.username.toUpperCase(); const b = gradeRowB.username.toUpperCase();
@@ -12,78 +10,24 @@ const sortAlphaAsc = (gradeRowA, gradeRowB) => {
return 0; return 0;
}; };
const sortAlphaDesc = (gradeRowA, gradeRowB) => {
const a = gradeRowA.username.toUpperCase();
const b = gradeRowB.username.toUpperCase();
if (a < b) {
return 1;
}
if (a > b) {
return -1;
}
return 0;
};
const sortNumerically = (colKey, direction) => {
function getPercents(gradeRowA, gradeRowB) {
if (colKey !== 'total') {
return {
a: gradeRowA.section_breakdown.find(x => x.label === colKey).percent,
b: gradeRowB.section_breakdown.find(x => x.label === colKey).percent,
};
}
return {
a: gradeRowA.percent,
b: gradeRowB.percent,
};
}
function sortNumAsc(gradeRowA, gradeRowB) {
const { a, b } = getPercents(gradeRowA, gradeRowB);
return a - b;
}
function sortNumDesc(gradeRowA, gradeRowB) {
const { a, b } = getPercents(gradeRowA, gradeRowB);
return b - a;
}
return direction === 'desc' ? sortNumDesc : sortNumAsc;
};
function gradeSortMap(columnName, direction) {
if (columnName === 'username' && direction === 'desc') {
return sortAlphaDesc;
} else if (columnName === 'username') {
return sortAlphaAsc;
}
return sortNumerically(columnName, direction);
}
const headingMapper = (filterKey) => { const headingMapper = (filterKey) => {
function all(dispatch, entry) { function all(entry) {
if (entry) { if (entry) {
const results = [{ const results = [{
label: 'Username', label: 'Username',
key: 'username', key: 'username',
columnSortable: true,
onSort: (direction) => { dispatch(sortGrades('username', direction)); },
}]; }];
const assignmentHeadings = entry.section_breakdown const assignmentHeadings = entry.section_breakdown
.filter(section => section.is_graded && section.label) .filter(section => section.label)
.map(s => ({ .map(s => ({
label: s.label, label: s.label,
key: s.label, key: s.label,
columnSortable: true,
onSort: direction => dispatch(sortGrades(s.label, direction)),
})); }));
const totals = [{ const totals = [{
label: 'Total', label: 'Total',
key: 'total', key: 'total',
columnSortable: true,
onSort: direction => dispatch(sortGrades('total', direction)),
}]; }];
return results.concat(assignmentHeadings).concat(totals); return results.concat(assignmentHeadings).concat(totals);
@@ -91,28 +35,24 @@ const headingMapper = (filterKey) => {
return []; return [];
} }
function some(dispatch, entry) { function some(entry) {
if (!entry) return [];
const results = [{ const results = [{
label: 'Username', label: 'Username',
key: 'username', key: 'username',
columnSortable: true,
onSort: (direction) => { dispatch(sortGrades('username', direction)); },
}]; }];
const assignmentHeadings = entry.section_breakdown const assignmentHeadings = entry.section_breakdown
.filter(section => section.is_graded && section.label && section.category === filterKey) .filter(section => section.label && section.category === filterKey)
.map(s => ({ .map(s => ({
label: s.label, label: s.label,
key: s.label, key: s.label,
columnSortable: false,
onSort: (direction) => { this.sortNumerically(s.label, direction); },
})); }));
const totals = [{ const totals = [{
label: 'Total', label: 'Total',
key: 'total', key: 'total',
columnSortable: true,
onSort: direction => dispatch(sortGrades('total', direction)),
}]; }];
return results.concat(assignmentHeadings).concat(totals); return results.concat(assignmentHeadings).concat(totals);
@@ -121,5 +61,5 @@ const headingMapper = (filterKey) => {
return filterKey === 'All' ? all : some; return filterKey === 'All' ? all : some;
}; };
export { headingMapper, gradeSortMap, sortAlphaAsc }; export { headingMapper, sortAlphaAsc };

View File

@@ -8,7 +8,6 @@ const GRADE_UPDATE_SUCCESS = 'GRADE_UPDATE_SUCCESS';
const GRADE_UPDATE_FAILURE = 'GRADE_UPDATE_FAILURE'; const GRADE_UPDATE_FAILURE = 'GRADE_UPDATE_FAILURE';
const TOGGLE_GRADE_FORMAT = 'TOGGLE_GRADE_FORMAT'; const TOGGLE_GRADE_FORMAT = 'TOGGLE_GRADE_FORMAT';
const SORT_GRADES = 'SORT_GRADES';
const FILTER_COLUMNS = 'FILTER_COLUMNS'; const FILTER_COLUMNS = 'FILTER_COLUMNS';
const UPDATE_BANNER = 'UPDATE_BANNER'; const UPDATE_BANNER = 'UPDATE_BANNER';
@@ -21,7 +20,6 @@ export {
GRADE_UPDATE_SUCCESS, GRADE_UPDATE_SUCCESS,
GRADE_UPDATE_FAILURE, GRADE_UPDATE_FAILURE,
TOGGLE_GRADE_FORMAT, TOGGLE_GRADE_FORMAT,
SORT_GRADES,
FILTER_COLUMNS, FILTER_COLUMNS,
UPDATE_BANNER, UPDATE_BANNER,
}; };

View File

@@ -1,5 +1,5 @@
const GOT_ROLES = 'GOT_ROLES'; const GOT_ROLES = 'GOT_ROLES';
const ERROR_FETCHING_ROLES = 'ERROR_FETCHING_ROLES' const ERROR_FETCHING_ROLES = 'ERROR_FETCHING_ROLES';
export { export {
GOT_ROLES, GOT_ROLES,

View File

@@ -5,7 +5,6 @@ import {
TOGGLE_GRADE_FORMAT, TOGGLE_GRADE_FORMAT,
FILTER_COLUMNS, FILTER_COLUMNS,
UPDATE_BANNER, UPDATE_BANNER,
SORT_GRADES,
} from '../constants/actionTypes/grades'; } from '../constants/actionTypes/grades';
const initialState = { const initialState = {
@@ -32,9 +31,11 @@ const grades = (state = initialState, action) => {
errorFetching: false, errorFetching: false,
selectedTrack: action.track, selectedTrack: action.track,
selectedCohort: action.cohort, selectedCohort: action.cohort,
selectedAssignmentType: action.assignmentType,
prevPage: action.prev, prevPage: action.prev,
nextPage: action.next, nextPage: action.next,
showSpinner: false, showSpinner: false,
courseId: action.courseId,
}; };
case STARTED_FETCHING_GRADES: case STARTED_FETCHING_GRADES:
return { return {
@@ -64,11 +65,6 @@ const grades = (state = initialState, action) => {
...state, ...state,
showSuccess: action.showSuccess, showSuccess: action.showSuccess,
}; };
case SORT_GRADES:
return {
...state,
results: action.results,
};
default: default:
return state; return state;
} }

View File

@@ -6,7 +6,6 @@ import {
TOGGLE_GRADE_FORMAT, TOGGLE_GRADE_FORMAT,
FILTER_COLUMNS, FILTER_COLUMNS,
UPDATE_BANNER, UPDATE_BANNER,
SORT_GRADES,
} from '../constants/actionTypes/grades'; } from '../constants/actionTypes/grades';
const initialState = { const initialState = {
@@ -113,6 +112,7 @@ describe('grades reducer', () => {
prevPage: expectedPrev, prevPage: expectedPrev,
nextPage: expectedNext, nextPage: expectedNext,
showSpinner: false, showSpinner: false,
courseId,
}; };
expect(grades(undefined, { expect(grades(undefined, {
type: GOT_GRADES, type: GOT_GRADES,
@@ -123,6 +123,7 @@ describe('grades reducer', () => {
track: expectedTrack, track: expectedTrack,
cohort: expectedCohortId, cohort: expectedCohortId,
showSpinner: true, showSpinner: true,
courseId,
})).toEqual(expected); })).toEqual(expected);
}); });
@@ -162,17 +163,6 @@ describe('grades reducer', () => {
})).toEqual(expected); })).toEqual(expected);
}); });
it('updates sort grades state success', () => {
const expected = {
...initialState,
results: gradesData,
};
expect(grades(undefined, {
type: SORT_GRADES,
results: gradesData,
})).toEqual(expected);
});
it('updates fetch grades failure state', () => { it('updates fetch grades failure state', () => {
const expected = { const expected = {
...initialState, ...initialState,

View File

@@ -1,26 +1,27 @@
import { import {
GOT_ROLES, GOT_ROLES,
ERROR_FETCHING_ROLES, ERROR_FETCHING_ROLES,
} from '../constants/actionTypes/roles'; } from '../constants/actionTypes/roles';
const initialState = { const initialState = {
canUserViewGradebook: null, canUserViewGradebook: null,
}; };
const roles = (state = initialState, action) => { const roles = (state = initialState, action) => {
switch (action.type) { switch (action.type) {
case GOT_ROLES: case GOT_ROLES:
return { return {
...state, ...state,
canUserViewGradebook: action.canUserViewGradebook, canUserViewGradebook: action.canUserViewGradebook,
}; };
case ERROR_FETCHING_ROLES: case ERROR_FETCHING_ROLES:
return { return {
...state, ...state,
canUserViewGradebook: false, canUserViewGradebook: false,
}; };
default: default:
return state; return state;
}}; }
};
export default roles; export default roles;

View File

@@ -16,7 +16,7 @@ describe('tracks reducer', () => {
it('updates canUserViewGradebook to true', () => { it('updates canUserViewGradebook to true', () => {
const expected = { const expected = {
...initialState, ...initialState,
canUserViewGradebook: true canUserViewGradebook: true,
}; };
expect(roles(undefined, { expect(roles(undefined, {
type: GOT_ROLES, type: GOT_ROLES,
@@ -27,7 +27,7 @@ describe('tracks reducer', () => {
it('updates canUserViewGradebook to false', () => { it('updates canUserViewGradebook to false', () => {
const expected = { const expected = {
...initialState, ...initialState,
canUserViewGradebook: false canUserViewGradebook: false,
}; };
expect(roles(undefined, { expect(roles(undefined, {
type: GOT_ROLES, type: GOT_ROLES,

View File

@@ -3,7 +3,7 @@ import { configuration } from '../../config';
class LmsApiService { class LmsApiService {
static baseUrl = configuration.LMS_BASE_URL; static baseUrl = configuration.LMS_BASE_URL;
static pageSize = 10 static pageSize = 25
static fetchGradebookData(courseId, searchText, cohort, track) { static fetchGradebookData(courseId, searchText, cohort, track) {
let gradebookUrl = `${LmsApiService.baseUrl}/api/grades/v1/gradebook/${courseId}/`; let gradebookUrl = `${LmsApiService.baseUrl}/api/grades/v1/gradebook/${courseId}/`;
@@ -25,7 +25,10 @@ class LmsApiService {
/* /*
updateData is expected to be a list of objects with the keys 'user_id' (an integer), updateData is expected to be a list of objects with the keys 'user_id' (an integer),
'usage_id' (a string) and 'grade', which is an object with the keys: 'usage_id' (a string) and 'grade', which is an object with the keys:
'earned_all_override', 'possible_all_override', 'earned_graded_override', and 'possible_graded_override', 'earned_all_override',
'possible_all_override',
'earned_graded_override',
and 'possible_graded_override',
each of which should be an integer. each of which should be an integer.
Example: Example:
[ [
@@ -46,7 +49,7 @@ class LmsApiService {
} }
static fetchTracks(courseId) { static fetchTracks(courseId) {
const trackUrl = `${LmsApiService.baseUrl}/api/enrollment/v1/course/${courseId}`; const trackUrl = `${LmsApiService.baseUrl}/api/enrollment/v1/course/${courseId}?include_expired=1`;
return apiClient.get(trackUrl); return apiClient.get(trackUrl);
} }

View File

@@ -10,16 +10,21 @@ import { GOT_GRADES, GRADE_UPDATE_SUCCESS, GRADE_UPDATE_FAILURE } from './consta
import reducers from './reducers'; import reducers from './reducers';
const loggerMiddleware = createLogger(); const loggerMiddleware = createLogger();
const trackingCategory = 'gradebook';
const eventsMap = { const eventsMap = {
[GOT_ROLES]: trackPageView(action => ({ [GOT_ROLES]: trackPageView(action => ({
category: trackingCategory,
page: action.courseId, page: action.courseId,
})), })),
[GOT_GRADES]: trackEvent(action => ({ [GOT_GRADES]: trackEvent(action => ({
name: 'Grades displayed or paginated', name: 'Grades displayed or paginated',
properties: { properties: {
category: trackingCategory,
courseId: action.courseId,
track: action.track, track: action.track,
cohort: action.cohort, cohort: action.cohort,
assignmentType: action.assignmentType,
prev: action.prev, prev: action.prev,
next: action.next, next: action.next,
}, },
@@ -27,12 +32,16 @@ const eventsMap = {
[GRADE_UPDATE_SUCCESS]: trackEvent(action => ({ [GRADE_UPDATE_SUCCESS]: trackEvent(action => ({
name: 'Grades Updated', name: 'Grades Updated',
properties: { properties: {
category: trackingCategory,
courseId: action.courseId,
updatedGrades: action.payload.responseData, updatedGrades: action.payload.responseData,
}, },
})), })),
[GRADE_UPDATE_FAILURE]: trackEvent(action => ({ [GRADE_UPDATE_FAILURE]: trackEvent(action => ({
name: 'Grades Fail to Update', name: 'Grades Fail to Update',
properties: { properties: {
category: trackingCategory,
courseId: action.courseId,
error: action.payload.error, error: action.payload.error,
}, },
})), })),

View File

@@ -12,8 +12,6 @@ import store from './data/store';
import FooterLogo from '../assets/edx-footer.png'; import FooterLogo from '../assets/edx-footer.png';
import './App.scss'; import './App.scss';
var courseId = window.location.pathname.substring(1);
const App = () => ( const App = () => (
<Provider store={store}> <Provider store={store}>
<Router> <Router>