Compare commits

..

2 Commits

Author SHA1 Message Date
jansenk
286e194414 change test expectations 2019-01-03 16:55:13 -05:00
jansenk
3deb592d89 remove sorting on columns 2019-01-03 16:48:30 -05:00
38 changed files with 5459 additions and 7268 deletions

View File

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

View File

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

29
Dockerfile Executable file
View File

@@ -0,0 +1,29 @@
# Copied from https://github.com/BretFisher/node-docker-good-defaults/blob/master/Dockerfile
FROM node:8.9.3
# Create app directory
RUN mkdir -p /edx/app
ARG NODE_ENV=production
ENV NODE_ENV $NODE_ENV
ARG PORT=80
ENV PORT $PORT
EXPOSE $PORT 1991
WORKDIR /edx
# Install app dependencies
# A wildcard is used to ensure both package.json AND package-lock.json are copied
# where available (npm@5+)
COPY package*.json ./
# If you are building your code for production
# RUN npm install --only=production
RUN npm install
ENV PATH /edx/app/node_modules/.bin:$PATH
WORKDIR /edx/app
COPY . /edx/app
ENTRYPOINT npm install && npm run start

View File

@@ -1,9 +1,35 @@
npm-install-%: ## install specified % npm package
npm install $* --save-dev
shell: ## run a shell on the cookie-cutter container
docker exec -it edx.gradebook /bin/bash
build:
docker-compose build
up: ## bring up cookie-cutter container
docker-compose up
up-detached: ## bring up cookie-cutter container in detached mode
docker-compose up -d
logs: ## show logs for cookie-cutter container
docker-compose logs -f
down: ## stop and remove cookie-cutter container
docker-compose down
npm-install-%: ## install specified % npm package on the cookie-cutter container
docker exec npm install $* --save-dev
git add package.json
restart:
make down
make up
restart-detached:
make down
make up-detached
validate-no-uncommitted-package-lock-changes:
git diff --exit-code package-lock.json
test:
npm run test
docker exec -it edx.gradebook jest

View File

@@ -21,20 +21,20 @@ npm i --save @edx/gradebook
## Running the UI Standalone
To install the project please refer to the [`edX Developer Stack`](https://github.com/edx/devstack) instructions.
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 **1994**, so when you go to `http://localhost:1994/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.
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 gradebook-logs` in the `devstack` directory.
If you don't, you can see the log messages for the docker container by executing `make logs` in the `gradebook` directory.
Note that starting the container executes the `npm run start` script which will hot-reload JavaScript and Sass files changes, so you should (:crossed_fingers:) not need to do anything (other than wait) when making changes.
Note that `make up-detached` executes the `npm run start` script which will hot-reload JavaScript and Sass files changes, so you should (:crossed_fingers:) not need to do anything (other than wait) when making changes.
## Configuring for local use in edx-platform
Assuming you've got the UI running at `http://localhost:1994`, you can configure the LMS in edx-platform
Assuming you've got the UI running at `http://localhost:1991`, you can configure the LMS in edx-platform
to point to your local gradebook from the instructor dashboard by putting this settings in `lms/env/private.py`:
```
WRITABLE_GRADEBOOK_URL = 'http://localhost:1994'
WRITABLE_GRADEBOOK_URL = 'http://localhost:1991'
```
There are also several edx-platform waffle and feature flags you'll have to enable from the Django admin:
@@ -49,13 +49,6 @@ in which you'd like to enable the gradebook. Add a course override flag using a
``grades.writable_gradebook``. Make sure to check the ``enabled`` box. Alternatively, you could add this as a
regular waffle flag to enable the gradebook for all courses.
## Running tests
1. Assuming that you're operating in the context of the edX devstack,
run `gradebook-shell` from your devstack directory. This will start a bash shell inside your
running gradebook container.
2. Run `make test` (which executes `npm run test`). This will run all of the gradebook tests.
## Directory Structure
* `config`

View File

@@ -4,7 +4,6 @@ const path = require('path');
module.exports = {
entry: {
segment: path.resolve(__dirname, '../src/segment.js'),
app: path.resolve(__dirname, '../src/index.jsx'),
},
output: {

View File

@@ -12,7 +12,6 @@ module.exports = Merge.smart(commonConfig, {
entry: [
// enable react's custom hot dev client so we get errors reported in the browser
require.resolve('react-dev-utils/webpackHotDevClient'),
path.resolve(__dirname, '../src/segment.js'),
path.resolve(__dirname, '../src/index.jsx'),
],
module: {
@@ -98,7 +97,7 @@ module.exports = Merge.smart(commonConfig, {
}),
new webpack.EnvironmentPlugin({
NODE_ENV: 'development',
BASE_URL: 'localhost:1994',
BASE_URL: 'localhost:1991',
LMS_BASE_URL: 'http://localhost:18000',
LOGIN_URL: 'http://localhost:18000/login',
LOGOUT_URL: 'http://localhost:18000/login',
@@ -111,21 +110,6 @@ module.exports = Merge.smart(commonConfig, {
FEATURE_FLAGS: {},
ACCESS_TOKEN_COOKIE_NAME: 'edx-jwt-cookie-header-payload',
CSRF_COOKIE_NAME: 'csrftoken',
SITE_NAME: 'edX',
MARKETING_SITE_BASE_URL: 'http://localhost:18000',
SUPPORT_URL: 'http://localhost:18000/support',
CONTACT_URL: 'http://localhost:18000/contact',
OPEN_SOURCE_URL: 'http://localhost:18000/openedx',
TERMS_OF_SERVICE_URL: 'http://localhost:18000/terms-of-service',
PRIVACY_POLICY_URL: 'http://localhost:18000/privacy-policy',
FACEBOOK_URL: 'https://www.facebook.com',
TWITTER_URL: 'https://twitter.com',
YOU_TUBE_URL: 'https://www.youtube.com',
LINKED_IN_URL: 'https://www.linkedin.com',
GOOGLE_PLUS_URL: 'https://plus.google.com',
REDDIT_URL: 'https://www.reddit.com',
APPLE_APP_STORE_URL: 'https://www.apple.com/ios/app-store/',
GOOGLE_PLAY_URL: 'https://play.google.com/store',
}),
// when the --hot option is not passed in as part of the command
// the HotModuleReplacementPlugin has to be specified in the Webpack configuration
@@ -136,7 +120,7 @@ module.exports = Merge.smart(commonConfig, {
// reloading.
devServer: {
host: '0.0.0.0',
port: 1994,
port: 1991,
historyApiFallback: true,
hot: true,
inline: true,

View File

@@ -47,7 +47,7 @@ module.exports = Merge.smart(commonConfig, {
minimize: true,
},
},
'postcss-loader', // for autoprefixing, needs to be before the sass loader, not sure why
'postcss-loader',
{
loader: 'sass-loader', // compiles Sass to CSS
options: {
@@ -126,21 +126,6 @@ module.exports = Merge.smart(commonConfig, {
CSRF_COOKIE_NAME: 'csrftoken',
NEW_RELIC_APP_ID: null,
NEW_RELIC_LICENSE_KEY: null,
SITE_NAME: null,
MARKETING_SITE_BASE_URL: null,
SUPPORT_URL: null,
CONTACT_URL: null,
OPEN_SOURCE_URL: null,
TERMS_OF_SERVICE_URL: null,
PRIVACY_POLICY_URL: null,
FACEBOOK_URL: null,
TWITTER_URL: null,
YOU_TUBE_URL: null,
LINKED_IN_URL: null,
GOOGLE_PLUS_URL: null,
REDDIT_URL: null,
APPLE_APP_STORE_URL: null,
GOOGLE_PLAY_URL: null,
}),
],
});

20
docker-compose.yml Executable file
View File

@@ -0,0 +1,20 @@
version: "2"
services:
web:
build:
context: .
dockerfile: Dockerfile
args:
- NODE_ENV=development
container_name: edx.gradebook
image: edxops/front-end-cookie-cutter:latest
volumes:
- .:/edx/app:delegated
- notused:/edx/app/node_modules
ports:
- "1991:1991"
environment:
- NODE_ENV=development
volumes:
notused:

View File

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

View File

@@ -1,8 +0,0 @@
# This file describes this Open edX repo, as described in OEP-2:
# http://open-edx-proposals.readthedocs.io/en/latest/oeps/oep-0002.html#specification
nick: grbk
oeps: {}
owner: edx/educator-neem
openedx-release: {ref: master}
track-pulls: true

11311
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -25,69 +25,66 @@
},
"dependencies": {
"@edx/edx-bootstrap": "^0.4.3",
"@edx/frontend-auth": "^1.3.0",
"@edx/frontend-component-footer": "^1.0.0",
"@edx/paragon": "^3.8.3",
"@redux-beacon/segment": "^1.0.0",
"@edx/frontend-auth": "^1.2.1",
"@edx/paragon": "^3.7.2",
"babel-polyfill": "^6.26.0",
"classnames": "^2.2.6",
"email-prop-type": "^1.1.7",
"classnames": "^2.2.5",
"email-prop-type": "^1.1.5",
"font-awesome": "^4.7.0",
"history": "^4.7.2",
"prop-types": "^15.6.2",
"prop-types": "^15.5.10",
"query-string": "^5.1.1",
"react": "^16.7.0",
"react-dom": "^16.7.0",
"react-redux": "^5.1.1",
"react-router": "^4.3.1",
"react-router-dom": "^4.3.1",
"react": "^16.2.0",
"react-dom": "^16.2.0",
"react-redux": "^5.0.7",
"react-router": "^4.2.0",
"react-router-dom": "^4.2.2",
"react-router-redux": "^5.0.0-alpha.9",
"redux": "^3.7.2",
"redux-devtools-extension": "^2.13.7",
"redux-beacon": "^2.0.3",
"redux-devtools-extension": "^2.13.2",
"redux-logger": "^3.0.6",
"redux-thunk": "^2.3.0",
"whatwg-fetch": "^2.0.4"
"redux-thunk": "^2.2.0",
"whatwg-fetch": "^2.0.3"
},
"devDependencies": {
"autoprefixer": "^9.4.5",
"axios-mock-adapter": "^1.16.0",
"autoprefixer": "^9.4.2",
"axios-mock-adapter": "^1.15.0",
"babel-cli": "^6.26.0",
"babel-eslint": "^8.2.6",
"babel-jest": "^22.4.4",
"babel-loader": "^7.1.5",
"babel-eslint": "^8.2.2",
"babel-jest": "^22.4.0",
"babel-loader": "^7.1.2",
"babel-plugin-transform-class-properties": "^6.24.1",
"babel-plugin-transform-object-rest-spread": "^6.26.0",
"babel-preset-env": "^1.7.0",
"babel-preset-env": "^1.6.1",
"babel-preset-react": "^6.24.1",
"codecov": "^3.1.0",
"css-loader": "^0.28.11",
"enzyme": "^3.8.0",
"enzyme-adapter-react-16": "^1.7.1",
"es-check": "^2.3.0",
"eslint-config-edx": "^4.0.4",
"fetch-mock": "^6.5.2",
"codecov": "^3.0.0",
"css-loader": "^0.28.9",
"enzyme": "^3.3.0",
"enzyme-adapter-react-16": "^1.1.1",
"es-check": "^2.0.2",
"eslint-config-edx": "^4.0.3",
"fetch-mock": "^6.3.0",
"file-loader": "^1.1.9",
"html-webpack-harddisk-plugin": "^0.2.0",
"html-webpack-plugin": "^3.2.0",
"html-webpack-plugin": "^3.0.3",
"husky": "^0.14.3",
"identity-obj-proxy": "^3.0.0",
"image-webpack-loader": "^4.2.0",
"jest": "^22.4.4",
"jest": "^22.4.0",
"mini-css-extract-plugin": "^0.4.0",
"node-sass": "^4.11.0",
"node-sass": "^4.7.2",
"postcss-loader": "^3.0.0",
"react-dev-utils": "^5.0.3",
"react-test-renderer": "^16.7.0",
"redux-mock-store": "^1.5.3",
"react-dev-utils": "^5.0.0",
"react-test-renderer": "^16.2.0",
"redux-mock-store": "^1.5.1",
"sass-loader": "^6.0.6",
"semantic-release": "^15.13.3",
"style-loader": "^0.20.3",
"travis-deploy-once": "^5.0.11",
"webpack": "^4.28.4",
"webpack-cli": "^3.2.1",
"webpack-dev-server": "^3.1.14",
"webpack-merge": "^4.2.1"
"semantic-release": "^15.10.7",
"style-loader": "^0.20.2",
"travis-deploy-once": "^5.0.9",
"webpack": "^4.25.1",
"webpack-cli": "^3.1.2",
"webpack-dev-server": "^3.1.0",
"webpack-merge": "^4.1.1"
},
"jest": {
"setupFiles": [
@@ -108,7 +105,6 @@
],
"transformIgnorePatterns": [
"/node_modules/(?!(@edx/paragon)/).*/"
],
"testURL": "http://localhost"
]
}
}

View File

@@ -6,6 +6,5 @@ $fa-font-path: "~font-awesome/fonts";
@import "~@edx/paragon/src/SearchField/SearchField";
@import "~@edx/frontend-component-footer/src/lib/scss/site-footer";
@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

@@ -68,10 +68,6 @@
padding-left: 170px;
}
.table tbody th {
font-weight: normal;
}
.link-style {
color: #0075b4;
&:hover, &:focus {
@@ -81,6 +77,3 @@
}
}
.mb-85 {
margin-bottom: 85px;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,11 +10,7 @@ import LmsApiService from '../services/LmsApiService';
const allowedRoles = ['staff', 'instructor', 'support'];
const gotRoles = (canUserViewGradebook, courseId) => ({
type: GOT_ROLES,
canUserViewGradebook,
courseId,
});
const gotRoles = canUserViewGradebook => ({ type: GOT_ROLES, canUserViewGradebook });
const errorFetchingRoles = () => ({ type: ERROR_FETCHING_ROLES });
const getRoles = (courseId, urlQuery) => (
@@ -24,9 +20,9 @@ const getRoles = (courseId, urlQuery) => (
const canUserViewGradebook = response.is_staff
|| (response.roles.some(role => (role.course_id === courseId)
&& allowedRoles.includes(role.role)));
dispatch(gotRoles(canUserViewGradebook, courseId));
dispatch(gotRoles(canUserViewGradebook));
if (canUserViewGradebook) {
dispatch(fetchGrades(courseId, urlQuery.cohort, urlQuery.track, urlQuery.assignmentType));
dispatch(fetchGrades(courseId, urlQuery.cohort, urlQuery.track));
dispatch(fetchTracks(courseId));
dispatch(fetchCohorts(courseId));
dispatch(fetchAssignmentTypes(courseId));

View File

@@ -22,23 +22,23 @@ 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) {
function makeRoleListObj(roles, isGlobalStaff){
return {
roles,
roles: roles,
is_staff: isGlobalStaff,
};
}
}
function makeRoleObj(courseId, role) {
return {
course_id: courseId,
role,
};
}
role: role,
}
};
const course1StaffRole = makeRoleObj(course1Id, 'staff');
const course1DummyRole = makeRoleObj(course1Id, 'dummy');
const course2StaffRole = makeRoleObj(course2Id, 'staff');
const course2DummyRole = makeRoleObj(course2Id, 'dummy');
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', () => {
@@ -49,7 +49,7 @@ describe('actions', () => {
describe('getRoles', () => {
it('dispatches got_roles action and subsequent actions after fetching role that allows gradebook', () => {
const expectedActions = [
{ type: GOT_ROLES, canUserViewGradebook: true, courseId: course1Id },
{ type: GOT_ROLES, canUserViewGradebook: true },
{ type: STARTED_FETCHING_GRADES },
{ type: STARTED_FETCHING_TRACKS },
{ type: STARTED_FETCHING_COHORTS },
@@ -57,10 +57,7 @@ describe('actions', () => {
];
const store = mockStore();
axiosMock.onGet(rolesUrl)
.replyOnce(
200,
JSON.stringify(makeRoleListObj([course1StaffRole, course2DummyRole], false)),
);
.replyOnce(200, JSON.stringify(makeRoleListObj([course1StaffRole, course2DummyRole], false)));
return store.dispatch(getRoles(course1Id, urlParams)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
@@ -69,7 +66,7 @@ describe('actions', () => {
it('dispatches got_roles action and other actions after fetching irrelevent roles but user is global staff', () => {
const expectedActions = [
{ type: GOT_ROLES, canUserViewGradebook: true, courseId: course1Id },
{ type: GOT_ROLES, canUserViewGradebook: true },
{ type: STARTED_FETCHING_GRADES },
{ type: STARTED_FETCHING_TRACKS },
{ type: STARTED_FETCHING_COHORTS },
@@ -78,10 +75,7 @@ describe('actions', () => {
const store = mockStore();
axiosMock.onGet(rolesUrl)
.replyOnce(
200,
JSON.stringify(makeRoleListObj([course1DummyRole, course2DummyRole], true)),
);
.replyOnce(200, JSON.stringify(makeRoleListObj([course1DummyRole, course2DummyRole], true)));
return store.dispatch(getRoles(course1Id, urlParams)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
@@ -90,17 +84,12 @@ describe('actions', () => {
it('dispatches got_roles action and no other actions after fetching role that disallows gradebook', () => {
const expectedActions = [
{
type: GOT_ROLES, canUserViewGradebook: false, courseId: course1Id,
},
{ type: GOT_ROLES, canUserViewGradebook: false },
];
const store = mockStore();
axiosMock.onGet(rolesUrl)
.replyOnce(
200,
JSON.stringify(makeRoleListObj([course1DummyRole, course2StaffRole], false)),
);
.replyOnce(200, JSON.stringify(makeRoleListObj([course1DummyRole, course2StaffRole], false)));
return store.dispatch(getRoles(course1Id, urlParams)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
@@ -109,15 +98,12 @@ describe('actions', () => {
it('dispatches got_roles action and no other actions after fetching empty roles', () => {
const expectedActions = [
{ type: GOT_ROLES, canUserViewGradebook: false, courseId: course1Id },
{ type: GOT_ROLES, canUserViewGradebook: false },
];
const store = mockStore();
axiosMock.onGet(rolesUrl)
.replyOnce(
200,
JSON.stringify(makeRoleListObj([], false)),
);
.replyOnce(200, JSON.stringify(makeRoleListObj([], false)));
return store.dispatch(getRoles(course1Id, urlParams)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
@@ -126,7 +112,7 @@ describe('actions', () => {
it('dispatches got_roles action and other actions after fetching empty roles but user is global staff', () => {
const expectedActions = [
{ type: GOT_ROLES, canUserViewGradebook: true, courseId: course1Id },
{ type: GOT_ROLES, canUserViewGradebook: true },
{ type: STARTED_FETCHING_GRADES },
{ type: STARTED_FETCHING_TRACKS },
{ type: STARTED_FETCHING_COHORTS },
@@ -135,10 +121,7 @@ describe('actions', () => {
const store = mockStore();
axiosMock.onGet(rolesUrl)
.replyOnce(
200,
JSON.stringify(makeRoleListObj([], true)),
);
.replyOnce(200, JSON.stringify(makeRoleListObj([], true)));
return store.dispatch(getRoles(course1Id, urlParams)).then(() => {
expect(store.getActions()).toEqual(expectedActions);

View File

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

View File

@@ -1,3 +1,5 @@
import { sortGrades } from './grades';
const sortAlphaAsc = (gradeRowA, gradeRowB) => {
const a = gradeRowA.username.toUpperCase();
const b = gradeRowB.username.toUpperCase();
@@ -10,8 +12,56 @@ const sortAlphaAsc = (gradeRowA, gradeRowB) => {
return 0;
};
const sortAlphaDesc = (gradeRowA, gradeRowB) => {
const a = gradeRowA.username.toUpperCase();
const b = gradeRowB.username.toUpperCase();
if (a < b) {
return 1;
}
if (a > b) {
return -1;
}
return 0;
};
const sortNumerically = (colKey, direction) => {
function getPercents(gradeRowA, gradeRowB) {
if (colKey !== 'total') {
return {
a: gradeRowA.section_breakdown.find(x => x.label === colKey).percent,
b: gradeRowB.section_breakdown.find(x => x.label === colKey).percent,
};
}
return {
a: gradeRowA.percent,
b: gradeRowB.percent,
};
}
function sortNumAsc(gradeRowA, gradeRowB) {
const { a, b } = getPercents(gradeRowA, gradeRowB);
return a - b;
}
function sortNumDesc(gradeRowA, gradeRowB) {
const { a, b } = getPercents(gradeRowA, gradeRowB);
return b - a;
}
return direction === 'desc' ? sortNumDesc : sortNumAsc;
};
function gradeSortMap(columnName, direction) {
if (columnName === 'username' && direction === 'desc') {
return sortAlphaDesc;
} else if (columnName === 'username') {
return sortAlphaAsc;
}
return sortNumerically(columnName, direction);
}
const headingMapper = (filterKey) => {
function all(entry) {
function all(dispatch, entry) {
if (entry) {
const results = [{
label: 'Username',
@@ -19,7 +69,7 @@ const headingMapper = (filterKey) => {
}];
const assignmentHeadings = entry.section_breakdown
.filter(section => section.label)
.filter(section => section.is_graded && section.label)
.map(s => ({
label: s.label,
key: s.label,
@@ -35,16 +85,14 @@ const headingMapper = (filterKey) => {
return [];
}
function some(entry) {
if (!entry) return [];
function some(dispatch, entry) {
const results = [{
label: 'Username',
key: 'username',
}];
const assignmentHeadings = entry.section_breakdown
.filter(section => section.label && section.category === filterKey)
.filter(section => section.is_graded && section.label && section.category === filterKey)
.map(s => ({
label: s.label,
key: s.label,
@@ -61,5 +109,5 @@ const headingMapper = (filterKey) => {
return filterKey === 'All' ? all : some;
};
export { headingMapper, sortAlphaAsc };
export { headingMapper, gradeSortMap, sortAlphaAsc };

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ import {
TOGGLE_GRADE_FORMAT,
FILTER_COLUMNS,
UPDATE_BANNER,
SORT_GRADES,
} from '../constants/actionTypes/grades';
const initialState = {
@@ -112,7 +113,6 @@ describe('grades reducer', () => {
prevPage: expectedPrev,
nextPage: expectedNext,
showSpinner: false,
courseId,
};
expect(grades(undefined, {
type: GOT_GRADES,
@@ -123,7 +123,6 @@ describe('grades reducer', () => {
track: expectedTrack,
cohort: expectedCohortId,
showSpinner: true,
courseId,
})).toEqual(expected);
});
@@ -163,6 +162,17 @@ describe('grades reducer', () => {
})).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,

View File

@@ -1,27 +1,26 @@
import {
GOT_ROLES,
ERROR_FETCHING_ROLES,
} from '../constants/actionTypes/roles';
GOT_ROLES,
ERROR_FETCHING_ROLES,
} from '../constants/actionTypes/roles';
const initialState = {
canUserViewGradebook: null,
};
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;
}
};
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;
export default roles;

View File

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

View File

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

View File

@@ -2,57 +2,14 @@ import { applyMiddleware, createStore } from 'redux';
import thunkMiddleware from 'redux-thunk';
import { composeWithDevTools } from 'redux-devtools-extension/logOnlyInProduction';
import { createLogger } from 'redux-logger';
import { createMiddleware } from 'redux-beacon';
import Segment, { trackEvent, trackPageView } from '@redux-beacon/segment';
import { GOT_ROLES } from './constants/actionTypes/roles';
import { GOT_GRADES, GRADE_UPDATE_SUCCESS, GRADE_UPDATE_FAILURE } from './constants/actionTypes/grades';
import reducers from './reducers';
const loggerMiddleware = createLogger();
const trackingCategory = 'gradebook';
const eventsMap = {
[GOT_ROLES]: trackPageView(action => ({
category: trackingCategory,
page: action.courseId,
})),
[GOT_GRADES]: trackEvent(action => ({
name: 'Grades displayed or paginated',
properties: {
category: trackingCategory,
courseId: action.courseId,
track: action.track,
cohort: action.cohort,
assignmentType: action.assignmentType,
prev: action.prev,
next: action.next,
},
})),
[GRADE_UPDATE_SUCCESS]: trackEvent(action => ({
name: 'Grades Updated',
properties: {
category: trackingCategory,
courseId: action.courseId,
updatedGrades: action.payload.responseData,
},
})),
[GRADE_UPDATE_FAILURE]: trackEvent(action => ({
name: 'Grades Fail to Update',
properties: {
category: trackingCategory,
courseId: action.courseId,
error: action.payload.error,
},
})),
};
const segmentMiddleware = createMiddleware(eventsMap, Segment());
const store = createStore(
reducers,
composeWithDevTools(applyMiddleware(thunkMiddleware, loggerMiddleware, segmentMiddleware)),
composeWithDevTools(applyMiddleware(thunkMiddleware, loggerMiddleware)),
);
export default store;

View File

@@ -3,15 +3,16 @@ import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
import { Provider } from 'react-redux';
import SiteFooter from '@edx/frontend-component-footer';
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 FooterLogo from '../assets/edx-footer.png';
import './App.scss';
var courseId = window.location.pathname.substring(1);
const App = () => (
<Provider store={store}>
<Router>
@@ -22,24 +23,7 @@ const App = () => (
<Route exact path="/:courseId" component={GradebookPage} />
</Switch>
</main>
<SiteFooter
siteName={process.env.SITE_NAME}
siteLogo={FooterLogo}
marketingSiteBaseUrl={process.env.MARKETING_SITE_BASE_URL}
supportUrl={process.env.SUPPORT_URL}
contactUrl={process.env.CONTACT_URL}
openSourceUrl={process.env.OPEN_SOURCE_URL}
termsOfServiceUrl={process.env.TERMS_OF_SERVICE_URL}
privacyPolicyUrl={process.env.PRIVACY_POLICY_URL}
facebookUrl={process.env.FACEBOOK_URL}
twitterUrl={process.env.TWITTER_URL}
youTubeUrl={process.env.YOU_TUBE_URL}
linkedInUrl={process.env.LINKED_IN_URL}
googlePlusUrl={process.env.GOOGLE_PLUS_URL}
redditUrl={process.env.REDDIT_URL}
appleAppStoreUrl={process.env.APPLE_APP_STORE_URL}
googlePlayUrl={process.env.GOOGLE_PLAY_URL}
/>
<Footer />
</div>
</Router>
</Provider>

View File

@@ -1,85 +0,0 @@
// The code in this file is from Segment's website:
// https://segment.com/docs/sources/website/analytics.js/quickstart/
import { configuration } from './config';
(function () {
// Create a queue, but don't obliterate an existing one!
const analytics = window.analytics = window.analytics || [];
// If the real analytics.js is already on the page return.
if (analytics.initialize) return;
// If the snippet was invoked already show an error.
if (analytics.invoked) {
if (window.console && console.error) {
console.error('Segment snippet included twice.');
}
return;
}
// Invoked flag, to make sure the snippet
// is never invoked twice.
analytics.invoked = true;
// A list of the methods in Analytics.js to stub.
analytics.methods = [
'trackSubmit',
'trackClick',
'trackLink',
'trackForm',
'pageview',
'identify',
'reset',
'group',
'track',
'ready',
'alias',
'debug',
'page',
'once',
'off',
'on',
];
// Define a factory to create stubs. These are placeholders
// for methods in Analytics.js so that you never have to wait
// for it to load to actually record data. The `method` is
// stored as the first argument, so we can replay the data.
analytics.factory = function (method) {
return function () {
const args = Array.prototype.slice.call(arguments);
args.unshift(method);
analytics.push(args);
return analytics;
};
};
// For each of our methods, generate a queueing stub.
for (let i = 0; i < analytics.methods.length; i++) {
const key = analytics.methods[i];
analytics[key] = analytics.factory(key);
}
// Define a method to load Analytics.js from our CDN,
// and that will be sure to only ever load it once.
analytics.load = function (key, options) {
// Create an async script element based on your key.
const script = document.createElement('script');
script.type = 'text/javascript';
script.async = true;
script.src = `https://cdn.segment.com/analytics.js/v1/${
key}/analytics.min.js`;
// Insert our script next to the first script element.
const first = document.getElementsByTagName('script')[0];
first.parentNode.insertBefore(script, first);
analytics._loadOptions = options;
};
// Add a version to keep track of what's in the wild.
analytics.SNIPPET_VERSION = '4.1.0';
// Load Analytics.js with your key, which will automatically
// load the tools you've enabled for your account. Boosh!
analytics.load(configuration.SEGMENT_KEY);
}());