Compare commits

...

36 Commits

Author SHA1 Message Date
Nathan Sprenkle
84a7531530 Split SearchControls into separate component (#174)
* Split SearchControls into separate component

* Add testing config

* Add webpack config

* Add snapshot tests for Search Controls

* bump version and update package-lock format
2021-04-28 13:27:02 -04:00
Nathan Sprenkle
27296449b4 Gradebook Test Plan (#171)
Add basic testing setup/instructions
2021-04-28 11:21:15 -04:00
Ben Warzeski
2b37919222 Merge pull request #173 from muselesscreator/fix_filters2
Fix filters2
2021-04-21 16:42:44 -04:00
Ben Warzeski
384d6cc296 fix: all filters now update queryParams 2021-04-21 15:34:26 -04:00
Ben Warzeski
a0943b3946 updateQueryParams fix for filters 2021-04-21 14:38:42 -04:00
Jansen Kantor
8bc1fc82f2 Add Show Course Staff option and exclude all course roles by default (#168)
* Show Course Role Members

* add option to hide FilterBadge value for boolean filters

* chore: bump package to 1.4.20

Co-authored-by: Nathan Sprenkle <nsprenkle@edx.org>
2021-04-16 11:06:45 -04:00
Jansen Kantor
1c26aa1d71 fix: typo preventing display of assignment name (#169)
* fix: typo preventing display of assignment name

* bump version
2021-03-23 10:08:06 -04:00
Michael Roytman
582b6cb1c5 Merge pull request #166 from edx/mroytman/update-openedx-yaml-file
update openedx.yaml to use current best practices
2021-02-02 16:12:12 -05:00
Michael Roytman
bc04f6d86f update openedx.yaml to use current best practices 2021-02-02 15:46:57 -05:00
Kyle McCormick
84f1efefb3 Allow special access to bulk management tools (#165)
Access is configured on a per-course-run basis
via the new setting:
`BULK_MANAGEMENT_SPECIAL_ACCESS_COURSE_IDS`

TNL-7901
2021-01-25 17:20:21 -05:00
Nathan Sprenkle
e9f01ea3a3 Remove duplicate labels from assignments filter (#164)
* Remove duplicate labels from assignments filter

* Bump to v1.4.18
2021-01-21 14:31:07 -05:00
Nathan Sprenkle
100fbc08bf Update README and add PR template (#160)
* Update README.md
* Add CODEOWNERS
* Add PR template
* Add VS Code .gitignore
2021-01-14 14:27:16 -05:00
Matthew Carter
500364dc99 Merge pull request #161 from muselesscreator/update_axios
update axios and frontend-platform
2021-01-06 14:19:39 -05:00
Ben Warzeski
609c0a8d3a update axios and frontend-platform 2021-01-05 15:31:34 -05:00
Jawayria
5f81624342 Merge pull request #147 from Jawayria/update-badge
Updated the build status badge to point to travis-ci.com
2020-12-17 12:44:30 +05:00
Ben Warzeski
647ecbab75 Merge pull request #159 from muselesscreator/v1.4.16
v1.4.16
2020-12-16 15:48:46 -05:00
Ben Warzeski
de539382bd v1.4.16 2020-12-16 15:45:11 -05:00
Ben Warzeski
cc01ab0a92 Bulk mgmt tab fix (#158)
* fix bulk management tab

* fix status alert prop

* shorthand fragment to make ci happy
2020-12-16 12:47:20 -05:00
Ben Warzeski
8881e62337 Merge pull request #155 from muselesscreator/rebrand
Rebrand
2020-12-16 12:14:18 -05:00
Ben Warzeski
92e7cc39cd Filter UI css/layout clean-up 2020-12-14 16:20:43 -05:00
Ben Warzeski
d6d09205f4 update theme to branding 2020-12-14 16:19:50 -05:00
Ben Warzeski
b2e4e330bf fix footer logo 2020-12-14 16:19:10 -05:00
Ben Warzeski
d10dc54116 update npm requirements 2020-12-14 16:16:17 -05:00
Matthew Carter
15d7dcfe85 Merge pull request #156 from edx/column_header_width
fix column header width to static value 
resolves https://openedx.atlassian.net/browse/EDUCATOR-4849
2020-12-10 16:24:26 -05:00
Ben Warzeski
6717663c07 fix column header width to static value 2020-12-10 10:30:12 -05:00
Ben Warzeski
ac229ebc85 Merge pull request #154 from muselesscreator/logo_update
logo update
2020-12-03 16:25:41 -05:00
Ben Warzeski
4c481721bc logo update 2020-12-02 08:15:35 -05:00
morenol
40f52b2dc9 Follow up to #149 (#152)
* fix: Update frontend-build and frontend-platform (#145)

* fix: Update frontend-build

* Upgrade frontend-platform

* Workaround for gradebook to use PUBLIC_PATH env in paths

* fix: error with undefined PUBLIC_PATH configuration
2020-12-01 15:58:10 -05:00
Matthew Carter
a25e446998 Merge pull request #143 from open-craft/fox/SE-3221-update-README
[SE-3221] Update README and provide screenshots of functionality.
2020-11-23 12:48:15 -05:00
Matthew Carter
326ae93ed7 Merge pull request #151 from edx/jkantor/semanitc-release-17.2.3
EDUCATOR-5470: upgrade semantic-release to 17.2.3
2020-11-20 09:45:33 -05:00
jansenk
95e9b51aca fix(security): Upgrade semantic-version to 17.2.3 2020-11-19 17:11:12 -05:00
Cory Lee
4d76329946 Merge pull request #149 from edx/revert-145-lmm/build
Revert "fix: Update frontend-build and frontend-platform"
2020-11-18 13:47:04 -05:00
Cory Lee
5c565bebb0 Revert "fix: Update frontend-build and frontend-platform (#145)"
This reverts commit 677521808b.
2020-11-18 13:46:38 -05:00
Jawayria
d1ca314565 Updated the build status badge to point to travis-ci.com 2020-11-18 21:31:44 +05:00
Fox Danger Piacenti
351bf48561 Address feedback on README. 2020-09-15 12:58:50 -05:00
Fox Danger Piacenti
30c51668c4 Update README and provide screenshots of functionality. 2020-09-14 12:46:42 -05:00
40 changed files with 45271 additions and 3761 deletions

2
.env
View File

@@ -31,3 +31,5 @@ ENTERPRISE_MARKETING_URL=null,
ENTERPRISE_MARKETING_UTM_SOURCE=null,
ENTERPRISE_MARKETING_UTM_CAMPAIGN=null,
ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM=null,
BULK_MANAGEMENT_SPECIAL_ACCESS_COURSE_IDS=null,

View File

@@ -4,6 +4,11 @@ BASE_URL='localhost:1994'
LMS_BASE_URL='http://localhost:18000'
LOGIN_URL='http://localhost:18000/login'
LOGOUT_URL='http://localhost:18000/login'
LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
LOGO_POWERED_BY_OPEN_EDX_URL_SVG=https://edx-cdn.org/v3/stage/open-edx-tag.svg
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
@@ -33,3 +38,5 @@ 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'
BULK_MANAGEMENT_SPECIAL_ACCESS_COURSE_IDS=null

6
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1,6 @@
# Code owners for frontend-app-gradebook, editable gradebook micro-frontend (MFE)
# These owners will be the default owners for everything in
# the repo. Unless a later match takes precedence, they will
# be requested for review when someone opens a pull request.
* @edx/masters-devs-gta

29
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,29 @@
**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: @edx/masters-devs-gta

5
.gitignore vendored
View File

@@ -1,6 +1,5 @@
.DS_Store
.eslintcache
.idea
node_modules
npm-debug.log
coverage
@@ -14,3 +13,7 @@ dist/
*~
*.swo
*.swp
### Development environments ###
.idea
.vscode

View File

@@ -1,18 +1,60 @@
[![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)
[![Build Status](https://api.travis-ci.com/edx/frontend-app-gradebook.svg?branch=master)](https://travis-ci.com/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
# Gradebook
Please tag **@edx/educator-neem** on any PRs or issues.
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.
## Introduction
Jump to:
The front-end of our editable Gradebook feature.
- [Should I use Gradebook in my course?](#should-i-use-gradebook-in-my-course)
- [Quickstart](#quickstart)
## Usage
For existing documentation see:
- Basic Usage: [Review Learner Grades (read-the-docs)](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/student_progress/course_grades.html#review-learner-grades-on-the-instructor-dashboard)
- Bulk Grade Management: [Override Learner Subsection Scores in Bulk (read-the-docs)](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/student_progress/course_grades.html#review-learner-grades-on-the-instructor-dashboard)
## Should I use Gradebook in my course?
### What does this offer over the legacy gradebook?
![A screenshot of the grade listings](documentation/screenshots/grade-listings.png)
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.
![Screenshot of the grade editing interface](documentation/screenshots/grade-editing.png)
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).
![Screenshot of the filtering options](documentation/screenshots/grade-filtering.png)
### What does the legacy gradebook offer that this project does not?
This project does not (yet, at least) create any graphs, which the traditional gradebook does. It also does not give
quick links to the problems for the instructor to visit. It expects the instructor to be familiar with the problems they
are grading and which unit they refer to.
The gradebook is expected to be much more performant for larger numbers of students as well. The Instructor Dashboard
link for the legacy gradebook reports that "this feature is available only to courses with a small number of enrolled
learners." However, this project comes with no such warning.
### Who should not change to this gradebook?
Groups whose instructors need not ever manually override grades do not need this project, but may not be any worse off
depending on their needs. Instructors that expect to review grades infrequently enough that not having a direct link
to the problem in question will have a worse UX than the legacy gradebook provides. Instructors that rely on the graphs
generated by the current gradebook might find the lack of autogenerated graphs to be frustrating.
## Quickstart
### Installation
To install gradebook into your project:
```
@@ -32,7 +74,7 @@ Note that starting the container executes the `npm run start` script which will
## Configuring for local use in edx-platform
Assuming you've got the UI running at `http://localhost:1994`, you can configure the LMS in edx-platform
to point to your local gradebook from the instructor dashboard by putting this settings in `lms/env/private.py`:
to point to your local gradebook from the instructor dashboard by putting this setting in `lms/env/private.py`:
```
WRITABLE_GRADEBOOK_URL = 'http://localhost:1994'
```
@@ -44,10 +86,15 @@ 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.
3. Waffle_utils > Waffle flag course overrides. Activate waffle flags for courses where you want to enable Gradebook functionality:
- Enable Gradebook by adding the ``grades.writable_gradebook`` add checking the ``enabled`` box.
- Enable Bulk Grade Management by adding the ``grades.bulk_management`` flag and checking the ``enabled`` box.
Alternatively, you could add these as regular waffle flags to enable the functionality for all courses.
**NOTE:** IF the above flags are not configured correctly, the gradebook may appear to work, but will return bogus
numbers for grades. If your gradebook isn't accepting your changes, or the changes aren't resulting in sane,
recalculated grade values, verify you've set all flags correctly.
## Running tests

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 KiB

View File

@@ -0,0 +1,100 @@
# 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.
- [ ] *Masters only*: "Bulk Management" allows overriding grades in bulk.
- Open a non-masters-track course.
- [ ] Verify that the "Bulk Management" tab does not appear.
- [ ] Verify that the "Bulk Management" button does not appear.
- Open a masters-track course.
- [ ] Verify that the "Bulk Management" tab appears to the right of the "Grades" tab.
- [ ] Verify that the "Bulk Management" button appears.
- Click the "Bulk Management" button. This downloads existing student/assignment info.
- [ ] Open the downloaded CSV and verify that students and assignments in the file match applied filters/searches.
- Add values in the "new_override-{subsection-short-id}" columns for student grades to be overridden and save the CSV file.
- [ ] Clicking the "Bulk Management" tab shows the Bulk Management page.
- [ ] The bulk management history table appears with columns: "Gradebook", "Download Summary", "Who", "When".
- [ ] Previous bulk management imports (if applicable) appear in the table.
- 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.)
- Navigate back to the "Bulk Management" tab.
- [ ] 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 "Interventions" tab does not appear.
- [ ] Verify that the "Interventions" button does not appear.
- Open a masters-track course.
- [ ] Verify that the "Interventions" tab appears to the right of the "Grades" tab.
- [ ] Verify that the "Interventions" button appears.
- Click on the "Interventions" button to generate a CSV students and activity info.
- Open the interventions report and verify student info and activity info appear.

View File

@@ -0,0 +1,52 @@
# Test Setup
Instructions for setting up environments and data for testing Gradebook.
## Set up a course with graded content
A course with graded content is the first prerequisite to testing. Use an existing course (e.g. the DemoX Demonstration Course in Devstack) or see [Building and Running an edX Course > Developing Your Course](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/developing_course/index.html) for notes on how to develop a course from scratch.
Notably, the course needs a grading policy and subsections with scoreable content.
After creating subsections with content, they need to be configured with an "Assignment Type" to be included in grading.
Suggested resources:
- [Establishing a Grading Policy For Your Course](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/grading/index.html)
- [Adding Exercises and Tools](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/grading/index.html)
- [Set the Assignment Type and Due Date for a Subsection](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/developing_course/course_subsections.html#set-the-assignment-type-and-due-date-for-a-subsection)
## Enable Gradebook and feature toggles 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
- [ ] Add a new flag called `grades.bulk_management`, select "Force On", and enable it for your course
## Create a Master's track for testing Master's-only features
[source](https://openedx.atlassian.net/wiki/spaces/MS/pages/1453818012/Add+a+learner+into+a+master+s+track)
Add a Master's track in your course:
- As an admin user, go to Django Admin (`{lms-url}/admin`) > Course Modes and add a new course mode
- Set the Mode to "Master's"
- Set any valid price and currency values
- Click "Save"
Enroll a student in the Master's track:
- As a staff/admin user, go to `{lms-url}/support/enrollment`
- Search for the username or email of student to enroll
- In the results table row matching the user/course, click the "Change Enrollment" button
- Select the "Master's" enrollment mode and click "Submit enrollment change"
## Setup different types of students in course
To fully test features the course should have at least:
- [ ] An audit-track student
- [ ] A master's-track student
- [ ] A staff member
- [ ] A non-staff user

11
jest.config.js Normal file
View File

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

View File

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

48027
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@edx/frontend-app-gradebook",
"version": "1.4.14",
"version": "1.4.21",
"description": "edx editable gradebook-ui to manipulate grade overrides on subsections",
"repository": {
"type": "git",
@@ -26,9 +26,10 @@
"access": "public"
},
"dependencies": {
"@edx/frontend-component-footer": "10.0.11",
"@edx/frontend-platform": "1.6.1",
"@edx/paragon": "10.0.1",
"@edx/brand": "npm:@edx/brand-edx.org@^1.3.2",
"@edx/frontend-component-footer": "10.1.1",
"@edx/frontend-platform": "1.8.1",
"@edx/paragon": "12.4.1",
"@fortawesome/fontawesome-svg-core": "^1.2.25",
"@fortawesome/free-brands-svg-icons": "^5.11.2",
"@fortawesome/free-solid-svg-icons": "^5.11.2",
@@ -37,6 +38,7 @@
"classnames": "^2.2.6",
"core-js": "3.6.5",
"email-prop-type": "^1.1.7",
"enzyme-to-json": "^3.6.2",
"font-awesome": "4.7.0",
"history": "4.10.1",
"node-sass": "^4.14.1",
@@ -58,8 +60,8 @@
"whatwg-fetch": "^2.0.4"
},
"devDependencies": {
"@edx/frontend-build": "5.3.2",
"axios": "0.19.2",
"@edx/frontend-build": "5.5.2",
"axios": "0.21.1",
"axios-mock-adapter": "^1.17.0",
"codecov": "^3.6.1",
"enzyme": "^3.10.0",
@@ -68,10 +70,11 @@
"fetch-mock": "^6.5.2",
"husky": "2.7.0",
"identity-obj-proxy": "^3.0.0",
"jest": "24.9.0",
"react-dev-utils": "^5.0.3",
"react-test-renderer": "^16.10.1",
"redux-mock-store": "^1.5.3",
"semantic-release": "^15.13.24",
"semantic-release": "^17.2.3",
"travis-deploy-once": "^5.0.11"
}
}

View File

@@ -4,6 +4,7 @@
<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" />
</head>
<body>
<div id="root"></div>

View File

@@ -1,5 +1,8 @@
@import "~@edx/paragon/scss/edx/theme.scss";
// frontend-app-*/src/index.scss
@import "~@edx/brand/paragon/fonts";
@import "~@edx/brand/paragon/variables";
@import "~@edx/paragon/scss/core/core";
@import "~@edx/brand/paragon/overrides";
$fa-font-path: "~font-awesome/fonts";
@import "~font-awesome/scss/font-awesome";
@@ -10,4 +13,3 @@ $input-focus-box-shadow: $input-box-shadow; // hack to get upgrade to paragon 4.
@import "./components/Gradebook/gradebook";
@import "./components/Drawer/Drawer";

View File

@@ -4,11 +4,13 @@ import React from 'react';
import PropTypes from 'prop-types';
import initialFilters from '../../data/constants/filters';
function FilterBadge({ name, value, onClick }) {
function FilterBadge({
name, value, onClick, showValue,
}) {
return (
<div>
<span className="badge badge-info">
<span>{`${name}: ${value}`}</span>
<span>{name}{showValue && `: ${value}`}</span>
<button type="button" className="btn-info" aria-label="Close" onClick={onClick}>
<span aria-hidden="true">&times;</span>
</button>
@@ -18,6 +20,20 @@ function FilterBadge({ name, value, onClick }) {
);
}
FilterBadge.defaultProps = {
showValue: true,
};
FilterBadge.propTypes = {
name: PropTypes.string.isRequired,
value: PropTypes.oneOfType([
PropTypes.string,
PropTypes.bool,
]).isRequired,
onClick: PropTypes.func.isRequired,
showValue: PropTypes.bool,
};
function RangeFilterBadge({
displayName,
filterName1,
@@ -46,7 +62,7 @@ RangeFilterBadge.propTypes = {
};
function SingleValueFilterBadge({
displayName, filterName, filterValue, handleBadgeClose,
displayName, filterName, filterValue, handleBadgeClose, showValue,
}) {
return (filterValue !== initialFilters[filterName])
&& (
@@ -54,14 +70,24 @@ function SingleValueFilterBadge({
name={displayName}
value={filterValue}
onClick={handleBadgeClose}
showValue={showValue}
/>
);
}
SingleValueFilterBadge.defaultProps = {
showValue: true,
};
SingleValueFilterBadge.propTypes = {
displayName: PropTypes.string.isRequired,
filterName: PropTypes.string.isRequired,
filterValue: PropTypes.string.isRequired,
filterValue: PropTypes.oneOfType([
PropTypes.string,
PropTypes.bool,
]).isRequired,
handleBadgeClose: PropTypes.func.isRequired,
showValue: PropTypes.bool,
};
function FilterBadges({
@@ -73,6 +99,7 @@ function FilterBadges({
assignmentGradeMax,
courseGradeMin,
courseGradeMax,
includeCourseRoleMembers,
handleFilterBadgeClose,
}) {
return (
@@ -113,10 +140,17 @@ function FilterBadges({
/>
<SingleValueFilterBadge
displayName="Cohort"
filterName="track"
filterName="cohort"
filterValue={cohort}
handleBadgeClose={handleFilterBadgeClose(['cohort'])}
/>
<SingleValueFilterBadge
displayName="Including Course Team Members"
filterName="includeCourseRoleMembers"
filterValue={includeCourseRoleMembers}
showValue={false}
handleBadgeClose={handleFilterBadgeClose(['includeCourseRoleMembers'])}
/>
</div>
);
}
@@ -131,18 +165,13 @@ const mapStateToProps = state => (
assignmentGradeMax: state.filters.assignmentGradeMax,
courseGradeMin: state.filters.courseGradeMin,
courseGradeMax: state.filters.courseGradeMax,
includeCourseRoleMembers: state.filters.includeCourseRoleMembers,
}
);
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,
@@ -152,6 +181,7 @@ FilterBadges.defaultProps = {
assignmentGradeMax: initialFilters.assignmentGradeMax,
courseGradeMin: initialFilters.courseGradeMin,
courseGradeMax: initialFilters.courseGradeMax,
includeCourseRoleMembers: initialFilters.includeCourseRoleMembers,
};
FilterBadges.propTypes = {
@@ -163,5 +193,6 @@ FilterBadges.propTypes = {
assignmentGradeMax: PropTypes.string,
courseGradeMin: PropTypes.string,
courseGradeMax: PropTypes.string,
includeCourseRoleMembers: PropTypes.bool,
handleFilterBadgeClose: PropTypes.func.isRequired,
};

View File

@@ -34,7 +34,7 @@ export class Assignments extends React.Component {
const { type, id } = selectedFilterOption || {};
const typedValue = { label: assignment, type, id };
this.props.updateAssignmentFilter(typedValue);
this.updateQueryParams({ assignment: id });
this.props.updateQueryParams({ assignment: id });
this.props.updateGradesIfAssignmentGradeFiltersSet(
this.props.courseId,
this.props.selectedCohort,
@@ -70,17 +70,14 @@ export class Assignments extends React.Component {
updateAssignmentTypes = (assignmentType) => {
this.props.filterAssignmentType(assignmentType);
this.updateQueryParams({ assignmentType });
this.props.updateQueryParams({ assignmentType });
}
render() {
return (
<Collapsible title="Assignments" open className="filter-group mb-3">
<Collapsible title="Assignments" defaultOpen className="filter-group mb-3">
<div>
<div className="student-filters">
<span className="label">
Assignment Types:
</span>
<InputSelect
label="Assignment Types"
name="assignment-types"
@@ -92,9 +89,6 @@ export class Assignments extends React.Component {
/>
</div>
<div className="student-filters">
<span className="label">
Assignment:
</span>
<InputSelect
label="Assignment"
name="assignment"
@@ -105,40 +99,45 @@ export class Assignments extends React.Component {
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 className="grade-filter-inputs" onSubmit={this.handleSubmitAssignmentGrade}>
<div className="percent-group">
<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>
</div>
<div className="percent-group">
<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>
</div>
<div className="grade-filter-action">
<Button
type="submit"
className="btn-outline-secondary"
name="assignmentGradeMinMax"
disabled={!this.props.selectedAssignment}
>
Apply
</Button>
</div>
</form>
</div>
</Collapsible>

View File

@@ -7,7 +7,6 @@ import {
Button,
StatusAlert,
Table,
Tab,
} from '@edx/paragon';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faDownload } from '@fortawesome/free-solid-svg-icons';
@@ -84,7 +83,7 @@ export class BulkManagement extends React.Component {
render() {
return (
<Tab eventKey="bulk_management" title="Bulk Management">
<div>
<h4>Use this feature by downloading a CSV for bulk management,
overriding grades locally, and coming back here to upload.
</h4>
@@ -92,7 +91,7 @@ export class BulkManagement extends React.Component {
<StatusAlert
alertType="danger"
dialog={this.props.bulkImportError}
open={this.props.bulkImportError}
isOpen={this.props.bulkImportError}
dismissible={false}
/>
<StatusAlert
@@ -151,7 +150,7 @@ export class BulkManagement extends React.Component {
]}
className="table-striped"
/>
</Tab>
</div>
);
}
}

View File

@@ -0,0 +1,110 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { Button, Icon, SearchField } from '@edx/paragon';
import {
fetchGrades,
fetchMatchingUserGrades,
} from '../../data/actions/grades';
/**
* Controls for filtering the GradebookTable. Contains the "Edit Filters" button for opening the filter drawer
* as well as the search box for searching by username/email.
*/
export class SearchControls extends React.Component {
constructor(props) {
super(props);
this.onSubmit = this.onSubmit.bind(this);
this.onChange = this.onChange.bind(this);
this.onClear = this.onClear.bind(this);
}
/** Submitting searches for user matching the username/email in `value` */
onSubmit(value) {
this.props.searchForUser(
this.props.courseId,
value,
this.props.selectedCohort,
this.props.selectedTrack,
this.props.selectedAssignmentType,
);
}
/** Changing the search value stores the key in Gradebook. Currently unused */
onChange(filterValue) {
this.props.setFilterValue(filterValue);
}
/** Clearing the search box falls back to showing students with already applied filters */
onClear() {
this.props.getUserGrades(
this.props.courseId,
this.props.selectedCohort,
this.props.selectedTrack,
this.props.selectedAssignmentType,
);
}
render() {
return (
<>
<h4>Step 1: Filter the Grade Report</h4>
<div className="d-flex justify-content-between">
<Button
id="edit-filters-btn"
className="btn-primary align-self-start"
onClick={this.props.toggleFilterDrawer}
>
<Icon className="fa fa-filter" /> Edit Filters
</Button>
<div>
<SearchField
onSubmit={this.onSubmit}
inputLabel="Search for a learner"
onChange={this.onChange}
onClear={this.onClear}
value={this.props.filterValue}
/>
<small className="form-text text-muted search-help-text">Search by username, email, or student key</small>
</div>
</div>
</>
);
}
}
SearchControls.defaultProps = {
courseId: '',
filterValue: '',
selectedAssignmentType: '',
selectedCohort: null,
selectedTrack: null,
};
SearchControls.propTypes = {
courseId: PropTypes.string,
filterValue: PropTypes.string,
setFilterValue: PropTypes.func.isRequired,
toggleFilterDrawer: PropTypes.func.isRequired,
// From Redux
getUserGrades: PropTypes.func.isRequired,
searchForUser: PropTypes.func.isRequired,
selectedAssignmentType: PropTypes.string,
selectedCohort: PropTypes.string,
selectedTrack: PropTypes.string,
};
export const mapStateToProps = (state) => ({
selectedAssignmentType: state.filters.assignmentType,
selectedTrack: state.filters.track,
selectedCohort: state.filters.cohort,
});
export const mapDispatchToProps = {
getUserGrades: fetchGrades,
searchForUser: fetchMatchingUserGrades,
};
export default connect(mapStateToProps, mapDispatchToProps)(SearchControls);

View File

@@ -0,0 +1,118 @@
import React from 'react';
import { shallow } from 'enzyme';
import {
fetchGrades,
fetchMatchingUserGrades,
} from '../../data/actions/grades';
import { mapDispatchToProps, mapStateToProps, SearchControls } from './SearchControls';
jest.mock('@edx/paragon', () => ({
Icon: 'Icon',
Button: 'Button',
SearchField: 'SearchField',
}));
describe('SearchControls', () => {
let props;
beforeEach(() => {
jest.resetAllMocks();
props = {
courseId: 'course-v1:edX+DEV101+T1',
filterValue: 'alice',
selectedAssignmentType: 'homework',
selectedCohort: 'spring term',
selectedTrack: 'masters',
getUserGrades: jest.fn(),
searchForUser: jest.fn(),
setFilterValue: jest.fn(),
toggleFilterDrawer: jest.fn().mockName('toggleFilterDrawer'),
};
});
const searchControls = (overriddenProps) => {
props = { ...props, ...overriddenProps };
return shallow(<SearchControls {...props} />);
};
describe('Component', () => {
describe('onSubmit', () => {
it('calls props.searchForUser with correct data', () => {
const wrapper = searchControls();
wrapper.instance().onSubmit('bob');
expect(props.searchForUser).toHaveBeenCalledWith(
props.courseId,
'bob',
props.selectedCohort,
props.selectedTrack,
props.selectedAssignmentType,
);
});
});
describe('onChange', () => {
it('saves the changed search value to Gradebook state', () => {
const wrapper = searchControls();
wrapper.instance().onChange('bob');
expect(props.setFilterValue).toHaveBeenCalledWith('bob');
});
});
describe('onClear', () => {
it('re-runs search with existing filters', () => {
const wrapper = searchControls();
wrapper.instance().onClear();
expect(props.getUserGrades).toHaveBeenCalledWith(
props.courseId,
props.selectedCohort,
props.selectedTrack,
props.selectedAssignmentType,
);
});
});
describe('mapStateToProps', () => {
const state = {
filters: {
assignmentType: 'labs',
track: 'honor',
cohort: 'fall term',
},
};
it('maps assignment type filter correctly', () => {
expect(mapStateToProps(state).selectedAssignmentType).toEqual(state.filters.assignmentType);
});
it('maps track filter correctly', () => {
expect(mapStateToProps(state).selectedTrack).toEqual(state.filters.track);
});
it('maps cohort filter correctly', () => {
expect(mapStateToProps(state).selectedCohort).toEqual(state.filters.cohort);
});
});
describe('mapDispatchToProps', () => {
test('getUserGrades', () => {
expect(mapDispatchToProps.getUserGrades).toEqual(fetchGrades);
});
test('searchForUser', () => {
expect(mapDispatchToProps.searchForUser).toEqual(fetchMatchingUserGrades);
});
});
describe('Snapshots', () => {
test('basic snapshot', () => {
const wrapper = searchControls();
wrapper.instance().onChange = jest.fn().mockName('onChange');
wrapper.instance().onClear = jest.fn().mockName('onClear');
wrapper.instance().onSubmit = jest.fn().mockName('onSubmit');
expect(wrapper.instance().render()).toMatchSnapshot();
});
});
});
});

View File

@@ -0,0 +1,37 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SearchControls Component Snapshots basic snapshot 1`] = `
<React.Fragment>
<h4>
Step 1: Filter the Grade Report
</h4>
<div
className="d-flex justify-content-between"
>
<Button
className="btn-primary align-self-start"
id="edit-filters-btn"
onClick={[MockFunction toggleFilterDrawer]}
>
<Icon
className="fa fa-filter"
/>
Edit Filters
</Button>
<div>
<SearchField
inputLabel="Search for a learner"
onChange={[MockFunction onChange]}
onClear={[MockFunction onClear]}
onSubmit={[MockFunction onSubmit]}
value="alice"
/>
<small
className="form-text text-muted search-help-text"
>
Search by username, email, or student key
</small>
</div>
</div>
</React.Fragment>
`;

View File

@@ -31,13 +31,9 @@
}
.student-filters{
display: flex;
.label{
padding-top: 30px;
}
.form-group{
margin-left: 10px;
}
}
.grade-history-header{
float: left;
@@ -113,10 +109,10 @@
td:nth-child(1),
th:nth-child(2),
td:nth-child(2) {
min-width: 240px;
width: 240px;
}
th, td {
min-width: 120px;
width: 120px;
}
}
.table tbody th {
@@ -151,10 +147,33 @@
text-decoration: underline;
}
}
}
.input-percent-label {
margin-top: 10px;
.form-group {
label {
font-weight: bold;
}
}
.filter-group {
.grade-filter-inputs {
.percent-group {
display: inline-block;
.form-group {
width: 115px;
display: inline-block;
}
.input-percent-label {
margin-top: 22px;
margin-left: 5px;
margin-right: 5px;
}
}
}
.grade-filter-action {
text-align: right;
}
}
.mb-85 {

View File

@@ -4,10 +4,10 @@ import PropTypes from 'prop-types';
import {
Button,
Collapsible,
CheckBox,
Icon,
InputSelect,
InputText,
SearchField,
StatusAlert,
Tab,
Tabs,
@@ -26,6 +26,7 @@ import BulkManagement from './BulkManagement';
import BulkManagementControls from './BulkManagementControls';
import EditModal from './EditModal';
import GradebookTable from './GradebookTable';
import SearchControls from './SearchControls';
export default class Gradebook extends React.Component {
constructor(props) {
@@ -238,6 +239,11 @@ export default class Gradebook extends React.Component {
);
}
handleIncludeTeamMembersChange = (includeCourseRoleMembers) => {
this.props.updateIncludeCourseRoleMembers(includeCourseRoleMembers);
this.updateQueryParams({ includeCourseRoleMembers });
};
createStateFieldSetter = (key) => (value) => this.setState({ [key]: value });
createStateFieldOnChange = (key) => ({ target }) => this.setState({ [key]: target.value });
@@ -252,7 +258,8 @@ export default class Gradebook extends React.Component {
safeSetState = this.createLimitedSetter(
'adjustedGradePossible',
'adjustedGradeValue',
'assignmnentName',
'assignmentName',
'filterValue',
'modalOpen',
'reasonForChange',
'todaysDate',
@@ -288,32 +295,17 @@ export default class Gradebook extends React.Component {
)}
<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>
{this.props.showSpinner && (
<div className="spinner-overlay">
<Icon className="fa fa-spinner fa-spin fa-5x color-black" />
</div>
</div>
)}
<SearchControls
courseId={this.props.courseId}
filterValue={this.state.filterValue}
setFilterValue={this.createStateFieldSetter('filterValue')}
toggleFilterDrawer={toggleFilterDrawer}
/>
<ConnectedFilterBadges
handleFilterBadgeClose={this.handleFilterBadgeClose}
/>
@@ -385,10 +377,12 @@ export default class Gradebook extends React.Component {
</Tab>
{this.props.showBulkManagement
&& (
<BulkManagement
courseId={this.props.courseId}
gradeExportUrl={this.props.gradeExportUrl}
/>
<Tab eventKey="bulk_management" title="Bulk Management">
<BulkManagement
courseId={this.props.courseId}
gradeExportUrl={this.props.gradeExportUrl}
/>
</Tab>
)}
</Tabs>
</div>
@@ -408,28 +402,34 @@ export default class Gradebook extends React.Component {
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>
<Collapsible title="Overall Grade" defaultOpen className="filter-group mb-3">
<div className="grade-filter-inputs">
<div className="percent-group">
<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>
</div>
<div className="percent-group">
<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>
</div>
</div>
<div className="grade-filter-action">
<Button
variant="outline-secondary"
onClick={this.handleCourseGradeFilterApplyButtonClick}
@@ -438,7 +438,7 @@ export default class Gradebook extends React.Component {
</Button>
</div>
</Collapsible>
<Collapsible title="Student Groups" open className="filter-group mb-3">
<Collapsible title="Student Groups" defaultOpen className="filter-group mb-3">
<InputSelect
label="Tracks"
name="Tracks"
@@ -458,6 +458,15 @@ export default class Gradebook extends React.Component {
onChange={this.updateCohorts}
/>
</Collapsible>
<Collapsible title="Include Course Team Members" className="filter-group mb-3">
<CheckBox
name="include-course-team-members"
aria-label="Include Course Team Members"
label="Include Course Team Members"
checked={this.props.includeCourseRoleMembers}
onChange={this.handleIncludeTeamMembersChange}
/>
</Collapsible>
</Drawer>
);
}
@@ -479,6 +488,7 @@ Gradebook.defaultProps = {
showSpinner: false,
totalUsersCount: null,
tracks: [],
includeCourseRoleMembers: false,
};
Gradebook.propTypes = {
@@ -503,7 +513,6 @@ Gradebook.propTypes = {
search: PropTypes.string,
}),
resetFilters: PropTypes.func.isRequired,
searchForUser: PropTypes.func.isRequired,
selectedAssignmentType: PropTypes.string,
selectedCohort: PropTypes.string,
selectedTrack: PropTypes.string,
@@ -516,4 +525,6 @@ Gradebook.propTypes = {
name: PropTypes.string,
})),
updateCourseGradeFilter: PropTypes.func.isRequired,
includeCourseRoleMembers: PropTypes.bool,
updateIncludeCourseRoleMembers: PropTypes.func.isRequired,
};

View File

@@ -1,12 +1,11 @@
import React from 'react';
import { Hyperlink } from '@edx/paragon';
import EdxLogo from '../../../assets/edx-sm.png';
import { getConfig } from '@edx/frontend-platform';
export default class Header extends React.Component {
renderLogo() {
return (
<img src={EdxLogo} alt="edX logo" height="30" width="60" />
<img src={getConfig().LOGO_URL} alt="edX logo" height="30" width="60" />
);
}

View File

@@ -5,7 +5,6 @@ import {
closeBanner,
fetchGradeOverrideHistory,
fetchGrades,
fetchMatchingUserGrades,
fetchPrevNextGrades,
filterAssignmentType,
submitFileUploadFormData,
@@ -16,7 +15,12 @@ import {
import { fetchCohorts } from '../../data/actions/cohorts';
import { fetchTracks } from '../../data/actions/tracks';
import {
initializeFilters, resetFilters, updateAssignmentFilter, updateAssignmentLimits, updateCourseGradeFilter,
initializeFilters,
resetFilters,
updateAssignmentFilter,
updateAssignmentLimits,
updateCourseGradeFilter,
updateIncludeCourseRoleMembers,
} from '../../data/actions/filters';
import stateHasMastersTrack from '../../data/selectors/tracks';
import {
@@ -28,6 +32,7 @@ import {
formatMaxCourseGrade,
} from '../../data/selectors/grades';
import { selectableAssignmentLabels } from '../../data/selectors/filters';
import { hasSpecialBulkManagementAccess } from '../../data/selectors/special';
import { getCohortNameById } from '../../data/selectors/cohorts';
import { fetchAssignmentTypes } from '../../data/actions/assignmentTypes';
import { getRoles } from '../../data/actions/roles';
@@ -72,6 +77,7 @@ const mapStateToProps = (state, ownProps) => (
),
courseGradeMin: formatMinCourseGrade(state.filters.courseGradeMin),
courseGradeMax: formatMaxCourseGrade(state.filters.courseGradeMax),
excludedCourseRoles: state.filters.includeCourseRoleMembers ? '' : 'all',
}),
grades: state.grades.results,
headings: getHeadings(state),
@@ -97,13 +103,17 @@ const mapStateToProps = (state, ownProps) => (
selectedCohort: state.filters.cohort,
selectedAssignmentType: state.filters.assignmentType,
selectedAssignment: (state.filters.assignment || {}).label,
showBulkManagement: stateHasMastersTrack(state) && state.config.bulkManagementAvailable,
showBulkManagement: (
hasSpecialBulkManagementAccess(ownProps.match.params.courseId)
|| (stateHasMastersTrack(state) && state.config.bulkManagementAvailable)
),
showSpinner: shouldShowSpinner(state),
showSuccess: state.grades.showSuccess,
totalUsersCount: state.grades.totalUsersCount,
tracks: state.tracks.results,
uploadSuccess: !!(state.grades.bulkManagement
&& state.grades.bulkManagement.uploadSuccess),
includeCourseRoleMembers: state.filters.includeCourseRoleMembers,
}
);
@@ -121,12 +131,12 @@ const mapDispatchToProps = {
getUserGrades: fetchGrades,
initializeFilters,
resetFilters,
searchForUser: fetchMatchingUserGrades,
submitFileUploadFormData,
toggleFormat: toggleGradeFormat,
updateAssignmentFilter,
updateAssignmentLimits,
updateCourseGradeFilter,
updateIncludeCourseRoleMembers,
};
const GradebookPage = connect(

View File

@@ -1,7 +1,14 @@
import initialFilters from '../constants/filters';
import {
INITIALIZE_FILTERS, RESET_FILTERS, UPDATE_ASSIGNMENT_FILTER, UPDATE_ASSIGNMENT_LIMITS, UPDATE_COURSE_GRADE_LIMITS,
INITIALIZE_FILTERS,
RESET_FILTERS,
UPDATE_ASSIGNMENT_FILTER,
UPDATE_ASSIGNMENT_LIMITS,
UPDATE_COURSE_GRADE_LIMITS,
UPDATE_INCLUDE_COURSE_ROLE_MEMBERS,
} from '../constants/actionTypes/filters';
import { getFilters } from '../selectors/filters';
import { fetchGrades } from './grades';
const initializeFilters = ({
assignment = initialFilters.assignment,
@@ -12,6 +19,7 @@ const initializeFilters = ({
assignmentGradeMax = initialFilters.assignmentGradeMax,
courseGradeMin = initialFilters.courseGradeMin,
courseGradeMax = initialFilters.assignmentGradeMax,
includeCourseRoleMembers = initialFilters.includeCourseRoleMembers,
}) => ({
type: INITIALIZE_FILTERS,
data: {
@@ -23,6 +31,7 @@ const initializeFilters = ({
assignmentGradeMax,
courseGradeMin,
courseGradeMax,
includeCourseRoleMembers: Boolean(includeCourseRoleMembers),
},
});
@@ -50,7 +59,21 @@ const updateCourseGradeFilter = (courseGradeMin, courseGradeMax, courseId) => ({
},
});
const updateIncludeCourseRoleMembersFilter = (includeCourseRoleMembers) => ({
type: UPDATE_INCLUDE_COURSE_ROLE_MEMBERS,
data: {
includeCourseRoleMembers,
},
});
const updateIncludeCourseRoleMembers = includeCourseRoleMembers => (dispatch, getState) => {
dispatch(updateIncludeCourseRoleMembersFilter(includeCourseRoleMembers));
const state = getState();
const { cohort, track, assignmentType } = getFilters(state);
dispatch(fetchGrades(state.grades.courseId, cohort, track, assignmentType));
};
export {
initializeFilters, resetFilters, updateAssignmentFilter,
updateAssignmentLimits, updateCourseGradeFilter,
updateAssignmentLimits, updateCourseGradeFilter, updateIncludeCourseRoleMembers,
};

View File

@@ -141,6 +141,7 @@ const fetchGrades = (
assignmentGradeMin: assignmentMin,
courseGradeMin,
courseGradeMax,
includeCourseRoleMembers,
} = getFilters(getState());
const { id: assignmentId } = assignment || {};
const assignmentGradeMax = formatMaxAssignmentGrade(assignmentMax, { assignmentId });
@@ -158,6 +159,7 @@ const fetchGrades = (
assignmentGradeMin,
courseGradeMin: courseGradeMinFormatted,
courseGradeMax: courseGradeMaxFormatted,
includeCourseRoleMembers,
},
)

View File

@@ -36,7 +36,7 @@ describe('actions', () => {
const expectedCohort = 1;
const expectedTrack = 'verified';
const expectedAssignmentType = 'Exam';
const fetchGradesURL = `${configuration.LMS_BASE_URL}/api/grades/v1/gradebook/${courseId}/?page_size=25&cohort_id=${expectedCohort}&enrollment_mode=${expectedTrack}`;
const fetchGradesURL = `${configuration.LMS_BASE_URL}/api/grades/v1/gradebook/${courseId}/?page_size=25&cohort_id=${expectedCohort}&enrollment_mode=${expectedTrack}&excluded_course_roles=all`;
const responseData = {
next: `${fetchGradesURL}&cursor=2344fda`,
previous: null,

View File

@@ -3,7 +3,8 @@ const RESET_FILTERS = 'RESET_FILTERS';
const UPDATE_ASSIGNMENT_FILTER = 'UPDATE_ASSIGNMENT_FILTER';
const UPDATE_ASSIGNMENT_LIMITS = 'UPDATE_ASSIGNMENT_LIMITS';
const UPDATE_COURSE_GRADE_LIMITS = 'UPDATE_COURSE_GRADE_LIMITS';
const UPDATE_INCLUDE_COURSE_ROLE_MEMBERS = 'UPDATE_INCLUDE_COURSE_ROLE_MEMBERS';
export {
INITIALIZE_FILTERS, RESET_FILTERS, UPDATE_ASSIGNMENT_FILTER,
UPDATE_ASSIGNMENT_LIMITS, UPDATE_COURSE_GRADE_LIMITS,
UPDATE_ASSIGNMENT_LIMITS, UPDATE_COURSE_GRADE_LIMITS, UPDATE_INCLUDE_COURSE_ROLE_MEMBERS,
};

View File

@@ -7,6 +7,7 @@ const initialFilters = {
assignmentGradeMax: '100',
courseGradeMin: '0',
courseGradeMax: '100',
includeCourseRoleMembers: false,
};
export default initialFilters;

View File

@@ -1,7 +1,12 @@
import { GOT_GRADES, FILTER_BY_ASSIGNMENT_TYPE } from '../constants/actionTypes/grades';
import {
INITIALIZE_FILTERS, UPDATE_ASSIGNMENT_FILTER, UPDATE_ASSIGNMENT_LIMITS, UPDATE_COURSE_GRADE_LIMITS, RESET_FILTERS,
INITIALIZE_FILTERS,
UPDATE_ASSIGNMENT_FILTER,
UPDATE_ASSIGNMENT_LIMITS,
UPDATE_COURSE_GRADE_LIMITS,
RESET_FILTERS,
UPDATE_INCLUDE_COURSE_ROLE_MEMBERS,
} from '../constants/actionTypes/filters';
import initialFilters from '../constants/filters';
@@ -70,6 +75,11 @@ const reducer = (state = initialState, action) => {
courseGradeMin: action.data.courseGradeMin,
courseGradeMax: action.data.courseGradeMax,
};
case UPDATE_INCLUDE_COURSE_ROLE_MEMBERS:
return {
...state,
includeCourseRoleMembers: action.data.includeCourseRoleMembers,
};
default:
return state;
}

View File

@@ -0,0 +1,12 @@
// Certain course runs may be expressly allowed to view the
// bulk management tools, bypassing the other checks.
// Note that this does not affect whether or not the backend
// LMS API will permit usage of the tool.
const hasSpecialBulkManagementAccess = courseId => {
const specialIdList = process.env.BULK_MANAGEMENT_SPECIAL_ACCESS_COURSE_IDS || '';
return specialIdList.split(',').includes(courseId);
};
export { hasSpecialBulkManagementAccess };
export default hasSpecialBulkManagementAccess;

View File

@@ -36,6 +36,9 @@ class LmsApiService {
if (options.courseGradeMax) {
queryParams.course_grade_max = options.courseGradeMax;
}
if (!options.includeCourseRoleMembers) {
queryParams.excluded_course_roles = ['all'];
}
const queryParamString = Object.keys(queryParams)
.map(attr => `${attr}=${encodeURIComponent(queryParams[attr])}`)
@@ -96,7 +99,7 @@ class LmsApiService {
static getGradeExportCsvUrl(courseId, options = {}) {
const queryParams = ['track', 'cohort', 'assignment', 'assignmentType', 'assignmentGradeMax',
'assignmentGradeMin', 'courseGradeMin', 'courseGradeMax']
'assignmentGradeMin', 'courseGradeMin', 'courseGradeMax', 'excludedCourseRoles']
.filter(opt => options[opt]
&& options[opt] !== 'All')
.map(opt => `${opt}=${encodeURIComponent(options[opt])}`)

View File

@@ -5,8 +5,12 @@ import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
import { Provider } from 'react-redux';
import SiteFooter, { messages as footerMessages } from '@edx/frontend-component-footer';
import { APP_READY, subscribe, initialize } from '@edx/frontend-platform';
import {
APP_READY,
getConfig,
initialize,
subscribe,
} from '@edx/frontend-platform';
import { IntlProvider } from 'react-intl';
import {
@@ -17,10 +21,11 @@ import {
} from '@fortawesome/free-brands-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import SiteFooter, { messages as footerMessages } from '@edx/frontend-component-footer';
import GradebookPage from './containers/GradebookPage';
import Header from './components/Header';
import store from './data/store';
import FooterLogo from '../assets/edx-footer.png';
import './App.scss';
const socialLinks = [
@@ -58,12 +63,12 @@ const App = () => (
<Header />
<main>
<Switch>
<Route exact path={process.env.PUBLIC_PATH.concat(':courseId')} component={GradebookPage} />
<Route exact path={getConfig().PUBLIC_PATH.concat(':courseId')} component={GradebookPage} />
</Switch>
</main>
<SiteFooter
siteName={process.env.SITE_NAME}
siteLogo={FooterLogo}
logo={process.env.LOGO_POWERED_BY_OPEN_EDX_URL_SVG}
marketingSiteBaseUrl={process.env.MARKETING_SITE_BASE_URL}
supportUrl={process.env.SUPPORT_URL}
contactUrl={process.env.CONTACT_URL}

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

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

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

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