10
.dockerignore
Executable file
10
.dockerignore
Executable 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
32
.env
Normal 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
38
.env.development
Normal 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
38
.env.test
Normal 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
5
.eslintignore
Executable file
@@ -0,0 +1,5 @@
|
||||
coverage/*
|
||||
dist/
|
||||
node_modules/
|
||||
src/postcss.config.js
|
||||
src/segment.js
|
||||
21
.eslintrc.js
Normal file
21
.eslintrc.js
Normal 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
23
.gitignore
vendored
Executable 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
13
.npmignore
Executable 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
27
.releaserc
Normal 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
28
.travis.yml
Executable 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
8
.tx/config
Normal 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
|
||||
65
Makefile
Executable file
65
Makefile
Executable 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
3
babel.config.js
Normal file
@@ -0,0 +1,3 @@
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
|
||||
module.exports = createConfig('babel');
|
||||
77
documentation/.travis.yml.md
Executable file
77
documentation/.travis.yml.md
Executable 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).
|
||||
|
||||

|
||||
|
||||
## 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
15
jest.config.js
Normal 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
9
openedx.yaml
Normal 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
26675
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
90
package.json
Executable file
90
package.json
Executable 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
12
public/index.html
Executable 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
49
src/App.jsx
Executable 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
77
src/App.scss
Executable 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
80
src/App.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
26
src/__snapshots__/App.test.jsx.snap
Normal file
26
src/__snapshots__/App.test.jsx.snap
Normal 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>
|
||||
`;
|
||||
60
src/components/ResponseDisplay.jsx
Normal file
60
src/components/ResponseDisplay.jsx
Normal 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);
|
||||
42
src/components/StatusBadge.jsx
Normal file
42
src/components/StatusBadge.jsx
Normal 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
16
src/config/index.js
Normal 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 };
|
||||
34
src/containers/CourseHeader/AnonymousUserMenu.jsx
Normal file
34
src/containers/CourseHeader/AnonymousUserMenu.jsx
Normal 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);
|
||||
31
src/containers/CourseHeader/AnonymousUserMenu.messages.js
Normal file
31
src/containers/CourseHeader/AnonymousUserMenu.messages.js
Normal 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;
|
||||
53
src/containers/CourseHeader/AuthenticatedUserDropdown.jsx
Normal file
53
src/containers/CourseHeader/AuthenticatedUserDropdown.jsx
Normal 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);
|
||||
@@ -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);
|
||||
81
src/containers/CourseHeader/Header.jsx
Normal file
81
src/containers/CourseHeader/Header.jsx
Normal 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);
|
||||
29
src/containers/CourseHeader/Header.test.jsx
Normal file
29
src/containers/CourseHeader/Header.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
1
src/containers/CourseHeader/index.js
Normal file
1
src/containers/CourseHeader/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Header } from './Header';
|
||||
36
src/containers/CourseHeader/messages.js
Normal file
36
src/containers/CourseHeader/messages.js
Normal 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;
|
||||
3
src/containers/ListView/ListView.scss
Normal file
3
src/containers/ListView/ListView.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
#ora-esg-list-view {
|
||||
padding: 20px;
|
||||
}
|
||||
149
src/containers/ListView/index.jsx
Normal file
149
src/containers/ListView/index.jsx
Normal 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);
|
||||
61
src/containers/ReviewModal/ReviewActions.jsx
Normal file
61
src/containers/ReviewModal/ReviewActions.jsx
Normal 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);
|
||||
93
src/containers/ReviewModal/ReviewModal.scss
Normal file
93
src/containers/ReviewModal/ReviewModal.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
68
src/containers/ReviewModal/StartGradingButton.jsx
Normal file
68
src/containers/ReviewModal/StartGradingButton.jsx
Normal 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);
|
||||
65
src/containers/ReviewModal/SubmissionNavigation.jsx
Normal file
65
src/containers/ReviewModal/SubmissionNavigation.jsx
Normal 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);
|
||||
81
src/containers/ReviewModal/index.jsx
Normal file
81
src/containers/ReviewModal/index.jsx
Normal 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);
|
||||
93
src/containers/Rubric/GradingRubric.temp.jsx
Normal file
93
src/containers/Rubric/GradingRubric.temp.jsx
Normal 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);
|
||||
33
src/containers/Rubric/index.jsx
Normal file
33
src/containers/Rubric/index.jsx
Normal 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
19
src/data/actions/app.js
Normal 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,
|
||||
});
|
||||
95
src/data/actions/grading.js
Normal file
95
src/data/actions/grading.js
Normal 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
11
src/data/actions/index.js
Normal 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,
|
||||
});
|
||||
15
src/data/actions/submissions.js
Normal file
15
src/data/actions/submissions.js
Normal 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,
|
||||
});
|
||||
48
src/data/actions/testUtils.js
Normal file
48
src/data/actions/testUtils.js
Normal 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
10
src/data/actions/utils.js
Normal 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,
|
||||
};
|
||||
19
src/data/actions/utils.test.js
Normal file
19
src/data/actions/utils.test.js
Normal 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),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
4
src/data/constants/app.js
Normal file
4
src/data/constants/app.js
Normal 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
36
src/data/reducers/app.js
Normal 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;
|
||||
140
src/data/reducers/grading.js
Normal file
140
src/data/reducers/grading.js
Normal 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
14
src/data/reducers/index.js
Executable 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;
|
||||
32
src/data/reducers/submissions.js
Normal file
32
src/data/reducers/submissions.js
Normal 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
50
src/data/selectors/app.js
Normal 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,
|
||||
});
|
||||
150
src/data/selectors/grading.js
Normal file
150
src/data/selectors/grading.js
Normal 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,
|
||||
});
|
||||
11
src/data/selectors/index.js
Normal file
11
src/data/selectors/index.js
Normal 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,
|
||||
});
|
||||
33
src/data/selectors/submissions.js
Normal file
33
src/data/selectors/submissions.js
Normal 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,
|
||||
});
|
||||
135
src/data/services/lms/api.js
Normal file
135
src/data/services/lms/api.js
Normal 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,
|
||||
});
|
||||
32
src/data/services/lms/constants.js
Normal file
32
src/data/services/lms/constants.js
Normal 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',
|
||||
});
|
||||
5
src/data/services/lms/fakeData/course.js
Normal file
5
src/data/services/lms/fakeData/course.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export const org = 'AuroraU';
|
||||
export const number = '101';
|
||||
export const title = 'Time Travel 101';
|
||||
|
||||
export default { org, number, title };
|
||||
17
src/data/services/lms/fakeData/ids.js
Normal file
17
src/data/services/lms/fakeData/ids.js
Normal 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,
|
||||
});
|
||||
23
src/data/services/lms/fakeData/index.js
Normal file
23
src/data/services/lms/fakeData/index.js
Normal 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,
|
||||
};
|
||||
65
src/data/services/lms/fakeData/ora.js
Normal file
65
src/data/services/lms/fakeData/ora.js
Normal 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,
|
||||
};
|
||||
22
src/data/services/lms/fakeData/submissionFull.js
Normal file
22
src/data/services/lms/fakeData/submissionFull.js
Normal 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,
|
||||
});
|
||||
74
src/data/services/lms/fakeData/submissionList.js
Normal file
74
src/data/services/lms/fakeData/submissionList.js
Normal 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;
|
||||
8
src/data/services/lms/index.js
Normal file
8
src/data/services/lms/index.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import { StrictDict } from 'utils';
|
||||
import api from './api';
|
||||
import urls from './urls';
|
||||
|
||||
export default StrictDict({
|
||||
api,
|
||||
urls,
|
||||
});
|
||||
11
src/data/services/lms/urls.js
Normal file
11
src/data/services/lms/urls.js
Normal 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,
|
||||
});
|
||||
42
src/data/services/lms/utils.js
Normal file
42
src/data/services/lms/utils.js
Normal 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] }),
|
||||
{},
|
||||
);
|
||||
97
src/data/services/lms/utils.test.js
Normal file
97
src/data/services/lms/utils.test.js
Normal 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
36
src/data/store.js
Executable 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
65
src/data/store.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
20
src/data/thunkActions/app.js
Normal file
20
src/data/thunkActions/app.js
Normal 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 });
|
||||
119
src/data/thunkActions/grading.js
Normal file
119
src/data/thunkActions/grading.js
Normal 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,
|
||||
});
|
||||
9
src/data/thunkActions/index.js
Normal file
9
src/data/thunkActions/index.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { StrictDict } from 'utils';
|
||||
|
||||
import app from './app';
|
||||
import grading from './grading';
|
||||
|
||||
export default StrictDict({
|
||||
app,
|
||||
grading,
|
||||
});
|
||||
53
src/data/thunkActions/testUtils.js
Normal file
53
src/data/thunkActions/testUtils.js
Normal 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
19
src/data/utils.js
Normal 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
29
src/data/utils.test.js
Normal 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
14
src/i18n/index.jsx
Normal 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;
|
||||
2
src/i18n/messages/ar.json
Normal file
2
src/i18n/messages/ar.json
Normal file
@@ -0,0 +1,2 @@
|
||||
{
|
||||
}
|
||||
2
src/i18n/messages/es_419.json
Normal file
2
src/i18n/messages/es_419.json
Normal file
@@ -0,0 +1,2 @@
|
||||
{
|
||||
}
|
||||
2
src/i18n/messages/fr.json
Normal file
2
src/i18n/messages/fr.json
Normal file
@@ -0,0 +1,2 @@
|
||||
{
|
||||
}
|
||||
2
src/i18n/messages/zh_CN.json
Normal file
2
src/i18n/messages/zh_CN.json
Normal file
@@ -0,0 +1,2 @@
|
||||
{
|
||||
}
|
||||
46
src/index.jsx
Executable file
46
src/index.jsx
Executable 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
51
src/index.test.jsx
Normal 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
7
src/postcss.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
/* I'm here to allow autoprefixing in webpack.prod.config.js */
|
||||
module.exports = {
|
||||
plugins: [
|
||||
require('autoprefixer')({ grid: true, browsers: ['>1%'] }),
|
||||
],
|
||||
};
|
||||
|
||||
85
src/segment.js
Normal file
85
src/segment.js
Normal 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
104
src/setupTest.js
Executable 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
24
src/utils/StrictDict.js
Normal 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;
|
||||
62
src/utils/StrictDict.test.js
Normal file
62
src/utils/StrictDict.test.js
Normal 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
2
src/utils/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as StrictDict } from './StrictDict';
|
||||
13
webpack.dev.config.js
Normal file
13
webpack.dev.config.js
Normal 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
13
webpack.prod.config.js
Normal 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;
|
||||
Reference in New Issue
Block a user