Compare commits

...

23 Commits

Author SHA1 Message Date
Simon Chen
ec81eb47d9 Merge pull request #18 from edx/aed/edit-modal
feat(editing): display a status alert after grades have updated.
2018-11-14 21:03:12 -05:00
Alex Dusenbery
cd2a5ae903 feat(editing): display a status alert after grades have updated. 2018-11-14 20:36:41 -05:00
Richard I Reilly
7a02330e9e Merge pull request #17 from edx/rir/frontend-auth-1-1
Return the frontend-auth npm package to 1.1.0
2018-11-14 16:45:51 -05:00
Rick Reilly
a929194a29 Return the frontend-auth npm package to 1.1.0 2018-11-14 16:16:47 -05:00
Simon Chen
febf4d99c6 Merge pull request #14 from edx/schen/add_links
fix(functionality): Add links and subtitles to the UI
2018-11-14 15:52:47 -05:00
Simon Chen
83ed8ab875 Merge pull request #16 from edx/schen/default-wording
fix(ui): update the default wording of the group filters
2018-11-14 15:51:10 -05:00
Simon Chen
6563f54590 fix(functionality): Add links and subtitles to the UI so it helps user navigate 2018-11-14 15:43:48 -05:00
Simon Chen
e1fe31dc94 fix(ui): update the default wording of the group filters 2018-11-14 15:39:56 -05:00
Richard I Reilly
8754263584 Merge pull request #15 from edx/rir/fix-category-filter
Rir/fix category filter
2018-11-14 15:35:34 -05:00
Rick Reilly
c660bd8d15 Fix the category filter 2018-11-14 15:16:40 -05:00
Richard I Reilly
df32123f34 Merge pull request #8 from edx/rir/fix-percent-absolute-filter
Rir/fix percent absolute filter
2018-11-14 14:50:40 -05:00
Rick Reilly
e81db01be2 Fix percent vs absolute radio buttons 2018-11-14 14:32:34 -05:00
Simon Chen
05c3468d93 Merge pull request #12 from edx/schen/fix_download_link
fix(functionality): Update the download report link URL
2018-11-14 14:18:39 -05:00
Simon Chen
1a88343be9 fix(functionality): Update the download report link URL so it goes to the right place 2018-11-14 13:55:12 -05:00
Alex Dusenbery
6f752f3a18 fix(auth): Pin frontend-auth to 1.1.0 for now. 2018-11-14 13:35:09 -05:00
Alex Dusenbery
7bbc9a84dc fix(auth): use frontend-auth 1.2.0 (allows us to POST without CSRF violation). 2018-11-14 12:12:13 -05:00
Simon Chen
85cf3e35e4 Merge pull request #9 from edx/aed/prod-env
fix(packaging): Add default environment key/value pairs to prod webpack config
2018-11-14 10:35:40 -05:00
Alex Dusenbery
85fa6bca72 fix(packaging): Add default environment key/value pairs to prod webpack config. 2018-11-14 10:23:38 -05:00
Simon Chen
231685e78d Merge pull request #7 from edx/schen/cohorts
Create the cohorts and track dropdown for filtering students
2018-11-13 21:49:21 -05:00
Simon Chen
a4dc135129 feat(filter): Create the cohorts and track dropdown for filtering students
This is to provide the feature to filter students on the gradebook by
cohorts or enrollment tracks
2018-11-13 21:33:11 -05:00
Alex Dusenbery
2c890e53f8 Get the basic grade update working. 2018-11-13 13:03:50 -05:00
Richard I Reilly
33556fd749 Merge pull request #4 from edx/rir/hookup-redux
Hook up redux to get data for component
2018-11-09 15:58:35 -05:00
Rick Reilly
8a62e8b710 Hook search up to the backend 2018-11-09 15:54:05 -05:00
20 changed files with 826 additions and 302 deletions

View File

@@ -3,6 +3,7 @@
const Merge = require('webpack-merge');
const commonConfig = require('./webpack.common.config.js');
const path = require('path');
const webpack = require('webpack');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
@@ -91,5 +92,20 @@ module.exports = Merge.smart(commonConfig, {
inject: true, // Appends script tags linking to the webpack bundles at the end of the body
template: path.resolve(__dirname, '../public/index.html'),
}),
new webpack.EnvironmentPlugin({
NODE_ENV: 'production',
BASE_URL: null,
LMS_BASE_URL: null,
LOGIN_URL: null,
LOGOUT_URL: null,
REFRESH_ACCESS_TOKEN_ENDPOINT: null,
DATA_API_BASE_URL: null,
SEGMENT_KEY: null,
FEATURE_FLAGS: {},
ACCESS_TOKEN_COOKIE_NAME: null,
CSRF_COOKIE_NAME: 'csrftoken',
NEW_RELIC_APP_ID: null,
NEW_RELIC_LICENSE_KEY: null,
}),
],
});

78
package-lock.json generated
View File

@@ -2988,14 +2988,14 @@
}
},
"react": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/react/-/react-16.6.1.tgz",
"integrity": "sha512-OtawJThYlvRgm9BXK+xTL7BIlDx8vv21j+fbQDjRRUyok6y7NyjlweGorielTahLZHYIdKUoK2Dp9ByVWuMqxw==",
"version": "16.6.3",
"resolved": "https://registry.npmjs.org/react/-/react-16.6.3.tgz",
"integrity": "sha512-zCvmH2vbEolgKxtqXL2wmGCUxUyNheYn/C+PD1YAjfxHC54+MhdruyhO7QieQrYsYeTxrn93PM2y0jRH1zEExw==",
"requires": {
"loose-envify": "^1.1.0",
"object-assign": "^4.1.1",
"prop-types": "^15.6.2",
"scheduler": "^0.11.0"
"scheduler": "^0.11.2"
},
"dependencies": {
"prop-types": {
@@ -3055,6 +3055,15 @@
}
}
},
"scheduler": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.11.2.tgz",
"integrity": "sha512-+WCP3s3wOaW4S7C1tl3TEXp4l9lJn0ZK8G3W3WKRWmw77Z2cIFUW2MiNTMHn5sCjxN+t7N43HAOOgMjyAg5hlg==",
"requires": {
"loose-envify": "^1.1.0",
"object-assign": "^4.1.1"
}
},
"warning": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.2.tgz",
@@ -3066,9 +3075,9 @@
}
},
"@edx/paragon": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-3.7.0.tgz",
"integrity": "sha512-BFzPA03CjzSMEwz4utSu5Nzh9tz5kShrWHuBNZvTtwD529ObYK52C7occ9Eid2jVHtAkbwkBNQdLzz6KscCniw==",
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-3.7.1.tgz",
"integrity": "sha512-HmcWcCL6ocz0yoTajeSEt62a/CPM+Jz/Ch+RzxZhvUrlced6u7bSrjM0CdK7rlz4sZKI5oPQzZe199x1KHXk6Q==",
"requires": {
"@edx/edx-bootstrap": "^1.0.0",
"@sambego/storybook-styles": "^1.0.0",
@@ -3103,14 +3112,14 @@
"integrity": "sha512-gulJE5dGFo6Q61V/whS6VM4WIyrlydXfCgkE+Gxe5hjrJ8rXLLZlALq7zq2RPhOc45PSwQpJkrTnc2KgD6cvmA=="
},
"react": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/react/-/react-16.6.1.tgz",
"integrity": "sha512-OtawJThYlvRgm9BXK+xTL7BIlDx8vv21j+fbQDjRRUyok6y7NyjlweGorielTahLZHYIdKUoK2Dp9ByVWuMqxw==",
"version": "16.6.3",
"resolved": "https://registry.npmjs.org/react/-/react-16.6.3.tgz",
"integrity": "sha512-zCvmH2vbEolgKxtqXL2wmGCUxUyNheYn/C+PD1YAjfxHC54+MhdruyhO7QieQrYsYeTxrn93PM2y0jRH1zEExw==",
"requires": {
"loose-envify": "^1.1.0",
"object-assign": "^4.1.1",
"prop-types": "^15.6.2",
"scheduler": "^0.11.0"
"scheduler": "^0.11.2"
},
"dependencies": {
"prop-types": {
@@ -3123,6 +3132,15 @@
}
}
}
},
"scheduler": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.11.2.tgz",
"integrity": "sha512-+WCP3s3wOaW4S7C1tl3TEXp4l9lJn0ZK8G3W3WKRWmw77Z2cIFUW2MiNTMHn5sCjxN+t7N43HAOOgMjyAg5hlg==",
"requires": {
"loose-envify": "^1.1.0",
"object-assign": "^4.1.1"
}
}
}
},
@@ -7254,8 +7272,7 @@
"decode-uri-component": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz",
"integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=",
"dev": true
"integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU="
},
"decompress-response": {
"version": "3.3.0",
@@ -13485,6 +13502,16 @@
"cast-array": "~1.0.0",
"object-filter": "~1.0.2",
"query-string": "~2.4.1"
},
"dependencies": {
"query-string": {
"version": "2.4.2",
"resolved": "http://registry.npmjs.org/query-string/-/query-string-2.4.2.tgz",
"integrity": "sha1-fbBmZCCAS6qSrp8miWKFWnYUPfs=",
"requires": {
"strict-uri-encode": "^1.0.0"
}
}
}
},
"make-dir": {
@@ -19187,11 +19214,19 @@
"dev": true
},
"query-string": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/query-string/-/query-string-2.4.2.tgz",
"integrity": "sha1-fbBmZCCAS6qSrp8miWKFWnYUPfs=",
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/query-string/-/query-string-6.2.0.tgz",
"integrity": "sha512-5wupExkIt8RYL4h/FE+WTg3JHk62e6fFPWtAZA9J5IWK1PfTfKkMS93HBUHcFpeYi9KsY5pFbh+ldvEyaz5MyA==",
"requires": {
"strict-uri-encode": "^1.0.0"
"decode-uri-component": "^0.2.0",
"strict-uri-encode": "^2.0.0"
},
"dependencies": {
"strict-uri-encode": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz",
"integrity": "sha1-ucczDHBChi9rFC3CdLvMWGbONUY="
}
}
},
"querystring": {
@@ -20479,15 +20514,6 @@
"integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==",
"dev": true
},
"scheduler": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.11.0.tgz",
"integrity": "sha512-MAYbBfmiEHxF0W+c4CxMpEqMYK+rYF584VP/qMKSiHM6lTkBKKYOJaDiSILpJHla6hBOsVd6GucPL46o2Uq3sg==",
"requires": {
"loose-envify": "^1.1.0",
"object-assign": "^4.1.1"
}
},
"schema-utils": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-0.4.5.tgz",

View File

@@ -25,14 +25,15 @@
},
"dependencies": {
"@edx/edx-bootstrap": "^0.4.3",
"@edx/frontend-auth": "^1.1.0",
"@edx/paragon": "^3.7.0",
"@edx/frontend-auth": "1.1.0",
"@edx/paragon": "^3.7.1",
"babel-polyfill": "^6.26.0",
"classnames": "^2.2.5",
"email-prop-type": "^1.1.5",
"font-awesome": "^4.7.0",
"history": "^4.7.2",
"prop-types": "^15.5.10",
"query-string": "^6.2.0",
"react": "^16.2.0",
"react-dom": "^16.2.0",
"react-redux": "^5.0.7",

View File

@@ -1,3 +1,15 @@
.back-link{
float:right;
}
.student-filters{
display: flex;
.label{
padding-top: 30px;
}
.form-group{
margin-left: 10px;
}
}
.gbook {
overflow-x: scroll;

View File

@@ -1,214 +1,200 @@
import React from 'react';
import PropTypes from 'prop-types';
import emailPropType from 'email-prop-type';
import { SearchField, Table, Modal } from '@edx/paragon';
import { Button, InputSelect, Modal, SearchField, StatusAlert, Table } from '@edx/paragon';
import queryString from 'query-string';
import { configuration } from '../../config';
export default class Gradebook extends React.Component {
constructor(props) {
super(props);
this.state = {
grades: [], // this.mapUserEnteriesPercent(this.props.grades).sort(this.sortAlphaDesc),
grades: [], // this.mapUserEntriesPercent(this.props.grades).sort(this.sortAlphaDesc),
headings: [], // this.mapHeadings(this.props.grades[0]),
filterValue: '',
modalContent: (<h1>Hello, World!</h1>),
modalOpen: false,
modalModel: [{}],
updateVal: 0,
updateModuleId: null,
updateUserId: null,
};
}
componentDidMount() {
this.props.getUserGrades(this.props.match.params.courseId);
const urlQuery = queryString.parse(this.props.location.search);
this.props.getUserGrades(
this.props.match.params.courseId,
urlQuery.cohort,
urlQuery.track,
);
this.props.getTracks(this.props.match.params.courseId);
this.props.getCohorts(this.props.match.params.courseId);
}
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;
};
sortAlphaAsc = (gradeRowA, gradeRowB) => {
const a = gradeRowA.username.toUpperCase();
const b = gradeRowB.username.toUpperCase();
if (a < b) {
return 1;
}
if (a > b) {
return -1;
}
return 0;
};
sortNumerically = (colKey, direction) => {
function sortNumAsc(gradeRowA, gradeRowB) {
if (gradeRowA[colKey] < gradeRowB[colKey]) {
return -1;
}
if (gradeRowA[colKey] > gradeRowB[colKey]) {
return 1;
}
return 0;
}
function sortNumDesc(gradeRowA, gradeRowB) {
if (gradeRowA[colKey] < gradeRowB[colKey]) {
return 1;
}
if (gradeRowA[colKey] > gradeRowB[colKey]) {
return -1;
}
return 0;
}
this.setState({ grades: [...this.state.grades].sort(direction === 'desc' ? sortNumDesc : sortNumAsc) });
setNewModalState = (userEntry, subsection) => {
this.setState({
modalModel: [{
username: userEntry.username,
currentGrade: `${subsection.score_earned}/${subsection.score_possible}`,
adjustedGrade: (
<span>
<input
style={{ width: '25px' }}
type="text"
onChange={event => this.setState({ updateVal: event.target.value })}
/> / {subsection.score_possible}
</span>
),
assignmentName: `${subsection.subsection_name}`,
}],
modalOpen: true,
updateModuleId: subsection.module_id,
updateUserId: userEntry.user_id,
});
}
mapHeadings = (entry) => {
if (entry) {
const results = [{
label: 'Username',
key: 'username',
columnSortable: true,
onSort: (direction) => {
this.setState({
grades: [...this.state.grades].sort(direction === 'desc' ? this.sortAlphaDesc : this.sortAlphaAsc),
});
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,
},
}];
},
]);
const assignmentHeadings = entry.section_breakdown
.filter(section => section.is_graded && section.label)
.map(s => ({
label: s.label,
key: s.label,
columnSortable: true,
onSort: (direction) => { this.sortNumerically(s.label, direction); },
}));
this.setState({
modalModel: [{}],
modalOpen: false,
updateModuleId: null,
updateUserId: null,
});
}
const totals = [{
label: 'Total',
key: 'total',
columnSortable: true,
onSort: (direction) => { this.sortNumerically('total', direction); },
}];
updateQueryParams = (queryKey, queryValue) => {
const parsed = queryString.parse(this.props.location.search);
parsed[queryKey] = queryValue;
return `?${queryString.stringify(parsed)}`;
};
return results.concat(assignmentHeadings).concat(totals);
mapCohortsEntries = (entries) => {
const mapped = entries.map(entry => ({
id: entry.id,
label: entry.name,
}));
mapped.unshift({ id: 0, label: 'Cohort-All' });
return mapped;
};
mapTracksEntries = (entries) => {
const mapped = entries.map(entry => ({
id: entry.slug,
label: entry.name,
}));
mapped.unshift({ label: 'Track-All' });
return mapped;
};
updateTracks = (event) => {
const selectedTrackItem = this.props.tracks.find(x => x.name === event);
let selectedTrackSlug = null;
if (selectedTrackItem) {
selectedTrackSlug = selectedTrackItem.slug;
}
return [];
this.props.getUserGrades(
this.props.match.params.courseId,
this.props.selectedCohort,
selectedTrackSlug,
);
const updatedQueryStrings = this.updateQueryParams('track', selectedTrackSlug);
this.props.history.push(updatedQueryStrings);
};
mapHeadingsHw = (entry) => {
const results = [{
label: 'Username',
key: 'username',
columnSortable: true,
onSort: (direction) => {
this.setState({
grades: [...this.state.grades].sort(direction === 'desc' ? this.sortAlphaDesc : this.sortAlphaAsc),
});
},
}];
const assignmentHeadings = entry.section_breakdown
.filter(section => section.is_graded && section.label && section.category == 'Homework')
.map(s => ({
label: s.label,
key: s.label,
columnSortable: true,
onSort: (direction) => { this.sortNumerically(s.label, direction); },
}));
return results.concat(assignmentHeadings);
updateCohorts = (event) => {
const selectedCohortItem = this.props.cohorts.find(x => x.name === event);
let selectedCohortId = null;
if (selectedCohortItem) {
selectedCohortId = selectedCohortItem.id;
}
this.props.getUserGrades(
this.props.match.params.courseId,
selectedCohortId,
this.props.selectedTrack,
);
const updatedQueryStrings = this.updateQueryParams('cohort', selectedCohortId);
this.props.history.push(updatedQueryStrings);
};
mapHeadingsExam = (entry) => {
const results = [{
label: 'Username',
key: 'username',
columnSortable: true,
onSort: (direction) => {
this.setState({
grades: [...this.state.grades].sort(direction === 'desc' ? this.sortAlphaDesc : this.sortAlphaAsc),
});
},
}];
const assignmentHeadings = entry.section_breakdown
.filter(section => section.is_graded && section.label && section.category == 'Exam')
.map(s => ({
label: s.label,
key: s.label,
columnSortable: true,
onSort: (direction) => { this.sortNumerically(s.label, direction); },
}));
return results.concat(assignmentHeadings);
mapSelectedCohortEntry = (entry) => {
const selectedCohortEntry = this.props.cohorts.find(x => x.id === parseInt(entry, 10));
if (selectedCohortEntry) {
return selectedCohortEntry.name;
}
return 'Cohorts';
};
mapUserEnteriesPercent = entries => entries.map((entry) => {
const results = { username: entry.username };
const assignments = entry.section_breakdown
.filter(section => section.is_graded)
.reduce((acc, s) => {
acc[s.label] = (
<button
className="btn btn-header link-style"
onClick={() => this.setState({
modalModel: [{
username: entry.username,
autoGrade: `${s.score_earned}/${s.score_possible}`,
adjustedGrade: (<span><input style={{ width: '25px' }} type="text" value={this.updateVal} /> / {s.score_possible}</span>),
assignmentName: `${s.subsection_name}`,
}],
modalOpen: true,
})}
>
{s.percent}
</button>);
return acc;
}, {});
const totals = { total: entry.percent * 100 };
return Object.assign(results, assignments, totals);
});
mapSelectedTrackEntry = (entry) => {
const selectedTrackEntry = this.props.tracks.find(x => x.slug === entry);
if (selectedTrackEntry) {
return selectedTrackEntry.name;
}
return 'Tracks';
};
mapUserEnteriesAbsolute = entries => entries.map((entry) => {
const results = { username: entry.username };
const assignments = entry.section_breakdown
.filter(section => section.is_graded)
.reduce((acc, s) => {
acc[s.label] = (
<button
className="btn btn-header link-style"
onClick={() => this.setState({
modalModel: [{
username: entry.username,
autoGrade: `${s.score_earned}/${s.score_possible}`,
adjustedGrade: (<span><input style={{ width: '25px' }} type="text" value={this.updateVal} /> / {s.score_possible}</span>),
assignmentName: `${s.subsection_name}`,
}],
modalOpen: true,
})}
>
{s.score_earned}/{s.score_possible}
</button>);
return acc;
}, {});
formatter = {
percent: entries => entries.map((entry) => {
const results = { username: entry.username };
const assignments = entry.section_breakdown
.filter(section => section.is_graded)
.reduce((acc, subsection) => {
acc[subsection.label] = (
<button
className="btn btn-header link-style"
onClick={() => this.setNewModalState(entry, subsection)}
>
{subsection.percent}
</button>);
return acc;
}, {});
const totals = { total: entry.percent * 100 };
return Object.assign(results, assignments, totals);
}),
const totals = { total: entry.percent * 100 };
return Object.assign(results, assignments, totals);
});
absolute: entries => entries.map((entry) => {
const results = { username: entry.username };
const assignments = entry.section_breakdown
.filter(section => section.is_graded)
.reduce((acc, subsection) => {
acc[subsection.label] = (
<button
className="btn btn-header link-style"
onClick={() => this.setNewModalState(entry, subsection)}
>
{subsection.score_earned}/{subsection.score_possible}
</button>);
return acc;
}, {});
const totals = { total: entry.percent * 100 };
return Object.assign(results, assignments, totals);
}),
};
lmsInstructorDashboardUrl = courseId => `${configuration.LMS_BASE_URL}/courses/${courseId}/instructor`;
render() {
return (
<div className="d-flex justify-content-center">
<div className="card" style={{ width: '50rem' }}>
<div className="card-body">
<a
href={this.lmsInstructorDashboardUrl(this.props.match.params.courseId)}
className="back-link"
>
Back to Dashboard
</a>
<h1>Gradebook</h1>
<h3> {this.props.match.params.courseId}</h3>
<hr />
<div className="d-flex justify-content-between" >
<div>
@@ -217,13 +203,13 @@ export default class Gradebook extends React.Component {
<span>
<input
id="score-view-percent"
className="ml-2"
className="ml-2 mr-1"
type="radio"
name="score-view"
value="percent"
onClick={() => this.setState({ grades: this.mapUserEnteriesPercent(this.props.results).sort(this.sortAlphaDesc) })}
onClick={() => this.props.toggleFormat('percent')}
/>
<label className="ml-2 mr-2" htmlFor="score-view-percent">Percent</label>
<label className="mr-2" htmlFor="score-view-percent">Percent</label>
</span>
<span>
<input
@@ -231,76 +217,99 @@ export default class Gradebook extends React.Component {
type="radio"
name="score-view"
value="absolute"
onClick={() => this.setState({ grades: this.mapUserEnteriesAbsolute(this.props.results).sort(this.sortAlphaDesc) })}
className="mr-1"
onClick={() => this.props.toggleFormat('absolute')}
/>
<label className="ml-2 mr-2" htmlFor="score-view-absolute">Absolute</label>
<label htmlFor="score-view-absolute">Absolute</label>
</span>
</div>
<div>
Category:
<span>
<label className="ml-2 mr-2" htmlFor="category-all">
<input
id="category-all"
className="ml-2"
type="radio"
name="category"
value="all"
onClick={() =>
this.setState({ headings: this.mapHeadings(this.props.results[0]) })}
/>
<input
id="category-all"
className="ml-2 mr-1"
type="radio"
name="category"
value="all"
onClick={() => this.props.filterColumns('all', this.props.grades[0])}
/>
<label className="mr-2" htmlFor="category-all">
All
</label>
</span>
<span>
<input
id="category-homework"
className="ml-2"
className="mr-1"
type="radio"
name="category"
value="homework"
onClick={() =>
this.setState({
headings: this.mapHeadingsHw(this.props.results[0]),
})}
onClick={() => this.props.filterColumns('hw', this.props.grades[0])}
/>
<label className="ml-2 mr-2" htmlFor="category-homework">Homework</label>
<label className="mr-2" htmlFor="category-homework">Homework</label>
</span>
<span>
<label className="ml-2 mr-2" htmlFor="Exam">
<input
id="category-exam"
type="radio"
name="category"
value="exam"
onClick={() => this.setState({ headings: this.mapHeadingsExam(this.props.results[0]) })}
/>
<input
id="category-exam"
type="radio"
name="category"
value="exam"
className="ml-2 mr-1"
onClick={() => this.props.filterColumns('exam', this.props.grades[0])}
/>
<label htmlFor="Exam">
Exam
</label>
</span>
</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>
<div>
<div style={{ marginLeft: '10px', marginBottom: '10px' }}>
<a href="https://www.google./com">Download Grade Report</a>
<a href={`${this.lmsInstructorDashboardUrl(this.props.match.params.courseId)}#view-data_download`}>Download Grade Report</a>
</div>
<SearchField
onSubmit={() => this.setState({ grades: this.mapUserEnteriesPercent(this.props.results).filter(entry => entry.username === '' || entry.username.includes(this.state.filterValue)) }) }
onSubmit={value => this.props.searchForUser(this.props.match.params.courseId, value, this.props.selectedCohort, this.props.selectedTrack)}
onChange={filterValue => this.setState({ filterValue })}
onClear={() => this.setState({
grades: this.mapUserEnteriesPercent(this.props.results)
.sort(this.sortAlphaDesc),
})}
onClear={() => this.props.getUserGrades(this.props.match.params.courseId, this.props.selectedCohort, this.props.selectedTrack)}
value={this.state.filterValue}
/>
</div>
</div>
<br />
<StatusAlert
alertType="success"
dialog="The grade has been successfully edited."
onClose={() => this.props.updateBanner(false)}
open={this.props.showSuccess}
/>
<div className="gbook">
<Table
columns={this.mapHeadings(this.props.grades[0])}
data={this.mapUserEnteriesPercent(this.props.grades)}
tableSortable
columns={this.props.headings}
data={this.formatter[this.props.format](this.props.grades)}
defaultSortDirection="desc"
defaultSortedColumn="username"
/>
@@ -312,15 +321,28 @@ export default class Gradebook extends React.Component {
<div>
<h3>{this.state.modalModel[0].assignmentName}</h3>
<Table
columns={[{ label: 'Username', key: 'username' }, { label: 'Auto grade', key: 'autoGrade' }, { label: 'Adjusted grade', key: 'adjustedGrade' }]}
data={this.state.modalModel}
tableSortable
defaultSortDirection="desc"
defaultSortedColumn="username"
/>
columns={[{ label: 'Username', key: 'username' }, { label: 'Current grade', key: 'currentGrade' }, { label: 'Adjusted grade', key: 'adjustedGrade' }]}
data={this.state.modalModel}
tableSortable
defaultSortDirection="desc"
defaultSortedColumn="username"
/>
</div>
)}
onClose={() => this.setState({ modalOpen: false })}
)}
buttons={[
<Button
label="Edit Grade"
buttonType="primary"
onClick={this.handleAdjustedGradeClick}
/>,
]}
onClose={() => this.setState({
modalOpen: false,
modalModel: [{}],
updateVal: 0,
updateModuleId: null,
updateUserId: null,
})}
/>
</div>
</div>
@@ -329,19 +351,3 @@ export default class Gradebook extends React.Component {
}
}
// CommentDetails.defaultProps = {
// id: null,
// postId: null,
// name: '',
// email: 'example@example.com',
// body: '',
// };
// CommentDetails.propTypes = {
// id: PropTypes.number,
// postId: PropTypes.number,
// name: PropTypes.string,
// email: emailPropType,
// body: PropTypes.string,
// };

View File

@@ -1,18 +1,55 @@
import { connect } from 'react-redux';
import Gradebook from '../../components/Gradebook';
import { fetchGrades } from '../../data/actions/grades';
import {
fetchGrades,
fetchMatchingUserGrades,
updateGrades,
toggleGradeFormat,
filterColumns,
updateBanner,
} from '../../data/actions/grades';
import { fetchCohorts } from '../../data/actions/cohorts';
import { fetchTracks } from '../../data/actions/tracks';
const mapStateToProps = state => (
{
grades: state.grades.results,
headings: state.grades.headings,
tracks: state.tracks.results,
cohorts: state.cohorts.results,
selectedTrack: state.grades.selectedTrack,
selectedCohort: state.grades.selectedCohort,
format: state.grades.gradeFormat,
showSuccess: state.grades.showSuccess,
}
);
const mapDispatchToProps = dispatch => (
{
getUserGrades: (courseId) => {
dispatch(fetchGrades(courseId));
getUserGrades: (courseId, cohort, track) => {
dispatch(fetchGrades(courseId, cohort, track));
},
searchForUser: (courseId, searchText, cohort, track) => {
dispatch(fetchMatchingUserGrades(courseId, searchText, cohort, track));
},
getCohorts: (courseId) => {
dispatch(fetchCohorts(courseId));
},
getTracks: (courseId) => {
dispatch(fetchTracks(courseId));
},
updateGrades: (courseId, updateData) => {
dispatch(updateGrades(courseId, updateData));
},
toggleFormat: (formatType) => {
dispatch(toggleGradeFormat(formatType));
},
filterColumns: (filterType, exampleUser) => {
dispatch(filterColumns(filterType, exampleUser));
},
updateBanner: (showSuccess) => {
dispatch(updateBanner(showSuccess));
},
}
);

View File

@@ -0,0 +1,31 @@
import {
STARTED_FETCHING_COHORTS,
GOT_COHORTS,
ERROR_FETCHING_COHORTS,
} from '../constants/actionTypes/cohorts';
import LmsApiService from '../services/LmsApiService';
const startedFetchingCohorts = () => ({ type: STARTED_FETCHING_COHORTS });
const errorFetchingCohorts = () => ({ type: ERROR_FETCHING_COHORTS });
const gotCohorts = cohorts => ({ type: GOT_COHORTS, cohorts });
const fetchCohorts = courseId => (
(dispatch) => {
dispatch(startedFetchingCohorts());
return LmsApiService.fetchCohorts(courseId)
.then(response => response.data)
.then((data) => {
dispatch(gotCohorts(data.cohorts));
})
.catch((error) => {
dispatch(errorFetchingCohorts());
});
}
);
export {
fetchCohorts,
startedFetchingCohorts,
gotCohorts,
errorFetchingCohorts,
};

View File

@@ -3,25 +3,91 @@ import {
FINISHED_FETCHING_GRADES,
ERROR_FETCHING_GRADES,
GOT_GRADES,
GRADE_UPDATE_REQUEST,
GRADE_UPDATE_SUCCESS,
GRADE_UPDATE_FAILURE,
TOGGLE_GRADE_FORMAT,
SORT_GRADES,
FILTER_COLUMNS,
UPDATE_BANNER,
} from '../constants/actionTypes/grades';
import LmsApiService from '../services/LmsApiService';
import { headingMapper } from './utils';
const startedFetchingGrades = () => ({ type: STARTED_FETCHING_GRADES });
const finishedFetchingGrades = () => ({ type: FINISHED_FETCHING_GRADES });
const errorFetchingGrades = () => ({ type: ERROR_FETCHING_GRADES });
const gotGrades = grades => ({ type: GOT_GRADES, grades });
const gotGrades = (grades, cohort, track, headings) => ({
type: GOT_GRADES,
grades,
cohort,
track,
headings,
});
const fetchGrades = courseId => (
const gradeUpdateRequest = () => ({ type: GRADE_UPDATE_REQUEST });
const gradeUpdateSuccess = (responseData) => ({
type: GRADE_UPDATE_SUCCESS,
payload: { responseData },
});
const gradeUpdateFailure = error => ({
type: GRADE_UPDATE_FAILURE,
payload: { error },
});
const toggleGradeFormat = formatType => ({ type: TOGGLE_GRADE_FORMAT, formatType });
const sortGrades = (columnName, direction) => ({ type: SORT_GRADES, columnName, direction });
const filterColumns = (filterType, exampleUser) => ({
type: FILTER_COLUMNS,
headings: headingMapper[filterType](exampleUser)
});
const updateBanner = (showSuccess) => ({ type: UPDATE_BANNER, showSuccess });
const fetchGrades = (courseId, cohort, track, showSuccess) => (
(dispatch) => {
dispatch(startedFetchingGrades());
return LmsApiService.fetchGradebookData(courseId)
return LmsApiService.fetchGradebookData(courseId, null, cohort, track)
.then(response => response.data)
.then((data) => {
dispatch(gotGrades(data.results));
dispatch(gotGrades(data.results, cohort, track, headingMapper.all(data.results[0])));
dispatch(finishedFetchingGrades());
dispatch(updateBanner(!!showSuccess));
})
.catch(() => {
dispatch(errorFetchingGrades());
});
}
);
const fetchMatchingUserGrades = (courseId, searchText, cohort, track) => (
(dispatch) => {
dispatch(startedFetchingGrades());
return LmsApiService.fetchGradebookData(courseId, searchText, cohort, track)
.then(response => response.data)
.then((data) => {
dispatch(gotGrades(data.results, cohort, track));
dispatch(finishedFetchingGrades());
})
.catch(() => {
dispatch(errorFetchingGrades());
});
}
);
const updateGrades = (courseId, updateData) => (
(dispatch) => {
dispatch(gradeUpdateRequest());
return LmsApiService.updateGradebookData(courseId, updateData)
.then(response => response.data)
.then((data) => {
dispatch(gradeUpdateSuccess(data))
dispatch(fetchGrades(courseId, null, null, true))
})
.catch((error) => {
dispatch(errorFetchingGrades())
dispatch(gradeUpdateFailure(error));
});
}
);
@@ -32,4 +98,13 @@ export {
errorFetchingGrades,
gotGrades,
fetchGrades,
fetchMatchingUserGrades,
gradeUpdateRequest,
gradeUpdateSuccess,
gradeUpdateFailure,
updateGrades,
toggleGradeFormat,
sortGrades,
filterColumns,
updateBanner,
};

View File

@@ -0,0 +1,31 @@
import {
STARTED_FETCHING_TRACKS,
GOT_TRACKS,
ERROR_FETCHING_TRACKS,
} from '../constants/actionTypes/tracks';
import LmsApiService from '../services/LmsApiService';
const startedFetchingTracks = () => ({ type: STARTED_FETCHING_TRACKS });
const errorFetchingTracks = () => ({ type: ERROR_FETCHING_TRACKS });
const gotTracks = tracks => ({ type: GOT_TRACKS, tracks });
const fetchTracks = courseId => (
(dispatch) => {
dispatch(startedFetchingTracks());
return LmsApiService.fetchTracks(courseId)
.then(response => response.data)
.then((data) => {
dispatch(gotTracks(data.course_modes));
})
.catch((error) => {
dispatch(errorFetchingTracks());
});
}
);
export {
fetchTracks,
startedFetchingTracks,
gotTracks,
errorFetchingTracks,
};

131
src/data/actions/utils.js Normal file
View File

@@ -0,0 +1,131 @@
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 sortAlphaAsc = (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 sortNumAsc(gradeRowA, gradeRowB) {
if (gradeRowA[colKey] < gradeRowB[colKey]) {
return -1;
}
if (gradeRowA[colKey] > gradeRowB[colKey]) {
return 1;
}
return 0;
}
function sortNumDesc(gradeRowA, gradeRowB) {
if (gradeRowA[colKey] < gradeRowB[colKey]) {
return 1;
}
if (gradeRowA[colKey] > gradeRowB[colKey]) {
return -1;
}
return 0;
}
this.setState({ grades: [...this.state.grades].sort(direction === 'desc' ? sortNumDesc : sortNumAsc) });
};
const headingMapper = {
all: (entry) => {
if (entry) {
const results = [{
label: 'Username',
key: 'username',
columnSortable: true,
onSort: (direction) => {
this.setState({
grades: [...this.state.grades].sort(direction === 'desc' ? this.sortAlphaDesc : this.sortAlphaAsc),
});
},
}];
const assignmentHeadings = entry.section_breakdown
.filter(section => section.is_graded && section.label)
.map(s => ({
label: s.label,
key: s.label,
columnSortable: true,
onSort: (direction) => { this.sortNumerically(s.label, direction); },
}));
const totals = [{
label: 'Total',
key: 'total',
columnSortable: true,
onSort: (direction) => { this.sortNumerically('total', direction); },
}];
return results.concat(assignmentHeadings).concat(totals);
}
return [];
},
hw: (entry) => {
const results = [{
label: 'Username',
key: 'username',
columnSortable: true,
onSort: (direction) => {
this.setState({
grades: [...this.state.grades].sort(direction === 'desc' ? this.sortAlphaDesc : this.sortAlphaAsc),
});
},
}];
const assignmentHeadings = entry.section_breakdown
.filter(section => section.is_graded && section.label && section.category == 'Homework')
.map(s => ({
label: s.label,
key: s.label,
columnSortable: false,
onSort: (direction) => { this.sortNumerically(s.label, direction); },
}));
return results.concat(assignmentHeadings);
},
exam: (entry) => {
const results = [{
label: 'Username',
key: 'username',
columnSortable: false,
onSort: (direction) => {
this.setState({
grades: [...this.state.grades].sort(direction === 'desc' ? this.sortAlphaDesc : this.sortAlphaAsc),
});
},
}];
const assignmentHeadings = entry.section_breakdown
.filter(section => section.is_graded && section.label && section.category == 'Exam')
.map(s => ({
label: s.label,
key: s.label,
columnSortable: false,
onSort: (direction) => { this.sortNumerically(s.label, direction); },
}));
return results.concat(assignmentHeadings);
},
};
export { headingMapper };

View File

@@ -1,6 +1,7 @@
import { configuration } from '../config';
import { getAuthenticatedAPIClient } from '@edx/frontend-auth';
import { configuration } from '../config';
const apiClient = getAuthenticatedAPIClient({
appBaseUrl: configuration.BASE_URL,
loginUrl: configuration.LOGIN_URL,

View File

@@ -0,0 +1,9 @@
const STARTED_FETCHING_COHORTS = 'STARTED_FETCHING_COHORTS';
const GOT_COHORTS = 'GOT_COHORTS';
const ERROR_FETCHING_COHORTS = 'ERROR_FETCHING_COHORTS';
export {
STARTED_FETCHING_COHORTS,
GOT_COHORTS,
ERROR_FETCHING_COHORTS,
};

View File

@@ -3,9 +3,25 @@ const FINISHED_FETCHING_GRADES = 'FINISHED_FETCHING_GRADES';
const ERROR_FETCHING_GRADES = 'ERROR_FETCHING_GRADES';
const GOT_GRADES = 'GOT_GRADES';
const GRADE_UPDATE_REQUEST = 'GRADE_UPDATE_REQUEST';
const GRADE_UPDATE_SUCCESS = 'GRADE_UPDATE_SUCCESS';
const GRADE_UPDATE_FAILURE = 'GRADE_UPDATE_FAILURE';
const TOGGLE_GRADE_FORMAT = 'TOGGLE_GRADE_FORMAT';
const SORT_GRADES = 'SORT_GRADES';
const FILTER_COLUMNS = 'FILTER_COLUMNS';
const UPDATE_BANNER = 'UPDATE_BANNER';
export {
STARTED_FETCHING_GRADES,
FINISHED_FETCHING_GRADES,
ERROR_FETCHING_GRADES,
GOT_GRADES,
GRADE_UPDATE_REQUEST,
GRADE_UPDATE_SUCCESS,
GRADE_UPDATE_FAILURE,
TOGGLE_GRADE_FORMAT,
SORT_GRADES,
FILTER_COLUMNS,
UPDATE_BANNER,
};

View File

@@ -0,0 +1,9 @@
const STARTED_FETCHING_TRACKS = 'STARTED_FETCHING_TRACKS';
const GOT_TRACKS = 'GOT_TRACKS';
const ERROR_FETCHING_TRACKS = 'ERROR_FETCHING_TRACKS';
export {
STARTED_FETCHING_TRACKS,
GOT_TRACKS,
ERROR_FETCHING_TRACKS,
};

View File

@@ -0,0 +1,39 @@
import {
STARTED_FETCHING_COHORTS,
ERROR_FETCHING_COHORTS,
GOT_COHORTS,
} from '../constants/actionTypes/cohorts';
const initialState = {
results: [],
startedFetching: false,
errorFetching: false,
};
const cohorts = (state = initialState, action) => {
switch (action.type) {
case GOT_COHORTS:
return {
...state,
results: action.cohorts,
errorFetching: false,
};
case STARTED_FETCHING_COHORTS:
return {
...state,
startedFetching: true,
};
case ERROR_FETCHING_COHORTS:
return {
...state,
finishedFetching: true,
errorFetching: true,
};
default:
return state;
}
};
export default cohorts;

View File

@@ -2,13 +2,20 @@ import {
STARTED_FETCHING_GRADES,
ERROR_FETCHING_GRADES,
GOT_GRADES,
TOGGLE_GRADE_FORMAT,
FILTER_COLUMNS,
GRADE_UPDATE_SUCCESS,
UPDATE_BANNER,
} from '../constants/actionTypes/grades';
const initialState = {
results: [],
headings: [],
startedFetching: false,
finishedFetching: false,
errorFetching: false,
gradeFormat: 'percent',
showSuccess: false,
};
const grades = (state = initialState, action) => {
@@ -17,8 +24,11 @@ const grades = (state = initialState, action) => {
return {
...state,
results: action.grades,
headings: action.headings,
finishedFetching: true,
errorFetching: false,
selectedTrack: action.track,
selectedCohort: action.cohort,
};
case STARTED_FETCHING_GRADES:
return {
@@ -32,6 +42,21 @@ const grades = (state = initialState, action) => {
finishedFetching: true,
errorFetching: true,
};
case TOGGLE_GRADE_FORMAT:
return {
...state,
gradeFormat: action.formatType,
};
case FILTER_COLUMNS:
return {
...state,
headings: action.headings,
};
case UPDATE_BANNER:
return {
...state,
showSuccess: action.showSuccess,
};
default:
return state;
}

View File

@@ -1,9 +1,13 @@
import { combineReducers } from 'redux';
import cohorts from './cohorts';
import grades from './grades';
import tracks from './tracks';
const rootReducer = combineReducers({
grades,
cohorts,
tracks,
});
export default rootReducer;

View File

@@ -0,0 +1,39 @@
import {
STARTED_FETCHING_TRACKS,
ERROR_FETCHING_TRACKS,
GOT_TRACKS,
} from '../constants/actionTypes/tracks';
const initialState = {
results: [],
startedFetching: false,
errorFetching: false,
};
const tracks = (state = initialState, action) => {
switch (action.type) {
case GOT_TRACKS:
return {
...state,
results: action.tracks,
errorFetching: false,
};
case STARTED_FETCHING_TRACKS:
return {
...state,
startedFetching: true,
};
case ERROR_FETCHING_TRACKS:
return {
...state,
finishedFetching: true,
errorFetching: true,
};
default:
return state;
}
};
export default tracks;

View File

@@ -4,11 +4,56 @@ import { configuration } from '../../config';
class LmsApiService {
static baseUrl = configuration.LMS_BASE_URL;
static fetchGradebookData(courseId) {
const fixedCourseId = 'course-v1:edX+DemoX+Demo_Course'; // TODO: get rid of this in favor of courseId
const gradebookUrl = `${LmsApiService.baseUrl}/api/grades/v1/gradebook/${fixedCourseId}/`;
static fetchGradebookData(courseId, searchText, cohort, track) {
let gradebookUrl = `${LmsApiService.baseUrl}/api/grades/v1/gradebook/${courseId}/`;
if (searchText || track || cohort) {
gradebookUrl += '?';
}
if (searchText) {
gradebookUrl += `username_contains=${searchText}&`;
}
if (cohort) {
gradebookUrl += `cohort_id=${cohort}&`;
}
if (track) {
gradebookUrl += `enrollment_mode=${track}`;
}
return apiClient.get(gradebookUrl);
}
static updateGradebookData(courseId, updateData) {
/*
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:
'earned_all_override', 'possible_all_override', 'earned_graded_override', and 'possible_graded_override',
each of which should be an integer.
Example:
[
{
"user_id": 9,
"usage_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions",
"grade": {
"earned_all_override": 11,
"possible_all_override": 11,
"earned_graded_override": 11,
"possible_graded_override": 11
}
}
]
*/
const gradebookUrl = `${LmsApiService.baseUrl}/api/grades/v1/gradebook/${courseId}/bulk-update`;
return apiClient.post(gradebookUrl, updateData);
}
static fetchTracks(courseId) {
const trackUrl = `${LmsApiService.baseUrl}/api/enrollment/v1/course/${courseId}`;
return apiClient.get(trackUrl);
}
static fetchCohorts(courseId) {
const cohortsUrl = `${LmsApiService.baseUrl}/courses/${courseId}/cohorts/`;
return apiClient.get(cohortsUrl);
}
}
export default LmsApiService;

View File

@@ -21,32 +21,3 @@ const App = () => (
);
ReactDOM.render(<App />, document.getElementById('root'));
// const App = () => (
// <Provider store={store}>
// <Router>
// <div>
// <header>
// <nav>
// <ul className="nav">
// <li className="nav-item"><Link className="nav-link" to="/">Home</Link></li>
// <li className="nav-item"><Link className="nav-link" to="/posts">Posts</Link></li>
// <li className="nav-item"><Link className="nav-link" to="/disclosure">Disclosure</Link></li>
// <li className="nav-item"><Link className="nav-link" to="/comment-search">Comment Search</Link></li>
// </ul>
// </nav>
// </header>
// <main>
// <Switch>
// <Route exact path="/" component={() => <span>Hello World</span>} />
// <Route path="/posts" component={PostsPage} />
// <Route path="/disclosure" component={DisclosurePage} />
// <Route path="/comment-search" component={CommentSearchPage} />
// </Switch>
// </main>
// </div>
// </Router>
// </Provider>
// );
// ReactDOM.render(<App />, document.getElementById('root'));