Compare commits
38 Commits
v1.4.3
...
rir/header
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5b025aef5 | ||
|
|
0b507c558a | ||
|
|
86092f22b3 | ||
|
|
c8cb07228f | ||
|
|
a1946e7bc4 | ||
|
|
01d80e0fff | ||
|
|
e6da087e83 | ||
|
|
ac5eaed5cb | ||
|
|
88997ca242 | ||
|
|
d5daf9086f | ||
|
|
8a01a60d63 | ||
|
|
66cdcc7f2a | ||
|
|
0c73d66666 | ||
|
|
28e3e6d0e6 | ||
|
|
6473bafa3d | ||
|
|
167901e665 | ||
|
|
50a0d6e579 | ||
|
|
a284c286f5 | ||
|
|
dd967e703c | ||
|
|
725dc071e3 | ||
|
|
3da7730f23 | ||
|
|
bea36fb387 | ||
|
|
1408b0ae7e | ||
|
|
7b817a4234 | ||
|
|
a762c47d77 | ||
|
|
aecb93c252 | ||
|
|
5a489b1bd5 | ||
|
|
5c642a1be5 | ||
|
|
9a0e0e0ece | ||
|
|
7486a342e2 | ||
|
|
fd807c54f8 | ||
|
|
9b894b502f | ||
|
|
afd9692f6b | ||
|
|
8f77dea222 | ||
|
|
3bf3acaaec | ||
|
|
f1ab3d0330 | ||
|
|
8d89bc16b1 | ||
|
|
b46d47286b |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.eslintcache
|
.eslintcache
|
||||||
|
.idea
|
||||||
node_modules
|
node_modules
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
coverage
|
coverage
|
||||||
|
|||||||
@@ -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.
|
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.
|
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
BIN
assets/edx-footer.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
@@ -4,8 +4,8 @@ const Merge = require('webpack-merge');
|
|||||||
const commonConfig = require('./webpack.common.config.js');
|
const commonConfig = require('./webpack.common.config.js');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const webpack = require('webpack');
|
const webpack = require('webpack');
|
||||||
const ExtractTextPlugin = require('extract-text-webpack-plugin');
|
|
||||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||||
|
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
||||||
|
|
||||||
module.exports = Merge.smart(commonConfig, {
|
module.exports = Merge.smart(commonConfig, {
|
||||||
mode: 'production',
|
mode: 'production',
|
||||||
@@ -38,29 +38,27 @@ module.exports = Merge.smart(commonConfig, {
|
|||||||
// increases build time.
|
// increases build time.
|
||||||
{
|
{
|
||||||
test: /(.scss|.css)$/,
|
test: /(.scss|.css)$/,
|
||||||
use: ExtractTextPlugin.extract({
|
use: [
|
||||||
// creates style nodes from JS strings, only used if extracting fails
|
MiniCssExtractPlugin.loader,
|
||||||
fallback: 'style-loader',
|
{
|
||||||
use: [
|
loader: 'css-loader', // translates CSS into CommonJS
|
||||||
{
|
options: {
|
||||||
loader: 'css-loader', // translates CSS into CommonJS
|
sourceMap: true,
|
||||||
options: {
|
minimize: true,
|
||||||
sourceMap: true,
|
|
||||||
minimize: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
},
|
||||||
loader: 'sass-loader', // compiles Sass to CSS
|
'postcss-loader',
|
||||||
options: {
|
{
|
||||||
sourceMap: true,
|
loader: 'sass-loader', // compiles Sass to CSS
|
||||||
includePaths: [
|
options: {
|
||||||
path.join(__dirname, '../node_modules'),
|
sourceMap: true,
|
||||||
path.join(__dirname, '../src'),
|
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
|
// 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
|
// 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.
|
// Specify additional processing or side-effects done on the Webpack output bundles as a whole.
|
||||||
plugins: [
|
plugins: [
|
||||||
// Writes the extracted CSS from each entry to a file in the output directory.
|
// Writes the extracted CSS from each entry to a file in the output directory.
|
||||||
new ExtractTextPlugin({
|
new MiniCssExtractPlugin({
|
||||||
filename: '[name].min.css',
|
filename: '[name].[chunkhash].css',
|
||||||
allChunks: true,
|
|
||||||
}),
|
}),
|
||||||
// Generates an HTML file in the output directory.
|
// Generates an HTML file in the output directory.
|
||||||
new HtmlWebpackPlugin({
|
new HtmlWebpackPlugin({
|
||||||
|
|||||||
12821
package-lock.json
generated
12821
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -33,7 +33,7 @@
|
|||||||
"font-awesome": "^4.7.0",
|
"font-awesome": "^4.7.0",
|
||||||
"history": "^4.7.2",
|
"history": "^4.7.2",
|
||||||
"prop-types": "^15.5.10",
|
"prop-types": "^15.5.10",
|
||||||
"query-string": "^6.2.0",
|
"query-string": "^5.1.1",
|
||||||
"react": "^16.2.0",
|
"react": "^16.2.0",
|
||||||
"react-dom": "^16.2.0",
|
"react-dom": "^16.2.0",
|
||||||
"react-redux": "^5.0.7",
|
"react-redux": "^5.0.7",
|
||||||
@@ -47,6 +47,7 @@
|
|||||||
"whatwg-fetch": "^2.0.3"
|
"whatwg-fetch": "^2.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"autoprefixer": "^9.4.2",
|
||||||
"axios-mock-adapter": "^1.15.0",
|
"axios-mock-adapter": "^1.15.0",
|
||||||
"babel-cli": "^6.26.0",
|
"babel-cli": "^6.26.0",
|
||||||
"babel-eslint": "^8.2.2",
|
"babel-eslint": "^8.2.2",
|
||||||
@@ -62,7 +63,6 @@
|
|||||||
"enzyme-adapter-react-16": "^1.1.1",
|
"enzyme-adapter-react-16": "^1.1.1",
|
||||||
"es-check": "^2.0.2",
|
"es-check": "^2.0.2",
|
||||||
"eslint-config-edx": "^4.0.3",
|
"eslint-config-edx": "^4.0.3",
|
||||||
"extract-text-webpack-plugin": "^4.0.0-beta.0",
|
|
||||||
"fetch-mock": "^6.3.0",
|
"fetch-mock": "^6.3.0",
|
||||||
"file-loader": "^1.1.9",
|
"file-loader": "^1.1.9",
|
||||||
"html-webpack-harddisk-plugin": "^0.2.0",
|
"html-webpack-harddisk-plugin": "^0.2.0",
|
||||||
@@ -71,7 +71,9 @@
|
|||||||
"identity-obj-proxy": "^3.0.0",
|
"identity-obj-proxy": "^3.0.0",
|
||||||
"image-webpack-loader": "^4.2.0",
|
"image-webpack-loader": "^4.2.0",
|
||||||
"jest": "^22.4.0",
|
"jest": "^22.4.0",
|
||||||
|
"mini-css-extract-plugin": "^0.4.0",
|
||||||
"node-sass": "^4.7.2",
|
"node-sass": "^4.7.2",
|
||||||
|
"postcss-loader": "^3.0.0",
|
||||||
"react-dev-utils": "^5.0.0",
|
"react-dev-utils": "^5.0.0",
|
||||||
"react-test-renderer": "^16.2.0",
|
"react-test-renderer": "^16.2.0",
|
||||||
"redux-mock-store": "^1.5.1",
|
"redux-mock-store": "^1.5.1",
|
||||||
@@ -79,8 +81,8 @@
|
|||||||
"semantic-release": "^15.10.7",
|
"semantic-release": "^15.10.7",
|
||||||
"style-loader": "^0.20.2",
|
"style-loader": "^0.20.2",
|
||||||
"travis-deploy-once": "^5.0.9",
|
"travis-deploy-once": "^5.0.9",
|
||||||
"webpack": "^4.1.0",
|
"webpack": "^4.25.1",
|
||||||
"webpack-cli": "^2.0.10",
|
"webpack-cli": "^3.1.2",
|
||||||
"webpack-dev-server": "^3.1.0",
|
"webpack-dev-server": "^3.1.0",
|
||||||
"webpack-merge": "^4.1.1"
|
"webpack-merge": "^4.1.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html>
|
<html lang="en-us">
|
||||||
<head>
|
<head>
|
||||||
|
<title>Gradebook | edX</title>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -5,5 +5,8 @@ $fa-font-path: "~font-awesome/fonts";
|
|||||||
@import "~font-awesome/scss/font-awesome";
|
@import "~font-awesome/scss/font-awesome";
|
||||||
|
|
||||||
@import "~@edx/paragon/src/SearchField/SearchField";
|
@import "~@edx/paragon/src/SearchField/SearchField";
|
||||||
|
@import "./components/Gradebook/gradebook";
|
||||||
|
|
||||||
@import "./components/Gradebook/gradebook";
|
@import "./components/Gradebook/gradebook";
|
||||||
|
@import "./components/Gradebook/footer";
|
||||||
|
@import "./components/Header/header";
|
||||||
|
|||||||
122
src/components/Gradebook/footer.jsx
Normal file
122
src/components/Gradebook/footer.jsx
Normal 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 & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
165
src/components/Gradebook/footer.scss
Normal file
165
src/components/Gradebook/footer.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,28 +28,27 @@ export default class Gradebook extends React.Component {
|
|||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
const urlQuery = queryString.parse(this.props.location.search);
|
const urlQuery = queryString.parse(this.props.location.search);
|
||||||
this.props.getUserGrades(
|
this.props.getRoles(this.props.match.params.courseId, urlQuery);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setNewModalState = (userEntry, subsection) => {
|
setNewModalState = (userEntry, subsection) => {
|
||||||
|
let adjustedGradePossible = '';
|
||||||
|
let currentGradePossible = '';
|
||||||
|
if (subsection.attempted) {
|
||||||
|
adjustedGradePossible = ` / ${subsection.score_possible}`;
|
||||||
|
currentGradePossible = `/${subsection.score_possible}`;
|
||||||
|
}
|
||||||
this.setState({
|
this.setState({
|
||||||
modalModel: [{
|
modalModel: [{
|
||||||
username: userEntry.username,
|
username: userEntry.username,
|
||||||
currentGrade: `${subsection.score_earned}/${subsection.score_possible}`,
|
currentGrade: `${subsection.score_earned}${currentGradePossible}`,
|
||||||
adjustedGrade: (
|
adjustedGrade: (
|
||||||
<span>
|
<span>
|
||||||
<input
|
<input
|
||||||
style={{ width: '25px' }}
|
style={{ width: '25px' }}
|
||||||
type="text"
|
type="text"
|
||||||
onChange={event => this.setState({ updateVal: event.target.value })}
|
onChange={event => this.setState({ updateVal: event.target.value })}
|
||||||
/> / {subsection.score_possible}
|
/>{adjustedGradePossible}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
assignmentName: `${subsection.subsection_name}`,
|
assignmentName: `${subsection.subsection_name}`,
|
||||||
@@ -179,36 +178,51 @@ export default class Gradebook extends React.Component {
|
|||||||
roundGrade = percent => parseFloat(percent.toFixed(DECIMAL_PRECISION));
|
roundGrade = percent => parseFloat(percent.toFixed(DECIMAL_PRECISION));
|
||||||
|
|
||||||
formatter = {
|
formatter = {
|
||||||
percent: entries => 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)
|
.filter(section => section.is_graded)
|
||||||
.reduce((acc, subsection) => {
|
.reduce((acc, subsection) => {
|
||||||
acc[subsection.label] = (
|
if (areGradesFrozen) {
|
||||||
<button
|
acc[subsection.label] = `${this.roundGrade(subsection.percent * 100)} %`;
|
||||||
className="btn btn-header link-style"
|
} else {
|
||||||
onClick={() => this.setNewModalState(entry, subsection)}
|
acc[subsection.label] = (
|
||||||
>
|
<button
|
||||||
{this.roundGrade(subsection.percent * 100)}%
|
className="btn btn-header link-style"
|
||||||
</button>);
|
onClick={() => this.setNewModalState(entry, subsection)}
|
||||||
|
>
|
||||||
|
{this.roundGrade(subsection.percent * 100)}%
|
||||||
|
</button>);
|
||||||
|
}
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
const totals = { total: `${this.roundGrade(entry.percent * 100)}%` };
|
const totals = { total: `${this.roundGrade(entry.percent * 100)}%` };
|
||||||
return Object.assign(results, assignments, totals);
|
return Object.assign(results, assignments, totals);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
absolute: entries => 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)
|
.filter(section => section.is_graded)
|
||||||
.reduce((acc, subsection) => {
|
.reduce((acc, subsection) => {
|
||||||
acc[subsection.label] = (
|
const scoreEarned = this.roundGrade(subsection.score_earned);
|
||||||
<button
|
const scorePossible = this.roundGrade(subsection.score_possible);
|
||||||
className="btn btn-header link-style"
|
let label = `${scoreEarned}`;
|
||||||
onClick={() => this.setNewModalState(entry, subsection)}
|
if (subsection.attempted) {
|
||||||
>
|
label = `${scoreEarned}/${scorePossible}`;
|
||||||
{this.roundGrade(subsection.score_earned)}/{this.roundGrade(subsection.score_possible)}
|
}
|
||||||
</button>);
|
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;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
@@ -233,6 +247,16 @@ export default class Gradebook extends React.Component {
|
|||||||
</a>
|
</a>
|
||||||
<h1>Gradebook</h1>
|
<h1>Gradebook</h1>
|
||||||
<h3> {this.props.match.params.courseId}</h3>
|
<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 />
|
<hr />
|
||||||
<div className="d-flex justify-content-between" >
|
<div className="d-flex justify-content-between" >
|
||||||
<div>
|
<div>
|
||||||
@@ -245,6 +269,7 @@ export default class Gradebook extends React.Component {
|
|||||||
type="radio"
|
type="radio"
|
||||||
name="score-view"
|
name="score-view"
|
||||||
value="percent"
|
value="percent"
|
||||||
|
defaultChecked
|
||||||
onClick={() => this.props.toggleFormat('percent')}
|
onClick={() => this.props.toggleFormat('percent')}
|
||||||
/>
|
/>
|
||||||
<label className="mr-2" htmlFor="score-view-percent">Percent</label>
|
<label className="mr-2" htmlFor="score-view-percent">Percent</label>
|
||||||
@@ -274,33 +299,29 @@ export default class Gradebook extends React.Component {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
{(this.props.tracks.length > 0 || this.props.cohorts.length > 0) &&
|
<div className="student-filters">
|
||||||
<div className="student-filters">
|
<span className="label">
|
||||||
<span className="label">
|
Student Groups:
|
||||||
Student Groups:
|
</span>
|
||||||
</span>
|
<InputSelect
|
||||||
{this.props.tracks.length > 0 &&
|
name="Tracks"
|
||||||
<InputSelect
|
disabled={this.props.tracks.length === 0}
|
||||||
name="Tracks"
|
value={this.mapSelectedTrackEntry(this.props.selectedTrack)}
|
||||||
value={this.mapSelectedTrackEntry(this.props.selectedTrack)}
|
options={this.mapTracksEntries(this.props.tracks)}
|
||||||
options={this.mapTracksEntries(this.props.tracks)}
|
onChange={this.updateTracks}
|
||||||
onChange={this.updateTracks}
|
/>
|
||||||
/>
|
<InputSelect
|
||||||
}
|
name="Cohorts"
|
||||||
{this.props.cohorts.length > 0 &&
|
disabled={this.props.cohorts.length === 0}
|
||||||
<InputSelect
|
value={this.mapSelectedCohortEntry(this.props.selectedCohort)}
|
||||||
name="Cohorts"
|
options={this.mapCohortsEntries(this.props.cohorts)}
|
||||||
value={this.mapSelectedCohortEntry(this.props.selectedCohort)}
|
onChange={this.updateCohorts}
|
||||||
options={this.mapCohortsEntries(this.props.cohorts)}
|
/>
|
||||||
onChange={this.updateCohorts}
|
</div>
|
||||||
/>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</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`}>Download Grade Report</a>
|
<a 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)}
|
||||||
@@ -335,7 +356,7 @@ export default class Gradebook extends React.Component {
|
|||||||
<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)}
|
data={this.formatter[this.props.format](this.props.grades, this.props.areGradesFrozen)}
|
||||||
tableSortable
|
tableSortable
|
||||||
defaultSortDirection="asc"
|
defaultSortDirection="asc"
|
||||||
defaultSortedColumn="username"
|
defaultSortedColumn="username"
|
||||||
@@ -344,6 +365,7 @@ export default class Gradebook extends React.Component {
|
|||||||
<Modal
|
<Modal
|
||||||
open={this.state.modalOpen}
|
open={this.state.modalOpen}
|
||||||
title="Edit Grades"
|
title="Edit Grades"
|
||||||
|
closeText="Cancel"
|
||||||
body={(
|
body={(
|
||||||
<div>
|
<div>
|
||||||
<h3>{this.state.modalModel[0].assignmentName}</h3>
|
<h3>{this.state.modalModel[0].assignmentName}</h3>
|
||||||
@@ -351,11 +373,12 @@ export default class Gradebook extends React.Component {
|
|||||||
columns={[{ label: 'Username', key: 'username' }, { label: 'Current grade', key: 'currentGrade' }, { label: 'Adjusted grade', key: 'adjustedGrade' }]}
|
columns={[{ label: 'Username', key: 'username' }, { label: 'Current grade', key: 'currentGrade' }, { label: 'Adjusted grade', key: 'adjustedGrade' }]}
|
||||||
data={this.state.modalModel}
|
data={this.state.modalModel}
|
||||||
/>
|
/>
|
||||||
|
<div>Note: Once you save, your changes will be visible to students.</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
buttons={[
|
buttons={[
|
||||||
<Button
|
<Button
|
||||||
label="Edit Grade"
|
label="Save Grade"
|
||||||
buttonType="primary"
|
buttonType="primary"
|
||||||
onClick={this.handleAdjustedGradeClick}
|
onClick={this.handleAdjustedGradeClick}
|
||||||
/>,
|
/>,
|
||||||
|
|||||||
24
src/components/Header/_header.scss
Normal file
24
src/components/Header/_header.scss
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
.color-gray-dark {
|
||||||
|
color: #767676;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weight-bold {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.size-16 {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-bottom-blue {
|
||||||
|
border-bottom: 1px solid #0075b4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-bottom-gray {
|
||||||
|
border-bottom: 1px solid #e7e7e7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link::after {
|
||||||
|
content: '\00BB';
|
||||||
|
padding-left: 4px;
|
||||||
|
}
|
||||||
0
src/components/Header/header.scss
Normal file
0
src/components/Header/header.scss
Normal file
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Hyperlink } from '@edx/paragon';
|
import { Hyperlink, Icon } from '@edx/paragon';
|
||||||
|
import { configuration } from '../../config';
|
||||||
|
|
||||||
import EdxLogo from '../../../assets/edx-sm.png';
|
import EdxLogo from '../../../assets/edx-sm.png';
|
||||||
|
|
||||||
@@ -24,6 +25,15 @@ export default class Header extends React.Component {
|
|||||||
<Hyperlink content={this.renderLogo()} destination="https://www.edx.org" />
|
<Hyperlink content={this.renderLogo()} destination="https://www.edx.org" />
|
||||||
<div />
|
<div />
|
||||||
</header>
|
</header>
|
||||||
|
{this.state.mobileNavOpen &&
|
||||||
|
<nav className="d-flex flex-column weight-bold size-16">
|
||||||
|
<a href="https://www.google.com" className="nav-link border-bottom-gray">Rick</a>
|
||||||
|
<a href="https://www.google.com" className="nav-link border-bottom-gray">Alex</a>
|
||||||
|
<a href="https://www.google.com" className="nav-link border-bottom-gray">Jasen</a>
|
||||||
|
<a href="https://www.google.com" className="nav-link border-bottom-gray">Doug</a>
|
||||||
|
<a href="https://www.google.com" className="nav-link border-bottom-gray">Simon</a>
|
||||||
|
</nav>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import { fetchCohorts } from '../../data/actions/cohorts';
|
import { fetchCohorts } from '../../data/actions/cohorts';
|
||||||
import { fetchTracks } from '../../data/actions/tracks';
|
import { fetchTracks } from '../../data/actions/tracks';
|
||||||
import { fetchAssignmentTypes } from '../../data/actions/assignmentTypes';
|
import { fetchAssignmentTypes } from '../../data/actions/assignmentTypes';
|
||||||
|
import { getRoles } from '../../data/actions/roles';
|
||||||
|
|
||||||
const mapStateToProps = state => (
|
const mapStateToProps = state => (
|
||||||
{
|
{
|
||||||
@@ -27,10 +28,22 @@ const mapStateToProps = state => (
|
|||||||
prevPage: state.grades.prevPage,
|
prevPage: state.grades.prevPage,
|
||||||
nextPage: state.grades.nextPage,
|
nextPage: state.grades.nextPage,
|
||||||
assignmnetTypes: state.assignmentTypes.results,
|
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 => (
|
const mapDispatchToProps = dispatch => (
|
||||||
{
|
{
|
||||||
getUserGrades: (courseId, cohort, track) => {
|
getUserGrades: (courseId, cohort, track) => {
|
||||||
@@ -63,6 +76,9 @@ const mapDispatchToProps = dispatch => (
|
|||||||
updateBanner: (showSuccess) => {
|
updateBanner: (showSuccess) => {
|
||||||
dispatch(updateBanner(showSuccess));
|
dispatch(updateBanner(showSuccess));
|
||||||
},
|
},
|
||||||
|
getRoles: (matchParams, urlQuery) => {
|
||||||
|
dispatch(getRoles(matchParams, urlQuery));
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import {
|
|||||||
STARTED_FETCHING_ASSIGNMENT_TYPES,
|
STARTED_FETCHING_ASSIGNMENT_TYPES,
|
||||||
GOT_ASSIGNMENT_TYPES,
|
GOT_ASSIGNMENT_TYPES,
|
||||||
ERROR_FETCHING_ASSIGNMENT_TYPES,
|
ERROR_FETCHING_ASSIGNMENT_TYPES,
|
||||||
|
GOT_ARE_GRADES_FROZEN,
|
||||||
} from '../constants/actionTypes/assignmentTypes';
|
} from '../constants/actionTypes/assignmentTypes';
|
||||||
import LmsApiService from '../services/LmsApiService';
|
import LmsApiService from '../services/LmsApiService';
|
||||||
|
|
||||||
const startedFetchingAssignmentTypes = () => ({ type: STARTED_FETCHING_ASSIGNMENT_TYPES });
|
const startedFetchingAssignmentTypes = () => ({ type: STARTED_FETCHING_ASSIGNMENT_TYPES });
|
||||||
const errorFetchingAssignmentTypes = () => ({ type: ERROR_FETCHING_ASSIGNMENT_TYPES });
|
const errorFetchingAssignmentTypes = () => ({ type: ERROR_FETCHING_ASSIGNMENT_TYPES });
|
||||||
const gotAssignmentTypes = assignmentTypes => ({ type: GOT_ASSIGNMENT_TYPES, assignmentTypes });
|
const gotAssignmentTypes = assignmentTypes => ({ type: GOT_ASSIGNMENT_TYPES, assignmentTypes });
|
||||||
|
const gotGradesFrozen = areGradesFrozen => ({ type: GOT_ARE_GRADES_FROZEN, areGradesFrozen });
|
||||||
|
|
||||||
const fetchAssignmentTypes = courseId => (
|
const fetchAssignmentTypes = courseId => (
|
||||||
(dispatch) => {
|
(dispatch) => {
|
||||||
@@ -16,6 +18,7 @@ const fetchAssignmentTypes = courseId => (
|
|||||||
.then(response => response.data)
|
.then(response => response.data)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
dispatch(gotAssignmentTypes(Object.keys(data.assignment_types)));
|
dispatch(gotAssignmentTypes(Object.keys(data.assignment_types)));
|
||||||
|
dispatch(gotGradesFrozen(data.grades_frozen));
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
dispatch(errorFetchingAssignmentTypes());
|
dispatch(errorFetchingAssignmentTypes());
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
STARTED_FETCHING_ASSIGNMENT_TYPES,
|
STARTED_FETCHING_ASSIGNMENT_TYPES,
|
||||||
GOT_ASSIGNMENT_TYPES,
|
GOT_ASSIGNMENT_TYPES,
|
||||||
ERROR_FETCHING_ASSIGNMENT_TYPES,
|
ERROR_FETCHING_ASSIGNMENT_TYPES,
|
||||||
|
GOT_ARE_GRADES_FROZEN,
|
||||||
} from '../constants/actionTypes/assignmentTypes';
|
} from '../constants/actionTypes/assignmentTypes';
|
||||||
|
|
||||||
const mockStore = configureMockStore([thunk]);
|
const mockStore = configureMockStore([thunk]);
|
||||||
@@ -21,29 +22,30 @@ describe('actions', () => {
|
|||||||
|
|
||||||
describe('fetchAssignmentTypes', () => {
|
describe('fetchAssignmentTypes', () => {
|
||||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||||
|
const responseData = {
|
||||||
it('dispatches success action after fetching fetchAssignmentTypes', () => {
|
assignment_types: {
|
||||||
const responseData = {
|
Exam: {
|
||||||
assignment_types: {
|
drop_count: 0,
|
||||||
Exam: {
|
min_count: 1,
|
||||||
drop_count: 0,
|
short_label: 'Exam',
|
||||||
min_count: 1,
|
type: 'Exam',
|
||||||
short_label: 'Exam',
|
weight: 0.25,
|
||||||
type: 'Exam',
|
|
||||||
weight: 0.25,
|
|
||||||
},
|
|
||||||
Homework: {
|
|
||||||
drop_count: 1,
|
|
||||||
min_count: 3,
|
|
||||||
short_label: 'Ex',
|
|
||||||
type: 'Homework',
|
|
||||||
weight: 0.75,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
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 = [
|
const expectedActions = [
|
||||||
{ type: STARTED_FETCHING_ASSIGNMENT_TYPES },
|
{ type: STARTED_FETCHING_ASSIGNMENT_TYPES },
|
||||||
{ type: GOT_ASSIGNMENT_TYPES, assignmentTypes: Object.keys(responseData.assignment_types) },
|
{ type: GOT_ASSIGNMENT_TYPES, assignmentTypes: Object.keys(responseData.assignment_types) },
|
||||||
|
{ type: GOT_ARE_GRADES_FROZEN, areGradesFrozen: responseData.grades_frozen },
|
||||||
];
|
];
|
||||||
const store = mockStore();
|
const store = mockStore();
|
||||||
|
|
||||||
@@ -69,5 +71,21 @@ describe('actions', () => {
|
|||||||
expect(store.getActions()).toEqual(expectedActions);
|
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
38
src/data/actions/roles.js
Normal 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 allowedRoles = ['staff', 'instructor', 'support'];
|
||||||
|
|
||||||
|
const gotRoles = canUserViewGradebook => ({ type: GOT_ROLES, canUserViewGradebook });
|
||||||
|
const errorFetchingRoles = () => ({ type: ERROR_FETCHING_ROLES });
|
||||||
|
|
||||||
|
const getRoles = (courseId, urlQuery) => (
|
||||||
|
dispatch => LmsApiService.fetchUserRoles(courseId)
|
||||||
|
.then(response => response.data)
|
||||||
|
.then((response) => {
|
||||||
|
const canUserViewGradebook = response.is_staff
|
||||||
|
|| (response.roles.some(role => (role.course_id === courseId)
|
||||||
|
&& allowedRoles.includes(role.role)));
|
||||||
|
dispatch(gotRoles(canUserViewGradebook));
|
||||||
|
if (canUserViewGradebook) {
|
||||||
|
dispatch(fetchGrades(courseId, urlQuery.cohort, urlQuery.track));
|
||||||
|
dispatch(fetchTracks(courseId));
|
||||||
|
dispatch(fetchCohorts(courseId));
|
||||||
|
dispatch(fetchAssignmentTypes(courseId));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
dispatch(errorFetchingRoles());
|
||||||
|
}));
|
||||||
|
|
||||||
|
export {
|
||||||
|
getRoles,
|
||||||
|
errorFetchingRoles,
|
||||||
|
};
|
||||||
144
src/data/actions/roles.test.js
Normal file
144
src/data/actions/roles.test.js
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
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 course1Id = 'course-v1:edX+DemoX+Demo_Course';
|
||||||
|
const course2Id = 'course-v1:edX+DemoX+Demo_Course_2';
|
||||||
|
const rolesUrl = `${configuration.LMS_BASE_URL}/api/enrollment/v1/roles/?course_id=${encodeURIComponent(course1Id)}`;
|
||||||
|
|
||||||
|
function makeRoleListObj(roles, isGlobalStaff){
|
||||||
|
return {
|
||||||
|
roles: roles,
|
||||||
|
is_staff: isGlobalStaff,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function makeRoleObj(courseId, role) {
|
||||||
|
return {
|
||||||
|
course_id: courseId,
|
||||||
|
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(makeRoleListObj([course1StaffRole, course2DummyRole], false)));
|
||||||
|
|
||||||
|
return store.dispatch(getRoles(course1Id, urlParams)).then(() => {
|
||||||
|
expect(store.getActions()).toEqual(expectedActions);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches got_roles action and other actions after fetching irrelevent roles but user is global staff', () => {
|
||||||
|
const expectedActions = [
|
||||||
|
{ type: GOT_ROLES, canUserViewGradebook: true },
|
||||||
|
{ type: STARTED_FETCHING_GRADES },
|
||||||
|
{ type: STARTED_FETCHING_TRACKS },
|
||||||
|
{ type: STARTED_FETCHING_COHORTS },
|
||||||
|
{ type: STARTED_FETCHING_ASSIGNMENT_TYPES },
|
||||||
|
];
|
||||||
|
const store = mockStore();
|
||||||
|
|
||||||
|
axiosMock.onGet(rolesUrl)
|
||||||
|
.replyOnce(200, JSON.stringify(makeRoleListObj([course1DummyRole, course2DummyRole], true)));
|
||||||
|
|
||||||
|
return store.dispatch(getRoles(course1Id, urlParams)).then(() => {
|
||||||
|
expect(store.getActions()).toEqual(expectedActions);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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(makeRoleListObj([course1DummyRole, course2StaffRole], false)));
|
||||||
|
|
||||||
|
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(makeRoleListObj([], false)));
|
||||||
|
|
||||||
|
return store.dispatch(getRoles(course1Id, urlParams)).then(() => {
|
||||||
|
expect(store.getActions()).toEqual(expectedActions);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches got_roles action and other actions after fetching empty roles but user is global staff', () => {
|
||||||
|
const expectedActions = [
|
||||||
|
{ type: GOT_ROLES, canUserViewGradebook: true },
|
||||||
|
{ type: STARTED_FETCHING_GRADES },
|
||||||
|
{ type: STARTED_FETCHING_TRACKS },
|
||||||
|
{ type: STARTED_FETCHING_COHORTS },
|
||||||
|
{ type: STARTED_FETCHING_ASSIGNMENT_TYPES },
|
||||||
|
];
|
||||||
|
const store = mockStore();
|
||||||
|
|
||||||
|
axiosMock.onGet(rolesUrl)
|
||||||
|
.replyOnce(200, JSON.stringify(makeRoleListObj([], true)));
|
||||||
|
|
||||||
|
return store.dispatch(getRoles(course1Id, urlParams)).then(() => {
|
||||||
|
expect(store.getActions()).toEqual(expectedActions);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
const STARTED_FETCHING_ASSIGNMENT_TYPES = 'STARTED_FETCHING_ASSIGNMENT_TYPES';
|
const STARTED_FETCHING_ASSIGNMENT_TYPES = 'STARTED_FETCHING_ASSIGNMENT_TYPES';
|
||||||
const GOT_ASSIGNMENT_TYPES = 'GOT_ASSIGNMENT_TYPES';
|
const GOT_ASSIGNMENT_TYPES = 'GOT_ASSIGNMENT_TYPES';
|
||||||
const ERROR_FETCHING_ASSIGNMENT_TYPES = 'ERROR_FETCHING_ASSIGNMENT_TYPES';
|
const ERROR_FETCHING_ASSIGNMENT_TYPES = 'ERROR_FETCHING_ASSIGNMENT_TYPES';
|
||||||
|
const GOT_ARE_GRADES_FROZEN = 'GOT_ARE_GRADES_FROZEN';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
STARTED_FETCHING_ASSIGNMENT_TYPES,
|
STARTED_FETCHING_ASSIGNMENT_TYPES,
|
||||||
GOT_ASSIGNMENT_TYPES,
|
GOT_ASSIGNMENT_TYPES,
|
||||||
ERROR_FETCHING_ASSIGNMENT_TYPES,
|
ERROR_FETCHING_ASSIGNMENT_TYPES,
|
||||||
|
GOT_ARE_GRADES_FROZEN,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
7
src/data/constants/actionTypes/roles.js
Normal file
7
src/data/constants/actionTypes/roles.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
const GOT_ROLES = 'GOT_ROLES';
|
||||||
|
const ERROR_FETCHING_ROLES = 'ERROR_FETCHING_ROLES'
|
||||||
|
|
||||||
|
export {
|
||||||
|
GOT_ROLES,
|
||||||
|
ERROR_FETCHING_ROLES,
|
||||||
|
};
|
||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
STARTED_FETCHING_ASSIGNMENT_TYPES,
|
STARTED_FETCHING_ASSIGNMENT_TYPES,
|
||||||
ERROR_FETCHING_ASSIGNMENT_TYPES,
|
ERROR_FETCHING_ASSIGNMENT_TYPES,
|
||||||
GOT_ASSIGNMENT_TYPES,
|
GOT_ASSIGNMENT_TYPES,
|
||||||
|
GOT_ARE_GRADES_FROZEN,
|
||||||
} from '../constants/actionTypes/assignmentTypes';
|
} from '../constants/actionTypes/assignmentTypes';
|
||||||
|
|
||||||
const initialState = {
|
const initialState = {
|
||||||
@@ -31,6 +32,13 @@ const assignmentTypes = (state = initialState, action) => {
|
|||||||
finishedFetching: true,
|
finishedFetching: true,
|
||||||
errorFetching: true,
|
errorFetching: true,
|
||||||
};
|
};
|
||||||
|
case GOT_ARE_GRADES_FROZEN:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
areGradesFrozen: action.areGradesFrozen,
|
||||||
|
errorFetching: false,
|
||||||
|
finishedFetching: true,
|
||||||
|
};
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
STARTED_FETCHING_ASSIGNMENT_TYPES,
|
STARTED_FETCHING_ASSIGNMENT_TYPES,
|
||||||
ERROR_FETCHING_ASSIGNMENT_TYPES,
|
ERROR_FETCHING_ASSIGNMENT_TYPES,
|
||||||
GOT_ASSIGNMENT_TYPES,
|
GOT_ASSIGNMENT_TYPES,
|
||||||
|
GOT_ARE_GRADES_FROZEN,
|
||||||
} from '../constants/actionTypes/assignmentTypes';
|
} from '../constants/actionTypes/assignmentTypes';
|
||||||
|
|
||||||
const initialState = {
|
const initialState = {
|
||||||
@@ -51,4 +52,17 @@ describe('assignmentTypes reducer', () => {
|
|||||||
type: ERROR_FETCHING_ASSIGNMENT_TYPES,
|
type: ERROR_FETCHING_ASSIGNMENT_TYPES,
|
||||||
})).toEqual(expected);
|
})).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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import cohorts from './cohorts';
|
|||||||
import grades from './grades';
|
import grades from './grades';
|
||||||
import tracks from './tracks';
|
import tracks from './tracks';
|
||||||
import assignmentTypes from './assignmentTypes';
|
import assignmentTypes from './assignmentTypes';
|
||||||
|
import roles from './roles';
|
||||||
|
|
||||||
const rootReducer = combineReducers({
|
const rootReducer = combineReducers({
|
||||||
grades,
|
grades,
|
||||||
cohorts,
|
cohorts,
|
||||||
tracks,
|
tracks,
|
||||||
assignmentTypes,
|
assignmentTypes,
|
||||||
|
roles,
|
||||||
});
|
});
|
||||||
|
|
||||||
export default rootReducer;
|
export default rootReducer;
|
||||||
|
|||||||
26
src/data/reducers/roles.js
Normal file
26
src/data/reducers/roles.js
Normal 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;
|
||||||
47
src/data/reducers/roles.test.js
Normal file
47
src/data/reducers/roles.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -59,6 +59,11 @@ class LmsApiService {
|
|||||||
const assignmentTypesUrl = `${LmsApiService.baseUrl}/api/grades/v1/gradebook/${courseId}/grading-info?graded_only=true`;
|
const assignmentTypesUrl = `${LmsApiService.baseUrl}/api/grades/v1/gradebook/${courseId}/grading-info?graded_only=true`;
|
||||||
return apiClient.get(assignmentTypesUrl);
|
return apiClient.get(assignmentTypesUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static fetchUserRoles(courseId) {
|
||||||
|
const rolesUrl = `${LmsApiService.baseUrl}/api/enrollment/v1/roles/?course_id=${encodeURIComponent(courseId)}`;
|
||||||
|
return apiClient.get(rolesUrl);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default LmsApiService;
|
export default LmsApiService;
|
||||||
|
|||||||
@@ -5,11 +5,14 @@ import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
|
|||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
|
|
||||||
import apiClient from './data/apiClient';
|
import apiClient from './data/apiClient';
|
||||||
|
import Footer from './components/Gradebook/footer';
|
||||||
import GradebookPage from './containers/GradebookPage';
|
import GradebookPage from './containers/GradebookPage';
|
||||||
import Header from './components/Header';
|
import Header from './components/Header';
|
||||||
import store from './data/store';
|
import store from './data/store';
|
||||||
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>
|
||||||
@@ -20,6 +23,7 @@ const App = () => (
|
|||||||
<Route exact path="/:courseId" component={GradebookPage} />
|
<Route exact path="/:courseId" component={GradebookPage} />
|
||||||
</Switch>
|
</Switch>
|
||||||
</main>
|
</main>
|
||||||
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
</Router>
|
</Router>
|
||||||
</Provider>
|
</Provider>
|
||||||
|
|||||||
7
src/postcss.config.js
Normal file
7
src/postcss.config.js
Normal 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%'] }),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
Reference in New Issue
Block a user