Compare commits

..

1 Commits

Author SHA1 Message Date
morenol
324bb97c15 Bring change from main branch to koa release (#153) 2020-12-03 13:55:00 -05:00
335 changed files with 31152 additions and 40787 deletions

69
.env
View File

@@ -1,38 +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=''
LANGUAGE_PREFERENCE_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=''
DISPLAY_FEEDBACK_WIDGET='true'
# Fallback in local style files
PARAGON_THEME_URLS={}
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,

View File

@@ -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
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'
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
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,8 +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=''
DISPLAY_FEEDBACK_WIDGET='false'
# Fallback in local style files
PARAGON_THEME_URLS={}

View File

@@ -3,4 +3,3 @@ dist/
node_modules/
src/postcss.config.js
src/segment.js
src/lightning.js

View File

@@ -1,30 +1,3 @@
// eslint-disable-next-line import/no-extraneous-dependencies
const { createConfig } = require('@openedx/frontend-build');
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');

View File

@@ -1,7 +0,0 @@
version: 2
updates:
# Adding new check for github-actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"

View File

@@ -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

33
.github/renovate.json vendored
View File

@@ -1,33 +0,0 @@
{
"extends": [
"config:base",
"schedule:weekly",
":automergeLinters",
":automergeMinor",
":automergeTesters",
":enableVulnerabilityAlerts",
":rebaseStalePrs",
":semanticCommits",
":updateNotScheduled"
],
"packageRules": [
{
"matchDepTypes": [
"devDependencies"
],
"matchUpdateTypes": [
"lockFileMaintenance",
"minor",
"patch",
"pin"
],
"automerge": true
},
{
"matchPackagePatterns": ["@edx", "@openedx"],
"matchUpdateTypes": ["minor", "patch"],
"automerge": true
}
],
"timezone": "America/New_York"
}

View File

@@ -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 }}

View File

@@ -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

View File

@@ -1,60 +0,0 @@
name: node_js CI
on:
push:
branches:
- master
pull_request:
branches:
- '**'
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Nodejs
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
- 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@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: true
- name: Send failure notification
if: ${{ failure() }}
uses: dawidd6/action-send-mail@v6
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 }}"

View File

@@ -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

View File

@@ -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-v3.yml@master

View File

@@ -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

View File

@@ -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 }}

13
.gitignore vendored
View File

@@ -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,13 +14,3 @@ temp/babel-plugin-react-intl
*~
*.swo
*.swp
### Development environments ###
.idea
.vscode
### transifex ###
src/i18n/transifex_input.json
temp
src/i18n/messages/

View File

@@ -1,6 +1,7 @@
.eslintignore
.eslintrc.json
.gitignore
.travis.yml
docker-compose.yml
Dockerfile
Makefile

1
.nvmrc
View File

@@ -1 +0,0 @@
24

27
.releaserc Normal file
View File

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

28
.travis.yml Executable file
View File

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

55
Makefile Normal file → Executable file
View File

@@ -1,56 +1,9 @@
npm-install-%: ## install specified % npm package
npm ci $* --save-dev
npm install $* --save-dev
git add package.json
intl_imports = ./node_modules/.bin/intl-imports.js
transifex_utils = ./node_modules/.bin/transifex-utils.js
i18n = ./src/i18n
transifex_input = $(i18n)/transifex_input.json
# This directory must match .babelrc .
transifex_temp = ./temp/babel-plugin-formatjs
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
# Experimental: OEP-58 Pulls translations using atlas
pull_translations:
rm -rf src/i18n/messages
mkdir src/i18n/messages
cd src/i18n/messages \
&& atlas pull $(ATLAS_OPTIONS) \
translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \
translations/frontend-component-header/src/i18n/messages:frontend-component-header \
translations/frontend-platform/src/i18n/messages:frontend-platform \
translations/paragon/src/i18n/messages:paragon \
translations/frontend-app-gradebook/src/i18n/messages:frontend-app-gradebook
$(intl_imports) frontend-platform paragon frontend-component-header frontend-component-footer frontend-app-gradebook
# 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

79
README.md Executable file
View File

@@ -0,0 +1,79 @@
[![Build Status](https://api.travis-ci.org/edx/frontend-app-gradebook.svg?branch=master)](https://travis-ci.org/edx/frontend-app-gradebook) [![Coveralls](https://img.shields.io/coveralls/edx/frontend-app-gradebook.svg?branch=master)](https://coveralls.io/github/edx/frontend-app-gradebook)
[![npm_version](https://img.shields.io/npm/v/@edx/frontend-app-gradebook.svg)](@edx/frontend-app-gradebook)
[![npm_downloads](https://img.shields.io/npm/dt/@edx/frontend-app-gradebook.svg)](@edx/frontend-app-gradebook)
[![license](https://img.shields.io/npm/l/@edx/frontend-app-gradebook.svg)](@edx/frontend-app-gradebook)
[![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release)
# gradebook
Please tag **@edx/educator-neem** on any PRs or issues.
## Introduction
The front-end of our editable Gradebook feature.
## Usage
To install gradebook into your project:
```
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/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.
If you don't, you can see the log messages for the docker container by executing `make gradebook-logs` in the `devstack` directory.
Note that starting the container executes the `npm run start` script which will hot-reload JavaScript and Sass files changes, so you should (:crossed_fingers:) not need to do anything (other than wait) when making changes.
## 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 settings in `lms/env/private.py`:
```
WRITABLE_GRADEBOOK_URL = 'http://localhost:1994'
```
There are also several edx-platform waffle and feature flags you'll have to enable from the Django admin:
1. Grades > Persistent grades enabled flag. Add this flag if it doesn't exist,
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. 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
1. Assuming that you're operating in the context of the edX devstack,
run `gradebook-shell` from your devstack directory. This will start a bash shell inside your
running gradebook container.
2. Run `make test` (which executes `npm run test`). This will run all of the gradebook tests.
## Directory Structure
* `config`
* Directory for [`webpack`](https://webpack.js.org/) configurations
* `public`
* Entry point for the single-page application - `gradebook` has a single `index.html` file
* `src`
* `components`
* Directory for presentational `React` components
* `containers`
* Directory for container `React` components
* `data`
* `actions`
* Directory for `Redux` action creators
* `constants`
* `reducers`
* Directory for `Redux` reducers
## Authentication with backend API services
See the [`@edx/frontend-auth`](https://github.com/edx/frontend-auth) repo for information about securing routes in your application that require user authentication.

View File

@@ -1,253 +0,0 @@
frontend-app-gradebook
#######################
|Build Status| |Codecov| |npm_version| |npm_downloads| |license| |semantic-release|
Purpose
*******
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.
Jump to:
- `Should I use Gradebook in my course?`_
- Quickstart_
For existing documentation see:
- Basic Usage: `Review Learner Grades (read-the-docs) <https://docs.openedx.org/en/latest/educators/how-tos/data/view_learner_grades.html>`_
- Bulk Grade Management: `Override Learner Subsection Scores in Bulk (read-the-docs) <https://docs.openedx.org/en/latest/educators/how-tos/data/manage_learner_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.
Getting Started
***************
Installation
============
To install gradebook into your project:
.. code-block:: bash
npm i --save @edx/frontend-app-gradebook
Quickstart
==========
Cloning and Setup
-----------------
1. Clone your new repo:
.. code-block:: bash
git clone https://github.com/openedx/frontend-app-gradebook.git
2. Use the version of Node specified in ``.nvmrc``
3. Stop the Tutor devstack, if it's running:
.. code-block:: bash
tutor dev stop
4. Next, we need to tell Tutor that we're going to be running this repo in development mode, and it should be excluded from the mfe container that otherwise runs every MFE. Run this:
.. code-block:: bash
tutor mounts add /path/to/frontend-app-gradebook
5. Start Tutor in development mode. This command will start the LMS and Studio,
and other required MFEs like ``authn`` and ``account``, but will not start the
Gradebook MFE, which we're going to run on the host instead of in a container
managed by Tutor. Run:
.. code-block:: bash
tutor dev start lms cms mfe
Startup
-------
1. Install npm dependencies:
.. code-block:: bash
cd frontend-app-gradebook && npm install
2. Start the dev server:
.. code-block:: bash
npm run dev
Running the UI Standalone
==========================
To install the project please refer to the `MFE Development on Tutor <https://github.com/overhangio/tutor-mfe?tab=readme-ov-file#mfe-development>`_ instructions.
When not mounted, gradebook will run in the shared MFE container at http://apps.local.openedx.io/gradebook/course-v1:edX+DemoX+Demo_Course.
When mounted in the tutor ``gradebook`` container, or when running a local (host) webpack dev server, the web application runs on port **1994**, so when you go to http://apps.local.openedx.io:1994/gradebook/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.
(Note: This may not work in Tutor; these instructions are for the deprecated Devstack) You can see the log messages for the docker container by executing ``make gradebook-logs`` in the ``devstack`` directory.
Note that starting the container executes the ``npm run start`` script which will hot-reload JavaScript and Sass files changes, so you should (:crossed_fingers:) not need to do anything (other than wait) when making changes.
Plugins
*******
This MFE can be customized using `Frontend Plugin Framework <https://github.com/openedx/frontend-plugin-framework>`_.
The parts of this MFE that can be customized in that manner are documented `here </src/plugin-slots>`_.
Running tests
*************
Run:
.. code-block:: bash
nvm use
npm ci
npm test
Directory Structure
*******************
* ``config``
* Directory for `webpack <https://webpack.js.org/>`_ configurations
* ``public``
* Entry point for the single-page application - ``gradebook`` has a single ``index.html`` file
* ``src``
* ``components``
* Directory for presentational ``React`` components
* ``containers``
* Directory for container ``React`` components
* ``data``
* ``actions``
* Directory for ``Redux`` action creators
* ``constants``
* ``reducers``
* Directory for ``Redux`` reducers
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.
License
*******
The code in this repository is licensed under the AGPLv3 unless otherwise
noted.
Please see `LICENSE <LICENSE>`_ for details.
Contributing
************
Contributions are very welcome. Please read `How To Contribute`_ for details.
.. _How To Contribute: https://openedx.org/r/how-to-contribute
This project is currently accepting all types of contributions, bug fixes,
security fixes, maintenance work, or new features. However, please make sure
to have a discussion about your new feature idea with the maintainers prior to
beginning development to maximize the chances of your change being accepted.
You can start a conversation by creating a new issue on this repo summarizing
your idea.
Getting Help
************
If you're having trouble, we have discussion forums at
https://discuss.openedx.org where you can connect with others in the community.
Our real-time conversations are on Slack. You can request a `Slack
invitation`_, then join our `community Slack workspace`_. Because this is a
frontend repository, the best place to discuss it would be in the
`#wg-frontend channel`_.
For anything non-trivial, the best path is to open an issue in this repository
with as many details about the issue you are facing as you can provide.
https://github.com/openedx/frontend-app-gradebook/issues
For more information about these options, see the `Getting Help`_ page.
.. _Slack invitation: https://openedx.org/slack
.. _community Slack workspace: https://openedx.slack.com/
.. _#wg-frontend channel: https://openedx.slack.com/archives/C04BM6YC7A6
.. _Getting Help: https://openedx.org/community/connect
The Open edX Code of Conduct
*****************************
All community members are expected to follow the `Open edX Code of Conduct`_.
.. _Open edX Code of Conduct: https://openedx.org/code-of-conduct/
Reporting Security Issues
**************************
Please do not report security issues in public. Please email security@openedx.org.
.. |Build Status| image:: https://api.travis-ci.com/edx/frontend-app-gradebook.svg?branch=master
:target: https://travis-ci.com/edx/frontend-app-gradebook
.. |Codecov| image:: https://img.shields.io/codecov/c/gh/openedx/frontend-app-gradebook
:target: https://app.codecov.io/gh/openedx/frontend-app-gradebook
.. |npm_version| image:: https://img.shields.io/npm/v/@edx/frontend-app-gradebook.svg
:target: @edx/frontend-app-gradebook
.. |npm_downloads| image:: https://img.shields.io/npm/dt/@edx/frontend-app-gradebook.svg
:target: @edx/frontend-app-gradebook
.. |license| image:: https://img.shields.io/npm/l/@edx/frontend-app-gradebook.svg
:target: @edx/frontend-app-gradebook
.. |semantic-release| image:: https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg
:target: https://github.com/semantic-release/semantic-release

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

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

View File

@@ -1,14 +0,0 @@
# This file records information about this repo. Its use is described in OEP-55:
# https://open-edx-proposals.readthedocs.io/en/latest/processes/oep-0055-proc-project-maintainers.html
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: "frontend-app-gradebook"
description: "The frontend (MFE) for Open edX Gradebook"
annotations:
openedx.org/arch-interest-groups: ""
openedx.org/release: "master"
spec:
owner: user:farhaanbukhsh
type: 'website'
lifecycle: 'experimental'

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

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

View File

@@ -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`.

View File

@@ -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
========

View File

@@ -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.

View File

@@ -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://docs.openedx.org/en/latest/educators/quickstarts/build_a_course.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://docs.openedx.org/en/latest/educators/how-tos/data/manage_learner_grades.html#review-how-grading-is-configured-for-your-course)
- [Adding Exercises and Tools](https://docs.openedx.org/en/latest/educators/concepts/exercise_tools/about_problems_exercises_tools.html)
- [Set the Assignment Type and Due Date for a Subsection](https://docs.openedx.org/en/latest/educators/how-tos/course_development/set_subsection_problem_date.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 - note: possibly outdated, edx.org-specific](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

View File

@@ -1,14 +0,0 @@
const { createConfig } = require('@openedx/frontend-build');
module.exports = createConfig('jest', {
setupFilesAfterEnv: [
'<rootDir>/src/setupTest.js',
],
modulePaths: ['<rootDir>/src/'],
coveragePathIgnorePatterns: [
'src/segment.js',
'src/postcss.config.js',
'testUtils', // don't unit test jest mocking tools
'testUtilsExtra', // don't unit test jest mocking tools
],
});

13
openedx.yaml Normal file
View File

@@ -0,0 +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

48825
package-lock.json generated

File diff suppressed because it is too large Load Diff

65
package.json Normal file → Executable file
View File

@@ -1,60 +1,53 @@
{
"name": "@edx/frontend-app-gradebook",
"version": "1.6.3",
"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": "fedx-scripts formatjs extract",
"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",
"dev": "PUBLIC_PATH=/gradebook/ MFE_CONFIG_API_URL='http://localhost:8000/api/mfe_config/v1' fedx-scripts webpack-dev-server --progress --host apps.local.openedx.io",
"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:@openedx/brand-openedx@^1.2.2",
"@edx/frontend-component-footer": "^14.6.0",
"@edx/frontend-component-header": "^6.6.1",
"@edx/frontend-platform": "^8.3.7",
"@edx/openedx-atlas": "^0.6.0",
"@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",
"@openedx/frontend-plugin-framework": "^1.6.0",
"@openedx/paragon": "^23.4.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",
"font-awesome": "4.7.0",
"history": "4.10.1",
"prop-types": "15.8.1",
"node-sass": "^4.14.1",
"prop-types": "15.7.2",
"query-string": "6.13.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-helmet": "^6.1.0",
"react-redux": "^7.2.9",
"react-router": "6.15.0",
"react-router-dom": "6.15.0",
"react": "16.13.1",
"react-dom": "16.13.1",
"react-intl": "^2.9.0",
"react-redux": "^5.1.1",
"react-router": "5.2.0",
"react-router-dom": "5.2.0",
"react-router-redux": "^5.0.0-alpha.9",
"redux": "4.0.5",
"redux-beacon": "^2.1.0",
@@ -62,21 +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",
"@openedx/frontend-build": "^14.6.2",
"@testing-library/jest-dom": "^6.6.4",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@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": "^12.2.0",
"fetch-mock": "^6.5.2",
"husky": "2.7.0",
"identity-obj-proxy": "^3.0.0",
"jest": "^29.7.0",
"react-dev-utils": "^12.0.1",
"react-test-renderer": "^18.3.1",
"redux-mock-store": "^1.5.3"
"react-dev-utils": "^5.0.3",
"react-test-renderer": "^16.10.1",
"redux-mock-store": "^1.5.3",
"semantic-release": "^15.13.24",
"travis-deploy-once": "^5.0.11"
}
}

View File

@@ -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>

View File

@@ -1,32 +0,0 @@
import React from 'react';
import { Route, Routes } from 'react-router-dom';
import { AppProvider } from '@edx/frontend-platform/react';
import { FooterSlot } from '@edx/frontend-component-footer';
import Header from '@edx/frontend-component-header';
import store from 'data/store';
import GradebookPage from 'containers/GradebookPage';
import './App.scss';
import Head from './head/Head';
const App = () => (
<AppProvider store={store}>
<Head />
<div>
<Header />
<main>
<Routes>
<Route
path="/:courseId"
element={<GradebookPage />}
/>
</Routes>
</main>
<FooterSlot />
</div>
</AppProvider>
);
export default App;

View File

@@ -1,15 +1,13 @@
// frontend-app-*/src/index.scss
@use "@openedx/paragon/styles/css/core/custom-media-breakpoints.css" as paragonCustomMediaBreakpoints;
@import "~@edx/paragon/scss/edx/theme.scss";
$fa-font-path: "~font-awesome/fonts";
@import "~font-awesome/scss/font-awesome";
$input-focus-box-shadow: var(--pgn-elevation-form-input-base); // hack to get upgrade to paragon 4.0.0 to work
$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";

View File

@@ -1,63 +0,0 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
jest.mock('react-router-dom', () => ({
Routes: ({ children }) => children,
Route: ({ element }) => element,
}));
jest.mock('@edx/frontend-platform/react', () => ({
AppProvider: ({ children }) => children,
}));
jest.mock('@edx/frontend-component-header', () => ({
__esModule: true,
default: () => <div>Header</div>,
}));
jest.mock('@edx/frontend-component-footer', () => ({
FooterSlot: () => <div>Footer</div>,
}));
jest.mock('./head/Head', () => ({
__esModule: true,
default: () => <div>Head</div>,
}));
jest.mock('containers/GradebookPage', () => ({
__esModule: true,
default: () => <div>Gradebook</div>,
}));
describe('App', () => {
beforeEach(() => {
render(<App />);
});
afterEach(() => {
jest.clearAllMocks();
});
it('renders Head component', () => {
const head = screen.getByText('Head');
expect(head).toBeInTheDocument();
});
it('renders Header component', () => {
const header = screen.getByText('Header');
expect(header).toBeInTheDocument();
});
it('renders Footer component', () => {
const footer = screen.getByText('Footer');
expect(footer).toBeInTheDocument();
});
it('renders main content wrapper', () => {
const main = screen.getByRole('main');
expect(main).toBeInTheDocument();
const gradebook = screen.getByText('Gradebook');
expect(gradebook).toBeInTheDocument();
});
});

View File

@@ -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 '@openedx/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);

View File

@@ -1,51 +0,0 @@
import React from 'react';
import selectors from 'data/selectors';
import { BulkManagementAlerts, mapStateToProps } from './BulkManagementAlerts';
import { renderWithIntl, screen } from '../../testUtilsExtra';
jest.mock('data/selectors', () => ({
__esModule: true,
default: {
grades: {
bulkImportError: (state) => ({ bulkImportError: state }),
uploadSuccess: (state) => ({ uploadSuccess: state }),
},
},
}));
const errorMessage = 'Oh noooooo';
describe('BulkManagementAlerts', () => {
describe('component', () => {
describe('states of the warnings', () => {
test('no alert shown', () => {
renderWithIntl(<BulkManagementAlerts bulkImportError="" uploadSuccess={false} />);
expect(document.querySelectorAll('.alert').length).toEqual(0);
});
test('Just success alert shown', () => {
renderWithIntl(<BulkManagementAlerts bulkImportError="" uploadSuccess />);
expect(document.querySelectorAll('.alert-success').length).toEqual(1);
});
test('Just error alert shown', () => {
renderWithIntl(<BulkManagementAlerts bulkImportError={errorMessage} uploadSuccess={false} />);
expect(document.querySelectorAll('.alert-danger').length).toEqual(1);
expect(screen.getByText(errorMessage)).toBeInTheDocument();
});
});
});
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));
});
});
});

View File

@@ -1,6 +0,0 @@
.bulk-management-history-view {
.help-text {
margin-bottom: 40px;
max-width: 70%;
}
}

View File

@@ -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 '@openedx/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);

View File

@@ -1,187 +0,0 @@
import React from 'react';
import { render, screen, initializeMocks } from 'testUtilsExtra';
import { DataTable } from '@openedx/paragon';
import selectors from 'data/selectors';
import { bulkManagementColumns } from 'data/constants/app';
import { HistoryTable, mapHistoryRows, mapStateToProps } from './HistoryTable';
import ResultsSummary from './ResultsSummary';
initializeMocks();
jest.mock('@openedx/paragon', () => ({
...jest.requireActual('@openedx/paragon'),
DataTable: jest.fn(() => <div data-testid="data-table">DataTable</div>),
}));
jest.mock('./ResultsSummary', () => jest.fn(() => <div data-testid="results-summary">ResultsSummary</div>));
jest.mock('data/selectors', () => ({
__esModule: true,
default: {
grades: {
bulkManagementHistoryEntries: jest.fn(),
},
},
}));
describe('HistoryTable', () => {
beforeEach(() => {
jest.clearAllMocks();
});
const mockBulkManagementHistory = [
{
originalFilename: 'test-file-1.csv',
user: 'test-user-1',
timeUploaded: '2025-01-01T10:00:00Z',
resultsSummary: {
rowId: 1,
text: 'Download results 1',
},
},
{
originalFilename: 'test-file-2.csv',
user: 'test-user-2',
timeUploaded: '2025-01-02T10:00:00Z',
resultsSummary: {
rowId: 2,
text: 'Download results 2',
},
},
];
beforeEach(() => {
jest.clearAllMocks();
});
describe('mapHistoryRows', () => {
const mockRow = {
resultsSummary: {
rowId: 1,
text: 'Download results',
},
originalFilename: 'test-file.csv',
user: 'test-user',
timeUploaded: '2025-01-01T10:00:00Z',
};
it('transforms row data correctly', () => {
const result = mapHistoryRows(mockRow);
expect(result).toHaveProperty('resultsSummary');
expect(result).toHaveProperty('filename');
expect(result).toHaveProperty('user');
expect(result).toHaveProperty('timeUploaded');
expect(result.timeUploaded).toBe('2025-01-01T10:00:00Z');
});
it('wraps filename in span with correct class', () => {
const result = mapHistoryRows(mockRow);
render(<div>{result.filename}</div>);
const filenameSpan = screen.getByText('test-file.csv');
expect(filenameSpan).toBeInTheDocument();
expect(filenameSpan).toHaveClass('wrap-text-in-cell');
});
it('wraps user in span with correct class', () => {
const result = mapHistoryRows(mockRow);
render(<div>{result.user}</div>);
const userSpan = screen.getByText('test-user');
expect(userSpan).toBeInTheDocument();
expect(userSpan).toHaveClass('wrap-text-in-cell');
});
it('renders ResultsSummary component with correct props', () => {
const result = mapHistoryRows(mockRow);
render(<div>{result.resultsSummary}</div>);
expect(ResultsSummary).toHaveBeenCalledWith(mockRow.resultsSummary, {});
expect(screen.getByTestId('results-summary')).toBeInTheDocument();
});
});
describe('component', () => {
it('renders DataTable with empty data when no history provided', () => {
render(<HistoryTable />);
expect(DataTable).toHaveBeenCalledWith(
{
data: [],
hasFixedColumnWidths: true,
columns: bulkManagementColumns,
className: 'table-striped',
itemCount: 0,
},
{},
);
expect(screen.getByTestId('data-table')).toBeInTheDocument();
});
it('renders DataTable with mapped history data', () => {
render(
<HistoryTable bulkManagementHistory={mockBulkManagementHistory} />,
);
expect(DataTable).toHaveBeenCalledWith(
{
data: expect.arrayContaining([
expect.objectContaining({
filename: expect.any(Object),
user: expect.any(Object),
resultsSummary: expect.any(Object),
timeUploaded: '2025-01-01T10:00:00Z',
}),
expect.objectContaining({
filename: expect.any(Object),
user: expect.any(Object),
resultsSummary: expect.any(Object),
timeUploaded: '2025-01-02T10:00:00Z',
}),
]),
hasFixedColumnWidths: true,
columns: bulkManagementColumns,
className: 'table-striped',
itemCount: 2,
},
{},
);
});
it('passes correct props to DataTable', () => {
render(
<HistoryTable bulkManagementHistory={mockBulkManagementHistory} />,
);
const dataTableCall = DataTable.mock.calls[0][0];
expect(dataTableCall.hasFixedColumnWidths).toBe(true);
expect(dataTableCall.columns).toBe(bulkManagementColumns);
expect(dataTableCall.className).toBe('table-striped');
expect(dataTableCall.itemCount).toBe(mockBulkManagementHistory.length);
});
});
describe('mapStateToProps', () => {
const mockState = { test: 'state' };
const mockHistoryEntries = [
{ originalFilename: 'file1.csv', user: 'user1' },
{ originalFilename: 'file2.csv', user: 'user2' },
];
beforeEach(() => {
selectors.grades.bulkManagementHistoryEntries.mockReturnValue(
mockHistoryEntries,
);
});
it('maps bulkManagementHistory from selector', () => {
const result = mapStateToProps(mockState);
expect(
selectors.grades.bulkManagementHistoryEntries,
).toHaveBeenCalledWith(mockState);
expect(result.bulkManagementHistory).toBe(mockHistoryEntries);
});
});
});

View File

@@ -1,36 +0,0 @@
/* eslint-disable react/sort-comp, react/button-has-type */
import React from 'react';
import PropTypes from 'prop-types';
import { Hyperlink, Icon } from '@openedx/paragon';
import { Download } from '@openedx/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
destination={lms.urls.bulkGradesUrlByRow(rowId)}
target="_blank"
showLaunchIcon={false}
>
<Icon src={Download} className="d-inline-block" />
{text}
</Hyperlink>
);
ResultsSummary.propTypes = {
rowId: PropTypes.number.isRequired,
text: PropTypes.string.isRequired,
};
export default ResultsSummary;

View File

@@ -1,35 +0,0 @@
import React from 'react';
import lms from 'data/services/lms';
import { renderWithIntl, screen } from '../../testUtilsExtra';
import ResultsSummary from './ResultsSummary';
jest.mock('data/services/lms', () => ({
urls: {
bulkGradesUrlByRow: jest.fn((rowId) => (`www.edx.org/${rowId}`)),
},
}));
describe('ResultsSummary component', () => {
const props = {
rowId: 42,
text: 'texty',
};
let link;
beforeEach(() => {
renderWithIntl(<ResultsSummary {...props} />);
link = screen.getByRole('link', { name: props.text });
});
test('Hyperlink has target="_blank" and rel="noopener noreferrer"', () => {
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
});
test('Hyperlink has href to bulkGradesUrl', () => {
expect(link).toHaveAttribute('href', lms.urls.bulkGradesUrlByRow(props.rowId));
});
test('displays Download Icon and text', () => {
expect(link).toHaveTextContent(props.text);
const icon = screen.getByRole('img', { hidden: true });
expect(icon).toBeInTheDocument();
});
});

View File

@@ -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;

View File

@@ -1,25 +0,0 @@
import { render, initializeMocks, screen } from 'testUtilsExtra';
import { BulkManagementHistoryView } from '.';
import messages from './messages';
jest.mock('./BulkManagementAlerts', () => jest.fn(() => <div>BulkManagementAlerts</div>));
jest.mock('./HistoryTable', () => jest.fn(() => <div>HistoryTable</div>));
initializeMocks();
describe('BulkManagementHistoryView', () => {
describe('component', () => {
beforeEach(() => {
render(<BulkManagementHistoryView />);
});
describe('render alerts and heading', () => {
it('heading - h4 loaded from messages', () => {
expect(screen.getByText(messages.heading.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.helpText.defaultMessage)).toBeInTheDocument();
expect(screen.getByText('BulkManagementAlerts')).toBeInTheDocument();
expect(screen.getByText('HistoryTable')).toBeInTheDocument();
});
});
});
});

View File

@@ -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;

View 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%);
}
}

View 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,
};

View File

@@ -1,35 +0,0 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { render, screen } from '@testing-library/react';
import Header from '.';
jest.mock('@edx/frontend-platform', () => ({
...jest.requireActual('@edx/frontend-platform'),
getConfig: jest.fn(),
}));
describe('Header', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('has edx link with logo url', () => {
const url = 'www.ourLogo.url';
const baseUrl = 'www.lms.url';
getConfig.mockReturnValue({ LOGO_URL: url, LMS_BASE_URL: baseUrl });
render(
<IntlProvider messages={{}} locale="en">
<Header />
</IntlProvider>,
);
const link = screen.getByRole('link');
const logo = screen.getByAltText('edX logo');
expect(link).toHaveAttribute('href', `${baseUrl}/dashboard`);
expect(logo).toHaveAttribute('src', url);
});
});

View File

@@ -1,21 +0,0 @@
import React from 'react';
import { Hyperlink } from '@openedx/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;

View 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">&times;</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,
};

View 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);

View 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);

View 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);

View 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);

View 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);

View 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;
}

View 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&apos;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,
};

View File

@@ -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;

View File

@@ -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);
});
});
});

View File

@@ -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;

View File

@@ -1,37 +0,0 @@
import React from 'react';
import { render, screen, initializeMocks } from 'testUtilsExtra';
import useAssignmentFilterData from './hooks';
import AssignmentFilter from '.';
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();
describe('AssignmentFilter component', () => {
beforeAll(() => {
initializeMocks();
render(<AssignmentFilter updateQueryParams={updateQueryParams} />);
});
describe('render', () => {
test('filter options', () => {
expect(screen.getByRole('combobox', { name: 'Assignment' })).toBeInTheDocument();
expect(screen.getAllByRole('option')).toHaveLength(assignmentFilterOptions.length + 1); // +1 for the default option
expect(screen.getAllByRole('option')[assignmentFilterOptions.length]).toHaveTextContent(assignmentFilterOptions[assignmentFilterOptions.length - 1].label);
});
});
});

View File

@@ -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;

View File

@@ -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);
});
});
});

View File

@@ -1,56 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Button } from '@openedx/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;

View File

@@ -1,74 +0,0 @@
/* eslint-disable import/no-extraneous-dependencies */
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import useAssignmentGradeFilterData from './hooks';
import AssignmentFilter from '.';
import { renderWithIntl } from '../../../testUtilsExtra';
jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() }));
const hookData = {
handleSubmit: jest.fn(),
handleSetMax: jest.fn(),
handleSetMin: jest.fn(),
selectedAssignment: 'test-assignment',
assignmentGradeMax: 300,
assignmentGradeMin: 23,
};
useAssignmentGradeFilterData.mockReturnValue(hookData);
const updateQueryParams = jest.fn();
describe('AssignmentFilter component', () => {
describe('behavior', () => {
it('initializes hooks', () => {
renderWithIntl(<AssignmentFilter updateQueryParams={updateQueryParams} />);
expect(useAssignmentGradeFilterData).toHaveBeenCalledWith({ updateQueryParams });
});
});
describe('render', () => {
describe('with selected assignment', () => {
beforeEach(() => {
jest.clearAllMocks();
renderWithIntl(<AssignmentFilter updateQueryParams={updateQueryParams} />);
});
it('renders a PercentGroup for both Max and Min filters', async () => {
const user = userEvent.setup();
const minGradeInput = screen.getByRole('spinbutton', { name: /Min Grade/i });
const maxGradeInput = screen.getByRole('spinbutton', { name: /Max Grade/i });
expect(minGradeInput).toBeInTheDocument();
expect(maxGradeInput).toBeInTheDocument();
expect(minGradeInput).toBeEnabled();
expect(maxGradeInput).toBeEnabled();
await user.type(minGradeInput, '25');
expect(hookData.handleSetMin).toHaveBeenCalled();
await user.type(maxGradeInput, '50');
expect(hookData.handleSetMax).toHaveBeenCalled();
});
it('renders a submit button', async () => {
const user = userEvent.setup();
const submitButton = screen.getByRole('button', { name: /Apply/ });
expect(submitButton).toBeInTheDocument();
expect(submitButton).not.toHaveAttribute('disabled');
await user.click(submitButton);
expect(hookData.handleSubmit).toHaveBeenCalled();
});
});
describe('without selected assignment', () => {
beforeEach(() => {
useAssignmentGradeFilterData.mockReturnValueOnce({
...hookData,
selectedAssignment: null,
});
renderWithIntl(<AssignmentFilter updateQueryParams={updateQueryParams} />);
});
it('disables controls', () => {
const minGrade = screen.getByRole('spinbutton', { name: /Min Grade/ });
const maxGrade = screen.getByRole('spinbutton', { name: /Max Grade/ });
expect(minGrade).toHaveAttribute('disabled');
expect(maxGrade).toHaveAttribute('disabled');
});
});
});
});

View File

@@ -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;

View File

@@ -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 });
});
});
});

View File

@@ -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;

View File

@@ -1,33 +0,0 @@
import React from 'react';
import { screen } from '@testing-library/react';
import useAssignmentFilterTypeData from './hooks';
import AssignmentFilterType from '.';
import { renderWithIntl } from '../../../testUtilsExtra';
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();
describe('AssignmentFilterType component', () => {
beforeAll(() => {
renderWithIntl(<AssignmentFilterType updateQueryParams={updateQueryParams} />);
});
describe('render', () => {
test('filter options', () => {
const options = screen.getAllByRole('option');
expect(options.length).toEqual(5); // 4 types + "All Types"
expect(options[1]).toHaveTextContent(testType);
});
});
});

View File

@@ -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;

View File

@@ -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);
});
});
});

View File

@@ -1,52 +0,0 @@
import PropTypes from 'prop-types';
import { Button } from '@openedx/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;

View File

@@ -1,61 +0,0 @@
import React from 'react';
import { screen } from '@testing-library/react';
import useCourseGradeFilterData from './hooks';
import CourseFilter from '.';
import { renderWithIntl } from '../../../testUtilsExtra';
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();
describe('CourseFilter component', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('render', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('with selected assignment', () => {
beforeEach(() => {
renderWithIntl(<CourseFilter updateQueryParams={updateQueryParams} />);
});
it('renders a PercentGroup for both Max and Min filters', () => {
expect(screen.getByRole('spinbutton', { name: 'Min Grade' })).toHaveValue(hookData.min.value);
expect(screen.getByRole('spinbutton', { name: 'Max Grade' })).toHaveValue(hookData.max.value);
});
it('renders a submit button', () => {
expect(screen.getByRole('button', { name: 'Apply' })).toBeInTheDocument();
// Expect it to be enabled
expect(screen.getByRole('button', { name: 'Apply' })).not.toBeDisabled();
});
});
describe('if disabled', () => {
beforeEach(() => {
jest.clearAllMocks();
useCourseGradeFilterData.mockReturnValueOnce({ ...hookData, isDisabled: true });
renderWithIntl(<CourseFilter updateQueryParams={updateQueryParams} />);
});
it('disables submit', () => {
expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled();
});
});
});
});

View File

@@ -1,6 +0,0 @@
.filter-sidebar-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 15px;
}

View File

@@ -1,39 +0,0 @@
/* eslint-disable react/sort-comp */
import React from 'react';
import PropTypes from 'prop-types';
import { Form } from '@openedx/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;

View File

@@ -1,35 +0,0 @@
import { render, screen, initializeMocks } from 'testUtilsExtra';
import PercentGroup from './PercentGroup';
describe('PercentGroup', () => {
let props = {
id: 'group id',
label: 'Group Label',
value: 'group VALUE',
disabled: false,
};
beforeEach(() => {
initializeMocks();
props = {
...props,
onChange: jest.fn().mockName('props.onChange'),
};
});
describe('Component', () => {
test('is displayed', () => {
render(<PercentGroup {...props} />);
expect(screen.getByRole('spinbutton', { name: 'Group Label' })).toBeInTheDocument();
expect(screen.getByText('Group Label')).toBeVisible();
expect(screen.getByText('%')).toBeVisible();
});
test('disabled', () => {
render(<PercentGroup {...props} disabled />);
expect(screen.getByRole('spinbutton', { name: 'Group Label' })).toBeDisabled();
expect(screen.getByText('Group Label')).toBeVisible();
expect(screen.getByText('%')).toBeVisible();
});
});
});

View File

@@ -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 '@openedx/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;

View File

@@ -1,37 +0,0 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
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', () => {
test('rendered with all options and label', () => {
render(<SelectGroup {...props} />);
expect(screen.getAllByRole('option')).toHaveLength(props.options.length);
expect(screen.getByLabelText(props.label)).toBeInTheDocument();
});
test('disabled', () => {
render(<SelectGroup {...props} disabled />);
expect(screen.getByRole('combobox')).toBeDisabled();
});
});
});

View File

@@ -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;

View File

@@ -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();
});
});
});
});
});

View File

@@ -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;

View File

@@ -1,164 +0,0 @@
import React from 'react';
import { render, screen, initializeMocks } from 'testUtilsExtra';
import SelectGroup from '../SelectGroup';
import { StudentGroupsFilter } from './index';
import useStudentGroupsFilterData from './hooks';
jest.mock('../SelectGroup', () => jest.fn(() => <div data-testid="select-group">SelectGroup</div>));
jest.mock('./hooks', () => jest.fn());
initializeMocks();
describe('StudentGroupsFilter', () => {
const mockUpdateQueryParams = jest.fn();
const mockTracksData = {
value: 'test-track-value',
entries: [
{ value: 'track1', name: 'Track 1' },
{ value: 'track2', name: 'Track 2' },
],
handleChange: jest.fn(),
};
const mockCohortsData = {
value: 'test-cohort-value',
entries: [
{ value: 'cohort1', name: 'Cohort 1' },
{ value: 'cohort2', name: 'Cohort 2' },
],
handleChange: jest.fn(),
isDisabled: false,
};
beforeEach(() => {
jest.clearAllMocks();
useStudentGroupsFilterData.mockReturnValue({
tracks: mockTracksData,
cohorts: mockCohortsData,
});
});
it('calls useStudentGroupsFilterData hook with updateQueryParams', () => {
render(<StudentGroupsFilter updateQueryParams={mockUpdateQueryParams} />);
expect(useStudentGroupsFilterData).toHaveBeenCalledWith({
updateQueryParams: mockUpdateQueryParams,
});
});
it('renders two SelectGroup components', () => {
render(<StudentGroupsFilter updateQueryParams={mockUpdateQueryParams} />);
expect(SelectGroup).toHaveBeenCalledTimes(2);
expect(screen.getAllByTestId('select-group')).toHaveLength(2);
});
describe('tracks SelectGroup', () => {
it('renders tracks SelectGroup with correct props', () => {
render(<StudentGroupsFilter updateQueryParams={mockUpdateQueryParams} />);
const tracksCall = SelectGroup.mock.calls[0][0];
expect(tracksCall.id).toBe('Tracks');
expect(tracksCall.value).toBe(mockTracksData.value);
expect(tracksCall.onChange).toBe(mockTracksData.handleChange);
});
it('includes trackAll option in tracks SelectGroup', () => {
render(<StudentGroupsFilter updateQueryParams={mockUpdateQueryParams} />);
const tracksCall = SelectGroup.mock.calls[0][0];
const { options } = tracksCall;
expect(options).toHaveLength(3);
expect(options[0].props.value).toBeDefined();
expect(options[0].props.children).toBeDefined();
});
it('includes track entries in tracks SelectGroup options', () => {
render(<StudentGroupsFilter updateQueryParams={mockUpdateQueryParams} />);
const tracksCall = SelectGroup.mock.calls[0][0];
const { options } = tracksCall;
expect(options[1].props.value).toBe('track1');
expect(options[1].props.children).toBe('Track 1');
expect(options[2].props.value).toBe('track2');
expect(options[2].props.children).toBe('Track 2');
});
});
describe('cohorts SelectGroup', () => {
it('renders cohorts SelectGroup with correct props', () => {
render(<StudentGroupsFilter updateQueryParams={mockUpdateQueryParams} />);
const cohortsCall = SelectGroup.mock.calls[1][0];
expect(cohortsCall.id).toBe('Cohorts');
expect(cohortsCall.value).toBe(mockCohortsData.value);
expect(cohortsCall.onChange).toBe(mockCohortsData.handleChange);
expect(cohortsCall.disabled).toBe(mockCohortsData.isDisabled);
});
it('includes cohortAll option in cohorts SelectGroup', () => {
render(<StudentGroupsFilter updateQueryParams={mockUpdateQueryParams} />);
const cohortsCall = SelectGroup.mock.calls[1][0];
const { options } = cohortsCall;
expect(options).toHaveLength(3);
expect(options[0].props.value).toBeDefined();
expect(options[0].props.children).toBeDefined();
});
it('includes cohort entries in cohorts SelectGroup options', () => {
render(<StudentGroupsFilter updateQueryParams={mockUpdateQueryParams} />);
const cohortsCall = SelectGroup.mock.calls[1][0];
const { options } = cohortsCall;
expect(options[1].props.value).toBe('cohort1');
expect(options[1].props.children).toBe('Cohort 1');
expect(options[2].props.value).toBe('cohort2');
expect(options[2].props.children).toBe('Cohort 2');
});
it('passes disabled state to cohorts SelectGroup', () => {
useStudentGroupsFilterData.mockReturnValue({
tracks: mockTracksData,
cohorts: { ...mockCohortsData, isDisabled: true },
});
render(<StudentGroupsFilter updateQueryParams={mockUpdateQueryParams} />);
const cohortsCall = SelectGroup.mock.calls[1][0];
expect(cohortsCall.disabled).toBe(true);
});
});
describe('with empty entries', () => {
it('handles empty tracks entries', () => {
useStudentGroupsFilterData.mockReturnValue({
tracks: { ...mockTracksData, entries: [] },
cohorts: mockCohortsData,
});
render(<StudentGroupsFilter updateQueryParams={mockUpdateQueryParams} />);
const tracksCall = SelectGroup.mock.calls[0][0];
expect(tracksCall.options).toHaveLength(1);
});
it('handles empty cohorts entries', () => {
useStudentGroupsFilterData.mockReturnValue({
tracks: mockTracksData,
cohorts: { ...mockCohortsData, entries: [] },
});
render(<StudentGroupsFilter updateQueryParams={mockUpdateQueryParams} />);
const cohortsCall = SelectGroup.mock.calls[1][0];
expect(cohortsCall.options).toHaveLength(1);
});
});
});

View File

@@ -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.filterMenu.useCloseMenu();
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;

View File

@@ -1,59 +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: {
filterMenu: { useCloseMenu: 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.filterMenu.useCloseMenu.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.filterMenu.useCloseMenu).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 });
});
});
});
});

View File

@@ -1,89 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
Collapsible,
Icon,
IconButton,
Form,
} from '@openedx/paragon';
import { Close } from '@openedx/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;

View File

@@ -1,30 +0,0 @@
import React from 'react';
import { render, screen, initializeMocks } from 'testUtilsExtra';
import GradebookFilters from '.';
const updateQueryParams = jest.fn();
initializeMocks();
describe('GradebookFilters', () => {
beforeEach(() => {
jest.clearAllMocks();
render(<GradebookFilters updateQueryParams={updateQueryParams} />);
});
describe('All filters render together', () => {
test('Assignment filters', () => {
expect(screen.getByRole('combobox', { name: 'Assignment Types' })).toBeInTheDocument();
expect(screen.getByRole('combobox', { name: 'Assignment' })).toBeInTheDocument();
});
test('CourseGrade filters', () => {
expect(screen.getByRole('button', { name: 'Overall Grade' })).toBeInTheDocument();
});
test('StudentGroups filters', () => {
expect(screen.getByRole('button', { name: 'Student Groups' })).toBeInTheDocument();
});
test('includeCourseTeamMembers', () => {
expect(screen.getByRole('button', { name: 'Include Course Team Members' })).toBeInTheDocument();
});
});
});

View File

@@ -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;

View File

@@ -1,35 +0,0 @@
import { views } from 'data/constants/app';
import { actions, selectors } from 'data/redux/hooks';
import messages from './messages';
export const useGradebookHeaderData = () => {
const activeView = selectors.app.useActiveView();
const courseId = selectors.app.useCourseId();
const areGradesFrozen = selectors.assignmentTypes.useAreGradesFrozen();
const canUserViewGradebook = selectors.roles.useCanUserViewGradebook();
const showBulkManagement = selectors.root.useShowBulkManagement();
const setView = actions.app.useSetView();
const handleToggleViewClick = () => setView(
activeView === views.grades
? views.bulkManagementHistory
: views.grades,
);
const toggleViewMessage = activeView === views.grades
? messages.toActivityLog
: messages.toGradesView;
return {
areGradesFrozen,
canUserViewGradebook,
courseId,
showBulkManagement,
handleToggleViewClick,
toggleViewMessage,
};
};
export default useGradebookHeaderData;

View File

@@ -1,90 +0,0 @@
import { views } from 'data/constants/app';
import { actions, selectors } from 'data/redux/hooks';
import messages from './messages';
import useGradebookHeaderData from './hooks';
jest.mock('data/redux/hooks', () => ({
actions: {
app: {
useSetView: jest.fn(),
},
},
selectors: {
app: {
useActiveView: jest.fn(),
useCourseId: jest.fn(),
},
assignmentTypes: {
useAreGradesFrozen: jest.fn(),
},
roles: {
useCanUserViewGradebook: jest.fn(),
},
root: {
useShowBulkManagement: jest.fn(),
},
},
}));
const activeView = 'test-active-view';
selectors.app.useActiveView.mockReturnValue(activeView);
const courseId = 'test-course-id';
selectors.app.useCourseId.mockReturnValue(courseId);
const areGradesFrozen = 'test-are-grades-frozen';
selectors.assignmentTypes.useAreGradesFrozen.mockReturnValue(areGradesFrozen);
const canUserViewGradebook = 'test-can-user-view-gradebook';
selectors.roles.useCanUserViewGradebook.mockReturnValue(canUserViewGradebook);
const showBulkManagement = 'test-show-bulk-management';
selectors.root.useShowBulkManagement.mockReturnValue(showBulkManagement);
const setView = jest.fn();
actions.app.useSetView.mockReturnValue(setView);
let out;
describe('useGradebookHeaderData hooks', () => {
describe('initialization', () => {
it('initializes redux hooks', () => {
out = useGradebookHeaderData();
expect(selectors.app.useActiveView).toHaveBeenCalled();
expect(selectors.app.useCourseId).toHaveBeenCalled();
expect(selectors.assignmentTypes.useAreGradesFrozen).toHaveBeenCalled();
expect(selectors.roles.useCanUserViewGradebook).toHaveBeenCalled();
expect(selectors.root.useShowBulkManagement).toHaveBeenCalled();
expect(actions.app.useSetView).toHaveBeenCalled();
});
});
describe('output', () => {
test('redux fields', () => {
out = useGradebookHeaderData();
expect(out.areGradesFrozen).toEqual(areGradesFrozen);
expect(out.canUserViewGradebook).toEqual(canUserViewGradebook);
expect(out.courseId).toEqual(courseId);
expect(out.showBulkManagement).toEqual(showBulkManagement);
});
describe('handleToggleViewClick', () => {
it('calls setView with bulkManagemnetHistory message if grades view is active', () => {
selectors.app.useActiveView.mockReturnValueOnce(views.grades);
out = useGradebookHeaderData();
out.handleToggleViewClick();
expect(setView).toHaveBeenCalledWith(views.bulkManagementHistory);
});
it('calls setView with grades view if grades view is not active', () => {
out = useGradebookHeaderData();
out.handleToggleViewClick();
expect(setView).toHaveBeenCalledWith(views.grades);
});
});
describe('toggleViewMessage', () => {
it('returns toActivityLog message if grades view is active', () => {
selectors.app.useActiveView.mockReturnValueOnce(views.grades);
out = useGradebookHeaderData();
expect(out.toggleViewMessage).toEqual(messages.toActivityLog);
});
it('returns toGradesView message if grades view is not active', () => {
out = useGradebookHeaderData();
expect(out.toggleViewMessage).toEqual(messages.toGradesView);
});
});
});
});

View File

@@ -1,50 +0,0 @@
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Button } from '@openedx/paragon';
import { instructorDashboardUrl } from 'data/services/lms/urls';
import useGradebookHeaderData from './hooks';
import messages from './messages';
export const GradebookHeader = () => {
const { formatMessage } = useIntl();
const {
areGradesFrozen,
canUserViewGradebook,
courseId,
handleToggleViewClick,
showBulkManagement,
toggleViewMessage,
} = useGradebookHeaderData();
const dashboardUrl = instructorDashboardUrl();
return (
<div className="gradebook-header">
<a href={dashboardUrl} className="mb-3">
<span aria-hidden="true">{'<< '}</span>
{formatMessage(messages.backToDashboard)}
</a>
<h1>{formatMessage(messages.gradebook)}</h1>
<div className="subtitle-row d-flex justify-content-between align-items-center">
<h2 className="text-break">{courseId}</h2>
{showBulkManagement && (
<Button variant="tertiary" onClick={handleToggleViewClick}>
{formatMessage(toggleViewMessage)}
</Button>
)}
</div>
{areGradesFrozen && (
<div className="alert alert-warning" role="alert">
{formatMessage(messages.frozenWarning)}
</div>
)}
{(canUserViewGradebook === false) && (
<div className="alert alert-warning" role="alert">
{formatMessage(messages.unauthorizedWarning)}
</div>
)}
</div>
);
};
export default GradebookHeader;

View File

@@ -1,300 +0,0 @@
import React from 'react';
import { render, screen, initializeMocks } from 'testUtilsExtra';
import userEvent from '@testing-library/user-event';
import { instructorDashboardUrl } from 'data/services/lms/urls';
import { GradebookHeader } from './index';
import useGradebookHeaderData from './hooks';
import messages from './messages';
jest.mock('data/services/lms/urls', () => ({
instructorDashboardUrl: jest.fn(),
}));
jest.mock('./hooks', () => jest.fn());
initializeMocks();
describe('GradebookHeader', () => {
const mockHandleToggleViewClick = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
instructorDashboardUrl.mockReturnValue('https://example.com/dashboard');
});
describe('basic rendering', () => {
beforeEach(() => {
useGradebookHeaderData.mockReturnValue({
areGradesFrozen: false,
canUserViewGradebook: true,
courseId: 'course-v1:TestU+CS101+2024',
handleToggleViewClick: mockHandleToggleViewClick,
showBulkManagement: false,
toggleViewMessage: messages.toActivityLog,
});
});
it('renders the main header container', () => {
render(<GradebookHeader />);
const header = screen.getByText('Gradebook').closest('.gradebook-header');
expect(header).toHaveClass('gradebook-header');
});
it('renders back to dashboard link', () => {
render(<GradebookHeader />);
const dashboardLink = screen.getByRole('link');
expect(dashboardLink).toHaveAttribute(
'href',
'https://example.com/dashboard',
);
expect(dashboardLink).toHaveClass('mb-3');
expect(dashboardLink).toHaveTextContent('Back to Dashboard');
});
it('renders gradebook title', () => {
render(<GradebookHeader />);
const title = screen.getByRole('heading', { level: 1 });
expect(title).toHaveTextContent('Gradebook');
});
it('renders course ID subtitle', () => {
render(<GradebookHeader />);
const subtitle = screen.getByRole('heading', { level: 2 });
expect(subtitle).toHaveTextContent('course-v1:TestU+CS101+2024');
expect(subtitle).toHaveClass('text-break');
});
it('renders subtitle row with correct classes', () => {
render(<GradebookHeader />);
const subtitleRow = screen.getByRole('heading', {
level: 2,
}).parentElement;
expect(subtitleRow).toHaveClass(
'subtitle-row',
'd-flex',
'justify-content-between',
'align-items-center',
);
});
it('calls instructorDashboardUrl to get dashboard URL', () => {
render(<GradebookHeader />);
expect(instructorDashboardUrl).toHaveBeenCalled();
});
it('calls useGradebookHeaderData hook', () => {
render(<GradebookHeader />);
expect(useGradebookHeaderData).toHaveBeenCalled();
});
});
describe('bulk management toggle button', () => {
describe('when showBulkManagement is true', () => {
beforeEach(() => {
useGradebookHeaderData.mockReturnValue({
areGradesFrozen: false,
canUserViewGradebook: true,
courseId: 'course-v1:TestU+CS101+2024',
handleToggleViewClick: mockHandleToggleViewClick,
showBulkManagement: true,
toggleViewMessage: messages.toActivityLog,
});
});
it('renders toggle view button', () => {
render(<GradebookHeader />);
expect(screen.getByRole('button')).toBeInTheDocument();
});
it('displays correct button text from toggleViewMessage', () => {
render(<GradebookHeader />);
const toggleButton = screen.getByRole('button');
expect(toggleButton).toHaveTextContent('View Bulk Management History');
});
it('calls handleToggleViewClick when button is clicked', async () => {
render(<GradebookHeader />);
const user = userEvent.setup();
const toggleButton = screen.getByRole('button');
await user.click(toggleButton);
expect(mockHandleToggleViewClick).toHaveBeenCalledTimes(1);
});
it('displays correct message from toggleViewMessage', () => {
useGradebookHeaderData.mockReturnValue({
areGradesFrozen: false,
canUserViewGradebook: true,
courseId: 'course-v1:TestU+CS101+2024',
handleToggleViewClick: mockHandleToggleViewClick,
showBulkManagement: true,
toggleViewMessage: messages.toGradesView,
});
render(<GradebookHeader />);
const toggleButton = screen.getByRole('button');
expect(toggleButton).toHaveTextContent('Return to Gradebook');
});
});
describe('when showBulkManagement is false', () => {
beforeEach(() => {
useGradebookHeaderData.mockReturnValue({
areGradesFrozen: false,
canUserViewGradebook: true,
courseId: 'course-v1:TestU+CS101+2024',
handleToggleViewClick: mockHandleToggleViewClick,
showBulkManagement: false,
toggleViewMessage: messages.toActivityLog,
});
});
it('does not render toggle view button', () => {
render(<GradebookHeader />);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
});
});
describe('frozen grades warning', () => {
describe('when areGradesFrozen is true', () => {
beforeEach(() => {
useGradebookHeaderData.mockReturnValue({
areGradesFrozen: true,
canUserViewGradebook: true,
courseId: 'course-v1:TestU+CS101+2024',
handleToggleViewClick: mockHandleToggleViewClick,
showBulkManagement: false,
toggleViewMessage: messages.toActivityLog,
});
});
it('renders frozen warning alert', () => {
render(<GradebookHeader />);
const alert = screen.getByRole('alert');
expect(alert).toHaveClass('alert', 'alert-warning');
expect(alert).toHaveTextContent(
'The grades for this course are now frozen. Editing of grades is no longer allowed.',
);
});
});
describe('when areGradesFrozen is false', () => {
beforeEach(() => {
useGradebookHeaderData.mockReturnValue({
areGradesFrozen: false,
canUserViewGradebook: true,
courseId: 'course-v1:TestU+CS101+2024',
handleToggleViewClick: mockHandleToggleViewClick,
showBulkManagement: false,
toggleViewMessage: messages.toActivityLog,
});
});
it('does not render frozen warning alert', () => {
render(<GradebookHeader />);
expect(
screen.queryByText(
'The grades for this course are now frozen. Editing of grades is no longer allowed.',
),
).not.toBeInTheDocument();
});
});
});
describe('unauthorized warning', () => {
describe('when canUserViewGradebook is false', () => {
beforeEach(() => {
useGradebookHeaderData.mockReturnValue({
areGradesFrozen: false,
canUserViewGradebook: false,
courseId: 'course-v1:TestU+CS101+2024',
handleToggleViewClick: mockHandleToggleViewClick,
showBulkManagement: false,
toggleViewMessage: messages.toActivityLog,
});
});
it('renders unauthorized warning alert', () => {
render(<GradebookHeader />);
const alert = screen.getByRole('alert');
expect(alert).toHaveClass('alert', 'alert-warning');
expect(alert).toHaveTextContent(
'You are not authorized to view the gradebook for this course.',
);
});
});
describe('when canUserViewGradebook is true', () => {
beforeEach(() => {
useGradebookHeaderData.mockReturnValue({
areGradesFrozen: false,
canUserViewGradebook: true,
courseId: 'course-v1:TestU+CS101+2024',
handleToggleViewClick: mockHandleToggleViewClick,
showBulkManagement: false,
toggleViewMessage: messages.toActivityLog,
});
});
it('does not render unauthorized warning alert', () => {
render(<GradebookHeader />);
expect(
screen.queryByText(
'You are not authorized to view the gradebook for this course.',
),
).not.toBeInTheDocument();
});
});
});
describe('multiple warnings', () => {
it('renders both frozen and unauthorized warnings when both conditions are true', () => {
useGradebookHeaderData.mockReturnValue({
areGradesFrozen: true,
canUserViewGradebook: false,
courseId: 'course-v1:TestU+CS101+2024',
handleToggleViewClick: mockHandleToggleViewClick,
showBulkManagement: false,
toggleViewMessage: messages.toActivityLog,
});
render(<GradebookHeader />);
const alerts = screen.getAllByRole('alert');
expect(alerts).toHaveLength(2);
expect(
screen.getByText(
'The grades for this course are now frozen. Editing of grades is no longer allowed.',
),
).toBeInTheDocument();
expect(
screen.getByText(
'You are not authorized to view the gradebook for this course.',
),
).toBeInTheDocument();
});
});
describe('complete integration', () => {
it('renders all elements when showBulkManagement is true', () => {
useGradebookHeaderData.mockReturnValue({
areGradesFrozen: false,
canUserViewGradebook: true,
courseId: 'course-v1:TestU+CS101+2024',
handleToggleViewClick: mockHandleToggleViewClick,
showBulkManagement: true,
toggleViewMessage: messages.toActivityLog,
});
render(<GradebookHeader />);
expect(screen.getByRole('link')).toBeInTheDocument();
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument();
expect(screen.getByRole('heading', { level: 2 })).toBeInTheDocument();
expect(screen.getByRole('button')).toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
});
});

View File

@@ -1,36 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
backToDashboard: {
id: 'gradebook.GradebookHeader.backButton',
defaultMessage: 'Back to Dashboard',
description: 'Button text to take user back to LMS dashboard in Gradebook Header',
},
gradebook: {
id: 'gradebook.GradebookHeader.appLabel',
defaultMessage: 'Gradebook',
description: 'Top-level app title in Gradebook Header component',
},
frozenWarning: {
id: 'gradebook.GradebookHeader.frozenWarning',
defaultMessage: 'The grades for this course are now frozen. Editing of grades is no longer allowed.',
description: 'Warning message in Gradebook Header for frozen messages',
},
unauthorizedWarning: {
id: 'gradebook.GradebookHeader.unauthorizedWarning',
defaultMessage: 'You are not authorized to view the gradebook for this course.',
description: 'Warning message in Gradebook Header when user is not allowed to view the app',
},
toActivityLog: {
id: 'gradebook.GradebookHeader.toActivityLogButton',
defaultMessage: 'View Bulk Management History',
description: 'Button text for button navigating to Bulk Managment Activity Log',
},
toGradesView: {
id: 'gradebook.GradebookHeader.toGradesView',
defaultMessage: 'Return to Gradebook',
description: 'Button text for button navigating to Grades view.',
},
});
export default messages;

View File

@@ -1,18 +0,0 @@
import { actions, selectors } from 'data/redux/hooks';
export const useBulkManagementControlsData = () => {
const gradeExportUrl = selectors.root.useGradeExportUrl();
const showBulkManagement = selectors.root.useShowBulkManagement();
const downloadBulkGradesReport = actions.grades.useDownloadBulkGradesReport();
const handleClickExportGrades = () => {
downloadBulkGradesReport();
window.location.assign(gradeExportUrl);
};
return {
show: showBulkManagement,
handleClickExportGrades,
};
};
export default useBulkManagementControlsData;

View File

@@ -1,72 +0,0 @@
import { actions, selectors } from 'data/redux/hooks';
import useBulkManagementControlsData from './hooks';
jest.mock('data/redux/hooks', () => ({
actions: {
grades: {
useDownloadBulkGradesReport: jest.fn(),
},
},
selectors: {
root: {
useGradeExportUrl: jest.fn(),
useShowBulkManagement: jest.fn(),
},
},
}));
const downloadBulkGrades = jest.fn();
actions.grades.useDownloadBulkGradesReport.mockReturnValue(downloadBulkGrades);
const gradeExportUrl = 'test-grade-export-url';
selectors.root.useGradeExportUrl.mockReturnValue(gradeExportUrl);
selectors.root.useShowBulkManagement.mockReturnValue(true);
let hook;
describe('useBulkManagementControlsData', () => {
const oldWindowLocation = window.location;
beforeAll(() => {
delete window.location;
window.location = Object.defineProperties(
{},
{
...Object.getOwnPropertyDescriptors(oldWindowLocation),
assign: { configurable: true, value: jest.fn() },
},
);
});
beforeEach(() => {
window.location.assign.mockReset();
hook = useBulkManagementControlsData();
});
afterAll(() => {
// restore `window.location` to the `jsdom` `Location` object
window.location = oldWindowLocation;
});
describe('initialization', () => {
it('initializes redux hooks', () => {
expect(selectors.root.useGradeExportUrl).toHaveBeenCalledWith();
expect(selectors.root.useShowBulkManagement).toHaveBeenCalledWith();
expect(actions.grades.useDownloadBulkGradesReport).toHaveBeenCalledWith();
});
});
describe('output', () => {
it('forwards show from showBulkManagement', () => {
expect(hook.show).toEqual(true);
selectors.root.useShowBulkManagement.mockReturnValue(false);
hook = useBulkManagementControlsData();
expect(hook.show).toEqual(false);
});
describe('handleClickExportGrades', () => {
beforeEach(() => {
hook.handleClickExportGrades();
});
it('downloads bulk grades report', () => {
expect(downloadBulkGrades).toHaveBeenCalledWith();
});
it('sets window location to grade export url', () => {
expect(window.location.assign).toHaveBeenCalledWith(gradeExportUrl);
});
});
});
});

View File

@@ -1,33 +0,0 @@
/* eslint-disable react/sort-comp, react/button-has-type */
import React from 'react';
import NetworkButton from 'components/NetworkButton';
import ImportGradesButton from '../ImportGradesButton';
import useBulkManagementControlsData from './hooks';
import messages from './messages';
/**
* <BulkManagementControls />
* Provides download buttons for Bulk Management and Intervention reports, only if
* showBulkManagement is set in redus.
*/
export const BulkManagementControls = () => {
const {
show,
handleClickExportGrades,
} = useBulkManagementControlsData();
if (!show) { return null; }
return (
<div className="d-flex">
<NetworkButton
label={messages.downloadGradesBtn}
onClick={handleClickExportGrades}
/>
<ImportGradesButton />
</div>
);
};
export default BulkManagementControls;

View File

@@ -1,160 +0,0 @@
import React from 'react';
import { render, screen, initializeMocks } from 'testUtilsExtra';
import NetworkButton from 'components/NetworkButton';
import ImportGradesButton from '../ImportGradesButton';
import { BulkManagementControls } from './index';
import useBulkManagementControlsData from './hooks';
import messages from './messages';
jest.mock('components/NetworkButton', () => jest.fn(() => <div data-testid="network-button">NetworkButton</div>));
jest.mock('../ImportGradesButton', () => jest.fn(() => (
<div data-testid="import-grades-button">ImportGradesButton</div>
)));
jest.mock('./hooks', () => jest.fn());
initializeMocks();
describe('BulkManagementControls', () => {
const mockHandleClickExportGrades = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
describe('when show is false', () => {
beforeEach(() => {
useBulkManagementControlsData.mockReturnValue({
show: false,
handleClickExportGrades: mockHandleClickExportGrades,
});
});
it('renders nothing when show is false', () => {
render(<BulkManagementControls />);
expect(screen.queryByTestId('network-button')).not.toBeInTheDocument();
expect(
screen.queryByTestId('import-grades-button'),
).not.toBeInTheDocument();
});
it('does not render NetworkButton when show is false', () => {
render(<BulkManagementControls />);
expect(NetworkButton).not.toHaveBeenCalled();
});
it('does not render ImportGradesButton when show is false', () => {
render(<BulkManagementControls />);
expect(ImportGradesButton).not.toHaveBeenCalled();
});
});
describe('when show is true', () => {
beforeEach(() => {
useBulkManagementControlsData.mockReturnValue({
show: true,
handleClickExportGrades: mockHandleClickExportGrades,
});
});
it('renders the container div with correct class when show is true', () => {
render(<BulkManagementControls />);
const containerDiv = screen.getByTestId('network-button').parentElement;
expect(containerDiv).toHaveClass('d-flex');
});
it('renders NetworkButton with correct props', () => {
render(<BulkManagementControls />);
expect(NetworkButton).toHaveBeenCalledWith(
{
label: messages.downloadGradesBtn,
onClick: mockHandleClickExportGrades,
},
{},
);
expect(screen.getByTestId('network-button')).toBeInTheDocument();
});
it('renders ImportGradesButton', () => {
render(<BulkManagementControls />);
expect(ImportGradesButton).toHaveBeenCalledWith({}, {});
expect(screen.getByTestId('import-grades-button')).toBeInTheDocument();
});
it('calls handleClickExportGrades when NetworkButton is clicked', () => {
render(<BulkManagementControls />);
const networkButtonCall = NetworkButton.mock.calls[0][0];
const { onClick } = networkButtonCall;
onClick();
expect(mockHandleClickExportGrades).toHaveBeenCalledTimes(1);
});
it('passes correct label to NetworkButton', () => {
render(<BulkManagementControls />);
const networkButtonCall = NetworkButton.mock.calls[0][0];
expect(networkButtonCall.label).toBe(messages.downloadGradesBtn);
});
it('renders both buttons in the correct order', () => {
render(<BulkManagementControls />);
expect(NetworkButton).toHaveBeenCalled();
expect(ImportGradesButton).toHaveBeenCalled();
const networkButton = screen.getByTestId('network-button');
const importButton = screen.getByTestId('import-grades-button');
expect(networkButton).toBeInTheDocument();
expect(importButton).toBeInTheDocument();
});
});
describe('hook integration', () => {
it('calls useBulkManagementControlsData hook', () => {
useBulkManagementControlsData.mockReturnValue({
show: true,
handleClickExportGrades: mockHandleClickExportGrades,
});
render(<BulkManagementControls />);
expect(useBulkManagementControlsData).toHaveBeenCalledTimes(1);
});
it('uses the show value from hook to determine rendering', () => {
useBulkManagementControlsData.mockReturnValue({
show: false,
handleClickExportGrades: mockHandleClickExportGrades,
});
render(<BulkManagementControls />);
expect(screen.queryByTestId('network-button')).not.toBeInTheDocument();
useBulkManagementControlsData.mockReturnValue({
show: true,
handleClickExportGrades: mockHandleClickExportGrades,
});
render(<BulkManagementControls />);
expect(screen.getByTestId('network-button')).toBeInTheDocument();
});
it('passes handleClickExportGrades from hook to NetworkButton', () => {
const customHandler = jest.fn();
useBulkManagementControlsData.mockReturnValue({
show: true,
handleClickExportGrades: customHandler,
});
render(<BulkManagementControls />);
const networkButtonCall = NetworkButton.mock.calls[0][0];
expect(networkButtonCall.onClick).toBe(customHandler);
});
});
});

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