Compare commits

..

31 Commits

Author SHA1 Message Date
Simon Chen
8a01a60d63 Merge pull request #61 from edx/schen/default_select
fix(functionality): Make sure we default select radio button
2018-12-20 10:06:27 -05:00
Simon Chen
66cdcc7f2a fix(functionality): Make sure we default select radio button 2018-12-20 09:57:14 -05:00
Robert Raposa
0c73d66666 Merge pull request #59 from edx/robrap/footer-logo
Update footer logo.
2018-12-19 16:13:55 -05:00
albemarle
28e3e6d0e6 Merge pull request #60 from edx/home-link
add aria-label for edX Home
2018-12-19 15:29:57 -05:00
albemarle
6473bafa3d edx -> edX 2018-12-19 15:16:37 -05:00
Jansen Kantor
167901e665 Merge pull request #55 from edx/jkantor/role-error
render error message if user is not allowed to view gradebook
2018-12-19 15:15:34 -05:00
albemarle
50a0d6e579 add aria-label for edX Home 2018-12-19 15:09:58 -05:00
Robert Raposa
a284c286f5 Update footer logo.
ARCH-322
2018-12-19 14:04:31 -05:00
jansenk
dd967e703c render error message if user is not allowed to view gradebook 2018-12-19 14:00:27 -05:00
Richard I Reilly
725dc071e3 Merge pull request #58 from edx/rir/base-html-fixes
Add necessary meta tag to the base html file, language attribute for …
2018-12-19 13:37:45 -05:00
Rick Reilly
3da7730f23 Add necessary meta tag to the base html file, language attribute for a11y, and title 2018-12-19 11:20:33 -05:00
Richard I Reilly
bea36fb387 Merge pull request #57 from edx/kill-the-cats
Minor clean-up.
2018-12-18 18:29:09 -05:00
Robert Raposa
1408b0ae7e Minor clean-up. 2018-12-18 17:35:37 -05:00
Robert Raposa
7b817a4234 Merge pull request #56 from edx/dynamic-copyright
ARCH-321: Dynamic copyright in footer.
2018-12-18 17:13:42 -05:00
Richard I Reilly
a762c47d77 Merge pull request #37 from edx/add-footer
ARCH-308: Reimplement LMS footer in React in Gradebook.
2018-12-18 16:55:08 -05:00
Robert Raposa
aecb93c252 Dynamic copyright in footer.
ARCH-321
2018-12-18 16:51:27 -05:00
Robert Raposa
5a489b1bd5 Add footer matching LMS courses footer.
Note: There are still some follow-up tasks in ARCH-308
for analytics, i18n, etc. This gets the base functionality
in place.

ARCH-308
2018-12-18 14:19:01 -05:00
Simon Chen
5c642a1be5 Merge pull request #54 from edx/schen/alert
Update the color of the alert from red to yellow
2018-12-13 15:30:20 -05:00
Simon Chen
9a0e0e0ece Update the color of the alert from red to yellow 2018-12-13 14:50:38 -05:00
Alex Dusenbery
7486a342e2 Merge pull request #53 from edx/aed/show-attempted
Distinguish unattempted subsections.
2018-12-13 14:20:18 -05:00
Alex Dusenbery
fd807c54f8 Update README.md 2018-12-12 16:12:15 -05:00
Alex Dusenbery
9b894b502f Distinguish unattempted subsections. 2018-12-12 15:03:42 -05:00
Douglas Hall
afd9692f6b Merge pull request #51 from edx/douglashall/fix_prod_webpack_config
fix(webpack): use the appropriate css loader in prod webpack config
2018-12-12 10:56:04 -05:00
Douglas Hall
8f77dea222 fix(webpack): use the appropriate css loader in prod webpack config 2018-12-12 09:48:15 -05:00
Simon Chen
3bf3acaaec Merge pull request #50 from edx/schen/frozen_grades
fix(feat): Prevent editing if course grades are frozen
2018-12-11 13:45:23 -05:00
Simon Chen
f1ab3d0330 fix(feat): Prevent editing if course grades are frozen 2018-12-11 13:29:00 -05:00
Simon Chen
8d89bc16b1 Merge pull request #48 from edx/schen/update_wording
Update link wording
2018-12-10 15:29:01 -05:00
Alex Dusenbery
d93476c198 fix(UI): round numerator of grade ratios. 2018-12-10 14:26:19 -05:00
Simon Chen
b46d47286b Update link wording 2018-12-10 14:22:49 -05:00
Simon Chen
4aecfce14a Merge pull request #42 from edx/schen/test3
Add all the tests to reducers
2018-12-10 13:01:03 -05:00
Simon Chen
a812ee3816 Add all the tests to reducers 2018-12-07 12:11:21 -05:00
31 changed files with 7019 additions and 6902 deletions

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
.DS_Store
.eslintcache
.idea
node_modules
npm-debug.log
coverage

View File

@@ -23,7 +23,7 @@ npm i --save @edx/gradebook
After cloning the repository, run `make up-detached` in the `gradebook` directory - this will build and start the `gradebook` web application in a docker container.
The web application runs on port **1991**, so when you go to `http://localhost:1991` you should see the UI.
The web application runs on port **1991**, so when you go to `http://localhost:1991/course-v1:edX+DemoX+Demo_Course` you should see the UI (assuming you have such a Demo Course in your devstack). Note that you always have to provide a course id to actually see a gradebook.
If you don't, you can see the log messages for the docker container by executing `make logs` in the `gradebook` directory.

BIN
assets/edx-footer.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -4,8 +4,8 @@ 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');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = Merge.smart(commonConfig, {
mode: 'production',
@@ -38,29 +38,27 @@ module.exports = Merge.smart(commonConfig, {
// increases build time.
{
test: /(.scss|.css)$/,
use: ExtractTextPlugin.extract({
// creates style nodes from JS strings, only used if extracting fails
fallback: 'style-loader',
use: [
{
loader: 'css-loader', // translates CSS into CommonJS
options: {
sourceMap: true,
minimize: true,
},
use: [
MiniCssExtractPlugin.loader,
{
loader: 'css-loader', // translates CSS into CommonJS
options: {
sourceMap: true,
minimize: true,
},
{
loader: 'sass-loader', // compiles Sass to CSS
options: {
sourceMap: true,
includePaths: [
path.join(__dirname, '../node_modules'),
path.join(__dirname, '../src'),
],
},
},
'postcss-loader',
{
loader: 'sass-loader', // compiles Sass to CSS
options: {
sourceMap: true,
includePaths: [
path.join(__dirname, '../node_modules'),
path.join(__dirname, '../src'),
],
},
],
}),
},
],
},
// Webpack, by default, uses the url-loader for images and fonts that are required/included by
// files it processes, which just base64 encodes them and inlines them in the javascript
@@ -105,9 +103,8 @@ module.exports = Merge.smart(commonConfig, {
// Specify additional processing or side-effects done on the Webpack output bundles as a whole.
plugins: [
// Writes the extracted CSS from each entry to a file in the output directory.
new ExtractTextPlugin({
filename: '[name].min.css',
allChunks: true,
new MiniCssExtractPlugin({
filename: '[name].[chunkhash].css',
}),
// Generates an HTML file in the output directory.
new HtmlWebpackPlugin({

12821
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -33,7 +33,7 @@
"font-awesome": "^4.7.0",
"history": "^4.7.2",
"prop-types": "^15.5.10",
"query-string": "^6.2.0",
"query-string": "^5.1.1",
"react": "^16.2.0",
"react-dom": "^16.2.0",
"react-redux": "^5.0.7",
@@ -47,6 +47,7 @@
"whatwg-fetch": "^2.0.3"
},
"devDependencies": {
"autoprefixer": "^9.4.2",
"axios-mock-adapter": "^1.15.0",
"babel-cli": "^6.26.0",
"babel-eslint": "^8.2.2",
@@ -62,7 +63,6 @@
"enzyme-adapter-react-16": "^1.1.1",
"es-check": "^2.0.2",
"eslint-config-edx": "^4.0.3",
"extract-text-webpack-plugin": "^4.0.0-beta.0",
"fetch-mock": "^6.3.0",
"file-loader": "^1.1.9",
"html-webpack-harddisk-plugin": "^0.2.0",
@@ -71,7 +71,9 @@
"identity-obj-proxy": "^3.0.0",
"image-webpack-loader": "^4.2.0",
"jest": "^22.4.0",
"mini-css-extract-plugin": "^0.4.0",
"node-sass": "^4.7.2",
"postcss-loader": "^3.0.0",
"react-dev-utils": "^5.0.0",
"react-test-renderer": "^16.2.0",
"redux-mock-store": "^1.5.1",
@@ -79,8 +81,8 @@
"semantic-release": "^15.10.7",
"style-loader": "^0.20.2",
"travis-deploy-once": "^5.0.9",
"webpack": "^4.1.0",
"webpack-cli": "^2.0.10",
"webpack": "^4.25.1",
"webpack-cli": "^3.1.2",
"webpack-dev-server": "^3.1.0",
"webpack-merge": "^4.1.1"
},

View File

@@ -1,7 +1,9 @@
<!doctype html>
<html>
<html lang="en-us">
<head>
<title>Gradebook | edX</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
<div id="root"></div>

View File

@@ -7,3 +7,4 @@ $fa-font-path: "~font-awesome/fonts";
@import "~@edx/paragon/src/SearchField/SearchField";
@import "./components/Gradebook/gradebook";
@import "./components/Gradebook/footer";

View File

@@ -0,0 +1,122 @@
import React from 'react';
import { Hyperlink, Icon } from '@edx/paragon';
import EdXFooterLogo from '../../../assets/edx-footer.png';
export default function Footer() {
function renderLogo() {
return (
<img src={EdXFooterLogo} alt="edX logo" />
);
}
return (
<footer
role="contentinfo"
aria-label="Page Footer"
className="footer d-flex justify-content-center border-top py-3 px-4"
>
<div className="max-width-1180 d-grid">
<div className="area-1">
<Hyperlink destination="https://www.edx.org/" content={renderLogo()} aria-label="edX Home" />
</div>
<div className="area-2">
<h2>edX</h2>
<ul className="list-unstyled p-0 m-0">
<li><a href="https://www.edx.org/about-us">About</a></li>
<li><a href="https://www.edx.org/enterprise">edX for Business</a></li>
<li><a href="https://www.edx.org/affiliate-program">Affiliates</a></li>
<li><a href="http://open.edx.org">Open edX</a></li>
<li><a href="https://www.edx.org/careers">Careers</a></li>
<li><a href="https://www.edx.org/news-announcements">News</a></li>
</ul>
</div>
<div className="area-3">
<h2>Legal</h2>
<ul className="list-unstyled p-0 m-0">
<li><a href="https://www.edx.org/edx-terms-service">Terms of Service &amp; Honor Code</a></li>
<li><a href="https://www.edx.org/edx-privacy-policy">Privacy Policy</a></li>
<li><a href="https://www.edx.org/accessibility">Accessibility Policy</a></li>
<li><a href="https://www.edx.org/trademarks">Trademark Policy</a></li>
<li><a href="https://www.edx.org/sitemap">Sitemap</a></li>
</ul>
</div>
<div className="area-4">
<h2>Connect</h2>
<ul className="list-unstyled p-0 m-0">
<li><a href="https://www.edx.org/blog">Blog</a></li>
<li><a href="https://courses.edx.org/support/contact_us">Contact Us</a></li>
<li><a href="https://support.edx.org">Help Center</a></li>
<li><a href="https://www.edx.org/media-kit">Media Kit</a></li>
<li><a href="https://www.edx.org/donate">Donate</a></li>
</ul>
</div>
<div className="area-5">
<ul
className="d-flex flex-row justify-content-between list-unstyled max-width-222 p-0 mb-4"
>
{/* TODO: Use Paragon HyperLink with Icon. */}
{/* Would need to add rel to paragon if we still need it. */}
<li>
<a href="http://www.facebook.com/EdxOnline" title="Facebook" rel="noopener noreferrer" target="_blank">
<Icon className={['fa', 'fa-facebook-square', 'fa-2x']} screenReaderText="Like edX on Facebook" />
</a>
</li>
<li>
<a href="https://twitter.com/edXOnline" title="Twitter" rel="noopener noreferrer" target="_blank">
<Icon className={['fa', 'fa-twitter-square', 'fa-2x']} screenReaderText="Follow edX on Twitter" />
</a>
</li>
<li>
<a href="https://www.youtube.com/user/edxonline" title="Youtube" rel="noopener noreferrer" target="_blank">
<Icon className={['fa', 'fa-youtube-square', 'fa-2x']} screenReaderText="Subscribe to the edX YouTube channel" />
</a>
</li>
<li>
<a href="https://www.linkedin.com/company/edx" title="LinkedIn" rel="noopener noreferrer" target="_blank">
<Icon className={['fa', 'fa-linkedin-square', 'fa-2x']} screenReaderText="Follow edX on LinkedIn" />
</a>
</li>
<li>
<a href="https://plus.google.com/+edXOnline" title="Google+" rel="noopener noreferrer" target="_blank">
<Icon className={['fa', 'fa-google-plus-square', 'fa-2x']} screenReaderText="Follow edX on Google+" />
</a>
</li>
<li>
<a href="https://www.reddit.com/r/edx" title="Reddit" rel="noopener noreferrer" target="_blank">
<Icon className={['fa', 'fa-reddit-square', 'fa-2x']} screenReaderText="Subscribe to the edX subreddit" />
</a>
</li>
</ul>
<ul className="d-flex flex-row justify-content-between list-unstyled max-width-264 p-0 mb-5">
<li>
<a href="https://itunes.apple.com/us/app/edx/id945480667?mt=8" rel="noopener noreferrer" target="_blank">
<img
className="max-height-39"
alt="Download the edX mobile app from the Apple App Store"
src="https://prod-edxapp.edx-cdn.org/static/images/app/app_store_badge_135x40.d0558d910630.svg"
/>
</a>
</li>
<li>
<a href="https://play.google.com/store/apps/details?id=org.edx.mobile" rel="noopener noreferrer" target="_blank">
<img
className="max-height-39"
alt="Download the edX mobile app from Google Play"
src="https://prod-edxapp.edx-cdn.org/static/images/app/google_play_badge_45.6ea466e328da.png"
/>
</a>
</li>
</ul>
<p>
© 2012{(new Date().getFullYear())} edX Inc.
<br />
EdX, Open edX, and MicroMasters are registered trademarks of edX Inc.
| 粤ICP备17044299号-2
</p>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,165 @@
.max-width-222 {
max-width: 222px;
}
.max-width-264 {
max-width: 264px;
}
.max-width-1180 {
max-width: 1180px;
}
.max-height-39 {
max-height: 39px;
}
.d-grid {
display: grid;
}
$gray-footer: #fcfcfc;
$border-1: 1px solid $gray-200;
.footer {
background-color: $gray-footer;
.area-1 {
grid-column: 1;
grid-row: 1;
border-bottom: $border-1;
padding-bottom: 1rem;
}
.area-2 {
grid-column: 1;
grid-row: 2;
border-bottom: $border-1;
padding: 1rem 0;
}
.area-3 {
grid-column: 1;
grid-row: 3;
border-bottom: $border-1;
padding: 1rem 0;
}
.area-4 {
grid-column: 1;
grid-row: 4;
border-bottom: $border-1;
padding: 1rem 0;
}
.area-5 {
grid-column: 1;
grid-row: 5;
padding: 1rem 0;
}
@media only screen and (min-width: 717px) {
.area-1 {
grid-column: 1 / span 2;
grid-row: 1;
border-bottom: none;
padding: 1rem 0;
}
.area-2 {
grid-column: 1;
grid-row: 2;
}
.area-3 {
grid-column: 1;
grid-row: 3;
}
.area-4 {
grid-column: 1;
grid-row: 4;
border-bottom: none;
}
.area-5 {
grid-column: 2;
grid-row: 2 / span 3;
border-left: $border-1;
padding-left: 1rem;
margin-left: 1rem;
}
}
@media only screen and (min-width: 870px) {
.area-1 {
grid-column: 1;
grid-row: 1 / span 3;
border-right: $border-1;
padding-right: 1rem;
margin-right: 1rem;
}
.area-2 {
grid-column: 2;
grid-row: 1;
border-bottom: none;
border-right: $border-1;
padding-right: 1rem;
margin-right: 1rem;
}
.area-3 {
grid-column: 3;
grid-row: 1;
border-bottom: none;
border-right: $border-1;
padding-right: 1rem;
margin-right: 1rem;
}
.area-4 {
grid-column: 4;
grid-row: 1;
}
.area-5 {
grid-column: 2 / span 3;
grid-row: 2;
border: none;
margin-left: 0;
padding-left: 0;
}
}
@media only screen and (min-width: 1188px) {
.area-1 {
grid-column: 1 / span 1;
grid-row: 1;
}
.area-2 {
grid-column: 2;
grid-row: 1;
}
.area-3 {
grid-column: 3;
grid-row: 1;
}
.area-4 {
grid-column: 4;
grid-row: 1;
border-right: $border-1;
padding-right: 1rem;
margin-right: 1rem;
}
.area-5 {
grid-column: 5 / span 1;
grid-row: 1;
max-width: 372px;
}
}
}

View File

@@ -28,28 +28,27 @@ export default class Gradebook extends React.Component {
componentDidMount() {
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);
this.props.getAssignmentTypes(this.props.match.params.courseId);
this.props.getRoles(this.props.match.params.courseId, urlQuery);
}
setNewModalState = (userEntry, subsection) => {
let adjustedGradePossible = '';
let currentGradePossible = '';
if (subsection.attempted) {
adjustedGradePossible = ` / ${subsection.score_possible}`;
currentGradePossible = `/${subsection.score_possible}`;
}
this.setState({
modalModel: [{
username: userEntry.username,
currentGrade: `${subsection.score_earned}/${subsection.score_possible}`,
currentGrade: `${subsection.score_earned}${currentGradePossible}`,
adjustedGrade: (
<span>
<input
style={{ width: '25px' }}
type="text"
onChange={event => this.setState({ updateVal: event.target.value })}
/> / {subsection.score_possible}
/>{adjustedGradePossible}
</span>
),
assignmentName: `${subsection.subsection_name}`,
@@ -176,43 +175,58 @@ export default class Gradebook extends React.Component {
return 'Tracks';
};
roundPercentageGrade = percent => parseFloat(percent.toFixed(DECIMAL_PRECISION));
roundGrade = percent => parseFloat(percent.toFixed(DECIMAL_PRECISION));
formatter = {
percent: entries => entries.map((entry) => {
percent: (entries, areGradesFrozen) => 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)}
>
{this.roundPercentageGrade(subsection.percent * 100)}%
</button>);
if (areGradesFrozen) {
acc[subsection.label] = `${this.roundGrade(subsection.percent * 100)} %`;
} else {
acc[subsection.label] = (
<button
className="btn btn-header link-style"
onClick={() => this.setNewModalState(entry, subsection)}
>
{this.roundGrade(subsection.percent * 100)}%
</button>);
}
return acc;
}, {});
const totals = { total: `${this.roundPercentageGrade(entry.percent * 100)}%` };
const totals = { total: `${this.roundGrade(entry.percent * 100)}%` };
return Object.assign(results, assignments, totals);
}),
absolute: entries => entries.map((entry) => {
absolute: (entries, areGradesFrozen) => 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>);
const scoreEarned = this.roundGrade(subsection.score_earned);
const scorePossible = this.roundGrade(subsection.score_possible);
let label = `${scoreEarned}`;
if (subsection.attempted) {
label = `${scoreEarned}/${scorePossible}`;
}
if (areGradesFrozen) {
acc[subsection.label] = label;
} else {
acc[subsection.label] = (
<button
className="btn btn-header link-style"
onClick={() => this.setNewModalState(entry, subsection)}
>
{label}
</button>
);
}
return acc;
}, {});
const totals = { total: `${entry.percent * 100}/100` };
const totals = { total: `${this.roundGrade(entry.percent * 100)}/100` };
return Object.assign(results, assignments, totals);
}),
};
@@ -233,6 +247,16 @@ export default class Gradebook extends React.Component {
</a>
<h1>Gradebook</h1>
<h3> {this.props.match.params.courseId}</h3>
{ this.props.areGradesFrozen &&
<div className="alert alert-warning" role="alert" >
The grades for this course are now frozen. Editing of grades is no longer allowed.
</div>
}
{ !this.props.canUserViewGradebook &&
<div className="alert alert-warning" role="alert" >
You are not authorized to view the gradebook for this course. If you have a global role, please enroll in this course and try again.
</div>
}
<hr />
<div className="d-flex justify-content-between" >
<div>
@@ -245,6 +269,7 @@ export default class Gradebook extends React.Component {
type="radio"
name="score-view"
value="percent"
defaultChecked
onClick={() => this.props.toggleFormat('percent')}
/>
<label className="mr-2" htmlFor="score-view-percent">Percent</label>
@@ -300,7 +325,7 @@ export default class Gradebook extends React.Component {
</div>
<div>
<div style={{ marginLeft: '10px', marginBottom: '10px' }}>
<a href={`${this.lmsInstructorDashboardUrl(this.props.match.params.courseId)}#view-data_download`}>Download Grade Report</a>
<a href={`${this.lmsInstructorDashboardUrl(this.props.match.params.courseId)}#view-data_download`}>Generate Grade Report</a>
</div>
<SearchField
onSubmit={value => this.props.searchForUser(this.props.match.params.courseId, value, this.props.selectedCohort, this.props.selectedTrack)}
@@ -335,7 +360,7 @@ export default class Gradebook extends React.Component {
<div className="gbook">
<Table
columns={this.props.headings}
data={this.formatter[this.props.format](this.props.grades)}
data={this.formatter[this.props.format](this.props.grades, this.props.areGradesFrozen)}
tableSortable
defaultSortDirection="asc"
defaultSortedColumn="username"

View File

@@ -13,6 +13,7 @@ import {
import { fetchCohorts } from '../../data/actions/cohorts';
import { fetchTracks } from '../../data/actions/tracks';
import { fetchAssignmentTypes } from '../../data/actions/assignmentTypes';
import { getRoles } from '../../data/actions/roles';
const mapStateToProps = state => (
{
@@ -27,10 +28,22 @@ const mapStateToProps = state => (
prevPage: state.grades.prevPage,
nextPage: state.grades.nextPage,
assignmnetTypes: state.assignmentTypes.results,
showSpinner: state.grades.showSpinner,
areGradesFrozen: state.assignmentTypes.areGradesFrozen,
showSpinner: shouldShowSpinner(state),
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 => (
{
getUserGrades: (courseId, cohort, track) => {
@@ -63,6 +76,9 @@ const mapDispatchToProps = dispatch => (
updateBanner: (showSuccess) => {
dispatch(updateBanner(showSuccess));
},
getRoles: (matchParams, urlQuery) => {
dispatch(getRoles(matchParams, urlQuery));
},
}
);

View File

@@ -2,12 +2,14 @@ import {
STARTED_FETCHING_ASSIGNMENT_TYPES,
GOT_ASSIGNMENT_TYPES,
ERROR_FETCHING_ASSIGNMENT_TYPES,
GOT_ARE_GRADES_FROZEN,
} from '../constants/actionTypes/assignmentTypes';
import LmsApiService from '../services/LmsApiService';
const startedFetchingAssignmentTypes = () => ({ type: STARTED_FETCHING_ASSIGNMENT_TYPES });
const errorFetchingAssignmentTypes = () => ({ type: ERROR_FETCHING_ASSIGNMENT_TYPES });
const gotAssignmentTypes = assignmentTypes => ({ type: GOT_ASSIGNMENT_TYPES, assignmentTypes });
const gotGradesFrozen = areGradesFrozen => ({ type: GOT_ARE_GRADES_FROZEN, areGradesFrozen });
const fetchAssignmentTypes = courseId => (
(dispatch) => {
@@ -16,6 +18,7 @@ const fetchAssignmentTypes = courseId => (
.then(response => response.data)
.then((data) => {
dispatch(gotAssignmentTypes(Object.keys(data.assignment_types)));
dispatch(gotGradesFrozen(data.grades_frozen));
})
.catch(() => {
dispatch(errorFetchingAssignmentTypes());

View File

@@ -9,6 +9,7 @@ import {
STARTED_FETCHING_ASSIGNMENT_TYPES,
GOT_ASSIGNMENT_TYPES,
ERROR_FETCHING_ASSIGNMENT_TYPES,
GOT_ARE_GRADES_FROZEN,
} from '../constants/actionTypes/assignmentTypes';
const mockStore = configureMockStore([thunk]);
@@ -21,29 +22,30 @@ describe('actions', () => {
describe('fetchAssignmentTypes', () => {
const courseId = 'course-v1:edX+DemoX+Demo_Course';
it('dispatches success action after fetching fetchAssignmentTypes', () => {
const responseData = {
assignment_types: {
Exam: {
drop_count: 0,
min_count: 1,
short_label: 'Exam',
type: 'Exam',
weight: 0.25,
},
Homework: {
drop_count: 1,
min_count: 3,
short_label: 'Ex',
type: 'Homework',
weight: 0.75,
},
const responseData = {
assignment_types: {
Exam: {
drop_count: 0,
min_count: 1,
short_label: 'Exam',
type: 'Exam',
weight: 0.25,
},
};
Homework: {
drop_count: 1,
min_count: 3,
short_label: 'Ex',
type: 'Homework',
weight: 0.75,
},
},
grades_frozen: false,
};
it('dispatches success action after fetching fetchAssignmentTypes', () => {
const expectedActions = [
{ type: STARTED_FETCHING_ASSIGNMENT_TYPES },
{ type: GOT_ASSIGNMENT_TYPES, assignmentTypes: Object.keys(responseData.assignment_types) },
{ type: GOT_ARE_GRADES_FROZEN, areGradesFrozen: responseData.grades_frozen },
];
const store = mockStore();
@@ -69,5 +71,21 @@ describe('actions', () => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it('dispatches frozen grade action with True value after fetching', () => {
const expectedActions = [
{ type: STARTED_FETCHING_ASSIGNMENT_TYPES },
{ type: GOT_ASSIGNMENT_TYPES, assignmentTypes: Object.keys(responseData.assignment_types) },
{ type: GOT_ARE_GRADES_FROZEN, areGradesFrozen: true },
];
const store = mockStore();
responseData.grades_frozen = true;
axiosMock.onGet(`${configuration.LMS_BASE_URL}/api/grades/v1/gradebook/${courseId}/grading-info?graded_only=true`)
.replyOnce(200, JSON.stringify(responseData));
return store.dispatch(fetchAssignmentTypes(courseId)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
});
});

38
src/data/actions/roles.js Normal file
View File

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

View File

@@ -0,0 +1,103 @@
import configureMockStore from 'redux-mock-store';
import MockAdapter from 'axios-mock-adapter';
import thunk from 'redux-thunk';
import apiClient from '../apiClient';
import { configuration } from '../../config';
import { getRoles } from './roles';
import {
GOT_ROLES,
ERROR_FETCHING_ROLES,
} from '../constants/actionTypes/roles';
import { STARTED_FETCHING_GRADES } from '../constants/actionTypes/grades';
import { STARTED_FETCHING_TRACKS } from '../constants/actionTypes/tracks';
import { STARTED_FETCHING_COHORTS } from '../constants/actionTypes/cohorts';
import { STARTED_FETCHING_ASSIGNMENT_TYPES } from '../constants/actionTypes/assignmentTypes';
const mockStore = configureMockStore([thunk]);
const axiosMock = new MockAdapter(apiClient);
const rolesUrl = `${configuration.LMS_BASE_URL}/api/enrollment/v1/roles/`;
const course1Id = 'course-v1:edX+DemoX+Demo_Course';
const course2Id = 'course-v1:edX+DemoX+Demo_Course_2';
function makeRoleObj(courseId, role) {
return {
course_id: courseId,
role: role,
}
};
const course1StaffRole = makeRoleObj(course1Id, "staff");
const course1DummyRole = makeRoleObj(course1Id, "dummy");
const course2StaffRole = makeRoleObj(course2Id, "staff");
const course2DummyRole = makeRoleObj(course2Id, "dummy");
const urlParams = { cohort: null, track: null };
describe('actions', () => {
afterEach(() => {
axiosMock.reset();
});
describe('getRoles', () => {
it('dispatches got_roles action and subsequent actions after fetching role that allows gradebook', () => {
const expectedActions = [
{ type: GOT_ROLES, canUserViewGradebook: true },
{ type: STARTED_FETCHING_GRADES },
{ type: STARTED_FETCHING_TRACKS },
{ type: STARTED_FETCHING_COHORTS },
{ type: STARTED_FETCHING_ASSIGNMENT_TYPES },
];
const store = mockStore();
axiosMock.onGet(rolesUrl)
.replyOnce(200, JSON.stringify([course1StaffRole, course2DummyRole]));
return store.dispatch(getRoles(course1Id, urlParams)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it('dispatches got_roles action and no other actions after fetching role that disallows gradebook', () => {
const expectedActions = [
{ type: GOT_ROLES, canUserViewGradebook: false },
];
const store = mockStore();
axiosMock.onGet(rolesUrl)
.replyOnce(200, JSON.stringify([course1DummyRole, course2StaffRole]));
return store.dispatch(getRoles(course1Id, urlParams)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it('dispatches got_roles action and no other actions after fetching empty roles', () => {
const expectedActions = [
{ type: GOT_ROLES, canUserViewGradebook: false },
];
const store = mockStore();
axiosMock.onGet(rolesUrl)
.replyOnce(200, JSON.stringify([]));
return store.dispatch(getRoles(course1Id, urlParams)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it('dispatches error action after getting an error when trying to get roles', () => {
const expectedActions = [
{ type: ERROR_FETCHING_ROLES },
];
const store = mockStore();
axiosMock.onGet(rolesUrl).replyOnce(400);
return store.dispatch(getRoles(course1Id, urlParams)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
});
});

View File

@@ -1,10 +1,12 @@
const STARTED_FETCHING_ASSIGNMENT_TYPES = 'STARTED_FETCHING_ASSIGNMENT_TYPES';
const GOT_ASSIGNMENT_TYPES = 'GOT_ASSIGNMENT_TYPES';
const ERROR_FETCHING_ASSIGNMENT_TYPES = 'ERROR_FETCHING_ASSIGNMENT_TYPES';
const GOT_ARE_GRADES_FROZEN = 'GOT_ARE_GRADES_FROZEN';
export {
STARTED_FETCHING_ASSIGNMENT_TYPES,
GOT_ASSIGNMENT_TYPES,
ERROR_FETCHING_ASSIGNMENT_TYPES,
GOT_ARE_GRADES_FROZEN,
};

View File

@@ -0,0 +1,7 @@
const GOT_ROLES = 'GOT_ROLES';
const ERROR_FETCHING_ROLES = 'ERROR_FETCHING_ROLES'
export {
GOT_ROLES,
ERROR_FETCHING_ROLES,
};

View File

@@ -2,6 +2,7 @@ import {
STARTED_FETCHING_ASSIGNMENT_TYPES,
ERROR_FETCHING_ASSIGNMENT_TYPES,
GOT_ASSIGNMENT_TYPES,
GOT_ARE_GRADES_FROZEN,
} from '../constants/actionTypes/assignmentTypes';
const initialState = {
@@ -18,6 +19,7 @@ const assignmentTypes = (state = initialState, action) => {
...state,
results: action.assignmentTypes,
errorFetching: false,
finishedFetching: true,
};
case STARTED_FETCHING_ASSIGNMENT_TYPES:
return {
@@ -30,6 +32,13 @@ const assignmentTypes = (state = initialState, action) => {
finishedFetching: true,
errorFetching: true,
};
case GOT_ARE_GRADES_FROZEN:
return {
...state,
areGradesFrozen: action.areGradesFrozen,
errorFetching: false,
finishedFetching: true,
};
default:
return state;
}

View File

@@ -0,0 +1,68 @@
import assignmentTypes from './assignmentTypes';
import {
STARTED_FETCHING_ASSIGNMENT_TYPES,
ERROR_FETCHING_ASSIGNMENT_TYPES,
GOT_ASSIGNMENT_TYPES,
GOT_ARE_GRADES_FROZEN,
} from '../constants/actionTypes/assignmentTypes';
const initialState = {
results: [],
startedFetching: false,
errorFetching: false,
};
const assignmentTypesData = ['Exam', 'Homework'];
describe('assignmentTypes reducer', () => {
it('has initial state', () => {
expect(assignmentTypes(undefined, {})).toEqual(initialState);
});
it('updates fetch assignmentTypes request state', () => {
const expected = {
...initialState,
startedFetching: true,
};
expect(assignmentTypes(undefined, {
type: STARTED_FETCHING_ASSIGNMENT_TYPES,
})).toEqual(expected);
});
it('updates fetch assignmentTypes success state', () => {
const expected = {
...initialState,
results: assignmentTypesData,
errorFetching: false,
finishedFetching: true,
};
expect(assignmentTypes(undefined, {
type: GOT_ASSIGNMENT_TYPES,
assignmentTypes: assignmentTypesData,
})).toEqual(expected);
});
it('updates fetch assignmentTypes failure state', () => {
const expected = {
...initialState,
errorFetching: true,
finishedFetching: true,
};
expect(assignmentTypes(undefined, {
type: ERROR_FETCHING_ASSIGNMENT_TYPES,
})).toEqual(expected);
});
it('updates areGradesFrozen success state', () => {
const expected = {
...initialState,
errorFetching: false,
finishedFetching: true,
areGradesFrozen: true,
};
expect(assignmentTypes(undefined, {
type: GOT_ARE_GRADES_FROZEN,
areGradesFrozen: true,
})).toEqual(expected);
});
});

View File

@@ -29,7 +29,7 @@ const cohortsData = [
user_partition_id: null,
}];
describe('dashboardAnalytics reducer', () => {
describe('cohorts reducer', () => {
it('has initial state', () => {
expect(cohorts(undefined, {})).toEqual(initialState);
});

View File

@@ -4,7 +4,6 @@ import {
GOT_GRADES,
TOGGLE_GRADE_FORMAT,
FILTER_COLUMNS,
GRADE_UPDATE_SUCCESS,
UPDATE_BANNER,
SORT_GRADES,
} from '../constants/actionTypes/grades';

View File

@@ -0,0 +1,186 @@
import grades from './grades';
import {
STARTED_FETCHING_GRADES,
ERROR_FETCHING_GRADES,
GOT_GRADES,
TOGGLE_GRADE_FORMAT,
FILTER_COLUMNS,
UPDATE_BANNER,
SORT_GRADES,
} from '../constants/actionTypes/grades';
const initialState = {
results: [],
headings: [],
startedFetching: false,
finishedFetching: false,
errorFetching: false,
gradeFormat: 'percent',
showSuccess: false,
prevPage: null,
nextPage: null,
showSpinner: true,
};
const courseId = 'course-v1:edX+DemoX+Demo_Course';
const headingsData = [
{ name: 'exam' },
{ name: 'homework2' },
];
const gradesData = [
{
course_id: courseId,
email: 'user1@example.com',
username: 'user1',
user_id: 1,
percent: 0.5,
letter_grade: null,
section_breakdown: [
{
subsection_name: 'Demo Course Overview',
score_earned: 0,
score_possible: 0,
percent: 0,
displayed_value: '0.00',
grade_description: '(0.00/0.00)',
},
{
subsection_name: 'Example Week 1: Getting Started',
score_earned: 1,
score_possible: 1,
percent: 1,
displayed_value: '1.00',
grade_description: '(0.00/0.00)',
},
],
},
{
course_id: courseId,
email: 'user22@example.com',
username: 'user22',
user_id: 22,
percent: 0,
letter_grade: null,
section_breakdown: [
{
subsection_name: 'Demo Course Overview',
score_earned: 0,
score_possible: 0,
percent: 0,
displayed_value: '0.00',
grade_description: '(0.00/0.00)',
},
{
subsection_name: 'Example Week 1: Getting Started',
score_earned: 1,
score_possible: 1,
percent: 0,
displayed_value: '0.00',
grade_description: '(0.00/0.00)',
},
],
}];
describe('grades reducer', () => {
it('has initial state', () => {
expect(grades(undefined, {})).toEqual(initialState);
});
it('updates fetch grades request state', () => {
const expected = {
...initialState,
startedFetching: true,
showSpinner: true,
};
expect(grades(undefined, {
type: STARTED_FETCHING_GRADES,
})).toEqual(expected);
});
it('updates fetch grades success state', () => {
const expectedPrev = 'testPrevUrl';
const expectedNext = 'testNextUrl';
const expectedTrack = 'verified';
const expectedCohortId = 2;
const expected = {
...initialState,
results: gradesData,
headings: headingsData,
errorFetching: false,
finishedFetching: true,
selectedTrack: expectedTrack,
selectedCohort: expectedCohortId,
prevPage: expectedPrev,
nextPage: expectedNext,
showSpinner: false,
};
expect(grades(undefined, {
type: GOT_GRADES,
grades: gradesData,
headings: headingsData,
prev: expectedPrev,
next: expectedNext,
track: expectedTrack,
cohort: expectedCohortId,
showSpinner: true,
})).toEqual(expected);
});
it('updates toggle grade format state success', () => {
const formatTypeData = 'percent';
const expected = {
...initialState,
gradeFormat: formatTypeData,
};
expect(grades(undefined, {
type: TOGGLE_GRADE_FORMAT,
formatType: formatTypeData,
})).toEqual(expected);
});
it('updates filter columns state success', () => {
const expectedHeadings = headingsData;
const expected = {
...initialState,
headings: expectedHeadings,
};
expect(grades(undefined, {
type: FILTER_COLUMNS,
headings: expectedHeadings,
})).toEqual(expected);
});
it('updates update_banner state success', () => {
const expectedShowSuccess = true;
const expected = {
...initialState,
showSuccess: expectedShowSuccess,
};
expect(grades(undefined, {
type: UPDATE_BANNER,
showSuccess: expectedShowSuccess,
})).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', () => {
const expected = {
...initialState,
errorFetching: true,
finishedFetching: true,
};
expect(grades(undefined, {
type: ERROR_FETCHING_GRADES,
})).toEqual(expected);
});
});

View File

@@ -4,12 +4,14 @@ import cohorts from './cohorts';
import grades from './grades';
import tracks from './tracks';
import assignmentTypes from './assignmentTypes';
import roles from './roles';
const rootReducer = combineReducers({
grades,
cohorts,
tracks,
assignmentTypes,
roles,
});
export default rootReducer;

View File

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

View File

@@ -0,0 +1,47 @@
import roles from './roles';
import {
ERROR_FETCHING_ROLES,
GOT_ROLES,
} from '../constants/actionTypes/roles';
const initialState = {
canUserViewGradebook: null,
};
describe('tracks reducer', () => {
it('has initial state', () => {
expect(roles(undefined, {})).toEqual(initialState);
});
it('updates canUserViewGradebook to true', () => {
const expected = {
...initialState,
canUserViewGradebook: true
};
expect(roles(undefined, {
type: GOT_ROLES,
canUserViewGradebook: true,
})).toEqual(expected);
});
it('updates canUserViewGradebook to false', () => {
const expected = {
...initialState,
canUserViewGradebook: false
};
expect(roles(undefined, {
type: GOT_ROLES,
canUserViewGradebook: false,
})).toEqual(expected);
});
it('updates fetch roles failure state', () => {
const expected = {
...initialState,
canUserViewGradebook: false,
};
expect(roles(undefined, {
type: ERROR_FETCHING_ROLES,
})).toEqual(expected);
});
});

View File

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

View File

@@ -0,0 +1,76 @@
import tracks from './tracks';
import {
STARTED_FETCHING_TRACKS,
ERROR_FETCHING_TRACKS,
GOT_TRACKS,
} from '../constants/actionTypes/tracks';
const initialState = {
results: [],
startedFetching: false,
errorFetching: false,
};
const tracksData = [
{
slug: 'audit',
name: 'Audit',
min_price: 0,
suggested_prices: '',
currency: 'usd',
expiration_datetime: null,
description: null,
sku: '68EFFFF',
bulk_sku: null,
},
{
slug: 'verified',
name: 'Verified Certificate',
min_price: 100,
suggested_prices: '',
currency: 'usd',
expiration_datetime: '2021-05-04T18:08:12.644361Z',
description: null,
sku: '8CF08E5',
bulk_sku: 'A5B6DBE',
}];
describe('tracks reducer', () => {
it('has initial state', () => {
expect(tracks(undefined, {})).toEqual(initialState);
});
it('updates fetch tracks request state', () => {
const expected = {
...initialState,
startedFetching: true,
};
expect(tracks(undefined, {
type: STARTED_FETCHING_TRACKS,
})).toEqual(expected);
});
it('updates fetch tracks success state', () => {
const expected = {
...initialState,
results: tracksData,
errorFetching: false,
finishedFetching: true,
};
expect(tracks(undefined, {
type: GOT_TRACKS,
tracks: tracksData,
})).toEqual(expected);
});
it('updates fetch tracks failure state', () => {
const expected = {
...initialState,
errorFetching: true,
finishedFetching: true,
};
expect(tracks(undefined, {
type: ERROR_FETCHING_TRACKS,
})).toEqual(expected);
});
});

View File

@@ -59,6 +59,11 @@ class LmsApiService {
const assignmentTypesUrl = `${LmsApiService.baseUrl}/api/grades/v1/gradebook/${courseId}/grading-info?graded_only=true`;
return apiClient.get(assignmentTypesUrl);
}
static fetchUserRoles(){
var rolesUrl = `${LmsApiService.baseUrl}/api/enrollment/v1/roles/`;
return apiClient.get(rolesUrl)
}
}
export default LmsApiService;

View File

@@ -5,11 +5,14 @@ import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
import { Provider } from 'react-redux';
import apiClient from './data/apiClient';
import Footer from './components/Gradebook/footer';
import GradebookPage from './containers/GradebookPage';
import Header from './components/Header';
import store from './data/store';
import './App.scss';
var courseId = window.location.pathname.substring(1);
const App = () => (
<Provider store={store}>
<Router>
@@ -20,6 +23,7 @@ const App = () => (
<Route exact path="/:courseId" component={GradebookPage} />
</Switch>
</main>
<Footer />
</div>
</Router>
</Provider>

7
src/postcss.config.js Normal file
View File

@@ -0,0 +1,7 @@
/* I'm here to allow autoprefixing in webpack.prod.config.js */
module.exports = {
plugins: [
require('autoprefixer')({ grid: true, browsers: ['>1%'] }),
],
};