Compare commits
1 Commits
bw/compone
...
open-relea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
324bb97c15 |
65
.env
65
.env
@@ -1,34 +1,33 @@
|
||||
NODE_ENV='production'
|
||||
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=''
|
||||
APP_ID=''
|
||||
MFE_CONFIG_API_URL=''
|
||||
BASE_URL=null,
|
||||
LMS_BASE_URL=null,
|
||||
LOGIN_URL=null,
|
||||
LOGOUT_URL=null,
|
||||
CSRF_TOKEN_API_PATH=null,
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT=null,
|
||||
DATA_API_BASE_URL=null,
|
||||
SEGMENT_KEY=null,
|
||||
FEATURE_FLAGS={},
|
||||
ACCESS_TOKEN_COOKIE_NAME=null,
|
||||
CSRF_COOKIE_NAME='csrftoken',
|
||||
NEW_RELIC_APP_ID=null,
|
||||
NEW_RELIC_LICENSE_KEY=null,
|
||||
SITE_NAME=null,
|
||||
MARKETING_SITE_BASE_URL=null,
|
||||
SUPPORT_URL=null,
|
||||
CONTACT_URL=null,
|
||||
OPEN_SOURCE_URL=null,
|
||||
TERMS_OF_SERVICE_URL=null,
|
||||
PRIVACY_POLICY_URL=null,
|
||||
FACEBOOK_URL=null,
|
||||
TWITTER_URL=null,
|
||||
YOU_TUBE_URL=null,
|
||||
LINKED_IN_URL=null,
|
||||
REDDIT_URL=null,
|
||||
APPLE_APP_STORE_URL=null,
|
||||
GOOGLE_PLAY_URL=null,
|
||||
ENTERPRISE_MARKETING_URL=null,
|
||||
ENTERPRISE_MARKETING_UTM_SOURCE=null,
|
||||
ENTERPRISE_MARKETING_UTM_CAMPAIGN=null,
|
||||
ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM=null,
|
||||
|
||||
@@ -4,26 +4,22 @@ BASE_URL='localhost:1994'
|
||||
LMS_BASE_URL='http://localhost:18000'
|
||||
LOGIN_URL='http://localhost:18000/login'
|
||||
LOGOUT_URL='http://localhost:18000/login'
|
||||
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
|
||||
SITE_NAME='edX'
|
||||
|
||||
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=''
|
||||
SEGMENT_KEY=null
|
||||
FEATURE_FLAGS={}
|
||||
CSRF_COOKIE_NAME='csrftoken'
|
||||
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'
|
||||
ORDER_HISTORY_URL='http://localhost:1996/orders'
|
||||
TERMS_OF_SERVICE_URL='http://localhost:18000/terms-of-service'
|
||||
PRIVACY_POLICY_URL='http://localhost:18000/privacy-policy'
|
||||
FACEBOOK_URL='https://www.facebook.com'
|
||||
@@ -37,5 +33,3 @@ 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'
|
||||
APP_ID=''
|
||||
MFE_CONFIG_API_URL=''
|
||||
|
||||
29
.eslintrc.js
29
.eslintrc.js
@@ -1,30 +1,3 @@
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
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': ['*'] } }],
|
||||
|
||||
// TOD: Remove this rule once we have a better way to handle this.
|
||||
'import/no-import-module-exports': 'off',
|
||||
'no-import-assign': 'off',
|
||||
'default-param-last': 'off',
|
||||
},
|
||||
overrides: [{
|
||||
files: ['*.test.js'], rules: { 'no-import-assign': 'off' },
|
||||
}],
|
||||
});
|
||||
|
||||
config.settings = {
|
||||
'import/resolver': {
|
||||
node: {
|
||||
paths: ['src', 'node_modules'],
|
||||
extensions: ['.js', '.jsx'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
module.exports = createConfig('eslint');
|
||||
|
||||
6
.github/CODEOWNERS
vendored
6
.github/CODEOWNERS
vendored
@@ -1,6 +0,0 @@
|
||||
# Code owners for frontend-app-gradebook, editable gradebook micro-frontend (MFE)
|
||||
|
||||
# These owners will be the default owners for everything in
|
||||
# the repo. Unless a later match takes precedence, they will
|
||||
# be requested for review when someone opens a pull request.
|
||||
* @openedx/content-aurora
|
||||
29
.github/pull_request_template.md
vendored
29
.github/pull_request_template.md
vendored
@@ -1,29 +0,0 @@
|
||||
**TL;DR -** [ A short summary of what this PR does and why ]
|
||||
|
||||
JIRA: [JIRA-XXXX](https://openedx.atlassian.net/browse/JIRA-XXXX)
|
||||
|
||||
**What changed?**
|
||||
|
||||
- [ More in depth breakdown of changes ]
|
||||
- [ Peripheral things that got changed ]
|
||||
- [ etc... ]
|
||||
|
||||
**Developer Checklist**
|
||||
- [ ] Test suites passing
|
||||
- [ ] Documentation and test plan updated, if applicable
|
||||
- [ ] Received code-owner approving review
|
||||
- [ ] Bumped version number [package.json](../package.json)
|
||||
|
||||
**Testing Instructions**
|
||||
|
||||
[ How should a reviewer test this PR? ]
|
||||
|
||||
**Reviewer Checklist**
|
||||
|
||||
Collectively, these should be completed by reviewers of this PR:
|
||||
|
||||
- [ ] I've done a visual code review
|
||||
- [ ] I've tested the new functionality
|
||||
|
||||
|
||||
FYI: @openedx/content-aurora
|
||||
@@ -1,19 +0,0 @@
|
||||
# Run the workflow that adds new tickets that are either:
|
||||
# - labelled "DEPR"
|
||||
# - title starts with "[DEPR]"
|
||||
# - body starts with "Proposal Date" (this is the first template field)
|
||||
# to the org-wide DEPR project board
|
||||
|
||||
name: Add newly created DEPR issues to the DEPR project board
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
routeissue:
|
||||
uses: openedx/.github/.github/workflows/add-depr-ticket-to-depr-board.yml@master
|
||||
secrets:
|
||||
GITHUB_APP_ID: ${{ secrets.GRAPHQL_AUTH_APP_ID }}
|
||||
GITHUB_APP_PRIVATE_KEY: ${{ secrets.GRAPHQL_AUTH_APP_PEM }}
|
||||
SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }}
|
||||
@@ -1,20 +0,0 @@
|
||||
# This workflow runs when a comment is made on the ticket
|
||||
# If the comment starts with "label: " it tries to apply
|
||||
# the label indicated in rest of comment.
|
||||
# If the comment starts with "remove label: ", it tries
|
||||
# to remove the indicated label.
|
||||
# Note: Labels are allowed to have spaces and this script does
|
||||
# not parse spaces (as often a space is legitimate), so the command
|
||||
# "label: really long lots of words label" will apply the
|
||||
# label "really long lots of words label"
|
||||
|
||||
name: Allows for the adding and removing of labels via comment
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
add_remove_labels:
|
||||
uses: openedx/.github/.github/workflows/add-remove-label-on-comment.yml@master
|
||||
|
||||
64
.github/workflows/ci.yml
vendored
64
.github/workflows/ci.yml
vendored
@@ -1,64 +0,0 @@
|
||||
name: node_js CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
branches:
|
||||
- '**'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-20.04
|
||||
strategy:
|
||||
matrix:
|
||||
node: [16]
|
||||
npm: [8.5.x]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Setup Nodejs
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
|
||||
- name: Install npm 8.5.x
|
||||
run: npm install -g npm@${{ matrix.npm }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Unit Tests
|
||||
run: npm run test
|
||||
|
||||
- name: Validate Package Lock
|
||||
run: make validate-no-uncommitted-package-lock-changes
|
||||
|
||||
- name: Run Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Run Test
|
||||
run: npm run test
|
||||
|
||||
- name: Run 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: CI workflow failed in ${{github.repository}}
|
||||
to: masters-grades@edx.org
|
||||
from: github-actions <github-actions@edx.org>
|
||||
body: CI workflow in ${{github.repository}} failed! For details see "github.com/${{
|
||||
github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
10
.github/workflows/commitlint.yml
vendored
10
.github/workflows/commitlint.yml
vendored
@@ -1,10 +0,0 @@
|
||||
# Run commitlint on the commit messages in a pull request.
|
||||
|
||||
name: Lint Commit Messages
|
||||
|
||||
on:
|
||||
- pull_request
|
||||
|
||||
jobs:
|
||||
commitlint:
|
||||
uses: openedx/.github/.github/workflows/commitlint.yml@master
|
||||
13
.github/workflows/lockfileversion-check.yml
vendored
13
.github/workflows/lockfileversion-check.yml
vendored
@@ -1,13 +0,0 @@
|
||||
#check package-lock file version
|
||||
|
||||
name: Lockfile Version check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
version-check:
|
||||
uses: openedx/.github/.github/workflows/lockfileversion-check.yml@master
|
||||
33
.github/workflows/npm-publish.yml
vendored
33
.github/workflows/npm-publish.yml
vendored
@@ -1,33 +0,0 @@
|
||||
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@v1
|
||||
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
|
||||
12
.github/workflows/self-assign-issue.yml
vendored
12
.github/workflows/self-assign-issue.yml
vendored
@@ -1,12 +0,0 @@
|
||||
# This workflow runs when a comment is made on the ticket
|
||||
# If the comment starts with "assign me" it assigns the author to the
|
||||
# ticket (case insensitive)
|
||||
|
||||
name: Assign comment author to ticket if they say "assign me"
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
self_assign_by_comment:
|
||||
uses: openedx/.github/.github/workflows/self-assign-issue.yml@master
|
||||
12
.github/workflows/update-browserslist-db.yml
vendored
12
.github/workflows/update-browserslist-db.yml
vendored
@@ -1,12 +0,0 @@
|
||||
name: Update Browserslist DB
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * 1'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
update-browserslist:
|
||||
uses: openedx/.github/.github/workflows/update-browserslist-db.yml@master
|
||||
|
||||
secrets:
|
||||
requirements_bot_github_token: ${{ secrets.requirements_bot_github_token }}
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -1,12 +1,11 @@
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
.idea
|
||||
node_modules
|
||||
npm-debug.log
|
||||
coverage
|
||||
|
||||
dist/
|
||||
src/i18n/transifex_input.json
|
||||
temp/babel-plugin-react-intl
|
||||
|
||||
### pyenv ###
|
||||
.python-version
|
||||
@@ -15,11 +14,3 @@ temp/babel-plugin-react-intl
|
||||
*~
|
||||
*.swo
|
||||
*.swp
|
||||
|
||||
### Development environments ###
|
||||
.idea
|
||||
.vscode
|
||||
|
||||
### transifex ###
|
||||
src/i18n/transifex_input.json
|
||||
temp
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
.eslintignore
|
||||
.eslintrc.json
|
||||
.gitignore
|
||||
.travis.yml
|
||||
docker-compose.yml
|
||||
Dockerfile
|
||||
Makefile
|
||||
|
||||
28
.travis.yml
Executable file
28
.travis.yml
Executable file
@@ -0,0 +1,28 @@
|
||||
language: node_js
|
||||
node_js: 12
|
||||
notifications:
|
||||
email:
|
||||
recipients:
|
||||
- masters-grades@edx.org
|
||||
on_success: never
|
||||
on_failure: always
|
||||
webhooks: https://www.travisbuddy.com/
|
||||
on_success: never
|
||||
before_install:
|
||||
- npm install -g greenkeeper-lockfile@1.14.0
|
||||
install:
|
||||
- npm ci
|
||||
before_script: greenkeeper-lockfile-update
|
||||
after_script: greenkeeper-lockfile-upload
|
||||
script:
|
||||
- make validate-no-uncommitted-package-lock-changes
|
||||
- npm run lint
|
||||
- npm run test
|
||||
- npm run build
|
||||
after_success:
|
||||
- npm run travis-deploy-once "npm run semantic-release"
|
||||
- npm run coveralls
|
||||
env:
|
||||
global:
|
||||
- secure: bBLQZVw1aVUxB7GFNXGrdKeztyFrCCJusVgFcSuej9S4qmj9/jrVsEc9dEcH+BMS+b49+SvILoxzd6ZYLaRygQLzevnO1/dX596DeCKVK48PTTZRsNyafaSMCkxNKqEmRcA9hYL52xJJ5GpKo7ViWsFy8VFgUfZEJxQi8/lYbfQ1vlXRpo2LJfJh09v85roSXdQmajyGJ1Dz6elcwUX5B+BgXmIHizJXUMfFci61xTEZmgKtfeCiwFQA5pCvVMHBQhgySqT2N3eRESzRt2jAfAdcRKBYXS0rwKymdlL1ZF349Jm8xwtqm19Fwsut21181Lnn6FmccMWhQ7man3WH1xfT0ahmHNs1KJMyZcwRJd/gDfbd6iD3LB9Pt9hEQ00Qh/m7MYeahMxTEL9bp2TyILi8cTP91jeBUHCExCdv2jRrUQEnUS5vZUYRdM8CR2DLoLmNh3APndKzwgr5U8rh6RdhbQBJp97Hb/YYVrBiP2atLJAaYPY/xEQHK/YoXelQgiZ6wHBMV+tF/L0ZRn7KyVWdkbBKWfbEjRKbEJD9WD+V7HayMR81tm5CSqlrG8mTvSy2boIGiX14GV11ZEfMj5bjb6W41BW+QGqQerZvmwk/4ywe304X85PD0OBhIYPRzeLIi0Gt6lD1aOpVxgm4M03tdgYQzCPWRPq32CB+1IA=
|
||||
- secure: w1d/E+cc4+Bf017Jpp9YsKBzLSZw9sqKZGeM2tNrO6eJZbMJqfKTmfUrRw8BoLh1Z8YRkHF7RADDy3ln7XEdeAX3j9OoC3Cz0zN6iDX6TPcI461NuOIscJYb4tyFcuWm6FhgVlBAlo/BI3q+zqKwjfWuDaORpk6+haacCmvTe5V0vWhY+MYT7M+LfnKeKVzhI4magGt8jPTE21oziIFwCqCCjJc4+AmsWoWTzU0Q7Db0DZiJnLXFfXybLbkedAgJmcSgEGZCSpaZIOkX0/Lbazsz1Ky4KASfkrYT1Z5iKQ8TE3skmx1IIu+1egN8iBbdrY+NhvV24RkT+rpUvD7TBIHTrjQ5JYLe0kGjN70vG7YlKgjNSyTjkrEd7fCKpuIol3DVjBRz3tV5aCl0t/A8mIPqKyNI94MamWsExpqsxgcb9vBVno5caZvD8ZXNrGNqanB3MSoLGxZTLKif9u+AZfLnB3xtjaiJg3/BNoWaOBPlp/M6BvGIGHElwvLrAhUvl8wzrwJcQQWpmRMh0b6enr6Y7ox/mGGs7NBCT+CNKEsWeCfY4thZzgi6/GocXyqdTpXMkNSI1PDoPmi+vKafBd+7aAYbcUlJBTU6TAxyncln0tF2JF+ghTZ0v8nNzEQ9VmV4ddyoOHx6YnHvEcenWZGMROQnMCVifyDbaHpPbPI=
|
||||
@@ -1,9 +0,0 @@
|
||||
[main]
|
||||
host = https://www.transifex.com
|
||||
|
||||
[o:open-edx:p:edx-platform:r:frontend-app-gradebook]
|
||||
file_filter = src/i18n/messages/<lang>.json
|
||||
source_file = src/i18n/transifex_input.json
|
||||
source_lang = en
|
||||
type = KEYVALUEJSON
|
||||
|
||||
61
Makefile
Normal file → Executable file
61
Makefile
Normal file → Executable file
@@ -1,64 +1,9 @@
|
||||
npm-install-%: ## install specified % npm package
|
||||
npm install $* --save-dev
|
||||
git add package.json
|
||||
export TRANSIFEX_RESOURCE = frontend-app-gradebook
|
||||
transifex_langs = "ar,de,es_419,fa_IR,fr,fr_CA,hi,it,pt,ru,uk,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/@edx/reactifex/bash_scripts/get_hashed_strings_v3.sh
|
||||
# Writing out comments to file...
|
||||
$(transifex_utils) $(transifex_temp) --comments --v3-scripts-path
|
||||
# Pushing comments to Transifex...
|
||||
./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh
|
||||
|
||||
# Pulls translations from Transifex.
|
||||
pull_translations:
|
||||
tx pull -t -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
|
||||
|
||||
test:
|
||||
npm run test
|
||||
|
||||
68
README.md
68
README.md
@@ -1,55 +1,18 @@
|
||||
[](https://travis-ci.com/edx/frontend-app-gradebook)
|
||||
[](https://app.codecov.io/gh/openedx/frontend-app-gradebook)
|
||||
[](https://travis-ci.org/edx/frontend-app-gradebook) [](https://coveralls.io/github/edx/frontend-app-gradebook)
|
||||
[](@edx/frontend-app-gradebook)
|
||||
[](@edx/frontend-app-gradebook)
|
||||
[](@edx/frontend-app-gradebook)
|
||||
[](https://github.com/semantic-release/semantic-release)
|
||||
|
||||
# Gradebook
|
||||
# gradebook
|
||||
|
||||
Gradebook allows course staff to view, filter, and override subsection grades for a course. Additionally for Masters courses, Gradebook enables bulk management of subsection grades.
|
||||
Please tag **@edx/educator-neem** on any PRs or issues.
|
||||
|
||||
Jump to:
|
||||
## Introduction
|
||||
|
||||
- [Should I use Gradebook in my course?](#should-i-use-gradebook-in-my-course)
|
||||
- [Quickstart](#quickstart)
|
||||
The front-end of our editable Gradebook feature.
|
||||
|
||||
For existing documentation see:
|
||||
|
||||
- Basic Usage: [Review Learner Grades (read-the-docs)](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/student_progress/course_grades.html#review-learner-grades-on-the-instructor-dashboard)
|
||||
- Bulk Grade Management: [Override Learner Subsection Scores in Bulk (read-the-docs)](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/student_progress/course_grades.html#override-learner-subsection-scores-in-bulk)
|
||||
|
||||
## Should I use Gradebook in my course?
|
||||
|
||||
### What does this offer over the legacy gradebook?
|
||||
|
||||
The micro-frontend offers a great deal more granularity when searching for problems, an easy interface for editing grades, an
|
||||
audit trail for seeing who edited what grade and what reason they gave (if any) for doing so.
|
||||
|
||||
UsageProblems can be filtered by student as in the traditional gradebook, but can also be filtered by scores to see who
|
||||
scored within a certain range, and by assignment types (note: Not problem types, but categories like ‘Exams’ or
|
||||
‘Homework’).
|
||||
|
||||
### What does the legacy gradebook offer that this project does not?
|
||||
|
||||
This project does not (yet, at least) create any graphs, which the traditional gradebook does. It also does not give
|
||||
quick links to the problems for the instructor to visit. It expects the instructor to be familiar with the problems they
|
||||
are grading and which unit they refer to.
|
||||
|
||||
The gradebook is expected to be much more performant for larger numbers of students as well. The Instructor Dashboard
|
||||
link for the legacy gradebook reports that "this feature is available only to courses with a small number of enrolled
|
||||
learners." However, this project comes with no such warning.
|
||||
|
||||
### Who should not change to this gradebook?
|
||||
|
||||
Groups whose instructors need not ever manually override grades do not need this project, but may not be any worse off
|
||||
depending on their needs. Instructors that expect to review grades infrequently enough that not having a direct link
|
||||
to the problem in question will have a worse UX than the legacy gradebook provides. Instructors that rely on the graphs
|
||||
generated by the current gradebook might find the lack of autogenerated graphs to be frustrating.
|
||||
|
||||
## Quickstart
|
||||
|
||||
### Installation
|
||||
## Usage
|
||||
|
||||
To install gradebook into your project:
|
||||
```
|
||||
@@ -58,7 +21,7 @@ npm i --save @edx/frontend-app-gradebook
|
||||
|
||||
## Running the UI Standalone
|
||||
|
||||
To install the project please refer to the [`edX Developer Stack`](https://github.com/openedx/devstack) instructions.
|
||||
To install the project please refer to the [`edX Developer Stack`](https://github.com/edx/devstack) instructions.
|
||||
|
||||
The web application runs on port **1994**, so when you go to `http://localhost:1994/course-v1:edX+DemoX+Demo_Course` you should see the UI (assuming you have such a Demo Course in your devstack). Note that you always have to provide a course id to actually see a gradebook.
|
||||
|
||||
@@ -69,7 +32,7 @@ Note that starting the container executes the `npm run start` script which will
|
||||
## Configuring for local use in edx-platform
|
||||
|
||||
Assuming you've got the UI running at `http://localhost:1994`, you can configure the LMS in edx-platform
|
||||
to point to your local gradebook from the instructor dashboard by putting this setting in `lms/env/private.py`:
|
||||
to point to your local gradebook from the instructor dashboard by putting this settings in `lms/env/private.py`:
|
||||
```
|
||||
WRITABLE_GRADEBOOK_URL = 'http://localhost:1994'
|
||||
```
|
||||
@@ -81,15 +44,10 @@ check the ``enabled`` and ``enabled for all courses`` boxes.
|
||||
|
||||
2. Waffle > Switches. Add the ``grades.assume_zero_grade_if_absent`` switch and make it active.
|
||||
|
||||
3. Waffle_utils > Waffle flag course overrides. Activate waffle flags for courses where you want to enable Gradebook functionality:
|
||||
- Enable Gradebook by adding the ``grades.writable_gradebook`` add checking the ``enabled`` box.
|
||||
- Enable Bulk Grade Management by adding the ``grades.bulk_management`` flag and checking the ``enabled`` box.
|
||||
|
||||
Alternatively, you could add these as regular waffle flags to enable the functionality for all courses.
|
||||
|
||||
**NOTE:** IF the above flags are not configured correctly, the gradebook may appear to work, but will return bogus
|
||||
numbers for grades. If your gradebook isn't accepting your changes, or the changes aren't resulting in sane,
|
||||
recalculated grade values, verify you've set all flags correctly.
|
||||
3. Waffle_utils > Waffle flag course overrides. You want to activate this flag for any course
|
||||
in which you'd like to enable the gradebook. Add a course override flag using a course id and the flag name
|
||||
``grades.writable_gradebook``. Make sure to check the ``enabled`` box. Alternatively, you could add this as a
|
||||
regular waffle flag to enable the gradebook for all courses.
|
||||
|
||||
## Running tests
|
||||
|
||||
@@ -118,4 +76,4 @@ running gradebook container.
|
||||
|
||||
## Authentication with backend API services
|
||||
|
||||
See the [`@edx/frontend-auth`](https://github.com/edx-unsupported/frontend-auth) repo for information about securing routes in your application that require user authentication.
|
||||
See the [`@edx/frontend-auth`](https://github.com/edx/frontend-auth) repo for information about securing routes in your application that require user authentication.
|
||||
|
||||
BIN
assets/edx-footer.png
Normal file
BIN
assets/edx-footer.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
BIN
assets/edx-sm.png
Normal file
BIN
assets/edx-sm.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
77
documentation/.travis.yml.md
Executable file
77
documentation/.travis.yml.md
Executable file
@@ -0,0 +1,77 @@
|
||||
# Travis Configuration
|
||||
|
||||
Your project might have different build requirements - however, this project's `.travis.yml` configuration is supposed to represent a good starting point.
|
||||
|
||||
## Node JS Version
|
||||
|
||||
The minimum `Node` and `npm` versions that edX supports is `8.9.3` and `5.5.1`, respectively.
|
||||
|
||||
## Caching node_modules
|
||||
|
||||
While [the `Travis` blog](https://blog.travis-ci.com/2016-11-21-travis-ci-now-supports-yarn) recommends
|
||||
|
||||
```yaml
|
||||
cache:
|
||||
directories:
|
||||
- node_modules
|
||||
```
|
||||
|
||||
this causes issues when testing different versions of `Node` because [`node_modules` will store the compiled native modules](https://stackoverflow.com/a/42523517/5225575).
|
||||
|
||||
Caching the `~/.npm` directory avoids storing these native modules.
|
||||
|
||||
## Notifications
|
||||
|
||||
This project uses a service called [`TravisBuddy`](https://www.travisbuddy.com/), which provides Travis build context within a PR via webhooks (configured only to add feedback for build failures).
|
||||
|
||||

|
||||
|
||||
## Installing `greenkeeper-lockfile`
|
||||
|
||||
As explained in [the `Greenkeeper` documentation](https://greenkeeper.io/docs.html#greenkeeper-step-by-step), `Greenkeeper` is a service that keeps track of your project's dependencies, and will, for example, automatically open PRs with an updated `package.json` file when the latest version of a dependency is a major version ahead of the existing dependency version in your `package.json` file.
|
||||
|
||||
This automated updating is great, but `Greenkeeper` does not update your `package-lock.json` file, just your `package.json` file. This makes sense, as the only way to update the `package-lock.json` file would be to run `npm install` when building your project, using the latest `package.json`, and then committing the updated `package-lock.json` file.
|
||||
|
||||
This is essentially what you have to do manually when `Greenkeeper` opens a PR - `git checkout` the branch, `npm install` locally, `git commit` the `package-lock.json` changes, and then `git push` those changes to the `Greenkeeper` branch on `origin`. It's fun probably only the first time, and even then it gets old, fast.
|
||||
|
||||
What [`greenkeeper-lockfile`](https://github.com/greenkeeperio/greenkeeper-lockfile) does is that it automates the previous steps as part of the build process.
|
||||
|
||||
It will
|
||||
* Check that the branch is a `Greenkeeper` branch
|
||||
* Update the lockfile
|
||||
* Push a commit with the updated lockfile back to the Greenkeeper branch
|
||||
|
||||
This is why it's important to install `greenkeeper-lockfile` in the `before_install` step, and since it's used exclusively only in the Travis Build, why it's not part of the package's dependencies.
|
||||
|
||||
## Scripts
|
||||
|
||||
Most of the `script`s are self-explanatory - you probably want to fail a build if there are linting violations, or if any tests don't pass, or if it cannot compile your files.
|
||||
|
||||
However, there are a couple additional `script`s that might seem less self-explanatory.
|
||||
|
||||
### What the heck is `make validate-no-uncommitted-package-lock-changes`?
|
||||
|
||||
There are only two requirements for a good `make target` name
|
||||
|
||||
1. Definitely make it really verbose so people can't remember what it's called
|
||||
2. Definitely don't not use a double-negative
|
||||
|
||||
What `make validate-no-uncommitted-package-lock-changes` does is `git diff`s for any `package-lock.json` file changes in your project. It's important to remember that all build `script`s are executed in Travis *after* the `install` step (aka post-`npm install`).
|
||||
|
||||
This is important because `npm` uses the pinned dependencies in your `package-lock.json` file to build the `node_modules` directory. However, the dependencies defined within the `package.json` file can be modified manually, for example, to become misaligned with the dependencies defined within the `package-lock.json`. So when `npm install` executes, the `package-lock.json` file will be updated to mirror the modified `package.json` changes.
|
||||
|
||||
However, when these changes surface within a Travis build, this indicates differing dependency expectations between the committed `package.json` file and the `package-lock.json` file, which is a good reason to fail a build.
|
||||
|
||||
### What is this `npm run is-es5` check?
|
||||
|
||||
This project outputs production files to the `dist` folder. The `npm script`, `npm run is-es5`, checks the JavaScript files in the `dist` folder to make sure that they are `ES5`-compliant.
|
||||
|
||||
This check is important because `ES5` JavaScript has [greater browser compatibility](http://kangax.github.io/compat-table/es5/) than [`ES2015+`](http://kangax.github.io/compat-table/es6/) - particularly for `IE11`.
|
||||
|
||||
### `deploy` step
|
||||
|
||||
How your project deploys will probably differ between the cookie cutter and your own application.
|
||||
|
||||
For demonstrational purposes, the cookie cutter deploys to GitHub pages using [`Travis`'s GitHub pages configuration](https://docs.travis-ci.com/user/deployment/pages/).
|
||||
|
||||
Your application might deploy to an `S3` bucket or to `npm`.
|
||||
@@ -1,40 +0,0 @@
|
||||
# CI Configuration
|
||||
|
||||
Your project might have different build requirements - however, this project's `.github/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.
|
||||
|
||||
## Scripts
|
||||
|
||||
Most of the `script`s are self-explanatory - you probably want to fail a build if there are linting violations, or if any tests don't pass, or if it cannot compile your files.
|
||||
|
||||
However, there are a couple additional `script`s that might seem less self-explanatory.
|
||||
|
||||
### What the heck is `make validate-no-uncommitted-package-lock-changes`?
|
||||
|
||||
There are only two requirements for a good `make target` name
|
||||
|
||||
1. Definitely make it really verbose so people can't remember what it's called
|
||||
2. Definitely don't not use a double-negative
|
||||
|
||||
What `make validate-no-uncommitted-package-lock-changes` does is `git diff`s for any `package-lock.json` file changes in your project. It's important to remember that all build `script`s are executed in CI *after* the `install` step (aka post-`npm install`).
|
||||
|
||||
This is important because `npm` uses the pinned dependencies in your `package-lock.json` file to build the `node_modules` directory. However, the dependencies defined within the `package.json` file can be modified manually, for example, to become misaligned with the dependencies defined within the `package-lock.json`. So when `npm install` executes, the `package-lock.json` file will be updated to mirror the modified `package.json` changes.
|
||||
|
||||
However, when these changes surface within a CI build, this indicates differing dependency expectations between the committed `package.json` file and the `package-lock.json` file, which is a good reason to fail a build.
|
||||
|
||||
### What is this `npm run is-es5` check?
|
||||
|
||||
This project outputs production files to the `dist` folder. The `npm script`, `npm run is-es5`, checks the JavaScript files in the `dist` folder to make sure that they are `ES5`-compliant.
|
||||
|
||||
This check is important because `ES5` JavaScript has [greater browser compatibility](http://kangax.github.io/compat-table/es5/) than [`ES2015+`](http://kangax.github.io/compat-table/es6/) - particularly for `IE11`.
|
||||
|
||||
### `deploy` step
|
||||
|
||||
How your project deploys will probably differ between the cookie cutter and your own application.
|
||||
|
||||
For demonstrational purposes, the cookie cutter deploys to GitHub pages using [ GitHUb CI ].
|
||||
|
||||
Your application might deploy to an `S3` bucket or to `npm`.
|
||||
@@ -5,7 +5,7 @@ Context
|
||||
=======
|
||||
|
||||
The LMS Grades API exposes a set of Gradebook-related endpoints:
|
||||
https://github.com/openedx/edx-platform/blob/master/lms/djangoapps/grades/api/v1/gradebook_views.py
|
||||
https://github.com/edx/edx-platform/blob/master/lms/djangoapps/grades/api/v1/gradebook_views.py
|
||||
The ``bulk-update`` endpoint defined therein allows for the creation/modification of subsection
|
||||
grades for multiple users and sections in a single request. This allows clients of the API to limit
|
||||
the number of network requests made and to more easily manage client-side data. Moreover,
|
||||
@@ -13,7 +13,7 @@ the course grade updates that occur during calls to this API are synchronous - t
|
||||
is completed before a response is given to the client.
|
||||
|
||||
For decisions made about the implementation of this API, see:
|
||||
https://github.com/openedx/edx-platform/blob/master/lms/djangoapps/grades/docs/decisions/0001-gradebook-api.rst
|
||||
https://github.com/edx/edx-platform/blob/master/lms/djangoapps/grades/docs/decisions/0001-gradebook-api.rst
|
||||
|
||||
Decision
|
||||
========
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
# Test Plan
|
||||
|
||||
Designed to be a catalog of major Gradebook workflows to aid in testing. This should be kept up-to-date with new feature changes.
|
||||
|
||||
## Quickstart
|
||||
|
||||
Check that the items below are complete and continue to [Workflow Tests](#workflow-tests). Otherwise, followed the detailed setup in [test-setup.md](./test-setup.md).
|
||||
|
||||
- [ ] Course set up with graded content.
|
||||
- [ ] Gradebook & feature toggles set up for course.
|
||||
- [ ] Course has a Master's track for testing Master's-only features.
|
||||
- [ ] Different types of students enrolled in course (e.g. Master's, TA's).
|
||||
- [ ] Gradebook running.
|
||||
|
||||
## Workflow Tests
|
||||
|
||||
Visit a course as an instructor/staff then **Instructor** tab > **Student Admin** sub-tab > click **Show Gradebook**. Should navigate to `<root-url>:1994/{course-id}`.
|
||||
|
||||
Confirm the following workflows:
|
||||
|
||||
- [ ] Grades table results can be filtered from the "Filter" panel.
|
||||
- The "Edit Filters" button renders for all courses.
|
||||
- Click the "Edit Filters" button to open the "Filter" panel.
|
||||
- [ ] Filter panel shows the sections: Assignments, Overall Grade, Student Groups, Include Course Team Members.
|
||||
- **Note:** Filters are cumulative and act with other applied filters.
|
||||
- Assignments pane
|
||||
- [ ] Applying the "Assignment Types" filter limits the assignment columns show in the grades table to the selected assignment types.
|
||||
- [ ] Applying an "Assignment" filter shows only the selected assignment column in the grades table.
|
||||
- [ ] With an "Assignment" filter already selected, setting a "Min/Max Grade" filter shows only student rows with grades for the assignment within the filtered range.
|
||||
- Overall Grade pane
|
||||
- [ ] Applying a "Min/Max Grade" filter shows only students with Total Course Grades within the filtered range.
|
||||
- Student Groups pane
|
||||
- [ ] Applying a "Tracks" filter shows only student rows matching the selected track.
|
||||
- [ ] Applying a "Cohorts" filter shows only student rows matching the selected cohort.
|
||||
- Include Course Team Members pane
|
||||
- By default, any user with a course role (e.g. staff, beta testers, TA's) are hidden from the grades table.
|
||||
- [ ] Selecting "Include Course Team Members" shows course team members in the grades table.
|
||||
- [ ] Deselecting "Include Course Team Members" shows only students without course roles in the grades table.
|
||||
|
||||
- [ ] Users can be searched/filtered using the Search box.
|
||||
- The Search Box renders for all courses.
|
||||
- [ ] Entering characters into the Search Box filters students on top of already applied filters.
|
||||
- Note: characters can appear anywhere in a name or email, even though emails are only shown for masters-track students. It doesn't appear that search actually works for student keys.
|
||||
|
||||
- [ ] Grades table "Score View" allows selecting how scores are displayed.
|
||||
- [ ] The "Score View" selector renders with the options: Absolute, Percent.
|
||||
- [ ] Changing the "Score View" dropdown to "Percent" shows scores as percentages in the assignment columns (note that scores can be over 100%).
|
||||
- [ ] Changing the "Score View" dropdown to "Absolute" shows scores as {awarded-points}/{possible-points} values, rounded to 2 decimal points.
|
||||
- [ ] For unattempted problems score shows '0'.
|
||||
- [ ] For attempted problems, score always shows an {awarded-points}/{possible-points} value.
|
||||
- [ ] "Total Course Grade" always shows scores as percentages (including 0% for unattempted).
|
||||
|
||||
- [ ] Grades table displays correctly.
|
||||
- [ ] The grades table shows with columns: Username, Email, {numbered-assignments}, Total.
|
||||
- [ ] Usernames appear in the "Username" column.
|
||||
- [ ] Student external keys (where applicable) also appear in the "Username" column.
|
||||
- [ ] Student emails appear in the "Email" column only for masters-track students.
|
||||
- [ ] Assignment scores show in their respective assignment columns.
|
||||
- [ ] Total course grade shows in the "Total Course Grade" column.
|
||||
|
||||
- [ ] Grade overrides can be applied.
|
||||
- [ ] Clicking on an assignment score in the grades table opens the "Edit Grades" modal.
|
||||
- [ ] "Assignment name", "Student username", "Original grade", and "Current grade" display in the modal.
|
||||
- [ ] A history of grade overrides including "Date", "Grader", "Reason", and "Adjusted Grade" shows (if the subsection was previously overridden).
|
||||
- [ ] An entry with the current time appears in the table with areas to enter adjusted grades and reasons for adjusting.
|
||||
- Enter an "Adjusted Grade" and "Reason" for the override.
|
||||
- [ ] Modal can be navigated away from by clicking outside the modal, clicking the "x" button, or hitting "Cancel".
|
||||
- [ ] Clicking "Save Grade" applies the override, shows the successful "grade has been edited" banner and updates score in grades table (may take a few seconds).
|
||||
- [ ] Opening back up the "Edit Grades" modal shows the change as an entry in the override history table.
|
||||
|
||||
- [ ] *Master's (or selectively-enabled) only*: "Bulk Management" allows overriding grades in bulk.
|
||||
- Open a non-masters-track course.
|
||||
- [ ] Verify that the "Bulk Management" button does not appear.
|
||||
- [ ] Verify that the "Download Interventions" interface does not appear.
|
||||
- Open a masters-track course.
|
||||
- [ ] Verify that the "Bulk Management History" button appears at the right of the header.
|
||||
- [ ] Verify that the "Download Interventions" interface appears.
|
||||
- [ ] Verify that the "Download Grades" button appears.
|
||||
- [ ] Verify that the "Import Grades" button appears.
|
||||
- Click the "Download Grades" button. This downloads existing student/assignment info.
|
||||
- [ ] Open the downloaded CSV and verify that students and assignments in the file match applied filters/searches.
|
||||
- Navigate to Bulk Management History tab.
|
||||
- [ ] Clicking the "ViewBulk Management History" tab shows the Bulk Management History view.
|
||||
- [ ] The bulk management history table appears with columns: "Gradebook", "Download Summary", "Who", "When".
|
||||
- [ ] Previous bulk management imports (if applicable) appear in the table.
|
||||
- Add values in the "new_override-{subsection-short-id}" columns for student grades to be overridden and save the CSV file.
|
||||
- Navigate back to Gradebook view
|
||||
- Click the "Import Grades" button and select the modified CSV file.
|
||||
- [ ] Verify that the "CSV processing" banner appears.
|
||||
- Wait for processing to complete and reload the page. (Can take seconds to minutes depending on environment and size of the override.)
|
||||
- [ ] Verify that Import Grades Success toast appears (and disappears after 5 seconds)
|
||||
- Navigate back to the "Bulk Management History" view.
|
||||
- [ ] Verify that a new entry appears in the results table indicating how many students were affected by the bulk grade change.
|
||||
- Click the "Download Summary" link to see the summary of changes from the bulk grade changes.
|
||||
- [ ] Verify that students are shown with modified subsections and actions: "No Action" for unchanged users, "Success" for successful overrides.
|
||||
|
||||
- [ ] *Masters only*: Interventions report shows student activity in the course.
|
||||
- Open a non-masters-track course.
|
||||
- [ ] Verify that the "View Bulk Management History" button does not appear.
|
||||
- [ ] Verify that the "Interventions" interface does not appear.
|
||||
- [ ] Verify that the "Download Grades" and "Import Grades" buttons do not appear.
|
||||
- Open a masters-track course.
|
||||
- [ ] Verify that the "View Bulk Management History" button appears at the right of the header.
|
||||
- [ ] Verify that the "Interventions" interface appears.
|
||||
- [ ] Verify that the "Download Grades" and "Import Grades" buttons appear.
|
||||
- Click on the "Download Interventions" button to generate a CSV students and activity info.
|
||||
- Open the interventions report and verify student info and activity info appear.
|
||||
@@ -1,58 +0,0 @@
|
||||
# Test Setup
|
||||
|
||||
Instructions for setting up environments and data for testing Gradebook.
|
||||
|
||||
## Set up a course with graded content
|
||||
|
||||
A course with graded content is the first prerequisite to testing. Use an existing course (e.g. the DemoX Demonstration Course in Devstack) or see [Building and Running an edX Course > Developing Your Course](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/developing_course/index.html) for notes on how to develop a course from scratch.
|
||||
|
||||
Notably, the course needs a grading policy and subsections with scoreable content.
|
||||
After creating subsections with content, they need to be configured with an "Assignment Type" to be included in grading.
|
||||
|
||||
Suggested resources:
|
||||
- [Establishing a Grading Policy For Your Course](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/grading/index.html)
|
||||
- [Adding Exercises and Tools](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/grading/index.html)
|
||||
- [Set the Assignment Type and Due Date for a Subsection](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/developing_course/course_subsections.html#set-the-assignment-type-and-due-date-for-a-subsection)
|
||||
|
||||
## Enable Gradebook for course
|
||||
|
||||
See README.md #Quickstart for more detailed instructions.
|
||||
|
||||
As an admin user, visit Django Admin (`{lms-url}/admin`) to modify features.
|
||||
- In Grades > Persistent Grades Enabled flag, click "Add persistent grades enabled flag"
|
||||
- [ ] Enable the flag globally or for the course and click "Save"
|
||||
- In Django-Waffle > Switches, click "Add switch"
|
||||
- [ ] Set name to `grades.assume_zero_grade_if_absent`, select "Active", and click "Save"
|
||||
- In Waffle_Utils > Waffle flag course overrides:
|
||||
- [ ] Add a new flag called `grades.writeable_gradebook`, select "Force On", and enable it for your course
|
||||
|
||||
## Enable Bulk Management
|
||||
|
||||
Bulk Management is an added feature to allow modifying grades in bulk via CSV upload. Bulk Management is default enabled for Master's track courses but can be selectively enabled for other courses with a waffle flag following the steps below.
|
||||
|
||||
- In Waffle_Utils > Waffle flag course overrides:
|
||||
- [ ] Add a new flag called `grades.bulk_management`, select "Force On", and enable it for your course.
|
||||
|
||||
## Create a Master's track for testing Master's-only features
|
||||
|
||||
[source](https://openedx.atlassian.net/wiki/spaces/MS/pages/1453818012/Add+a+learner+into+a+master+s+track)
|
||||
|
||||
Add a Master's track in your course:
|
||||
- As an admin user, go to Django Admin (`{lms-url}/admin`) > Course Modes and add a new course mode
|
||||
- Set the Mode to "Master's"
|
||||
- Set any valid price and currency values
|
||||
- Click "Save"
|
||||
|
||||
Enroll a student in the Master's track:
|
||||
- As a staff/admin user, go to `{lms-url}/support/enrollment`
|
||||
- Search for the username or email of student to enroll
|
||||
- In the results table row matching the user/course, click the "Change Enrollment" button
|
||||
- Select the "Master's" enrollment mode and click "Submit enrollment change"
|
||||
|
||||
## Setup different types of students in course
|
||||
|
||||
To fully test features the course should have at least:
|
||||
- [ ] An audit-track student
|
||||
- [ ] A master's-track student
|
||||
- [ ] A staff member
|
||||
- [ ] A non-staff user
|
||||
@@ -1,16 +0,0 @@
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
|
||||
module.exports = createConfig('jest', {
|
||||
setupFilesAfterEnv: [
|
||||
'<rootDir>/src/setupTest.js',
|
||||
],
|
||||
modulePaths: ['<rootDir>/src/'],
|
||||
snapshotSerializers: [
|
||||
'enzyme-to-json/serializer',
|
||||
],
|
||||
coveragePathIgnorePatterns: [
|
||||
'src/segment.js',
|
||||
'src/postcss.config.js',
|
||||
'testUtils', // don't unit test jest mocking tools
|
||||
],
|
||||
});
|
||||
@@ -1,9 +1,13 @@
|
||||
# This file describes this Open edX repo, as described in OEP-2:
|
||||
# http://open-edx-proposals.readthedocs.io/en/latest/oeps/oep-0002.html#specification
|
||||
|
||||
nick: grbk
|
||||
tags:
|
||||
- frontend-app
|
||||
- masters
|
||||
oeps:
|
||||
oep-2: true # Repository metadata
|
||||
openedx-release: {ref: master}
|
||||
owner:
|
||||
type: team
|
||||
team: edx/masters-devs-gta
|
||||
|
||||
63861
package-lock.json
generated
63861
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
50
package.json
Normal file → Executable file
50
package.json
Normal file → Executable file
@@ -1,59 +1,51 @@
|
||||
{
|
||||
"name": "@edx/frontend-app-gradebook",
|
||||
"version": "1.6.1",
|
||||
"version": "1.4.14",
|
||||
"description": "edx editable gradebook-ui to manipulate grade overrides on subsections",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/openedx/frontend-app-gradebook.git"
|
||||
"url": "git+https://github.com/edx/frontend-app-gradebook.git"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "fedx-scripts webpack",
|
||||
"coveralls": "cat ./coverage/lcov.info | coveralls",
|
||||
"is-es5": "es-check es5 ./dist/*.js",
|
||||
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
|
||||
"lint": "fedx-scripts eslint --ext .jsx,.js src/",
|
||||
"lint-fix": "fedx-scripts eslint --fix --ext .jsx,.js src/",
|
||||
"prepush": "npm run lint",
|
||||
"semantic-release": "semantic-release",
|
||||
"start": "fedx-scripts webpack-dev-server --progress",
|
||||
"test": "TZ=GMT fedx-scripts jest --coverage --passWithNoTests",
|
||||
"watch-tests": "jest --watch"
|
||||
"watch-tests": "jest --watch",
|
||||
"travis-deploy-once": "travis-deploy-once"
|
||||
},
|
||||
"author": "edX",
|
||||
"license": "AGPL-3.0",
|
||||
"homepage": "https://github.com/openedx/frontend-app-gradebook#readme",
|
||||
"homepage": "https://github.com/edx/frontend-app-gradebook#readme",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"browserslist": [
|
||||
"extends @edx/browserslist-config"
|
||||
],
|
||||
"dependencies": {
|
||||
"@edx/brand": "npm:@edx/brand-edx.org@^1.3.2",
|
||||
"@edx/frontend-component-footer": "^11.1.1",
|
||||
"@edx/frontend-component-header": "^3.1.1",
|
||||
"@edx/frontend-platform": "2.5.0",
|
||||
"@edx/paragon": "^19.25.4",
|
||||
"@edx/reactifex": "^2.1.1",
|
||||
"@edx/frontend-component-footer": "10.0.11",
|
||||
"@edx/frontend-platform": "1.6.1",
|
||||
"@edx/paragon": "10.0.1",
|
||||
"@fortawesome/fontawesome-svg-core": "^1.2.25",
|
||||
"@fortawesome/free-brands-svg-icons": "^5.11.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^5.11.2",
|
||||
"@fortawesome/react-fontawesome": "^0.1.5",
|
||||
"@redux-beacon/segment": "^1.0.0",
|
||||
"@reduxjs/toolkit": "^1.5.1",
|
||||
"classnames": "^2.2.6",
|
||||
"core-js": "3.6.5",
|
||||
"email-prop-type": "^1.1.7",
|
||||
"enzyme": "^3.10.0",
|
||||
"enzyme-to-json": "^3.6.2",
|
||||
"font-awesome": "4.7.0",
|
||||
"history": "4.10.1",
|
||||
"node-sass": "^4.14.1",
|
||||
"prop-types": "15.7.2",
|
||||
"query-string": "6.13.0",
|
||||
"react": "16.14.0",
|
||||
"react-dom": "16.14.0",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react": "16.13.1",
|
||||
"react-dom": "16.13.1",
|
||||
"react-intl": "^2.9.0",
|
||||
"react-redux": "^7.1.1",
|
||||
"react-redux": "^5.1.1",
|
||||
"react-router": "5.2.0",
|
||||
"react-router-dom": "5.2.0",
|
||||
"react-router-redux": "^5.0.0-alpha.9",
|
||||
@@ -63,25 +55,23 @@
|
||||
"redux-logger": "3.0.6",
|
||||
"redux-thunk": "2.3.0",
|
||||
"regenerator-runtime": "^0.13.7",
|
||||
"sass": "^1.49.0",
|
||||
"whatwg-fetch": "^2.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edx/browserslist-config": "^1.1.1",
|
||||
"@edx/frontend-build": "^12.4.15",
|
||||
"@testing-library/react": "^12.1.0",
|
||||
"axios": "0.21.2",
|
||||
"@edx/frontend-build": "5.3.2",
|
||||
"axios": "0.19.2",
|
||||
"axios-mock-adapter": "^1.17.0",
|
||||
"codecov": "^3.6.1",
|
||||
"enzyme": "^3.10.0",
|
||||
"enzyme-adapter-react-16": "^1.14.0",
|
||||
"es-check": "^2.3.0",
|
||||
"fetch-mock": "^6.5.2",
|
||||
"husky": "2.7.0",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"jest": "29.3.1",
|
||||
"react-dev-utils": "^12.0.1",
|
||||
"react-dev-utils": "^5.0.3",
|
||||
"react-test-renderer": "^16.10.1",
|
||||
"reactifex": "1.1.1",
|
||||
"redux-mock-store": "^1.5.3",
|
||||
"semantic-release": "^19.0.3"
|
||||
"semantic-release": "^15.13.24",
|
||||
"travis-deploy-once": "^5.0.11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
<!doctype html>
|
||||
<html lang="en-us">
|
||||
<head>
|
||||
<title>Gradebook | <%= process.env.SITE_NAME %></title>
|
||||
<title>Gradebook | edX</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" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
36
src/App.jsx
36
src/App.jsx
@@ -1,36 +0,0 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
|
||||
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import Footer from '@edx/frontend-component-footer';
|
||||
import Header from '@edx/frontend-component-header';
|
||||
|
||||
import { routePath } from 'data/constants/app';
|
||||
import store from 'data/store';
|
||||
import GradebookPage from 'containers/GradebookPage';
|
||||
import './App.scss';
|
||||
import Head from './head/Head';
|
||||
|
||||
const App = () => (
|
||||
<AppProvider store={store}>
|
||||
<Head />
|
||||
<Router>
|
||||
<div>
|
||||
<Header />
|
||||
<main>
|
||||
<Switch>
|
||||
<Route
|
||||
exact
|
||||
path={routePath}
|
||||
component={GradebookPage}
|
||||
/>
|
||||
</Switch>
|
||||
</main>
|
||||
<Footer logo={process.env.LOGO_POWERED_BY_OPEN_EDX_URL_SVG} />
|
||||
</div>
|
||||
</Router>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
export default App;
|
||||
15
src/App.scss
15
src/App.scss
@@ -1,18 +1,13 @@
|
||||
// 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";
|
||||
|
||||
@import "~@edx/paragon/scss/edx/theme.scss";
|
||||
|
||||
$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-header/dist/index";
|
||||
@import "~@edx/frontend-component-footer/dist/_footer";
|
||||
|
||||
@import "./components/GradesView/GradesView";
|
||||
@import "./components/BulkManagementHistoryView/BulkManagementHistoryView";
|
||||
@import "./components/WithSidebar/WithSidebar";
|
||||
@import "./components/GradebookFilters/GradebookFilters";
|
||||
@import "./components/Gradebook/gradebook";
|
||||
@import "./components/Drawer/Drawer";
|
||||
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import Footer from '@edx/frontend-component-footer';
|
||||
import Header from '@edx/frontend-component-header';
|
||||
|
||||
import { routePath } from 'data/constants/app';
|
||||
import store from 'data/store';
|
||||
import GradebookPage from 'containers/GradebookPage';
|
||||
|
||||
import App from './App';
|
||||
import Head from './head/Head';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
BrowserRouter: () => 'BrowserRouter',
|
||||
Route: () => 'Route',
|
||||
Switch: () => 'Switch',
|
||||
}));
|
||||
jest.mock('@edx/frontend-platform/react', () => ({
|
||||
AppProvider: () => 'AppProvider',
|
||||
}));
|
||||
jest.mock('data/constants/app', () => ({
|
||||
routePath: '/:courseId',
|
||||
}));
|
||||
jest.mock('@edx/frontend-component-footer', () => 'Footer');
|
||||
jest.mock('data/store', () => 'testStore');
|
||||
jest.mock('containers/GradebookPage', () => 'GradebookPage');
|
||||
jest.mock('@edx/frontend-component-header', () => 'Header');
|
||||
|
||||
const logo = 'fakeLogo.png';
|
||||
let el;
|
||||
let router;
|
||||
|
||||
describe('App router component', () => {
|
||||
test('snapshot', () => {
|
||||
expect(shallow(<App />)).toMatchSnapshot();
|
||||
});
|
||||
describe('component', () => {
|
||||
beforeEach(() => {
|
||||
process.env.LOGO_POWERED_BY_OPEN_EDX_URL_SVG = logo;
|
||||
el = shallow(<App />);
|
||||
router = el.childAt(1);
|
||||
});
|
||||
describe('AppProvider', () => {
|
||||
test('AppProvider is the parent component, passed the redux store props', () => {
|
||||
expect(el.type()).toBe(AppProvider);
|
||||
expect(el.props().store).toEqual(store);
|
||||
});
|
||||
});
|
||||
describe('Head', () => {
|
||||
test('first child of AppProvider', () => {
|
||||
expect(el.childAt(0).type()).toBe(Head);
|
||||
});
|
||||
});
|
||||
describe('Router', () => {
|
||||
test('second child of AppProvider', () => {
|
||||
expect(router.type()).toBe(Router);
|
||||
});
|
||||
test('Header is above/outside-of the routing', () => {
|
||||
expect(router.childAt(0).childAt(0).type()).toBe(Header);
|
||||
expect(router.childAt(0).childAt(1).type()).toBe('main');
|
||||
});
|
||||
test('Routing - GradebookPage is only route', () => {
|
||||
expect(router.find('main')).toEqual(shallow(
|
||||
<main>
|
||||
<Switch>
|
||||
<Route exact path={routePath} component={GradebookPage} />
|
||||
</Switch>
|
||||
</main>,
|
||||
));
|
||||
});
|
||||
});
|
||||
test('Footer logo drawn from env variable', () => {
|
||||
expect(router.find(Footer).props().logo).toEqual(logo);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,24 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`App router component snapshot 1`] = `
|
||||
<AppProvider
|
||||
store="testStore"
|
||||
>
|
||||
<Head />
|
||||
<BrowserRouter>
|
||||
<div>
|
||||
<Header />
|
||||
<main>
|
||||
<Switch>
|
||||
<Route
|
||||
component="GradebookPage"
|
||||
exact={true}
|
||||
path="/:courseId"
|
||||
/>
|
||||
</Switch>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
</AppProvider>
|
||||
`;
|
||||
@@ -1,54 +0,0 @@
|
||||
/* eslint-disable react/sort-comp, react/button-has-type */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { Alert } from '@edx/paragon';
|
||||
|
||||
import selectors from 'data/selectors';
|
||||
import messages from './messages';
|
||||
|
||||
/**
|
||||
* <BulkManagementAlerts />
|
||||
* Alerts to display at the top of the BulkManagement tab
|
||||
*/
|
||||
export const BulkManagementAlerts = ({
|
||||
bulkImportError,
|
||||
uploadSuccess,
|
||||
}) => (
|
||||
<>
|
||||
<Alert
|
||||
variant="danger"
|
||||
show={!!bulkImportError}
|
||||
dismissible={false}
|
||||
>
|
||||
{bulkImportError}
|
||||
</Alert>
|
||||
<Alert
|
||||
variant="success"
|
||||
show={uploadSuccess}
|
||||
dismissible={false}
|
||||
>
|
||||
<FormattedMessage {...messages.successDialog} />
|
||||
</Alert>
|
||||
</>
|
||||
);
|
||||
|
||||
BulkManagementAlerts.defaultProps = {
|
||||
bulkImportError: '',
|
||||
uploadSuccess: false,
|
||||
};
|
||||
|
||||
BulkManagementAlerts.propTypes = {
|
||||
// redux
|
||||
bulkImportError: PropTypes.string,
|
||||
uploadSuccess: PropTypes.bool,
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => ({
|
||||
bulkImportError: selectors.grades.bulkImportError(state),
|
||||
uploadSuccess: selectors.grades.uploadSuccess(state),
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps)(BulkManagementAlerts);
|
||||
@@ -1,89 +0,0 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { Alert } from '@edx/paragon';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import selectors from 'data/selectors';
|
||||
import messages from './messages';
|
||||
|
||||
import { BulkManagementAlerts, mapStateToProps } from './BulkManagementAlerts';
|
||||
|
||||
jest.mock('@edx/frontend-platform/i18n', () => ({
|
||||
defineMessages: m => m,
|
||||
FormattedMessage: () => 'FormattedMessage',
|
||||
}));
|
||||
jest.mock('@edx/paragon', () => ({
|
||||
Alert: () => 'Alert',
|
||||
}));
|
||||
jest.mock('data/selectors', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
grades: {
|
||||
bulkImportError: (state) => ({ bulkImportError: state }),
|
||||
uploadSuccess: (state) => ({ uploadSuccess: state }),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const errorMessage = 'Oh noooooo';
|
||||
|
||||
describe('BulkManagementAlerts', () => {
|
||||
describe('component', () => {
|
||||
let el;
|
||||
describe('no errer, no upload success', () => {
|
||||
beforeEach(() => {
|
||||
el = shallow(<BulkManagementAlerts />);
|
||||
});
|
||||
test('snapshot - bulkImportError closed, success closed', () => {
|
||||
expect(el).toMatchSnapshot();
|
||||
});
|
||||
test('closed danger alert', () => {
|
||||
expect(el.childAt(0).is(Alert)).toEqual(true);
|
||||
expect(el.childAt(0).props().show).toEqual(false);
|
||||
expect(el.childAt(0).props().variant).toEqual('danger');
|
||||
});
|
||||
test('closed success alert', () => {
|
||||
expect(el.childAt(1).is(Alert)).toEqual(true);
|
||||
expect(el.childAt(1).props().show).toEqual(false);
|
||||
expect(el.childAt(1).props().variant).toEqual('success');
|
||||
});
|
||||
});
|
||||
describe('no errer, no upload success', () => {
|
||||
beforeEach(() => {
|
||||
el = shallow(<BulkManagementAlerts uploadSuccess bulkImportError={errorMessage} />);
|
||||
});
|
||||
const assertions = [
|
||||
'danger alert open with bulkImportError',
|
||||
'success alert open with messages.successDialog',
|
||||
];
|
||||
test(`snapshot - ${assertions.join(', ')}`, () => {
|
||||
expect(el).toMatchSnapshot();
|
||||
});
|
||||
test('open danger alert with bulkImportError content', () => {
|
||||
expect(el.childAt(0).is(Alert)).toEqual(true);
|
||||
expect(el.childAt(0).children().text()).toEqual(errorMessage);
|
||||
expect(el.childAt(0).props().show).toEqual(true);
|
||||
});
|
||||
test('open success alert with messages.successDialog content', () => {
|
||||
expect(el.childAt(1).is(Alert)).toEqual(true);
|
||||
expect(el.childAt(1).children().getElement()).toEqual(
|
||||
<FormattedMessage {...messages.successDialog} />,
|
||||
);
|
||||
expect(el.childAt(1).props().show).toEqual(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('mapStateToProps', () => {
|
||||
let mapped;
|
||||
const testState = { a: 'puppy', named: 'Ember' };
|
||||
beforeEach(() => {
|
||||
mapped = mapStateToProps(testState);
|
||||
});
|
||||
test('bulkImportError from grades.bulkImportError', () => {
|
||||
expect(mapped.bulkImportError).toEqual(selectors.grades.bulkImportError(testState));
|
||||
});
|
||||
test('uploadSuccess from grades.uploadSuccess', () => {
|
||||
expect(mapped.uploadSuccess).toEqual(selectors.grades.uploadSuccess(testState));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
.bulk-management-history-view {
|
||||
.help-text {
|
||||
margin-bottom: 40px;
|
||||
max-width: 70%;
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
/* eslint-disable react/button-has-type, import/no-named-as-default */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { DataTable } from '@edx/paragon';
|
||||
|
||||
import { bulkManagementColumns } from 'data/constants/app';
|
||||
import selectors from 'data/selectors';
|
||||
|
||||
import ResultsSummary from './ResultsSummary';
|
||||
|
||||
export const mapHistoryRows = ({
|
||||
resultsSummary,
|
||||
originalFilename,
|
||||
user,
|
||||
...rest
|
||||
}) => ({
|
||||
resultsSummary: (<ResultsSummary {...resultsSummary} />),
|
||||
filename: (<span className="wrap-text-in-cell">{originalFilename}</span>),
|
||||
user: (<span className="wrap-text-in-cell">{user}</span>),
|
||||
...rest,
|
||||
});
|
||||
|
||||
/**
|
||||
* <HistoryTable />
|
||||
* Table with history of bulk management uploads, including a results summary which
|
||||
* displays total, skipped, and failed uploads
|
||||
*/
|
||||
export const HistoryTable = ({
|
||||
bulkManagementHistory,
|
||||
}) => (
|
||||
<DataTable
|
||||
data={bulkManagementHistory.map(mapHistoryRows)}
|
||||
hasFixedColumnWidths
|
||||
columns={bulkManagementColumns}
|
||||
className="table-striped"
|
||||
itemCount={bulkManagementHistory.length}
|
||||
/>
|
||||
);
|
||||
HistoryTable.defaultProps = {
|
||||
bulkManagementHistory: [],
|
||||
};
|
||||
HistoryTable.propTypes = {
|
||||
// redux
|
||||
bulkManagementHistory: PropTypes.arrayOf(PropTypes.shape({
|
||||
originalFilename: PropTypes.string.isRequired,
|
||||
user: PropTypes.string.isRequired,
|
||||
timeUploaded: PropTypes.string.isRequired,
|
||||
resultsSummary: PropTypes.shape({
|
||||
rowId: PropTypes.number.isRequired,
|
||||
text: PropTypes.string.isRequired,
|
||||
}),
|
||||
})),
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => ({
|
||||
bulkManagementHistory: selectors.grades.bulkManagementHistoryEntries(state),
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps)(HistoryTable);
|
||||
@@ -1,108 +0,0 @@
|
||||
/* eslint-disable import/no-named-as-default */
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { DataTable } from '@edx/paragon';
|
||||
|
||||
import selectors from 'data/selectors';
|
||||
import { bulkManagementColumns } from 'data/constants/app';
|
||||
|
||||
import ResultsSummary from './ResultsSummary';
|
||||
import { HistoryTable, mapStateToProps } from './HistoryTable';
|
||||
|
||||
jest.mock('@edx/paragon', () => ({ DataTable: () => 'DataTable' }));
|
||||
|
||||
jest.mock('@edx/frontend-platform/i18n', () => ({
|
||||
defineMessages: m => m,
|
||||
FormattedMessage: () => 'FormattedMessage',
|
||||
}));
|
||||
jest.mock('data/selectors', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
grades: {
|
||||
bulkManagementHistoryEntries: jest.fn(state => ({ historyEntries: state })),
|
||||
},
|
||||
},
|
||||
}));
|
||||
jest.mock('./ResultsSummary', () => 'ResultsSummary');
|
||||
|
||||
describe('HistoryTable', () => {
|
||||
describe('component', () => {
|
||||
const entry1 = {
|
||||
originalFilename: 'blue.png',
|
||||
user: 'Eifel',
|
||||
timeUploaded: '65',
|
||||
resultsSummary: {
|
||||
rowId: 12,
|
||||
courseId: 'Da Bu Dee',
|
||||
text: 'Da ba daa',
|
||||
},
|
||||
};
|
||||
const entry2 = {
|
||||
originalFilename: 'allStar.jpg',
|
||||
user: 'Smashmouth',
|
||||
timeUploaded: '2000s?',
|
||||
resultsSummary: {
|
||||
courseId: 'rockstar',
|
||||
rowId: 2,
|
||||
text: 'all that glitters is gold',
|
||||
},
|
||||
};
|
||||
const props = {
|
||||
bulkManagementHistory: [entry1, entry2],
|
||||
};
|
||||
let el;
|
||||
describe('snapshot', () => {
|
||||
beforeEach(() => {
|
||||
el = shallow(<HistoryTable {...props} />);
|
||||
});
|
||||
test('snapshot - loads formatted table', () => {
|
||||
expect(el).toMatchSnapshot();
|
||||
});
|
||||
describe('history table', () => {
|
||||
let table;
|
||||
beforeEach(() => {
|
||||
table = el.find(DataTable);
|
||||
});
|
||||
describe('data (from bulkManagementHistory.map(this.formatHistoryRow)', () => {
|
||||
const fieldAssertions = [
|
||||
'maps resultsSummay to ResultsSummary',
|
||||
'wraps filename and user',
|
||||
'forwards the rest',
|
||||
];
|
||||
test(`snapshot: ${fieldAssertions.join(', ')}`, () => {
|
||||
expect(table.props().data).toMatchSnapshot();
|
||||
});
|
||||
test(fieldAssertions.join(', '), () => {
|
||||
const rows = table.props().data;
|
||||
expect(rows[0].resultsSummary).toEqual(<ResultsSummary {...entry1.resultsSummary} />);
|
||||
expect(rows[0].user).toEqual(<span className="wrap-text-in-cell">{entry1.user}</span>);
|
||||
expect(
|
||||
rows[0].filename,
|
||||
).toEqual(<span className="wrap-text-in-cell">{entry1.originalFilename}</span>);
|
||||
expect(rows[1].resultsSummary).toEqual(<ResultsSummary {...entry2.resultsSummary} />);
|
||||
expect(rows[1].user).toEqual(<span className="wrap-text-in-cell">{entry2.user}</span>);
|
||||
expect(
|
||||
rows[1].filename,
|
||||
).toEqual(<span className="wrap-text-in-cell">{entry2.originalFilename}</span>);
|
||||
});
|
||||
});
|
||||
test('columns from bulkManagementColumns', () => {
|
||||
expect(table.props().columns).toEqual(bulkManagementColumns);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapStateToProps', () => {
|
||||
const testState = { a: 'simple', test: 'state' };
|
||||
let mapped;
|
||||
beforeEach(() => {
|
||||
mapped = mapStateToProps(testState);
|
||||
});
|
||||
test('bulkManagementHistory from grades.bulkManagementHistoryEntries', () => {
|
||||
expect(
|
||||
mapped.bulkManagementHistory,
|
||||
).toEqual(selectors.grades.bulkManagementHistoryEntries(testState));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
/* eslint-disable react/sort-comp, react/button-has-type */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Hyperlink, Icon } from '@edx/paragon';
|
||||
import { Download } from '@edx/paragon/icons';
|
||||
|
||||
import lms from 'data/services/lms';
|
||||
|
||||
/**
|
||||
* <ResultsSummary {...{ courseId, rowId, text }} />
|
||||
* displays a result summary cell for a single bulk management upgrade history entry.
|
||||
* @param {string} courseId - course identifier
|
||||
* @param {number} rowId - row/error identifier
|
||||
* @param {string} text - summary string
|
||||
*/
|
||||
const ResultsSummary = ({
|
||||
rowId,
|
||||
text,
|
||||
}) => (
|
||||
<Hyperlink
|
||||
href={lms.urls.bulkGradesUrlByRow(rowId)}
|
||||
destination="www.edx.org"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
showLaunchIcon={false}
|
||||
>
|
||||
<Icon src={Download} className="d-inline-block" />
|
||||
{text}
|
||||
</Hyperlink>
|
||||
);
|
||||
|
||||
ResultsSummary.propTypes = {
|
||||
rowId: PropTypes.number.isRequired,
|
||||
text: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default ResultsSummary;
|
||||
@@ -1,53 +0,0 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { Icon } from '@edx/paragon';
|
||||
import { Download } from '@edx/paragon/icons';
|
||||
|
||||
import lms from 'data/services/lms';
|
||||
import ResultsSummary from './ResultsSummary';
|
||||
|
||||
jest.mock('@edx/paragon', () => ({
|
||||
Hyperlink: () => 'Hyperlink',
|
||||
Icon: () => 'Icon',
|
||||
}));
|
||||
jest.mock('@edx/paragon/icons', () => ({
|
||||
Download: 'DownloadIcon',
|
||||
}));
|
||||
jest.mock('data/services/lms', () => ({
|
||||
urls: {
|
||||
bulkGradesUrlByRow: jest.fn((rowId) => ({ url: { rowId } })),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('ResultsSummary component', () => {
|
||||
const props = {
|
||||
rowId: 42,
|
||||
text: 'texty',
|
||||
};
|
||||
let el;
|
||||
const assertions = [
|
||||
'safe hyperlink with bulkGradesUrl with course and row id',
|
||||
'download icon',
|
||||
'results text',
|
||||
];
|
||||
beforeEach(() => {
|
||||
el = shallow(<ResultsSummary {...props} />);
|
||||
});
|
||||
test(`snapshot - ${assertions.join(', ')}`, () => {
|
||||
expect(el).toMatchSnapshot();
|
||||
});
|
||||
test('Hyperlink has target="_blank" and rel="noopener noreferrer"', () => {
|
||||
expect(el.props().target).toEqual('_blank');
|
||||
expect(el.props().rel).toEqual('noopener noreferrer');
|
||||
});
|
||||
test('Hyperlink has href to bulkGradesUrl', () => {
|
||||
expect(el.props().href).toEqual(lms.urls.bulkGradesUrlByRow(props.rowId));
|
||||
});
|
||||
test('displays Download Icon and text', () => {
|
||||
const icon = el.childAt(0);
|
||||
expect(icon.is(Icon)).toEqual(true);
|
||||
expect(icon.props().src).toEqual(Download);
|
||||
expect(el.childAt(1).text()).toEqual(props.text);
|
||||
});
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`BulkManagementAlerts component no errer, no upload success snapshot - bulkImportError closed, success closed 1`] = `
|
||||
<Fragment>
|
||||
<Alert
|
||||
dismissible={false}
|
||||
show={false}
|
||||
variant="danger"
|
||||
/>
|
||||
<Alert
|
||||
dismissible={false}
|
||||
show={false}
|
||||
variant="success"
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="CSV processing. File uploads may take several minutes to complete."
|
||||
description="Success Dialog message in BulkManagement Tab File Upload Form"
|
||||
id="gradebook.BulkManagementHistoryView.successDialog"
|
||||
/>
|
||||
</Alert>
|
||||
</Fragment>
|
||||
`;
|
||||
|
||||
exports[`BulkManagementAlerts component no errer, no upload success snapshot - danger alert open with bulkImportError, success alert open with messages.successDialog 1`] = `
|
||||
<Fragment>
|
||||
<Alert
|
||||
dismissible={false}
|
||||
show={true}
|
||||
variant="danger"
|
||||
>
|
||||
Oh noooooo
|
||||
</Alert>
|
||||
<Alert
|
||||
dismissible={false}
|
||||
show={true}
|
||||
variant="success"
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="CSV processing. File uploads may take several minutes to complete."
|
||||
description="Success Dialog message in BulkManagement Tab File Upload Form"
|
||||
id="gradebook.BulkManagementHistoryView.successDialog"
|
||||
/>
|
||||
</Alert>
|
||||
</Fragment>
|
||||
`;
|
||||
@@ -1,118 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`HistoryTable component snapshot history table data (from bulkManagementHistory.map(this.formatHistoryRow) snapshot: maps resultsSummay to ResultsSummary, wraps filename and user, forwards the rest 1`] = `
|
||||
Array [
|
||||
Object {
|
||||
"filename": <span
|
||||
className="wrap-text-in-cell"
|
||||
>
|
||||
blue.png
|
||||
</span>,
|
||||
"resultsSummary": <ResultsSummary
|
||||
courseId="Da Bu Dee"
|
||||
rowId={12}
|
||||
text="Da ba daa"
|
||||
/>,
|
||||
"timeUploaded": "65",
|
||||
"user": <span
|
||||
className="wrap-text-in-cell"
|
||||
>
|
||||
Eifel
|
||||
</span>,
|
||||
},
|
||||
Object {
|
||||
"filename": <span
|
||||
className="wrap-text-in-cell"
|
||||
>
|
||||
allStar.jpg
|
||||
</span>,
|
||||
"resultsSummary": <ResultsSummary
|
||||
courseId="rockstar"
|
||||
rowId={2}
|
||||
text="all that glitters is gold"
|
||||
/>,
|
||||
"timeUploaded": "2000s?",
|
||||
"user": <span
|
||||
className="wrap-text-in-cell"
|
||||
>
|
||||
Smashmouth
|
||||
</span>,
|
||||
},
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`HistoryTable component snapshot snapshot - loads formatted table 1`] = `
|
||||
<DataTable
|
||||
className="table-striped"
|
||||
columns={
|
||||
Array [
|
||||
Object {
|
||||
"Header": "Gradebook",
|
||||
"accessor": "filename",
|
||||
"columnSortable": false,
|
||||
"width": "col-5",
|
||||
},
|
||||
Object {
|
||||
"Header": "Download Summary",
|
||||
"accessor": "resultsSummary",
|
||||
"columnSortable": false,
|
||||
"width": "col",
|
||||
},
|
||||
Object {
|
||||
"Header": "Who",
|
||||
"accessor": "user",
|
||||
"columnSortable": false,
|
||||
"width": "col-1",
|
||||
},
|
||||
Object {
|
||||
"Header": "When",
|
||||
"accessor": "timeUploaded",
|
||||
"columnSortable": false,
|
||||
"width": "col",
|
||||
},
|
||||
]
|
||||
}
|
||||
data={
|
||||
Array [
|
||||
Object {
|
||||
"filename": <span
|
||||
className="wrap-text-in-cell"
|
||||
>
|
||||
blue.png
|
||||
</span>,
|
||||
"resultsSummary": <ResultsSummary
|
||||
courseId="Da Bu Dee"
|
||||
rowId={12}
|
||||
text="Da ba daa"
|
||||
/>,
|
||||
"timeUploaded": "65",
|
||||
"user": <span
|
||||
className="wrap-text-in-cell"
|
||||
>
|
||||
Eifel
|
||||
</span>,
|
||||
},
|
||||
Object {
|
||||
"filename": <span
|
||||
className="wrap-text-in-cell"
|
||||
>
|
||||
allStar.jpg
|
||||
</span>,
|
||||
"resultsSummary": <ResultsSummary
|
||||
courseId="rockstar"
|
||||
rowId={2}
|
||||
text="all that glitters is gold"
|
||||
/>,
|
||||
"timeUploaded": "2000s?",
|
||||
"user": <span
|
||||
className="wrap-text-in-cell"
|
||||
>
|
||||
Smashmouth
|
||||
</span>,
|
||||
},
|
||||
]
|
||||
}
|
||||
hasFixedColumnWidths={true}
|
||||
itemCount={2}
|
||||
/>
|
||||
`;
|
||||
@@ -1,23 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`ResultsSummary component snapshot - safe hyperlink with bulkGradesUrl with course and row id, download icon, results text 1`] = `
|
||||
<Hyperlink
|
||||
destination="www.edx.org"
|
||||
href={
|
||||
Object {
|
||||
"url": Object {
|
||||
"rowId": 42,
|
||||
},
|
||||
}
|
||||
}
|
||||
rel="noopener noreferrer"
|
||||
showLaunchIcon={false}
|
||||
target="_blank"
|
||||
>
|
||||
<Icon
|
||||
className="d-inline-block"
|
||||
src="DownloadIcon"
|
||||
/>
|
||||
texty
|
||||
</Hyperlink>
|
||||
`;
|
||||
@@ -1,26 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`BulkManagementHistoryView component snapshot snapshot - loads heading from messages.BulkManagementHistoryView.heading, <BulkManagementAlerts />, <HistoryTable /> 1`] = `
|
||||
<div
|
||||
className="bulk-management-history-view"
|
||||
>
|
||||
<h4>
|
||||
<FormattedMessage
|
||||
defaultMessage="Bulk Management History"
|
||||
description="Heading text for BulkManagement History Tab"
|
||||
id="gradebook.BulkManagementHistoryView.heading"
|
||||
/>
|
||||
</h4>
|
||||
<p
|
||||
className="help-text"
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="Below is a log of previous grade imports. To download a CSV of your gradebook and import grades for override, return to the Gradebook. Please note, after importing grades, it may take a few seconds to process the override."
|
||||
description="Bulk Management History View help text"
|
||||
id="gradebook.BulkManagementHistoryView"
|
||||
/>
|
||||
</p>
|
||||
<BulkManagementAlerts />
|
||||
<HistoryTable />
|
||||
</div>
|
||||
`;
|
||||
@@ -1,24 +0,0 @@
|
||||
/* eslint-disable react/button-has-type, import/no-named-as-default */
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import messages from './messages';
|
||||
import BulkManagementAlerts from './BulkManagementAlerts';
|
||||
import HistoryTable from './HistoryTable';
|
||||
|
||||
/**
|
||||
* <BulkManagementHistoryView />
|
||||
* top-level view for managing uploads of bulk management override csvs.
|
||||
*/
|
||||
export const BulkManagementHistoryView = () => (
|
||||
<div className="bulk-management-history-view">
|
||||
<h4><FormattedMessage {...messages.heading} /></h4>
|
||||
<p className="help-text">
|
||||
<FormattedMessage {...messages.helpText} />
|
||||
</p>
|
||||
<BulkManagementAlerts />
|
||||
<HistoryTable />
|
||||
</div>
|
||||
);
|
||||
|
||||
export default BulkManagementHistoryView;
|
||||
@@ -1,44 +0,0 @@
|
||||
/* eslint-disable import/no-named-as-default */
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { BulkManagementHistoryView } from '.';
|
||||
import BulkManagementAlerts from './BulkManagementAlerts';
|
||||
import HistoryTable from './HistoryTable';
|
||||
import messages from './messages';
|
||||
|
||||
jest.mock('./BulkManagementAlerts', () => 'BulkManagementAlerts');
|
||||
jest.mock('./HistoryTable', () => 'HistoryTable');
|
||||
|
||||
describe('BulkManagementHistoryView', () => {
|
||||
describe('component', () => {
|
||||
let el;
|
||||
beforeEach(() => {
|
||||
el = shallow(<BulkManagementHistoryView />);
|
||||
});
|
||||
describe('snapshot', () => {
|
||||
const snapshotSegments = [
|
||||
'heading from messages.BulkManagementHistoryView.heading',
|
||||
'<BulkManagementAlerts />',
|
||||
'<HistoryTable />',
|
||||
];
|
||||
test(`snapshot - loads ${snapshotSegments.join(', ')}`, () => {
|
||||
expect(el).toMatchSnapshot();
|
||||
});
|
||||
test('heading - h4 loaded from messages', () => {
|
||||
const heading = el.find('h4');
|
||||
expect(heading.getElement()).toEqual((
|
||||
<h4>
|
||||
<FormattedMessage {...messages.heading} />
|
||||
</h4>
|
||||
));
|
||||
});
|
||||
test('heading, then alerts, then upload form, then table', () => {
|
||||
expect(el.childAt(0).is('h4')).toEqual(true);
|
||||
expect(el.childAt(2).is(BulkManagementAlerts)).toEqual(true);
|
||||
expect(el.childAt(3).is(HistoryTable)).toEqual(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: {
|
||||
id: 'gradebook.BulkManagementHistoryView.heading',
|
||||
defaultMessage: 'Bulk Management History',
|
||||
description: 'Heading text for BulkManagement History Tab',
|
||||
},
|
||||
helpText: {
|
||||
id: 'gradebook.BulkManagementHistoryView',
|
||||
defaultMessage: 'Below is a log of previous grade imports. To download a CSV of your gradebook and import grades for override, return to the Gradebook. Please note, after importing grades, it may take a few seconds to process the override.',
|
||||
description: 'Bulk Management History View help text',
|
||||
},
|
||||
successDialog: {
|
||||
id: 'gradebook.BulkManagementHistoryView.successDialog',
|
||||
defaultMessage: 'CSV processing. File uploads may take several minutes to complete.',
|
||||
description: 'Success Dialog message in BulkManagement Tab File Upload Form',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
48
src/components/Drawer/Drawer.scss
Normal file
48
src/components/Drawer/Drawer.scss
Normal file
@@ -0,0 +1,48 @@
|
||||
$drawer-width: 350px;
|
||||
|
||||
.drawer-contents {
|
||||
overflow-x: auto;
|
||||
transition: margin 300ms cubic-bezier(0.4,0,0.2,1);
|
||||
margin-left: 0;
|
||||
.drawer.open + & {
|
||||
margin-left: $drawer-width;
|
||||
}
|
||||
&.opened {
|
||||
width: calc(100vw - #{$drawer-width});
|
||||
}
|
||||
}
|
||||
|
||||
.drawer-contents {
|
||||
overflow-x: auto;
|
||||
transition: margin 300ms cubic-bezier(0.4,0,0.2,1);
|
||||
margin-left: 0;
|
||||
.drawer.open + & {
|
||||
margin-left: $drawer-width;
|
||||
}
|
||||
&.opened {
|
||||
width: calc(100vw - #{$drawer-width});
|
||||
}
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.drawer-container .collapsible {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.drawer {
|
||||
height: 100%;
|
||||
width: $drawer-width;
|
||||
position: absolute;
|
||||
transform: translateX(-$drawer-width);
|
||||
flex-direction: column;
|
||||
transition: transform 300ms cubic-bezier(0.4,0,0.2,1);
|
||||
&.open {
|
||||
transform: translateX(0%);
|
||||
}
|
||||
}
|
||||
85
src/components/Drawer/index.jsx
Normal file
85
src/components/Drawer/index.jsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { Button } from '@edx/paragon';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faTimes } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
export default class Drawer extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
open: props.initiallyOpen,
|
||||
transitioning: false,
|
||||
};
|
||||
}
|
||||
|
||||
close = () => {
|
||||
if (this.state.open) {
|
||||
this.toggleOpen();
|
||||
}
|
||||
};
|
||||
|
||||
toggleOpen = () => {
|
||||
this.setState({ transitioning: true });
|
||||
// defer the transition to the next repaint so we can be sure that
|
||||
// opening drawer is visible before it transitions
|
||||
// (the start state of the opening animation doesn't work if the element starts hidden)
|
||||
this.deferToNextRepaint(() => this.setState(prevState => ({ open: !prevState.open })));
|
||||
};
|
||||
|
||||
handleSlideDone = (e) => {
|
||||
if (e.currentTarget === e.target) {
|
||||
this.setState({ transitioning: false });
|
||||
}
|
||||
};
|
||||
|
||||
deferToNextRepaint(callback) {
|
||||
window.requestAnimationFrame(() => window.setTimeout(callback, 0));
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="d-flex drawer-container">
|
||||
<aside
|
||||
className={classNames(
|
||||
'drawer',
|
||||
{
|
||||
open: this.state.open,
|
||||
'd-none': !this.state.transitioning && !this.state.open,
|
||||
},
|
||||
)}
|
||||
onTransitionEnd={this.handleSlideDone}
|
||||
>
|
||||
<div className="drawer-header">
|
||||
<h2>{this.props.title}</h2>
|
||||
<Button
|
||||
className="p-1"
|
||||
onClick={this.close}
|
||||
aria-label="Close Filters"
|
||||
>
|
||||
<FontAwesomeIcon icon={faTimes} />
|
||||
</Button>
|
||||
</div>
|
||||
{this.props.children}
|
||||
</aside>
|
||||
<div
|
||||
className={classNames(
|
||||
'drawer-contents',
|
||||
'position-relative',
|
||||
!this.state.drawerTransitioning && this.state.drawerOpen && 'opened',
|
||||
)}
|
||||
>
|
||||
{this.props.mainContent(this.toggleOpen)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Drawer.propTypes = {
|
||||
initiallyOpen: PropTypes.bool.isRequired,
|
||||
children: PropTypes.node.isRequired,
|
||||
mainContent: PropTypes.func.isRequired,
|
||||
title: PropTypes.node.isRequired,
|
||||
};
|
||||
@@ -1,23 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Header snapshot - has edx link with logo url 1`] = `
|
||||
<div
|
||||
className="mb-3"
|
||||
>
|
||||
<header
|
||||
className="d-flex justify-content-center align-items-center p-3 border-bottom-blue"
|
||||
>
|
||||
<Hyperlink
|
||||
destination="undefined/dashboard"
|
||||
>
|
||||
<img
|
||||
alt="edX logo"
|
||||
height="30"
|
||||
src="www.ourLogo.url"
|
||||
width="60"
|
||||
/>
|
||||
</Hyperlink>
|
||||
<div />
|
||||
</header>
|
||||
</div>
|
||||
`;
|
||||
@@ -1,21 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Hyperlink } from '@edx/paragon';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
/**
|
||||
* <EdxHeader />
|
||||
* Gradebook MFE app header.
|
||||
* Displays edx logo, linked to lms dashboard
|
||||
*/
|
||||
const EdxHeader = () => (
|
||||
<div className="mb-3">
|
||||
<header className="d-flex justify-content-center align-items-center p-3 border-bottom-blue">
|
||||
<Hyperlink destination={`${getConfig().LMS_BASE_URL}/dashboard`}>
|
||||
<img src={getConfig().LOGO_URL} alt="edX logo" height="30" width="60" />
|
||||
</Hyperlink>
|
||||
<div />
|
||||
</header>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default EdxHeader;
|
||||
@@ -1,21 +0,0 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import Header from '.';
|
||||
|
||||
jest.mock('@edx/paragon', () => ({
|
||||
Hyperlink: () => 'Hyperlink',
|
||||
}));
|
||||
jest.mock('@edx/frontend-platform', () => ({
|
||||
getConfig: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('Header', () => {
|
||||
test('snapshot - has edx link with logo url', () => {
|
||||
const url = 'www.ourLogo.url';
|
||||
getConfig.mockReturnValue({ LOGO_URL: url });
|
||||
expect(shallow(<Header />)).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
167
src/components/FilterBadges/index.jsx
Normal file
167
src/components/FilterBadges/index.jsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import initialFilters from '../../data/constants/filters';
|
||||
|
||||
function FilterBadge({ name, value, onClick }) {
|
||||
return (
|
||||
<div>
|
||||
<span className="badge badge-info">
|
||||
<span>{`${name}: ${value}`}</span>
|
||||
<button type="button" className="btn-info" aria-label="Close" onClick={onClick}>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</span>
|
||||
<br />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RangeFilterBadge({
|
||||
displayName,
|
||||
filterName1,
|
||||
filterValue1,
|
||||
filterName2,
|
||||
filterValue2,
|
||||
handleBadgeClose,
|
||||
}) {
|
||||
return ((filterValue1 !== initialFilters[filterName1])
|
||||
|| (filterValue2 !== initialFilters[filterName2]))
|
||||
&& (
|
||||
<FilterBadge
|
||||
name={displayName}
|
||||
value={`${filterValue1} - ${filterValue2}`}
|
||||
onClick={handleBadgeClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
RangeFilterBadge.propTypes = {
|
||||
displayName: PropTypes.string.isRequired,
|
||||
filterName1: PropTypes.string.isRequired,
|
||||
filterValue1: PropTypes.string.isRequired,
|
||||
filterName2: PropTypes.string.isRequired,
|
||||
filterValue2: PropTypes.string.isRequired,
|
||||
handleBadgeClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
function SingleValueFilterBadge({
|
||||
displayName, filterName, filterValue, handleBadgeClose,
|
||||
}) {
|
||||
return (filterValue !== initialFilters[filterName])
|
||||
&& (
|
||||
<FilterBadge
|
||||
name={displayName}
|
||||
value={filterValue}
|
||||
onClick={handleBadgeClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
SingleValueFilterBadge.propTypes = {
|
||||
displayName: PropTypes.string.isRequired,
|
||||
filterName: PropTypes.string.isRequired,
|
||||
filterValue: PropTypes.string.isRequired,
|
||||
handleBadgeClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
function FilterBadges({
|
||||
assignment,
|
||||
assignmentType,
|
||||
track,
|
||||
cohort,
|
||||
assignmentGradeMin,
|
||||
assignmentGradeMax,
|
||||
courseGradeMin,
|
||||
courseGradeMax,
|
||||
handleFilterBadgeClose,
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<SingleValueFilterBadge
|
||||
displayName="Assignment Type"
|
||||
filterName="assignmentType"
|
||||
filterValue={assignmentType}
|
||||
handleBadgeClose={handleFilterBadgeClose(['assignmentType'])}
|
||||
/>
|
||||
<SingleValueFilterBadge
|
||||
displayName="Assignment"
|
||||
filterName="assignment"
|
||||
filterValue={assignment}
|
||||
handleBadgeClose={handleFilterBadgeClose(['assignment', 'assignmentGradeMax', 'assignmentGradeMin'])}
|
||||
/>
|
||||
<RangeFilterBadge
|
||||
displayName="Assignment Grade"
|
||||
filterName1="assignmentGradeMin"
|
||||
filterValue1={assignmentGradeMin}
|
||||
filterName2="assignmentGradeMax"
|
||||
filterValue2={assignmentGradeMax}
|
||||
handleBadgeClose={handleFilterBadgeClose(['assignmentGradeMin', 'assignmentGradeMax'])}
|
||||
/>
|
||||
<RangeFilterBadge
|
||||
displayName="Course Grade"
|
||||
filterName1="courseGradeMin"
|
||||
filterValue1={courseGradeMin}
|
||||
filterName2="courseGradeMax"
|
||||
filterValue2={courseGradeMax}
|
||||
handleBadgeClose={handleFilterBadgeClose(['courseGradeMin', 'courseGradeMax'])}
|
||||
/>
|
||||
<SingleValueFilterBadge
|
||||
displayName="Track"
|
||||
filterName="track"
|
||||
filterValue={track}
|
||||
handleBadgeClose={handleFilterBadgeClose(['track'])}
|
||||
/>
|
||||
<SingleValueFilterBadge
|
||||
displayName="Cohort"
|
||||
filterName="track"
|
||||
filterValue={cohort}
|
||||
handleBadgeClose={handleFilterBadgeClose(['cohort'])}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const mapStateToProps = state => (
|
||||
{
|
||||
assignment: (state.filters.assignment || {}).label,
|
||||
assignmentType: state.filters.assignmentType,
|
||||
track: state.filters.track,
|
||||
cohort: state.filters.cohort,
|
||||
assignmentGradeMin: state.filters.assignmentGradeMin,
|
||||
assignmentGradeMax: state.filters.assignmentGradeMax,
|
||||
courseGradeMin: state.filters.courseGradeMin,
|
||||
courseGradeMax: state.filters.courseGradeMax,
|
||||
}
|
||||
);
|
||||
|
||||
const ConnectedFilterBadges = connect(mapStateToProps)(FilterBadges);
|
||||
export default ConnectedFilterBadges;
|
||||
|
||||
FilterBadge.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
value: PropTypes.string.isRequired,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
FilterBadges.defaultProps = {
|
||||
assignment: initialFilters.assignmentType,
|
||||
assignmentType: initialFilters.assignmentType,
|
||||
track: initialFilters.track,
|
||||
cohort: initialFilters.cohort,
|
||||
assignmentGradeMin: initialFilters.assignmentGradeMin,
|
||||
assignmentGradeMax: initialFilters.assignmentGradeMax,
|
||||
courseGradeMin: initialFilters.courseGradeMin,
|
||||
courseGradeMax: initialFilters.courseGradeMax,
|
||||
};
|
||||
|
||||
FilterBadges.propTypes = {
|
||||
assignment: PropTypes.string,
|
||||
assignmentType: PropTypes.string,
|
||||
track: PropTypes.string,
|
||||
cohort: PropTypes.string,
|
||||
assignmentGradeMin: PropTypes.string,
|
||||
assignmentGradeMax: PropTypes.string,
|
||||
courseGradeMin: PropTypes.string,
|
||||
courseGradeMax: PropTypes.string,
|
||||
handleFilterBadgeClose: PropTypes.func.isRequired,
|
||||
};
|
||||
200
src/components/Gradebook/Assignments.jsx
Normal file
200
src/components/Gradebook/Assignments.jsx
Normal file
@@ -0,0 +1,200 @@
|
||||
/* eslint-disable react/sort-comp, react/button-has-type */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Collapsible,
|
||||
InputSelect,
|
||||
InputText,
|
||||
} from '@edx/paragon';
|
||||
import { selectableAssignmentLabels } from '../../data/selectors/filters';
|
||||
import {
|
||||
filterAssignmentType,
|
||||
fetchGrades,
|
||||
updateGradesIfAssignmentGradeFiltersSet,
|
||||
} from '../../data/actions/grades';
|
||||
import {
|
||||
updateAssignmentFilter,
|
||||
updateAssignmentLimits,
|
||||
} from '../../data/actions/filters';
|
||||
|
||||
export class Assignments extends React.Component {
|
||||
getAssignmentFilterOptions = () => [
|
||||
{ label: 'All', value: '' },
|
||||
...this.props.assignmentFilterOptions.map(({ label, subsectionLabel }) => ({
|
||||
label: `${label}: ${subsectionLabel}`,
|
||||
value: label,
|
||||
})),
|
||||
];
|
||||
|
||||
handleAssignmentFilterChange = (assignment) => {
|
||||
const selectedFilterOption = this.props.assignmentFilterOptions.find(assig => assig.label === assignment);
|
||||
const { type, id } = selectedFilterOption || {};
|
||||
const typedValue = { label: assignment, type, id };
|
||||
this.props.updateAssignmentFilter(typedValue);
|
||||
this.updateQueryParams({ assignment: id });
|
||||
this.props.updateGradesIfAssignmentGradeFiltersSet(
|
||||
this.props.courseId,
|
||||
this.props.selectedCohort,
|
||||
this.props.selectedTrack,
|
||||
this.props.selectedAssignmentType,
|
||||
);
|
||||
};
|
||||
|
||||
handleSubmitAssignmentGrade = (event) => {
|
||||
event.preventDefault();
|
||||
const {
|
||||
assignmentGradeMin,
|
||||
assignmentGradeMax,
|
||||
} = this.props;
|
||||
|
||||
this.props.updateAssignmentLimits(assignmentGradeMin, assignmentGradeMax);
|
||||
this.props.getUserGrades(
|
||||
this.props.courseId,
|
||||
this.props.selectedCohort,
|
||||
this.props.selectedTrack,
|
||||
this.props.selectedAssignmentType,
|
||||
);
|
||||
this.props.updateQueryParams({ assignmentGradeMin, assignmentGradeMax });
|
||||
};
|
||||
|
||||
mapAssignmentTypeEntries = (entries) => {
|
||||
const mapped = [
|
||||
{ id: 0, label: 'All', value: '' },
|
||||
...entries.map(entry => ({ id: entry, label: entry })),
|
||||
];
|
||||
return mapped;
|
||||
};
|
||||
|
||||
updateAssignmentTypes = (assignmentType) => {
|
||||
this.props.filterAssignmentType(assignmentType);
|
||||
this.updateQueryParams({ assignmentType });
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Collapsible title="Assignments" open className="filter-group mb-3">
|
||||
<div>
|
||||
<div className="student-filters">
|
||||
<span className="label">
|
||||
Assignment Types:
|
||||
</span>
|
||||
<InputSelect
|
||||
label="Assignment Types"
|
||||
name="assignment-types"
|
||||
aria-label="Assignment Types"
|
||||
value={this.props.selectedAssignmentType}
|
||||
options={this.mapAssignmentTypeEntries(this.props.assignmentTypes)}
|
||||
onChange={this.updateAssignmentTypes}
|
||||
disabled={this.props.assignmentFilterOptions.length === 0}
|
||||
/>
|
||||
</div>
|
||||
<div className="student-filters">
|
||||
<span className="label">
|
||||
Assignment:
|
||||
</span>
|
||||
<InputSelect
|
||||
label="Assignment"
|
||||
name="assignment"
|
||||
aria-label="Assignment"
|
||||
value={this.props.selectedAssignment}
|
||||
options={this.getAssignmentFilterOptions()}
|
||||
onChange={this.handleAssignmentFilterChange}
|
||||
disabled={this.props.assignmentFilterOptions.length === 0}
|
||||
/>
|
||||
</div>
|
||||
<p>Grade Range (0% - 100%)</p>
|
||||
<form className="d-fnlex justify-content-between align-items-center" onSubmit={this.handleSubmitAssignmentGrade}>
|
||||
<InputText
|
||||
label="Min Grade"
|
||||
name="assignmentGradeMin"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={this.props.assignmentGradeMin}
|
||||
disabled={!this.props.selectedAssignment}
|
||||
onChange={this.props.setAssignmentGradeMin}
|
||||
/>
|
||||
<span className="input-percent-label">%</span>
|
||||
<InputText
|
||||
label="Max Grade"
|
||||
name="assignmentGradeMax"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={this.props.assignmentGradeMax}
|
||||
disabled={!this.props.selectedAssignment}
|
||||
onChange={this.props.setAssignmentGradeMax}
|
||||
/>
|
||||
<span className="input-percent-label">%</span>
|
||||
<Button
|
||||
type="submit"
|
||||
className="btn-outline-secondary"
|
||||
name="assignmentGradeMinMax"
|
||||
disabled={!this.props.selectedAssignment}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Assignments.defaultProps = {
|
||||
assignmentTypes: [],
|
||||
assignmentFilterOptions: [],
|
||||
selectedAssignment: '',
|
||||
selectedAssignmentType: '',
|
||||
selectedCohort: null,
|
||||
selectedTrack: null,
|
||||
};
|
||||
|
||||
Assignments.propTypes = {
|
||||
assignmentGradeMin: PropTypes.string.isRequired,
|
||||
assignmentGradeMax: PropTypes.string.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
setAssignmentGradeMin: PropTypes.func.isRequired,
|
||||
setAssignmentGradeMax: PropTypes.func.isRequired,
|
||||
updateQueryParams: PropTypes.func.isRequired,
|
||||
|
||||
// redux
|
||||
assignmentTypes: PropTypes.arrayOf(PropTypes.string),
|
||||
assignmentFilterOptions: PropTypes.arrayOf(PropTypes.shape({
|
||||
label: PropTypes.string,
|
||||
subsectionLabel: PropTypes.string,
|
||||
})),
|
||||
filterAssignmentType: PropTypes.func.isRequired,
|
||||
getUserGrades: PropTypes.func.isRequired,
|
||||
selectedAssignmentType: PropTypes.string,
|
||||
selectedAssignment: PropTypes.string,
|
||||
selectedCohort: PropTypes.string,
|
||||
selectedTrack: PropTypes.string,
|
||||
updateGradesIfAssignmentGradeFiltersSet: PropTypes.func.isRequired,
|
||||
updateAssignmentFilter: PropTypes.func.isRequired,
|
||||
updateAssignmentLimits: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => ({
|
||||
assignmentTypes: state.assignmentTypes.results,
|
||||
assignmentFilterOptions: selectableAssignmentLabels(state),
|
||||
selectedAssignment: (state.filters.assignment || {}).label,
|
||||
selectedAssignmentTypes: state.filters.assignmentType,
|
||||
selectedCohort: state.filters.cohort,
|
||||
selectedTrack: state.filters.track,
|
||||
});
|
||||
|
||||
export const mapDispatchToProps = {
|
||||
getUserGrades: fetchGrades,
|
||||
filterAssignmentType,
|
||||
updateAssignmentFilter,
|
||||
updateAssignmentLimits,
|
||||
updateGradesIfAssignmentGradeFiltersSet,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(Assignments);
|
||||
200
src/components/Gradebook/BulkManagement.jsx
Normal file
200
src/components/Gradebook/BulkManagement.jsx
Normal file
@@ -0,0 +1,200 @@
|
||||
/* eslint-disable react/sort-comp, react/button-has-type */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import {
|
||||
Button,
|
||||
StatusAlert,
|
||||
Table,
|
||||
Tab,
|
||||
} from '@edx/paragon';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faDownload } from '@fortawesome/free-solid-svg-icons';
|
||||
import { configuration } from '../../config';
|
||||
|
||||
import { submitFileUploadFormData } from '../../data/actions/grades';
|
||||
import { getBulkManagementHistory } from '../../data/selectors/grades';
|
||||
|
||||
export class BulkManagement extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.fileFormRef = React.createRef();
|
||||
this.fileInputRef = React.createRef();
|
||||
}
|
||||
|
||||
formatHistoryRow = (row) => {
|
||||
const {
|
||||
summaryOfRowsProcessed: {
|
||||
total,
|
||||
successfullyProcessed,
|
||||
failed,
|
||||
skipped,
|
||||
},
|
||||
unique_id: courseId,
|
||||
originalFilename,
|
||||
id,
|
||||
user: username,
|
||||
...rest
|
||||
} = row;
|
||||
const resultsText = [
|
||||
`${total} Students: ${successfullyProcessed} processed`,
|
||||
...(skipped > 0 ? [`${skipped} skipped`] : []),
|
||||
...(failed > 0 ? [`${failed} failed`] : []),
|
||||
].join(', ');
|
||||
const resultsSummary = (
|
||||
<a
|
||||
href={`${configuration.LMS_BASE_URL}/api/bulk_grades/course/${courseId}/?error_id=${id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<FontAwesomeIcon icon={faDownload} />
|
||||
{resultsText}
|
||||
</a>
|
||||
);
|
||||
const createWrappedCell = (text) => (<span className="wrap-text-in-cell">{text}</span>);
|
||||
const filename = createWrappedCell(originalFilename);
|
||||
const user = createWrappedCell(username);
|
||||
return {
|
||||
resultsSummary,
|
||||
filename,
|
||||
user,
|
||||
...rest,
|
||||
};
|
||||
};
|
||||
|
||||
handleClickImportGrades = () => {
|
||||
const fileInput = this.fileInputRef.current;
|
||||
if (fileInput) {
|
||||
fileInput.click();
|
||||
}
|
||||
};
|
||||
|
||||
handleFileInputChange = (event) => {
|
||||
const fileInput = event.target;
|
||||
const file = fileInput.files[0];
|
||||
const form = this.fileFormRef.current;
|
||||
if (file && form) {
|
||||
const formData = new FormData(form);
|
||||
this.props.submitFileUploadFormData(this.props.courseId, formData).then(() => {
|
||||
fileInput.value = null;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Tab eventKey="bulk_management" title="Bulk Management">
|
||||
<h4>Use this feature by downloading a CSV for bulk management,
|
||||
overriding grades locally, and coming back here to upload.
|
||||
</h4>
|
||||
<form ref={this.fileFormRef} action={this.props.gradeExportUrl} method="post">
|
||||
<StatusAlert
|
||||
alertType="danger"
|
||||
dialog={this.props.bulkImportError}
|
||||
open={this.props.bulkImportError}
|
||||
dismissible={false}
|
||||
/>
|
||||
<StatusAlert
|
||||
alertType="success"
|
||||
dialog="CSV processing. File uploads may take several minutes to complete"
|
||||
open={this.props.uploadSuccess}
|
||||
dismissible={false}
|
||||
/>
|
||||
<input
|
||||
className="d-none"
|
||||
type="file"
|
||||
name="csv"
|
||||
label="Upload Grade CSV"
|
||||
onChange={this.handleFileInputChange}
|
||||
ref={this.fileInputRef}
|
||||
/>
|
||||
</form>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={this.handleClickImportGrades}
|
||||
>
|
||||
Import Grades
|
||||
</Button>
|
||||
<p>
|
||||
Results appear in the table below.<br />
|
||||
Grade processing may take a few seconds.
|
||||
</p>
|
||||
<Table
|
||||
data={this.props.bulkManagementHistory.map(this.formatHistoryRow)}
|
||||
hasFixedColumnWidths
|
||||
columns={[
|
||||
{
|
||||
key: 'filename',
|
||||
label: 'Gradebook',
|
||||
columnSortable: false,
|
||||
width: 'col-5',
|
||||
},
|
||||
{
|
||||
key: 'resultsSummary',
|
||||
label: 'Download Summary',
|
||||
columnSortable: false,
|
||||
width: 'col',
|
||||
},
|
||||
{
|
||||
key: 'user',
|
||||
label: 'Who',
|
||||
columnSortable: false,
|
||||
width: 'col-1',
|
||||
},
|
||||
{
|
||||
key: 'timeUploaded',
|
||||
label: 'When',
|
||||
columnSortable: false,
|
||||
width: 'col',
|
||||
},
|
||||
]}
|
||||
className="table-striped"
|
||||
/>
|
||||
</Tab>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
BulkManagement.defaultProps = {
|
||||
bulkImportError: '',
|
||||
bulkManagementHistory: [],
|
||||
courseId: '',
|
||||
uploadSuccess: false,
|
||||
};
|
||||
|
||||
BulkManagement.propTypes = {
|
||||
courseId: PropTypes.string,
|
||||
gradeExportUrl: PropTypes.string.isRequired,
|
||||
|
||||
// redux
|
||||
bulkImportError: PropTypes.string,
|
||||
bulkManagementHistory: PropTypes.arrayOf(PropTypes.shape({
|
||||
originalFilename: PropTypes.string.isRequired,
|
||||
user: PropTypes.string.isRequired,
|
||||
timeUploaded: PropTypes.string.isRequired,
|
||||
summaryOfRowsProcessed: PropTypes.shape({
|
||||
total: PropTypes.number.isRequired,
|
||||
successfullyProcessed: PropTypes.number.isRequired,
|
||||
failed: PropTypes.number.isRequired,
|
||||
skipped: PropTypes.number.isRequired,
|
||||
}).isRequired,
|
||||
})),
|
||||
submitFileUploadFormData: PropTypes.func.isRequired,
|
||||
uploadSuccess: PropTypes.bool,
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => ({
|
||||
bulkImportError: state.grades.bulkManagement
|
||||
&& state.grades.bulkManagement.errorMessages
|
||||
? `Errors while processing: ${state.grades.bulkManagement.errorMessages.join(', ')}`
|
||||
: '',
|
||||
bulkManagementHistory: getBulkManagementHistory(state),
|
||||
uploadSuccess: !!(state.grades.bulkManagement && state.grades.bulkManagement.uploadSuccess),
|
||||
});
|
||||
|
||||
export const mapDispatchToProps = {
|
||||
submitFileUploadFormData,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(BulkManagement);
|
||||
90
src/components/Gradebook/BulkManagementControls.jsx
Normal file
90
src/components/Gradebook/BulkManagementControls.jsx
Normal file
@@ -0,0 +1,90 @@
|
||||
/* eslint-disable react/sort-comp, react/button-has-type */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { StatefulButton } from '@edx/paragon';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faDownload, faSpinner } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
import {
|
||||
downloadBulkGradesReport,
|
||||
downloadInterventionReport,
|
||||
} from '../../data/actions/grades';
|
||||
|
||||
export class BulkManagementControls extends React.Component {
|
||||
handleClickDownloadInterventions = () => {
|
||||
this.props.downloadInterventionReport(this.props.courseId);
|
||||
window.location = this.props.interventionExportUrl;
|
||||
};
|
||||
|
||||
// At present, we don't store label and value in google analytics. By setting the label
|
||||
// property of the below events, I want to verify that we can set the label of google anlatyics
|
||||
// The following properties of a google analytics event are:
|
||||
// category (used), name(used), lavel(not used), value(not used)
|
||||
handleClickExportGrades = () => {
|
||||
this.props.downloadBulkGradesReport(this.props.courseId);
|
||||
window.location = this.props.gradeExportUrl;
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<StatefulButton
|
||||
variant="outline-primary"
|
||||
onClick={this.handleClickExportGrades}
|
||||
state={this.props.showSpinner ? 'pending' : 'default'}
|
||||
labels={{
|
||||
default: 'Bulk Management',
|
||||
pending: 'Bulk Management',
|
||||
}}
|
||||
icons={{
|
||||
default: <FontAwesomeIcon className="mr-2" icon={faDownload} />,
|
||||
pending: <FontAwesomeIcon className="fa-spin mr-2" icon={faSpinner} />,
|
||||
}}
|
||||
disabledStates={['pending']}
|
||||
/>
|
||||
<StatefulButton
|
||||
variant="outline-primary"
|
||||
onClick={this.handleClickDownloadInterventions}
|
||||
state={this.props.showSpinner ? 'pending' : 'default'}
|
||||
className="ml-2"
|
||||
labels={{
|
||||
default: 'Interventions*',
|
||||
pending: 'Interventions*',
|
||||
}}
|
||||
icons={{
|
||||
default: <FontAwesomeIcon className="mr-2" icon={faDownload} />,
|
||||
pending: <FontAwesomeIcon className="fa-spin mr-2" icon={faSpinner} />,
|
||||
}}
|
||||
disabledStates={['pending']}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
BulkManagementControls.defaultProps = {
|
||||
courseId: '',
|
||||
showSpinner: false,
|
||||
};
|
||||
|
||||
BulkManagementControls.propTypes = {
|
||||
courseId: PropTypes.string,
|
||||
gradeExportUrl: PropTypes.string.isRequired,
|
||||
interventionExportUrl: PropTypes.string.isRequired,
|
||||
showSpinner: PropTypes.bool,
|
||||
|
||||
// redux
|
||||
downloadBulkGradesReport: PropTypes.func.isRequired,
|
||||
downloadInterventionReport: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export const mapStateToProps = () => ({ });
|
||||
|
||||
export const mapDispatchToProps = {
|
||||
downloadBulkGradesReport,
|
||||
downloadInterventionReport,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(BulkManagementControls);
|
||||
203
src/components/Gradebook/EditModal.jsx
Normal file
203
src/components/Gradebook/EditModal.jsx
Normal file
@@ -0,0 +1,203 @@
|
||||
/* eslint-disable react/sort-comp, react/button-has-type */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
StatusAlert,
|
||||
Table,
|
||||
} from '@edx/paragon';
|
||||
|
||||
import {
|
||||
doneViewingAssignment,
|
||||
updateGrades,
|
||||
} from '../../data/actions/grades';
|
||||
|
||||
const GRADE_OVERRIDE_HISTORY_COLUMNS = [{ label: 'Date', key: 'date' }, { label: 'Grader', key: 'grader' },
|
||||
{ label: 'Reason', key: 'reason' },
|
||||
{ label: 'Adjusted grade', key: 'adjustedGrade' }];
|
||||
|
||||
export class EditModal extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.overrideReasonInput = React.createRef();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.overrideReasonInput.current.focus();
|
||||
}
|
||||
|
||||
handleAdjustedGradeClick = () => {
|
||||
this.props.updateGrades(
|
||||
this.props.courseId, [
|
||||
{
|
||||
user_id: this.props.updateUserId,
|
||||
usage_id: this.props.updateModuleId,
|
||||
grade: {
|
||||
earned_graded_override: this.props.adjustedGradeValue,
|
||||
comment: this.props.reasonForChange,
|
||||
},
|
||||
},
|
||||
],
|
||||
this.props.filterValue,
|
||||
this.props.selectedCohort,
|
||||
this.props.selectedTrack,
|
||||
);
|
||||
|
||||
this.closeAssignmentModal();
|
||||
}
|
||||
|
||||
closeAssignmentModal = () => {
|
||||
this.props.doneViewingAssignment();
|
||||
this.props.setGradebookState({
|
||||
adjustedGradePossible: '',
|
||||
adjustedGradeValue: '',
|
||||
modalOpen: false,
|
||||
reasonForChange: '',
|
||||
updateModuleId: null,
|
||||
updateUserId: null,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Modal
|
||||
open={this.props.open}
|
||||
title="Edit Grades"
|
||||
closeText="Cancel"
|
||||
body={(
|
||||
<div>
|
||||
<div>
|
||||
<div className="grade-history-header grade-history-assignment">Assignment: </div>
|
||||
<div>{this.props.assignmentName}</div>
|
||||
<div className="grade-history-header grade-history-student">Student: </div>
|
||||
<div>{this.props.updateUserName}</div>
|
||||
<div className="grade-history-header grade-history-original-grade">Original Grade: </div>
|
||||
<div>{this.props.gradeOriginalEarnedGraded}</div>
|
||||
<div className="grade-history-header grade-history-current-grade">Current Grade: </div>
|
||||
<div>{this.props.gradeOverrideCurrentEarnedGradedOverride}</div>
|
||||
</div>
|
||||
<StatusAlert
|
||||
alertType="danger"
|
||||
dialog={this.props.gradeOverrideHistoryError}
|
||||
open={!!this.props.gradeOverrideHistoryError}
|
||||
dismissible={false}
|
||||
/>
|
||||
{!this.props.gradeOverrideHistoryError && (
|
||||
<Table
|
||||
columns={GRADE_OVERRIDE_HISTORY_COLUMNS}
|
||||
data={[...this.props.gradeOverrides, {
|
||||
date: this.props.todaysDate,
|
||||
reason: (<input
|
||||
type="text"
|
||||
name="reasonForChange"
|
||||
value={this.props.reasonForChange}
|
||||
onChange={this.props.setReasonForChange}
|
||||
ref={this.overrideReasonInput}
|
||||
/>),
|
||||
adjustedGrade: (
|
||||
<span>
|
||||
<input
|
||||
type="text"
|
||||
name="adjustedGradeValue"
|
||||
value={this.props.adjustedGradeValue}
|
||||
onChange={this.props.setAdjustedGradeValue}
|
||||
/>
|
||||
{(this.props.adjustedGradePossible || this.props.gradeOriginalPossibleGraded) && ' / '}
|
||||
{this.props.adjustedGradePossible || this.props.gradeOriginalPossibleGraded}
|
||||
</span>),
|
||||
}]}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div>Showing most recent actions (max 5). To see more, please contact
|
||||
support.
|
||||
</div>
|
||||
<div>Note: Once you save, your changes will be visible to students.</div>
|
||||
</div>
|
||||
)}
|
||||
buttons={[
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={this.handleAdjustedGradeClick}
|
||||
>
|
||||
Save Grade
|
||||
</Button>,
|
||||
]}
|
||||
onClose={this.closeAssignmentModal}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
EditModal.defaultProps = {
|
||||
adjustedGradeValue: null,
|
||||
courseId: '',
|
||||
gradeOverrideCurrentEarnedGradedOverride: null,
|
||||
gradeOverrideHistoryError: '',
|
||||
gradeOverrides: [],
|
||||
gradeOriginalEarnedGraded: null,
|
||||
gradeOriginalPossibleGraded: null,
|
||||
selectedCohort: null,
|
||||
selectedTrack: null,
|
||||
updateModuleId: '',
|
||||
updateUserId: '',
|
||||
updateUserName: '',
|
||||
};
|
||||
|
||||
EditModal.propTypes = {
|
||||
courseId: PropTypes.string,
|
||||
|
||||
// Gradebook State
|
||||
adjustedGradePossible: PropTypes.string.isRequired,
|
||||
// should pick one?
|
||||
adjustedGradeValue: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
|
||||
assignmentName: PropTypes.string.isRequired,
|
||||
filterValue: PropTypes.string.isRequired,
|
||||
open: PropTypes.bool.isRequired,
|
||||
reasonForChange: PropTypes.string.isRequired,
|
||||
todaysDate: PropTypes.string.isRequired,
|
||||
updateModuleId: PropTypes.string,
|
||||
updateUserId: PropTypes.number,
|
||||
updateUserName: PropTypes.string,
|
||||
|
||||
// Gradebook State Setters
|
||||
setAdjustedGradeValue: PropTypes.func.isRequired,
|
||||
setGradebookState: PropTypes.func.isRequired,
|
||||
setReasonForChange: PropTypes.func.isRequired,
|
||||
|
||||
// redux
|
||||
doneViewingAssignment: PropTypes.func.isRequired,
|
||||
gradeOverrideCurrentEarnedGradedOverride: PropTypes.number,
|
||||
gradeOverrideHistoryError: PropTypes.string,
|
||||
gradeOverrides: PropTypes.arrayOf(PropTypes.shape({
|
||||
date: PropTypes.string,
|
||||
grader: PropTypes.string,
|
||||
reason: PropTypes.string,
|
||||
adjustedGrade: PropTypes.number,
|
||||
})),
|
||||
gradeOriginalEarnedGraded: PropTypes.number,
|
||||
gradeOriginalPossibleGraded: PropTypes.number,
|
||||
selectedCohort: PropTypes.string,
|
||||
selectedTrack: PropTypes.string,
|
||||
updateGrades: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => ({
|
||||
gradeOverrides: state.grades.gradeOverrideHistoryResults,
|
||||
gradeOverrideCurrentEarnedGradedOverride: state.grades.gradeOverrideCurrentEarnedGradedOverride,
|
||||
gradeOverrideHistoryError: state.grades.gradeOverrideHistoryError,
|
||||
gradeOriginalEarnedGraded: state.grades.gradeOriginalEarnedGraded,
|
||||
grdaeOriginalPossibleGraded: state.grades.grdaeOriginalPossibleGraded,
|
||||
selectedCohort: state.filters.cohort,
|
||||
selectedTrack: state.filters.track,
|
||||
});
|
||||
|
||||
export const mapDispatchToProps = {
|
||||
doneViewingAssignment,
|
||||
updateGrades,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(EditModal);
|
||||
203
src/components/Gradebook/GradebookTable.jsx
Normal file
203
src/components/Gradebook/GradebookTable.jsx
Normal file
@@ -0,0 +1,203 @@
|
||||
/* eslint-disable react/sort-comp, react/button-has-type */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { Table } from '@edx/paragon';
|
||||
|
||||
import { formatDateForDisplay } from '../../data/actions/utils';
|
||||
import { getHeadings } from '../../data/selectors/grades';
|
||||
import { fetchGradeOverrideHistory } from '../../data/actions/grades';
|
||||
|
||||
const DECIMAL_PRECISION = 2;
|
||||
|
||||
export class GradebookTable extends React.Component {
|
||||
setNewModalState = (userEntry, subsection) => {
|
||||
this.props.fetchGradeOverrideHistory(
|
||||
subsection.module_id,
|
||||
userEntry.user_id,
|
||||
);
|
||||
|
||||
let adjustedGradePossible = '';
|
||||
if (subsection.attempted) {
|
||||
adjustedGradePossible = subsection.score_possible;
|
||||
}
|
||||
|
||||
this.props.setGradebookState({
|
||||
adjustedGradePossible,
|
||||
adjustedGradeValue: '',
|
||||
assignmentName: `${subsection.subsection_name}`,
|
||||
modalOpen: true,
|
||||
reasonForChange: '',
|
||||
todaysDate: formatDateForDisplay(new Date()),
|
||||
updateModuleId: subsection.module_id,
|
||||
updateUserId: userEntry.user_id,
|
||||
updateUserName: userEntry.username,
|
||||
});
|
||||
}
|
||||
|
||||
getLearnerInformation = entry => (
|
||||
<div>
|
||||
<div>{entry.username}</div>
|
||||
{entry.external_user_key && <div className="student-key">{entry.external_user_key}</div>}
|
||||
</div>
|
||||
)
|
||||
|
||||
roundGrade = percent => parseFloat((percent || 0).toFixed(DECIMAL_PRECISION));
|
||||
|
||||
formatter = {
|
||||
percent: (entries, areGradesFrozen) => entries.map((entry) => {
|
||||
const learnerInformation = this.getLearnerInformation(entry);
|
||||
const results = {
|
||||
Username: (
|
||||
<div><span className="wrap-text-in-cell">{learnerInformation}</span></div>
|
||||
),
|
||||
Email: (
|
||||
<span className="wrap-text-in-cell">{entry.email}</span>
|
||||
),
|
||||
};
|
||||
|
||||
const assignments = entry.section_breakdown
|
||||
.reduce((acc, subsection) => {
|
||||
if (areGradesFrozen) {
|
||||
acc[subsection.label] = `${this.roundGrade(subsection.percent * 100)} %`;
|
||||
} else {
|
||||
acc[subsection.label] = (
|
||||
<button
|
||||
className="btn btn-header link-style grade-button"
|
||||
onClick={() => this.setNewModalState(entry, subsection)}
|
||||
>
|
||||
{this.roundGrade(subsection.percent * 100)}%
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
const totals = { Total: `${this.roundGrade(entry.percent * 100)}%` };
|
||||
return Object.assign(results, assignments, totals);
|
||||
}),
|
||||
|
||||
absolute: (entries, areGradesFrozen) => entries.map((entry) => {
|
||||
const learnerInformation = this.getLearnerInformation(entry);
|
||||
const results = {
|
||||
Username: (
|
||||
<div><span className="wrap-text-in-cell">{learnerInformation}</span></div>
|
||||
),
|
||||
Email: (
|
||||
<span className="wrap-text-in-cell">{entry.email}</span>
|
||||
),
|
||||
};
|
||||
|
||||
const assignments = entry.section_breakdown
|
||||
.reduce((acc, subsection) => {
|
||||
const scoreEarned = this.roundGrade(subsection.score_earned);
|
||||
const scorePossible = this.roundGrade(subsection.score_possible);
|
||||
let label = `${scoreEarned}`;
|
||||
if (subsection.attempted) {
|
||||
label = `${scoreEarned}/${scorePossible}`;
|
||||
}
|
||||
if (areGradesFrozen) {
|
||||
acc[subsection.label] = label;
|
||||
} else {
|
||||
acc[subsection.label] = (
|
||||
<button
|
||||
className="btn btn-header link-style"
|
||||
onClick={() => this.setNewModalState(entry, subsection)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const totals = { Total: `${this.roundGrade(entry.percent * 100)}/100` };
|
||||
return Object.assign(results, assignments, totals);
|
||||
}),
|
||||
};
|
||||
|
||||
formatHeadings = () => {
|
||||
let headings = [...this.props.headings];
|
||||
|
||||
if (headings.length > 0) {
|
||||
const userInformationHeadingLabel = (
|
||||
<div>
|
||||
<div>Username</div>
|
||||
<div className="font-weight-normal student-key">Student Key*</div>
|
||||
</div>
|
||||
);
|
||||
const emailHeadingLabel = 'Email*';
|
||||
|
||||
headings = headings.map(heading => ({
|
||||
label: heading,
|
||||
key: heading,
|
||||
}));
|
||||
|
||||
// replace username heading label to include additional user data
|
||||
headings[0].label = userInformationHeadingLabel;
|
||||
headings[1].label = emailHeadingLabel;
|
||||
}
|
||||
|
||||
return headings;
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="gradebook-container">
|
||||
<div className="gbook">
|
||||
<Table
|
||||
columns={this.formatHeadings()}
|
||||
data={this.formatter[this.props.format](
|
||||
this.props.grades,
|
||||
this.props.areGradesFrozen,
|
||||
)}
|
||||
rowHeaderColumnKey="username"
|
||||
hasFixedColumnWidths
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
GradebookTable.defaultProps = {
|
||||
areGradesFrozen: false,
|
||||
grades: [],
|
||||
};
|
||||
|
||||
GradebookTable.propTypes = {
|
||||
setGradebookState: PropTypes.func.isRequired,
|
||||
// redux
|
||||
areGradesFrozen: PropTypes.bool,
|
||||
format: PropTypes.string.isRequired,
|
||||
grades: PropTypes.arrayOf(PropTypes.shape({
|
||||
percent: PropTypes.number,
|
||||
section_breakdown: PropTypes.arrayOf(PropTypes.shape({
|
||||
attempted: PropTypes.bool,
|
||||
category: PropTypes.string,
|
||||
label: PropTypes.string,
|
||||
module_id: PropTypes.string,
|
||||
percent: PropTypes.number,
|
||||
scoreEarned: PropTypes.number,
|
||||
scorePossible: PropTypes.number,
|
||||
subsection_name: PropTypes.string,
|
||||
})),
|
||||
user_id: PropTypes.number,
|
||||
user_name: PropTypes.string,
|
||||
})),
|
||||
headings: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
fetchGradeOverrideHistory: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => ({
|
||||
areGradesFrozen: state.assignmentTypes.areGradesFrozen,
|
||||
format: state.grades.gradeFormat,
|
||||
grades: state.grades.results,
|
||||
headings: getHeadings(state),
|
||||
});
|
||||
|
||||
export const mapDispatchToProps = {
|
||||
fetchGradeOverrideHistory,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(GradebookTable);
|
||||
171
src/components/Gradebook/gradebook.scss
Normal file
171
src/components/Gradebook/gradebook.scss
Normal file
@@ -0,0 +1,171 @@
|
||||
.spinner-overlay {
|
||||
position: fixed;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background-color: #999;
|
||||
opacity: 0.5;
|
||||
z-index: 99999;
|
||||
display:flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 200px;
|
||||
}
|
||||
|
||||
.color-black {
|
||||
color: black;
|
||||
}
|
||||
|
||||
.gradebook-content {
|
||||
// note that this width isn't well-abstracted from Drawer code.
|
||||
// if we need to change it we may need to dig into those styles as well
|
||||
width: 100vw;
|
||||
.search-help-text {
|
||||
margin-left: 20px;
|
||||
}
|
||||
h4 {
|
||||
font-weight: bold;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.student-filters{
|
||||
display: flex;
|
||||
.label{
|
||||
padding-top: 30px;
|
||||
}
|
||||
.form-group{
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
.grade-history-header{
|
||||
float: left;
|
||||
}
|
||||
|
||||
.grade-history-assignment{
|
||||
padding-right: 49px;
|
||||
}
|
||||
.grade-history-student{
|
||||
padding-right: 75px;
|
||||
}
|
||||
|
||||
.grade-history-original-grade{
|
||||
padding-right: 25px;
|
||||
}
|
||||
|
||||
.grade-history-current-grade{
|
||||
padding-right: 25px;
|
||||
}
|
||||
.gradebook-container {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
height: 600px;
|
||||
overflow-y: auto;
|
||||
word-break: break-word;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.gbook {
|
||||
width: 100%;
|
||||
|
||||
.grade-button {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.student-key {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
|
||||
.table thead tr {
|
||||
min-height: 60px;
|
||||
&:nth-child(1) {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background-color: white;
|
||||
th {
|
||||
background-color: white;
|
||||
border-bottom: 1px solid $gray_200;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
thead, tbody, tr, td, th {
|
||||
display: block;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.table tr th:first-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.table tr th:first-child,
|
||||
.table tr td:first-child {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 1; // to float over the following children in the side-scrolling case
|
||||
background: white;
|
||||
}
|
||||
|
||||
.table tr {
|
||||
th:nth-child(1),
|
||||
td:nth-child(1),
|
||||
th:nth-child(2),
|
||||
td:nth-child(2) {
|
||||
min-width: 240px;
|
||||
}
|
||||
th, td {
|
||||
min-width: 120px;
|
||||
}
|
||||
}
|
||||
.table tbody th {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.table {
|
||||
overflow-x: hidden;
|
||||
|
||||
height: 100%;
|
||||
|
||||
tbody {
|
||||
overflow-y: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
thead, tbody tr {
|
||||
display: table;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
th {
|
||||
vertical-align: top;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.link-style {
|
||||
color: #0075b4;
|
||||
&:hover, &:focus {
|
||||
color: #004368;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.input-percent-label {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.mb-85 {
|
||||
margin-bottom: 85px;
|
||||
}
|
||||
|
||||
.modal-dialog {
|
||||
max-width: 1000px;
|
||||
}
|
||||
|
||||
.wrap-text-in-cell {
|
||||
overflow-wrap: break-word;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
519
src/components/Gradebook/index.jsx
Normal file
519
src/components/Gradebook/index.jsx
Normal file
@@ -0,0 +1,519 @@
|
||||
/* eslint-disable react/sort-comp, react/button-has-type, import/no-named-as-default */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
Button,
|
||||
Collapsible,
|
||||
Icon,
|
||||
InputSelect,
|
||||
InputText,
|
||||
SearchField,
|
||||
StatusAlert,
|
||||
Tab,
|
||||
Tabs,
|
||||
} from '@edx/paragon';
|
||||
import queryString from 'query-string';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faFilter } from '@fortawesome/free-solid-svg-icons';
|
||||
import { configuration } from '../../config';
|
||||
import PageButtons from '../PageButtons';
|
||||
import Drawer from '../Drawer';
|
||||
import initialFilters from '../../data/constants/filters';
|
||||
import ConnectedFilterBadges from '../FilterBadges';
|
||||
|
||||
import Assignments from './Assignments';
|
||||
import BulkManagement from './BulkManagement';
|
||||
import BulkManagementControls from './BulkManagementControls';
|
||||
import EditModal from './EditModal';
|
||||
import GradebookTable from './GradebookTable';
|
||||
|
||||
export default class Gradebook extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
adjustedGradePossible: '',
|
||||
adjustedGradeValue: 0,
|
||||
assignmentGradeMin: '0',
|
||||
assignmentGradeMax: '100',
|
||||
assignmentName: '',
|
||||
courseGradeMin: '0',
|
||||
courseGradeMax: '100',
|
||||
filterValue: '',
|
||||
isMinCourseGradeFilterValid: true,
|
||||
isMaxCourseGradeFilterValid: true,
|
||||
modalOpen: false,
|
||||
reasonForChange: '',
|
||||
todaysDate: '',
|
||||
updateModuleId: null,
|
||||
updateUserId: null,
|
||||
};
|
||||
this.myRef = React.createRef();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const urlQuery = queryString.parse(this.props.location.search);
|
||||
this.props.initializeFilters(urlQuery);
|
||||
this.props.getRoles(this.props.courseId);
|
||||
|
||||
const newStateFields = {};
|
||||
['assignmentGradeMin', 'assignmentGradeMax', 'courseGradeMin', 'courseGradeMax'].forEach((attr) => {
|
||||
if (urlQuery[attr]) {
|
||||
newStateFields[attr] = urlQuery[attr];
|
||||
}
|
||||
});
|
||||
|
||||
this.setState(newStateFields);
|
||||
}
|
||||
|
||||
onChange(e) {
|
||||
this.setState({ [e.target.name]: e.target.value });
|
||||
}
|
||||
|
||||
getActiveTabs = () => {
|
||||
if (this.props.showBulkManagement) {
|
||||
return ['Grades', 'Bulk Management'];
|
||||
}
|
||||
return ['Grades'];
|
||||
};
|
||||
|
||||
getCourseGradeFilterAlertDialog = () => {
|
||||
let dialog = '';
|
||||
|
||||
if (!this.state.isMinCourseGradeFilterValid) {
|
||||
dialog += 'Minimum course grade value must be between 0 and 100. ';
|
||||
}
|
||||
if (!this.state.isMaxCourseGradeFilterValid) {
|
||||
dialog += 'Maximum course grade value must be between 0 and 100. ';
|
||||
}
|
||||
return dialog;
|
||||
};
|
||||
|
||||
updateQueryParams = (queryParams) => {
|
||||
const parsed = queryString.parse(this.props.location.search);
|
||||
Object.keys(queryParams).forEach((key) => {
|
||||
if (queryParams[key]) {
|
||||
parsed[key] = queryParams[key];
|
||||
} else {
|
||||
delete parsed[key];
|
||||
}
|
||||
});
|
||||
this.props.history.push(`?${queryString.stringify(parsed)}`);
|
||||
};
|
||||
|
||||
mapCohortsEntries = (entries) => {
|
||||
const mapped = entries.map(entry => ({
|
||||
id: entry.id,
|
||||
label: entry.name,
|
||||
}));
|
||||
mapped.unshift({ id: 0, label: 'Cohort-All' });
|
||||
return mapped;
|
||||
};
|
||||
|
||||
mapTracksEntries = (entries) => {
|
||||
const mapped = entries.map(entry => ({
|
||||
id: entry.slug,
|
||||
label: entry.name,
|
||||
}));
|
||||
mapped.unshift({ label: 'Track-All' });
|
||||
return mapped;
|
||||
};
|
||||
|
||||
updateTracks = (event) => {
|
||||
const selectedTrackItem = this.props.tracks.find(x => x.name === event);
|
||||
let selectedTrackSlug = null;
|
||||
if (selectedTrackItem) {
|
||||
selectedTrackSlug = selectedTrackItem.slug;
|
||||
}
|
||||
this.props.getUserGrades(
|
||||
this.props.courseId,
|
||||
this.props.selectedCohort,
|
||||
selectedTrackSlug,
|
||||
this.props.selectedAssignmentType,
|
||||
);
|
||||
this.updateQueryParams({ track: selectedTrackSlug });
|
||||
};
|
||||
|
||||
updateCohorts = (event) => {
|
||||
const selectedCohortItem = this.props.cohorts.find(x => x.name === event);
|
||||
let selectedCohortId = null;
|
||||
if (selectedCohortItem) {
|
||||
selectedCohortId = selectedCohortItem.id;
|
||||
}
|
||||
this.props.getUserGrades(
|
||||
this.props.courseId,
|
||||
selectedCohortId,
|
||||
this.props.selectedTrack,
|
||||
this.props.selectedAssignmentType,
|
||||
);
|
||||
this.updateQueryParams({ cohort: selectedCohortId });
|
||||
};
|
||||
|
||||
mapSelectedCohortEntry = (entry) => {
|
||||
const selectedCohortEntry = this.props.cohorts.find(x => x.id === parseInt(entry, 10));
|
||||
if (selectedCohortEntry) {
|
||||
return selectedCohortEntry.name;
|
||||
}
|
||||
return 'Cohorts';
|
||||
};
|
||||
|
||||
mapSelectedTrackEntry = (entry) => {
|
||||
const selectedTrackEntry = this.props.tracks.find(x => x.slug === entry);
|
||||
if (selectedTrackEntry) {
|
||||
return selectedTrackEntry.name;
|
||||
}
|
||||
return 'Tracks';
|
||||
};
|
||||
|
||||
lmsInstructorDashboardUrl = courseId => `${configuration.LMS_BASE_URL}/courses/${courseId}/instructor`;
|
||||
|
||||
handleCourseGradeFilterChange = (type, value) => {
|
||||
const filterValue = value;
|
||||
|
||||
if (type === 'min') {
|
||||
this.setState({
|
||||
courseGradeMin: filterValue,
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
courseGradeMax: filterValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleCourseGradeFilterApplyButtonClick = () => {
|
||||
const { courseGradeMin, courseGradeMax } = this.state;
|
||||
const isMinValid = this.isGradeFilterValueInRange(courseGradeMin);
|
||||
const isMaxValid = this.isGradeFilterValueInRange(courseGradeMax);
|
||||
|
||||
this.setState({
|
||||
isMinCourseGradeFilterValid: isMinValid,
|
||||
isMaxCourseGradeFilterValid: isMaxValid,
|
||||
});
|
||||
|
||||
if (isMinValid && isMaxValid) {
|
||||
this.props.updateCourseGradeFilter(
|
||||
courseGradeMin,
|
||||
courseGradeMax,
|
||||
this.props.courseId,
|
||||
);
|
||||
this.props.getUserGrades(
|
||||
this.props.courseId,
|
||||
this.props.selectedCohort,
|
||||
this.props.selectedTrack,
|
||||
this.props.selectedAssignmentType,
|
||||
{
|
||||
courseGradeMin,
|
||||
courseGradeMax,
|
||||
},
|
||||
);
|
||||
this.updateQueryParams({ courseGradeMin, courseGradeMax });
|
||||
}
|
||||
}
|
||||
|
||||
isGradeFilterValueInRange = (value) => {
|
||||
const valueAsInt = parseInt(value, 10);
|
||||
return valueAsInt >= 0 && valueAsInt <= 100;
|
||||
};
|
||||
|
||||
handleFilterBadgeClose = filterNames => () => {
|
||||
this.props.resetFilters(filterNames);
|
||||
const queryParams = {};
|
||||
filterNames.forEach((filterName) => {
|
||||
queryParams[filterName] = false;
|
||||
});
|
||||
this.updateQueryParams(queryParams);
|
||||
const stateUpdate = {};
|
||||
const rangeStateFilters = ['assignmentGradeMin', 'assignmentGradeMax', 'courseGradeMin', 'courseGradeMax'];
|
||||
rangeStateFilters.forEach((filterName) => {
|
||||
if (filterNames.includes(filterName)) {
|
||||
stateUpdate[filterName] = initialFilters[filterName];
|
||||
}
|
||||
});
|
||||
this.setState(stateUpdate);
|
||||
this.props.getUserGrades(
|
||||
this.props.courseId,
|
||||
filterNames.includes('cohort') ? initialFilters.cohort : this.props.selectedCohort,
|
||||
filterNames.includes('track') ? initialFilters.track : this.props.selectedTrack,
|
||||
filterNames.includes('assignmentType') ? initialFilters.assignmentType : this.props.selectedAssignmentType,
|
||||
);
|
||||
}
|
||||
|
||||
createStateFieldSetter = (key) => (value) => this.setState({ [key]: value });
|
||||
|
||||
createStateFieldOnChange = (key) => ({ target }) => this.setState({ [key]: target.value });
|
||||
|
||||
createLimitedSetter = (...keys) => (values) => this.setState(
|
||||
keys.reduce(
|
||||
(obj, key) => (values[key] === undefined ? obj : { ...obj, [key]: values[key] }),
|
||||
{},
|
||||
),
|
||||
)
|
||||
|
||||
safeSetState = this.createLimitedSetter(
|
||||
'adjustedGradePossible',
|
||||
'adjustedGradeValue',
|
||||
'assignmnentName',
|
||||
'modalOpen',
|
||||
'reasonForChange',
|
||||
'todaysDate',
|
||||
'updateModuleId',
|
||||
'updateUserId',
|
||||
'updateUserName',
|
||||
);
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Drawer
|
||||
mainContent={toggleFilterDrawer => (
|
||||
<div className="px-3 gradebook-content">
|
||||
<a
|
||||
href={this.lmsInstructorDashboardUrl(this.props.courseId)}
|
||||
className="mb-3"
|
||||
>
|
||||
<span aria-hidden="true">{'<< '}</span> Back to Dashboard
|
||||
</a>
|
||||
<h1>Gradebook</h1>
|
||||
<h3> {this.props.courseId}</h3>
|
||||
{this.props.areGradesFrozen
|
||||
&& (
|
||||
<div className="alert alert-warning" role="alert">
|
||||
The grades for this course are now frozen. Editing of grades is no longer allowed.
|
||||
</div>
|
||||
)}
|
||||
{(this.props.canUserViewGradebook === false)
|
||||
&& (
|
||||
<div className="alert alert-warning" role="alert">
|
||||
You are not authorized to view the gradebook for this course.
|
||||
</div>
|
||||
)}
|
||||
<Tabs defaultActiveKey="grades">
|
||||
<Tab eventKey="grades" title="Grades">
|
||||
<h4>Step 1: Filter the Grade Report</h4>
|
||||
<div className="d-flex justify-content-between">
|
||||
{this.props.showSpinner && <div className="spinner-overlay"><Icon className="fa fa-spinner fa-spin fa-5x color-black" /></div>}
|
||||
<Button className="btn-primary align-self-start" onClick={toggleFilterDrawer}><FontAwesomeIcon icon={faFilter} /> Edit Filters</Button>
|
||||
<div>
|
||||
<SearchField
|
||||
onSubmit={value => this.props.searchForUser(
|
||||
this.props.courseId,
|
||||
value,
|
||||
this.props.selectedCohort,
|
||||
this.props.selectedTrack,
|
||||
this.props.selectedAssignmentType,
|
||||
)}
|
||||
inputLabel="Search for a learner"
|
||||
onChange={filterValue => this.setState({ filterValue })}
|
||||
onClear={() => this.props.getUserGrades(
|
||||
this.props.courseId,
|
||||
this.props.selectedCohort,
|
||||
this.props.selectedTrack,
|
||||
this.props.selectedAssignmentType,
|
||||
)}
|
||||
value={this.state.filterValue}
|
||||
/>
|
||||
<small className="form-text text-muted search-help-text">Search by username, email, or student key</small>
|
||||
</div>
|
||||
</div>
|
||||
<ConnectedFilterBadges
|
||||
handleFilterBadgeClose={this.handleFilterBadgeClose}
|
||||
/>
|
||||
<StatusAlert
|
||||
alertType="success"
|
||||
dialog="The grade has been successfully edited. You may see a slight delay before updates appear in the Gradebook."
|
||||
onClose={() => this.props.closeBanner()}
|
||||
open={this.props.showSuccess}
|
||||
/>
|
||||
<StatusAlert
|
||||
alertType="danger"
|
||||
dialog={this.getCourseGradeFilterAlertDialog()}
|
||||
dismissible={false}
|
||||
open={
|
||||
!this.state.isMinCourseGradeFilterValid
|
||||
|| !this.state.isMaxCourseGradeFilterValid
|
||||
}
|
||||
/>
|
||||
<h4>Step 2: View or Modify Individual Grades</h4>
|
||||
{this.props.totalUsersCount
|
||||
? (
|
||||
<div>
|
||||
Showing
|
||||
<span className="font-weight-bold"> {this.props.filteredUsersCount} </span>
|
||||
of
|
||||
<span className="font-weight-bold"> {this.props.totalUsersCount} </span>
|
||||
total learners
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
<div className="d-flex justify-content-between align-items-center mb-2">
|
||||
<InputSelect
|
||||
label="Score View:"
|
||||
name="ScoreView"
|
||||
value="percent"
|
||||
options={[{ label: 'Percent', value: 'percent' }, { label: 'Absolute', value: 'absolute' }]}
|
||||
onChange={this.props.toggleFormat}
|
||||
/>
|
||||
{this.props.showBulkManagement && (
|
||||
<BulkManagementControls
|
||||
courseId={this.props.courseId}
|
||||
gradeExportUrl={this.props.gradeExportUrl}
|
||||
interventionExportUrl={this.props.interventionExportUrl}
|
||||
showSpinner={this.props.showSpinner}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<GradebookTable setGradebookState={this.safeSetState} />
|
||||
{PageButtons(this.props)}
|
||||
<p>* available for learners in the Master's track only</p>
|
||||
<EditModal
|
||||
assignmentName={this.state.assignmentName}
|
||||
adjustedGradeValue={this.state.adjustedGradeValue}
|
||||
adjustedGradePossible={this.state.adjustedGradePossible}
|
||||
courseId={this.props.courseId}
|
||||
filterValue={this.state.filterValue}
|
||||
onChange={this.onChange}
|
||||
open={this.state.modalOpen}
|
||||
reasonForChange={this.state.reasonForChange}
|
||||
setAdjustedGradeValue={this.createStateFieldOnChange('adjustedGradeValue')}
|
||||
setGradebookState={this.safeSetState}
|
||||
setReasonForChange={this.createStateFieldOnChange('reasonForChange')}
|
||||
todaysDate={this.state.todaysDate}
|
||||
updateModuleId={this.state.updateModuleId}
|
||||
updateUserId={this.state.updateUserId}
|
||||
updateUserName={this.state.updateUserName}
|
||||
/>
|
||||
|
||||
</Tab>
|
||||
{this.props.showBulkManagement
|
||||
&& (
|
||||
<BulkManagement
|
||||
courseId={this.props.courseId}
|
||||
gradeExportUrl={this.props.gradeExportUrl}
|
||||
/>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
initiallyOpen={false}
|
||||
title={(
|
||||
<>
|
||||
<FontAwesomeIcon icon={faFilter} /> Filter By...
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<Assignments
|
||||
assignmentGradeMin={this.state.assignmentGradeMin}
|
||||
assignmentGradeMax={this.state.assignmentGradeMax}
|
||||
courseId={this.props.courseId}
|
||||
setAssignmentGradeMin={this.createStateFieldSetter('assignmentGradeMin')}
|
||||
setAssignmentGradeMax={this.createStateFieldSetter('assignmentGradeMax')}
|
||||
updateQueryParams={this.updateQueryParams}
|
||||
/>
|
||||
<Collapsible title="Overall Grade" open className="filter-group mb-3">
|
||||
<div className="d-flex justify-content-between align-items-center">
|
||||
<InputText
|
||||
value={this.state.courseGradeMin}
|
||||
name="minimum-grade"
|
||||
label="Min Grade"
|
||||
onChange={value => this.handleCourseGradeFilterChange('min', value)}
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
/>
|
||||
<span className="input-percent-label">%</span>
|
||||
<InputText
|
||||
value={this.state.courseGradeMax}
|
||||
name="max-grade"
|
||||
label="Max Grade"
|
||||
onChange={value => this.handleCourseGradeFilterChange('max', value)}
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
/>
|
||||
<span className="input-percent-label">%</span>
|
||||
<Button
|
||||
variant="outline-secondary"
|
||||
onClick={this.handleCourseGradeFilterApplyButtonClick}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</Collapsible>
|
||||
<Collapsible title="Student Groups" open className="filter-group mb-3">
|
||||
<InputSelect
|
||||
label="Tracks"
|
||||
name="Tracks"
|
||||
aria-label="Tracks"
|
||||
disabled={this.props.tracks.length === 0}
|
||||
value={this.mapSelectedTrackEntry(this.props.selectedTrack)}
|
||||
options={this.mapTracksEntries(this.props.tracks)}
|
||||
onChange={this.updateTracks}
|
||||
/>
|
||||
<InputSelect
|
||||
name="Cohorts"
|
||||
aria-label="Cohorts"
|
||||
label="Cohorts"
|
||||
disabled={this.props.cohorts.length === 0}
|
||||
value={this.mapSelectedCohortEntry(this.props.selectedCohort)}
|
||||
options={this.mapCohortsEntries(this.props.cohorts)}
|
||||
onChange={this.updateCohorts}
|
||||
/>
|
||||
</Collapsible>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Gradebook.defaultProps = {
|
||||
areGradesFrozen: false,
|
||||
canUserViewGradebook: false,
|
||||
cohorts: [],
|
||||
courseId: '',
|
||||
filteredUsersCount: null,
|
||||
location: {
|
||||
search: '',
|
||||
},
|
||||
selectedAssignmentType: '',
|
||||
selectedCohort: null,
|
||||
selectedTrack: null,
|
||||
showBulkManagement: false,
|
||||
showSpinner: false,
|
||||
totalUsersCount: null,
|
||||
tracks: [],
|
||||
};
|
||||
|
||||
Gradebook.propTypes = {
|
||||
areGradesFrozen: PropTypes.bool,
|
||||
canUserViewGradebook: PropTypes.bool,
|
||||
closeBanner: PropTypes.func.isRequired,
|
||||
cohorts: PropTypes.arrayOf(PropTypes.shape({
|
||||
name: PropTypes.string,
|
||||
id: PropTypes.number,
|
||||
})),
|
||||
courseId: PropTypes.string,
|
||||
filteredUsersCount: PropTypes.number,
|
||||
getRoles: PropTypes.func.isRequired,
|
||||
getUserGrades: PropTypes.func.isRequired,
|
||||
gradeExportUrl: PropTypes.string.isRequired,
|
||||
history: PropTypes.shape({
|
||||
push: PropTypes.func,
|
||||
}).isRequired,
|
||||
initializeFilters: PropTypes.func.isRequired,
|
||||
interventionExportUrl: PropTypes.string.isRequired,
|
||||
location: PropTypes.shape({
|
||||
search: PropTypes.string,
|
||||
}),
|
||||
resetFilters: PropTypes.func.isRequired,
|
||||
searchForUser: PropTypes.func.isRequired,
|
||||
selectedAssignmentType: PropTypes.string,
|
||||
selectedCohort: PropTypes.string,
|
||||
selectedTrack: PropTypes.string,
|
||||
showBulkManagement: PropTypes.bool,
|
||||
showSpinner: PropTypes.bool,
|
||||
showSuccess: PropTypes.bool.isRequired,
|
||||
toggleFormat: PropTypes.func.isRequired,
|
||||
totalUsersCount: PropTypes.number,
|
||||
tracks: PropTypes.arrayOf(PropTypes.shape({
|
||||
name: PropTypes.string,
|
||||
})),
|
||||
updateCourseGradeFilter: PropTypes.func.isRequired,
|
||||
};
|
||||
@@ -1,52 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`AssignmentFilter component render snapshot 1`] = `
|
||||
<div
|
||||
className="student-filters"
|
||||
>
|
||||
<SelectGroup
|
||||
disabled={false}
|
||||
id="assignment"
|
||||
label="Assignment"
|
||||
onChange={[MockFunction]}
|
||||
options={
|
||||
Array [
|
||||
<option
|
||||
value=""
|
||||
>
|
||||
All
|
||||
</option>,
|
||||
<option
|
||||
value="label1"
|
||||
>
|
||||
label1
|
||||
:
|
||||
sLabel1
|
||||
</option>,
|
||||
<option
|
||||
value="label2"
|
||||
>
|
||||
label2
|
||||
:
|
||||
sLabel2
|
||||
</option>,
|
||||
<option
|
||||
value="label3"
|
||||
>
|
||||
label3
|
||||
:
|
||||
sLabel3
|
||||
</option>,
|
||||
<option
|
||||
value="label4"
|
||||
>
|
||||
label4
|
||||
:
|
||||
sLabel4
|
||||
</option>,
|
||||
]
|
||||
}
|
||||
value="test-label"
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
@@ -1,33 +0,0 @@
|
||||
import {
|
||||
selectors,
|
||||
actions,
|
||||
thunkActions,
|
||||
} from 'data/redux/hooks';
|
||||
|
||||
export const useAssignmentFilterData = ({
|
||||
updateQueryParams,
|
||||
}) => {
|
||||
const assignmentFilterOptions = selectors.filters.useSelectableAssignmentLabels();
|
||||
const selectedAssignmentLabel = selectors.filters.useSelectedAssignmentLabel() || '';
|
||||
|
||||
const updateAssignmentFilter = actions.filters.useUpdateAssignment();
|
||||
const conditionalFetch = thunkActions.grades.useFetchGradesIfAssignmentGradeFiltersSet();
|
||||
|
||||
const handleChange = ({ target: { value: assignment } }) => {
|
||||
const selectedFilterOption = assignmentFilterOptions.find(
|
||||
({ label }) => label === assignment,
|
||||
);
|
||||
const { type, id } = selectedFilterOption || {};
|
||||
updateAssignmentFilter({ label: assignment, type, id });
|
||||
updateQueryParams({ assignment: id });
|
||||
conditionalFetch();
|
||||
};
|
||||
|
||||
return {
|
||||
handleChange,
|
||||
selectedAssignmentLabel,
|
||||
assignmentFilterOptions,
|
||||
};
|
||||
};
|
||||
|
||||
export default useAssignmentFilterData;
|
||||
@@ -1,88 +0,0 @@
|
||||
import { selectors, actions, thunkActions } from 'data/redux/hooks';
|
||||
|
||||
import useAssignmentFilterData from './hooks';
|
||||
|
||||
jest.mock('data/redux/hooks', () => ({
|
||||
selectors: {
|
||||
filters: {
|
||||
useSelectableAssignmentLabels: jest.fn(),
|
||||
useSelectedAssignmentLabel: jest.fn(),
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
filters: { useUpdateAssignment: jest.fn() },
|
||||
},
|
||||
thunkActions: {
|
||||
grades: { useFetchGradesIfAssignmentGradeFiltersSet: jest.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
let out;
|
||||
const testKey = 'test-key';
|
||||
const event = { target: { value: testKey } };
|
||||
const testId = 'test-id';
|
||||
const testType = 'test-type';
|
||||
|
||||
const testLabel = { label: testKey, id: testId, type: testType };
|
||||
const selectableAssignmentLabels = [
|
||||
{ label: 'some' },
|
||||
{ label: 'test' },
|
||||
{ label: 'labels' },
|
||||
testLabel,
|
||||
];
|
||||
const selectedAssignmentLabel = 'test-assignment-label';
|
||||
selectors.filters.useSelectableAssignmentLabels.mockReturnValue(selectableAssignmentLabels);
|
||||
selectors.filters.useSelectedAssignmentLabel.mockReturnValue(selectedAssignmentLabel);
|
||||
|
||||
const updateAssignment = jest.fn();
|
||||
const fetch = jest.fn();
|
||||
actions.filters.useUpdateAssignment.mockReturnValue(updateAssignment);
|
||||
thunkActions.grades.useFetchGradesIfAssignmentGradeFiltersSet.mockReturnValue(fetch);
|
||||
|
||||
const updateQueryParams = jest.fn();
|
||||
|
||||
describe('useAssignmentFilterData hook', () => {
|
||||
beforeEach(() => {
|
||||
out = useAssignmentFilterData({ updateQueryParams });
|
||||
});
|
||||
describe('behavior', () => {
|
||||
it('initializes redux hooks', () => {
|
||||
expect(selectors.filters.useSelectableAssignmentLabels).toHaveBeenCalledWith();
|
||||
expect(selectors.filters.useSelectedAssignmentLabel).toHaveBeenCalledWith();
|
||||
expect(actions.filters.useUpdateAssignment).toHaveBeenCalledWith();
|
||||
expect(thunkActions.grades.useFetchGradesIfAssignmentGradeFiltersSet)
|
||||
.toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
describe('output', () => {
|
||||
describe('handleEvent', () => {
|
||||
beforeEach(() => {
|
||||
out.handleChange(event);
|
||||
});
|
||||
it('updates assignment filter with selected filter', () => {
|
||||
expect(updateAssignment).toHaveBeenCalledWith(testLabel);
|
||||
});
|
||||
it('updates queryParams', () => {
|
||||
expect(updateQueryParams).toHaveBeenCalledWith({ assignment: testId });
|
||||
});
|
||||
it('updates assignment filter with only label if no match', () => {
|
||||
out.handleChange({ target: { value: 'no-match' } });
|
||||
expect(updateAssignment).toHaveBeenCalledWith({ label: 'no-match' });
|
||||
});
|
||||
it('calls conditional fetch', () => {
|
||||
expect(fetch).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
it('passes selectedAssignmentLabel from hook', () => {
|
||||
expect(out.selectedAssignmentLabel).toEqual(selectedAssignmentLabel);
|
||||
});
|
||||
test('selectedAssignmentLabel is empty string if not set', () => {
|
||||
selectors.filters.useSelectedAssignmentLabel.mockReturnValue(undefined);
|
||||
out = useAssignmentFilterData({ updateQueryParams });
|
||||
expect(out.selectedAssignmentLabel).toEqual('');
|
||||
});
|
||||
it('passes assignmentFilterOptions from hook', () => {
|
||||
expect(out.assignmentFilterOptions).toEqual(selectableAssignmentLabels);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
/* eslint-disable react/sort-comp, react/button-has-type */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import messages from '../messages';
|
||||
import SelectGroup from '../SelectGroup';
|
||||
import useAssignmentFilterData from './hooks';
|
||||
|
||||
const AssignmentFilter = ({ updateQueryParams }) => {
|
||||
const {
|
||||
handleChange,
|
||||
selectedAssignmentLabel,
|
||||
assignmentFilterOptions,
|
||||
} = useAssignmentFilterData({ updateQueryParams });
|
||||
const { formatMessage } = useIntl();
|
||||
const filterOptions = assignmentFilterOptions.map(({ label, subsectionLabel }) => (
|
||||
<option key={label} value={label}>
|
||||
{label}: {subsectionLabel}
|
||||
</option>
|
||||
));
|
||||
return (
|
||||
<div className="student-filters">
|
||||
<SelectGroup
|
||||
id="assignment"
|
||||
label={formatMessage(messages.assignment)}
|
||||
value={selectedAssignmentLabel}
|
||||
onChange={handleChange}
|
||||
disabled={assignmentFilterOptions.length === 0}
|
||||
options={[
|
||||
<option key="0" value="">All</option>,
|
||||
...filterOptions,
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
AssignmentFilter.propTypes = {
|
||||
updateQueryParams: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default AssignmentFilter;
|
||||
@@ -1,53 +0,0 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import SelectGroup from '../SelectGroup';
|
||||
import useAssignmentFilterData from './hooks';
|
||||
import AssignmentFilter from '.';
|
||||
|
||||
jest.mock('../SelectGroup', () => 'SelectGroup');
|
||||
jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() }));
|
||||
|
||||
const handleChange = jest.fn();
|
||||
const selectedAssignmentLabel = 'test-label';
|
||||
const assignmentFilterOptions = [
|
||||
{ label: 'label1', subsectionLabel: 'sLabel1' },
|
||||
{ label: 'label2', subsectionLabel: 'sLabel2' },
|
||||
{ label: 'label3', subsectionLabel: 'sLabel3' },
|
||||
{ label: 'label4', subsectionLabel: 'sLabel4' },
|
||||
];
|
||||
useAssignmentFilterData.mockReturnValue({
|
||||
handleChange,
|
||||
selectedAssignmentLabel,
|
||||
assignmentFilterOptions,
|
||||
});
|
||||
|
||||
const updateQueryParams = jest.fn();
|
||||
|
||||
let el;
|
||||
describe('AssignmentFilter component', () => {
|
||||
beforeAll(() => {
|
||||
el = shallow(<AssignmentFilter updateQueryParams={updateQueryParams} />);
|
||||
});
|
||||
describe('behavior', () => {
|
||||
it('initializes hooks', () => {
|
||||
expect(useAssignmentFilterData).toHaveBeenCalledWith({ updateQueryParams });
|
||||
expect(useIntl).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
describe('render', () => {
|
||||
test('snapshot', () => {
|
||||
expect(el).toMatchSnapshot();
|
||||
});
|
||||
test('filter options', () => {
|
||||
const { options } = el.find(SelectGroup).props();
|
||||
expect(options.length).toEqual(5);
|
||||
const testOption = assignmentFilterOptions[0];
|
||||
const optionProps = options[1].props;
|
||||
expect(optionProps.value).toEqual(testOption.label);
|
||||
expect(optionProps.children.join(''))
|
||||
.toEqual(`${testOption.label}: ${testOption.subsectionLabel}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`AssignmentFilter component render with selected assignment snapshot 1`] = `
|
||||
<div
|
||||
className="grade-filter-inputs"
|
||||
>
|
||||
<PercentGroup
|
||||
disabled={false}
|
||||
id="assignmentGradeMin"
|
||||
label="Min Grade"
|
||||
onChange={[MockFunction]}
|
||||
value={23}
|
||||
/>
|
||||
<PercentGroup
|
||||
disabled={false}
|
||||
id="assignmentGradeMax"
|
||||
label="Max Grade"
|
||||
onChange={[MockFunction]}
|
||||
value={300}
|
||||
/>
|
||||
<div
|
||||
className="grade-filter-action"
|
||||
>
|
||||
<Button
|
||||
disabled={false}
|
||||
name="assignmentGradeMinMax"
|
||||
type="submit"
|
||||
variant="outline-secondary"
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`AssignmentFilter component render without selected assignment snapshot 1`] = `
|
||||
<div
|
||||
className="grade-filter-inputs"
|
||||
>
|
||||
<PercentGroup
|
||||
disabled={true}
|
||||
id="assignmentGradeMin"
|
||||
label="Min Grade"
|
||||
onChange={[MockFunction]}
|
||||
value={23}
|
||||
/>
|
||||
<PercentGroup
|
||||
disabled={true}
|
||||
id="assignmentGradeMax"
|
||||
label="Max Grade"
|
||||
onChange={[MockFunction]}
|
||||
value={300}
|
||||
/>
|
||||
<div
|
||||
className="grade-filter-action"
|
||||
>
|
||||
<Button
|
||||
disabled={true}
|
||||
name="assignmentGradeMinMax"
|
||||
type="submit"
|
||||
variant="outline-secondary"
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -1,36 +0,0 @@
|
||||
/* eslint-disable react/sort-comp, react/button-has-type */
|
||||
import { selectors, actions, thunkActions } from 'data/redux/hooks';
|
||||
|
||||
const useAssignmentGradeFilterData = ({ updateQueryParams }) => {
|
||||
const localAssignmentLimits = selectors.app.useAssignmentGradeLimits();
|
||||
const selectedAssignment = selectors.filters.useSelectedAssignmentLabel();
|
||||
const fetchGrades = thunkActions.grades.useFetchGrades();
|
||||
const setFilter = actions.app.useSetLocalFilter();
|
||||
const updateAssignmentLimits = actions.filters.useUpdateAssignmentLimits();
|
||||
|
||||
const handleSubmit = () => {
|
||||
updateAssignmentLimits(localAssignmentLimits);
|
||||
fetchGrades();
|
||||
updateQueryParams(localAssignmentLimits);
|
||||
};
|
||||
|
||||
const handleSetMax = ({ target: { value } }) => {
|
||||
setFilter({ assignmentGradeMax: value });
|
||||
};
|
||||
|
||||
const handleSetMin = ({ target: { value } }) => {
|
||||
setFilter({ assignmentGradeMin: value });
|
||||
};
|
||||
|
||||
const { assignmentGradeMax, assignmentGradeMin } = localAssignmentLimits;
|
||||
return {
|
||||
assignmentGradeMin,
|
||||
assignmentGradeMax,
|
||||
selectedAssignment,
|
||||
handleSetMax,
|
||||
handleSetMin,
|
||||
handleSubmit,
|
||||
};
|
||||
};
|
||||
|
||||
export default useAssignmentGradeFilterData;
|
||||
@@ -1,81 +0,0 @@
|
||||
import { selectors, actions, thunkActions } from 'data/redux/hooks';
|
||||
|
||||
import useAssignmentGradeFilterData from './hooks';
|
||||
|
||||
jest.mock('data/redux/hooks', () => ({
|
||||
selectors: {
|
||||
app: { useAssignmentGradeLimits: jest.fn() },
|
||||
filters: { useSelectedAssignmentLabel: jest.fn() },
|
||||
},
|
||||
actions: {
|
||||
app: { useSetLocalFilter: jest.fn() },
|
||||
filters: { useUpdateAssignmentLimits: jest.fn() },
|
||||
},
|
||||
thunkActions: {
|
||||
grades: { useFetchGrades: jest.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
let out;
|
||||
|
||||
const assignmentGradeLimits = { assignmentGradeMax: 200, assignmentGradeMin: 3 };
|
||||
const selectedAssignmentLabel = 'test-assignment-label';
|
||||
selectors.app.useAssignmentGradeLimits.mockReturnValue(assignmentGradeLimits);
|
||||
selectors.filters.useSelectedAssignmentLabel.mockReturnValue(selectedAssignmentLabel);
|
||||
|
||||
const setLocalFilter = jest.fn();
|
||||
const updateAssignmentLimits = jest.fn();
|
||||
const fetch = jest.fn();
|
||||
actions.app.useSetLocalFilter.mockReturnValue(setLocalFilter);
|
||||
actions.filters.useUpdateAssignmentLimits.mockReturnValue(updateAssignmentLimits);
|
||||
thunkActions.grades.useFetchGrades.mockReturnValue(fetch);
|
||||
|
||||
const testValue = 42;
|
||||
|
||||
const updateQueryParams = jest.fn();
|
||||
|
||||
describe('useAssignmentFilterData hook', () => {
|
||||
beforeEach(() => {
|
||||
out = useAssignmentGradeFilterData({ updateQueryParams });
|
||||
});
|
||||
describe('behavior', () => {
|
||||
it('initializes redux hooks', () => {
|
||||
expect(selectors.app.useAssignmentGradeLimits).toHaveBeenCalledWith();
|
||||
expect(selectors.filters.useSelectedAssignmentLabel).toHaveBeenCalledWith();
|
||||
expect(actions.app.useSetLocalFilter).toHaveBeenCalledWith();
|
||||
expect(actions.filters.useUpdateAssignmentLimits).toHaveBeenCalledWith();
|
||||
expect(thunkActions.grades.useFetchGrades).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
describe('output', () => {
|
||||
describe('handleSubmit', () => {
|
||||
beforeEach(() => {
|
||||
out.handleSubmit();
|
||||
});
|
||||
it('updates assignment limits filter', () => {
|
||||
expect(updateAssignmentLimits).toHaveBeenCalledWith(assignmentGradeLimits);
|
||||
});
|
||||
it('updates queryParams', () => {
|
||||
expect(updateQueryParams).toHaveBeenCalledWith(assignmentGradeLimits);
|
||||
});
|
||||
it('calls conditional fetch', () => {
|
||||
expect(fetch).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
test('handleSetMax sets assignmentGradeMax', () => {
|
||||
out.handleSetMax({ target: { value: testValue } });
|
||||
expect(setLocalFilter).toHaveBeenCalledWith({ assignmentGradeMax: testValue });
|
||||
});
|
||||
test('handleSetMin sets assignmentGradeMin', () => {
|
||||
out.handleSetMin({ target: { value: testValue } });
|
||||
expect(setLocalFilter).toHaveBeenCalledWith({ assignmentGradeMin: testValue });
|
||||
});
|
||||
it('passes selectedAssignment from hook', () => {
|
||||
expect(out.selectedAssignment).toEqual(selectedAssignmentLabel);
|
||||
});
|
||||
it('passes assignmentGradeMin and assignmentGradeMax from hook', () => {
|
||||
expect(out.assignmentGradeMax).toEqual(assignmentGradeLimits.assignmentGradeMax);
|
||||
expect(out.assignmentGradeMin).toEqual(assignmentGradeLimits.assignmentGradeMin);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Button } from '@edx/paragon';
|
||||
|
||||
import useAssignmentGradeFilterData from './hooks';
|
||||
import messages from '../messages';
|
||||
import PercentGroup from '../PercentGroup';
|
||||
|
||||
export const AssignmentGradeFilter = ({ updateQueryParams }) => {
|
||||
const {
|
||||
assignmentGradeMin,
|
||||
assignmentGradeMax,
|
||||
selectedAssignment,
|
||||
handleSetMax,
|
||||
handleSetMin,
|
||||
handleSubmit,
|
||||
} = useAssignmentGradeFilterData({ updateQueryParams });
|
||||
const { formatMessage } = useIntl();
|
||||
return (
|
||||
<div className="grade-filter-inputs">
|
||||
<PercentGroup
|
||||
id="assignmentGradeMin"
|
||||
label={formatMessage(messages.minGrade)}
|
||||
value={assignmentGradeMin}
|
||||
disabled={!selectedAssignment}
|
||||
onChange={handleSetMin}
|
||||
/>
|
||||
<PercentGroup
|
||||
id="assignmentGradeMax"
|
||||
label={formatMessage(messages.maxGrade)}
|
||||
value={assignmentGradeMax}
|
||||
disabled={!selectedAssignment}
|
||||
onChange={handleSetMax}
|
||||
/>
|
||||
<div className="grade-filter-action">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline-secondary"
|
||||
name="assignmentGradeMinMax"
|
||||
disabled={!selectedAssignment}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{formatMessage(messages.apply)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
AssignmentGradeFilter.propTypes = {
|
||||
updateQueryParams: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default AssignmentGradeFilter;
|
||||
@@ -1,77 +0,0 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Button } from '@edx/paragon';
|
||||
|
||||
import PercentGroup from '../PercentGroup';
|
||||
import useAssignmentGradeFilterData from './hooks';
|
||||
import AssignmentFilter from '.';
|
||||
|
||||
jest.mock('../PercentGroup', () => 'PercentGroup');
|
||||
jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() }));
|
||||
|
||||
const hookData = {
|
||||
handleChange: jest.fn(),
|
||||
handleSetMax: jest.fn(),
|
||||
handleSetMin: jest.fn(),
|
||||
selectedAssignment: 'test-assignment',
|
||||
assignmentGradeMax: 300,
|
||||
assignmentGradeMin: 23,
|
||||
};
|
||||
useAssignmentGradeFilterData.mockReturnValue(hookData);
|
||||
|
||||
const updateQueryParams = jest.fn();
|
||||
|
||||
let el;
|
||||
describe('AssignmentFilter component', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
el = shallow(<AssignmentFilter updateQueryParams={updateQueryParams} />);
|
||||
});
|
||||
describe('behavior', () => {
|
||||
it('initializes hooks', () => {
|
||||
expect(useAssignmentGradeFilterData).toHaveBeenCalledWith({ updateQueryParams });
|
||||
expect(useIntl).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
describe('render', () => {
|
||||
describe('with selected assignment', () => {
|
||||
test('snapshot', () => {
|
||||
expect(el).toMatchSnapshot();
|
||||
});
|
||||
it('renders a PercentGroup for both Max and Min filters', () => {
|
||||
let props = el.find(PercentGroup).at(0).props();
|
||||
expect(props.value).toEqual(hookData.assignmentGradeMin);
|
||||
expect(props.disabled).toEqual(false);
|
||||
expect(props.onChange).toEqual(hookData.handleSetMin);
|
||||
props = el.find(PercentGroup).at(1).props();
|
||||
expect(props.value).toEqual(hookData.assignmentGradeMax);
|
||||
expect(props.disabled).toEqual(false);
|
||||
expect(props.onChange).toEqual(hookData.handleSetMax);
|
||||
});
|
||||
it('renders a submit button', () => {
|
||||
const props = el.find(Button).props();
|
||||
expect(props.disabled).toEqual(false);
|
||||
expect(props.onClick).toEqual(hookData.handleSubmit);
|
||||
});
|
||||
});
|
||||
describe('without selected assignment', () => {
|
||||
beforeEach(() => {
|
||||
useAssignmentGradeFilterData.mockReturnValueOnce({
|
||||
...hookData,
|
||||
selectedAssignment: null,
|
||||
});
|
||||
el = shallow(<AssignmentFilter updateQueryParams={updateQueryParams} />);
|
||||
});
|
||||
test('snapshot', () => {
|
||||
expect(el).toMatchSnapshot();
|
||||
});
|
||||
it('disables controls', () => {
|
||||
let props = el.find(PercentGroup).at(0).props();
|
||||
expect(props.disabled).toEqual(true);
|
||||
props = el.find(PercentGroup).at(1).props();
|
||||
expect(props.disabled).toEqual(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`AssignmentFilterType component render snapshot 1`] = `
|
||||
<div
|
||||
className="student-filters"
|
||||
>
|
||||
<SelectGroup
|
||||
disabled={true}
|
||||
id="assignment-types"
|
||||
label="Assignment Types"
|
||||
onChange={[MockFunction]}
|
||||
options={
|
||||
Array [
|
||||
<option
|
||||
value=""
|
||||
>
|
||||
All
|
||||
</option>,
|
||||
<option
|
||||
value="test-type"
|
||||
>
|
||||
test-type
|
||||
</option>,
|
||||
<option
|
||||
value="type1"
|
||||
>
|
||||
type1
|
||||
</option>,
|
||||
<option
|
||||
value="type2"
|
||||
>
|
||||
type2
|
||||
</option>,
|
||||
<option
|
||||
value="type3"
|
||||
>
|
||||
type3
|
||||
</option>,
|
||||
]
|
||||
}
|
||||
value="test-type"
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
@@ -1,22 +0,0 @@
|
||||
import { selectors, actions } from 'data/redux/hooks';
|
||||
|
||||
export const useAssignmentTypeFilterData = ({ updateQueryParams }) => {
|
||||
const assignmentTypes = selectors.assignmentTypes.useAllAssignmentTypes() || {};
|
||||
const assignmentFilterOptions = selectors.filters.useSelectableAssignmentLabels();
|
||||
const selectedAssignmentType = selectors.filters.useAssignmentType() || '';
|
||||
const filterAssignmentType = actions.filters.useUpdateAssignmentType();
|
||||
|
||||
const handleChange = (event) => {
|
||||
const assignmentType = event.target.value;
|
||||
filterAssignmentType(assignmentType);
|
||||
updateQueryParams({ assignmentType });
|
||||
};
|
||||
|
||||
return {
|
||||
assignmentTypes,
|
||||
handleChange,
|
||||
isDisabled: assignmentFilterOptions.length === 0,
|
||||
selectedAssignmentType,
|
||||
};
|
||||
};
|
||||
export default useAssignmentTypeFilterData;
|
||||
@@ -1,92 +0,0 @@
|
||||
import { selectors, actions } from 'data/redux/hooks';
|
||||
|
||||
import useAssignmentTypeFilterData from './hooks';
|
||||
|
||||
jest.mock('data/redux/hooks', () => ({
|
||||
selectors: {
|
||||
assignmentTypes: {
|
||||
useAllAssignmentTypes: jest.fn(),
|
||||
},
|
||||
filters: {
|
||||
useSelectableAssignmentLabels: jest.fn(),
|
||||
useAssignmentType: jest.fn(),
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
filters: { useUpdateAssignmentType: jest.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
let out;
|
||||
const testId = 'test-id';
|
||||
const testKey = 'test-key';
|
||||
|
||||
const testType = 'test-type';
|
||||
const allTypes = [testType, 'and', 'some', 'other', 'types'];
|
||||
selectors.assignmentTypes.useAllAssignmentTypes.mockReturnValue(allTypes);
|
||||
const event = { target: { value: testType } };
|
||||
|
||||
const testLabel = { label: testKey, id: testId, type: testType };
|
||||
const selectableAssignmentLabels = [
|
||||
{ label: 'some' },
|
||||
{ label: 'test' },
|
||||
{ label: 'labels' },
|
||||
testLabel,
|
||||
];
|
||||
selectors.filters.useSelectableAssignmentLabels.mockReturnValue(selectableAssignmentLabels);
|
||||
selectors.filters.useAssignmentType.mockReturnValue(testType);
|
||||
|
||||
const updateAssignmentType = jest.fn();
|
||||
actions.filters.useUpdateAssignmentType.mockReturnValue(updateAssignmentType);
|
||||
|
||||
const updateQueryParams = jest.fn();
|
||||
|
||||
describe('useAssignmentTypeFilterData hook', () => {
|
||||
beforeEach(() => {
|
||||
out = useAssignmentTypeFilterData({ updateQueryParams });
|
||||
});
|
||||
describe('behavior', () => {
|
||||
it('initializes redux hooks', () => {
|
||||
expect(selectors.assignmentTypes.useAllAssignmentTypes).toHaveBeenCalledWith();
|
||||
expect(selectors.filters.useSelectableAssignmentLabels).toHaveBeenCalledWith();
|
||||
expect(selectors.filters.useAssignmentType).toHaveBeenCalledWith();
|
||||
expect(actions.filters.useUpdateAssignmentType).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
describe('output', () => {
|
||||
describe('handleEvent', () => {
|
||||
beforeEach(() => {
|
||||
out.handleChange(event);
|
||||
});
|
||||
it('updates assignmentType filter with selected filter', () => {
|
||||
expect(updateAssignmentType).toHaveBeenCalledWith(testType);
|
||||
});
|
||||
it('updates queryParams', () => {
|
||||
expect(updateQueryParams).toHaveBeenCalledWith({ assignmentType: testType });
|
||||
});
|
||||
});
|
||||
describe('selectedAssignmentType', () => {
|
||||
it('returns selected assignmentType', () => {
|
||||
expect(out.selectedAssignmentType).toEqual(testType);
|
||||
});
|
||||
it('returns empty string if no assignmentType is selected', () => {
|
||||
selectors.filters.useAssignmentType.mockReturnValue(undefined);
|
||||
out = useAssignmentTypeFilterData({ updateQueryParams });
|
||||
expect(out.selectedAssignmentType).toEqual('');
|
||||
});
|
||||
});
|
||||
it('passes assignmentTypes from hook', () => {
|
||||
expect(out.assignmentTypes).toEqual(allTypes);
|
||||
});
|
||||
test('assignmentTypes is empty object if hook returns undefined', () => {
|
||||
selectors.assignmentTypes.useAllAssignmentTypes.mockReturnValue(undefined);
|
||||
out = useAssignmentTypeFilterData({ updateQueryParams });
|
||||
expect(out.assignmentTypes).toEqual({});
|
||||
});
|
||||
it('returns isDisabled if assigmentFilterOptions is empty', () => {
|
||||
expect(out.isDisabled).toEqual(false);
|
||||
selectors.assignmentTypes.useAllAssignmentTypes.mockReturnValue([]);
|
||||
out = useAssignmentTypeFilterData({ updateQueryParams });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
/* eslint-disable react/sort-comp, react/button-has-type */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import SelectGroup from '../SelectGroup';
|
||||
import messages from '../messages';
|
||||
import useAssignmentTypeFilterData from './hooks';
|
||||
|
||||
export const AssignmentTypeFilter = ({ updateQueryParams }) => {
|
||||
const {
|
||||
assignmentTypes,
|
||||
handleChange,
|
||||
isDisabled,
|
||||
selectedAssignmentType,
|
||||
} = useAssignmentTypeFilterData({ updateQueryParams });
|
||||
const { formatMessage } = useIntl();
|
||||
return (
|
||||
<div className="student-filters">
|
||||
<SelectGroup
|
||||
id="assignment-types"
|
||||
label={formatMessage(messages.assignmentTypes)}
|
||||
value={selectedAssignmentType}
|
||||
onChange={handleChange}
|
||||
disabled={isDisabled}
|
||||
options={[
|
||||
<option key="0" value="">All</option>,
|
||||
...assignmentTypes.map(entry => (
|
||||
<option key={entry} value={entry}>{entry}</option>
|
||||
)),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
AssignmentTypeFilter.propTypes = {
|
||||
updateQueryParams: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default AssignmentTypeFilter;
|
||||
@@ -1,47 +0,0 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import SelectGroup from '../SelectGroup';
|
||||
import useAssignmentFilterTypeData from './hooks';
|
||||
import AssignmentFilterType from '.';
|
||||
|
||||
jest.mock('../SelectGroup', () => 'SelectGroup');
|
||||
jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() }));
|
||||
|
||||
const handleChange = jest.fn();
|
||||
const testType = 'test-type';
|
||||
const assignmentTypes = [testType, 'type1', 'type2', 'type3'];
|
||||
useAssignmentFilterTypeData.mockReturnValue({
|
||||
handleChange,
|
||||
selectedAssignmentType: testType,
|
||||
assignmentTypes,
|
||||
isDisabled: true,
|
||||
});
|
||||
|
||||
const updateQueryParams = jest.fn();
|
||||
|
||||
let el;
|
||||
describe('AssignmentFilterType component', () => {
|
||||
beforeAll(() => {
|
||||
el = shallow(<AssignmentFilterType updateQueryParams={updateQueryParams} />);
|
||||
});
|
||||
describe('behavior', () => {
|
||||
it('initializes hooks', () => {
|
||||
expect(useAssignmentFilterTypeData).toHaveBeenCalledWith({ updateQueryParams });
|
||||
expect(useIntl).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
describe('render', () => {
|
||||
test('snapshot', () => {
|
||||
expect(el).toMatchSnapshot();
|
||||
});
|
||||
test('filter options', () => {
|
||||
const { options } = el.find(SelectGroup).props();
|
||||
expect(options.length).toEqual(5);
|
||||
const optionProps = options[1].props;
|
||||
expect(optionProps.value).toEqual(assignmentTypes[0]);
|
||||
expect(optionProps.children).toEqual(testType);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`CourseFilter component render if disabled snapshot 1`] = `
|
||||
<Fragment>
|
||||
<div
|
||||
className="grade-filter-inputs"
|
||||
>
|
||||
<PercentGroup
|
||||
id="minimum-grade"
|
||||
label="Min Grade"
|
||||
onChange={[MockFunction]}
|
||||
value={23}
|
||||
/>
|
||||
<PercentGroup
|
||||
id="maximum-grade"
|
||||
label="Max Grade"
|
||||
onChange={[MockFunction]}
|
||||
value={300}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="grade-filter-action"
|
||||
>
|
||||
<Button
|
||||
disabled={true}
|
||||
variant="outline-secondary"
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</Fragment>
|
||||
`;
|
||||
|
||||
exports[`CourseFilter component render with selected assignment snapshot 1`] = `
|
||||
<Fragment>
|
||||
<div
|
||||
className="grade-filter-inputs"
|
||||
>
|
||||
<PercentGroup
|
||||
id="minimum-grade"
|
||||
label="Min Grade"
|
||||
onChange={[MockFunction]}
|
||||
value={23}
|
||||
/>
|
||||
<PercentGroup
|
||||
id="maximum-grade"
|
||||
label="Max Grade"
|
||||
onChange={[MockFunction]}
|
||||
value={300}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="grade-filter-action"
|
||||
>
|
||||
<Button
|
||||
disabled={false}
|
||||
variant="outline-secondary"
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</Fragment>
|
||||
`;
|
||||
@@ -1,33 +0,0 @@
|
||||
import { actions, selectors, thunkActions } from 'data/redux/hooks';
|
||||
|
||||
export const useCourseGradeFilterData = ({
|
||||
updateQueryParams,
|
||||
}) => {
|
||||
const isDisabled = !selectors.app.useAreCourseGradeFiltersValid();
|
||||
const localCourseLimits = selectors.app.useCourseGradeLimits();
|
||||
const fetchGrades = thunkActions.grades.useFetchGrades();
|
||||
const setLocalFilter = actions.app.useSetLocalFilter();
|
||||
const updateFilter = actions.filters.useUpdateCourseGradeLimits();
|
||||
|
||||
const handleApplyClick = () => {
|
||||
updateFilter(localCourseLimits);
|
||||
fetchGrades();
|
||||
updateQueryParams(localCourseLimits);
|
||||
};
|
||||
|
||||
const { courseGradeMin, courseGradeMax } = localCourseLimits;
|
||||
return {
|
||||
max: {
|
||||
value: courseGradeMax,
|
||||
onChange: (e) => setLocalFilter({ courseGradeMax: e.target.value }),
|
||||
},
|
||||
min: {
|
||||
value: courseGradeMin,
|
||||
onChange: (e) => setLocalFilter({ courseGradeMin: e.target.value }),
|
||||
},
|
||||
handleApplyClick,
|
||||
isDisabled,
|
||||
};
|
||||
};
|
||||
|
||||
export default useCourseGradeFilterData;
|
||||
@@ -1,78 +0,0 @@
|
||||
import { selectors, actions, thunkActions } from 'data/redux/hooks';
|
||||
|
||||
import useCourseTypeFilterData from './hooks';
|
||||
|
||||
jest.mock('data/redux/hooks', () => ({
|
||||
selectors: {
|
||||
app: {
|
||||
useAreCourseGradeFiltersValid: jest.fn(),
|
||||
useCourseGradeLimits: jest.fn(),
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
app: { useSetLocalFilter: jest.fn() },
|
||||
filters: { useUpdateCourseGradeLimits: jest.fn() },
|
||||
},
|
||||
thunkActions: {
|
||||
grades: { useFetchGrades: jest.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
let out;
|
||||
|
||||
const courseGradeLimits = { courseGradeMax: 120, courseGradeMin: 32 };
|
||||
selectors.app.useAreCourseGradeFiltersValid.mockReturnValue(true);
|
||||
selectors.app.useCourseGradeLimits.mockReturnValue(courseGradeLimits);
|
||||
|
||||
const setLocalFilter = jest.fn();
|
||||
actions.app.useSetLocalFilter.mockReturnValue(setLocalFilter);
|
||||
const updateCourseGradeLimits = jest.fn();
|
||||
actions.filters.useUpdateCourseGradeLimits.mockReturnValue(updateCourseGradeLimits);
|
||||
const fetch = jest.fn();
|
||||
thunkActions.grades.useFetchGrades.mockReturnValue(fetch);
|
||||
|
||||
const testValue = 55;
|
||||
|
||||
const updateQueryParams = jest.fn();
|
||||
|
||||
describe('useCourseTypeFilterData hook', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
out = useCourseTypeFilterData({ updateQueryParams });
|
||||
});
|
||||
describe('behavior', () => {
|
||||
it('initializes redux hooks', () => {
|
||||
expect(selectors.app.useAreCourseGradeFiltersValid).toHaveBeenCalledWith();
|
||||
expect(selectors.app.useCourseGradeLimits).toHaveBeenCalledWith();
|
||||
expect(actions.app.useSetLocalFilter).toHaveBeenCalledWith();
|
||||
expect(actions.filters.useUpdateCourseGradeLimits).toHaveBeenCalledWith();
|
||||
expect(thunkActions.grades.useFetchGrades).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
describe('output', () => {
|
||||
it('returns isDisabled if assigmentFilterOptions is empty', () => {
|
||||
expect(out.isDisabled).toEqual(false);
|
||||
selectors.app.useAreCourseGradeFiltersValid.mockReturnValue(false);
|
||||
out = useCourseTypeFilterData({ updateQueryParams });
|
||||
expect(out.isDisabled).toEqual(true);
|
||||
});
|
||||
test('min value and onChange', () => {
|
||||
const { courseGradeMin } = courseGradeLimits;
|
||||
expect(out.min.value).toEqual(courseGradeMin);
|
||||
out.min.onChange({ target: { value: testValue } });
|
||||
expect(setLocalFilter).toHaveBeenCalledWith({ courseGradeMin: testValue });
|
||||
});
|
||||
test('max value and onChange', () => {
|
||||
const { courseGradeMax } = courseGradeLimits;
|
||||
expect(out.max.value).toEqual(courseGradeMax);
|
||||
out.max.onChange({ target: { value: testValue } });
|
||||
expect(setLocalFilter).toHaveBeenCalledWith({ courseGradeMax: testValue });
|
||||
});
|
||||
it('updates filter, fetches grades, and updates query params on apply click', () => {
|
||||
out.handleApplyClick();
|
||||
expect(updateCourseGradeLimits).toHaveBeenCalledWith(courseGradeLimits);
|
||||
expect(fetch).toHaveBeenCalledWith();
|
||||
expect(updateQueryParams).toHaveBeenCalledWith(courseGradeLimits);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Button } from '@edx/paragon';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import messages from '../messages';
|
||||
import PercentGroup from '../PercentGroup';
|
||||
import useCourseGradeFilterData from './hooks';
|
||||
|
||||
export const CourseGradeFilter = ({ updateQueryParams }) => {
|
||||
const {
|
||||
max,
|
||||
min,
|
||||
isDisabled,
|
||||
handleApplyClick,
|
||||
} = useCourseGradeFilterData({ updateQueryParams });
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grade-filter-inputs">
|
||||
<PercentGroup
|
||||
id="minimum-grade"
|
||||
label={formatMessage(messages.minGrade)}
|
||||
value={min.value}
|
||||
onChange={min.onChange}
|
||||
/>
|
||||
<PercentGroup
|
||||
id="maximum-grade"
|
||||
label={formatMessage(messages.maxGrade)}
|
||||
value={max.value}
|
||||
onChange={max.onChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="grade-filter-action">
|
||||
<Button
|
||||
variant="outline-secondary"
|
||||
onClick={handleApplyClick}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{formatMessage(messages.apply)}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
CourseGradeFilter.propTypes = {
|
||||
updateQueryParams: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default CourseGradeFilter;
|
||||
@@ -1,75 +0,0 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Button } from '@edx/paragon';
|
||||
|
||||
import PercentGroup from '../PercentGroup';
|
||||
import useCourseGradeFilterData from './hooks';
|
||||
import CourseFilter from '.';
|
||||
|
||||
jest.mock('../PercentGroup', () => 'PercentGroup');
|
||||
jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() }));
|
||||
|
||||
const hookData = {
|
||||
handleChange: jest.fn(),
|
||||
max: {
|
||||
value: 300,
|
||||
onChange: jest.fn(),
|
||||
},
|
||||
min: {
|
||||
value: 23,
|
||||
onChange: jest.fn(),
|
||||
},
|
||||
selectedCourse: 'test-assignment',
|
||||
isDisabled: false,
|
||||
};
|
||||
useCourseGradeFilterData.mockReturnValue(hookData);
|
||||
|
||||
const updateQueryParams = jest.fn();
|
||||
|
||||
let el;
|
||||
describe('CourseFilter component', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
el = shallow(<CourseFilter updateQueryParams={updateQueryParams} />);
|
||||
});
|
||||
describe('behavior', () => {
|
||||
it('initializes hooks', () => {
|
||||
expect(useCourseGradeFilterData).toHaveBeenCalledWith({ updateQueryParams });
|
||||
expect(useIntl).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
describe('render', () => {
|
||||
describe('with selected assignment', () => {
|
||||
test('snapshot', () => {
|
||||
expect(el).toMatchSnapshot();
|
||||
});
|
||||
it('renders a PercentGroup for both Max and Min filters', () => {
|
||||
let props = el.find(PercentGroup).at(0).props();
|
||||
expect(props.value).toEqual(hookData.min.value);
|
||||
expect(props.onChange).toEqual(hookData.min.onChange);
|
||||
props = el.find(PercentGroup).at(1).props();
|
||||
expect(props.value).toEqual(hookData.max.value);
|
||||
expect(props.onChange).toEqual(hookData.max.onChange);
|
||||
});
|
||||
it('renders a submit button', () => {
|
||||
const props = el.find(Button).props();
|
||||
expect(props.disabled).toEqual(false);
|
||||
expect(props.onClick).toEqual(hookData.handleApplyClick);
|
||||
});
|
||||
});
|
||||
describe('if disabled', () => {
|
||||
beforeEach(() => {
|
||||
useCourseGradeFilterData.mockReturnValueOnce({ ...hookData, isDisabled: true });
|
||||
el = shallow(<CourseFilter updateQueryParams={updateQueryParams} />);
|
||||
});
|
||||
test('snapshot', () => {
|
||||
expect(el).toMatchSnapshot();
|
||||
});
|
||||
it('disables submit', () => {
|
||||
const props = el.find(Button).props();
|
||||
expect(props.disabled).toEqual(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
.filter-sidebar-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 15px;
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
/* eslint-disable react/sort-comp */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Form } from '@edx/paragon';
|
||||
|
||||
const PercentGroup = ({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
disabled,
|
||||
onChange,
|
||||
}) => (
|
||||
<div className="percent-group">
|
||||
<Form.Group controlId={id}>
|
||||
<Form.Label>{label}</Form.Label>
|
||||
<Form.Control
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
{...{ value, disabled, onChange }}
|
||||
/>
|
||||
</Form.Group>
|
||||
<span className="input-percent-label">%</span>
|
||||
</div>
|
||||
);
|
||||
PercentGroup.defaultProps = {
|
||||
disabled: false,
|
||||
};
|
||||
PercentGroup.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
label: PropTypes.node.isRequired,
|
||||
value: PropTypes.string.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
disabled: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default PercentGroup;
|
||||
@@ -1,33 +0,0 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import PercentGroup from './PercentGroup';
|
||||
|
||||
describe('PercentGroup', () => {
|
||||
let props = {
|
||||
id: 'group id',
|
||||
label: 'Group Label',
|
||||
value: 'group VALUE',
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
props = {
|
||||
...props,
|
||||
onChange: jest.fn().mockName('props.onChange'),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Component', () => {
|
||||
describe('snapshots', () => {
|
||||
test('basic snapshot', () => {
|
||||
const el = shallow(<PercentGroup {...props} />);
|
||||
expect(el).toMatchSnapshot();
|
||||
});
|
||||
test('disabled', () => {
|
||||
const el = shallow(<PercentGroup {...props} disabled />);
|
||||
expect(el).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
/* eslint-disable react/sort-comp, react/button-has-type */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Form } from '@edx/paragon';
|
||||
|
||||
const SelectGroup = ({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
options,
|
||||
}) => (
|
||||
<div className="student-filters">
|
||||
<Form.Group controlId={id}>
|
||||
<Form.Label>{label}</Form.Label>
|
||||
<Form.Control as="select" {...{ value, onChange, disabled }}>
|
||||
{options}
|
||||
</Form.Control>
|
||||
</Form.Group>
|
||||
</div>
|
||||
);
|
||||
SelectGroup.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
label: PropTypes.node.isRequired,
|
||||
value: PropTypes.string.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
disabled: PropTypes.bool,
|
||||
options: PropTypes.arrayOf(PropTypes.node).isRequired,
|
||||
};
|
||||
SelectGroup.defaultProps = {
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
export default SelectGroup;
|
||||
@@ -1,38 +0,0 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import SelectGroup from './SelectGroup';
|
||||
|
||||
describe('SelectGroup', () => {
|
||||
let props = {
|
||||
id: 'group id',
|
||||
label: 'Group Label',
|
||||
value: 'group VALUE',
|
||||
disabled: false,
|
||||
options: [
|
||||
<option value="opt1" key="opt1">Option 1</option>,
|
||||
<option value="opt2" key="opt2">Option 2</option>,
|
||||
<option value="opt3" key="opt3">Option 3</option>,
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
props = {
|
||||
...props,
|
||||
onChange: jest.fn().mockName('props.onChange'),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Component', () => {
|
||||
describe('snapshots', () => {
|
||||
test('basic snapshot', () => {
|
||||
const el = shallow(<SelectGroup {...props} />);
|
||||
expect(el).toMatchSnapshot();
|
||||
});
|
||||
test('disabled', () => {
|
||||
const el = shallow(<SelectGroup {...props} disabled />);
|
||||
expect(el).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`StudentGroupsFilter component render snapshot 1`] = `
|
||||
<Fragment>
|
||||
<SelectGroup
|
||||
id="Tracks"
|
||||
label="Tracks"
|
||||
onChange={[MockFunction]}
|
||||
options={
|
||||
Array [
|
||||
<option
|
||||
value="Track-All"
|
||||
>
|
||||
Track-All
|
||||
</option>,
|
||||
<option
|
||||
value="v1"
|
||||
>
|
||||
n1
|
||||
</option>,
|
||||
<option
|
||||
value="v2"
|
||||
>
|
||||
n2
|
||||
</option>,
|
||||
<option
|
||||
value="v3"
|
||||
>
|
||||
n3
|
||||
</option>,
|
||||
<option
|
||||
value="v4"
|
||||
>
|
||||
n4
|
||||
</option>,
|
||||
]
|
||||
}
|
||||
value="test-track"
|
||||
/>
|
||||
<SelectGroup
|
||||
disabled={false}
|
||||
id="Cohorts"
|
||||
label="Cohorts"
|
||||
onChange={[MockFunction]}
|
||||
options={
|
||||
Array [
|
||||
<option
|
||||
value="Cohort-All"
|
||||
>
|
||||
Cohort-All
|
||||
</option>,
|
||||
<option
|
||||
value="v1"
|
||||
>
|
||||
n1
|
||||
</option>,
|
||||
<option
|
||||
value="v2"
|
||||
>
|
||||
n2
|
||||
</option>,
|
||||
<option
|
||||
value="v3"
|
||||
>
|
||||
n3
|
||||
</option>,
|
||||
]
|
||||
}
|
||||
value="test-cohort"
|
||||
/>
|
||||
</Fragment>
|
||||
`;
|
||||
@@ -1,46 +0,0 @@
|
||||
import { actions, selectors, thunkActions } from 'data/redux/hooks';
|
||||
|
||||
export const useStudentGroupsFilterData = ({ updateQueryParams }) => {
|
||||
const selectedCohortEntry = selectors.root.useSelectedCohortEntry();
|
||||
const selectedTrackEntry = selectors.root.useSelectedTrackEntry();
|
||||
|
||||
const cohorts = selectors.cohorts.useAllCohorts();
|
||||
const tracks = selectors.tracks.useAllTracks();
|
||||
|
||||
const updateCohort = actions.filters.useUpdateCohort();
|
||||
const updateTrack = actions.filters.useUpdateTrack();
|
||||
|
||||
const fetchGrades = thunkActions.grades.useFetchGrades();
|
||||
|
||||
const handleUpdateTrack = (event) => {
|
||||
const selectedTrackItem = tracks.find(track => track.slug === event.target.value);
|
||||
const track = selectedTrackItem ? selectedTrackItem.slug.toString() : null;
|
||||
updateQueryParams({ track });
|
||||
updateTrack(track);
|
||||
fetchGrades();
|
||||
};
|
||||
|
||||
const handleUpdateCohort = (event) => {
|
||||
const selectedCohortItem = cohorts.find(cohort => cohort.id === parseInt(event.target.value, 10));
|
||||
const cohort = selectedCohortItem ? selectedCohortItem.id.toString() : null;
|
||||
// the param expected to be cohort_id
|
||||
updateQueryParams({ cohort });
|
||||
updateCohort(cohort);
|
||||
fetchGrades();
|
||||
};
|
||||
return {
|
||||
cohorts: {
|
||||
value: selectedCohortEntry?.id || '',
|
||||
isDisabled: cohorts.length === 0,
|
||||
handleChange: handleUpdateCohort,
|
||||
entries: cohorts.map(({ id: value, name }) => ({ value, name })),
|
||||
},
|
||||
tracks: {
|
||||
value: selectedTrackEntry?.slug || '',
|
||||
handleChange: handleUpdateTrack,
|
||||
entries: tracks.map(({ slug: value, name }) => ({ value, name })),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default useStudentGroupsFilterData;
|
||||
@@ -1,141 +0,0 @@
|
||||
import { selectors, actions, thunkActions } from 'data/redux/hooks';
|
||||
|
||||
import useAssignmentFilterData from './hooks';
|
||||
|
||||
jest.mock('data/redux/hooks', () => ({
|
||||
selectors: {
|
||||
root: {
|
||||
useSelectedCohortEntry: jest.fn(),
|
||||
useSelectedTrackEntry: jest.fn(),
|
||||
},
|
||||
cohorts: { useAllCohorts: jest.fn() },
|
||||
tracks: { useAllTracks: jest.fn() },
|
||||
},
|
||||
actions: {
|
||||
filters: {
|
||||
useUpdateCohort: jest.fn(),
|
||||
useUpdateTrack: jest.fn(),
|
||||
},
|
||||
},
|
||||
thunkActions: {
|
||||
grades: { useFetchGrades: jest.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
let out;
|
||||
|
||||
const testCohort = { name: 'cohort-name', id: 999 };
|
||||
selectors.root.useSelectedCohortEntry.mockReturnValue(testCohort);
|
||||
const testTrack = { name: 'track-name', slug: 8080 };
|
||||
selectors.root.useSelectedTrackEntry.mockReturnValue(testTrack);
|
||||
const allCohorts = [
|
||||
testCohort,
|
||||
{ name: 'cohort1', id: 11 },
|
||||
{ name: 'cohort2', id: 22 },
|
||||
{ name: 'cohort3', id: 33 },
|
||||
];
|
||||
selectors.cohorts.useAllCohorts.mockReturnValue(allCohorts);
|
||||
const allTracks = [
|
||||
testTrack,
|
||||
{ name: 'track1', slug: 111 },
|
||||
{ name: 'track2', slug: 222 },
|
||||
{ name: 'track3', slug: 333 },
|
||||
];
|
||||
selectors.tracks.useAllTracks.mockReturnValue(allTracks);
|
||||
|
||||
const updateCohort = jest.fn();
|
||||
actions.filters.useUpdateCohort.mockReturnValue(updateCohort);
|
||||
const updateTrack = jest.fn();
|
||||
actions.filters.useUpdateTrack.mockReturnValue(updateTrack);
|
||||
const fetch = jest.fn();
|
||||
thunkActions.grades.useFetchGrades.mockReturnValue(fetch);
|
||||
|
||||
const updateQueryParams = jest.fn();
|
||||
|
||||
describe('useAssignmentFilterData hook', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
out = useAssignmentFilterData({ updateQueryParams });
|
||||
});
|
||||
describe('behavior', () => {
|
||||
it('initializes redux hooks', () => {
|
||||
expect(selectors.root.useSelectedCohortEntry).toHaveBeenCalledWith();
|
||||
expect(selectors.root.useSelectedTrackEntry).toHaveBeenCalledWith();
|
||||
expect(selectors.cohorts.useAllCohorts).toHaveBeenCalledWith();
|
||||
expect(selectors.tracks.useAllTracks).toHaveBeenCalledWith();
|
||||
expect(actions.filters.useUpdateCohort).toHaveBeenCalledWith();
|
||||
expect(actions.filters.useUpdateTrack).toHaveBeenCalledWith();
|
||||
expect(thunkActions.grades.useFetchGrades).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
describe('output', () => {
|
||||
describe('cohorts', () => {
|
||||
test('value from hook', () => {
|
||||
expect(out.cohorts.value).toEqual(testCohort.id);
|
||||
});
|
||||
test('disabled iff no cohorts found', () => {
|
||||
expect(out.cohorts.isDisabled).toEqual(false);
|
||||
selectors.cohorts.useAllCohorts.mockReturnValueOnce([]);
|
||||
out = useAssignmentFilterData({ updateQueryParams });
|
||||
expect(out.cohorts.isDisabled).toEqual(true);
|
||||
});
|
||||
test('entries map id to value', () => {
|
||||
const { entries } = out.cohorts;
|
||||
expect(entries[0]).toEqual({ value: testCohort.id, name: testCohort.name });
|
||||
expect(entries[1]).toEqual({ value: allCohorts[1].id, name: allCohorts[1].name });
|
||||
expect(entries[2]).toEqual({ value: allCohorts[2].id, name: allCohorts[2].name });
|
||||
expect(entries[3]).toEqual({ value: allCohorts[3].id, name: allCohorts[3].name });
|
||||
});
|
||||
test('value defaults to empty string', () => {
|
||||
selectors.root.useSelectedCohortEntry.mockReturnValueOnce(null);
|
||||
out = useAssignmentFilterData({ updateQueryParams });
|
||||
expect(out.cohorts.value).toEqual('');
|
||||
});
|
||||
describe('handleEvent', () => {
|
||||
it('updates filter and query params and fetches grades', () => {
|
||||
out.cohorts.handleChange({ target: { value: testCohort.id } });
|
||||
expect(updateCohort).toHaveBeenCalledWith(testCohort.id.toString());
|
||||
expect(updateQueryParams).toHaveBeenCalledWith({ cohort: testCohort.id.toString() });
|
||||
expect(fetch).toHaveBeenCalled();
|
||||
});
|
||||
it('passes null if no matching track is found', () => {
|
||||
out.cohorts.handleChange({ target: { value: 'fake-name' } });
|
||||
expect(updateCohort).toHaveBeenCalledWith(null);
|
||||
expect(updateQueryParams).toHaveBeenCalledWith({ cohort: null });
|
||||
expect(fetch).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('tracks', () => {
|
||||
test('value from hook', () => {
|
||||
expect(out.tracks.value).toEqual(testTrack.slug);
|
||||
});
|
||||
test('entries map slug to value', () => {
|
||||
const { entries } = out.tracks;
|
||||
expect(entries[0]).toEqual({ value: testTrack.slug, name: testTrack.name });
|
||||
expect(entries[1]).toEqual({ value: allTracks[1].slug, name: allTracks[1].name });
|
||||
expect(entries[2]).toEqual({ value: allTracks[2].slug, name: allTracks[2].name });
|
||||
expect(entries[3]).toEqual({ value: allTracks[3].slug, name: allTracks[3].name });
|
||||
});
|
||||
test('value defaults to empty string', () => {
|
||||
selectors.root.useSelectedTrackEntry.mockReturnValueOnce(null);
|
||||
out = useAssignmentFilterData({ updateQueryParams });
|
||||
expect(out.tracks.value).toEqual('');
|
||||
});
|
||||
describe('handleEvent', () => {
|
||||
it('updates filter and query params and fetches grades', () => {
|
||||
out.tracks.handleChange({ target: { value: testTrack.slug } });
|
||||
expect(updateTrack).toHaveBeenCalledWith(testTrack.slug.toString());
|
||||
expect(updateQueryParams).toHaveBeenCalledWith({ track: testTrack.slug.toString() });
|
||||
expect(fetch).toHaveBeenCalled();
|
||||
});
|
||||
it('passes null if no matching track is found', () => {
|
||||
out.tracks.handleChange({ target: { value: 'fake-name' } });
|
||||
expect(updateTrack).toHaveBeenCalledWith(null);
|
||||
expect(updateQueryParams).toHaveBeenCalledWith({ track: null });
|
||||
expect(fetch).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,53 +0,0 @@
|
||||
/* eslint-disable react/sort-comp, react/button-has-type, import/no-named-as-default */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import messages from '../messages';
|
||||
import SelectGroup from '../SelectGroup';
|
||||
import useStudentGroupsFilterData from './hooks';
|
||||
|
||||
const mapOptions = ({ value, name }) => (
|
||||
<option key={name} value={value}>{name}</option>
|
||||
);
|
||||
|
||||
export const StudentGroupsFilter = ({ updateQueryParams }) => {
|
||||
const { tracks, cohorts } = useStudentGroupsFilterData({ updateQueryParams });
|
||||
const { formatMessage } = useIntl();
|
||||
return (
|
||||
<>
|
||||
<SelectGroup
|
||||
id="Tracks"
|
||||
label={formatMessage(messages.tracks)}
|
||||
value={tracks.value}
|
||||
onChange={tracks.handleChange}
|
||||
options={[
|
||||
<option value={formatMessage(messages.trackAll)} key="0">
|
||||
{formatMessage(messages.trackAll)}
|
||||
</option>,
|
||||
...tracks.entries.map(mapOptions),
|
||||
]}
|
||||
/>
|
||||
<SelectGroup
|
||||
id="Cohorts"
|
||||
label={formatMessage(messages.cohorts)}
|
||||
value={cohorts.value}
|
||||
disabled={cohorts.isDisabled}
|
||||
onChange={cohorts.handleChange}
|
||||
options={[
|
||||
<option value={formatMessage(messages.cohortAll)} key="0">
|
||||
{formatMessage(messages.cohortAll)}
|
||||
</option>,
|
||||
...cohorts.entries.map(mapOptions),
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
StudentGroupsFilter.propTypes = {
|
||||
updateQueryParams: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default StudentGroupsFilter;
|
||||
@@ -1,84 +0,0 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import SelectGroup from '../SelectGroup';
|
||||
import useStudentGroupsFilterData from './hooks';
|
||||
import StudentGroupsFilter from '.';
|
||||
|
||||
jest.mock('../SelectGroup', () => 'SelectGroup');
|
||||
jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() }));
|
||||
|
||||
const props = {
|
||||
cohorts: {
|
||||
value: 'test-cohort',
|
||||
entries: [
|
||||
{ value: 'v1', name: 'n1' },
|
||||
{ value: 'v2', name: 'n2' },
|
||||
{ value: 'v3', name: 'n3' },
|
||||
],
|
||||
handleChange: jest.fn(),
|
||||
isDisabled: false,
|
||||
},
|
||||
tracks: {
|
||||
value: 'test-track',
|
||||
entries: [
|
||||
{ value: 'v1', name: 'n1' },
|
||||
{ value: 'v2', name: 'n2' },
|
||||
{ value: 'v3', name: 'n3' },
|
||||
{ value: 'v4', name: 'n4' },
|
||||
],
|
||||
handleChange: jest.fn(),
|
||||
},
|
||||
};
|
||||
useStudentGroupsFilterData.mockReturnValue(props);
|
||||
const updateQueryParams = jest.fn();
|
||||
|
||||
let el;
|
||||
describe('StudentGroupsFilter component', () => {
|
||||
beforeAll(() => {
|
||||
jest.clearAllMocks();
|
||||
el = shallow(<StudentGroupsFilter updateQueryParams={updateQueryParams} />);
|
||||
});
|
||||
describe('behavior', () => {
|
||||
it('initializes hooks', () => {
|
||||
expect(useStudentGroupsFilterData).toHaveBeenCalledWith({ updateQueryParams });
|
||||
expect(useIntl).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
describe('render', () => {
|
||||
test('snapshot', () => {
|
||||
expect(el).toMatchSnapshot();
|
||||
});
|
||||
test('track options', () => {
|
||||
const {
|
||||
options,
|
||||
onChange,
|
||||
value,
|
||||
} = el.find(SelectGroup).at(0).props();
|
||||
expect(value).toEqual(props.tracks.value);
|
||||
expect(onChange).toEqual(props.tracks.handleChange);
|
||||
expect(options.length).toEqual(5);
|
||||
const testEntry = props.tracks.entries[0];
|
||||
const optionProps = options[1].props;
|
||||
expect(optionProps.value).toEqual(testEntry.value);
|
||||
expect(optionProps.children).toEqual(testEntry.name);
|
||||
});
|
||||
test('cohort options', () => {
|
||||
const {
|
||||
options,
|
||||
onChange,
|
||||
disabled,
|
||||
value,
|
||||
} = el.find(SelectGroup).at(1).props();
|
||||
expect(value).toEqual(props.cohorts.value);
|
||||
expect(disabled).toEqual(false);
|
||||
expect(onChange).toEqual(props.cohorts.handleChange);
|
||||
expect(options.length).toEqual(4);
|
||||
const testEntry = props.cohorts.entries[0];
|
||||
const optionProps = options[1].props;
|
||||
expect(optionProps.value).toEqual(testEntry.value);
|
||||
expect(optionProps.children).toEqual(testEntry.name);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,57 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`PercentGroup Component snapshots basic snapshot 1`] = `
|
||||
<div
|
||||
className="percent-group"
|
||||
>
|
||||
<Form.Group
|
||||
controlId="group id"
|
||||
>
|
||||
<Form.Label>
|
||||
Group Label
|
||||
</Form.Label>
|
||||
<Form.Control
|
||||
disabled={false}
|
||||
max={100}
|
||||
min={0}
|
||||
onChange={[MockFunction props.onChange]}
|
||||
step={1}
|
||||
type="number"
|
||||
value="group VALUE"
|
||||
/>
|
||||
</Form.Group>
|
||||
<span
|
||||
className="input-percent-label"
|
||||
>
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`PercentGroup Component snapshots disabled 1`] = `
|
||||
<div
|
||||
className="percent-group"
|
||||
>
|
||||
<Form.Group
|
||||
controlId="group id"
|
||||
>
|
||||
<Form.Label>
|
||||
Group Label
|
||||
</Form.Label>
|
||||
<Form.Control
|
||||
disabled={true}
|
||||
max={100}
|
||||
min={0}
|
||||
onChange={[MockFunction props.onChange]}
|
||||
step={1}
|
||||
type="number"
|
||||
value="group VALUE"
|
||||
/>
|
||||
</Form.Group>
|
||||
<span
|
||||
className="input-percent-label"
|
||||
>
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
@@ -1,79 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`SelectGroup Component snapshots basic snapshot 1`] = `
|
||||
<div
|
||||
className="student-filters"
|
||||
>
|
||||
<Form.Group
|
||||
controlId="group id"
|
||||
>
|
||||
<Form.Label>
|
||||
Group Label
|
||||
</Form.Label>
|
||||
<Form.Control
|
||||
as="select"
|
||||
disabled={false}
|
||||
onChange={[MockFunction props.onChange]}
|
||||
value="group VALUE"
|
||||
>
|
||||
<option
|
||||
key="opt1"
|
||||
value="opt1"
|
||||
>
|
||||
Option 1
|
||||
</option>
|
||||
<option
|
||||
key="opt2"
|
||||
value="opt2"
|
||||
>
|
||||
Option 2
|
||||
</option>
|
||||
<option
|
||||
key="opt3"
|
||||
value="opt3"
|
||||
>
|
||||
Option 3
|
||||
</option>
|
||||
</Form.Control>
|
||||
</Form.Group>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`SelectGroup Component snapshots disabled 1`] = `
|
||||
<div
|
||||
className="student-filters"
|
||||
>
|
||||
<Form.Group
|
||||
controlId="group id"
|
||||
>
|
||||
<Form.Label>
|
||||
Group Label
|
||||
</Form.Label>
|
||||
<Form.Control
|
||||
as="select"
|
||||
disabled={true}
|
||||
onChange={[MockFunction props.onChange]}
|
||||
value="group VALUE"
|
||||
>
|
||||
<option
|
||||
key="opt1"
|
||||
value="opt1"
|
||||
>
|
||||
Option 1
|
||||
</option>
|
||||
<option
|
||||
key="opt2"
|
||||
value="opt2"
|
||||
>
|
||||
Option 2
|
||||
</option>
|
||||
<option
|
||||
key="opt3"
|
||||
value="opt3"
|
||||
>
|
||||
Option 3
|
||||
</option>
|
||||
</Form.Control>
|
||||
</Form.Group>
|
||||
</div>
|
||||
`;
|
||||
@@ -1,70 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`GradebookFilters render snapshot 1`] = `
|
||||
<Fragment>
|
||||
<div
|
||||
className="filter-sidebar-header"
|
||||
>
|
||||
<h2>
|
||||
<Icon
|
||||
className="fa fa-filter"
|
||||
/>
|
||||
</h2>
|
||||
<IconButton
|
||||
alt="Close Filters"
|
||||
aria-label="Close Filters"
|
||||
className="p-1"
|
||||
iconAs="Icon"
|
||||
onClick={[MockFunction hook.closeMenu]}
|
||||
src={[Function]}
|
||||
/>
|
||||
</div>
|
||||
<Collapsible
|
||||
className="filter-group mb-3"
|
||||
defaultOpen={true}
|
||||
title="Assignments"
|
||||
>
|
||||
<div>
|
||||
<AssignmentTypeFilter
|
||||
updateQueryParams={[MockFunction]}
|
||||
/>
|
||||
<AssignmentFilter
|
||||
updateQueryParams={[MockFunction]}
|
||||
/>
|
||||
<AssignmentGradeFilter
|
||||
updateQueryParams={[MockFunction]}
|
||||
/>
|
||||
</div>
|
||||
</Collapsible>
|
||||
<Collapsible
|
||||
className="filter-group mb-3"
|
||||
defaultOpen={true}
|
||||
title="Overall Grade"
|
||||
>
|
||||
<CourseGradeFilter
|
||||
updateQueryParams={[MockFunction]}
|
||||
/>
|
||||
</Collapsible>
|
||||
<Collapsible
|
||||
className="filter-group mb-3"
|
||||
defaultOpen={true}
|
||||
title="Student Groups"
|
||||
>
|
||||
<StudentGroupsFilter
|
||||
updateQueryParams={[MockFunction]}
|
||||
/>
|
||||
</Collapsible>
|
||||
<Collapsible
|
||||
className="filter-group mb-3"
|
||||
defaultOpen={true}
|
||||
title="Include Course Team Members"
|
||||
>
|
||||
<Form.Checkbox
|
||||
checked={true}
|
||||
onChange={[MockFunction hook.handleChange]}
|
||||
>
|
||||
Include Course Team Members
|
||||
</Form.Checkbox>
|
||||
</Collapsible>
|
||||
</Fragment>
|
||||
`;
|
||||
@@ -1,23 +0,0 @@
|
||||
import { actions, selectors, thunkActions } from 'data/redux/hooks';
|
||||
|
||||
export const useGradebookFiltersData = ({ updateQueryParams }) => {
|
||||
const includeCourseRoleMembers = selectors.filters.useIncludeCourseRoleMembers();
|
||||
const updateIncludeCourseRoleMembers = actions.filters.useUpdateIncludeCourseRoleMembers();
|
||||
const closeMenu = thunkActions.app.useCloseFilterMenu();
|
||||
const fetchGrades = thunkActions.grades.useFetchGrades();
|
||||
|
||||
const handleIncludeTeamMembersChange = ({ target: { checked } }) => {
|
||||
updateIncludeCourseRoleMembers(checked);
|
||||
fetchGrades();
|
||||
updateQueryParams({ includeCourseRoleMembers: checked });
|
||||
};
|
||||
return {
|
||||
closeMenu,
|
||||
includeCourseTeamMembers: {
|
||||
handleChange: handleIncludeTeamMembersChange,
|
||||
value: includeCourseRoleMembers,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default useGradebookFiltersData;
|
||||
@@ -1,57 +0,0 @@
|
||||
import { actions, selectors, thunkActions } from 'data/redux/hooks';
|
||||
import * as hooks from './hooks';
|
||||
|
||||
jest.mock('data/redux/hooks', () => ({
|
||||
actions: {
|
||||
filters: { useUpdateIncludeCourseRoleMembers: jest.fn() },
|
||||
},
|
||||
selectors: {
|
||||
filters: { useIncludeCourseRoleMembers: jest.fn() },
|
||||
},
|
||||
thunkActions: {
|
||||
app: { useCloseFilterMenu: jest.fn() },
|
||||
grades: { useFetchGrades: jest.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
selectors.filters.useIncludeCourseRoleMembers.mockReturnValue(true);
|
||||
const updateIncludeCourseRoleMembers = jest.fn();
|
||||
actions.filters.useUpdateIncludeCourseRoleMembers.mockReturnValue(updateIncludeCourseRoleMembers);
|
||||
const closeFilterMenu = jest.fn();
|
||||
thunkActions.app.useCloseFilterMenu.mockReturnValue(closeFilterMenu);
|
||||
const fetchGrades = jest.fn();
|
||||
thunkActions.grades.useFetchGrades.mockReturnValue(fetchGrades);
|
||||
|
||||
const updateQueryParams = jest.fn();
|
||||
|
||||
let out;
|
||||
describe('GradebookFiltersData component hooks', () => {
|
||||
describe('useGradebookFiltersData', () => {
|
||||
beforeEach(() => {
|
||||
out = hooks.useGradebookFiltersData({ updateQueryParams });
|
||||
});
|
||||
describe('behavior', () => {
|
||||
it('initializes hooks', () => {
|
||||
expect(actions.filters.useUpdateIncludeCourseRoleMembers).toHaveBeenCalledWith();
|
||||
expect(selectors.filters.useIncludeCourseRoleMembers).toHaveBeenCalledWith();
|
||||
expect(thunkActions.app.useCloseFilterMenu).toHaveBeenCalledWith();
|
||||
expect(thunkActions.grades.useFetchGrades).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
describe('output', () => {
|
||||
test('closeMenu', () => {
|
||||
expect(out.closeMenu).toEqual(closeFilterMenu);
|
||||
});
|
||||
test('includeCourseTeamMembers value', () => {
|
||||
expect(out.includeCourseTeamMembers.value).toEqual(true);
|
||||
});
|
||||
test('includeCourseTeamMembers handleChange', () => {
|
||||
const event = { target: { checked: false } };
|
||||
out.includeCourseTeamMembers.handleChange(event);
|
||||
expect(updateIncludeCourseRoleMembers).toHaveBeenCalledWith(false);
|
||||
expect(fetchGrades).toHaveBeenCalledWith();
|
||||
expect(updateQueryParams).toHaveBeenCalledWith({ includeCourseRoleMembers: false });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,89 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
Icon,
|
||||
IconButton,
|
||||
Form,
|
||||
} from '@edx/paragon';
|
||||
import { Close } from '@edx/paragon/icons';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import messages from './messages';
|
||||
import AssignmentTypeFilter from './AssignmentTypeFilter';
|
||||
import AssignmentFilter from './AssignmentFilter';
|
||||
import AssignmentGradeFilter from './AssignmentGradeFilter';
|
||||
import CourseGradeFilter from './CourseGradeFilter';
|
||||
import StudentGroupsFilter from './StudentGroupsFilter';
|
||||
import useGradebookFiltersData from './hooks';
|
||||
|
||||
export const GradebookFilters = ({ updateQueryParams }) => {
|
||||
const {
|
||||
closeMenu,
|
||||
includeCourseTeamMembers,
|
||||
} = useGradebookFiltersData({ updateQueryParams });
|
||||
const { formatMessage } = useIntl();
|
||||
const collapsibleClassName = 'filter-group mb-3';
|
||||
return (
|
||||
<>
|
||||
<div className="filter-sidebar-header">
|
||||
<h2><Icon className="fa fa-filter" /></h2>
|
||||
<IconButton
|
||||
className="p-1"
|
||||
onClick={closeMenu}
|
||||
iconAs={Icon}
|
||||
src={Close}
|
||||
alt={formatMessage(messages.closeFilters)}
|
||||
aria-label={formatMessage(messages.closeFilters)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Collapsible
|
||||
title={formatMessage(messages.assignments)}
|
||||
defaultOpen
|
||||
className={collapsibleClassName}
|
||||
>
|
||||
<div>
|
||||
<AssignmentTypeFilter updateQueryParams={updateQueryParams} />
|
||||
<AssignmentFilter updateQueryParams={updateQueryParams} />
|
||||
<AssignmentGradeFilter updateQueryParams={updateQueryParams} />
|
||||
</div>
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible
|
||||
title={formatMessage(messages.overallGrade)}
|
||||
defaultOpen
|
||||
className={collapsibleClassName}
|
||||
>
|
||||
<CourseGradeFilter updateQueryParams={updateQueryParams} />
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible
|
||||
title={formatMessage(messages.studentGroups)}
|
||||
defaultOpen
|
||||
className={collapsibleClassName}
|
||||
>
|
||||
<StudentGroupsFilter updateQueryParams={updateQueryParams} />
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible
|
||||
title={formatMessage(messages.includeCourseTeamMembers)}
|
||||
defaultOpen
|
||||
className={collapsibleClassName}
|
||||
>
|
||||
<Form.Checkbox
|
||||
checked={includeCourseTeamMembers.value}
|
||||
onChange={includeCourseTeamMembers.handleChange}
|
||||
>
|
||||
{formatMessage(messages.includeCourseTeamMembers)}
|
||||
</Form.Checkbox>
|
||||
</Collapsible>
|
||||
</>
|
||||
);
|
||||
};
|
||||
GradebookFilters.propTypes = {
|
||||
updateQueryParams: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default GradebookFilters;
|
||||
@@ -1,82 +0,0 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Collapsible } from '@edx/paragon';
|
||||
|
||||
import { formatMessage } from 'testUtils';
|
||||
|
||||
import AssignmentTypeFilter from './AssignmentTypeFilter';
|
||||
import AssignmentFilter from './AssignmentFilter';
|
||||
import AssignmentGradeFilter from './AssignmentGradeFilter';
|
||||
import CourseGradeFilter from './CourseGradeFilter';
|
||||
import StudentGroupsFilter from './StudentGroupsFilter';
|
||||
import messages from './messages';
|
||||
|
||||
import useGradebookFiltersData from './hooks';
|
||||
import GradebookFilters from '.';
|
||||
|
||||
jest.mock('./AssignmentTypeFilter', () => 'AssignmentTypeFilter');
|
||||
jest.mock('./AssignmentFilter', () => 'AssignmentFilter');
|
||||
jest.mock('./AssignmentGradeFilter', () => 'AssignmentGradeFilter');
|
||||
jest.mock('./CourseGradeFilter', () => 'CourseGradeFilter');
|
||||
jest.mock('./StudentGroupsFilter', () => 'StudentGroupsFilter');
|
||||
|
||||
jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() }));
|
||||
|
||||
const hookProps = {
|
||||
closeMenu: jest.fn().mockName('hook.closeMenu'),
|
||||
includeCourseTeamMembers: {
|
||||
value: true,
|
||||
handleChange: jest.fn().mockName('hook.handleChange'),
|
||||
},
|
||||
};
|
||||
useGradebookFiltersData.mockReturnValue(hookProps);
|
||||
|
||||
let el;
|
||||
const updateQueryParams = jest.fn();
|
||||
|
||||
describe('GradebookFilters', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
el = shallow(<GradebookFilters updateQueryParams={updateQueryParams} />);
|
||||
});
|
||||
describe('behavior', () => {
|
||||
it('initializes hooks', () => {
|
||||
expect(useGradebookFiltersData).toHaveBeenCalledWith({ updateQueryParams });
|
||||
expect(useIntl).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
describe('render', () => {
|
||||
test('snapshot', () => {
|
||||
expect(el).toMatchSnapshot();
|
||||
});
|
||||
test('Assignment filters', () => {
|
||||
expect(el.find(Collapsible).at(0).children()).toMatchObject(shallow(
|
||||
<div>
|
||||
<AssignmentTypeFilter updateQueryParams={updateQueryParams} />
|
||||
<AssignmentFilter updateQueryParams={updateQueryParams} />
|
||||
<AssignmentGradeFilter updateQueryParams={updateQueryParams} />
|
||||
</div>,
|
||||
));
|
||||
});
|
||||
test('CourseGrade filters', () => {
|
||||
expect(el.find(Collapsible).at(1).children()).toMatchObject(shallow(
|
||||
<CourseGradeFilter updateQueryParams={updateQueryParams} />,
|
||||
));
|
||||
});
|
||||
test('StudentGroups filters', () => {
|
||||
expect(el.find(Collapsible).at(2).children()).toMatchObject(shallow(
|
||||
<StudentGroupsFilter updateQueryParams={updateQueryParams} />,
|
||||
));
|
||||
});
|
||||
test('includeCourseTeamMembers', () => {
|
||||
const checkbox = el.find(Collapsible).at(3).children();
|
||||
expect(checkbox.props()).toEqual({
|
||||
checked: true,
|
||||
onChange: hookProps.includeCourseTeamMembers.handleChange,
|
||||
children: formatMessage(messages.includeCourseTeamMembers),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
assignments: {
|
||||
id: 'gradebook.GradebookFilters.assignmentsFilterLabel',
|
||||
defaultMessage: 'Assignments',
|
||||
description: 'Assignment filter group label in Gradebook Filters',
|
||||
},
|
||||
overallGrade: {
|
||||
id: 'gradebook.GradebookFilters.overallGradeFilterLabel',
|
||||
defaultMessage: 'Overall Grade',
|
||||
description: 'Overall Grade filter group label in Gradebook Filters',
|
||||
},
|
||||
studentGroups: {
|
||||
id: 'gradebook.GradebookFilters.studentGroupsFilterLabel',
|
||||
defaultMessage: 'Student Groups',
|
||||
description: 'Student Groups filter group label in Gradebook Filters',
|
||||
},
|
||||
includeCourseTeamMembers: {
|
||||
id: 'gradebook.GradebookFilters.includeCourseTeamMembersFilterLabel',
|
||||
defaultMessage: 'Include Course Team Members',
|
||||
description: 'Include Course Team Members filter label in Gradebook Filters',
|
||||
},
|
||||
assignment: {
|
||||
id: 'gradebook.GradebookFilters.assignmentFilterLabel',
|
||||
defaultMessage: 'Assignment',
|
||||
description: 'Assignment filter select label in Gradebook Filters',
|
||||
},
|
||||
assignmentTypes: {
|
||||
id: 'gradebook.GradebookFilters.assignmentTypesLabel',
|
||||
defaultMessage: 'Assignment Types',
|
||||
description: 'Assignment Types filter select label in Gradebook Filters',
|
||||
},
|
||||
maxGrade: {
|
||||
id: 'gradebook.GradebookFilters.maxGradeFilterLabel',
|
||||
defaultMessage: 'Max Grade',
|
||||
description: 'Max-grade filter select label in Gradebook Filters',
|
||||
},
|
||||
minGrade: {
|
||||
id: 'gradebook.GradebookFilters.minGradeFilterLabel',
|
||||
defaultMessage: 'Min Grade',
|
||||
description: 'Min-grade filter select label in Gradebook Filters',
|
||||
},
|
||||
cohorts: {
|
||||
id: 'gradebook.GradebookFilters.cohorts',
|
||||
defaultMessage: 'Cohorts',
|
||||
description: 'Cohorts filter select label in Gradebook Filters',
|
||||
},
|
||||
cohortAll: {
|
||||
id: 'gradebook.GradebookFilters.cohortsAll',
|
||||
defaultMessage: 'Cohort-All',
|
||||
description: 'Cohorts filter select default in Gradebook Filters',
|
||||
},
|
||||
tracks: {
|
||||
id: 'gradebook.GradebookFilters.tracks',
|
||||
defaultMessage: 'Tracks',
|
||||
description: 'Tracks filter select label in Gradebook Filters',
|
||||
},
|
||||
trackAll: {
|
||||
id: 'gradebook.GradebookFilters.trackAll',
|
||||
defaultMessage: 'Track-All',
|
||||
description: 'Tracks filter select default in Gradebook Filters',
|
||||
},
|
||||
closeFilters: {
|
||||
id: 'gradebook.GradebookFilters.closeFilters',
|
||||
defaultMessage: 'Close Filters',
|
||||
description: 'Button label for Close button in Gradebook Filters',
|
||||
},
|
||||
apply: {
|
||||
id: 'gradebook.GradebookFilters.apply',
|
||||
defaultMessage: 'Apply',
|
||||
description: 'Apply filter button text',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user