initial checkin

This commit is contained in:
Ben Warzeski
2022-05-25 14:18:30 -04:00
parent ffc23cf8ef
commit 787e780645
102 changed files with 63591 additions and 0 deletions

10
.dockerignore Executable file
View File

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

32
.env Normal file
View File

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

38
.env.development Normal file
View File

@@ -0,0 +1,38 @@
NODE_ENV='development'
PORT=1993
BASE_URL='localhost:1993'
LMS_BASE_URL='http://localhost:18000'
LOGIN_URL='http://localhost:18000/login'
LOGOUT_URL='http://localhost:18000/logout'
LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
LOGO_POWERED_BY_OPEN_EDX_URL_SVG=https://edx-cdn.org/v3/stage/open-edx-tag.svg
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
USER_INFO_COOKIE_NAME='edx-user-info'
SITE_NAME=localhost
DATA_API_BASE_URL='http://localhost:8000'
// LMS_CLIENT_ID should match the lms DOT client application id your LMS containe
LMS_CLIENT_ID='login-service-client-id'
SEGMENT_KEY=''
FEATURE_FLAGS={}
MARKETING_SITE_BASE_URL='http://localhost:18000'
SUPPORT_URL='http://localhost:18000/support'
CONTACT_URL='http://localhost:18000/contact'
OPEN_SOURCE_URL='http://localhost:18000/openedx'
TERMS_OF_SERVICE_URL='http://localhost:18000/terms-of-service'
PRIVACY_POLICY_URL='http://localhost:18000/privacy-policy'
FACEBOOK_URL='https://www.facebook.com'
TWITTER_URL='https://twitter.com'
YOU_TUBE_URL='https://www.youtube.com'
LINKED_IN_URL='https://www.linkedin.com'
REDDIT_URL='https://www.reddit.com'
APPLE_APP_STORE_URL='https://www.apple.com/ios/app-store/'
GOOGLE_PLAY_URL='https://play.google.com/store'
ENTERPRISE_MARKETING_URL='http://example.com'
ENTERPRISE_MARKETING_UTM_SOURCE='example.com'
ENTERPRISE_MARKETING_UTM_CAMPAIGN='example.com Referral'
ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM='Footer'

38
.env.test Normal file
View File

@@ -0,0 +1,38 @@
NODE_ENV='test'
PORT=1993
BASE_URL='localhost:1993'
LMS_BASE_URL='http://localhost:18000'
LOGIN_URL='http://localhost:18000/login'
LOGOUT_URL='http://localhost:18000/logout'
LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
LOGO_POWERED_BY_OPEN_EDX_URL_SVG=https://edx-cdn.org/v3/stage/open-edx-tag.svg
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
USER_INFO_COOKIE_NAME='edx-user-info'
SITE_NAME=localhost
DATA_API_BASE_URL='http://localhost:8000'
// LMS_CLIENT_ID should match the lms DOT client application id your LMS containe
LMS_CLIENT_ID='login-service-client-id'
SEGMENT_KEY=''
FEATURE_FLAGS={}
MARKETING_SITE_BASE_URL='http://localhost:18000'
SUPPORT_URL='http://localhost:18000/support'
CONTACT_URL='http://localhost:18000/contact'
OPEN_SOURCE_URL='http://localhost:18000/openedx'
TERMS_OF_SERVICE_URL='http://localhost:18000/terms-of-service'
PRIVACY_POLICY_URL='http://localhost:18000/privacy-policy'
FACEBOOK_URL='https://www.facebook.com'
TWITTER_URL='https://twitter.com'
YOU_TUBE_URL='https://www.youtube.com'
LINKED_IN_URL='https://www.linkedin.com'
REDDIT_URL='https://www.reddit.com'
APPLE_APP_STORE_URL='https://www.apple.com/ios/app-store/'
GOOGLE_PLAY_URL='https://play.google.com/store'
ENTERPRISE_MARKETING_URL='http://example.com'
ENTERPRISE_MARKETING_UTM_SOURCE='example.com'
ENTERPRISE_MARKETING_UTM_CAMPAIGN='example.com Referral'
ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM='Footer'

5
.eslintignore Executable file
View File

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

21
.eslintrc.js Normal file
View File

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

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
*.snap linguist-generated=false

57
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,57 @@
name: Node CI
on:
push:
branches:
- master
pull_request:
branches:
- '**'
jobs:
tests:
runs-on: ubuntu-20.04
strategy:
matrix:
node: [12, 14, 16]
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Setup Nodejs
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node }}
- name: Install dependencies
run: npm ci
- name: Verify No Uncommitted Changes
run: make validate-no-uncommitted-package-lock-changes
- name: Lint
run: npm run lint
- name: Test
run: npm run test
- name: Build
run: npm run build
- name: Run Coverage
uses: codecov/codecov-action@v2
- name: Send failure notification
if: ${{ failure() }}
uses: dawidd6/action-send-mail@v3
with:
server_address: email-smtp.us-east-1.amazonaws.com
server_port: 465
username: ${{ secrets.EDX_SMTP_USERNAME }}
password: ${{ secrets.EDX_SMTP_PASSWORD }}
subject: Upgrade python requirements workflow failed in ${{github.repository}}
to: masters-grades@edx.org
from: github-actions <github-actions@edx.org>
body: Upgrade python requirements workflow in ${{github.repository}} failed!
For details see "github.com/${{ github.repository }}/actions/runs/${{ github.run_id
}}"

10
.github/workflows/commitlint.yml vendored Normal file
View File

@@ -0,0 +1,10 @@
# Run commitlint on the commit messages in a pull request.
name: Lint Commit Messages
on:
- pull_request
jobs:
commitlint:
uses: edx/.github/.github/workflows/commitlint.yml@master

View File

@@ -0,0 +1,13 @@
#check package-lock file version
name: Lockfile Version check
on:
push:
branches:
- master
pull_request:
jobs:
version-check:
uses: edx/.github/.github/workflows/lockfileversion-check.yml@master

32
.github/workflows/npm-publish.yml vendored Normal file
View File

@@ -0,0 +1,32 @@
name: Release CI
on:
push:
tags:
- "*"
jobs:
release:
name: Release
runs-on: ubuntu-20.04
steps:
- name: Checkout
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v2
with:
node-version: 12
- name: Install dependencies
run: npm ci
- name: Create Build
run: npm run build
- name: Release Package
env:
GITHUB_TOKEN: ${{ secrets.SEMANTIC_RELEASE_GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.SEMANTIC_RELEASE_NPM_TOKEN }}
run: npm semantic-release

27
.gitignore vendored Executable file
View File

@@ -0,0 +1,27 @@
.DS_Store
.eslintcache
node_modules
npm-debug.log
coverage
dist/
public/samples/
### pyenv ###
.python-version
### Emacs ###
*~
*.swo
*.swp
### Development environments ###
.idea
.vscode
# Local package dependencies
module.config.js
### transifex ###
src/i18n/transifex_input.json
temp

4
.husky/pre-push Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npm run lint

12
.npmignore Executable file
View File

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

27
.releaserc Normal file
View File

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

9
.tx/config Normal file
View File

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

0
LICENSE Normal file → Executable file
View File

65
Makefile Executable file
View File

@@ -0,0 +1,65 @@
npm-install-%: ## install specified % npm package
npm install $* --save-dev
git add package.json
transifex_resource = frontend-app-learner-dashboard
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 --languages=$(transifex_langs)
# This target is used by CI.
validate-no-uncommitted-package-lock-changes:
# Checking for package-lock.json changes...
git diff --exit-code package-lock.json

29
documentation/.ci.yml.md Executable file
View File

@@ -0,0 +1,29 @@
# CI Configuration
Your project might have different build requirements - however, this project's `.github/workflows/ci.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.
## Notifications
This project uses a [`Composite Action`](https://docs.github.com/en/actions/creating-actions/about-custom-actions) for sending notifications for failure on the PRs.
## 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.
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.

21
jest.config.js Normal file
View File

@@ -0,0 +1,21 @@
const { createConfig } = require('@edx/frontend-build');
module.exports = createConfig('jest', {
setupFilesAfterEnv: [
'jest-expect-message',
'<rootDir>/src/setupTest.js',
],
modulePaths: ['<rootDir>/src/'],
snapshotSerializers: [
'enzyme-to-json/serializer',
],
coveragePathIgnorePatterns: [
'src/segment.js',
'src/postcss.config.js',
'testUtils', // don't unit test jest mocking tools
'src/data/services/lms/fakeData', // don't unit test mock data
'src/test', // don't unit test integration test utils
],
testTimeout: 120000,
testEnvironment: 'jsdom',
});

9
openedx.yaml Normal file
View File

@@ -0,0 +1,9 @@
# This file describes this Open edX repo, as described in OEP-2:
# http://open-edx-proposals.readthedocs.io/en/latest/oeps/oep-0002.html#specification
tags:
- frontend-app
- masters
oeps:
oep-2: true # Repository metadata
openedx-release: {ref: master}

59319
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

94
package.json Executable file
View File

@@ -0,0 +1,94 @@
{
"name": "@edx/frontend-app-learner-dash",
"version": "0.0.1",
"description": "",
"repository": {
"type": "git",
"url": "git+https://github.com/edx/frontend-app-learner-dash.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/",
"semantic-release": "semantic-release",
"start": "fedx-scripts webpack-dev-server --progress",
"test": "TZ=GMT fedx-scripts jest --coverage --passWithNoTests",
"watch-tests": "jest --watch",
"prepare": "husky install"
},
"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-component-header": "^2.4.6",
"@edx/frontend-platform": "^1.15.6",
"@edx/paragon": "16.14.4",
"@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",
"@testing-library/user-event": "^13.5.0",
"@zip.js/zip.js": "^2.4.6",
"axios": "^0.21.4",
"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",
"file-saver": "^2.0.5",
"filesize": "^8.0.6",
"font-awesome": "4.7.0",
"history": "5.0.1",
"html-react-parser": "^1.3.0",
"lodash": "^4.17.21",
"prop-types": "15.7.2",
"query-string": "7.0.1",
"react": "^16.14.0",
"react-dom": "^16.14.0",
"react-intl": "^5.20.9",
"react-pdf": "^5.5.0",
"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": "^9.1.4",
"@testing-library/jest-dom": "^5.14.1",
"@testing-library/react": "^12.1.0",
"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.0",
"identity-obj-proxy": "^3.0.0",
"jest": "27.0.6",
"jest-expect-message": "^1.0.2",
"react-dev-utils": "^11.0.4",
"react-test-renderer": "^16.14.0",
"reactifex": "1.1.1",
"redux-mock-store": "^1.5.4",
"semantic-release": "^17.4.5"
}
}

9
public/empty-course.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 46 KiB

12
public/index.html Executable file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en-us" dir="ltr">
<head>
<title>ORA staff grading | <%= 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>

View File

@@ -0,0 +1,52 @@
<svg width="186" height="125" viewBox="0 0 186 125" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M136.816 127H-34L0.183996 -2H171L136.816 127Z" fill="#03C7E8"/>
<path d="M117.806 36.9351L114.389 88.1542C114.389 90.0483 112.27 91.5872 109.647 91.5872H38.8895C36.2742 91.5872 34.1445 90.0483 34.1445 88.1542L37.5618 36.9351C37.5618 35.0411 39.6915 33.5022 42.3067 33.5022H113.065C115.69 33.5022 117.806 35.0411 117.806 36.9351Z" fill="white"/>
<path d="M109.651 91.7611H38.893C36.1806 91.7611 33.9746 90.1422 33.9746 88.1541L37.3919 36.9316C37.3919 34.9435 39.5979 33.328 42.3102 33.328H113.068C115.777 33.328 117.983 34.9435 117.983 36.9316L114.566 88.1541C114.566 90.1596 112.36 91.7611 109.651 91.7611ZM42.3102 33.6762C39.7886 33.6762 37.7387 35.135 37.7387 36.9316L34.3215 88.1541C34.3215 89.9507 36.3714 91.413 38.893 91.413H109.651C112.169 91.413 114.219 89.9507 114.219 88.1541L117.636 36.9316C117.636 35.135 115.586 33.6762 113.068 33.6762H42.3102Z" fill="#002121"/>
<path d="M114.388 36.9351V39.63H37.5752V36.9351C37.5752 35.0411 39.7049 33.5022 42.3201 33.5022H109.661C112.273 33.5022 114.388 35.0411 114.388 36.9351Z" fill="white"/>
<path d="M117.806 39.8042H37.5758C37.5298 39.8042 37.4857 39.7858 37.4531 39.7532C37.4206 39.7206 37.4023 39.6763 37.4023 39.6301V36.9318C37.4023 34.9438 39.6083 33.3282 42.3207 33.3282H113.079C115.788 33.3282 117.994 34.9438 117.994 36.9318V39.6301C117.994 39.6542 117.989 39.678 117.979 39.7001C117.97 39.7222 117.956 39.742 117.938 39.7584C117.92 39.7748 117.9 39.7873 117.877 39.7952C117.854 39.803 117.83 39.8061 117.806 39.8042ZM37.7457 39.456H117.636V36.9318C117.636 35.1352 115.586 33.6764 113.068 33.6764H42.3103C39.7887 33.6764 37.7388 35.1352 37.7388 36.9318L37.7457 39.456Z" fill="#002121"/>
<path d="M101.787 43.4041H47.5498V48.8216H101.787V43.4041Z" fill="white"/>
<path d="M101.788 48.9957H47.5504C47.5044 48.9957 47.4603 48.9773 47.4277 48.9447C47.3952 48.912 47.377 48.8677 47.377 48.8216V43.4041C47.377 43.3579 47.3952 43.3136 47.4277 43.281C47.4603 43.2483 47.5044 43.23 47.5504 43.23H101.788C101.833 43.2309 101.877 43.2495 101.909 43.282C101.942 43.3144 101.96 43.3582 101.961 43.4041V48.8216C101.961 48.8677 101.943 48.912 101.91 48.9447C101.878 48.9773 101.834 48.9957 101.788 48.9957ZM47.7238 48.6475H101.614V43.5781H47.7238V48.6475Z" fill="#002121"/>
<path d="M101.787 43.4041H94.4307V48.8216H101.787V43.4041Z" fill="#D23228"/>
<path d="M101.787 48.9957H94.4303C94.3843 48.9957 94.3402 48.9773 94.3076 48.9447C94.2751 48.912 94.2568 48.8677 94.2568 48.8216V43.4041C94.2568 43.3579 94.2751 43.3136 94.3076 43.281C94.3402 43.2483 94.3843 43.23 94.4303 43.23H101.787C101.833 43.2309 101.876 43.2495 101.909 43.282C101.941 43.3144 101.96 43.3582 101.96 43.4041V48.8216C101.96 48.8677 101.942 48.912 101.91 48.9447C101.877 48.9773 101.833 48.9957 101.787 48.9957ZM94.6037 48.6475H101.614V43.5781H94.6176L94.6037 48.6475Z" fill="#002121"/>
<path d="M112.707 52.575H84.7161L81.2988 80.6721H109.29L112.707 52.575Z" fill="white"/>
<path d="M72.7453 53.2748H41.6084V53.9711H72.7453V53.2748Z" fill="#03C7E8"/>
<path d="M62.319 55.3567H41.6084V56.053H62.319V55.3567Z" fill="#03C7E8"/>
<path d="M74.9999 57.4353H41.6084V58.1316H74.9999V57.4353Z" fill="#D1D3D4"/>
<path d="M70.9591 59.5173H41.6084V60.2137H70.9591V59.5173Z" fill="#D1D3D4"/>
<path d="M46.2602 61.7386H41.8447V62.0868H46.2602V61.7386Z" fill="#D1D3D4"/>
<path d="M49.9916 61.7386H47.5498V62.0868H49.9916V61.7386Z" fill="#D1D3D4"/>
<path d="M53.4777 61.7386H51.1191V62.0868H53.4777V61.7386Z" fill="#D1D3D4"/>
<path d="M66.0303 65.8436H41.6084V66.54H66.0303V65.8436Z" fill="#03C7E8"/>
<path d="M70.9591 67.9255H41.6084V68.6219H70.9591V67.9255Z" fill="#03C7E8"/>
<path d="M61.2403 70.0042H41.6084V70.7005H61.2403V70.0042Z" fill="#D1D3D4"/>
<path d="M67.3449 72.0863H41.6084V72.7826H67.3449V72.0863Z" fill="#D1D3D4"/>
<path d="M44.0507 74.3076H41.8447V74.6558H44.0507V74.3076Z" fill="#D1D3D4"/>
<path d="M49.992 74.3076H45.1777V74.6558H49.992V74.3076Z" fill="#D1D3D4"/>
<path d="M52.7147 74.3076H51.1191V74.6558H52.7147V74.3076Z" fill="#D1D3D4"/>
<path d="M68.9161 78.4125H41.6084V79.1088H68.9161V78.4125Z" fill="#03C7E8"/>
<path d="M72.7453 80.4944H41.6084V81.1907H72.7453V80.4944Z" fill="#03C7E8"/>
<path d="M62.9294 82.573H41.6084V83.2693H62.9294V82.573Z" fill="#D1D3D4"/>
<path d="M59.173 84.6516H41.6084V85.3479H59.173V84.6516Z" fill="#D1D3D4"/>
<path d="M47.5851 86.8765H41.8447V87.2246H47.5851V86.8765Z" fill="#D1D3D4"/>
<path d="M52.2983 86.8765H49.0518V87.2246H52.2983V86.8765Z" fill="#D1D3D4"/>
<path d="M41.5567 37.7115C42.1889 37.7115 42.7013 37.1971 42.7013 36.5625C42.7013 35.928 42.1889 35.4136 41.5567 35.4136C40.9246 35.4136 40.4121 35.928 40.4121 36.5625C40.4121 37.1971 40.9246 37.7115 41.5567 37.7115Z" fill="#03C7E8"/>
<path d="M44.9981 37.7115C45.6303 37.7115 46.1427 37.1971 46.1427 36.5625C46.1427 35.928 45.6303 35.4136 44.9981 35.4136C44.366 35.4136 43.8535 35.928 43.8535 36.5625C43.8535 37.1971 44.366 37.7115 44.9981 37.7115Z" fill="#E6E7E8"/>
<path d="M48.4386 37.7115C49.0707 37.7115 49.5832 37.1971 49.5832 36.5625C49.5832 35.928 49.0707 35.4136 48.4386 35.4136C47.8064 35.4136 47.2939 35.928 47.2939 36.5625C47.2939 37.1971 47.8064 37.7115 48.4386 37.7115Z" fill="#E6E7E8"/>
<path d="M97.5748 47.241C97.2355 47.2404 96.9069 47.1217 96.645 46.9053C96.383 46.6888 96.2039 46.3879 96.1382 46.0537C96.0724 45.7196 96.1241 45.3729 96.2843 45.0727C96.4446 44.7725 96.7035 44.5373 97.0171 44.4072C97.3307 44.2771 97.6795 44.2601 98.0041 44.3592C98.3288 44.4582 98.6092 44.6671 98.7976 44.9503C98.9861 45.2335 99.0709 45.5736 99.0377 45.9125C99.0045 46.2515 98.8552 46.5684 98.6154 46.8093C98.479 46.9468 98.3168 47.0558 98.1382 47.1299C97.9596 47.204 97.7681 47.2418 97.5748 47.241ZM97.5748 44.6472C97.428 44.6465 97.2825 44.6751 97.1467 44.7313C97.011 44.7875 96.8877 44.8701 96.784 44.9745C96.6269 45.1316 96.5198 45.332 96.4763 45.5502C96.4328 45.7685 96.4547 45.9948 96.5394 46.2005C96.6241 46.4062 96.7677 46.5821 96.952 46.7058C97.1363 46.8296 97.3531 46.8956 97.5748 46.8956C97.7966 46.8956 98.0133 46.8296 98.1976 46.7058C98.382 46.5821 98.5256 46.4062 98.6102 46.2005C98.6949 45.9948 98.7169 45.7685 98.6733 45.5502C98.6298 45.332 98.5227 45.1316 98.3656 44.9745C98.2619 44.8701 98.1387 44.7875 98.0029 44.7313C97.8672 44.6751 97.7216 44.6465 97.5748 44.6472Z" fill="white"/>
<path d="M99.5557 47.9304C99.5088 47.9302 99.4639 47.9115 99.4308 47.8781L98.366 46.8093C98.3342 46.7767 98.3164 46.733 98.3164 46.6874C98.3164 46.6418 98.3342 46.5981 98.366 46.5655C98.3821 46.5492 98.4013 46.5363 98.4224 46.5274C98.4435 46.5186 98.4662 46.514 98.4891 46.514C98.512 46.514 98.5347 46.5186 98.5558 46.5274C98.5769 46.5363 98.5961 46.5492 98.6122 46.5655L99.6771 47.6344C99.7008 47.6587 99.717 47.6895 99.7235 47.723C99.73 47.7564 99.7265 47.7911 99.7136 47.8226C99.7007 47.8541 99.6788 47.8811 99.6507 47.9002C99.6227 47.9194 99.5896 47.9299 99.5557 47.9304Z" fill="white"/>
<path d="M122.772 62.2817H94.2923L90.875 83.4887H119.355L122.772 62.2817Z" fill="white"/>
<path d="M119.356 83.6629H90.8756C90.8296 83.6629 90.7855 83.6446 90.7529 83.6119C90.7204 83.5793 90.7021 83.535 90.7021 83.4888L94.1194 62.2819C94.1194 62.2357 94.1377 62.1914 94.1702 62.1588C94.2027 62.1261 94.2468 62.1078 94.2928 62.1078H122.773C122.819 62.1087 122.862 62.1273 122.895 62.1598C122.927 62.1922 122.945 62.236 122.946 62.2819L119.529 83.4888C119.529 83.535 119.511 83.5793 119.478 83.6119C119.446 83.6446 119.402 83.6629 119.356 83.6629ZM91.049 83.3147H119.182L122.599 62.456H94.4663L91.049 83.3147Z" fill="#002121"/>
<path d="M95.223 79.6511H95.1872C92.6218 79.5708 91.9169 77.1839 91.9097 77.1594C91.9034 77.1365 91.9017 77.1125 91.9049 77.089C91.908 77.0654 91.9158 77.0427 91.928 77.022C91.9401 77.0014 91.9563 76.9834 91.9755 76.9689C91.9948 76.9544 92.0168 76.9437 92.0403 76.9375C92.0638 76.9313 92.0883 76.9297 92.1125 76.9328C92.1366 76.9358 92.1599 76.9435 92.181 76.9553C92.2021 76.9672 92.2206 76.983 92.2354 77.0018C92.2503 77.0206 92.2612 77.0421 92.2675 77.0651C92.2926 77.1559 92.9152 79.2283 95.2123 79.3017H95.223C96.3536 79.3017 97.1515 76.3417 97.8528 73.7102C98.5684 70.9843 99.2841 68.4087 100.447 68.1431C100.677 68.0804 100.918 68.0645 101.155 68.0963C101.392 68.1282 101.62 68.2071 101.824 68.3283C102.862 68.9679 103.256 70.7572 103.732 72.826C103.967 74.0001 104.28 75.1581 104.669 76.2927C105.292 77.9772 106.458 79.0221 107.582 78.8963C108.973 78.739 110.025 76.8379 110.387 73.808C111.421 65.1691 114.491 64.2221 115.754 64.2151H115.8C118.963 64.2151 120.598 65.8751 121.629 70.1246C121.634 70.1476 121.635 70.1713 121.631 70.1945C121.627 70.2178 121.618 70.24 121.606 70.2599C121.58 70.3002 121.539 70.3289 121.491 70.3395C121.444 70.3502 121.394 70.342 121.353 70.3168C121.311 70.2915 121.282 70.2513 121.271 70.205C120.273 66.0987 118.788 64.5646 115.79 64.5646H115.747C113.296 64.5646 111.417 68.0592 110.737 73.85C110.351 77.0965 109.213 79.064 107.617 79.2423C106.333 79.3925 105.009 78.2498 104.329 76.4116C103.936 75.2675 103.618 74.1003 103.377 72.9169C102.948 71.0053 102.544 69.202 101.628 68.6359C101.463 68.5392 101.28 68.4769 101.09 68.4528C100.9 68.4288 100.706 68.4435 100.522 68.4961C99.5631 68.7162 98.8368 71.4246 98.1963 73.8115C97.3698 76.8134 96.622 79.6511 95.223 79.6511Z" fill="#D23228"/>
<path d="M116.702 75.0353H112.723C112.677 75.0353 112.633 75.017 112.601 74.9843C112.568 74.9517 112.55 74.9074 112.55 74.8612C112.55 74.815 112.568 74.7708 112.601 74.7381C112.633 74.7055 112.677 74.6871 112.723 74.6871H116.702C116.748 74.6871 116.792 74.7055 116.824 74.7381C116.857 74.7708 116.875 74.815 116.875 74.8612C116.875 74.9074 116.857 74.9517 116.824 74.9843C116.792 75.017 116.748 75.0353 116.702 75.0353Z" fill="#D1D3D4"/>
<path d="M116.702 76.6822H112.723C112.677 76.6822 112.633 76.6638 112.601 76.6312C112.568 76.5985 112.55 76.5542 112.55 76.5081C112.55 76.4619 112.568 76.4176 112.601 76.385C112.633 76.3523 112.677 76.334 112.723 76.334H116.702C116.748 76.334 116.792 76.3523 116.824 76.385C116.857 76.4176 116.875 76.4619 116.875 76.5081C116.875 76.5542 116.857 76.5985 116.824 76.6312C116.792 76.6638 116.748 76.6822 116.702 76.6822Z" fill="#D1D3D4"/>
<path d="M114.711 78.3219H112.723C112.677 78.3219 112.633 78.3036 112.601 78.2709C112.568 78.2383 112.55 78.194 112.55 78.1478C112.55 78.1017 112.568 78.0574 112.601 78.0247C112.633 77.9921 112.677 77.9738 112.723 77.9738H114.711C114.757 77.9738 114.801 77.9921 114.833 78.0247C114.866 78.0574 114.884 78.1017 114.884 78.1478C114.884 78.194 114.866 78.2383 114.833 78.2709C114.801 78.3036 114.757 78.3219 114.711 78.3219Z" fill="#D1D3D4"/>
<path d="M99.4721 64.4718H95.4937C95.4477 64.4718 95.4036 64.4535 95.3711 64.4208C95.3386 64.3882 95.3203 64.3439 95.3203 64.2977C95.3203 64.2516 95.3386 64.2073 95.3711 64.1746C95.4036 64.142 95.4477 64.1237 95.4937 64.1237H99.4721C99.5181 64.1237 99.5622 64.142 99.5948 64.1746C99.6273 64.2073 99.6456 64.2516 99.6456 64.2977C99.6447 64.3436 99.6261 64.3874 99.5938 64.4199C99.5614 64.4523 99.5178 64.4709 99.4721 64.4718Z" fill="#D1D3D4"/>
<path d="M99.4721 66.136H95.4937C95.4477 66.136 95.4036 66.1177 95.3711 66.085C95.3386 66.0524 95.3203 66.0081 95.3203 65.9619C95.3203 65.9158 95.3386 65.8715 95.3711 65.8388C95.4036 65.8062 95.4477 65.7878 95.4937 65.7878H99.4721C99.5178 65.7887 99.5614 65.8074 99.5938 65.8398C99.6261 65.8723 99.6447 65.916 99.6456 65.9619C99.6456 66.0081 99.6273 66.0524 99.5948 66.085C99.5622 66.1177 99.5181 66.136 99.4721 66.136Z" fill="#D1D3D4"/>
<path d="M97.4812 67.7691H95.4937C95.4477 67.7691 95.4036 67.7507 95.3711 67.7181C95.3386 67.6854 95.3203 67.6412 95.3203 67.595C95.3203 67.5488 95.3386 67.5045 95.3711 67.4719C95.4036 67.4392 95.4477 67.4209 95.4937 67.4209H97.4812C97.5272 67.4209 97.5713 67.4392 97.6038 67.4719C97.6364 67.5045 97.6546 67.5488 97.6546 67.595C97.6546 67.6412 97.6364 67.6854 97.6038 67.7181C97.5713 67.7507 97.5272 67.7691 97.4812 67.7691Z" fill="#D1D3D4"/>
<path d="M43.1571 66.5403L39.7041 64.7689L35.5086 73.0094L38.9616 74.7808L43.1571 66.5403Z" fill="#002121"/>
<path d="M38.9582 74.9552C38.9314 74.9548 38.9052 74.9476 38.8819 74.9343L35.4134 73.1656C35.3928 73.1556 35.3745 73.1416 35.3594 73.1243C35.3444 73.1071 35.333 73.087 35.3259 73.0652C35.3187 73.0434 35.3161 73.0205 35.318 72.9976C35.3199 72.9748 35.3264 72.9526 35.3371 72.9323L39.534 64.6912C39.5439 64.6705 39.5579 64.6521 39.5752 64.6371C39.5925 64.6222 39.6127 64.6109 39.6346 64.6041C39.6768 64.5867 39.7241 64.5867 39.7664 64.6041L43.2349 66.3763C43.2551 66.3866 43.2731 66.4008 43.2878 66.4182C43.3025 66.4356 43.3136 66.4558 43.3203 66.4775C43.3271 66.4992 43.3295 66.5221 43.3273 66.5448C43.3252 66.5675 43.3185 66.5895 43.3077 66.6096L39.1212 74.8612C39.099 74.9004 39.0632 74.9302 39.0206 74.9447C39.0009 74.9529 38.9795 74.9564 38.9582 74.9552ZM35.7394 72.9358L38.8854 74.5478L42.9366 66.6165L39.7941 65.001L35.7394 72.9358Z" fill="#002121"/>
<path d="M39.9732 72.3475L36.8784 70.7599C36.4537 70.542 35.9334 70.711 35.7163 71.1373L28.5075 85.2963C28.2905 85.7226 28.4588 86.2449 28.8836 86.4628L31.9783 88.0504C32.403 88.2683 32.9233 88.0993 33.1404 87.6729L40.3492 73.514C40.5662 73.0876 40.3979 72.5654 39.9732 72.3475Z" fill="white"/>
<path d="M32.3716 88.3213C32.2085 88.3216 32.0478 88.2822 31.9033 88.2064L28.8024 86.6187C28.5604 86.4923 28.3775 86.2755 28.2931 86.015C28.2088 85.7545 28.2298 85.4712 28.3515 85.2261L35.5626 71.066C35.6885 70.8231 35.9045 70.6395 36.1641 70.5548C36.4236 70.4701 36.7058 70.4912 36.95 70.6134L40.0474 72.2011C40.2895 72.3275 40.4724 72.5443 40.5567 72.8048C40.641 73.0653 40.6201 73.3486 40.4983 73.5937L33.2942 87.7538C33.207 87.9242 33.0749 88.0673 32.9121 88.1674C32.7494 88.2675 32.5624 88.3207 32.3716 88.3213ZM36.4852 70.8397C36.4132 70.8391 36.3417 70.8509 36.2736 70.8745C36.1871 70.9032 36.107 70.9487 36.038 71.0084C35.9689 71.0682 35.9123 71.141 35.8713 71.2227L28.6602 85.3793C28.5788 85.5429 28.5648 85.7322 28.6212 85.9062C28.6777 86.0801 28.8001 86.2248 28.962 86.3089L32.0594 87.8965C32.222 87.9785 32.4103 87.9931 32.5835 87.9371C32.7567 87.8811 32.9011 87.759 32.9855 87.5971L40.1965 73.4475C40.278 73.2838 40.292 73.0946 40.2355 72.9206C40.1791 72.7467 40.0567 72.602 39.8948 72.5179L36.7974 70.9303C36.7025 70.8744 36.5952 70.8433 36.4852 70.8397Z" fill="#002121"/>
<path d="M49.3743 49.5596C47.6191 48.6577 45.6367 48.2987 43.6782 48.5281C41.7197 48.7575 39.873 49.5651 38.3718 50.8485C36.8707 52.1319 35.7826 53.8334 35.2453 55.7379C34.708 57.6423 34.7456 59.6639 35.3534 61.5468C35.9611 63.4297 37.1118 65.0894 38.6596 66.3156C40.2074 67.5418 42.0829 68.2796 44.0486 68.4354C46.0143 68.5913 47.9819 68.1582 49.7023 67.1911C51.4227 66.224 52.8187 64.7663 53.7134 63.0024C54.9109 60.6417 55.1266 57.9005 54.3131 55.3804C53.4996 52.8602 51.7235 50.7669 49.3743 49.5596ZM41.4625 65.0914C40.1559 64.4211 39.0766 63.3769 38.3612 62.0908C37.6458 60.8047 37.3264 59.3346 37.4434 57.8663C37.5604 56.398 38.1085 54.9975 39.0185 53.8419C39.9284 52.6864 41.1593 51.8276 42.5555 51.3742C43.9516 50.9209 45.4504 50.8933 46.8622 51.295C48.274 51.6966 49.5354 52.5095 50.4869 53.6309C51.4385 54.7522 52.0374 56.1316 52.2079 57.5946C52.3784 59.0576 52.1129 60.5385 51.445 61.85C50.5493 63.6085 48.9945 64.938 47.1225 65.5458C45.2505 66.1537 43.2145 65.9903 41.4625 65.0914Z" fill="white"/>
<path d="M44.831 68.6465C42.2701 68.6533 39.8009 67.6903 37.9163 65.9499C36.0317 64.2095 34.8703 61.8196 34.6639 59.2574C34.4576 56.6952 35.2216 54.1491 36.8033 52.1275C38.385 50.1058 40.6681 48.7573 43.1968 48.3512C45.7255 47.9451 48.3138 48.5112 50.4449 49.9365C52.576 51.3619 54.0933 53.5416 54.6937 56.0405C55.2941 58.5394 54.9335 61.1737 53.684 63.4175C52.4344 65.6613 50.3878 67.3495 47.9526 68.1451C46.9448 68.476 45.8913 68.6452 44.831 68.6465ZM44.8518 48.6337C43.8248 48.6351 42.8044 48.7972 41.8272 49.1142C39.4947 49.8762 37.534 51.4927 36.3362 53.6414C35.1384 55.7901 34.7914 58.3131 35.3644 60.7072C35.9375 63.1013 37.3885 65.1906 39.428 66.5585C41.4676 67.9264 43.9459 68.4723 46.3687 68.0875C48.7915 67.7026 50.9807 66.4152 52.4998 64.482C54.019 62.5487 54.7563 60.1118 54.5649 57.6571C54.3735 55.2023 53.2674 52.9102 51.4671 51.2378C49.6668 49.5654 47.3047 48.6355 44.8518 48.6337ZM44.8275 66.0839C43.6311 66.0838 42.4519 65.7986 41.3867 65.2518C39.7285 64.4033 38.4382 62.9734 37.7604 61.2334C37.0827 59.4934 37.0646 57.5642 37.7097 55.8117C38.3548 54.0592 39.6181 52.6052 41.2602 51.7256C42.9022 50.846 44.8087 50.6018 46.6182 51.0394C48.4277 51.477 50.0142 52.566 51.077 54.0997C52.1398 55.6335 52.6049 57.5055 52.3841 59.3606C52.1633 61.2157 51.2719 62.9249 49.8791 64.1642C48.4862 65.4035 46.6887 66.0866 44.8275 66.0839ZM41.5497 64.942C42.8248 65.5948 44.2639 65.8539 45.6855 65.6866C47.1071 65.5193 48.4475 64.9332 49.5376 64.0021C50.6277 63.071 51.4186 61.8367 51.8106 60.4548C52.2026 59.073 52.1782 57.6056 51.7403 56.2377C51.2989 54.8697 50.4634 53.6637 49.3394 52.7722C48.2154 51.8806 46.8533 51.3435 45.4253 51.2287C43.9973 51.114 42.5675 51.4267 41.3165 52.1274C40.0655 52.8281 39.0495 53.8853 38.3969 55.1654C37.5272 56.8822 37.3707 58.875 37.9616 60.7074C38.5525 62.5397 39.8428 64.0624 41.5497 64.942Z" fill="#002121"/>
</svg>

After

Width:  |  Height:  |  Size: 17 KiB

22
src/App.jsx Executable file
View File

@@ -0,0 +1,22 @@
import React from 'react';
import { BrowserRouter as Router } from 'react-router-dom';
import Footer from '@edx/frontend-component-footer';
import LearnerDashboardHeader from 'containers/LearnerDashboardHeader';
import Dashboard from 'containers/Dashboard';
import './App.scss';
export const App = () => (
<Router>
<div>
<LearnerDashboardHeader />
<main>
<Dashboard />
</main>
<Footer logo={process.env.LOGO_POWERED_BY_OPEN_EDX_URL_SVG} />
</div>
</Router>
);
export default App;

54
src/App.scss Executable file
View File

@@ -0,0 +1,54 @@
// 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;
}
}
#paragon-portal-root {
.pgn__modal-layer {
.pgn__modal-close-container {
right: 1rem !important;
}
}
.confirm-modal .pgn__modal-body {
overflow: hidden;
}
}

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

@@ -0,0 +1,75 @@
// import React from 'react';
// import { shallow } from 'enzyme';
// import Footer from '@edx/frontend-component-footer';
// import { LearningHeader as Header } from '@edx/frontend-component-header';
// import ListView from 'containers/ListView';
// import { App } from './App';
// jest.mock('data/redux', () => ({
// app: {
// selectors: {
// courseMetadata: (state) => ({ courseMetadata: state }),
// isEnabled: (state) => ({ isEnabled: state }),
// },
// },
// }));
// jest.mock('@edx/frontend-component-header', () => ({
// LearningHeader: 'Header',
// }));
// jest.mock('@edx/frontend-component-footer', () => 'Footer');
// jest.mock('containers/DemoWarning', () => 'DemoWarning');
// jest.mock('containers/ListView', () => 'ListView');
// const logo = 'fakeLogo.png';
// let el;
// let router;
// describe('App router component', () => {
// const props = {
// courseMetadata: {
// org: 'course-org',
// number: 'course-number',
// title: 'course-title',
// },
// isEnabled: true,
// };
// test('snapshot: enabled', () => {
// expect(shallow(<App {...props} />)).toMatchSnapshot();
// });
// test('snapshot: disabled (show demo warning)', () => {
// expect(shallow(<App {...props} isEnabled={false} />)).toMatchSnapshot();
// });
// describe('component', () => {
// beforeEach(() => {
// process.env.LOGO_POWERED_BY_OPEN_EDX_URL_SVG = logo;
// el = shallow(<App {...props} />);
// router = el.childAt(0);
// });
// describe('Router', () => {
// test('Routing - ListView is only route', () => {
// expect(router.find('main')).toEqual(shallow(
// <main><ListView /></main>,
// ));
// });
// });
// test('Footer logo drawn from env variable', () => {
// expect(router.find(Footer).props().logo).toEqual(logo);
// });
// test('Header to use courseMetadata props', () => {
// const {
// courseTitle,
// courseNumber,
// courseOrg,
// } = router.find(Header).props();
// expect(courseTitle).toEqual(props.courseMetadata.title);
// expect(courseNumber).toEqual(props.courseMetadata.number);
// expect(courseOrg).toEqual(props.courseMetadata.org);
// });
// });
// });

View File

@@ -0,0 +1,27 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`app registry subscribe: APP_INIT_ERROR. snapshot: displays an ErrorPage to root element 1`] = `
<ErrorPage
message="test-error-message"
/>
`;
exports[`app registry subscribe: APP_READY. links App to root element 1`] = `
<IntlProvider
locale="en"
>
<AppProvider
store={
Object {
"dispatch": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
Symbol(Symbol.observable): [Function],
}
}
>
<App />
</AppProvider>
</IntlProvider>
`;

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

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

View File

@@ -0,0 +1,9 @@
import React from 'react';
import { Button, PageBanner, Alert } from '@edx/paragon';
import { Program } from '@edx/paragon/icons';
export const CourseCardFooter = () => (
<Alert variant='success'>
footer
</Alert>
);

View File

@@ -0,0 +1,22 @@
import React from 'react';
import { Dropdown, Icon, IconButton } from '@edx/paragon';
import { MoreVert } from '@edx/paragon/icons';
export const CourseCardMenu = () => (
<Dropdown>
<Dropdown.Toggle
id='dropdown-toggle-with-iconbutton'
as={IconButton}
src={MoreVert}
iconAs={Icon}
variant='primary'
alt='Actions dropdown'
/>
<Dropdown.Menu>
<Dropdown.Item href='#/action-1'>Unenroll</Dropdown.Item>
<Dropdown.Item href='#/action-2'>Email Settings</Dropdown.Item>
<Dropdown.Item href='#/action-3'>Share to Facebook</Dropdown.Item>
<Dropdown.Item href='#/action-3'>Share to Twitter</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
);

View File

@@ -0,0 +1,33 @@
import React from 'react';
import { Button, useToggle, ModalDialog } from '@edx/paragon';
import { Program } from '@edx/paragon/icons';
export const RelatedProgram = () => {
const [isOpen, open, close] = useToggle(false);
return (
<>
<Button variant='tertiary' size="sm" iconBefore={Program} onClick={open}>
2 Related Program
</Button>
<ModalDialog
title="My dialog"
isOpen={isOpen}
onClose={close}
hasCloseButton
isFullscreenOnMobile
>
<ModalDialog.Header>
<ModalDialog.Title>
Related Programs
</ModalDialog.Title>
</ModalDialog.Header>
<ModalDialog.Body>
<p>
I'm baby palo santo ugh celiac fashion axe. La croix lo-fi venmo whatever. Beard man braid migas single-origin coffee forage ramps. Tumeric messenger bag bicycle rights wayfarers, try-hard cronut blue bottle health goth. Sriracha tumblr cardigan, cloud bread succulents tumeric copper mug marfa semiotics woke next level organic roof party +1 try-hard.
</p>
</ModalDialog.Body>
</ModalDialog>
</>
);
};

View File

@@ -0,0 +1,60 @@
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 { Program, Locked, MoreVert } from '@edx/paragon/icons';
import { Button, Card } from '@edx/paragon';
import { RelatedProgram } from './RelatedProgram';
import { CourseCardMenu } from './CourseCardMenu';
import { CourseCardFooter } from './CourseCardFooter';
import { courseData } from 'data/services/lms/fakeData/courses';
function CourseCard({ courseID }) {
const {
title,
imageUrl,
displayNumber,
displayOrg,
accessExpiryDate,
} = courseData[courseID] || {};
console.log(courseID);
return (
<div>
<Card orientation='horizontal'>
<Card.ImageCap
src={imageUrl}
srcAlt='course thumbnail'
// logoSrc='https://via.placeholder.com/150'
// logoAlt='Card logo'
/>
<Card.Body>
<Card.Header
title={title}
actions={<CourseCardMenu />}
/>
<Card.Section>
{displayOrg} {displayNumber} Access expires {accessExpiryDate}
</Card.Section>
<Card.Footer orientation='vertical' textElement={<RelatedProgram />}>
<Button iconBefore={Locked} variant='outline-primary'>
Upgrade
</Button>
<Button>Resume</Button>
</Card.Footer>
</Card.Body>
</Card>
<CourseCardFooter />
</div>
);
}
CourseCard.propTypes = {
// intl: intlShape.isRequired,
};
CourseCard.defaultProps = {};
export default CourseCard;
// export default injectIntl(CourseCard);

View File

@@ -0,0 +1,13 @@
import React from 'react';
import CourseCard from 'containers/CourseCard';
export const CourseList = ({ courseIDs }) => (
<div className='d-flex flex-column flex-grow-1'>
{courseIDs.map((id) => (
<CourseCard courseID={id} />
))}
</div>
);
export default CourseList;

View File

@@ -0,0 +1,31 @@
import React from 'react';
import CourseList from 'containers/CourseList';
import WidgetSidebar from 'containers/WidgetSidebar';
import { courseIDs } from 'data/services/lms/fakeData/courses';
import EmptyCourse from '../EmptyCourse';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import messages from '../Dashboard/messages';
export const Dashboard = () => {
return (
<div className='d-flex flex-column p-2'>
{courseIDs.length ? (
<>
<h2 className='py-2'>
<FormattedMessage {...messages.myCourse} />
</h2>
<div className='d-flex'>
<CourseList courseIDs={courseIDs} />
<WidgetSidebar />
</div>
</>
) : (
<EmptyCourse />
)}
</div>
);
};
export default Dashboard;

View File

@@ -0,0 +1,11 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
myCourse: {
id: 'dashboard.mycourse',
defaultMessage: 'My Courses',
description: 'My Courses',
},
});
export default messages;

View File

@@ -0,0 +1,23 @@
import React from 'react';
import messages from './messages';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon'
import './index.scss';
export const EmptyCourse = () => (
<div className='empty-course'>
<img src='empty-course.svg' />
<h1>
<FormattedMessage {...messages.lookingForChallengePrompt} />
</h1>
<p>
<FormattedMessage {...messages.exploreCoursesPrompt} />
</p>
<Button variant="brand">
<FormattedMessage {...messages.exploreCoursesButton} />
</Button>
</div>
);
export default EmptyCourse;

View File

@@ -0,0 +1,10 @@
@import "@edx/paragon/scss/core/core";
.empty-course {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
padding: map-get($spacers, 3);
}

View File

@@ -0,0 +1,21 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
lookingForChallengePrompt: {
id: 'EmptyCourse.lookingForChallengePrompt',
defaultMessage: 'Looking for a new challenge?',
description: 'Prompt user for new challenge',
},
exploreCoursesPrompt: {
id: 'EmptyCourse.exploreCoursesPrompt',
defaultMessage: 'Explore our courses to add them to your dashboard.',
description: 'Prompt user to explore more courses',
},
exploreCoursesButton: {
id: 'EmptyCourse.exploreCoursesButton',
defaultMessage: 'Explore courses',
description: 'Button to explore more courses',
},
});
export default messages;

View File

@@ -0,0 +1,53 @@
import React from 'react';
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Dropdown, Icon } from '@edx/paragon';
import { Person } from '@edx/paragon/icons';
import messages from './messages';
function AuthenticatedUserDropdown({ intl, username }) {
return (
<>
<Dropdown className='user-dropdown'>
<Dropdown.Toggle invertColors variant='primary'>
<Icon src={Person} />
<span data-hj-suppress className='d-none d-md-inline'>
{username}
</span>
</Dropdown.Toggle>
<Dropdown.Menu className='dropdown-menu-right'>
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/dashboard`}>
{intl.formatMessage(messages.dashboard)}
</Dropdown.Item>{' '}
<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>
{getConfig().ORDER_HISTORY_URL && (
<Dropdown.Item href={getConfig().ORDER_HISTORY_URL}>
{intl.formatMessage(messages.orderHistory)}
</Dropdown.Item>
)}
<Dropdown.Item href={getConfig().SUPPORT_URL}>
{intl.formatMessage(messages.help)}
</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,
};
export default injectIntl(AuthenticatedUserDropdown);

View File

@@ -0,0 +1,33 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import messages from './messages';
export const GreetingBanner = () => {
let greetMessage;
let hour = new Date().getHours();
if ( hour > 16 ) {
greetMessage = messages.goodEvening;
}
else if ( hour > 11 ) {
greetMessage = messages.goodAfternoon;
}
else {
greetMessage = messages.goodMorning;
}
return (
<div className='d-flex p-5 align-items-center justify-content-center'>
<img
className='d-block'
src={getConfig().LOGO_URL}
alt={getConfig().SITE_NAME}
/>
<h1 className='text-center text-white'>
<FormattedMessage {...greetMessage} />
</h1>
</div>
);
};

View File

@@ -0,0 +1,36 @@
import React, { useContext } from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { AppContext } from '@edx/frontend-platform/react';
import { Program } from '@edx/paragon/icons';
import { Button } from '@edx/paragon';
import AuthenticatedUserDropdown from './AuthenticatedUserDropdown';
import { GreetingBanner } from './GreetingBanner';
import messages from './messages';
function LearnerDashboardHeader({ intl }) {
const { authenticatedUser } = useContext(AppContext);
return (
<div className='d-flex flex-column bg-primary'>
<header className='learner-dashboard-header'>
<div className='d-flex'>
<Button variant="inverse-tertiary" iconBefore={Program}>{intl.formatMessage(messages.switchToProgram)}</Button>
<div className='flex-grow-1'></div>
{authenticatedUser && (
<AuthenticatedUserDropdown username={authenticatedUser.username} />
)}
</div>
</header>
<GreetingBanner />
</div>
);
}
LearnerDashboardHeader.propTypes = {
intl: intlShape.isRequired,
};
LearnerDashboardHeader.defaultProps = {
};
export default injectIntl(LearnerDashboardHeader);

View File

@@ -0,0 +1,57 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
dashboard: {
id: 'leanerDashboard.menu.dashboard.label',
defaultMessage: 'Dashboard',
description: 'The text for the user menu Dashboard navigation link.',
},
help: {
id: 'leanerDashboard.help.label',
defaultMessage: 'Help',
description: 'The text for the link to the Help Center',
},
profile: {
id: 'leanerDashboard.menu.profile.label',
defaultMessage: 'Profile',
description: 'The text for the user menu Profile navigation link.',
},
account: {
id: 'leanerDashboard.menu.account.label',
defaultMessage: 'Account',
description: 'The text for the user menu Account navigation link.',
},
orderHistory: {
id: 'leanerDashboard.menu.orderHistory.label',
defaultMessage: 'Order History',
description: 'The text for the user menu Order History navigation link.',
},
signOut: {
id: 'leanerDashboard.menu.signOut.label',
defaultMessage: 'Sign Out',
description: 'The label for the user menu Sign Out action.',
},
goodMorning: {
id: 'greeting.morning',
defaultMessage: 'Good Morning',
description: 'Good Morning',
},
goodAfternoon: {
id: 'greeting.afternoon',
defaultMessage: 'Good Afternoon',
description: 'Good Afternoon',
},
goodEvening: {
id: 'greeting.evening',
defaultMessage: 'Good Evening',
description: 'Good Evening',
},
switchToProgram: {
id: 'leanerDashboard.switchToProgram',
defaultMessage: 'Switch to Programs',
description: 'Header link for switching to program page.'
}
});
export default messages;

View File

@@ -0,0 +1,38 @@
import React from 'react';
import messages from './messages';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Hyperlink, Card } from '@edx/paragon';
import './index.scss';
export const WidgetSidebar = () => (
<div className='widget-sidebar'>
<div className='d-flex'>
{/* <img src='more-courses-sidewidget.svg' />
<div>
<h3>
<FormattedMessage {...messages.lookingForChallengePrompt} />
</h3>
<Hyperlink variant='brand' destination='#'>
<FormattedMessage {...messages.findCoursesButton} />
</Hyperlink>
</div> */}
<Card orientation='horizontal' className='mb-4'>
<Card.ImageCap
src='more-courses-sidewidget.svg'
srcAlt='course side widget'
/>
<Card.Body>
<h3>
<FormattedMessage {...messages.lookingForChallengePrompt} />
</h3>
<Hyperlink variant='brand' destination='#'>
<FormattedMessage {...messages.findCoursesButton} />
</Hyperlink>
</Card.Body>
</Card>
</div>
</div>
);
export default WidgetSidebar;

View File

@@ -0,0 +1,7 @@
@import "@edx/paragon/scss/core/core";
.widget-sidebar {
width: 400px;
padding-left: map-get($spacers, 2);
padding-right: map-get($spacers, 2);
}

View File

@@ -0,0 +1,16 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
lookingForChallengePrompt: {
id: 'WidgetSidebar.lookingForChallengePrompt',
defaultMessage: 'Looking for a new challenge?',
description: 'Prompt user for new challenge',
},
findCoursesButton: {
id: 'WidgetSidebar.findCoursesButton',
defaultMessage: 'Explore courses',
description: 'Button to explore more courses',
},
});
export default messages;

View File

@@ -0,0 +1,4 @@
import { getConfig } from '@edx/frontend-platform';
export const routePath = `${getConfig().PUBLIC_PATH}:courseId`;
export const locationId = window.location.pathname.slice(1);

View File

@@ -0,0 +1,24 @@
import * as platform from '@edx/frontend-platform';
import * as constants from './app';
jest.unmock('./app');
jest.mock('@edx/frontend-platform', () => {
const PUBLIC_PATH = 'test-public-path';
return {
getConfig: () => ({ PUBLIC_PATH }),
PUBLIC_PATH,
};
});
describe('app constants', () => {
test('route path draws from public path and adds courseId', () => {
expect(constants.routePath).toEqual(`${platform.PUBLIC_PATH}:courseId`);
});
test('locationId returns trimmed pathname', () => {
const old = window.location;
window.location = { pathName: '/somePath.jpg' };
expect(constants.locationId).toEqual(window.location.pathname.slice(1));
window.location = old;
});
});

View File

@@ -0,0 +1,20 @@
import { StrictDict } from 'utils';
export const FileTypes = StrictDict({
pdf: 'pdf',
jpg: 'jpg',
jpeg: 'jpeg',
png: 'png',
bmp: 'bmp',
txt: 'txt',
gif: 'gif',
jfif: 'jfif',
pjpeg: 'pjpeg',
pjp: 'pjp',
svg: 'svg',
});
export const downloadSingleLimit = 1610612736; // 1.5GB
export const downloadAllLimit = 10737418240; // 10GB
export default FileTypes;

View File

@@ -0,0 +1,33 @@
import { StrictDict } from 'utils';
export const RequestStates = StrictDict({
inactive: 'inactive',
pending: 'pending',
completed: 'completed',
failed: 'failed',
});
export const RequestKeys = StrictDict({
batchUnlock: 'batchUnlock',
downloadFiles: 'downloadFiles',
fetchSubmission: 'fetchSubmission',
fetchSubmissionStatus: 'fetchSubmissionStatus',
initialize: 'initialize',
prefetchNext: 'prefetchNext',
prefetchPrev: 'prefetchPrev',
setLock: 'setLock',
submitGrade: 'submitGrade',
});
export const ErrorCodes = StrictDict({
missingParam: 'ERR_MISSING_PARAM',
});
export const ErrorStatuses = StrictDict({
badRequest: 400,
unauthorized: 401,
forbidden: 403,
notFound: 404,
conflict: 409,
serverError: 500,
});

View File

@@ -0,0 +1,2 @@
export { actions, reducer } from './reducer';
export { default as selectors } from './selectors';

View File

@@ -0,0 +1,30 @@
import { StrictDict } from 'utils';
import { createSlice } from '@reduxjs/toolkit';
const initialState = {
courseMetadata: {
name: '',
number: '',
org: '',
courseId: '',
},
};
// eslint-disable-next-line no-unused-vars
const app = createSlice({
name: 'app',
initialState,
reducers: {
loadCourseMetadata: (state, { payload }) => ({ ...state, courseMetadata: payload }),
},
});
const actions = StrictDict(app.actions);
const { reducer } = app;
export {
actions,
initialState,
reducer,
};

View File

@@ -0,0 +1,73 @@
import { initialState, reducer, actions } from './reducer';
describe('app reducer', () => {
describe('initialState', () => {
test('populated, but empty course metadata', () => {
const data = initialState.courseMetadata;
expect(data.name).toEqual('');
expect(data.number).toEqual('');
expect(data.org).toEqual('');
expect(data.courseId).toEqual('');
});
test('disabled (waffle flag)', () => {
expect(initialState.isEnabled).toEqual(false);
});
test('not grading', () => {
expect(initialState.isGrading).toEqual(false);
});
test('populated, but empty ora metadata', () => {
const data = initialState.oraMetadata;
expect(data.prompt).toEqual('');
expect(data.name).toEqual('');
expect(data.type).toEqual('');
expect(data.rubricConfig).toEqual(null);
});
test('not showing review', () => {
expect(initialState.showReview).toEqual(false);
});
test('not showing rubric', () => {
expect(initialState.showRubric).toEqual(false);
});
});
describe('reducers', () => {
it('returns initial state', () => {
expect(reducer(undefined, {})).toEqual(initialState);
});
const testState = {
...initialState,
showRubric: true,
showReview: true,
arbitrary: 'state',
};
const testValue = 'my-test-value';
const testAction = (action, expected) => {
expect(reducer(testState, action)).toEqual({
...testState,
...expected,
});
};
describe('action handlers', () => {
test('loadIsEnabled loads isEnabled from payload', () => {
testAction(actions.loadIsEnabled(testValue), { isEnabled: testValue });
});
test('loadCourseMetadata loads courseMetadata from payload', () => {
testAction(actions.loadCourseMetadata(testValue), { courseMetadata: testValue });
});
test('loadOraMetadata loads oraMetadata from payload', () => {
testAction(actions.loadOraMetadata(testValue), { oraMetadata: testValue });
});
describe('setShowReview', () => {
it('loads showReview, sets showRubric to false if set to false', () => {
testAction(actions.setShowReview(true), { showReview: true });
testAction(actions.setShowReview(false), { showReview: false, showRubric: false });
});
});
test('setShowRubric loads showRubric from payload', () => {
testAction(actions.setShowRubric(testValue), { showRubric: testValue });
});
test('toggleShowRubric toggles showRubric value', () => {
testAction(actions.toggleShowRubric(), { showRubric: !testState.showRubric });
});
});
});
});

View File

@@ -0,0 +1,18 @@
import { createSelector } from 'reselect';
import { StrictDict } from 'utils';
import * as module from './selectors';
export const appSelector = (state) => state.app;
const mkSimpleSelector = (cb) => createSelector([module.appSelector], cb);
// top-level app data selectors
export const simpleSelectors = {
courseMetadata: mkSimpleSelector(app => app.courseMetadata),
};
export default StrictDict({
...simpleSelectors,
});

28
src/data/redux/index.js Normal file
View File

@@ -0,0 +1,28 @@
import { combineReducers } from 'redux';
import { StrictDict } from 'utils';
import * as app from './app';
import * as requests from './requests';
export { default as thunkActions } from './thunkActions';
const modules = {
app,
requests,
};
const moduleProps = (propName) => Object.keys(modules).reduce(
(obj, moduleKey) => ({ ...obj, [moduleKey]: modules[moduleKey][propName] }),
{},
);
const rootReducer = combineReducers(moduleProps('reducer'));
const actions = StrictDict(moduleProps('actions'));
const selectors = StrictDict(moduleProps('selectors'));
export { actions, selectors };
export default rootReducer;

View File

@@ -0,0 +1,2 @@
export { actions, reducer } from './reducer';
export { default as selectors } from './selectors';

View File

@@ -0,0 +1,50 @@
import { createSlice } from '@reduxjs/toolkit';
import { StrictDict } from 'utils';
import { RequestStates, RequestKeys } from 'data/constants/requests';
const initialState = {
[RequestKeys.initialize]: { status: RequestStates.inactive },
};
// eslint-disable-next-line no-unused-vars
const requests = createSlice({
name: 'requests',
initialState,
reducers: {
startRequest: (state, { payload }) => ({
...state,
[payload]: {
status: RequestStates.pending,
},
}),
completeRequest: (state, { payload }) => ({
...state,
[payload.requestKey]: {
status: RequestStates.completed,
response: payload.response,
},
}),
failRequest: (state, { payload }) => ({
...state,
[payload.requestKey]: {
status: RequestStates.failed,
error: payload.error,
},
}),
clearRequest: (state, { payload }) => ({
...state,
[payload.requestKey]: {},
}),
},
});
const actions = StrictDict(requests.actions);
const { reducer } = requests;
export {
actions,
reducer,
initialState,
};

View File

@@ -0,0 +1,51 @@
import { RequestStates } from 'data/constants/requests';
import { initialState, actions, reducer } from './reducer';
const testingState = {
...initialState,
arbitraryField: 'arbitrary',
};
describe('requests reducer', () => {
it('has initial state', () => {
expect(reducer(undefined, {})).toEqual(initialState);
});
const testValue = 'roll for initiative';
const testKey = 'test-key';
describe('handling actions', () => {
describe('startRequest', () => {
it('adds a pending status for the given key', () => {
expect(reducer(
testingState,
actions.startRequest(testKey),
)).toEqual({
...testingState,
[testKey]: { status: RequestStates.pending },
});
});
});
describe('completeRequest', () => {
it('adds a completed status with passed response', () => {
expect(reducer(
testingState,
actions.completeRequest({ requestKey: testKey, response: testValue }),
)).toEqual({
...testingState,
[testKey]: { status: RequestStates.completed, response: testValue },
});
});
});
describe('failRequest', () => {
it('adds a failed status with passed error', () => {
expect(reducer(
testingState,
actions.failRequest({ requestKey: testKey, error: testValue }),
)).toEqual({
...testingState,
[testKey]: { status: RequestStates.failed, error: testValue },
});
});
});
});
});

View File

@@ -0,0 +1,29 @@
import { StrictDict } from 'utils';
import { RequestStates } from 'data/constants/requests';
import * as module from './selectors';
export const requestStatus = (state, { requestKey }) => state.requests[requestKey];
const statusSelector = (fn) => (state, { requestKey }) => fn(state.requests[requestKey]);
export const isInactive = ({ status }) => status === RequestStates.inactive;
export const isPending = ({ status }) => status === RequestStates.pending;
export const isCompleted = ({ status }) => status === RequestStates.completed;
export const isFailed = ({ status }) => status === RequestStates.failed;
export const error = (request) => request.error;
export const errorStatus = (request) => request.error?.response?.status;
export const errorCode = (request) => request.error?.response?.data;
export const data = (request) => request.data;
export default StrictDict({
requestStatus,
isInactive: statusSelector(isInactive),
isPending: statusSelector(isPending),
isCompleted: statusSelector(isCompleted),
isFailed: statusSelector(isFailed),
error: statusSelector(error),
errorCode: statusSelector(errorCode),
errorStatus: statusSelector(errorStatus),
data: statusSelector(data),
});

View File

@@ -0,0 +1,78 @@
import { RequestStates } from 'data/constants/requests';
import selectors from './selectors';
jest.mock('reselect', () => ({
createSelector: jest.fn((preSelectors, cb) => ({ preSelectors, cb })),
}));
const requestKey = 'my-test-request-key';
const requestData = { some: 'request-data' };
const inactiveRequest = { status: RequestStates.inactive, some: 'request-data' };
const pendingRequest = { status: RequestStates.pending, some: 'request-data' };
const completedRequest = { status: RequestStates.completed, some: 'request-data' };
const failedRequest = { status: RequestStates.failed, some: 'request-data' };
const testValue = 'my-test-value';
const testState = {
requests: {
[requestKey]: requestData,
},
};
const genRequests = (request) => ({
requests: { [requestKey]: request },
});
const select = (selector, request) => (
selector(genRequests(request), { requestKey })
);
describe('requests selectors unit tests', () => {
test('requestStatus returns data associated with given key', () => {
expect(selectors.requestStatus(testState, { requestKey })).toEqual(requestData);
});
describe('allowNavigation', () => {
it('returns false if any requests are pending', () => {
expect(selectors.allowNavigation(testState)).toEqual(true);
expect(selectors.allowNavigation({ requests: { key1: pendingRequest } })).toEqual(false);
});
});
const testStatusSelector = (selector, matchingRequest) => {
expect(selector(testState, { requestKey })).toEqual(false);
expect(selector(
{ requests: { [requestKey]: matchingRequest } },
{ requestKey },
)).toEqual(true);
};
test('isInactive returns true iff the given request is inactive', () => {
testStatusSelector(selectors.isInactive, inactiveRequest);
});
test('isPending returns true iff the given request is pending', () => {
testStatusSelector(selectors.isPending, pendingRequest);
});
test('isCompleted returns true iff the given request is completed', () => {
testStatusSelector(selectors.isCompleted, completedRequest);
});
test('isFailed returns true iff the given request is failed', () => {
testStatusSelector(selectors.isFailed, failedRequest);
});
test('error returns the error from the request', () => {
expect(select(selectors.error, { error: testValue })).toEqual(testValue);
});
test('errorStatus returns the error response status', () => {
expect(select(selectors.errorStatus, {})).toEqual(undefined);
expect(select(selectors.errorStatus, { error: {} })).toEqual(undefined);
expect(select(selectors.errorStatus, { error: { response: {} } })).toEqual(undefined);
expect(select(selectors.errorStatus, { error: { response: { status: testValue } } }))
.toEqual(testValue);
});
test('errorCode returns the error response data', () => {
expect(select(selectors.errorCode, {})).toEqual(undefined);
expect(select(selectors.errorCode, { error: {} })).toEqual(undefined);
expect(select(selectors.errorCode, { error: { response: {} } })).toEqual(undefined);
expect(select(selectors.errorCode, { error: { response: { data: testValue } } }))
.toEqual(testValue);
});
test('data reurns the request data', () => {
expect(select(selectors.data, { data: testValue })).toEqual(testValue);
});
});

View File

@@ -0,0 +1,18 @@
import { StrictDict } from 'utils';
import { selectors, actions } from 'data/redux';
import { locationId } from 'data/constants/app';
// import { } from './requests';
import * as module from './app';
/**
* initialize the app, loading ora and course metadata from the api, and loading the initial
* submission list data.
*/
export const initialize = () => (dispatch) => {
};
export default StrictDict({
initialize,
});

View File

@@ -0,0 +1,76 @@
import { locationId } from 'data/constants/app';
import { selectors, actions } from 'data/redux';
import { keyStore } from 'utils';
import * as thunkActions from './app';
jest.mock('./requests', () => ({
initializeApp: (args) => ({ initializeApp: args }),
batchUnlock: (args) => ({ batchUnlock: args }),
}));
const dispatch = jest.fn((action) => ({ dispatch: action }));
const testState = { my: 'test state' };
const getState = () => testState;
const moduleKeys = keyStore(thunkActions);
describe('app thunkActions', () => {
let dispatchedAction;
beforeEach(() => {
jest.clearAllMocks();
});
describe('initialize', () => {
beforeEach(() => {
thunkActions.initialize()(dispatch);
[[dispatchedAction]] = dispatch.mock.calls;
});
it('dispatches initializeApp with locationId and onSuccess', () => {
expect(dispatchedAction.initializeApp.locationId).toEqual(locationId);
expect(typeof dispatchedAction.initializeApp.onSuccess).toEqual('function');
});
describe('on success', () => {
test('loads isEnabled, oraMetadata, courseMetadata and list data', () => {
const response = {
courseMetadata: { some: 'course-metadata' },
isEnabled: { is: 'enabled?' },
oraMetadata: { some: 'ora-metadata' },
submissions: { some: 'submissions' },
};
dispatch.mockClear();
dispatchedAction.initializeApp.onSuccess(response);
expect(dispatch.mock.calls).toEqual([
[actions.app.loadIsEnabled(response.isEnabled)],
[actions.app.loadOraMetadata(response.oraMetadata)],
[actions.app.loadCourseMetadata(response.courseMetadata)],
[actions.submissions.loadList(response.submissions)],
]);
});
});
});
describe('cancelReview', () => {
const gradingSelection = (args) => ({ gradingSelection: args });
const mockInitialize = (args) => ({ initialize: args });
const gradingKeys = keyStore(selectors.grading);
beforeEach(() => {
jest.spyOn(thunkActions, moduleKeys.initialize)
.mockImplementationOnce(mockInitialize);
jest.spyOn(selectors.grading, gradingKeys.selection)
.mockImplementationOnce(gradingSelection);
thunkActions.cancelReview()(dispatch, getState);
[[dispatchedAction]] = dispatch.mock.calls;
});
it('dispatches batchUnlock with submissionUUIDs and onSuccess', () => {
expect(dispatchedAction.batchUnlock.submissionUUIDs)
.toEqual(gradingSelection(testState));
expect(typeof dispatchedAction.batchUnlock.onSuccess).toEqual('function');
});
it('clears show review state and calls dispatches initialize thunkAction on success', () => {
dispatch.mockClear();
dispatchedAction.batchUnlock.onSuccess();
expect(dispatch.mock.calls).toEqual([
[actions.app.setShowReview(false)],
[mockInitialize()],
]);
});
});
});

View File

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

View File

@@ -0,0 +1,37 @@
import { StrictDict } from 'utils';
import { RequestKeys } from 'data/constants/requests';
import { actions } from 'data/redux';
import api from 'data/services/lms/api';
import * as module from './requests';
/**
* Wrapper around a network request promise, that sends actions to the redux store to
* track the state of that promise.
* Tracks the promise by requestKey, and sends an action when it is started, succeeds, or
* fails. It also accepts onSuccess and onFailure methods to be called with the output
* of failure or success of the promise.
* @param {string} requestKey - request tracking identifier
* @param {Promise} promise - api event promise
* @param {[func]} onSuccess - onSuccess method ((response) => { ... })
* @param {[func]} onFailure - onFailure method ((error) => { ... })
*/
export const networkRequest = ({
requestKey,
promise,
onSuccess,
onFailure,
}) => (dispatch) => {
dispatch(actions.requests.startRequest(requestKey));
return promise.then((response) => {
if (onSuccess) { onSuccess(response); }
dispatch(actions.requests.completeRequest({ requestKey, response }));
}).catch((error) => {
if (onFailure) { onFailure(error); }
dispatch(actions.requests.failRequest({ requestKey, error }));
});
};
export default StrictDict({
});

View File

@@ -0,0 +1,208 @@
import { actions } from 'data/redux';
import { RequestKeys } from 'data/constants/requests';
import api from 'data/services/lms/api';
import * as requests from './requests';
jest.mock('data/services/lms/api', () => ({
batchUnlockSubmissions: (submissionUUIDs) => ({ batchUnlockSubmissions: submissionUUIDs }),
initializeApp: (locationId) => ({ initializeApp: locationId }),
fetchSubmissionStatus: (submissionUUID) => ({ fetchSubmissionStatus: submissionUUID }),
fetchSubmission: (submissionUUID) => ({ fetchSubmission: submissionUUID }),
lockSubmission: ({ submissionUUID }) => ({ lockSubmission: { submissionUUID } }),
unlockSubmission: ({ submissionUUID }) => ({ unlockSubmission: { submissionUUID } }),
updateGrade: (submissionUUID, gradeData) => ({ updateGrade: { submissionUUID, gradeData } }),
}));
const dispatch = jest.fn();
const onSuccess = jest.fn();
const onFailure = jest.fn();
describe('requests thunkActions module', () => {
beforeEach(jest.clearAllMocks);
describe('networkRequest', () => {
const requestKey = 'test-request';
const testData = { some: 'test data' };
let resolveFn;
let rejectFn;
describe('with both handlers', () => {
beforeEach(() => {
requests.networkRequest({
requestKey,
promise: new Promise((resolve, reject) => {
resolveFn = resolve;
rejectFn = reject;
}),
onSuccess,
onFailure,
})(dispatch);
});
test('calls startRequest action with requestKey', async () => {
expect(dispatch.mock.calls).toEqual([[actions.requests.startRequest(requestKey)]]);
});
describe('on success', () => {
beforeEach(async () => {
await resolveFn(testData);
});
it('dispatches completeRequest', async () => {
expect(dispatch.mock.calls).toEqual([
[actions.requests.startRequest(requestKey)],
[actions.requests.completeRequest({ requestKey, response: testData })],
]);
});
it('calls onSuccess with response', async () => {
expect(onSuccess).toHaveBeenCalledWith(testData);
expect(onFailure).not.toHaveBeenCalled();
});
});
describe('on failure', () => {
beforeEach(async () => {
await rejectFn(testData);
});
test('dispatches completeRequest', async () => {
expect(dispatch.mock.calls).toEqual([
[actions.requests.startRequest(requestKey)],
[actions.requests.failRequest({ requestKey, error: testData })],
]);
});
test('calls onSuccess with response', async () => {
expect(onFailure).toHaveBeenCalledWith(testData);
expect(onSuccess).not.toHaveBeenCalled();
});
});
});
describe('without onSuccess and onFailure', () => {
test('calls startRequest action with requestKey', async () => {
requests.networkRequest({ requestKey, promise: Promise.resolve(testData) })(dispatch);
expect(dispatch.mock.calls).toEqual([[actions.requests.startRequest(requestKey)]]);
});
it('on success dispatches completeRequest', async () => {
await requests.networkRequest({ requestKey, promise: Promise.resolve(testData) })(dispatch);
expect(dispatch.mock.calls).toEqual([
[actions.requests.startRequest(requestKey)],
[actions.requests.completeRequest({ requestKey, response: testData })],
]);
});
it('on failure disaptches completeRequest', async () => {
await requests.networkRequest({ requestKey, promise: Promise.reject(testData) })(dispatch);
expect(dispatch.mock.calls).toEqual([
[actions.requests.startRequest(requestKey)],
[actions.requests.failRequest({ requestKey, error: testData })],
]);
});
});
});
const testNetworkRequestAction = ({
action,
args,
expectedData,
expectedString,
}) => {
let dispatchedAction;
beforeEach(() => {
action({ ...args, onSuccess, onFailure })(dispatch);
[[dispatchedAction]] = dispatch.mock.calls;
});
it('dispatches networkRequest', () => {
expect(dispatchedAction.networkRequest).not.toEqual(undefined);
});
test('forwards onSuccess and onFailure', () => {
expect(dispatchedAction.networkRequest.onSuccess).toEqual(onSuccess);
expect(dispatchedAction.networkRequest.onFailure).toEqual(onFailure);
});
test(expectedString, () => {
expect(dispatchedAction.networkRequest).toEqual({
...expectedData,
onSuccess,
onFailure,
});
});
};
describe('network request actions', () => {
const submissionUUID = 'test-submission-id';
const locationId = 'test-location-id';
beforeEach(() => {
requests.networkRequest = jest.fn(args => ({ networkRequest: args }));
});
describe('initializeApp', () => {
testNetworkRequestAction({
action: requests.initializeApp,
args: { locationId },
expectedString: 'with initialize key, initializeApp promise',
expectedData: {
requestKey: RequestKeys.initialize,
promise: api.initializeApp(locationId),
},
});
});
describe('fetchSubmissionStatus', () => {
testNetworkRequestAction({
action: requests.fetchSubmissionStatus,
args: { submissionUUID },
expectedString: 'with fetchSubmissionStatus promise',
expectedData: {
requestKey: RequestKeys.fetchSubmissionStatus,
promise: api.fetchSubmissionStatus(submissionUUID),
},
});
});
describe('fetchSubmission', () => {
testNetworkRequestAction({
action: requests.fetchSubmission,
args: { submissionUUID },
expectedString: 'with fetchSubmission promise',
expectedData: {
requestKey: RequestKeys.fetchSubmission,
promise: api.fetchSubmission(submissionUUID),
},
});
});
describe('setLock: true', () => {
testNetworkRequestAction({
action: requests.setLock,
args: { submissionUUID, value: true },
expectedString: 'with setLock promise',
expectedData: {
requestKey: RequestKeys.setLock,
promise: api.lockSubmission(submissionUUID),
},
});
});
describe('setLock: false', () => {
testNetworkRequestAction({
action: requests.setLock,
args: { submissionUUID, value: false },
expectedString: 'with setLock promise',
expectedData: {
requestKey: RequestKeys.setLock,
promise: api.unlockSubmission(submissionUUID),
},
});
});
describe('batchUnlock', () => {
const submissionUUIDs = [1, 2, 3, 4, 5];
testNetworkRequestAction({
action: requests.batchUnlock,
args: { submissionUUIDs, value: false },
expectedString: 'with batchUnlock promise',
expectedData: {
requestKey: RequestKeys.batchUnlock,
promise: api.batchUnlockSubmissions(submissionUUIDs),
value: false,
},
});
});
describe('submitGrade', () => {
const gradeData = 'test-grade-data';
testNetworkRequestAction({
action: requests.submitGrade,
args: { submissionUUID, gradeData },
expectedString: 'with submitGrade promise',
expectedData: {
requestKey: RequestKeys.submitGrade,
promise: api.updateGrade(submissionUUID, gradeData),
},
});
});
});
});

View File

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

View File

@@ -0,0 +1,25 @@
// import { StrictDict } from 'utils';
import { locationId } from 'data/constants/app';
// import { paramKeys } from './constants';
import urls from './urls';
import {
// client,
get,
// post,
stringifyUrl,
} from './utils';
/*********************************************************************************
* GET Actions
*********************************************************************************/
/**
* get('/api/initialize', { ora_location, course_id? })
* @return {
* oraMetadata: { name, prompt, type ('individual' vs 'team'), rubricConfig, fileUploadResponseConfig },
* courseMetadata: { courseOrg, courseName, courseNumber, courseId },
* }
*/
// const initializeApp = () => get().then(response => response.data);
// export default { initializeApp };

View File

@@ -0,0 +1,26 @@
export const courseIDs = [
'course-id1',
'course-id2',
];
export const courseData = {
[courseIDs[0]]: {
org: 'edx',
title: 'Anatomy: Musculoskeletal stuff for courseware',
displayNumber: 'ANATOMY403.1x',
displayOrg: 'edx',
accessExpiryDate: '11/11/2021',
imageUrl: 'https://edx-cdn.org/v3/prod/logo.svg',
// startDate: '11/11/2021',
// endDate: '11/11/2023',
},
[courseIDs[1]]: {
org: 'edx',
title: 'Time Travel 101',
displayNumber: 'TTRAVEL1.4x',
displayOrg: 'edx',
accessExpiryDate: '11/11/2021',
imageUrl: 'https://edx-cdn.org/v3/prod/logo.svg',
// startDate: '11/11/2021',
// endDate: '11/11/2023',
},
};

View File

@@ -0,0 +1,42 @@
import { StrictDict } from 'utils';
import { ErrorStatuses, RequestKeys } from 'data/constants/requests';
import { actions } from 'data/redux';
export const errorData = (status, data = '') => ({
response: {
status,
data,
},
});
export const networkErrorData = errorData(ErrorStatuses.badRequest);
export const genTestUtils = ({ dispatch }) => {
const mockStart = (requestKey) => () => {
dispatch(actions.requests.startRequest(requestKey));
};
const mockError = (requestKey, status, data) => () => {
dispatch(actions.requests.failRequest({
requestKey,
error: errorData(status, data),
}));
};
const mockNetworkError = (requestKey) => (
mockError(requestKey, ErrorStatuses.badRequest)
);
return {
init: StrictDict({
start: mockStart(RequestKeys.initialize),
networkError: mockNetworkError(RequestKeys.initialize),
}),
fetch: StrictDict({
start: mockStart(RequestKeys.fetchSubmission),
mockError: mockError(RequestKeys.fetchSubmission, ErrorStatuses.badRequest),
}),
};
};
export default genTestUtils;

View File

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

View File

@@ -0,0 +1,34 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
import { gradingStatuses } from './constants';
const messages = defineMessages({
ungraded: {
id: 'learner-dashboard.lms-api.gradingStatusDisplay.ungraded',
defaultMessage: 'Ungraded',
description: 'Grading status label for ungraded submission',
},
locked: {
id: 'learner-dashboard.lms-api.gradingStatusDisplay.locked',
defaultMessage: 'Currently being graded by someone else',
description: 'Grading status label for locked submission',
},
graded: {
id: 'learner-dashboard.lms-api.gradingStatusDisplay.graded',
defaultMessage: 'Grading Completed',
description: 'Grading status label for graded submission',
},
inProgress: {
id: 'learner-dashboard.lms-api.gradingStatusDisplay.inProgress',
defaultMessage: 'You are currently grading this response',
description: 'Grading status label for in-progress submission',
},
});
// re-keying the messages to ensure that the api can link to them even if the passed
// status keys change.
export default {
[gradingStatuses.ungraded]: messages.ungraded,
[gradingStatuses.locked]: messages.locked,
[gradingStatuses.graded]: messages.graded,
[gradingStatuses.inProgress]: messages.inProgress,
};

View File

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

View File

@@ -0,0 +1,29 @@
import queryString from 'query-string';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
/**
* 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);
export const client = getAuthenticatedHttpClient;
/**
* 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 },
);

View File

@@ -0,0 +1,39 @@
import queryString from 'query-string';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
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),
);
});
});
});

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

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

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

@@ -0,0 +1,66 @@
import { applyMiddleware } from 'redux';
import thunkMiddleware from 'redux-thunk';
import { composeWithDevTools } from 'redux-devtools-extension/logOnlyInProduction';
import { createLogger } from 'redux-logger';
import rootReducer, { actions, selectors } from 'data/redux';
import exportedStore, { createStore } from './store';
jest.mock('data/redux', () => ({
__esModule: true,
default: 'REDUCER',
actions: 'ACTIONS',
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(rootReducer);
});
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);
});
});
});

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

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

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

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

5
src/hooks.js Normal file
View File

@@ -0,0 +1,5 @@
export const nullMethod = () => ({});
export default {
nullMethod,
};

11
src/hooks.test.jsx Normal file
View File

@@ -0,0 +1,11 @@
import * as hooks from './hooks';
jest.unmock('./hooks');
describe('app-level hooks', () => {
describe('nullMethod', () => {
it('returns an empty object', () => {
expect(hooks.nullMethod()).toEqual({});
});
});
});

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

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

101
src/i18n/messages/ar.json Normal file
View File

@@ -0,0 +1,101 @@
{
"ora-grading.demoAlert.warningMessage": "Grade submission is disabled in the Demo mode of the new ORA Staff Grader.",
"ora-grading.demoAlert.confirm": "Confirm",
"ora-grading.demoAlert.title": "Demo submit prevented",
"ora-grading.FilePopoverContent.filePopoverNameTitle": "File Name",
"ora-grading.FilePopoverCellContent.filePopoverDescriptionTitle": "File Description",
"ora-grading.FilePopoverCellContent.fileSizeTitle": "File Size",
"ora-grading.InfoPopover.fileInfo": "File info",
"ora-grading.ResponseDisplay.FileRenderer.retryButton": "Retry",
"ora-grading.ResponseDisplay.FileRenderer.fileNotFound": "File not found",
"ora-grading.ResponseDisplay.FileRenderer.unknownError": "Unknown errors",
"ora-grading.InfoPopover.alt-text": "Display more info",
"ora-grading.CriterionFeedback.addCommentsLabel": "Add comments",
"ora-grading.CriterionFeedback.commentsLabel": "Comments",
"ora-grading.CriterionFeedback.optional": "(Optional)",
"ora-grading.RadioCriterion.optionPoints": "{points} points",
"ora-grading.RadioCriterion.rubricSelectedError": "Rubric selection is required",
"ora-grading.CriterionFeedback.criterionFeedbackError": "The feedback is required",
"ora-grading.ReviewModal.demoHeading": "Demo Mode",
"ora-grading.ReviewModal.demoMessage": "You are using the Demo Mode of the new Enhanced ORA Staff Grader interface. You will be unable to submit grades until you activate the feature.",
"ora-grading.ListView.ListViewBreadcrumbs.backToResponses": "Back to all open responses",
"ora-grading.ListView.noResultsFoundTitle": "Nothing here yet",
"ora-grading.ListView.noResultsFoundBody": "When learners submit responses, they will appear here",
"ora-grading.ListView.viewAllResponses": "View all responses",
"ora-grading.ListView.viewSelectedResponses": "View selected responses ({value})",
"ora-grading.ListView.tableHeaders.username": "Username",
"ora-grading.ListView.tableHeaders.teamName": "Team name",
"ora-grading.ListView.tableHeaders.learnerSubmissionDate": "Learner submission date",
"ora-grading.ListView.tableHeaders.teamSubmissionDate": "Team submission date",
"ora-grading.ListView.tableHeaders.grade": "Grade",
"ora-grading.ListView.tableHeaders.gradingStatus": "Grading status",
"ora-grading.ListView.loadErrorHeading": "Error loading submissions",
"ora-grading.ListView.loadErrorMessage1": "An error occurred while loading the submissions for this response. Try reloading the page or going {backToResponses}.",
"ora-grading.ListView.backToResponsesLowercase": "back to all Open Responses",
"ora-grading.ListView.reloadSubmissions": "Reload submissions",
"ora-grading.ListView.loadingResponses": "Loading responses",
"ora-grading.ResponseDisplay.FilePopoverCell.filePopoverNameTitle": "File Name",
"ora-grading.ResponseDisplay.FilePopoverCell.filePopoverDescriptionTitle": "File Description",
"ora-grading.ResponseDisplay.SubmissionFiles.tableNameHeader": "Name",
"ora-grading.ResponseDisplay.SubmissionFiles.tableExtensionHeader": "File Extension",
"ora-grading.ResponseDisplay.SubmissionFiles.tablePopoverHeader": "File Metadata",
"ora-grading.ResponseDisplay.SubmissionFiles.downloadFiles": "Download files",
"ora-grading.ResponseDisplay.SubmissionFiles.downloading": "Downloading",
"ora-grading.ResponseDisplay.SubmissionFiles.downloaded": "Downloaded!",
"ora-grading.ResponseDisplay.SubmissionFiles.retryDownload": "Retry download",
"ora-grading.ResponseDisplay.SubmissionFiles.submissionFile": "Submission Files",
"ora-grading.ResponseDisplay.SubmissionFiles.fileSizeExceed": "Exceeded the allow download size",
"ora-grading.ReviewActions.overrideConfirmTitle": "Are you sure you want to override this grade?",
"ora-grading.ReviewActions.overrideConfirmWarning": "This cannot be undone. The learner may have already received their grade.",
"ora-grading.ReviewActions.overrideConfirmContinue": "Continue grade override",
"ora-grading.ReviewActions.StartGradingButton.startGrading": "Start grading",
"ora-grading.ReviewActions.StartGradingButton.overrideGrade": "Override grade",
"ora-grading.ReviewActions.StartGradingButton.stopGrading": "Stop grading this response",
"ora-grading.ReviewActions.StopGradingConfirmModal.override.title": "Are you sure you want to stop grade override?",
"ora-grading.ReviewActions.StopGradingConfirmModal.title": "Are you sure you want to stop grading this response?",
"ora-grading.ReviewActions.StopGradingConfirmModal.warning": "Your progress will be lost.",
"ora-grading.ReviewActions.StopGradingConfirmModal.override.confirmText": "Stop grade override",
"ora-grading.ReviewActions.StopGradingConfirmModal.confirmText": "Cancel grading",
"ora-grading.ReviewActions.goBack": "Go back",
"ora-grading.ReviewActions.loadPrevious": "Load previous submission",
"ora-grading.ReviewActions.loadNext": "Load next submission",
"ora-grading.ReviewActions.navigationLabel": "{current} of {total}",
"ora-grading.ReviewActions.pointsDisplay": "Score: {pointsEarned}/{pointsPossible}",
"ora-grading.ReviewActions.hideRubric": "Hide Rubric",
"ora-grading.ReviewActions.showRubric": "Show Rubric",
"ora-grading.ReviewModal.closeReviewConfirm.title": "Are you sure you want to close this modal?",
"ora-grading.ReviewModal.closeReviewConfirmWarning": "This cannot be undone. This will discard unsaved work and stop this grading process.",
"ora-grading.ReviewModal.goBack": "Go back",
"ora-grading.ReviewModal.CloseReviewConfirmModal.confirmText": "Close Modal",
"ora-grading.ReviewModal.loadingResponse": "Loading response",
"ora-grading.ReviewModal.demoTitleMessage": "Grading Demo",
"ora-grading.ReviewModal.loadErrorHeading": "Error loading submissions",
"ora-grading.ReviewModal.loadErrorMessage1": "An error occurred while loading this submission. Try reloading this submission.",
"ora-grading.ReviewModal.reloadSubmission": "Reload submission",
"ora-grading.ReviewModal.gradeNotSubmitted.heading": "Grade not submitted",
"ora-grading.ReviewModal.gradeNotSubmitted.Content": "We're sorry, something went wrong when we tried to submit this grade. Please try again.",
"ora-grading.ReviewModal.resubmitGrade": "Resubmit grate",
"ora-grading.ReviewModal.dismiss": "Dismiss",
"ora-grading.ReviewModal.errorSubmittingGrade.Heading": "Error submitting grade",
"ora-grading.ReviewModal.errorSubmittingGrade.Content": "It looks like someone else got here first! Your grade submission has been rejected",
"ora-grading.ReviewModal.errorLockContestedHeading": "The lock owned by another user",
"ora-grading.ReviewModal.errorLockContested": "The lock owned by another user",
"ora-grading.ReviewModal.errorLockBadRequestHeading": "Invalid request. Please check your input.",
"ora-grading.ReviewModal.errorLockBadRequest": "Invalid request. Please check your input.",
"ora-grading.ReviewModal.errorDownloadFailed": "Couldn't download files",
"ora-grading.ReviewModal.errorDownloadFailedContent": "We're sorry, something went wrong when we tried to download these files. Please try again.",
"ora-grading.ReviewModal.errorRetryDownload": "Retry download",
"ora-grading.ReviewModal.errorDownloadFailedFiles": "Failed files:",
"ora-grading.Rubric.gradeSubmitted": "Grade Submitted",
"ora-grading.Rubric.rubric": "Rubric",
"ora-grading.Rubric.submitGrade": "Submit grade",
"ora-grading.Rubric.submittingGrade": "Submitting grade",
"ora-grading.Rubric.overallComments": "Overall comments",
"ora-grading.Rubric.addComments": "Add comments (Optional)",
"ora-grading.Rubric.comments": "Comments (Optional)",
"ora-grading.RubricFeedback.error": "The overall feedback is required",
"ora-grading.lms-api.gradingStatusDisplay.ungraded": "Ungraded",
"ora-grading.lms-api.gradingStatusDisplay.locked": "Currently being graded by someone else",
"ora-grading.lms-api.gradingStatusDisplay.graded": "Grading Completed",
"ora-grading.lms-api.gradingStatusDisplay.inProgress": "You are currently grading this response"
}

View File

@@ -0,0 +1,101 @@
{
"ora-grading.demoAlert.warningMessage": "Grade submission is disabled in the Demo mode of the new ORA Staff Grader.",
"ora-grading.demoAlert.confirm": "Confirm",
"ora-grading.demoAlert.title": "Demo submit prevented",
"ora-grading.FilePopoverContent.filePopoverNameTitle": "Nombre de archivo",
"ora-grading.FilePopoverCellContent.filePopoverDescriptionTitle": "Descripción del archivo",
"ora-grading.FilePopoverCellContent.fileSizeTitle": "Tamaño del archivo",
"ora-grading.InfoPopover.fileInfo": "Información del archivo",
"ora-grading.ResponseDisplay.FileRenderer.retryButton": "Reintentar",
"ora-grading.ResponseDisplay.FileRenderer.fileNotFound": "Archivo no encontrado",
"ora-grading.ResponseDisplay.FileRenderer.unknownError": "Errores desconocidos",
"ora-grading.InfoPopover.alt-text": "Mostrar más información",
"ora-grading.CriterionFeedback.addCommentsLabel": "Añadir comentarios",
"ora-grading.CriterionFeedback.commentsLabel": "Comentarios",
"ora-grading.CriterionFeedback.optional": "(Opcional)",
"ora-grading.RadioCriterion.optionPoints": "{points} puntos",
"ora-grading.RadioCriterion.rubricSelectedError": "Se requiere selección de rúbrica",
"ora-grading.CriterionFeedback.criterionFeedbackError": "Se requiere la retroalimentación",
"ora-grading.ReviewModal.demoHeading": "Demo Mode",
"ora-grading.ReviewModal.demoMessage": "You are using the Demo Mode of the new Enhanced ORA Staff Grader interface. You will be unable to submit grades until you activate the feature.",
"ora-grading.ListView.ListViewBreadcrumbs.backToResponses": "Volver a todas las respuestas abiertas",
"ora-grading.ListView.noResultsFoundTitle": "nada aquí todavía",
"ora-grading.ListView.noResultsFoundBody": "Cuando los alumnos envíen respuestas, aparecerán aquí.",
"ora-grading.ListView.viewAllResponses": "Ver todas las respuestas",
"ora-grading.ListView.viewSelectedResponses": "Ver respuestas seleccionadas ({value})",
"ora-grading.ListView.tableHeaders.username": "Nombre de usuario",
"ora-grading.ListView.tableHeaders.teamName": "Nombre del equipo",
"ora-grading.ListView.tableHeaders.learnerSubmissionDate": "Fecha de envío del alumno",
"ora-grading.ListView.tableHeaders.teamSubmissionDate": "Fecha de presentación del equipo",
"ora-grading.ListView.tableHeaders.grade": "Calificación",
"ora-grading.ListView.tableHeaders.gradingStatus": "estado de calificación",
"ora-grading.ListView.loadErrorHeading": "Error al cargar envíos",
"ora-grading.ListView.loadErrorMessage1": "Se produjo un error al cargar los envíos para esta respuesta. Intente volver a cargar la página o vaya a {backToResponses}.",
"ora-grading.ListView.backToResponsesLowercase": "volver a todas las respuestas abiertas",
"ora-grading.ListView.reloadSubmissions": "Recargar envíos",
"ora-grading.ListView.loadingResponses": "Cargando respuestas",
"ora-grading.ResponseDisplay.FilePopoverCell.filePopoverNameTitle": "Nombre de archivo",
"ora-grading.ResponseDisplay.FilePopoverCell.filePopoverDescriptionTitle": "Descripción del archivo",
"ora-grading.ResponseDisplay.SubmissionFiles.tableNameHeader": "Nombre",
"ora-grading.ResponseDisplay.SubmissionFiles.tableExtensionHeader": "Extensión de archivo",
"ora-grading.ResponseDisplay.SubmissionFiles.tablePopoverHeader": "Metadatos de archivos",
"ora-grading.ResponseDisplay.SubmissionFiles.downloadFiles": "Descargar archivos",
"ora-grading.ResponseDisplay.SubmissionFiles.downloading": "Descargando",
"ora-grading.ResponseDisplay.SubmissionFiles.downloaded": "¡Descargado!",
"ora-grading.ResponseDisplay.SubmissionFiles.retryDownload": "Vuelva a intentar descargar",
"ora-grading.ResponseDisplay.SubmissionFiles.submissionFile": "Submission Files",
"ora-grading.ResponseDisplay.SubmissionFiles.fileSizeExceed": "Exceeded the allow download size",
"ora-grading.ReviewActions.overrideConfirmTitle": "¿Está seguro de que desea anular esta calificación?",
"ora-grading.ReviewActions.overrideConfirmWarning": "Esto no se puede deshacer. Es posible que el alumno ya haya recibido su calificación.",
"ora-grading.ReviewActions.overrideConfirmContinue": "Continuar anulación de calificación",
"ora-grading.ReviewActions.StartGradingButton.startGrading": "Empezar a calificar",
"ora-grading.ReviewActions.StartGradingButton.overrideGrade": "Anular calificación",
"ora-grading.ReviewActions.StartGradingButton.stopGrading": "Dejar de calificar esta respuesta",
"ora-grading.ReviewActions.StopGradingConfirmModal.override.title": "¿Está seguro de que desea detener la anulación de calificaciones?",
"ora-grading.ReviewActions.StopGradingConfirmModal.title": "¿Seguro que quieres dejar de calificar esta respuesta?",
"ora-grading.ReviewActions.StopGradingConfirmModal.warning": "Su progreso se perderá.",
"ora-grading.ReviewActions.StopGradingConfirmModal.override.confirmText": "Detener anulación de calificación",
"ora-grading.ReviewActions.StopGradingConfirmModal.confirmText": "Cancelar calificación",
"ora-grading.ReviewActions.goBack": "Volver",
"ora-grading.ReviewActions.loadPrevious": "Cargar envío anterior",
"ora-grading.ReviewActions.loadNext": "Cargar siguiente envío",
"ora-grading.ReviewActions.navigationLabel": "{current} de {total}",
"ora-grading.ReviewActions.pointsDisplay": "Puntuación: {pointsEarned}/{pointsPossible}",
"ora-grading.ReviewActions.hideRubric": "Ocultar rúbrica",
"ora-grading.ReviewActions.showRubric": "Mostrar rúbrica",
"ora-grading.ReviewModal.closeReviewConfirm.title": "¿Está seguro de que desea cerrar este modal?",
"ora-grading.ReviewModal.closeReviewConfirmWarning": "Esto no se puede deshacer. Esto descartará el trabajo no guardado y detendrá este proceso de calificación.",
"ora-grading.ReviewModal.goBack": "Volver",
"ora-grading.ReviewModal.CloseReviewConfirmModal.confirmText": "Cerrar modal",
"ora-grading.ReviewModal.loadingResponse": "Respuesta de carga",
"ora-grading.ReviewModal.demoTitleMessage": "Grading Demo",
"ora-grading.ReviewModal.loadErrorHeading": "Error al cargar envíos",
"ora-grading.ReviewModal.loadErrorMessage1": "Ocurrió un error al cargar este envío. Intente volver a cargar este envío.",
"ora-grading.ReviewModal.reloadSubmission": "Recargar envío",
"ora-grading.ReviewModal.gradeNotSubmitted.heading": "Calificación no enviada",
"ora-grading.ReviewModal.gradeNotSubmitted.Content": "Lo sentimos, algo salió mal cuando intentamos enviar esta calificación. Inténtalo de nuevo.",
"ora-grading.ReviewModal.resubmitGrade": "Reenviar rejilla",
"ora-grading.ReviewModal.dismiss": "Descartar",
"ora-grading.ReviewModal.errorSubmittingGrade.Heading": "Error al enviar la calificación",
"ora-grading.ReviewModal.errorSubmittingGrade.Content": "¡Parece que alguien más llegó primero! Su envío de calificación ha sido rechazado",
"ora-grading.ReviewModal.errorLockContestedHeading": "El candado propiedad de otro usuario.",
"ora-grading.ReviewModal.errorLockContested": "El candado propiedad de otro usuario.",
"ora-grading.ReviewModal.errorLockBadRequestHeading": "Solicitud no válida. Por favor, compruebe su entrada.",
"ora-grading.ReviewModal.errorLockBadRequest": "Solicitud no válida. Por favor, compruebe su entrada.",
"ora-grading.ReviewModal.errorDownloadFailed": "No se pudieron descargar los archivos",
"ora-grading.ReviewModal.errorDownloadFailedContent": "Lo sentimos, algo salió mal cuando intentamos descargar estos archivos. Inténtalo de nuevo.",
"ora-grading.ReviewModal.errorRetryDownload": "Vuelva a intentar descargar",
"ora-grading.ReviewModal.errorDownloadFailedFiles": "Failed files:",
"ora-grading.Rubric.gradeSubmitted": "Calificación enviada",
"ora-grading.Rubric.rubric": "Rúbrica",
"ora-grading.Rubric.submitGrade": "Enviar calificación",
"ora-grading.Rubric.submittingGrade": "Enviando calificación",
"ora-grading.Rubric.overallComments": "Comentarios totales",
"ora-grading.Rubric.addComments": "Añadir comentarios (Opcional)",
"ora-grading.Rubric.comments": "Comentarios (opcional)",
"ora-grading.RubricFeedback.error": "Se requiere la retroalimentación general",
"ora-grading.lms-api.gradingStatusDisplay.ungraded": "No calificado",
"ora-grading.lms-api.gradingStatusDisplay.locked": "Actualmente siendo calificado por otra persona",
"ora-grading.lms-api.gradingStatusDisplay.graded": "Calificación Completada",
"ora-grading.lms-api.gradingStatusDisplay.inProgress": "Actualmente estás calificando esta respuesta"
}

101
src/i18n/messages/fr.json Normal file
View File

@@ -0,0 +1,101 @@
{
"ora-grading.demoAlert.warningMessage": "La soumission des notes est désactivée dans le mode démonstration du nouveau correcteur ORA.",
"ora-grading.demoAlert.confirm": "Confirmer",
"ora-grading.demoAlert.title": "Soumission de démonstration empêchée",
"ora-grading.FilePopoverContent.filePopoverNameTitle": "Nom du fichier",
"ora-grading.FilePopoverCellContent.filePopoverDescriptionTitle": "Description du fichier",
"ora-grading.FilePopoverCellContent.fileSizeTitle": "Taille du fichier",
"ora-grading.InfoPopover.fileInfo": "Informations du fichier",
"ora-grading.ResponseDisplay.FileRenderer.retryButton": "Réessayez",
"ora-grading.ResponseDisplay.FileRenderer.fileNotFound": "Fichier introuvable",
"ora-grading.ResponseDisplay.FileRenderer.unknownError": "Erreurs inconnues",
"ora-grading.InfoPopover.alt-text": "Afficher plus d&#39;informations",
"ora-grading.CriterionFeedback.addCommentsLabel": "Ajoutez des commentaires",
"ora-grading.CriterionFeedback.commentsLabel": "Commentaires",
"ora-grading.CriterionFeedback.optional": "(Optionnel)",
"ora-grading.RadioCriterion.optionPoints": "{points} points",
"ora-grading.RadioCriterion.rubricSelectedError": "La sélection de la rubrique est requise",
"ora-grading.CriterionFeedback.criterionFeedbackError": "Le feedback est requis",
"ora-grading.ReviewModal.demoHeading": "Mode de démonstration",
"ora-grading.ReviewModal.demoMessage": "Vous utilisez le mode de démonstration de la nouvelle interface du nouveau correcteur ORA amélioré. Vous ne pourrez pas soumettre de notes tant que vous n'aurez pas activé la fonctionnalité.",
"ora-grading.ListView.ListViewBreadcrumbs.backToResponses": "Retour à toutes les réponses ouvertes",
"ora-grading.ListView.noResultsFoundTitle": "Rien ici encore",
"ora-grading.ListView.noResultsFoundBody": "Lorsque les apprenants soumettront des réponses, elles apparaîtront ici",
"ora-grading.ListView.viewAllResponses": "Afficher toutes les réponses",
"ora-grading.ListView.viewSelectedResponses": "Afficher les réponses sélectionnées ({value})",
"ora-grading.ListView.tableHeaders.username": "Nom dutilisateur",
"ora-grading.ListView.tableHeaders.teamName": "Nom de l&#39;équipe",
"ora-grading.ListView.tableHeaders.learnerSubmissionDate": "Date de soumission de l&#39;apprenant",
"ora-grading.ListView.tableHeaders.teamSubmissionDate": "Date de soumission de l&#39;équipe",
"ora-grading.ListView.tableHeaders.grade": "Note",
"ora-grading.ListView.tableHeaders.gradingStatus": "Statut du classement",
"ora-grading.ListView.loadErrorHeading": "Erreur lors du chargement des soumissions",
"ora-grading.ListView.loadErrorMessage1": "Une erreur s&#39;est produite lors du chargement des soumissions pour cette réponse. Essayez de recharger la page ou d&#39;aller {backToResponses}.",
"ora-grading.ListView.backToResponsesLowercase": "retour à toutes les réponses ouvertes",
"ora-grading.ListView.reloadSubmissions": "Recharger les soumissions",
"ora-grading.ListView.loadingResponses": "Chargement des réponses",
"ora-grading.ResponseDisplay.FilePopoverCell.filePopoverNameTitle": "Nom du fichier",
"ora-grading.ResponseDisplay.FilePopoverCell.filePopoverDescriptionTitle": "Description du fichier",
"ora-grading.ResponseDisplay.SubmissionFiles.tableNameHeader": "Nom",
"ora-grading.ResponseDisplay.SubmissionFiles.tableExtensionHeader": "Extension de fichier",
"ora-grading.ResponseDisplay.SubmissionFiles.tablePopoverHeader": "Métadonnées de fichier",
"ora-grading.ResponseDisplay.SubmissionFiles.downloadFiles": "Télecharger les fichiers",
"ora-grading.ResponseDisplay.SubmissionFiles.downloading": "Téléchargement",
"ora-grading.ResponseDisplay.SubmissionFiles.downloaded": "Téléchargé !",
"ora-grading.ResponseDisplay.SubmissionFiles.retryDownload": "Réessayez le téléchargement",
"ora-grading.ResponseDisplay.SubmissionFiles.submissionFile": "Submission Files",
"ora-grading.ResponseDisplay.SubmissionFiles.fileSizeExceed": "Exceeded the allow download size",
"ora-grading.ReviewActions.overrideConfirmTitle": "Êtes-vous sûr de vouloir remplacer cette note ?",
"ora-grading.ReviewActions.overrideConfirmWarning": "Ça ne peut pas être annulé. L&#39;apprenant peut avoir déjà reçu sa note.",
"ora-grading.ReviewActions.overrideConfirmContinue": "Continuer le remplacement de la note",
"ora-grading.ReviewActions.StartGradingButton.startGrading": "Commencer la notation",
"ora-grading.ReviewActions.StartGradingButton.overrideGrade": "Remplacer la note",
"ora-grading.ReviewActions.StartGradingButton.stopGrading": "Arrêtez de noter cette réponse",
"ora-grading.ReviewActions.StopGradingConfirmModal.override.title": "Êtes-vous sûr de vouloir arrêter le remplacement des notes ?",
"ora-grading.ReviewActions.StopGradingConfirmModal.title": "Voulez-vous vraiment arrêter de noter cette réponse ?",
"ora-grading.ReviewActions.StopGradingConfirmModal.warning": "Votre progression sera perdue.",
"ora-grading.ReviewActions.StopGradingConfirmModal.override.confirmText": "Arrêter le remplacement de note",
"ora-grading.ReviewActions.StopGradingConfirmModal.confirmText": "Annuler la notation",
"ora-grading.ReviewActions.goBack": "Retour",
"ora-grading.ReviewActions.loadPrevious": "Charger la soumission précédente",
"ora-grading.ReviewActions.loadNext": "Charger la prochaine soumission",
"ora-grading.ReviewActions.navigationLabel": "{current} de {total}",
"ora-grading.ReviewActions.pointsDisplay": "Résultat : {pointsEarned}/{pointsPossible}",
"ora-grading.ReviewActions.hideRubric": "Masquer la rubrique",
"ora-grading.ReviewActions.showRubric": "Afficher la rubrique",
"ora-grading.ReviewModal.closeReviewConfirm.title": "Êtes-vous sûr de vouloir fermer ce modal ?",
"ora-grading.ReviewModal.closeReviewConfirmWarning": "Ça ne peut pas être annulé. Cela supprimera le travail non enregistré et arrêtera ce processus de notation.",
"ora-grading.ReviewModal.goBack": "Retour",
"ora-grading.ReviewModal.CloseReviewConfirmModal.confirmText": "Fermer le modal",
"ora-grading.ReviewModal.loadingResponse": "Chargement de la réponse",
"ora-grading.ReviewModal.demoTitleMessage": "Démonstration de correcteur",
"ora-grading.ReviewModal.loadErrorHeading": "Erreur lors du chargement des soumissions",
"ora-grading.ReviewModal.loadErrorMessage1": "Une erreur s&#39;est produite lors du chargement de cette soumission. Essayez de recharger cette soumission.",
"ora-grading.ReviewModal.reloadSubmission": "Recharger la soumission",
"ora-grading.ReviewModal.gradeNotSubmitted.heading": "Note non soumise",
"ora-grading.ReviewModal.gradeNotSubmitted.Content": "Nous sommes désolés, une erreur s&#39;est produite lorsque nous avons essayé d&#39;envoyer cette note. Veuillez réessayer.",
"ora-grading.ReviewModal.resubmitGrade": "Resoumettre la grille",
"ora-grading.ReviewModal.dismiss": "Ignorer",
"ora-grading.ReviewModal.errorSubmittingGrade.Heading": "Erreur lors de l&#39;envoi de la note",
"ora-grading.ReviewModal.errorSubmittingGrade.Content": "Il semble que quelqu&#39;un d&#39;autre soit arrivé le premier ! Votre soumission de note a été rejetée",
"ora-grading.ReviewModal.errorLockContestedHeading": "La serrure appartenant à un autre utilisateur",
"ora-grading.ReviewModal.errorLockContested": "La serrure appartenant à un autre utilisateur",
"ora-grading.ReviewModal.errorLockBadRequestHeading": "Requête invalide. Veuillez vérifier votre entrée.",
"ora-grading.ReviewModal.errorLockBadRequest": "Requête invalide. Veuillez vérifier votre entrée.",
"ora-grading.ReviewModal.errorDownloadFailed": "Impossible de télécharger les fichiers",
"ora-grading.ReviewModal.errorDownloadFailedContent": "Nous sommes désolés, une erreur s&#39;est produite lorsque nous avons essayé de télécharger ces fichiers. Veuillez réessayer.",
"ora-grading.ReviewModal.errorRetryDownload": "Réessayez le téléchargement",
"ora-grading.ReviewModal.errorDownloadFailedFiles": "Fichiers ayant échoué :",
"ora-grading.Rubric.gradeSubmitted": "Note soumise",
"ora-grading.Rubric.rubric": "Rubrique",
"ora-grading.Rubric.submitGrade": "Soumettre la note",
"ora-grading.Rubric.submittingGrade": "Remise de la note",
"ora-grading.Rubric.overallComments": "Commentaires généraux",
"ora-grading.Rubric.addComments": "Ajouter des commentaires (facultatif)",
"ora-grading.Rubric.comments": "Commentaires (optionnel)",
"ora-grading.RubricFeedback.error": "La rétroaction globale est requise",
"ora-grading.lms-api.gradingStatusDisplay.ungraded": "Non noté",
"ora-grading.lms-api.gradingStatusDisplay.locked": "Actuellement noté par quelqu&#39;un d&#39;autre",
"ora-grading.lms-api.gradingStatusDisplay.graded": "Classement terminé",
"ora-grading.lms-api.gradingStatusDisplay.inProgress": "Vous notez actuellement cette réponse"
}

View File

@@ -0,0 +1,101 @@
{
"ora-grading.demoAlert.warningMessage": "Grade submission is disabled in the Demo mode of the new ORA Staff Grader.",
"ora-grading.demoAlert.confirm": "Confirm",
"ora-grading.demoAlert.title": "Demo submit prevented",
"ora-grading.FilePopoverContent.filePopoverNameTitle": "File Name",
"ora-grading.FilePopoverCellContent.filePopoverDescriptionTitle": "File Description",
"ora-grading.FilePopoverCellContent.fileSizeTitle": "File Size",
"ora-grading.InfoPopover.fileInfo": "File info",
"ora-grading.ResponseDisplay.FileRenderer.retryButton": "Retry",
"ora-grading.ResponseDisplay.FileRenderer.fileNotFound": "File not found",
"ora-grading.ResponseDisplay.FileRenderer.unknownError": "Unknown errors",
"ora-grading.InfoPopover.alt-text": "Display more info",
"ora-grading.CriterionFeedback.addCommentsLabel": "Add comments",
"ora-grading.CriterionFeedback.commentsLabel": "Comments",
"ora-grading.CriterionFeedback.optional": "(Optional)",
"ora-grading.RadioCriterion.optionPoints": "{points} points",
"ora-grading.RadioCriterion.rubricSelectedError": "Rubric selection is required",
"ora-grading.CriterionFeedback.criterionFeedbackError": "The feedback is required",
"ora-grading.ReviewModal.demoHeading": "Demo Mode",
"ora-grading.ReviewModal.demoMessage": "You are using the Demo Mode of the new Enhanced ORA Staff Grader interface. You will be unable to submit grades until you activate the feature.",
"ora-grading.ListView.ListViewBreadcrumbs.backToResponses": "Back to all open responses",
"ora-grading.ListView.noResultsFoundTitle": "Nothing here yet",
"ora-grading.ListView.noResultsFoundBody": "When learners submit responses, they will appear here",
"ora-grading.ListView.viewAllResponses": "View all responses",
"ora-grading.ListView.viewSelectedResponses": "View selected responses ({value})",
"ora-grading.ListView.tableHeaders.username": "Username",
"ora-grading.ListView.tableHeaders.teamName": "Team name",
"ora-grading.ListView.tableHeaders.learnerSubmissionDate": "Learner submission date",
"ora-grading.ListView.tableHeaders.teamSubmissionDate": "Team submission date",
"ora-grading.ListView.tableHeaders.grade": "Grade",
"ora-grading.ListView.tableHeaders.gradingStatus": "Grading status",
"ora-grading.ListView.loadErrorHeading": "Error loading submissions",
"ora-grading.ListView.loadErrorMessage1": "An error occurred while loading the submissions for this response. Try reloading the page or going {backToResponses}.",
"ora-grading.ListView.backToResponsesLowercase": "back to all Open Responses",
"ora-grading.ListView.reloadSubmissions": "Reload submissions",
"ora-grading.ListView.loadingResponses": "Loading responses",
"ora-grading.ResponseDisplay.FilePopoverCell.filePopoverNameTitle": "File Name",
"ora-grading.ResponseDisplay.FilePopoverCell.filePopoverDescriptionTitle": "File Description",
"ora-grading.ResponseDisplay.SubmissionFiles.tableNameHeader": "Name",
"ora-grading.ResponseDisplay.SubmissionFiles.tableExtensionHeader": "File Extension",
"ora-grading.ResponseDisplay.SubmissionFiles.tablePopoverHeader": "File Metadata",
"ora-grading.ResponseDisplay.SubmissionFiles.downloadFiles": "Download files",
"ora-grading.ResponseDisplay.SubmissionFiles.downloading": "Downloading",
"ora-grading.ResponseDisplay.SubmissionFiles.downloaded": "Downloaded!",
"ora-grading.ResponseDisplay.SubmissionFiles.retryDownload": "Retry download",
"ora-grading.ResponseDisplay.SubmissionFiles.submissionFile": "Submission Files",
"ora-grading.ResponseDisplay.SubmissionFiles.fileSizeExceed": "Exceeded the allow download size",
"ora-grading.ReviewActions.overrideConfirmTitle": "Are you sure you want to override this grade?",
"ora-grading.ReviewActions.overrideConfirmWarning": "This cannot be undone. The learner may have already received their grade.",
"ora-grading.ReviewActions.overrideConfirmContinue": "Continue grade override",
"ora-grading.ReviewActions.StartGradingButton.startGrading": "Start grading",
"ora-grading.ReviewActions.StartGradingButton.overrideGrade": "Override grade",
"ora-grading.ReviewActions.StartGradingButton.stopGrading": "Stop grading this response",
"ora-grading.ReviewActions.StopGradingConfirmModal.override.title": "Are you sure you want to stop grade override?",
"ora-grading.ReviewActions.StopGradingConfirmModal.title": "Are you sure you want to stop grading this response?",
"ora-grading.ReviewActions.StopGradingConfirmModal.warning": "Your progress will be lost.",
"ora-grading.ReviewActions.StopGradingConfirmModal.override.confirmText": "Stop grade override",
"ora-grading.ReviewActions.StopGradingConfirmModal.confirmText": "Cancel grading",
"ora-grading.ReviewActions.goBack": "Go back",
"ora-grading.ReviewActions.loadPrevious": "Load previous submission",
"ora-grading.ReviewActions.loadNext": "Load next submission",
"ora-grading.ReviewActions.navigationLabel": "{current} of {total}",
"ora-grading.ReviewActions.pointsDisplay": "Score: {pointsEarned}/{pointsPossible}",
"ora-grading.ReviewActions.hideRubric": "Hide Rubric",
"ora-grading.ReviewActions.showRubric": "Show Rubric",
"ora-grading.ReviewModal.closeReviewConfirm.title": "Are you sure you want to close this modal?",
"ora-grading.ReviewModal.closeReviewConfirmWarning": "This cannot be undone. This will discard unsaved work and stop this grading process.",
"ora-grading.ReviewModal.goBack": "Go back",
"ora-grading.ReviewModal.CloseReviewConfirmModal.confirmText": "Close Modal",
"ora-grading.ReviewModal.loadingResponse": "Loading response",
"ora-grading.ReviewModal.demoTitleMessage": "Grading Demo",
"ora-grading.ReviewModal.loadErrorHeading": "Error loading submissions",
"ora-grading.ReviewModal.loadErrorMessage1": "An error occurred while loading this submission. Try reloading this submission.",
"ora-grading.ReviewModal.reloadSubmission": "Reload submission",
"ora-grading.ReviewModal.gradeNotSubmitted.heading": "Grade not submitted",
"ora-grading.ReviewModal.gradeNotSubmitted.Content": "We're sorry, something went wrong when we tried to submit this grade. Please try again.",
"ora-grading.ReviewModal.resubmitGrade": "Resubmit grate",
"ora-grading.ReviewModal.dismiss": "Dismiss",
"ora-grading.ReviewModal.errorSubmittingGrade.Heading": "Error submitting grade",
"ora-grading.ReviewModal.errorSubmittingGrade.Content": "It looks like someone else got here first! Your grade submission has been rejected",
"ora-grading.ReviewModal.errorLockContestedHeading": "The lock owned by another user",
"ora-grading.ReviewModal.errorLockContested": "The lock owned by another user",
"ora-grading.ReviewModal.errorLockBadRequestHeading": "Invalid request. Please check your input.",
"ora-grading.ReviewModal.errorLockBadRequest": "Invalid request. Please check your input.",
"ora-grading.ReviewModal.errorDownloadFailed": "Couldn't download files",
"ora-grading.ReviewModal.errorDownloadFailedContent": "We're sorry, something went wrong when we tried to download these files. Please try again.",
"ora-grading.ReviewModal.errorRetryDownload": "Retry download",
"ora-grading.ReviewModal.errorDownloadFailedFiles": "Failed files:",
"ora-grading.Rubric.gradeSubmitted": "Grade Submitted",
"ora-grading.Rubric.rubric": "Rubric",
"ora-grading.Rubric.submitGrade": "Submit grade",
"ora-grading.Rubric.submittingGrade": "Submitting grade",
"ora-grading.Rubric.overallComments": "Overall comments",
"ora-grading.Rubric.addComments": "Add comments (Optional)",
"ora-grading.Rubric.comments": "Comments (Optional)",
"ora-grading.RubricFeedback.error": "The overall feedback is required",
"ora-grading.lms-api.gradingStatusDisplay.ungraded": "Ungraded",
"ora-grading.lms-api.gradingStatusDisplay.locked": "Currently being graded by someone else",
"ora-grading.lms-api.gradingStatusDisplay.graded": "Grading Completed",
"ora-grading.lms-api.gradingStatusDisplay.inProgress": "You are currently grading this response"
}

61
src/index.jsx Executable file
View File

@@ -0,0 +1,61 @@
/* eslint-disable import/prefer-default-export */
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,
mergeConfig,
} from '@edx/frontend-platform';
import { messages as footerMessages } from '@edx/frontend-component-footer';
import { messages as headerMesssages } from '@edx/frontend-component-header';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import messages from './i18n';
import App from './App';
subscribe(APP_READY, () => {
ReactDOM.render(
<IntlProvider locale="en">
<AppProvider store={store}>
<App />
</AppProvider>
</IntlProvider>,
document.getElementById('root'),
);
});
subscribe(APP_INIT_ERROR, (error) => {
ReactDOM.render(
<ErrorPage message={error.message} />,
document.getElementById('root'),
);
});
export const appName = 'OraGradingAppConfig';
initialize({
handlers: {
config: () => {
mergeConfig({
SUPPORT_URL: process.env.SUPPORT_URL || null,
}, appName);
},
},
messages: [
messages,
headerMesssages,
footerMessages,
],
requireAuthenticatedUser: true,
});

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

@@ -0,0 +1,87 @@
import { render } from 'react-dom';
import {
APP_INIT_ERROR,
APP_READY,
initialize,
mergeConfig,
subscribe,
} from '@edx/frontend-platform';
import { messages as footerMessages } from '@edx/frontend-component-footer';
import { messages as headerMesssages } from '@edx/frontend-component-header';
import appMessages from './i18n';
import * as app from '.';
jest.mock('react-dom', () => ({
render: jest.fn(),
}));
jest.mock('@edx/frontend-component-footer', () => ({
messages: 'frotnend-footer-messages',
}));
jest.mock('@edx/frontend-component-header', () => ({
messages: 'frotnend-header-messages',
}));
jest.mock('@edx/frontend-platform', () => ({
mergeConfig: jest.fn(),
APP_READY: 'app-is-ready-key',
APP_INIT_ERROR: 'app-init-error',
initialize: jest.fn(),
subscribe: jest.fn(),
}));
jest.mock('@edx/frontend-component-footer', () => ({
messages: ['some', 'messages'],
}));
jest.mock('./App', () => 'App');
const testValue = 'my-test-value';
describe('app registry', () => {
let getElement;
beforeEach(() => {
render.mockClear();
getElement = window.document.getElementById;
window.document.getElementById = jest.fn(id => ({ id }));
});
afterAll(() => {
window.document.getElementById = getElement;
});
test('subscribe: APP_READY. links App to root element', () => {
const callArgs = subscribe.mock.calls[0];
expect(callArgs[0]).toEqual(APP_READY);
callArgs[1]();
const [rendered, target] = render.mock.calls[0];
expect(rendered).toMatchSnapshot();
expect(target).toEqual(document.getElementById('root'));
});
test('subscribe: APP_INIT_ERROR. snapshot: displays an ErrorPage to root element', () => {
const callArgs = subscribe.mock.calls[1];
expect(callArgs[0]).toEqual(APP_INIT_ERROR);
const error = { message: 'test-error-message' };
callArgs[1](error);
const [rendered, target] = render.mock.calls[0];
expect(rendered).toMatchSnapshot();
expect(target).toEqual(document.getElementById('root'));
});
test('initialize is called with footerMessages and requireAuthenticatedUser', () => {
expect(initialize).toHaveBeenCalledTimes(1);
const initializeArg = initialize.mock.calls[0][0];
expect(initializeArg.messages).toEqual([appMessages, headerMesssages, footerMessages]);
expect(initializeArg.requireAuthenticatedUser).toEqual(true);
});
test('initialize config loads support url if available', () => {
const oldEnv = process.env;
const initializeArg = initialize.mock.calls[0][0];
delete process.env.SUPPORT_URL;
initializeArg.handlers.config();
expect(mergeConfig).toHaveBeenCalledWith({ SUPPORT_URL: null }, app.appName);
process.env.SUPPORT_URL = testValue;
initializeArg.handlers.config();
expect(mergeConfig).toHaveBeenCalledWith({ SUPPORT_URL: testValue }, app.appName);
process.env = oldEnv;
});
});

85
src/segment.js Normal file
View File

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

131
src/setupTest.js Executable file
View File

@@ -0,0 +1,131 @@
/* 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';
Enzyme.configure({ adapter: new Adapter() });
jest.mock('react', () => ({
...jest.requireActual('react'),
useRef: jest.fn((val) => ({ current: val, useRef: true })),
useCallback: jest.fn((cb, prereqs) => ({ useCallback: { cb, prereqs } })),
useEffect: jest.fn((cb, prereqs) => ({ useEffect: { cb, prereqs } })),
useContext: jest.fn(context => context),
}));
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: PropTypes.func,
}),
defineMessages: m => m,
FormattedMessage: () => 'FormattedMessage',
};
});
jest.mock('@edx/paragon', () => jest.requireActual('testUtils').mockNestedComponents({
Alert: {
Heading: 'Alert.Heading',
},
AlertModal: 'AlertModal',
ActionRow: 'ActionRow',
Badge: 'Badge',
Button: 'Button',
Card: {
Body: 'Card.Body',
Footer: 'Card.Footer',
},
Col: 'Col',
Collapsible: {
Advanced: 'Collapsible.Advanced',
Body: 'Collapsible.Body',
Trigger: 'Collapsible.Trigger',
Visible: 'Collapsible.Visible',
},
Container: 'Container',
DataTable: {
EmptyTable: 'DataTable.EmptyTable',
Table: 'DataTable.Table',
TableControlBar: 'DataTable.TableControlBar',
TableController: 'DataTable.TableController',
TableFooter: 'DataTable.TableFooter',
},
Dropdown: {
Item: 'Dropdown.Item',
Menu: 'Dropdown.Menu',
Toggle: 'Dropdown.Toggle',
},
Form: {
Control: {
Feedback: 'Form.Control.Feedback',
},
Group: 'Form.Group',
Label: 'Form.Label',
Radio: 'Form.Radio',
RadioSet: 'Form.RadioSet',
},
FormControlFeedback: 'FormControlFeedback',
FullscreenModal: 'FullscreenModal',
Hyperlink: 'Hyperlink',
Icon: 'Icon',
IconButton: 'IconButton',
MultiSelectDropdownFilter: 'MultiSelectDropdownFilter',
OverlayTrigger: 'OverlayTrigger',
Popover: {
Content: 'Popover.Content',
},
Row: 'Row',
StatefulButton: 'StatefulButton',
TextFilter: 'TextFilter',
Spinner: 'Spinner',
}));
jest.mock('@fortawesome/react-fontawesome', () => ({
FontAwesomeIcon: 'FontAwesomeIcon',
}));
jest.mock('@fortawesome/free-solid-svg-icons', () => ({
faUserCircle: jest.fn().mockName('fa-user-circle-icon'),
}));
jest.mock('@edx/paragon/icons', () => ({
ArrowBack: jest.fn().mockName('icons.ArrowBack'),
ArrowDropDown: jest.fn().mockName('icons.ArrowDropDown'),
ArrowDropUp: jest.fn().mockName('icons.ArrowDropUp'),
Cancel: jest.fn().mockName('icons.Cancel'),
ChevronLeft: jest.fn().mockName('icons.ChevronLeft'),
ChevronRight: jest.fn().mockName('icons.ChevronRight'),
Highlight: jest.fn().mockName('icons.Highlight'),
InfoOutline: jest.fn().mockName('icons.InfoOutline'),
Launch: jest.fn().mockName('icons.Launch'),
}));
jest.mock('data/constants/app', () => ({
locationId: 'fake-location-id',
}));
jest.mock('hooks', () => ({
...jest.requireActual('hooks'),
nullMethod: jest.fn().mockName('hooks.nullMethod'),
}));
jest.mock('@zip.js/zip.js', () => ({}));
// Mock react-redux hooks
// unmock for integration tests
jest.mock('react-redux', () => {
const dispatch = jest.fn((...args) => ({ dispatch: args })).mockName('react-redux.dispatch');
return {
connect: (mapStateToProps, mapDispatchToProps) => (component) => ({
mapStateToProps,
mapDispatchToProps,
component,
}),
useDispatch: jest.fn(() => dispatch),
useSelector: jest.fn((selector) => ({ useSelector: selector })),
};
});

515
src/test/app.test.jsx Normal file
View File

@@ -0,0 +1,515 @@
/* eslint-disable */
import React from 'react';
import * as redux from 'redux';
import { Provider } from 'react-redux';
import {
act,
render,
waitFor,
within,
prettyDOM,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import thunk from 'redux-thunk';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import urls from 'data/services/lms/urls';
import { ErrorStatuses, RequestKeys, RequestStates } from 'data/constants/requests';
import { gradeStatuses, lockStatuses } from 'data/services/lms/constants';
import fakeData from 'data/services/lms/fakeData';
import api from 'data/services/lms/api';
import reducers from 'data/redux';
import messages from 'i18n';
import { selectors } from 'data/redux';
import App from 'App';
import Inspector from './inspector';
import appMessages from './messages';
jest.unmock('@edx/paragon');
jest.unmock('@edx/paragon/icons');
jest.unmock('@edx/frontend-platform/i18n');
jest.unmock('react');
jest.unmock('react-redux');
jest.unmock('hooks');
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedHttpClient: jest.fn(),
getLoginRedirectUrl: jest.fn(),
}));
jest.mock('react-pdf', () => ({
Document: () => <div>Document</div>,
Image: () => <div>Image</div>,
Page: () => <div>Page</div>,
PDFViewer: jest.fn(() => null),
StyleSheet: { create: () => {} },
Text: () => <div>Text</div>,
View: () => <div>View</div>,
pdfjs: { GlobalWorkerOptions: {} },
}));
/*
jest.mock('react-pdf/node_modules/pdfjs-dist/build/pdf.worker.entry', () => (
jest.requireActual('react-pdf/dist/umd/entry.jest')
));
*/
const configureStore = () => redux.createStore(
reducers,
redux.compose(redux.applyMiddleware(thunk)),
);
let el;
let store;
let state;
let retryLink;
let inspector;
const { rubricConfig } = fakeData.oraMetadata;
/**
* Simple wrapper for updating the top-level state variable, that also returns the new value
* @return {obj} - current redux store state
*/
const getState = () => {
state = store.getState();
return state;
};
/** Fake Data for quick access */
const submissionUUIDs = [
fakeData.ids.submissionUUID(0),
fakeData.ids.submissionUUID(1),
fakeData.ids.submissionUUID(2),
fakeData.ids.submissionUUID(3),
fakeData.ids.submissionUUID(4),
];
const submissions = submissionUUIDs.map(id => fakeData.mockSubmission(id));
/**
* Object to be filled with resolve/reject functions for all controlled network comm channels
*/
const resolveFns = {};
/**
* Mock the api with jest functions that can be tested against.
*/
const mockNetworkError = (reject) => () => reject(new Error({
response: { status: ErrorStatuses.badRequest },
}));
const mockForbiddenError = (reject) => () => reject(new Error({
response: { status: ErrorStatuses.forbidden },
}));
const mockApi = () => {
api.initializeApp = jest.fn(() => new Promise(
(resolve, reject) => {
resolveFns.init = {
success: () => resolve({
isEnabled: true,
oraMetadata: fakeData.oraMetadata,
courseMetadata: fakeData.courseMetadata,
submissions: fakeData.submissions,
}),
networkError: mockNetworkError(reject),
};
},
));
api.fetchSubmission = jest.fn((submissionUUID) => new Promise(
(resolve, reject) => {
resolveFns.fetch = {
success: () => resolve(fakeData.mockSubmission(submissionUUID)),
networkError: mockNetworkError(reject),
};
},
));
api.fetchSubmissionStatus = jest.fn((submissionUUID) => Promise.resolve(
fakeData.mockSubmissionStatus(submissionUUID)
));
api.lockSubmission = jest.fn(() => new Promise(
(resolve, reject) => {
resolveFns.lock = {
success: () => resolve({ lockStatus: lockStatuses.inProgress }),
networkError: mockForbiddenError(reject),
};
},
));
api.unlockSubmission = jest.fn(() => new Promise(
(resolve, reject) => {
resolveFns.unlock = {
success: () => resolve({ lockStatus: lockStatuses.unlocked }),
networkError: mockNetworkError(reject),
};
},
));
api.updateGrade = jest.fn((uuid, gradeData) => new Promise(
(resolve, reject) => {
resolveFns.updateGrade = {
success: () => resolve({
gradeData,
gradeStatus: gradeStatuses.graded,
lockStatus: lockStatuses.unlocked,
}),
networkError: mockNetworkError(reject),
};
},
));
};
/**
* load and configure the store, render the element, and populate the top-level state object
*/
const renderEl = async () => {
store = configureStore();
el = await render(
<IntlProvider locale='en' messages={messages.en}>
<Provider store={store}>
<App />
</Provider>
</IntlProvider>,
);
getState();
};
/**
* resolve the initalization promise, and update state object
*/
const initialize = async () => {
resolveFns.init.success();
await inspector.find.listView.viewAllResponsesBtn();
getState();
};
/**
* Select the first 5 entries in the table and click the 'View Selected Responses' button
* Wait for the review page to show and update the top-level state object.
*/
const makeTableSelections = async () => {
[0, 1, 2, 3, 4].forEach(index => userEvent.click(inspector.listView.listCheckbox(index)));
userEvent.click(inspector.listView.selectedBtn(5));
// wait for navigation, which will show while request is pending
try {
await inspector.find.review.prevNav();
} catch (e) {
throw(e);
}
getState();
};
const waitForEqual = async (valFn, expected, key) => waitFor(() => {
expect(valFn(), `${key} is expected to equal ${expected}`).toEqual(expected);
});
const waitForRequestStatus = (key, status) => waitForEqual(
() => getState().requests[key].status,
status,
key,
);
describe('ESG app integration tests', () => {
beforeEach(async () => {
mockApi();
await renderEl();
inspector = new Inspector(el);
});
test('initialization', async (done) => {
const verifyInitialState = async () => {
await waitForRequestStatus(RequestKeys.initialize, RequestStates.pending);
const testInitialState = (key) => expect(
state[key],
`${key} store should have its configured initial state`,
).toEqual(
jest.requireActual(`data/redux/${key}/reducer`).initialState,
);
testInitialState('app');
testInitialState('submissions');
testInitialState('grading');
expect(
inspector.listView.loadingResponses(),
'Loading Responses pending state text should be displayed in the ListView',
).toBeVisible();
}
await verifyInitialState();
// initialization network error
const forceAndVerifyInitNetworkError = async () => {
resolveFns.init.networkError();
await waitForRequestStatus(RequestKeys.initialize, RequestStates.failed);
expect(
await inspector.find.listView.loadErrorHeading(),
'List Error should be available (by heading component)',
).toBeVisible();
const backLink = inspector.listView.backLink();
expect(
backLink.href,
'Back to responses button href should link to urls.openResponse(courseId)',
).toEqual(urls.openResponse(getState().app.courseMetadata.courseId));
};
await forceAndVerifyInitNetworkError();
// initialization retry/pending
retryLink = inspector.listView.reloadBtn();
await userEvent.click(retryLink);
await waitForRequestStatus(RequestKeys.initialize, RequestStates.pending);
// initialization success
const forceAndVerifyInitSuccess = async () => {
await initialize();
await waitForRequestStatus(RequestKeys.initialize, RequestStates.completed);
expect(
state.app.courseMetadata,
'Course metadata in redux should be populated with fake data',
).toEqual(fakeData.courseMetadata);
expect(
state.app.oraMetadata,
'ORA metadata in redux should be populated with fake data',
).toEqual(fakeData.oraMetadata);
expect(
state.submissions.allSubmissions,
'submissions data in redux should be populated with fake data',
).toEqual(fakeData.submissions);
};
await forceAndVerifyInitSuccess();
await makeTableSelections();
await waitForRequestStatus(RequestKeys.fetchSubmission, RequestStates.pending);
done();
});
describe('initialized', () => {
beforeEach(async () => {
await initialize();
await waitForRequestStatus(RequestKeys.initialize, RequestStates.completed);
await makeTableSelections();
await waitForRequestStatus(RequestKeys.fetchSubmission, RequestStates.pending);
});
test('initial review state', async (done) => {
// Make table selection and load Review pane
expect(
state.grading.selection,
'submission IDs should be loaded',
).toEqual(submissionUUIDs);
expect(state.app.showReview, 'app store should have showReview: true').toEqual(true);
expect(inspector.review.username(0), 'username should be visible').toBeVisible();
const nextNav = inspector.review.nextNav();
const prevNav = inspector.review.prevNav();
expect(nextNav, 'next nav should be displayed').toBeVisible();
expect(nextNav, 'next nav should be disabled').toHaveAttribute('disabled');
expect(prevNav, 'prev nav should be displayed').toBeVisible();
expect(prevNav, 'prev nav should be disabled').toHaveAttribute('disabled');
expect(
inspector.review.loadingResponse(),
'Loading Responses pending state text should be displayed in the ReviewModal',
).toBeVisible();
done();
});
test('fetch network error and retry', async (done) => {
await resolveFns.fetch.networkError();
await waitForRequestStatus(RequestKeys.fetchSubmission, RequestStates.failed);
expect(
await inspector.find.review.loadErrorHeading(),
'Load Submission error should be displayed in ReviewModal',
).toBeVisible();
// fetch: retry and succeed
await userEvent.click(inspector.review.retryFetchLink());
await waitForRequestStatus(RequestKeys.fetchSubmission, RequestStates.pending);
done()
});
test('fetch success and nav chain', async (done) => {
let showRubric = false;
// fetch: success with chained navigation
const verifyFetchSuccess = async (submissionIndex) => {
const submissionString = `for submission ${submissionIndex}`;
const submission = submissions[submissionIndex];
const forceAndVerifyFetchSuccess = async () => {
await resolveFns.fetch.success();
await waitForRequestStatus(RequestKeys.fetchSubmission, RequestStates.completed);
expect(
inspector.review.gradingStatus(submission),
`Should display current submission grading status ${submissionString}`,
).toBeVisible();
};
await forceAndVerifyFetchSuccess();
showRubric = showRubric || selectors.grading.selected.isGrading(getState());
const verifyRubricVisibility = async () => {
getState();
expect(
state.app.showRubric,
`${showRubric ? 'Should' : 'Should not'} show rubric ${submissionString}`,
).toEqual(showRubric);
if (showRubric) {
expect(
inspector.review.hideRubricBtn(),
`Hide Rubric button should be visible when rubric is shown ${submissionString}`,
).toBeVisible();
} else {
expect(
inspector.review.showRubricBtn(),
`Show Rubric button should be visible when rubric is hidden ${submissionString}`,
).toBeVisible();
}
}
await verifyRubricVisibility();
// loads current submission
const testSubmissionGradingState = () => {
expect(
state.grading.current,
`Redux current grading state should load the current submission ${submissionString}`,
).toEqual({
submissionUUID: submissionUUIDs[submissionIndex],
...submissions[submissionIndex],
});
};
testSubmissionGradingState();
const testNavState = () => {
const expectDisabled = (getNav, name) => (
 expect(getNav(), `${name} should be disabled`).toHaveAttribute('disabled')
);
const expectEnabled = (getNav, name) => (
 expect(getNav(), `${name} should be enabled`).not.toHaveAttribute('disabled')
);
(submissionIndex > 0 ? expectEnabled : expectDisabled)(
inspector.review.prevNav,
'Prev nav',
);
const hasNext = submissionIndex < submissions.length - 1;
(hasNext ? expectEnabled : expectDisabled)(inspector.review.nextNav, 'Next nav');
};
testNavState();
};
await verifyFetchSuccess(0);
for (let i = 1; i < 5; i++) {
await userEvent.click(inspector.review.nextNav());
await verifyFetchSuccess(i);
}
for (let i = 3; i >= 0; i--) {
await userEvent.click(inspector.review.prevNav());
await verifyFetchSuccess(i);
}
done();
});
describe('grading (basic)', () => {
beforeEach(async () => {
await resolveFns.fetch.success();
await waitForRequestStatus(RequestKeys.fetchSubmission, RequestStates.completed);
await userEvent.click(await inspector.find.review.startGradingBtn());
});
describe('active grading', () => {
beforeEach(async () => {
await resolveFns.lock.success();
});
const selectedOptions = [1, 2];
const feedback = ['feedback 0', 'feedback 1'];
const overallFeedback = 'some overall feedback';
// Set basic grade and feedback
const setGrade = async (done) => {
const {
criterionOption,
criterionFeedback,
feedbackInput,
} = inspector.review.rubric;
const options = [
criterionOption(0, selectedOptions[0]),
criterionOption(1, selectedOptions[1]),
];
await userEvent.click(options[0]);
await userEvent.type(criterionFeedback(0), feedback[0]);
await userEvent.click(options[1]);
await userEvent.type(criterionFeedback(1), feedback[1]);
await userEvent.type(inspector.review.rubric.feedbackInput(), overallFeedback);
return;
};
// Verify active-grading state
const checkGradingState = (submissionUUID=submissionUUIDs[0]) => {
const entry = getState().grading.gradingData[submissionUUID];
const checkCriteria = (index) => {
const criterion = entry.criteria[index];
const selected = rubricConfig.criteria[index].options[selectedOptions[index]].name;
expect(criterion.selectedOption).toEqual(selected);
expect(criterion.feedback).toEqual(feedback[index]);
}
[0, 1].forEach(checkCriteria);
expect(entry.overallFeedback).toEqual(overallFeedback);
}
// Verify after-submission-success grade state
const checkGradeSuccess = () => {
const { gradeData, current } = getState().grading;
const entry = gradeData[submissionUUIDs[0]];
const checkCriteria = (index) => {
const criterion = entry.criteria[index];
const rubricOptions = rubricConfig.criteria[index].options;
expect(criterion.selectedOption).toEqual(rubricOptions[selectedOptions[index]].name);
expect(criterion.feedback).toEqual(feedback[index]);
}
[0, 1].forEach(checkCriteria);
expect(entry.overallFeedback).toEqual(overallFeedback);
expect(current.gradeStatus).toEqual(gradeStatuses.graded);
expect(current.lockStatus).toEqual(lockStatuses.unlocked);
}
const loadNext = async () => {
await userEvent.click(inspector.review.nextNav());
await resolveFns.fetch.success();
};
const loadPrev = async () => {
await userEvent.click(inspector.review.prevNav());
await resolveFns.fetch.success();
}
const startGrading = async () => {
await waitForRequestStatus(RequestKeys.fetchSubmission, RequestStates.completed);
await userEvent.click(await inspector.find.review.startGradingBtn());
await resolveFns.lock.success();
}
/*
test('submit pending', async (done) => {
done();
});
test('submit failed', async (done) => {
done();
});
*/
test('grade and submit',
async (done) => {
expect(await inspector.find.review.submitGradeBtn()).toBeVisible();
await setGrade();
checkGradingState();
await userEvent.click(inspector.review.rubric.submitGradeBtn());
await resolveFns.updateGrade.success();
checkGradeSuccess();
done();
},
);
test('grade, navigate, and return, maintaining gradingState',
async (done) => {
expect(await inspector.find.review.submitGradeBtn()).toBeVisible();
await setGrade();
checkGradingState();
await loadNext();
await waitForEqual(() => getState().grading.activeIndex, 1, 'activeIndex');
await loadPrev();
await waitForEqual(() => getState().grading.activeIndex, 0, 'activeIndex');
checkGradingState();
done();
},
);
});
});
});
});

110
src/test/inspector.js Normal file
View File

@@ -0,0 +1,110 @@
/* eslint-disable import/no-extraneous-dependencies */
import { within } from '@testing-library/react';
import fakeData from 'data/services/lms/fakeData';
import { gradingStatusTransform } from 'data/redux/grading/selectors/selected';
import appMessages from './messages';
const { rubricConfig } = fakeData.oraMetadata;
/**
* App inspector class providing methods to return elements from within
* the virtual DOM
* @props {Root Node} el - Root app render node.
*/
class Inspector {
constructor(el) {
this.el = el;
this.getByRole = this.el.getByRole;
this.getByText = this.el.getByText;
this.getByLabelText = this.el.getByLabelText;
this.findByText = this.el.findByText;
this.findByLabelText = this.el.findByLabelText;
}
/**
* Returns listView elements (immediate return methods)
*/
get listView() {
const table = () => this.getByRole('table');
const tableRows = () => table().querySelectorAll('tbody tr');
return {
table,
tableRows,
selectedBtn: (num) => this.getByText(`View selected responses (${num})`),
loadingResponses: () => this.getByText(appMessages.ListView.loadingResponses),
listCheckbox: (index) => (
within(tableRows().item(index)).getByTitle('Toggle Row Selected')
),
backLink: () => this.getByText(appMessages.ListView.backToResponsesLowercase).closest('a'),
reloadBtn: () => this.getByText(appMessages.ListView.reloadSubmissions).closest('button'),
};
}
/**
* Returns Review Modal elements (immediate return methods)
*/
get review() {
const modal = this.getByRole('dialog');
const modalEl = within(modal);
const { getByLabelText, getByText } = modalEl;
const rubricContent = () => modalEl.getByText(appMessages.Rubric.rubric).closest('div');
const rubricFeedback = () => (
within(rubricContent()).getByText(appMessages.Rubric.overallComments).closest('div')
);
const rubricCriterion = (index) => (
within(rubricContent()).getByText(rubricConfig.criteria[index].prompt).closest('div')
);
return {
modal: () => modal,
modalEl: () => modalEl,
nextNav: () => getByLabelText(appMessages.ReviewActionsComponents.loadNext),
prevNav: () => getByLabelText(appMessages.ReviewActionsComponents.loadPrevious),
hideRubricBtn: () => getByText(appMessages.ReviewActions.hideRubric),
showRubricBtn: () => getByText(appMessages.ReviewActions.showRubric),
loadingResponse: () => getByText(appMessages.ReviewModal.loadingResponse),
retryFetchLink: () => (
getByText(appMessages.ReviewErrors.reloadSubmission).closest('button')
),
username: (index) => getByText(fakeData.ids.username(index)),
gradingStatus: (submission) => (
getByText(appMessages.lms[gradingStatusTransform(submission)])
),
rubric: {
criteria: () => modal.querySelectorAll('.rubric-criteria'),
feedbackInput: () => within(rubricFeedback()).getByRole('textbox'),
submitGradeBtn: () => modalEl.getByText(appMessages.Rubric.submitGrade),
criterion: rubricCriterion,
criterionOption: (criterionIndex, optionIndex) => (
within(rubricCriterion(criterionIndex))
.getByText(rubricConfig.criteria[criterionIndex].options[optionIndex].label)
),
criterionFeedback: (index) => within(rubricCriterion(index)).getByRole('textbox'),
},
};
}
/**
* Returns promises for attempting to find elements within the DOM
*/
get find() {
return {
listView: {
viewAllResponsesBtn: () => this.findByText(appMessages.ListView.viewAllResponses),
loadErrorHeading: () => this.findByText(appMessages.ListView.loadErrorHeading),
},
review: {
prevNav: () => (
this.review.modalEl().findByLabelText(appMessages.ReviewActionsComponents.loadPrevious)
),
loadErrorHeading: () => this.findByText(appMessages.ReviewErrors.loadErrorHeading),
startGradingBtn: () => this.findByText(appMessages.ReviewActionsComponents.startGrading),
overrideGradeBtn: () => this.findByText(appMessages.ReviewActionsComponents.overrideGrade),
submitGradeBtn: () => this.findByText(appMessages.Rubric.submitGrade),
},
};
}
}
export default Inspector;

30
src/test/messages.js Normal file
View File

@@ -0,0 +1,30 @@
import InfoPopover from 'components/InfoPopover/messages';
import ResponseDisplay from 'containers/ResponseDisplay/messages';
import ResponseDisplayComponents from 'containers/ResponseDisplay/components/messages';
import CriterionContainer from 'containers/CriterionContainer/messages';
import ListView from 'containers/ListView/messages';
import ReviewActions from 'containers/ReviewActions/messages';
import ReviewActionsComponents from 'containers/ReviewActions/components/messages';
import Rubric from 'containers/Rubric/messages';
import ReviewModal from 'containers/ReviewModal/messages';
import ReviewErrors from 'containers/ReviewModal/ReviewErrors/messages';
import lms from 'data/services/lms/messages';
const mapMessages = (messages) => Object.keys(messages).reduce(
(acc, key) => ({ ...acc, [key]: messages[key].defaultMessage }),
{},
);
export default {
InfoPopover: mapMessages(InfoPopover),
ResponseDisplay: mapMessages(ResponseDisplay),
ResponseDisplayComponents: mapMessages(ResponseDisplayComponents),
CriterionContainer: mapMessages(CriterionContainer),
ListView: mapMessages(ListView),
ReviewActions: mapMessages(ReviewActions),
ReviewActionsComponents: mapMessages(ReviewActionsComponents),
Rubric: mapMessages(Rubric),
ReviewModal: mapMessages(ReviewModal),
ReviewErrors: mapMessages(ReviewErrors),
lms: mapMessages(lms),
};

7
src/test/utils.js Normal file
View File

@@ -0,0 +1,7 @@
export const mockSuccess = (returnValFn) => (...args) => (
new Promise((resolve) => resolve(returnValFn(...args)))
);
export const mockFailure = (returnValFn) => (...args) => (
new Promise((resolve, reject) => reject(returnValFn(...args)))
);

187
src/testUtils.js Normal file
View File

@@ -0,0 +1,187 @@
import react from 'react';
import { StrictDict } from 'utils';
/**
* Mocked formatMessage provided by react-intl
*/
export const formatMessage = (msg, values) => {
let message = msg.defaultMessage;
if (values === undefined) {
return message;
}
Object.keys(values).forEach((key) => {
// eslint-disable-next-line
message = message.replace(`{${key}}`, values[key]);
});
return message;
};
/**
* Mock a single component, or a nested component so that its children render nicely
* in snapshots.
* @param {string} name - parent component name
* @param {obj} contents - object of child components with intended component
* render name.
* @return {func} - mock component with nested children.
*
* usage:
* mockNestedComponent('Card', { Body: 'Card.Body', Form: { Control: { Feedback: 'Form.Control.Feedback' }}... });
* mockNestedComponent('IconButton', 'IconButton');
*/
export const mockNestedComponent = (name, contents) => {
if (typeof contents !== 'object') {
return contents;
}
const fn = () => name;
Object.defineProperty(fn, 'name', { value: name });
Object.keys(contents).forEach((nestedName) => {
const value = contents[nestedName];
fn[nestedName] = typeof value !== 'object'
? value
: mockNestedComponent(`${name}.${nestedName}`, value);
});
return fn;
};
/**
* Mock a module of components. nested components will be rendered nicely in snapshots.
* @param {obj} mapping - component module mock config.
* @return {obj} - module of flat and nested components that will render nicely in snapshots.
* usage:
* mockNestedComponents({
* Card: { Body: 'Card.Body' },
* IconButton: 'IconButton',
* })
*/
export const mockNestedComponents = (mapping) => Object.entries(mapping).reduce(
(obj, [name, value]) => ({
...obj,
[name]: mockNestedComponent(name, value),
}),
{},
);
/**
* Mock utility for working with useState in a hooks module.
* Expects/requires an object containing the state object in order to ensure
* the mock behavior works appropriately.
*
* Expected format:
* hooks = { state: { <key>: (val) => React.createRef(val), ... } }
*
* Returns a utility for mocking useState and providing access to specific state values
* and setState methods, as well as allowing per-test configuration of useState value returns.
*
* Example usage:
* // hooks.js
* import * as module from './hooks';
* const state = {
* isOpen: (val) => React.useState(val),
* hasDoors: (val) => React.useState(val),
* selected: (val) => React.useState(val),
* };
* ...
* export const exampleHook = () => {
* const [isOpen, setIsOpen] = module.state.isOpen(false);
* if (!isOpen) { return null; }
* return { isOpen, setIsOpen };
* }
* ...
*
* // hooks.test.js
* import * as hooks from './hooks';
* const state = new MockUseState(hooks)
* ...
* describe('state hooks', () => {
* state.testGetter(state.keys.isOpen);
* state.testGetter(state.keys.hasDoors);
* state.testGetter(state.keys.selected);
* });
* describe('exampleHook', () => {
* beforeEach(() => { state.mock(); });
* it('returns null if isOpen is default value', () => {
* expect(hooks.exampleHook()).toEqual(null);
* });
* it('returns isOpen and setIsOpen if isOpen is not null', () => {
* state.mockVal(state.keys.isOpen, true);
* expect(hooks.exampleHook()).toEqual({
* isOpen: true,
* setIsOpen: state.setState[state.keys.isOpen],
* });
* });
* afterEach(() => { state.restore(); });
* });
*
* @param {obj} hooks - hooks module containing a 'state' object
*/
export class MockUseState {
constructor(hooks) {
this.hooks = hooks;
this.oldState = null;
this.setState = {};
this.stateVals = {};
this.mock = this.mock.bind(this);
this.restore = this.restore.bind(this);
this.mockVal = this.mockVal.bind(this);
this.testGetter = this.testGetter.bind(this);
}
/**
* @return {object} - StrictDict of state object keys
*/
get keys() {
return StrictDict(Object.keys(this.hooks.state).reduce(
(obj, key) => ({ ...obj, [key]: key }),
{},
));
}
/**
* Replace the hook module's state object with a mocked version, initialized to default values.
*/
mock() {
this.oldState = this.hooks.state;
Object.keys(this.keys).forEach(key => {
this.hooks.state[key] = jest.fn(val => {
this.stateVals[key] = val;
return [val, this.setState[key]];
});
});
this.setState = Object.keys(this.keys).reduce(
(obj, key) => ({
...obj,
[key]: jest.fn(val => {
this.hooks.state[key] = val;
}),
}),
{},
);
}
/**
* Restore the hook module's state object to the actual code.
*/
restore() {
this.hooks.state = this.oldState;
}
/**
* Mock the state getter associated with a single key to return a specific value one time.
* @param {string} key - state key (from this.keys)
* @param {any} val - new value to be returned by the useState call.
*/
mockVal(key, val) {
this.hooks.state[key].mockReturnValueOnce([val, this.setState[key]]);
}
testGetter(key) {
test(`${key} state getter should return useState passthrough`, () => {
const testValue = 'some value';
const useState = (val) => ({ useState: val });
jest.spyOn(react, 'useState').mockImplementationOnce(useState);
expect(this.hooks.state[key](testValue)).toEqual(useState(testValue));
});
}
}

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

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

View File

@@ -0,0 +1,66 @@
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 type querying', () => {
expect(typeof dict).toEqual('object');
expect(dict.$$typeof).toEqual('object');
});
it('allows entry listing', () => {
expect(Object.entries(dict)).toEqual(Object.entries(rawDict));
});
describe('missing key', () => {
it('logs error with target, name, and error stack', () => {
// eslint-ignore-next-line no-unused-vars
const callBadKey = () => dict.fakeKey;
callBadKey();
expect(window.console.error.mock.calls).toEqual([
[{ target: dict, name: 'fakeKey' }],
[Error('invalid property "fakeKey"').stack],
]);
});
it('returns undefined', () => {
expect(dict.fakeKey).toEqual(undefined);
});
});
});

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

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

10
src/utils/keyStore.js Normal file
View File

@@ -0,0 +1,10 @@
import StrictDict from './StrictDict';
const keyStore = (collection) => StrictDict(
Object.keys(collection).reduce(
(obj, key) => ({ ...obj, [key]: key }),
{},
),
);
export default keyStore;

Some files were not shown because too many files have changed in this diff Show More