Merge pull request #2 from edx/initial-merge

Initial merge
This commit is contained in:
Ben Warzeski
2021-09-28 17:51:34 -04:00
committed by GitHub
95 changed files with 30500 additions and 0 deletions

10
.dockerignore Executable file
View File

@@ -0,0 +1,10 @@
node_modules
npm-debug.log
README.md
LICENSE
.babelrc
.eslintignore
.eslintrc.json
.gitignore
.npmignore
commitlint.config.js

32
.env Normal file
View File

@@ -0,0 +1,32 @@
NODE_ENV='production'
NODE_PATH=./src
BASE_URL=''
LMS_BASE_URL=''
LOGIN_URL=''
LOGOUT_URL=''
CSRF_TOKEN_API_PATH=''
REFRESH_ACCESS_TOKEN_ENDPOINT=''
DATA_API_BASE_URL=''
SEGMENT_KEY=''
FEATURE_FLAGS={}
ACCESS_TOKEN_COOKIE_NAME=''
NEW_RELIC_APP_ID=''
NEW_RELIC_LICENSE_KEY=''
SITE_NAME=''
MARKETING_SITE_BASE_URL=''
SUPPORT_URL=''
CONTACT_URL=''
OPEN_SOURCE_URL=''
TERMS_OF_SERVICE_URL=''
PRIVACY_POLICY_URL=''
FACEBOOK_URL=''
TWITTER_URL=''
YOU_TUBE_URL=''
LINKED_IN_URL=''
REDDIT_URL=''
APPLE_APP_STORE_URL=''
GOOGLE_PLAY_URL=''
ENTERPRISE_MARKETING_URL=''
ENTERPRISE_MARKETING_UTM_SOURCE=''
ENTERPRISE_MARKETING_UTM_CAMPAIGN=''
ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM=''

38
.env.development Normal file
View File

@@ -0,0 +1,38 @@
NODE_ENV='development'
PORT=8181
BASE_URL='localhost:8181'
LMS_BASE_URL='http://localhost:18000'
LOGIN_URL='http://localhost:18000/login'
LOGOUT_URL='http://localhost:18000/logout'
LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
LOGO_POWERED_BY_OPEN_EDX_URL_SVG=https://edx-cdn.org/v3/stage/open-edx-tag.svg
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
USER_INFO_COOKIE_NAME='edx-user-info'
SITE_NAME=localhost
DATA_API_BASE_URL='http://localhost:8000'
// LMS_CLIENT_ID should match the lms DOT client application id your LMS containe
LMS_CLIENT_ID='login-service-client-id'
SEGMENT_KEY=''
FEATURE_FLAGS={}
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'
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'
ENTERPRISE_MARKETING_URL='http://example.com'
ENTERPRISE_MARKETING_UTM_SOURCE='example.com'
ENTERPRISE_MARKETING_UTM_CAMPAIGN='example.com Referral'
ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM='Footer'

38
.env.test Normal file
View File

@@ -0,0 +1,38 @@
NODE_ENV='test'
PORT=8181
BASE_URL='localhost:8181'
LMS_BASE_URL='http://localhost:18000'
LOGIN_URL='http://localhost:18000/login'
LOGOUT_URL='http://localhost:18000/logout'
LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
LOGO_POWERED_BY_OPEN_EDX_URL_SVG=https://edx-cdn.org/v3/stage/open-edx-tag.svg
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
USER_INFO_COOKIE_NAME='edx-user-info'
SITE_NAME=localhost
DATA_API_BASE_URL='http://localhost:8000'
// LMS_CLIENT_ID should match the lms DOT client application id your LMS containe
LMS_CLIENT_ID='login-service-client-id'
SEGMENT_KEY=''
FEATURE_FLAGS={}
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'
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'
ENTERPRISE_MARKETING_URL='http://example.com'
ENTERPRISE_MARKETING_UTM_SOURCE='example.com'
ENTERPRISE_MARKETING_UTM_CAMPAIGN='example.com Referral'
ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM='Footer'

5
.eslintignore Executable file
View File

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

21
.eslintrc.js Normal file
View File

@@ -0,0 +1,21 @@
const { createConfig } = require('@edx/frontend-build');
const config = createConfig('eslint', {
rules: {
'import/no-named-as-default': 'off',
'import/no-named-as-default-member': 'off',
'import/no-self-import': 'off',
'spaced-comment': ['error', 'always', { 'block': { 'exceptions': ['*'] } }],
},
});
config.settings = {
"import/resolver": {
node: {
paths: ["src", "node_modules"],
extensions: [".js", ".jsx"],
},
},
};
module.exports = config;

23
.gitignore vendored Executable file
View File

@@ -0,0 +1,23 @@
.DS_Store
.eslintcache
node_modules
npm-debug.log
coverage
dist/
### pyenv ###
.python-version
### Emacs ###
*~
*.swo
*.swp
### Development environments ###
.idea
.vscode
### transifex ###
src/i18n/transifex_input.json
temp

13
.npmignore Executable file
View File

@@ -0,0 +1,13 @@
.eslintignore
.eslintrc.json
.gitignore
.travis.yml
docker-compose.yml
Dockerfile
Makefile
npm-debug.log
config
coverage
node_modules
public

27
.releaserc Normal file
View File

@@ -0,0 +1,27 @@
{
"branch": "master",
"tagFormat": "v${version}",
"verifyConditions": [
"@semantic-release/npm",
{
"path": "@semantic-release/github",
"assets": {
"path": "dist/*"
}
}
],
"analyzeCommits": "@semantic-release/commit-analyzer",
"generateNotes": "@semantic-release/release-notes-generator",
"prepare": "@semantic-release/npm",
"publish": [
"@semantic-release/npm",
{
"path": "@semantic-release/github",
"assets": {
"path": "dist/*"
}
}
],
"success": [],
"fail": []
}

28
.travis.yml Executable file
View File

@@ -0,0 +1,28 @@
language: node_js
node_js: 12
notifications:
email:
recipients:
- masters-grades@edx.org
on_success: never
on_failure: always
webhooks: https://www.travisbuddy.com/
on_success: never
before_install:
- npm install -g greenkeeper-lockfile@1.14.0
install:
- npm ci
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:
- npm run travis-deploy-once "npm run semantic-release"
- npm run coveralls
env:
global:
- secure: bBLQZVw1aVUxB7GFNXGrdKeztyFrCCJusVgFcSuej9S4qmj9/jrVsEc9dEcH+BMS+b49+SvILoxzd6ZYLaRygQLzevnO1/dX596DeCKVK48PTTZRsNyafaSMCkxNKqEmRcA9hYL52xJJ5GpKo7ViWsFy8VFgUfZEJxQi8/lYbfQ1vlXRpo2LJfJh09v85roSXdQmajyGJ1Dz6elcwUX5B+BgXmIHizJXUMfFci61xTEZmgKtfeCiwFQA5pCvVMHBQhgySqT2N3eRESzRt2jAfAdcRKBYXS0rwKymdlL1ZF349Jm8xwtqm19Fwsut21181Lnn6FmccMWhQ7man3WH1xfT0ahmHNs1KJMyZcwRJd/gDfbd6iD3LB9Pt9hEQ00Qh/m7MYeahMxTEL9bp2TyILi8cTP91jeBUHCExCdv2jRrUQEnUS5vZUYRdM8CR2DLoLmNh3APndKzwgr5U8rh6RdhbQBJp97Hb/YYVrBiP2atLJAaYPY/xEQHK/YoXelQgiZ6wHBMV+tF/L0ZRn7KyVWdkbBKWfbEjRKbEJD9WD+V7HayMR81tm5CSqlrG8mTvSy2boIGiX14GV11ZEfMj5bjb6W41BW+QGqQerZvmwk/4ywe304X85PD0OBhIYPRzeLIi0Gt6lD1aOpVxgm4M03tdgYQzCPWRPq32CB+1IA=
- secure: w1d/E+cc4+Bf017Jpp9YsKBzLSZw9sqKZGeM2tNrO6eJZbMJqfKTmfUrRw8BoLh1Z8YRkHF7RADDy3ln7XEdeAX3j9OoC3Cz0zN6iDX6TPcI461NuOIscJYb4tyFcuWm6FhgVlBAlo/BI3q+zqKwjfWuDaORpk6+haacCmvTe5V0vWhY+MYT7M+LfnKeKVzhI4magGt8jPTE21oziIFwCqCCjJc4+AmsWoWTzU0Q7Db0DZiJnLXFfXybLbkedAgJmcSgEGZCSpaZIOkX0/Lbazsz1Ky4KASfkrYT1Z5iKQ8TE3skmx1IIu+1egN8iBbdrY+NhvV24RkT+rpUvD7TBIHTrjQ5JYLe0kGjN70vG7YlKgjNSyTjkrEd7fCKpuIol3DVjBRz3tV5aCl0t/A8mIPqKyNI94MamWsExpqsxgcb9vBVno5caZvD8ZXNrGNqanB3MSoLGxZTLKif9u+AZfLnB3xtjaiJg3/BNoWaOBPlp/M6BvGIGHElwvLrAhUvl8wzrwJcQQWpmRMh0b6enr6Y7ox/mGGs7NBCT+CNKEsWeCfY4thZzgi6/GocXyqdTpXMkNSI1PDoPmi+vKafBd+7aAYbcUlJBTU6TAxyncln0tF2JF+ghTZ0v8nNzEQ9VmV4ddyoOHx6YnHvEcenWZGMROQnMCVifyDbaHpPbPI=

8
.tx/config Normal file
View File

@@ -0,0 +1,8 @@
[main]
host = https://www.transifex.com
[edx-platform.frontend-app-gradebook]
file_filter = src/i18n/messages/<lang>.json
source_file = src/i18n/transifex_input.json
source_lang = en
type = KEYVALUEJSON

0
LICENSE Normal file → Executable file
View File

65
Makefile Executable file
View File

@@ -0,0 +1,65 @@
npm-install-%: ## install specified % npm package
npm install $* --save-dev
git add package.json
transifex_resource = ora-enhanced-staff-grader
transifex_langs = "ar,fr,es_419,zh_CN"
transifex_utils = ./node_modules/.bin/transifex-utils.js
i18n = ./src/i18n
transifex_input = $(i18n)/transifex_input.json
tx_url1 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/translation/en/strings/
tx_url2 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/source/
# This directory must match .babelrc .
transifex_temp = ./temp/babel-plugin-react-intl
NPM_TESTS=build i18n_extract lint test is-es5
.PHONY: test
test: $(addprefix test.npm.,$(NPM_TESTS)) ## validate ci suite
.PHONY: test.npm.*
test.npm.%: validate-no-uncommitted-package-lock-changes
test -d node_modules || $(MAKE) requirements
npm run $(*)
.PHONY: requirements
requirements: ## install ci requirements
npm ci
i18n.extract:
# Pulling display strings from .jsx files into .json files...
rm -rf $(transifex_temp)
npm run-script i18n_extract
i18n.concat:
# Gathering JSON messages into one file...
$(transifex_utils) $(transifex_temp) $(transifex_input)
extract_translations: | requirements i18n.extract i18n.concat
# Despite the name, we actually need this target to detect changes in the incoming translated message files as well.
detect_changed_source_translations:
# Checking for changed translations...
git diff --exit-code $(i18n)
# Pushes translations to Transifex. You must run make extract_translations first.
push_translations:
# Pushing strings to Transifex...
tx push -s
# Fetching hashes from Transifex...
./node_modules/reactifex/bash_scripts/get_hashed_strings.sh $(tx_url1)
# Writing out comments to file...
$(transifex_utils) $(transifex_temp) --comments
# Pushing comments to Transifex...
./node_modules/reactifex/bash_scripts/put_comments.sh $(tx_url2)
# Pulls translations from Transifex.
pull_translations:
tx pull -f --mode reviewed --language=$(transifex_langs)
# This target is used by Travis.
validate-no-uncommitted-package-lock-changes:
# Checking for package-lock.json changes...
git diff --exit-code package-lock.json

3
babel.config.js Normal file
View File

@@ -0,0 +1,3 @@
const { createConfig } = require('@edx/frontend-build');
module.exports = createConfig('babel');

77
documentation/.travis.yml.md Executable file
View File

@@ -0,0 +1,77 @@
# Travis Configuration
Your project might have different build requirements - however, this project's `.travis.yml` configuration is supposed to represent a good starting point.
## Node JS Version
The minimum `Node` and `npm` versions that edX supports is `8.9.3` and `5.5.1`, respectively.
## Caching node_modules
While [the `Travis` blog](https://blog.travis-ci.com/2016-11-21-travis-ci-now-supports-yarn) recommends
```yaml
cache:
directories:
- node_modules
```
this causes issues when testing different versions of `Node` because [`node_modules` will store the compiled native modules](https://stackoverflow.com/a/42523517/5225575).
Caching the `~/.npm` directory avoids storing these native modules.
## Notifications
This project uses a service called [`TravisBuddy`](https://www.travisbuddy.com/), which provides Travis build context within a PR via webhooks (configured only to add feedback for build failures).
![travis-buddy](https://i.imgur.com/VsR2TTs.png)
## Installing `greenkeeper-lockfile`
As explained in [the `Greenkeeper` documentation](https://greenkeeper.io/docs.html#greenkeeper-step-by-step), `Greenkeeper` is a service that keeps track of your project's dependencies, and will, for example, automatically open PRs with an updated `package.json` file when the latest version of a dependency is a major version ahead of the existing dependency version in your `package.json` file.
This automated updating is great, but `Greenkeeper` does not update your `package-lock.json` file, just your `package.json` file. This makes sense, as the only way to update the `package-lock.json` file would be to run `npm install` when building your project, using the latest `package.json`, and then committing the updated `package-lock.json` file.
This is essentially what you have to do manually when `Greenkeeper` opens a PR - `git checkout` the branch, `npm install` locally, `git commit` the `package-lock.json` changes, and then `git push` those changes to the `Greenkeeper` branch on `origin`. It's fun probably only the first time, and even then it gets old, fast.
What [`greenkeeper-lockfile`](https://github.com/greenkeeperio/greenkeeper-lockfile) does is that it automates the previous steps as part of the build process.
It will
* Check that the branch is a `Greenkeeper` branch
* Update the lockfile
* Push a commit with the updated lockfile back to the Greenkeeper branch
This is why it's important to install `greenkeeper-lockfile` in the `before_install` step, and since it's used exclusively only in the Travis Build, why it's not part of the package's dependencies.
## Scripts
Most of the `script`s are self-explanatory - you probably want to fail a build if there are linting violations, or if any tests don't pass, or if it cannot compile your files.
However, there are a couple additional `script`s that might seem less self-explanatory.
### What the heck is `make validate-no-uncommitted-package-lock-changes`?
There are only two requirements for a good `make target` name
1. Definitely make it really verbose so people can't remember what it's called
2. Definitely don't not use a double-negative
What `make validate-no-uncommitted-package-lock-changes` does is `git diff`s for any `package-lock.json` file changes in your project. It's important to remember that all build `script`s are executed in Travis *after* the `install` step (aka post-`npm install`).
This is important because `npm` uses the pinned dependencies in your `package-lock.json` file to build the `node_modules` directory. However, the dependencies defined within the `package.json` file can be modified manually, for example, to become misaligned with the dependencies defined within the `package-lock.json`. So when `npm install` executes, the `package-lock.json` file will be updated to mirror the modified `package.json` changes.
However, when these changes surface within a Travis build, this indicates differing dependency expectations between the committed `package.json` file and the `package-lock.json` file, which is a good reason to fail a build.
### What is this `npm run is-es5` check?
This project outputs production files to the `dist` folder. The `npm script`, `npm run is-es5`, checks the JavaScript files in the `dist` folder to make sure that they are `ES5`-compliant.
This check is important because `ES5` JavaScript has [greater browser compatibility](http://kangax.github.io/compat-table/es5/) than [`ES2015+`](http://kangax.github.io/compat-table/es6/) - particularly for `IE11`.
### `deploy` step
How your project deploys will probably differ between the cookie cutter and your own application.
For demonstrational purposes, the cookie cutter deploys to GitHub pages using [`Travis`'s GitHub pages configuration](https://docs.travis-ci.com/user/deployment/pages/).
Your application might deploy to an `S3` bucket or to `npm`.

15
jest.config.js Normal file
View File

@@ -0,0 +1,15 @@
const { createConfig } = require('@edx/frontend-build');
module.exports = createConfig('jest', {
setupFilesAfterEnv: [
'<rootDir>/src/setupTest.js',
],
modulePaths: ['<rootDir>/src/'],
snapshotSerializers: [
'enzyme-to-json/serializer',
],
coveragePathIgnorePatterns: [
'src/segment.js',
'src/postcss.config.js',
],
});

9
openedx.yaml Normal file
View File

@@ -0,0 +1,9 @@
# 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
tags:
- frontend-app
- masters
oeps:
oep-2: true # Repository metadata
openedx-release: {ref: master}

26675
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

90
package.json Executable file
View File

@@ -0,0 +1,90 @@
{
"name": "@edx/frontend-app-ora-enhanced-staff-grader",
"version": "0.0.1",
"description": "",
"repository": {
"type": "git",
"url": "git+https://github.com/edx/frontend-app-ora-enhanced-staff-grader.git"
},
"scripts": {
"build": "fedx-scripts webpack",
"coveralls": "cat ./coverage/lcov.info | coveralls",
"is-es5": "es-check es5 ./dist/*.js",
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
"lint": "fedx-scripts eslint --ext .jsx,.js src/",
"lint-fix": "fedx-scripts eslint --fix --ext .jsx,.js src/",
"prepush": "npm run lint",
"semantic-release": "semantic-release",
"start": "fedx-scripts webpack-dev-server --progress",
"test": "TZ=GMT fedx-scripts jest --coverage --passWithNoTests",
"watch-tests": "jest --watch",
"travis-deploy-once": "travis-deploy-once"
},
"author": "edX",
"license": "AGPL-3.0",
"homepage": "",
"publishConfig": {
"access": "public"
},
"dependencies": {
"@edx/brand": "npm:@edx/brand-edx.org@^2.0.3",
"@edx/frontend-component-footer": "10.1.6",
"@edx/frontend-platform": "1.12.4",
"@edx/paragon": "16.13.2",
"@fortawesome/fontawesome-svg-core": "^1.2.36",
"@fortawesome/free-brands-svg-icons": "^5.15.4",
"@fortawesome/free-solid-svg-icons": "^5.15.4",
"@fortawesome/react-fontawesome": "^0.1.15",
"@redux-beacon/segment": "^1.1.0",
"@reduxjs/toolkit": "^1.6.1",
"classnames": "^2.3.1",
"core-js": "3.16.2",
"dompurify": "^2.3.1",
"email-prop-type": "^3.0.1",
"enzyme": "^3.11.0",
"enzyme-to-json": "^3.6.2",
"font-awesome": "4.7.0",
"history": "5.0.1",
"html-react-parser": "^1.3.0",
"lodash": "^4.17.21",
"node-sass": "^6.0.1",
"prop-types": "15.7.2",
"query-string": "7.0.1",
"react": "17.0.2",
"react-dom": "17.0.2",
"react-intl": "^5.20.9",
"react-redux": "^7.2.4",
"react-router": "5.2.0",
"react-router-dom": "5.2.0",
"react-router-redux": "^5.0.0-alpha.9",
"redux": "4.1.1",
"redux-beacon": "^2.1.0",
"redux-devtools-extension": "2.13.9",
"redux-logger": "3.0.6",
"redux-thunk": "2.3.0",
"regenerator-runtime": "^0.13.9",
"reselect": "^4.0.0",
"util": "^0.12.4",
"whatwg-fetch": "^3.6.2"
},
"devDependencies": {
"@edx/frontend-build": "8.0.4",
"@testing-library/jest-dom": "^5.14.1",
"@testing-library/react": "^12.1.0",
"axios": "0.21.1",
"axios-mock-adapter": "^1.20.0",
"codecov": "^3.8.3",
"enzyme-adapter-react-16": "^1.15.6",
"es-check": "^6.0.0",
"fetch-mock": "^9.11.0",
"husky": "7.0.1",
"identity-obj-proxy": "^3.0.0",
"jest": "27.0.6",
"react-dev-utils": "^11.0.4",
"react-test-renderer": "^17.0.2",
"reactifex": "1.1.1",
"redux-mock-store": "^1.5.4",
"semantic-release": "^17.4.5",
"travis-deploy-once": "^5.0.11"
}
}

12
public/index.html Executable file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en-us" dir="ltr">
<head>
<title>ORA Enhanced Staff Grader | <%= process.env.SITE_NAME %></title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="shortcut icon" href="<%=htmlWebpackPlugin.options.FAVICON_URL%>" type="image/x-icon" />
</head>
<body>
<div id="root"></div>
</body>
</html>

49
src/App.jsx Executable file
View File

@@ -0,0 +1,49 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { BrowserRouter as Router } from 'react-router-dom';
import Footer from '@edx/frontend-component-footer';
import selectors from 'data/selectors';
import ListView from 'containers/ListView';
import './App.scss';
import { Header } from 'containers/CourseHeader';
const App = ({ courseMetadata }) => (
<Router>
<div>
<Header
courseTitle={courseMetadata.title}
courseNumber={courseMetadata.number}
courseOrg={courseMetadata.org}
/>
<main>
<ListView />
</main>
<Footer logo={process.env.LOGO_POWERED_BY_OPEN_EDX_URL_SVG} />
</div>
</Router>
);
App.defaultProps = {
courseMetadata: {
title: '',
number: null,
org: '',
},
};
App.propTypes = {
courseMetadata: PropTypes.shape({
title: PropTypes.string,
number: PropTypes.string,
org: PropTypes.string,
}),
};
export const mapStateToProps = (state) => ({
courseMetadata: selectors.app.courseMetadata(state),
});
export default connect(mapStateToProps)(App);

77
src/App.scss Executable file
View File

@@ -0,0 +1,77 @@
// frontend-app-*/src/index.scss
@import "~@edx/brand/paragon/fonts";
@import "~@edx/brand/paragon/variables";
@import "~@edx/paragon/scss/core/core";
@import "~@edx/brand/paragon/overrides";
$fa-font-path: "~font-awesome/fonts";
@import "~font-awesome/scss/font-awesome";
$input-focus-box-shadow: $input-box-shadow; // hack to get upgrade to paragon 4.0.0 to work
@import "~@edx/frontend-component-footer/dist/_footer";
#root {
display: flex;
flex-direction: column;
min-height: 100vh;
main {
flex-grow: 1;
}
header {
flex: 0 0 auto;
.logo {
display: block;
box-sizing: content-box;
position: relative;
top: 0.1em;
height: 1.75rem;
margin-right: 1rem;
img {
display: block;
height: 100%;
}
}
}
footer {
flex: 0;
}
}
.course-header {
min-width: 0;
border-bottom: 1px solid black;
.course-title-lockup {
min-width: 0;
span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding-bottom: 0.1rem;
}
}
.user-dropdown {
.btn {
height: 3rem;
@media (max-width: -1 + map-get($grid-breakpoints, "sm")) {
padding: 0 0.5rem;
}
}
}
}
#paragon-portal-root {
.pgn__modal-layer {
.pgn__modal-close-container {
right: 1rem !important;
}
}
}

80
src/App.test.jsx Normal file
View File

@@ -0,0 +1,80 @@
import React from 'react';
import { shallow } from 'enzyme';
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
import { Provider } from 'react-redux';
import { IntlProvider } from 'react-intl';
import Footer from '@edx/frontend-component-footer';
import { routePath } from 'data/constants/app';
import store from 'data/store';
import ListView from 'containers/ListView';
import App from './App';
jest.mock('react-router-dom', () => ({
BrowserRouter: () => 'BrowserRouter',
Route: () => 'Route',
Switch: () => 'Switch',
}));
jest.mock('react-redux', () => ({
Provider: () => 'Provider',
}));
jest.mock('react-intl', () => ({
IntlProvider: () => 'IntlProvider',
}));
jest.mock('data/constants/app', () => ({
routePath: '/:courseId',
}));
jest.mock('@edx/frontend-component-footer', () => 'Footer');
jest.mock('data/store', () => 'testStore');
jest.mock('containers/ListView', () => 'ListView');
const logo = 'fakeLogo.png';
let el;
let router;
describe('App router component', () => {
test('snapshot', () => {
expect(shallow(<App />)).toMatchSnapshot();
});
describe('component', () => {
beforeEach(() => {
process.env.LOGO_POWERED_BY_OPEN_EDX_URL_SVG = logo;
el = shallow(<App />);
router = el.childAt(0).childAt(0);
});
describe('IntlProvider', () => {
test('outer-wrapper component', () => {
expect(el.type()).toBe(IntlProvider);
});
test('"en" locale', () => {
expect(el.props().locale).toEqual('en');
});
});
describe('Provider, inside IntlProvider', () => {
test('first child, passed the redux store props', () => {
expect(el.childAt(0).type()).toBe(Provider);
expect(el.childAt(0).props().store).toEqual(store);
});
});
describe('Router', () => {
test('first child of Provider', () => {
expect(router.type()).toBe(Router);
});
test('Routing - ListView is only route', () => {
expect(router.find('main')).toEqual(shallow(
<main>
<Switch>
<Route exact path={routePath} component={ListView} />
</Switch>
</main>,
));
});
});
test('Footer logo drawn from env variable', () => {
expect(router.find(Footer).props().logo).toEqual(logo);
});
});
});

View File

@@ -0,0 +1,26 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`App router component snapshot 1`] = `
<IntlProvider
locale="en"
>
<Provider
store="testStore"
>
<BrowserRouter>
<div>
<main>
<Switch>
<Route
component="ListView"
exact={true}
path="/:courseId"
/>
</Switch>
</main>
<Footer />
</div>
</BrowserRouter>
</Provider>
</IntlProvider>
`;

View File

@@ -0,0 +1,60 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import {
Card,
} from '@edx/paragon';
import createDOMPurify from 'dompurify';
import parse from 'html-react-parser';
import selectors from 'data/selectors';
/**
* <ResponseDisplay />
*/
export class ResponseDisplay extends React.Component {
constructor(props) {
super(props);
this.purify = createDOMPurify(window);
}
get textContent() {
return parse(this.purify.sanitize(this.props.response.text));
}
get hasResponse() {
return this.props.response !== undefined;
}
render() {
return (
<Card className="response-card">
{this.hasResponse && (
<Card.Body>
{this.textContent}
</Card.Body>
)}
</Card>
);
}
}
ResponseDisplay.defaultProps = {
};
ResponseDisplay.propTypes = {
response: PropTypes.shape({
text: PropTypes.string,
}).isRequired,
};
export const mapStateToProps = (state) => ({
response: selectors.grading.selectedResponse(state),
});
export const mapDispatchToProps = {
};
export default connect(mapStateToProps, mapDispatchToProps)(ResponseDisplay);

View File

@@ -0,0 +1,42 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Badge } from '@edx/paragon';
import {
gradingStatuses as statuses,
gradingStatusDisplay as statusDisplay,
} from 'data/services/lms/constants';
export const statusVariants = {
[statuses.ungraded]: 'primary',
[statuses.locked]: 'light',
[statuses.graded]: 'success',
[statuses.inProgress]: 'warning',
};
/**
* <StatusBadge />
*/
export const StatusBadge = ({ className, status }) => {
if (statusVariants[status] === undefined) {
return null;
}
return (
<Badge
className={className}
variant={statusVariants[status]}
>
{statusDisplay[status]}
</Badge>
);
};
StatusBadge.defaultProps = {
className: '',
};
StatusBadge.propTypes = {
className: PropTypes.string,
status: PropTypes.string.isRequired,
};
export default StatusBadge;

16
src/config/index.js Normal file
View File

@@ -0,0 +1,16 @@
const configuration = {
// BASE_URL: process.env.BASE_URL,
// LMS_BASE_URL: process.env.LMS_BASE_URL,
// LOGIN_URL: process.env.LOGIN_URL,
// LOGOUT_URL: process.env.LOGOUT_URL,
// CSRF_TOKEN_API_PATH: process.env.CSRF_TOKEN_API_PATH,
// REFRESH_ACCESS_TOKEN_ENDPOINT: process.env.REFRESH_ACCESS_TOKEN_ENDPOINT,
// DATA_API_BASE_URL: process.env.DATA_API_BASE_URL,
// SECURE_COOKIES: process.env.NODE_ENV !== 'development',
// SEGMENT_KEY: process.env.SEGMENT_KEY,
// ACCESS_TOKEN_COOKIE_NAME: process.env.ACCESS_TOKEN_COOKIE_NAME,
};
const features = {};
export { configuration, features };

View File

@@ -0,0 +1,34 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { getLoginRedirectUrl } from '@edx/frontend-platform/auth';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon';
import message from './AnonymousUserMenu.messages';
function AnonymousUserMenu({ intl }) {
return (
<div>
<Button
className="mr-3"
variant="outline-primary"
href={`${getConfig().LMS_BASE_URL}/register?next=${encodeURIComponent(global.location.href)}`}
>
{intl.formatMessage(message.registerSentenceCase)}
</Button>
<Button
variant="primary"
href={`${getLoginRedirectUrl(global.location.href)}`}
>
{intl.formatMessage(message.signInSentenceCase)}
</Button>
</div>
);
}
AnonymousUserMenu.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(AnonymousUserMenu);

View File

@@ -0,0 +1,31 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
close: {
id: 'general.altText.close',
defaultMessage: 'Close',
description: 'Text used as an aria-label to describe closing or dismissing a component',
},
registerLowercase: {
id: 'learning.logistration.register', // ID left for historical purposes
defaultMessage: 'register',
description: 'Text in a link, prompting the user to create an account. Used in "learning.logistration.alert"',
},
registerSentenceCase: {
id: 'general.register.sentenceCase',
defaultMessage: 'Register',
description: 'Text in a button, prompting the user to register.',
},
signInLowercase: {
id: 'learning.logistration.login', // ID left for historical purposes
defaultMessage: 'sign in',
description: 'Text in a link, prompting the user to log in. Used in "learning.logistration.alert"',
},
signInSentenceCase: {
id: 'general.signIn.sentenceCase',
defaultMessage: 'Sign in',
description: 'Text in a button, prompting the user to log in.',
},
});
export default messages;

View File

@@ -0,0 +1,53 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faUserCircle } from '@fortawesome/free-solid-svg-icons';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Dropdown } from '@edx/paragon';
import messages from './messages';
function AuthenticatedUserDropdown({ intl, username }) {
let dashboardMenuItem = (
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/dashboard`}>
{intl.formatMessage(messages.dashboard)}
</Dropdown.Item>
);
return (
<>
<a className="text-gray-700 mr-3" href={`${getConfig().SUPPORT_URL}`}>{intl.formatMessage(messages.help)}</a>
<Dropdown className="user-dropdown">
<Dropdown.Toggle variant="outline-primary">
<FontAwesomeIcon icon={faUserCircle} className="d-md-none" size="lg" />
<span data-hj-suppress className="d-none d-md-inline">
{username}
</span>
</Dropdown.Toggle>
<Dropdown.Menu className="dropdown-menu-right">
{dashboardMenuItem}
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/u/${username}`}>
{intl.formatMessage(messages.profile)}
</Dropdown.Item>
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/account/settings`}>
{intl.formatMessage(messages.account)}
</Dropdown.Item>
<Dropdown.Item href={getConfig().LOGOUT_URL}>
{intl.formatMessage(messages.signOut)}
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</>
);
}
AuthenticatedUserDropdown.propTypes = {
intl: intlShape.isRequired,
username: PropTypes.string.isRequired,
};
AuthenticatedUserDropdown.defaultProps = {};
export default injectIntl(AuthenticatedUserDropdown);

View File

@@ -0,0 +1,53 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faUserCircle } from '@fortawesome/free-solid-svg-icons';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Dropdown } from '@edx/paragon';
import messages from './messages';
function AuthenticatedUserDropdown({ intl, username }) {
let dashboardMenuItem = (
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/dashboard`}>
{intl.formatMessage(messages.dashboard)}
</Dropdown.Item>
);
return (
<>
<a className="text-gray-700 mr-3" href={`${getConfig().SUPPORT_URL}`}>{intl.formatMessage(messages.help)}</a>
<Dropdown className="user-dropdown">
<Dropdown.Toggle variant="outline-primary">
<FontAwesomeIcon icon={faUserCircle} className="d-md-none" size="lg" />
<span data-hj-suppress className="d-none d-md-inline">
{username}
</span>
</Dropdown.Toggle>
<Dropdown.Menu className="dropdown-menu-right">
{dashboardMenuItem}
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/u/${username}`}>
{intl.formatMessage(messages.profile)}
</Dropdown.Item>
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/account/settings`}>
{intl.formatMessage(messages.account)}
</Dropdown.Item>
<Dropdown.Item href={getConfig().LOGOUT_URL}>
{intl.formatMessage(messages.signOut)}
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</>
);
}
AuthenticatedUserDropdown.propTypes = {
intl: intlShape.isRequired,
username: PropTypes.string.isRequired,
};
AuthenticatedUserDropdown.defaultProps = {};
export default injectIntl(AuthenticatedUserDropdown);

View File

@@ -0,0 +1,81 @@
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { AppContext } from '@edx/frontend-platform/react';
import AnonymousUserMenu from './AnonymousUserMenu';
import AuthenticatedUserDropdown from './AuthenticatedUserDropdown';
import messages from './messages';
function LinkedLogo({
href,
src,
alt,
...attributes
}) {
return (
<a href={href} {...attributes}>
<img className="d-block" src={src} alt={alt} />
</a>
);
}
LinkedLogo.propTypes = {
href: PropTypes.string.isRequired,
src: PropTypes.string.isRequired,
alt: PropTypes.string.isRequired,
};
function Header({
courseOrg, courseNumber, courseTitle, intl, showUserDropdown,
}) {
const { authenticatedUser } = useContext(AppContext);
let headerLogo = (
<LinkedLogo
className="logo"
href={`${getConfig().LMS_BASE_URL}/dashboard`}
src={getConfig().LOGO_URL}
alt={getConfig().SITE_NAME}
/>
);
return (
<header className="course-header">
<a className="sr-only sr-only-focusable" href="#main-content">{intl.formatMessage(messages.skipNavLink)}</a>
<div className="container-xl py-2 d-flex align-items-center">
{headerLogo}
<div className="flex-grow-1 course-title-lockup" style={{ lineHeight: 1 }}>
<span className="d-block small m-0">{courseOrg} {courseNumber}</span>
<span className="d-block m-0 font-weight-bold course-title">{courseTitle}</span>
</div>
{showUserDropdown && authenticatedUser && (
<AuthenticatedUserDropdown
username={authenticatedUser.username}
/>
)}
{showUserDropdown && !authenticatedUser && (
<AnonymousUserMenu />
)}
</div>
</header>
);
}
Header.propTypes = {
courseOrg: PropTypes.string,
courseNumber: PropTypes.string,
courseTitle: PropTypes.string,
intl: intlShape.isRequired,
showUserDropdown: PropTypes.bool,
};
Header.defaultProps = {
courseOrg: null,
courseNumber: null,
courseTitle: null,
showUserDropdown: true,
};
export default injectIntl(Header);

View File

@@ -0,0 +1,29 @@
import React from 'react';
import {
authenticatedUser, initializeMockApp, render, screen,
} from '../setupTest';
import { Header } from './index';
describe('Header', () => {
beforeAll(async () => {
// We need to mock AuthService to implicitly use `getAuthenticatedUser` within `AppContext.Provider`.
await initializeMockApp();
});
it('displays user button', () => {
render(<Header />);
expect(screen.getByRole('button')).toHaveTextContent(authenticatedUser.username);
});
it('displays course data', () => {
const courseData = {
courseOrg: 'course-org',
courseNumber: 'course-number',
courseTitle: 'course-title',
};
render(<Header {...courseData} />);
expect(screen.getByText(`${courseData.courseOrg} ${courseData.courseNumber}`)).toBeInTheDocument();
expect(screen.getByText(courseData.courseTitle)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1 @@
export { default as Header } from './Header';

View File

@@ -0,0 +1,36 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
dashboard: {
id: 'header.menu.dashboard.label',
defaultMessage: 'Dashboard',
description: 'The text for the user menu Dashboard navigation link.',
},
help: {
id: 'header.help.label',
defaultMessage: 'Help',
description: 'The text for the link to the Help Center',
},
profile: {
id: 'header.menu.profile.label',
defaultMessage: 'Profile',
description: 'The text for the user menu Profile navigation link.',
},
account: {
id: 'header.menu.account.label',
defaultMessage: 'Account',
description: 'The text for the user menu Account navigation link.',
},
skipNavLink: {
id: 'header.navigation.skipNavLink',
defaultMessage: 'Skip to main content.',
description: 'A link used by screen readers to allow users to skip to the main content of the page.',
},
signOut: {
id: 'header.menu.signOut.label',
defaultMessage: 'Sign Out',
description: 'The label for the user menu Sign Out action.',
},
});
export default messages;

View File

@@ -0,0 +1,3 @@
#ora-esg-list-view {
padding: 20px;
}

View File

@@ -0,0 +1,149 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import {
DataTable,
TextFilter,
MultiSelectDropdownFilter,
} from '@edx/paragon';
import {
gradingStatusDisplay,
} from 'data/services/lms/constants';
import selectors from 'data/selectors';
import thunkActions from 'data/thunkActions';
import StatusBadge from 'components/StatusBadge';
import ReviewModal from 'containers/ReviewModal';
import './ListView.scss';
const gradeStatusOptions = Object.keys(gradingStatusDisplay).map(key => ({
name: gradingStatusDisplay[key],
value: key
}));
/**
* <ListView />
*/
export class ListView extends React.Component {
constructor(props) {
super(props);
this.props.initializeApp();
this.handleViewAllResponsesClick = this.handleViewAllResponsesClick.bind(this);
}
formatDate = ({ value }) => {
const date = new Date(value);
return date.toLocaleString();
}
formatGrade = ({ value: grade }) => (
grade === null ? '-' : `${grade.pointsEarned}/${grade.pointsPossible}`
);
formatStatus = ({ value }) => (<StatusBadge status={value} />);
handleViewAllResponsesClick(data) {
const getSubmissionId = (row) => row.original.submissionId;
const rows = data.selectedRows.length ? data.selectedRows : data.tableInstance.rows;
this.props.loadSelectionForReview(rows.map(getSubmissionId));
}
render() {
// hide if submissions are not loaded.
if (this.props.listData.length === 0) {
return null;
}
return (
<div id="ora-esg-list-view">
<DataTable
isFilterable
numBreakoutFilters={2}
defaultColumnValues={{ Filter: TextFilter }}
isSelectable
isSortable
isPaginated
itemCount={this.props.listData.length}
initialState={{ pageSize: 10, pageIndex: 0 }}
data={this.props.listData}
tableActions={[
{
buttonText: 'View all responses',
handleClick: this.handleViewAllResponsesClick,
variant: 'primary',
},
]}
bulkActions={[
{
buttonText: 'View selected responses',
handleClick: this.handleViewAllResponsesClick,
variant: 'primary',
},
]}
columns={[
{
Header: 'Username',
accessor: 'username',
},
{
Header: 'Learner submission date',
accessor: 'dateSubmitted',
Cell: this.formatDate,
disableFilters: true,
},
{
Header: 'Grade',
accessor: 'score',
Cell: this.formatGrade,
disableFilters: true,
},
{
Header: 'Grading Status',
accessor: 'gradingStatus',
Cell: this.formatStatus,
Filter: MultiSelectDropdownFilter,
filter: 'includesValue',
filterChoices: gradeStatusOptions,
},
]}
>
<DataTable.TableControlBar />
<DataTable.Table />
<DataTable.EmptyTable content="No results found" />
<DataTable.TableFooter />
</DataTable>
<ReviewModal />
</div>
);
}
}
ListView.defaultProps = {
listData: [],
};
ListView.propTypes = {
initializeApp: PropTypes.func.isRequired,
listData: PropTypes.arrayOf(PropTypes.shape({
username: PropTypes.string,
dateSubmitted: PropTypes.number,
gradingStatus: PropTypes.string,
grade: PropTypes.shape({
pointsEarned: PropTypes.number,
pointsPossible: PropTypes.number,
}),
})),
loadSelectionForReview: PropTypes.func.isRequired,
};
export const mapStateToProps = (state) => ({
listData: selectors.submissions.listData(state),
});
export const mapDispatchToProps = {
initializeApp: thunkActions.app.initialize,
loadSelectionForReview: thunkActions.grading.loadSelectionForReview,
};
export default connect(mapStateToProps, mapDispatchToProps)(ListView);

View File

@@ -0,0 +1,61 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import {
ActionRow,
Button,
} from '@edx/paragon';
import { Edit } from '@edx/paragon/icons';
import actions from 'data/actions';
import selectors from 'data/selectors';
import thunkActions from 'data/thunkActions';
import StatusBadge from 'components/StatusBadge';
import SubmissionNavigation from './SubmissionNavigation';
import './ReviewModal.scss';
export const ReviewActions = ({
gradeStatus,
toggleShowRubric,
showRubric,
username,
startGrading,
}) => (
<div>
<ActionRow className="review-actions">
<span className="review-actions-username">
{username}
<StatusBadge className="review-actions-status" status={gradeStatus} />
</span>
<div className="review-actions-group">
<Button variant="outline-primary" onClick={toggleShowRubric}>
{showRubric ? 'Hide' : 'Show'} Rubric
</Button>
<Button variant="primary" iconAfter={Edit} onClick={startGrading}>Start Grading</Button>
<SubmissionNavigation />
</div>
</ActionRow>
</div>
);
ReviewActions.propTypes = {
gradeStatus: PropTypes.string.isRequired,
username: PropTypes.string.isRequired,
showRubric: PropTypes.bool.isRequired,
toggleShowRubric: PropTypes.func.isRequired,
startGrading: PropTypes.func.isRequired,
};
export const mapStateToProps = (state) => ({
username: selectors.grading.selected.username(state),
gradeStatus: selectors.grading.selected.gradeStatus(state),
showRubric: selectors.app.showRubric(state),
});
export const mapDispatchToProps = {
toggleShowRubric: actions.app.toggleShowRubric,
startGrading: thunkActions.grading.startGrading,
};
export default connect(mapStateToProps, mapDispatchToProps)(ReviewActions);

View File

@@ -0,0 +1,93 @@
@import "@edx/paragon/scss/core/core";
// action reviews
.review-actions {
padding: map_get($spacers, 3);
flex-direction: row;
background-color: $light-200;
.review-actions-username {
flex-grow: 1;
}
.review-actions-status {
margin-left: map_get($spacers, 3);
vertical-align: middle;
}
.review-actions-group {
margin-left: 0;
flex-shrink: 0;
}
}
@include media-breakpoint-down(md) {
.review-actions {
flex-direction: column;
align-items: flex-start !important;
}
}
.review-modal-body {
background-color: $gray-300 !important;
padding: inherit;
& > div.pgn__modal-body-content {
height: 100%;
.row {
height: 100%;
}
}
.content-block {
width: fit-content;
margin: auto;
height: 100%;
}
// text response
.response-card {
padding: map-get($spacers, 0);
max-width: map-get($container-max-widths, "sm");
overflow-y: hidden;
height: fit-content;
}
}
@include media-breakpoint-down(sm) {
.review-modal-body {
padding: 0 !important;
overflow-y: hidden !important;
.response-card {
height: 100%;
}
.content-block .col {
padding: 0;
}
}
}
.grading-rubric-card {
width: 320px;
height: fit-content;
max-height: 100%;
.grading-rubric-header {
box-shadow: 0 0 0.25rem rgba(0, 0, 0, 0.3) !important;
display: flex;
justify-content: center;
padding: map-get($spacers, 3);
}
.grading-rubric-body {
overflow-y: scroll;
}
.grading-rubric-footer {
box-shadow: 0 0 0.25rem rgba(0, 0, 0, 0.3) !important;
display: flex;
justify-content: center;
padding: map-get($spacers, 3);
}
}

View File

@@ -0,0 +1,68 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import {
Button,
} from '@edx/paragon';
import { Cancel, Highlight } from '@edx/paragon/icons';
import actions from 'data/actions';
import selectors from 'data/selectors';
import thunkActions from 'data/thunkActions';
import { gradingStatuses as statuses } from 'data/services/lms/constants';
export const StartGradingButton = ({
gradeStatus,
startGrading,
stopGrading,
}) => {
const buttonArgs = {
[statuses.ungraded]: {
label: 'Start Grading',
iconAfter: Highlight,
onClick: startGrading,
},
[statuses.graded]: {
label: 'Override grade',
iconAfter: Highlight,
onClick: startGrading,
},
[statuses.inProgress]: {
label: 'Stop grading this response',
iconAfter: Cancel,
onClick: stopGrading,
},
};
if (gradeStatus === statuses.locked) {
return null;
}
const args = buttonArgs[gradeStatus];
return (
<Button
variant="primary"
iconAfter={args.iconAfter}
onClick={args.onClick}
>
{args.label}
</Button>
);
};
StartGradingButton.propTypes = {
gradeStatus: PropTypes.string.isRequired,
startGrading: PropTypes.func.isRequired,
stopGrading: PropTypes.func.isRequired,
};
export const mapStateToProps = (state) => ({
gradeStatus: selectors.grading.selected.gradeStatus(state),
});
export const mapDispatchToProps = {
toggleShowRubric: actions.app.toggleShowRubric,
startGrading: thunkActions.grading.startGrading,
// TODO: fix
stopGrading: thunkActions.grading.startGrading,
};
export default connect(mapStateToProps, mapDispatchToProps)(StartGradingButton);

View File

@@ -0,0 +1,65 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { Icon, IconButton } from '@edx/paragon';
import { ChevronLeft, ChevronRight } from '@edx/paragon/icons';
import selectors from 'data/selectors';
import thunkActions from 'data/thunkActions';
/**
* <SubmissionNavigation />
*/
export const SubmissionNavigation = ({
hasPrevSubmission,
hasNextSubmission,
loadPrev,
loadNext,
activeIndex,
selectionLength,
}) => (
<>
<IconButton
size="inline"
disabled={!hasPrevSubmission}
alt="Load previous submission"
src={ChevronLeft}
iconAs={Icon}
onClick={loadPrev}
/>
<span>{activeIndex + 1} of {selectionLength}</span>
<IconButton
size="inline"
disabled={!hasNextSubmission}
alt="Load next submission"
src={ChevronRight}
iconAs={Icon}
onClick={loadNext}
/>
</>
);
SubmissionNavigation.defaultProps = {
};
SubmissionNavigation.propTypes = {
hasPrevSubmission: PropTypes.bool.isRequired,
hasNextSubmission: PropTypes.bool.isRequired,
loadPrev: PropTypes.func.isRequired,
loadNext: PropTypes.func.isRequired,
activeIndex: PropTypes.number.isRequired,
selectionLength: PropTypes.number.isRequired,
};
export const mapStateToProps = (state) => ({
hasPrevSubmission: selectors.grading.hasPrevSubmission(state),
hasNextSubmission: selectors.grading.hasNextSubmission(state),
activeIndex: selectors.grading.activeIndex(state),
selectionLength: selectors.grading.selectionLength(state),
});
export const mapDispatchToProps = {
loadPrev: thunkActions.grading.loadPrev,
loadNext: thunkActions.grading.loadNext,
};
export default connect(mapStateToProps, mapDispatchToProps)(SubmissionNavigation);

View File

@@ -0,0 +1,81 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import {
FullscreenModal,
Row,
Col,
} from '@edx/paragon';
import selectors from 'data/selectors';
import actions from 'data/actions';
import ResponseDisplay from 'components/ResponseDisplay';
import Rubric from 'containers/Rubric';
import ReviewActions from './ReviewActions';
import './ReviewModal.scss';
/**
* <ReviewModal />
*/
export class ReviewModal extends React.Component {
constructor(props) {
super(props);
this.onClose = this.onClose.bind(this);
}
onClose() {
this.props.setShowReview(false);
}
render() {
if (this.props.response === null) {
return null;
}
return (
<FullscreenModal
title={this.props.oraName}
isOpen={this.props.isOpen}
beforeBodyNode={<ReviewActions />}
onClose={this.onClose}
className="review-modal"
modalBodyClassName="review-modal-body"
>
<div className="content-block">
<Row className="flex-nowrap">
<Col><ResponseDisplay /></Col>
{ this.props.showRubric && <Rubric /> }
</Row>
</div>
</FullscreenModal>
);
}
}
ReviewModal.defaultProps = {
response: null,
};
ReviewModal.propTypes = {
oraName: PropTypes.string.isRequired,
isOpen: PropTypes.bool.isRequired,
response: PropTypes.shape({
text: PropTypes.node,
}),
setShowReview: PropTypes.func.isRequired,
showRubric: PropTypes.bool.isRequired,
};
export const mapStateToProps = (state) => ({
isOpen: selectors.app.showReview(state),
oraName: selectors.app.oraName(state),
response: selectors.grading.selected.response(state),
showRubric: selectors.app.showRubric(state),
});
export const mapDispatchToProps = {
setShowReview: actions.app.setShowReview,
};
export default connect(mapStateToProps, mapDispatchToProps)(ReviewModal);

View File

@@ -0,0 +1,93 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import {
Card,
Button,
Form,
} from '@edx/paragon';
/**
* <GradingRubric />
*/
export const GradingRubric = ({
}) => {
return (
<Card className="grading-rubric-card">
<Card.Body className="grading-rubric-body">
<h3>Rubric</h3>
<hr />
<Form.Group>
<Form.Label>Which Color?</Form.Label>
<Form.RadioSet
name="colors"
>
<Form.Radio value="red">Red</Form.Radio>
<Form.Radio value="green">Green</Form.Radio>
<Form.Radio value="blue">Blue</Form.Radio>
<Form.Radio value="cyan" disabled>Cyan</Form.Radio>
</Form.RadioSet>
</Form.Group>
<Form.Group>
<Form.Control
floatingLabel="Comments"
/>
</Form.Group>
<Form.Group isInvalid>
<Form.Label>Which Color?</Form.Label>
<Form.RadioSet
name="colors"
>
<Form.Radio value="red">Red</Form.Radio>
<Form.Radio value="green">Green</Form.Radio>
<Form.Radio value="blue">Blue</Form.Radio>
<Form.Radio value="cyan" disabled>Cyan</Form.Radio>
</Form.RadioSet>
<Form.Control.Feedback type="invalid">
Make a selection
</Form.Control.Feedback>
</Form.Group>
<Form.Group isInvalid>
<Form.Control
floatingLabel="Comments"
/>
<Form.Control.Feedback type="invalid">
Make a comment
</Form.Control.Feedback>
</Form.Group>
<Form.Group>
<Form.Label>Which Color?</Form.Label>
<Form.RadioSet
name="colors"
>
<Form.Radio value="red">Red</Form.Radio>
<Form.Radio value="green">Green</Form.Radio>
<Form.Radio value="blue">Blue</Form.Radio>
<Form.Radio value="cyan" disabled>Cyan</Form.Radio>
</Form.RadioSet>
</Form.Group>
<Form.Group>
<Form.Control
floatingLabel="Comments"
/>
</Form.Group>
</Card.Body>
<div className="grading-rubric-footer">
<Button>Submit Grade</Button>
</div>
</Card>
);
}
GradingRubric.defaultProps = {};
GradingRubric.propTypes = {};
export const mapStateToProps = (state) => ({
});
export const mapDispatchToProps = {
};
export default connect(mapStateToProps, mapDispatchToProps)(GradingRubric);

View File

@@ -0,0 +1,33 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import {
Card,
Button,
} from '@edx/paragon';
/**
* <GradingRubric />
*/
export const Rubric = ({
}) => {
return (
<Card className="grading-rubric-card">
<Card.Body className="grading-rubric-body" />
<div className="grading-rubric-footer">
<Button>Submit Grade</Button>
</div>
</Card>
);
}
Rubric.defaultProps = {};
Rubric.propTypes = {};
export const mapStateToProps = (state) => ({
});
export const mapDispatchToProps = {
};
export default connect(mapStateToProps, mapDispatchToProps)(Rubric);

19
src/data/actions/app.js Normal file
View File

@@ -0,0 +1,19 @@
import { StrictDict } from 'utils';
import { createActionFactory } from './utils';
export const dataKey = 'app';
const createAction = createActionFactory(dataKey);
export const loadCourseMetadata = createAction('loadCourseMetadata');
export const loadOraMetadata = createAction('loadOraMetadata');
export const setGrading = createAction('setGrading');
export const setShowReview = createAction('setReview');
export const toggleShowRubric = createAction('toggleShowRubric');
export default StrictDict({
loadCourseMetadata,
loadOraMetadata,
setGrading,
setShowReview,
toggleShowRubric,
});

View File

@@ -0,0 +1,95 @@
import { StrictDict } from 'utils';
import { createActionFactory } from './utils';
export const dataKey = 'grading';
const createAction = createActionFactory(dataKey);
/**
* Load the first of the selected submission list for review, and initializes
* the review pane to the first index.
* @param {obj} submission data for the review/grading view
* {
* {obj} response - api response data
* {obj} gradeData - api grade data
* {str} status - api grade status
* }
*/
const loadSubmission = createAction('loadSubmission');
/**
* Pre-load just the static info about the "next" submission in the review queue.
* Load submission and the learner's response.
* @param {obj} submission ({ response })
*/
const preloadNext = createAction('preloadNext');
/**
* Pre-load just the static info about the "previous" submission in the review queue.
* Load submission and the learner's response.
* @param {obj} submission ({ response })
*/
const preloadPrev = createAction('preloadPrev');
/**
* Load the "next" submission in the selected queue as the current selection, load its current
* status and grade data, and update prev/next accordingly.
* @param {obj} { status, gradeData }
*/
const loadNext = createAction('loadNext');
/**
* Load the "prev" submission in the selected queue as the current selection, load its current
* status and grade data, and update prev/next accordingly.
* @param {obj} { status, gradeData }
*/
const loadPrev = createAction('loadPrev');
/**
* Load the selected submissions, storing their static data in an ordered array and setting the starting
* index at the beginning of the list.
* @param {obj[]} selection - ordered array of submission static data for all selected submissions
*/
const updateSelection = createAction('updateSelection');
// TODO: implement/design data workflow
const rubric = StrictDict({
/*
* update the local version of the rubric-level comment
* @param {string} comment
*/
updateComment: createAction('rubric/comment'),
/*
* update the local version of points for the given criterion
* @param {number} index
* @param {number} points
*/
updateCriterionPoints: createAction('rubric/criterionPoints'),
/*
* update the local version of comment for the given criterion
* @param {number} index
* @param {string} comments
*/
updateCriterionComment: createAction('rubric/criterionComment'),
});
export const startGrading = createAction('grading/start');
export const setRubricFeedback = createAction('grading/setRubricFeedback');
export const setCriterionFeedback = createAction('grading/setCriterionFeedback');
export const setCriterionOption = createAction('grading/setCriterionOption');
export const clearGrade = createAction('grading/clear');
export default StrictDict({
loadSubmission,
preloadNext,
preloadPrev,
loadNext,
loadPrev,
updateSelection,
rubric,
startGrading,
setRubricFeedback,
setCriterionFeedback,
setCriterionOption,
clearGrade,
});

11
src/data/actions/index.js Normal file
View File

@@ -0,0 +1,11 @@
import { StrictDict } from 'utils';
import app from './app';
import grading from './grading';
import submissions from './submissions';
export default StrictDict({
app,
grading,
submissions,
});

View File

@@ -0,0 +1,15 @@
import { StrictDict } from 'utils';
import { createActionFactory } from './utils';
export const dataKey = 'submissions';
const createAction = createActionFactory(dataKey);
/**
* Load the basic list-level submission data, keyed by submission id
* @param {obj} submissionListData
*/
const loadList = createAction('loadList');
export default StrictDict({
loadList,
});

View File

@@ -0,0 +1,48 @@
/**
* testActionTypes(actionTypes, dataKey)
* Takes a list of actionTypes and a module dataKey, and verifies that
* * all actionTypes are unique
* * all actionTypes begin with the dataKey
* @param {string[]} actionTypes - list of action types
* @param {string} dataKey - module data key
*/
export const testActionTypes = (actionTypes, dataKey) => {
test('all types are unique', () => {
expect(actionTypes.length).toEqual((new Set(actionTypes)).size);
});
test('all types begin with the module dataKey', () => {
actionTypes.forEach(type => {
expect(type.startsWith(dataKey)).toEqual(true);
});
});
};
/**
* testAction(action, args, expectedPayload)
* Multi-purpose action creator test function.
* If args/expectedPayload are passed, verifies that it produces the expected output when called
* with the given args.
* If none are passed, (for action creators with basic definition) it tests against a default
* test payload.
* @param {object} action - action creator object/method
* @param {[object]} args - optional payload argument
* @param {[object]} expectedPayload - optional expected payload.
*/
export const testAction = (action, args, expectedPayload) => {
const type = action.toString();
if (args) {
if (Array.isArray(args)) {
expect(action(...args)).toEqual({ type, payload: expectedPayload });
} else {
expect(action(args)).toEqual({ type, payload: expectedPayload });
}
} else {
const payload = { test: 'PAYload' };
expect(action(payload)).toEqual({ type, payload });
}
};
export default {
testAction,
testActionTypes,
};

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

@@ -0,0 +1,10 @@
import { createAction } from '@reduxjs/toolkit';
const createActionFactory = (dataKey) => (actionKey, ...args) => (
createAction(`${dataKey}/${actionKey}`, ...args)
);
export {
// eslint-disable-next-line import/prefer-default-export
createActionFactory,
};

View File

@@ -0,0 +1,19 @@
import { createAction } from '@reduxjs/toolkit';
import * as utils from './utils';
jest.mock('@reduxjs/toolkit', () => ({
createAction: (key, ...args) => ({ action: key, args }),
}));
describe('redux action utils', () => {
describe('createActionFactory', () => {
it('returns an action creator with the data key', () => {
const dataKey = 'part-of-the-model';
const actionKey = 'an-action';
const args = ['some', 'args'];
expect(utils.createActionFactory(dataKey)(actionKey, ...args)).toEqual(
createAction(`${dataKey}/${actionKey}`, ...args),
);
});
});
});

View File

@@ -0,0 +1,4 @@
import { getConfig } from '@edx/frontend-platform';
// eslint-disable-next-line import/prefer-default-export
export const routePath = `${getConfig().PUBLIC_PATH}:courseId`;

36
src/data/reducers/app.js Normal file
View File

@@ -0,0 +1,36 @@
import { createReducer } from '@reduxjs/toolkit';
import actions from 'data/actions';
const initialState = {
oraMetadata: {
prompt: '',
name: '',
type: '',
rubricConfig: null,
},
courseMetadata: {
name: '',
number: '',
org: '',
},
showReview: false,
showRubric: false,
grading: false,
};
// eslint-disable-next-line no-unused-vars
const app = createReducer(initialState, {
[actions.app.loadCourseMetadata]: (state, { payload }) => ({ ...state, courseMetadata: payload }),
[actions.app.loadOraMetadata]: (state, { payload }) => ({ ...state, oraMetadata: payload }),
[actions.app.setShowReview]: (state, { payload }) => ({ ...state, showReview: payload }),
[actions.app.setGrading]: (state, { payload }) => ({
...state,
grading: payload,
showRubric: payload,
}),
[actions.app.toggleShowRubric]: (state) => ({ ...state, showRubric: !state.showRubric }),
});
export { initialState };
export default app;

View File

@@ -0,0 +1,140 @@
import { createReducer } from '@reduxjs/toolkit';
import actions from 'data/actions';
import selectors from 'data/selectors';
const initialState = {
selected: [
/**
* {
* submissionId: '',
* username: ''
* teamName: ''
* dateSubmitted: 0,
* gradeStatus: '',
* }
*/
],
gradingStatus: {
/**
* <submissionId>: {
* overallFeedback: '',
* criteria: [{
* orderNum: 0,
* points: 0,
* comments: '',
* }],
* }
*/
},
activeIndex: null,
current: {
/**
* gradeData: {
* score: {
* pointsEarned: 0,
* pointsPossible: 0,
* }
* overallFeedback: '',
* criteria: [{
* name: '',
* feedback: '',
* selectedOption: '',
* }],
* }
* gradeStatus: '',
* response: {
* text: '',
* files: [{
* download_url: '',
* description: '',
* name: '',
* }],
* },
*/
},
prev: null, // { response }
next: null, // { response }
};
// eslint-disable-next-line no-unused-vars
const app = createReducer(initialState, {
[actions.grading.loadSubmission]: (state, { payload }) => ({
...state,
current: { ...payload },
activeIndex: 0,
}),
[actions.grading.preloadNext]: (state, { payload }) => ({ ...state, next: payload }),
[actions.grading.preloadPrev]: (state, { payload }) => ({ ...state, prev: payload }),
[actions.grading.loadNext]: (state, { payload }) => ({
...state,
prev: state.current,
current: { response: state.next.response, ...payload },
activeIndex: state.activeIndex + 1,
next: null,
}),
[actions.grading.loadPrev]: (state, { payload }) => ({
...state,
next: state.current,
current: { response: state.prev.response, ...payload },
activeIndex: state.activeIndex - 1,
prev: null,
}),
[actions.grading.updateSelection]: (state, { payload }) => ({
...state,
selected: payload,
activeIndex: 0,
}),
[actions.grading.startGrading]: (state, { payload }) => ({
...state,
gradingStatus: {
...state.gradingStatus,
[state.current.submissionId]: { ...payload },
},
}),
[actions.grading.setRubricFeedback]: (state, { payload }) => ({
...state,
gradingStatus: {
...state.gradingStatus,
overallFeebadk: payload,
},
}),
[actions.grading.setCriterionOption]: (state, { payload: { orderNum, value } }) => {
const entry = state.gradingStatus[state.current.submissionId];
const { criteria } = entry;
criteria[orderNum] = { ...criteria[orderNum], selectedOption: value };
return {
...state,
gradingStatus: {
...state.gradingStatus,
[state.current.submissionId]: {
...entry,
criteria,
},
},
};
},
[actions.grading.setCriterionFeedback]: (state, { payload: { orderNum, value } }) => {
const entry = state.gradingStatus[state.current.submissionId];
const { criteria } = entry;
criteria[orderNum] = { ...criteria[orderNum], feedback: value };
return {
...state,
gradingStatus: {
...state.gradingStatus,
[state.current.submissionId]: {
...entry,
criteria,
},
},
};
},
[actions.grading.clearGrade]: (state) => {
const gradingStatus = { ...state.gradingStatus };
delete gradingStatus[state.current.submissionId];
return { ...state, gradingStatus };
},
});
export { initialState };
export default app;

14
src/data/reducers/index.js Executable file
View File

@@ -0,0 +1,14 @@
import { combineReducers } from 'redux';
import app from './app';
import grading from './grading';
import submissions from './submissions';
/* istanbul ignore next */
const rootReducer = combineReducers({
app,
grading,
submissions,
});
export default rootReducer;

View File

@@ -0,0 +1,32 @@
import actions from 'data/actions';
const initialState = {
allSubmissions: {
/**
* <submissionId>: {
* submissionId: '',
* username: ''
* teamName: ''
* dateSubmitted: 0,
* gradeStatus: ''
* grade: {
* pointsEarned: 0,
* pointsPossible: 0,
* }
* }
*/
},
};
// eslint-disable-next-line no-unused-vars
const grades = (state = initialState, { type, payload }) => {
switch (type) {
case actions.submissions.loadList.toString():
return { ...state, allSubmissions: payload };
default:
return state;
}
};
export { initialState };
export default grades;

50
src/data/selectors/app.js Normal file
View File

@@ -0,0 +1,50 @@
import { createSelector } from 'reselect';
import { feedbackRequirement } from 'data/services/lms/constants';
import { StrictDict } from 'utils';
export const simpleSelectors = {
showReview: state => state.app.showReview,
showRubric: state => state.app.showRubric,
grading: state => state.app.grading,
courseMetadata: state => state.app.courseMetadata,
oraName: state => state.app.oraMetadata.name,
oraPrompt: state => state.app.oraMetadata.prompt,
oraTypes: state => state.app.oraMetadata.type,
rubricConfig: state => state.app.oraMetadata.rubricConfig,
};
const shouldIncludeFeedback = (feedback) => ([
feedbackRequirement.required,
feedbackRequirement.optional,
]).includes(feedback);
export const emptyGrade = (state) => {
const { rubricConfig } = state.app.oraMetadata;
console.log({ rubricConfig });
if (rubricConfig === undefined) {
return null;
}
const gradeData = {};
if (shouldIncludeFeedback(rubricConfig.feedback)) {
gradeData.overallFeedback = '';
}
gradeData.criteria = rubricConfig.criteria.map(criterion => {
const entry = {
orderNum: criterion.orderNum,
name: criterion.name,
selectedOption: null,
};
if (shouldIncludeFeedback(criterion.feedback)) {
entry.feedback = '';
}
return entry;
});
return gradeData;
};
export default StrictDict({
...simpleSelectors,
emptyGrade,
});

View File

@@ -0,0 +1,150 @@
import { createSelector } from 'reselect';
import { StrictDict } from 'utils';
import submissionsSelectors from './submissions';
import * as module from './grading';
export const simpleSelectors = {
selected: state => state.grading.selected,
activeIndex: state => state.grading.activeIndex,
current: state => state.grading.current,
};
/**
* returns the length of the list of selected submissions
* @return {number} selected submission list length
*/
export const selectionLength = createSelector(
[module.simpleSelectors.selected],
(selected) => selected.length,
);
/**
* returns the selected submission id
* @return {string} selected submission id
*/
export const selectedSubmissionId = createSelector(
[module.simpleSelectors.selected, module.simpleSelectors.activeIndex],
(selected, index) => selected[index],
);
/**
* returns static data from the active selected submission
* @return {obj} - staticData
* { submissionId, username, teamName, dateSubmitted }
*/
export const selectedStaticData = createSelector(
[module.selectedSubmissionId, submissionsSelectors.allSubmissions],
(submissionId, allSubmissions) => {
const submission = allSubmissions[submissionId];
const { grade, gradeStatus, ...staticData } = submission;
return staticData;
},
);
/**
* Returns the username for the selected submission
* @return {string} username
*/
export const selectedUsername = createSelector(
[module.selectedStaticData],
(staticData) => staticData.username,
);
/**
* Returns the grade status for the selected submission
* @return {string} grade status
*/
export const selectedGradeStatus = createSelector(
[module.simpleSelectors.current],
(current) => current.gradeStatus,
);
/**
* Returns the grade data for the selected submission
* @return {obj} grade data
* { score, overallFeedback, criteria }
*/
export const selectedGradeData = createSelector(
[module.simpleSelectors.current],
(current) => current.gradeData,
);
/**
* Returns the response data for the selected submission
* @return {obj} response
* { text, files: [] }
*/
export const selectedResponse = createSelector(
[module.simpleSelectors.current],
(current) => current.response,
);
export const selected = StrictDict({
submissionId: module.selectedSubmissionId,
staticData: module.selectedStaticData,
username: module.selectedUsername,
gradeStatus: module.selectedGradeStatus,
gradeData: module.selectedGradeData,
response: module.selectedResponse,
});
/**
* Returns true iff there exists a selection previous to the current selection
* in the queue.
* @return {bool} has previous submission?
*/
export const hasPrevSubmission = createSelector(
[simpleSelectors.activeIndex],
(activeIndex) => activeIndex > 0,
);
/**
* Returns true iff there exists a selection after the current selection
* in the queue.
* @return {bool} has next submission?
*/
export const hasNextSubmission = createSelector(
[simpleSelectors.selected, simpleSelectors.activeIndex],
(list, activeIndex) => activeIndex < list.length - 1,
);
/**
* Returns the submissionId for the previous submission in the selection queu
* @return {string} previous submission id (null if there isn't one)
*/
export const prevSubmissionId = createSelector(
[simpleSelectors.selected, simpleSelectors.activeIndex],
(list, activeIndex) => {
if (activeIndex > 0) {
return list[activeIndex - 1];
}
return null;
},
);
/**
* Returns the submissionId for the next submission in the selection queu
* @return {string} next submission id (null if there isn't one)
*/
export const nextSubmissionId = createSelector(
[simpleSelectors.selected, simpleSelectors.activeIndex],
(list, activeIndex) => {
if (activeIndex < list.length - 1) {
return list[activeIndex + 1];
}
return null;
},
);
export default StrictDict({
...simpleSelectors,
selectedSubmissionId,
hasPrevSubmission,
hasNextSubmission,
nextSubmissionId,
prevSubmissionId,
selected,
selectedResponse,
selectionLength,
});

View File

@@ -0,0 +1,11 @@
import { StrictDict } from 'utils';
import app from './app';
import grading from './grading';
import submissions from './submissions';
export default StrictDict({
app,
grading,
submissions,
});

View File

@@ -0,0 +1,33 @@
import _ from 'lodash';
import { createSelector } from 'reselect';
import { StrictDict } from 'utils';
import { lockStatuses } from 'data/services/lms/constants';
export const simpleSelectors = {
allSubmissions: state => state.submissions.allSubmissions,
};
/**
* Returns the submission list in default order for the table.
*/
export const listData = createSelector(
[simpleSelectors.allSubmissions],
(allSubmissions) => {
const submissionIds = Object.keys(allSubmissions);
const submissionList = submissionIds.map(id => {
const { gradeStatus, lockStatus, ...rest } = allSubmissions[id];
const gradingStatus = (lockStatus === lockStatuses.unlocked ? gradeStatus : lockStatus);
return { gradingStatus, ...rest };
});
return _.sortBy(
submissionList,
['submissionDate'],
);
},
);
export default StrictDict({
...simpleSelectors,
listData,
});

View File

@@ -0,0 +1,135 @@
import { StrictDict } from 'utils';
import fakeData from './fakeData';
// import urls from './urls';
// import { pageSize, paramKeys } from './constants';
// import messages from './messages';
// import * as utils from './utils';
// const { get, post, stringifyUrl } = utils;
/*********************************************************************************
* GET Actions
*********************************************************************************/
const mockSuccess = (returnValFn) => (...args) => (
new Promise((resolve) => resolve(returnValFn(...args)))
);
const mockFailure = (returnValFn) => (...args) => (
new Promise((resolve, reject) => reject(returnValFn(...args)))
);
/**
* get('/api/initialize', { ora_location, course_id? })
* @return {
* oraMetadata: { name, prompt, type ('individual' vs 'team') },
* courseMetadata: { courseOrg, courseName, courseNumber },
* submissions: {
* [submissionId]: {
* id: <submissionId>, (not currently used)
* username
* submissionId
* dateSubmitted (timestamp)
* gradeStatus (['ungraded', 'graded', 'locked', 'locked_by_you'?])
* grade: { pointsEarned, pointsPossible }
* },
* ...
* },
* }
*/
const initializeApp = mockSuccess(() => ({
oraMetadata: fakeData.oraMetadata,
courseMetadata: fakeData.courseMetadata,
submissions: fakeData.submissions,
}));
/**
* get('/api/submission', { submissionId })
* @return {
* submision: {
* gradeData,
* gradeStatus,
* response: { files: [{}], text: <html> },
* },
* }
*/
const fetchSubmission = mockSuccess((submissionId) => (
fakeData.mockSubmission(submissionId)
));
/**
* fetches the current grade, gradeStatus, and rubricResponse data for the given submission
* get('/api/submissionStatus', { submissionId })
* @return {obj} submissionStatus object
* {
* grade: { pointsEarned: 0, pointsPossible: 0 },
* gradeStatus,
* rubricResponses: {
* rubricComment: '',
* criteria: [
* { grade, comments },
* ],
* },
* }
*/
const fetchSubmissionStatus = mockSuccess((submissionId) => (
fakeData.mockSubmissionStatus(submissionId)
));
/**
* Fetches only the learner response for a given submission. Used for pre-fetching response
* for neighboring submissions in the queue.
*/
export const fetchSubmissionResponse = mockSuccess((submissionId) => ({
response: fakeData.mockSubmission(submissionId).response,
}));
/* I assume this is the "Start Grading" call, even for if a
* submission is already graded and we are attempting re-lock.
* Assuming the check for if allowed would happen locally first.
* post('api/lock', { ora_location, submissionId });
* @param {bool} value - new lock value
* @param {string} submissionId
*/
const lockSubmission = mockSuccess(() => ({
response: true,
}));
/*
* Assuming we do not care who has locked it or why, as there
* is no design around communicating that info
* post('api/lock', { submissionId });
* @param {bool} value - new lock value
* @param {string} submissionId
*/
const lockSubmissionFail = mockFailure(() => ({
error: 'that did not work',
}));
/*
* post('api/updateGrade', { submissionId, gradeData })
* @param {object} gradeData - full grading submission data
* {
* rubricComments: '', (optional)
* criteria: [
* {
* comments: '', (optional)
* grade: '',
* },
* ...
* ],
* }
*/
const updateGrade = mockSuccess((submissionId, gradeData) => {
console.log({ updateGrade: { submissionId, gradeData } });
});
export default StrictDict({
initializeApp,
fetchSubmission,
fetchSubmissionResponse,
fetchSubmissionStatus,
lockSubmission,
lockSubmissionFail,
updateGrade,
});

View File

@@ -0,0 +1,32 @@
import { StrictDict } from 'utils';
export const lockStatuses = StrictDict({
unlocked: 'unlocked',
locked: 'locked',
inProgress: 'in-progress',
});
export const gradeStatuses = StrictDict({
ungraded: 'ungraded',
graded: 'graded',
});
export const gradingStatuses = StrictDict({
ungraded: gradeStatuses.ungraded,
graded: gradeStatuses.graded,
locked: lockStatuses.locked,
inProgress: lockStatuses.inProgress,
});
export const gradingStatusDisplay = StrictDict({
[gradingStatuses.ungraded]: 'Ungraded',
[gradingStatuses.locked]: 'Currently being graded by someone else',
[gradingStatuses.graded]: 'Grading Complete',
[gradingStatuses.inProgress]: 'You are currently grading this response',
});
export const feedbackRequirement = StrictDict({
disabled: 'disabled',
required: 'required',
optional: 'optional',
});

View File

@@ -0,0 +1,5 @@
export const org = 'AuroraU';
export const number = '101';
export const title = 'Time Travel 101';
export default { org, number, title };

View File

@@ -0,0 +1,17 @@
import { StrictDict } from 'utils';
export const submissionId = (index) => `SUBMISSION_ID-${index}`;
export const learnerId = (index) => `LEARNER_ID-${index}`;
export const locationId = (index) => `ORA_LOCATION_ID-${index}`;
export const sessionId = (index) => `ESG_SESSION_ID-${index}`;
export const username = (index) => `USERNAME-${index}`;
export const teamName = (index) => `TEAM_NAME-${index}`;
export default StrictDict({
learnerId,
locationId,
sessionId,
submissionId,
username,
teamName,
});

View File

@@ -0,0 +1,23 @@
import submissions from './submissionList';
import { mockSubmission, mockSubmissionStatus } from './submissionFull';
import oraMetadata from './ora';
import courseMetadata from './course';
import ids from './ids';
console.log({
submissions,
oraMetadata,
courseMetadata,
mockSubmission,
mockSubmissionStatus,
ids,
});
export default {
submissions,
oraMetadata,
courseMetadata,
mockSubmission,
mockSubmissionStatus,
ids,
};

View File

@@ -0,0 +1,65 @@
import ids from './ids';
export const prompt = `
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin scelerisque finibus sem in aliquam. Cras volutpat ipsum sit amet porttitor bibendum. Nunc tempor ex neque, sed faucibus nisl accumsan vitae. Proin et sem nisl. Aenean placerat justo a ligula eleifend, in imperdiet sem sodales. Maecenas eget aliquet purus, ac ornare risus. Nullam eget interdum erat. Mauris semper porta sapien et egestas. Aliquam viverra convallis pulvinar. Aliquam suscipit ligula felis, eu viverra ligula dignissim ut. Vivamus sit amet commodo sem. Nullam a viverra nibh.
Phasellus tempor eros aliquam ipsum molestie, vitae varius lectus tempus. Morbi iaculis, libero euismod vehicula rutrum, nisi leo volutpat diam, quis commodo ex nunc ut odio. Pellentesque condimentum feugiat erat ac vulputate. Pellentesque porta rutrum sagittis. Curabitur vulputate tempus accumsan. Fusce bibendum gravida metus a scelerisque. Mauris fringilla orci non lobortis commodo. Quisque iaculis, quam a tincidunt vehicula, erat nisi accumsan quam, eu cursus ligula magna id odio. Nulla porttitor, lorem gravida vehicula tristique, sapien metus tristique ex, id tincidunt sapien justo nec sapien. Maecenas luctus, nisl vestibulum scelerisque pharetra, ligula orci vulputate turpis, in ultrices mauris dolor eu enim. Suspendisse quis nibh nec augue semper maximus. Morbi maximus eleifend magna.
Phasellus porttitor vel magna et auctor. Nulla porttitor convallis aliquam. Donec cursus, ipsum ut egestas bibendum, purus metus dignissim est, ac condimentum leo felis eget diam. In magna mi, tincidunt id sapien id, fermentum vestibulum quam. Quisque et dui sed urna convallis rutrum pellentesque quis sapien. Cras non lectus velit. Praesent semper eros id risus mollis, quis interdum quam imperdiet. Sed nec vulputate tortor, at tristique tortor.
`;
export const name = 'This is the Name of the ORA';
export const type = 'individual';
const rubricConfig = {
feedback: 'optional',
criteria: [
{
name: 'firstCriterion',
orderNum: 0,
prompt: 'A criterion prompt',
feedback: 'optional',
options: [
{
orderNum: 0,
name: 'poor',
label: 'Poor',
explanation: 'Includes little information with few or no details or unrelated details. Unsuccessful in attempts to explore any facets of the topic.',
points: 0,
feedback: 'optional',
},
{
orderNum: 1,
name: 'fair',
prompt: 'Fair',
explanation: 'Includes little information and few or no details. Explores only one or two facets of the topic.',
points: 1,
feedback: 'optional',
},
{
orderNum: 2,
name: 'good',
prompt: 'Good',
explanation: 'Includes sufficient information and supporting details. (Details may not be fully developed; ideas may be listed.) Explores some facets of the topic.',
points: 2,
feedback: 'optional',
},
{
orderNum: 3,
name: 'excellent',
prompt: 'Excellent',
explanation: 'Includes in-depth information and exceptional supportint details that are fully developed. Explores all facets of the topic',
points: 3,
feedback: 'optional',
},
],
},
],
};
export default {
name,
prompt,
rubricConfig,
type,
};

View File

@@ -0,0 +1,22 @@
import submissionList from './submissionList';
const responseText = (submissionId) => `<div><h1>Title (${submissionId})</h1>
Phasellus tempor eros aliquam ipsum molestie, vitae varius lectus tempus. Morbi iaculis, libero euismod vehicula rutrum, nisi leo volutpat diam, quis commodo ex nunc ut odio. Pellentesque condimentum feugiat erat ac vulputate. Pellentesque porta rutrum sagittis. Curabitur vulputate tempus accumsan. Fusce bibendum gravida metus a scelerisque. Mauris fringilla orci non lobortis commodo. Quisque iaculis, quam a tincidunt vehicula, erat nisi accumsan quam, eu cursus ligula magna id odio. Nulla porttitor, lorem gravida vehicula tristique, sapien metus tristique ex, id tincidunt sapien justo nec sapien. Maecenas luctus, nisl vestibulum scelerisque pharetra, ligula orci vulputate turpis, in ultrices mauris dolor eu enim. Suspendisse quis nibh nec augue semper maximus. Morbi maximus eleifend magna.
Phasellus porttitor vel magna et auctor. Nulla porttitor convallis aliquam. Donec cursus, ipsum ut egestas bibendum, purus metus dignissim est, ac condimentum leo felis eget diam. In magna mi, tincidunt id sapien id, fermentum vestibulum quam. Quisque et dui sed urna convallis rutrum pellentesque quis sapien. Cras non lectus velit. Praesent semper eros id risus mollis, quis interdum quam imperdiet. Sed nec vulputate tortor, at tristique tortor.
</div>`;
// eslint-disable-next-line
export const mockSubmission = (submissionId) => ({
response: {
text: responseText(submissionId),
files: [],
},
gradeStatus: submissionList[submissionId].gradeStatus,
score: submissionList[submissionId].score,
});
export const mockSubmissionStatus = (submissionId) => ({
gradeData: submissionList[submissionId].gradeData,
gradeStatus: submissionList[submissionId].gradeStatus,
});

View File

@@ -0,0 +1,74 @@
import ids from './ids';
import { gradeStatuses, lockStatuses } from '../constants';
/**
* Response entries, with identifier.
* {
* id: {string}
* submissionId: {string}
* username: {string} (optional)
* dateSubmitted: {number}
* grade: {
* pointsPossible: {number}
* pointsEarned: {number}
* }
* gradeStatus: {string}
* }
*/
const date0 = 1631215154955;
const day = 86400000;
const submissions = {};
let lastIndex = 0;
const createSubmission = (score, gradeStatus, lockStatus) => {
const index = lastIndex;
lastIndex += 1;
const submissionId = ids.submissionId(index);
const gradeData = score === null ? null : {
score,
overallFeedback: 'was okay',
criteria: [{
name: 'firstCriterion',
feedback: 'did alright',
selectedOption: 'good',
}],
};
submissions[submissionId] = {
submissionId,
username: ids.username(index),
// teamName: '',
dateSubmitted: date0 + (day * index),
score,
gradeData,
gradeStatus,
lockStatus,
};
};
for (let i = 0; i < 10; i++) {
createSubmission(null, gradeStatuses.ungraded, lockStatuses.unlocked);
createSubmission(
{ pointsEarned: 70 + i, pointsPossible: 100 },
gradeStatuses.graded,
lockStatuses.locked,
);
createSubmission(
{ pointsEarned: 80 + i, pointsPossible: 100 },
gradeStatuses.graded,
lockStatuses.unlocked,
);
createSubmission(
null,
gradeStatuses.ungraded,
lockStatuses.inProgress,
);
createSubmission(
{ pointsEarned: 90 + i, pointsPossible: 100 },
gradeStatuses.graded,
lockStatuses.inProgress,
);
}
export default submissions;

View File

@@ -0,0 +1,8 @@
import { StrictDict } from 'utils';
import api from './api';
import urls from './urls';
export default StrictDict({
api,
urls,
});

View File

@@ -0,0 +1,11 @@
import { StrictDict } from 'utils';
import { configuration } from 'config';
const baseUrl = `${configuration.LMS_BASE_URL}`;
const api = `${baseUrl}/api/`;
export default StrictDict({
api,
baseUrl,
});

View File

@@ -0,0 +1,42 @@
import queryString from 'query-string';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { filters } from 'data/constants/filters';
/**
* get(url)
* simple wrapper providing an authenticated Http client get action
* @param {string} url - target url
*/
export const get = (...args) => getAuthenticatedHttpClient().get(...args);
/**
* post(url, data)
* simple wrapper providing an authenticated Http client post action
* @param {string} url - target url
* @param {object|string} data - post payload
*/
export const post = (...args) => getAuthenticatedHttpClient().post(...args);
/**
* stringifyUrl(url, query)
* simple wrapper around queryString.stringifyUrl that sets skip behavior
* @param {string} url - base url string
* @param {object} query - query parameters
*/
export const stringifyUrl = (url, query) => queryString.stringifyUrl(
{ url, query },
{ skipNull: true, skipEmptyString: true },
);
/**
* filterQuery(options)
* Takes current filter object and returns it with only valid filters that are
* set and have non-'All' values
* @param {object} options - filter values
* @return {object} - valid filters that are set and do not equal 'All'
*/
export const filterQuery = (options) => Object.values(filters)
.filter(filter => options[filter] && options[filter] !== 'All')
.reduce(
(obj, filter) => ({ ...obj, [filter]: options[filter] }),
{},
);

View File

@@ -0,0 +1,97 @@
import queryString from 'query-string';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { filters } from 'data/constants/filters';
import * as utils from './utils';
jest.mock('query-string', () => ({
stringifyUrl: jest.fn((url, options) => ({ url, options })),
}));
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedHttpClient: jest.fn(),
}));
describe('lms service utils', () => {
describe('get', () => {
it('forwards arguments to authenticatedHttpClient().get', () => {
const get = jest.fn((...args) => ({ get: args }));
getAuthenticatedHttpClient.mockReturnValue({ get });
const args = ['some', 'args', 'for', 'the', 'test'];
expect(utils.get(...args)).toEqual(get(...args));
});
});
describe('post', () => {
it('forwards arguments to authenticatedHttpClient().post', () => {
const post = jest.fn((...args) => ({ post: args }));
getAuthenticatedHttpClient.mockReturnValue({ post });
const args = ['some', 'args', 'for', 'the', 'test'];
expect(utils.post(...args)).toEqual(post(...args));
});
});
describe('stringifyUrl', () => {
it('forwards url and query to stringifyUrl with options to skip null and ""', () => {
const url = 'here.com';
const query = { some: 'set', of: 'queryParams' };
const options = { skipNull: true, skipEmptyString: true };
expect(utils.stringifyUrl(url, query)).toEqual(
queryString.stringifyUrl({ url, query }, options),
);
});
});
describe('filterQuery', () => {
it('returns all filters included in validated list that are not "All"', () => {
const goodOptions = {
[filters.assignmentType]: 'quiz',
[filters.courseGradeMax]: 100,
[filters.courseGradeMin]: 1,
};
const extraOptions = {
fake: 'option',
another: 'fake one',
};
expect(utils.filterQuery({
...goodOptions,
...extraOptions,
[filters.includeCourseRoleMembers]: 'All',
})).toEqual(goodOptions);
});
});
});
/**
* get(url)
* simple wrapper providing an authenticated Http client get action
* @param {string} url - target url
*/
export const get = (...args) => getAuthenticatedHttpClient().get(...args);
/**
* post(url, data)
* simple wrapper providing an authenticated Http client post action
* @param {string} url - target url
* @param {object|string} data - post payload
*/
export const post = (...args) => getAuthenticatedHttpClient().post(...args);
/**
* stringifyUrl(url, query)
* simple wrapper around queryString.stringifyUrl that sets skip behavior
* @param {string} url - base url string
* @param {object} query - query parameters
*/
export const stringifyUrl = (url, query) => queryString.stringifyUrl(
{ url, query },
{ skipNull: true, skipEmptyString: true },
);
/**
* filterQuery(options)
* Takes current filter object and returns it with only valid filters that are
* set and have non-'All' values
* @param {object} options - filter values
* @return {object} - valid filters that are set and do not equal 'All'
*/
export const filterQuery = (options) => Object.values(filters)
.filter(filter => options[filter] && options[filter] !== 'All')
.reduce(
(obj, filter) => ({ ...obj, [filter]: options[filter] }),
{},
);

36
src/data/store.js Executable file
View File

@@ -0,0 +1,36 @@
import * as redux from 'redux';
import thunkMiddleware from 'redux-thunk';
import { composeWithDevTools } from 'redux-devtools-extension/logOnlyInProduction';
import { createLogger } from 'redux-logger';
import fakeData from './services/lms/fakeData';
import actions from './actions';
import selectors from './selectors';
import reducers from './reducers';
export const createStore = () => {
const loggerMiddleware = createLogger();
const middleware = [thunkMiddleware, loggerMiddleware];
const store = redux.createStore(
reducers,
composeWithDevTools(redux.applyMiddleware(...middleware)),
);
/**
* Dev tools for redux work
*/
if (process.env.NODE_ENV === 'development') {
window.store = store;
window.actions = actions;
window.selectors = selectors;
}
return store;
};
const store = createStore();
export default store;

65
src/data/store.test.js Normal file
View File

@@ -0,0 +1,65 @@
import { applyMiddleware } from 'redux';
import thunkMiddleware from 'redux-thunk';
import { composeWithDevTools } from 'redux-devtools-extension/logOnlyInProduction';
import { createLogger } from 'redux-logger';
import actions from './actions';
import selectors from './selectors';
import reducers from './reducers';
import exportedStore, { createStore } from './store';
jest.mock('./reducers', () => 'REDUCER');
jest.mock('./actions', () => 'ACTIONS');
jest.mock('./selectors', () => 'SELECTORS');
jest.mock('redux-logger', () => ({
createLogger: () => 'logger',
}));
jest.mock('redux-thunk', () => 'thunkMiddleware');
jest.mock('redux', () => ({
applyMiddleware: (...middleware) => ({ applied: middleware }),
createStore: (reducer, middleware) => ({ reducer, middleware }),
}));
jest.mock('redux-devtools-extension/logOnlyInProduction', () => ({
composeWithDevTools: (middleware) => ({ withDevTools: middleware }),
}));
describe('store aggregator module', () => {
describe('exported store', () => {
it('is generated by createStore', () => {
expect(exportedStore).toEqual(createStore());
});
it('creates store with connected reducers', () => {
expect(createStore().reducer).toEqual(reducers);
});
describe('middleware', () => {
it('exports thunk and logger middleware, composed and applied with dev tools', () => {
expect(createStore().middleware).toEqual(
composeWithDevTools(applyMiddleware(thunkMiddleware, createLogger())),
);
});
});
});
describe('dev exposed tools', () => {
beforeEach(() => {
window.store = undefined;
window.actions = undefined;
window.selectors = undefined;
});
it('exposes redux tools if in development env', () => {
process.env.NODE_ENV = 'development';
const store = createStore();
expect(window.store).toEqual(store);
expect(window.actions).toEqual(actions);
expect(window.selectors).toEqual(selectors);
});
it('does not expose redux tools if in production env', () => {
process.env.NODE_ENV = 'production';
createStore();
expect(window.store).toEqual(undefined);
expect(window.actions).toEqual(undefined);
expect(window.selectors).toEqual(undefined);
});
});
});

View File

@@ -0,0 +1,20 @@
import { StrictDict } from 'utils';
import actions from 'data/actions';
import api from 'data/services/lms/api';
const locationId = window.location.pathname.slice(1);
/**
* initialize the app, loading ora and course metadata from the api, and loading the initial
* submission list data.
*/
export const initialize = () => (dispatch) => (
api.initializeApp(locationId).then((response) => {
dispatch(actions.app.loadOraMetadata(response.oraMetadata));
dispatch(actions.app.loadCourseMetadata(response.courseMetadata));
dispatch(actions.submissions.loadList(response.submissions));
})
);
export default StrictDict({ initialize });

View File

@@ -0,0 +1,119 @@
import { StrictDict } from 'utils';
import actions from 'data/actions';
import selectors from 'data/selectors';
import api from 'data/services/lms/api';
import { gradingStatuses as statuses } from 'data/services/lms/constants';
import * as module from './grading';
/**
* Prefetch the "next" submission in the selected queue. Only fetches the response info.
*/
export const prefetchNext = () => (dispatch, getState) => (
api.fetchSubmissionResponse(
selectors.grading.nextSubmissionId(getState()),
).then((response) => {
dispatch(actions.grading.preloadNext(response));
})
);
/**
* Prefetch the "previous" submission in the selected queue. Only fetches the response info.
*/
export const prefetchPrev = () => (dispatch, getState) => (
api.fetchSubmissionResponse(
selectors.grading.prevSubmissionId(getState()),
).then((response) => {
dispatch(actions.grading.preloadPrev(response));
})
);
/**
* Fetches the current status for the "next" submission in the selected queue,
* and calls loadNext with it to update the current selection index info.
* If the new index has a next submission available, preload its response.
*/
export const loadNext = () => (dispatch, getState) => {
const nextId = selectors.grading.nextSubmissionId(getState());
return api.fetchSubmissionStatus(nextId).then((response) => {
console.log({ loadNext: response });
dispatch(actions.grading.loadNext({ ...response, submissionId: nextId }));
if (response.gradeStatus === statuses.inProgress) {
dispatch(module.startGrading());
} else {
dispatch(actions.app.setGrading(false));
}
if (selectors.grading.hasNextSubmission(getState())) {
dispatch(module.prefetchNext());
}
});
};
/**
* Fetches the current status for the "previous" submission in the selected queue,
* and calls loadPrev with it to update the current selection index info.
* If the new index has a previous submission available, preload its response.
*/
export const loadPrev = () => (dispatch, getState) => {
const prevId = selectors.grading.prevSubmissionId(getState());
return api.fetchSubmissionStatus(prevId).then((response) => {
dispatch(actions.grading.loadPrev({ ...response, submissionId: prevId }));
if (response.gradeStatus === statuses.inProgress) {
dispatch(module.startGrading());
} else {
dispatch(actions.app.setGrading(false));
}
if (selectors.grading.hasPrevSubmission(getState())) {
dispatch(module.prefetchPrev());
}
});
};
/**
* Load a list of selected submissionIds, sets the app to review mode, and fetches the current
* selected submission's full data (grade data, status, and rubric).
* Then loads current selection and prefetches neighbors.
* @param {string[]} submissionIds - ordered list of submissionIds for selected submissions
*/
export const loadSelectionForReview = (submissionIds) => (dispatch, getState) => {
dispatch(actions.grading.updateSelection(submissionIds));
return api.fetchSubmission(
selectors.grading.selectedSubmissionId(getState()),
).then((response) => {
dispatch(actions.grading.loadSubmission({
...response,
submissionId: submissionIds[0],
}));
dispatch(actions.app.setShowReview(true));
if (selectors.grading.hasNextSubmission(getState())) {
dispatch(prefetchNext());
}
if (selectors.grading.hasPrevSubmission(getState())) {
dispatch(prefetchPrev());
}
});
};
export const startGrading = () => (dispatch, getState) => {
console.log('start grading');
return api.lockSubmission(
selectors.grading.selectedSubmissionId(getState()),
).then(() => {
console.log('succeed at locking');
dispatch(actions.app.setGrading(true));
let gradeData = selectors.grading.selected.gradeData(getState());
if (gradeData === undefined) {
gradeData = selectors.app.emptyGrade(getState());
}
dispatch(actions.grading.startGrading(gradeData));
}).catch((error) => {
console.log({ error });
});
};
export default StrictDict({
loadSelectionForReview,
loadNext,
loadPrev,
startGrading,
});

View File

@@ -0,0 +1,9 @@
import { StrictDict } from 'utils';
import app from './app';
import grading from './grading';
export default StrictDict({
app,
grading,
});

View File

@@ -0,0 +1,53 @@
/* eslint-disable import/no-extraneous-dependencies */
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
const mockStore = configureMockStore([thunk]);
/** createTestFetcher(mockedMethod, thunkAction, args, onDispatch)
* Creates a testFetch method, which will test a given thunkAction of the form:
* ```
* const <thunkAction> = (<args>) => (dispatch, getState) => {
* ...
* return <mockedMethod>.then().catch();
* ```
* The returned function will take a promise handler function, a list of expected actions
* to have been dispatched (objects only), and an optional verifyFn method to be called after
* the fetch has been completed.
*
* @param {fn} mockedMethod - already-mocked api method being exercised by the thunkAction.
* @param {fn} thunkAction - thunkAction to call/test
* @param {array} args - array of args to dispatch the thunkAction with
* @param {[fn]} onDispatch - optional function to be called after dispatch
*
* @return {fn} testFetch method
* @param {fn} resolveFn - promise handler of the form (resolve, reject) => {}.
* should return a call to resolve or reject with response data.
* @param {object[]} expectedActions - array of action objects expected to have been dispatched
* will be verified after the thunkAction resolves
* @param {[fn]} verifyFn - optional function to be called after dispatch
*/
export const createTestFetcher = (
mockedMethod,
thunkAction,
args,
onDispatch,
) => (
resolveFn,
expectedActions,
) => {
const store = mockStore({});
mockedMethod.mockReturnValue(new Promise(resolve => {
resolve(new Promise(resolveFn));
}));
return store.dispatch(thunkAction(...args)).then(() => {
onDispatch();
if (expectedActions !== undefined) {
expect(store.getActions()).toEqual(expectedActions);
}
});
};
export default {
createTestFetcher,
};

19
src/data/utils.js Normal file
View File

@@ -0,0 +1,19 @@
/**
* Simple selector factory.
* Takes a list of string keys, and returns a simple slector for each.
*
* @function
* @param {Object|string[]} keys - If passed as object, Object.keys(keys) is used.
* @return {Object} - object of `{[key]: ({key}) => key}`
*/
const simpleSelectorFactory = (transformer, keys) => {
const selKeys = Array.isArray(keys) ? keys : Object.keys(keys);
return selKeys.reduce(
(obj, key) => ({
...obj, [key]: (state) => transformer(state)[key],
}),
{ root: (state) => transformer(state) },
);
};
export default simpleSelectorFactory;

29
src/data/utils.test.js Normal file
View File

@@ -0,0 +1,29 @@
import simpleSelectorFactory from './utils';
describe('Redux utilities - creators', () => {
describe('simpleSelectors', () => {
const data = { a: 1, b: 2, c: 3 };
const state = {
testGroup: data,
other: 'stuff',
};
const transformer = ({ testGroup }) => testGroup;
test('given a list of strings, returns a dict w/ a simple selector per string', () => {
const keys = ['a', 'b'];
const selectors = simpleSelectorFactory(transformer, keys);
expect(Object.keys(selectors)).toEqual(['root', ...keys]);
expect(selectors.root(state)).toEqual(data);
expect(selectors.a(state)).toEqual(data.a);
expect(selectors.b(state)).toEqual(data.b);
});
test('given an object for keys, returns a dict w/ simple selector per key', () => {
const selectors = simpleSelectorFactory(transformer, data);
expect(Object.keys(selectors)).toEqual(['root', ...Object.keys(data)]);
expect(selectors.root(state)).toEqual(data);
expect(selectors.a(state)).toEqual(data.a);
expect(selectors.b(state)).toEqual(data.b);
expect(selectors.c(state)).toEqual(data.c);
});
});
});

14
src/i18n/index.jsx Normal file
View File

@@ -0,0 +1,14 @@
import arMessages from './messages/ar.json';
// no need to import en messages-- they are in the defaultMessage field
import es419Messages from './messages/es_419.json';
import frMessages from './messages/fr.json';
import zhcnMessages from './messages/zh_CN.json';
const messages = {
ar: arMessages,
'es-419': es419Messages,
fr: frMessages,
'zh-cn': zhcnMessages,
};
export default messages;

View File

@@ -0,0 +1,2 @@
{
}

View File

@@ -0,0 +1,2 @@
{
}

View File

@@ -0,0 +1,2 @@
{
}

View File

@@ -0,0 +1,2 @@
{
}

46
src/index.jsx Executable file
View File

@@ -0,0 +1,46 @@
import 'core-js/stable';
import 'regenerator-runtime/runtime';
import React from 'react';
import ReactDOM from 'react-dom';
import { AppProvider, ErrorPage } from '@edx/frontend-platform/react';
import store from 'data/store';
import {
APP_READY,
APP_INIT_ERROR,
initialize,
subscribe,
} from '@edx/frontend-platform';
import { messages as footerMessages } from '@edx/frontend-component-footer';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import messages from './i18n';
import App from './App';
subscribe(APP_READY, () => {
ReactDOM.render(
<IntlProvider locale="en" messages={messages.en}>
<AppProvider store={store}>
<App />
</AppProvider>
</IntlProvider>,
document.getElementById('root'),
);
});
subscribe(APP_INIT_ERROR, (error) => {
ReactDOM.render(
<ErrorPage message={error.message} />,
document.getElementById('root'),
);
});
initialize({
messages: [
messages,
footerMessages,
],
requireAuthenticatedUser: true,
});

51
src/index.test.jsx Normal file
View File

@@ -0,0 +1,51 @@
import React from 'react';
import ReactDOM from 'react-dom';
import {
APP_READY,
initialize,
subscribe,
} from '@edx/frontend-platform';
import { messages as footerMessages } from '@edx/frontend-component-footer';
import appMessages from './i18n';
import App from './App';
import '.';
jest.mock('react-dom', () => ({
render: jest.fn(),
}));
jest.mock('@edx/frontend-platform', () => ({
APP_READY: 'app-is-ready-key',
initialize: jest.fn(),
subscribe: jest.fn(),
}));
jest.mock('@edx/frontend-component-footer', () => ({
messages: ['some', 'messages'],
}));
jest.mock('./App', () => () => (<div>App</div>));
describe('app registry', () => {
let getElement;
beforeEach(() => {
getElement = window.document.getElementById;
window.document.getElementById = jest.fn(id => ({ id }));
});
afterAll(() => {
window.document.getElementById = getElement;
});
test('subscribe is called for APP_READY, linking App to root element', () => {
const callArgs = subscribe.mock.calls[0];
expect(callArgs[0]).toEqual(APP_READY);
expect(callArgs[1]()).toEqual(
ReactDOM.render(<App />, document.getElementById('root')),
);
});
test('initialize is called with footerMessages and requireAuthenticatedUser', () => {
expect(initialize).toHaveBeenCalledWith({
messages: [appMessages, footerMessages],
requireAuthenticatedUser: true,
});
});
});

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

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

85
src/segment.js Normal file
View File

@@ -0,0 +1,85 @@
// 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);
}());

104
src/setupTest.js Executable file
View File

@@ -0,0 +1,104 @@
/* eslint-disable import/no-extraneous-dependencies */
import '@testing-library/jest-dom';
import '@testing-library/jest-dom/extend-expect';
import Enzyme from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
import AppProvider from '@edx/frontend-platform/react/AppProvider';
import { IntlProvider } from 'react-intl';
import { render as rtlRender } from '@testing-library/react';
import PropTypes from 'prop-types';
import { getConfig, mergeConfig } from '@edx/frontend-platform';
import { configure as configureAuth, MockAuthService } from '@edx/frontend-platform/auth';
import { configure as configureI18n } from '@edx/frontend-platform/i18n';
import appMessages from './i18n';
import { messages as footerMessages } from '@edx/frontend-component-footer';
Enzyme.configure({ adapter: new Adapter() });
jest.mock('@edx/frontend-platform/i18n', () => {
const i18n = jest.requireActual('@edx/frontend-platform/i18n');
const PropTypes = jest.requireActual('prop-types');
return {
...i18n,
intlShape: PropTypes.shape({
formatMessage: jest.fn(msg => msg.defaultMessage),
}),
defineMessages: m => m,
FormattedMessage: () => 'FormattedMessage',
};
});
export const authenticatedUser = {
userId: 'abc123',
username: 'Mock User',
roles: [],
administrator: false,
};
export function initializeMockApp() {
mergeConfig({
CONTACT_URL: process.env.CONTACT_URL || null,
INSIGHTS_BASE_URL: process.env.INSIGHTS_BASE_URL || null,
STUDIO_BASE_URL: process.env.STUDIO_BASE_URL || null,
TWITTER_URL: process.env.TWITTER_URL || null,
authenticatedUser: {
userId: 'abc123',
username: 'Mock User',
roles: [],
administrator: false,
},
SUPPORT_URL_ID_VERIFICATION: 'http://example.com',
});
const authService = configureAuth(MockAuthService, {
config: getConfig()
});
// i18n doesn't have a service class to return.
configureI18n({
config: getConfig(),
messages: [appMessages, footerMessages],
requireAuthenticatedUser: true,
});
return { authService };
}
function render(
ui,
{
store = null,
...renderOptions
} = {},
) {
function Wrapper({ children }) {
return (
// eslint-disable-next-line react/jsx-filename-extension
<IntlProvider locale="en">
<AppProvider store={store || {}}>
{children}
</AppProvider>
</IntlProvider>
);
}
Wrapper.propTypes = {
children: PropTypes.node.isRequired,
};
return rtlRender(ui, { wrapper: Wrapper, ...renderOptions });
}
// Re-export everything.
export * from '@testing-library/react';
// Override `render` method.
export {
render,
};

24
src/utils/StrictDict.js Normal file
View File

@@ -0,0 +1,24 @@
/* eslint-disable no-console */
const strictGet = (target, name) => {
if (name === Symbol.toStringTag) {
return target;
}
if (name in target || name === '_reactFragment') {
return target[name];
}
if (name === '$$typeof') {
return typeof target;
}
console.log(name.toString());
console.error({ target, name });
const e = Error(`invalid property "${name.toString()}"`);
console.error(e.stack);
return undefined;
};
const StrictDict = (dict) => new Proxy(dict, { get: strictGet });
export default StrictDict;

View File

@@ -0,0 +1,62 @@
import StrictDict from './StrictDict';
const value1 = 'valUE1';
const value2 = 'vALue2';
const key1 = 'Key1';
const key2 = 'keY2';
jest.spyOn(window, 'Error').mockImplementation(error => ({ stack: error }));
describe('StrictDict', () => {
let consoleError;
let consoleLog;
let windowError;
beforeEach(() => {
consoleError = window.console.error;
consoleLog = window.console.lot;
windowError = window.Error;
window.console.error = jest.fn();
window.console.log = jest.fn();
window.Error = jest.fn(error => ({ stack: error }));
});
afterAll(() => {
window.console.error = consoleError;
window.console.log = consoleLog;
window.Error = windowError;
});
const rawDict = {
[key1]: value1,
[key2]: value2,
};
const dict = StrictDict(rawDict);
it('provides key access like a normal dict object', () => {
expect(dict[key1]).toEqual(value1);
});
it('allows key listing', () => {
expect(Object.keys(dict)).toEqual([key1, key2]);
});
it('allows item listing', () => {
expect(Object.values(dict)).toEqual([value1, value2]);
});
it('allows stringification', () => {
expect(dict.toString()).toEqual(rawDict.toString());
expect({ ...dict }).toEqual({ ...rawDict });
});
it('allows entry listing', () => {
expect(Object.entries(dict)).toEqual(Object.entries(rawDict));
});
describe('missing key', () => {
it('logs error with target, name, and error stack', () => {
// eslint-ignore-next-line no-unused-vars
const callBadKey = () => dict.fakeKey;
callBadKey();
expect(window.console.error.mock.calls).toEqual([
[{ target: dict, name: 'fakeKey' }],
[Error('invalid property "fakeKey"').stack],
]);
});
it('returns undefined', () => {
expect(dict.fakeKey).toEqual(undefined);
});
});
});

2
src/utils/index.js Normal file
View File

@@ -0,0 +1,2 @@
/* eslint-disable import/prefer-default-export */
export { default as StrictDict } from './StrictDict';

13
webpack.dev.config.js Normal file
View File

@@ -0,0 +1,13 @@
const path = require('path');
const { createConfig } = require('@edx/frontend-build');
const config = createConfig('webpack-dev');
config.resolve.modules = [
path.resolve(__dirname, './src'),
'node_modules',
];
config.module.rules[0].exclude = /node_modules\/(?!(query-string|split-on-first|strict-uri-encode|@edx))/;
module.exports = config;

13
webpack.prod.config.js Normal file
View File

@@ -0,0 +1,13 @@
const path = require('path');
const { createConfig } = require('@edx/frontend-build');
const config = createConfig('webpack-prod');
config.resolve.modules = [
path.resolve(__dirname, './src'),
'node_modules',
];
config.module.rules[0].exclude = /node_modules\/(?!(query-string|split-on-first|strict-uri-encode|@edx))/;
module.exports = config;