Compare commits

..

324 Commits

Author SHA1 Message Date
Leangseu Kim
bd799902f7 chore: rebase with nutmeg master 2023-02-06 09:23:13 -05:00
Ihor Romaniuk
75eeb45c8b fix: fix loss of field focus after each character for search field (#262)
* fix: fix loss of field focus after each character for search field

* fix: update package-lock file
2022-12-05 11:56:38 -05:00
Ihor Romaniuk
2536b93cbb fix: add an asterisk to the 'Email' name of column (#272)
* fix: add an asterisk to the 'Email' name of column

* fix: update package log
2022-11-23 10:48:30 -05:00
Usama Sadiq
97c58157f8 Merge pull request #238 from openedx/jenkins/transifex-client-migration-f07a96c
fix: transifex migration to new client
2022-04-06 12:42:11 +05:00
UsamaSadiq
ce093efba4 build: update transifex pull translations command 2022-04-05 15:26:42 +05:00
Jawayria
799ef5b8a1 Merge pull request #224 from openedx/jawayria/node-16
feat: added support for node 16
2022-03-31 12:13:11 +05:00
Jawayria
f956351cf7 feat: add support for node 16 2022-03-31 12:01:12 +05:00
edX requirements bot
7772e21c6a fix: transifex migration to new client 2022-03-17 08:47:39 -04:00
Sarina Canelake
f07a96ce58 Merge DEPR automation workflow
Add DEPR workflow automation
2022-02-24 15:21:36 -05:00
Sarina Canelake
f64bc8d4a6 build: add DEPR workflow automation 2022-02-23 14:36:18 -05:00
Michael Vlasov
134dabb710 fix: gradebook table rtl tooltip position (#225)
* fix: gradebook table rtl tooltip position

Co-authored-by: vlasovmichael <mykhailo.vlasov@raccoongang.com>
2022-02-10 10:36:46 -05:00
Matt Hughes
65c25f00b6 Merge pull request #227 from openedx/matthugs/rename-github-group-to-match-org-structure
chore: rename github group to match org. structure
2022-02-09 10:10:49 -05:00
Matt Hughes
31748e246e chore: rename github group to match org. structure 2022-02-09 10:06:03 -05:00
Michael Vlasov
650be29ef9 feat: gradebook main container classname (#226)
* feat: gradebook main container classname

* feat: gradebook main container classname / snapshot tests

Co-authored-by: vlasovmichael <mykhailo.vlasov@raccoongang.com>
2022-02-07 15:22:26 -05:00
Jhony Avella
b713ab5748 feat: enabling header override (#220)
feat: new versions of package.json and lock

fix: tests after adding header component

Fixing tests

change package files

Signed-off-by: Jhony Avella <jhony.avella@edunext.co>
2021-12-21 10:40:22 -05:00
Jhony Avella
5fe80b4a52 fix: build the course id properly when the MFE is deployed in a subdirectory (#213) 2021-12-21 10:38:58 -05:00
Carlos Muniz
9e04813d06 Merge pull request #219 from edx/i18n-string-definitions
docs: Describe messages descriptions better
2021-12-03 09:35:26 -05:00
Carlos Muniz
a0e1a60d23 docs: Describe messages descriptions better
These messages have been updated to be more descriptive.
The message descriptions need to be more descriptive in order to
be easily translated (i18n) into other languages.
2021-12-03 09:23:17 -05:00
Sarina Canelake
68c7944dd5 Merge pull request #217 from regisb/regisb/transifex
feat: add compatibility with transifex
2021-11-29 12:37:26 -05:00
Régis Behmo
f4f6e5551f feat: add compatibility with transifex
Now that the frontend-app-gradebook resource was added to Transifex, we can
start pulling strings fromt there. For now, the project contains very few
translated string.

Strings were pulled by running: make pull_translations

This is for https://github.com/openedx/build-test-release-wg/issues/107
2021-11-22 12:12:29 +01:00
Usama Sadiq
ee99bfdaa4 Merge pull request #212 from edx/usamasadiq/replace-travis-with-github-ci
BOM-2928: Replace Travis with GitHub CI
2021-11-16 16:56:36 +05:00
Usama Sadiq
318ce349fc build: Replace Travis with GitHub CI 2021-10-25 17:12:32 +05:00
Ned Batchelder
8f1c89a025 build: use the organization commitlint check 2021-10-07 13:55:57 -04:00
Ben Warzeski
a1de3a8612 feat: Bulk Management reorganization, phase 1 (#206)
* move GradesTab and BulkHistoryTab to views and control from container through redux

* add data logic for view control and import success toast

* add NetworkButton component for download/upload buttons

* remove download button from Bulk History view and update heading and help text

* add View control button to GradebookHeader if bulk management available

* remove FilterMenuToggle from SearchControls

* update BulkManagementControls to now include upload/download grades buttons

* add Import Success toast

* rename UserLabel to FilteredUsersLabel for clarity

* add InterventionsReport component

* update GradesView top-level component

* messageing update (separate messages into per-component files)

* style updates

* update test plan

* clean up css and add docstrings

* typo fix

* fix typo in bulk management view header
2021-08-26 10:37:35 -04:00
Nathan Sprenkle
4e26247ac3 docs: remove outdated screenshots (#211) 2021-08-19 13:49:29 -04:00
Ben Warzeski
f21e6da598 download bulk management history if showBulkManagement (#210)
* download bulk management history if showBulkManagement

* separate fetchBulkUpgrade call from fetchTracks

* 1.4.47
2021-08-09 10:49:04 -04:00
Nathan Sprenkle
650e9321b1 Simplify Bulk Management Config (#200)
* refactor: simplify bulk management enabling

Formerly, a course had to have bulk management enabled and have a master's
track. This painted us into a corner where we had to create a
workaround for enabling bulk management on non-master's track courses.
Instead, this relies only on an enabled flag from edx-platform (based on
a course waffle flag) which simplifies the enabling code here.

* feat: remove unneeded bulk management allow-list
2021-08-04 11:04:55 -04:00
Ben Warzeski
e8a8cca483 fix: add excludedCourseRoles to bulk-grades fetch (#209) 2021-08-04 09:46:51 -04:00
Ben Warzeski
f5d2a34660 Fix cohort url (#208)
* fix cohorts fetch url

* v1.4.45
2021-08-03 15:37:12 -04:00
Ben Warzeski
97d3a29a7f fix: add track back into lms api service calls. (#207)
* fix: add track back into lms api service calls.

* fix tests

* v1.4.44
2021-07-30 11:59:01 -04:00
Leangseu Kim
5b8f67e8d2 fix: update csv import error messages
update csv error according discussed

Ticket[au-66]

version bump
2021-07-26 12:59:42 -04:00
Ben Warzeski
c6e33307ba refactor: add transifex support to user-facing messages (#203)
* clean up and test segment integration

* add transifex config

* move user-facing messages into messages files and translate in usage

* lint cleanup

* fix introduced typos

* remove dead code

* remove should-be-ignored temp translation files

* make HistoryHeader use node-type to support translations

* fix apostrophe

* fix snapshot

* v1.4.42
2021-07-22 10:45:18 -04:00
Ben Warzeski
a4df8f7238 refactor: update test coverage (#202)
* clean up and test segment integration

* add tests for action utils

* add tests for store aggregator and utils

* clean up un-used paths in thunkAction testUtils

* clean up filter reducer coverage

* add filter reducer tests for filterMenu actions

* clean up grades selectors coverage

* separate App and add unit tests

* ignore external files in coverage analysis

* remove old/unused code from StrictDict and clean up tests

* clean up FileUploadForm coverage

* more cleanup for StrictDict tests

* clean up GradesTab test coverage

* clean up GradesTab coverage

* ignore reducer-mapping for unit-test coverage

* clean up AssignmentFilter test coverage

* add index-level test coverage

* temp remove snapshots

* re-add App snapshot

* v1.4.41
2021-07-02 12:32:18 -04:00
Ben Warzeski
15b76edb5d refactor: lms service testing (#199)
* v1.4.40

* ignore accepted import eslint errors

* clean up LmsApiService into smaller, tested modules in lms service

* set default format before initial fetches

* fix bulk grades export and grade filtering

* fix clearing assignment grade filter badge

* re-connect grade format control
2021-06-30 11:50:07 -04:00
Ben Warzeski
1ab2fee004 add reasonable width to grade format button (#198) 2021-06-24 10:59:11 -04:00
Ben Warzeski
9c9ba45fec Fix grade format button (#197)
* re-organize components based on function

* clean up GradebookFilters test

* fix grade format button

* v1.4.39

* uuuugh, actually fix button
2021-06-24 08:59:42 -04:00
Ben Warzeski
85d566d257 re-organize components based on function (#196)
* re-organize components based on function

* clean up GradebookFilters test

* fix grade format button

* v1.4.39
2021-06-23 13:56:10 -04:00
Ben Warzeski
5688adcd57 Refactor: add unit tests and docstrings to remaining components (#195)
* add unit tests and docstrings

* v1.4.38
2021-06-22 15:25:57 -04:00
Ben Warzeski
b4f4a27f73 refactor - Bulk management tab unit tests (#194)
* simplify histry row entries

* clean up BulkManagement tab component and add docstrings/unit tests

* slightly more breakup and docstrings

* fix: make updateQueryParams push to history again

* update paragon to get latest Hyperlink version

* fix display issues from PR

* make redux exposure only available in development

* fix message

* add oxford comma

* update snapshots

* v1.4.37
2021-06-21 16:01:41 -04:00
Ben Warzeski
868048381b refactor: unit test and docstring FilterBadges (#193)
* unit test and docstring FilterBadges

* v1.4.38
2021-06-16 15:34:50 -04:00
Chris Chávez
02c154ef50 fix: Hard-coded link in Header component changed by LMS_BASE_URL env var (#186)
* feat: LOGO_DESTINATION env var added for use it on Header component

On index.js file of the Header component, currenly there is a hard-coded link of "https://www.edx.org". I added a new enviroment variable LOGO_DESTINATION to use it in this Header.
I have also added a mechanism to use the LMS_BASE_URL environment variable if the LOGO_DESTINATION environment variable is not found or is null.

* fix: Hard-coded link in Header component changed by a env var

`index.jsx` file of Header component updated with change the hard-coded link of https://www.edx.org with a component property.
`index.jsx` file of root project updated. Added the environment variable LMS BASE_URL to the parameters of the Header Component

* fix: lint errors in previous commits

* style: change structure of the solution
2021-06-14 13:51:20 -04:00
Ben Warzeski
7acefe0468 refactor: gradebook table tests (#192)
* clean up, test, and docstring Gradebook table

* v1.4.36
2021-06-09 14:34:15 -04:00
Ben Warzeski
a836cc1b5b refactor: add edit modal tests (#191)
* update drawer layout and container logic

* add tests and docstrings for EditModal components

* typo fix and merge conflict

* v1.4.35
2021-06-09 10:48:23 -04:00
David Joy
6a3db4a11b Conditionally enable segment and clean up environment variables (#181)
* fix(deps): bumping frontend-platform to latest

This picks up a change in which a blank SEGMENT_KEY no longer causes the MFE to make a request to segment which results in a 404.

This was a requested fix for lilac.

* fix: cleanup environment variables

- Removes stray commas from the end of each environment variable in .env
- Removes CSRF_COOKIE_NAME completely - it is not used.
- Replaces default values with empty strings ‘’ - this will make the defaults falsy, rather than have them get converted to a string “null” value, which is very misleading.  It also enables the SEGMENT_KEY fix mentioned in the prior commit to work.

* fix: conditionally enable the segment middleware

Only add it if SEGMENT_KEY is truthy.

* build: bumping version number
2021-06-08 11:14:37 -04:00
Ben Warzeski
d727420c37 refactor: update drawer logic and container layout (#190)
* update drawer layout and container logic

* stop renaming the FilterBadges component

* docstring for WithSidebar

* add unit tests

* v1.4.33
2021-06-07 16:24:37 -04:00
Ben Warzeski
2029a7cef3 State to redux (#187)
* add app redux module

* simplify fetchGrades logic now that almost everything it needs is in redux

* simplified thunkAction logic with new app store

* component redux helper cleanup

* simplify PageButtons

* simplify FilterBadges props

* clean up BulkManagement

* re-org and simplify data logic for EditModal

* re-org and simplify data logic for Gradebook Table

* clean up StatusAlerts

* clean up SearchControls

* clean up GradebookHeader

* clean up AssignmentFilter

* clean up AssignmentGradeFilter

* simplify CourseGradeFilter

* simplify StudentGroupsFilter

* simplify GradebookFilters.index

* simplify Gradebook component :-)

* linting

* fix on-clear search behavior

* remove un-needed GradeButton variants

* add FilterBadge docstrings

* v1.4.32
2021-06-07 10:09:07 -04:00
Nathan Sprenkle
8462249d55 Trim unused footer props/vars (#189) 2021-06-04 13:46:07 -04:00
Ben Warzeski
40059ec41e Fix page buttons (#188)
* add prev/next grades selectors

* fix PageButtons props
2021-06-01 12:23:33 -04:00
Ben Warzeski
2ee522352e pull GradebookHeader out of monolith (#185)
* pull GradebookHeader out of monolith

* v1.4.30
2021-05-26 15:05:43 -04:00
Ben Warzeski
189152f51b refactor: redux refactor to use redux toolkit (#182)
* add redux-toolkit

* update actions to use redux toolkit action creators

* add StrictDict

* ready for testing

* update testing for reducers

* update unit testing for reducers

* export reducers from initial state

* update unit test

* reorder the test reducer's handler

* remove unnecessary testing data

update

* update actions to use redux toolkit action creators

* testing

* thunkActions tests

* component thunkAction reference updates

* linting

* a little bit of doc and syntax cleanup

* fix tests

* assignment type actions tests

* actions testing and cleanup

* selectors cleanup

* fix store action reference

* update package-lock.json

* add a bit of test coverage

* strict selector export

* docstrings for test utils

* docstrings

* fix assignment filtering

* some cleanup

* update version to 1.4.29

Co-authored-by: Leangseu Kim <lkim@edx.org>
2021-05-25 15:08:36 -04:00
Nathan Sprenkle
3bc2511cc1 fix: bad prop causing bulk mgmt errors to not show (#184) 2021-05-18 11:04:57 -04:00
leangseu-edx
f60e3c1188 fix:csv override previous fail state on success (#183)
On success and set uploadSuccess to true every time. This is important for unit testing.
[EDUCATOR-5796]
2021-05-17 14:29:40 -04:00
Nathan Sprenkle
807a57d947 Add tests for Redux selectors (#180)
* test: add selector tests

* refactor: remove unused typeOfSelectedAssignment

* chore: bump version to 1.4.26

Co-authored-by: Ben Warzeski <bwarzeski@edx.org>
2021-05-11 14:04:05 -04:00
Sarina Canelake
0c242ab6f0 Merge pull request #177 from pdpinch/patch-1
docs: correct link to bulk grade management
2021-05-07 16:01:08 -04:00
Nathan Sprenkle
ee2c573017 Clean up MFE/Redux usage (#179)
* refactor: clean up/standardize selector usage

* fix: fix eslint errors

* chore: bump version to 1.4.25

Co-authored-by: Ben Warzeski <bwarzeski@edx.org>
2021-05-07 12:44:00 -04:00
David Joy
4fdc541992 fix: use SITE_NAME env var for index.html title (#178)
We currently have a hard-coded “edX” string in Gradebook’s index.html file.  This replaces that string with the value of the SITE_NAME environment variable, which is set at build time from .env.development for dev, and from the build process at production build-time.
2021-05-04 13:40:08 -04:00
Peter Pinch
658b45136e docs: correct link to bulk grade management
a.k.a. Override Learner Subsection Scores in Bulk
2021-05-01 11:32:07 -04:00
Jansen Kantor
61fdb31316 feat: always show total as a pct, add tooltip (#170) 2021-04-29 09:54:00 -04:00
Jansen Kantor
93f45d0784 break out status alerts into their own component (#175)
* refactor: break out status alerts into their own component
2021-04-28 18:07:00 -04:00
Ben Warzeski
6c88291626 Merge pull request #176 from muselesscreator/assignment_tests
reafactor: separate and test GradebookFilters from Gradebook component
2021-04-28 17:50:24 -04:00
Ben Warzeski
621c297f1a version 1.4.22 2021-04-28 17:48:31 -04:00
Ben Warzeski
76b349e377 clean up snapshots and tests 2021-04-28 15:48:59 -04:00
Ben Warzeski
d88475aab5 fix: revert changes for eslint editor compatibility 2021-04-28 15:37:31 -04:00
Ben Warzeski
ddad9d9513 merge cleanup 2021-04-28 14:03:00 -04:00
Ben Warzeski
9f7e29ed76 remove redirected props 2021-04-28 13:50:13 -04:00
Ben Warzeski
539202f511 fix tracks select option 2021-04-28 13:50:13 -04:00
Ben Warzeski
c42a995b11 clean up tests and propTypes 2021-04-28 13:50:13 -04:00
Ben Warzeski
78644daf26 fix filters for includeCourseRoleMembers and cohorts 2021-04-28 13:50:12 -04:00
Ben Warzeski
7fd38dbcf1 remove moved methods 2021-04-28 13:50:12 -04:00
Ben Warzeski
62aad2aa2f add default value for courseRoleMembers flag 2021-04-28 13:50:12 -04:00
Ben Warzeski
12d32efe08 group filters under GradebookFilters sub-view 2021-04-28 13:50:10 -04:00
Ben Warzeski
c60358941e add tests for StudentGroupsFilters and CourseGradeFilters 2021-04-28 13:49:39 -04:00
Ben Warzeski
1345666e53 assignment filter tests 2021-04-28 13:49:14 -04:00
Ben Warzeski
c4bd8dc416 tell lint how to read module paths 2021-04-28 13:49:14 -04:00
Ben Warzeski
83986ea994 Assignment testing breakout pt1 2021-04-28 13:49:13 -04:00
Ben Warzeski
f891f90f77 update testing env 2021-04-28 13:49:11 -04:00
Ben Warzeski
313840fa10 fix: make gradebook filters update URL 2021-04-28 13:47:15 -04:00
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
morenol
677521808b 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
2020-11-10 16:45:21 -05:00
Ben Warzeski
139a0de6a6 Merge pull request #144 from muselesscreator/locked_header_and_components
Locked header and component split-out
2020-10-22 15:12:10 -04:00
Ben Warzeski
42b4a5b3dd fix event handlers 2020-10-22 10:57:26 -04:00
Ben Warzeski
9b9c703214 remove debug testing code 2020-10-22 10:06:16 -04:00
Ben Warzeski
d46f4da9d5 table update 2020-10-22 10:05:43 -04:00
Ben Warzeski
fa3826b452 merge fixes 2020-10-22 10:02:56 -04:00
Ben Warzeski
fd3eb71820 linting 2020-10-22 09:55:28 -04:00
Ben Warzeski
3dff787b37 rebase to master 2020-10-22 09:55:17 -04:00
Ben Warzeski
ac56ab766b component work 2020-10-22 09:51:04 -04:00
Ben Warzeski
dbe3dfa323 Merge pull request #146 from muselesscreator/locked_headers_alone
lock header row and column
2020-10-22 09:29:48 -04:00
Ben Warzeski
9e0c326dfc lock header row and column 2020-10-21 17:19:26 -04: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
Jansen Kantor
9bc86fc4f6 display grade override history error from lms (#142)
* display grade override history error from lms

* add error constants file

* quality

* code review

* fix stuff

* npm i -S regenerator-runtime
2020-09-08 17:14:21 -04:00
Ben Warzeski
aa39fcc7e0 Merge pull request #141 from muselesscreator/req_update_v2
Requirements Update
2020-09-02 09:57:26 -04:00
Ben Warzeski
f7fcaef03a remove old webpack config 2020-08-28 10:57:32 -04:00
Ben Warzeski
6f9c051ded bump version in package.json 2020-08-26 14:11:37 -04:00
Ben Warzeski
dabd923b10 linting 2020-08-26 14:11:02 -04:00
Ben Warzeski
da60ff9f1d p1 2020-08-26 14:11:02 -04:00
Ben Warzeski
f9d5987488 Merge pull request #140 from muselesscreator/table_cell_spacing
fix gradebook table cell spacing
2020-08-12 10:17:13 -04:00
Ben Warzeski
493d5df8fa fix gradebook table cell spacing 2020-08-11 15:07:58 -04:00
Kyle McCormick
cf4f806d76 Update openedx.yaml (#137)
* Reflects new sub-team-based ownership model.
* Puts `owner` value in line with updated OEP-2.
* Also updates values of `tags` and `oeps`.
2020-04-08 18:33:05 -04:00
Simon Chen
ee274a2fb0 Merge pull request #136 from edx/schen/gradebook_node12
Upgrade node build version from 8 to 12
2019-12-06 11:39:06 -05:00
Alex Dusenbery
baa26f3b20 Update travis email recipients. 2019-12-06 11:03:01 -05:00
Simon Chen
0f7ffcbec1 Upgrade node build version from 8 to 12 2019-12-06 10:55:01 -05:00
Thomas Tracy
f5616f77fd Remove commented scss imports 2019-10-21 14:55:57 -04:00
Thomas Tracy
ecf5ef4664 Upgrade gradebook from paragon 4 to 7 2019-10-21 14:55:57 -04:00
Adam Butterworth
480ea1915c fix: remove youtube logo from footer
Related ticket: PROD-816
2019-10-16 14:44:27 -04:00
Jansen Kantor
a646b7b77c add FilterBadges (#133)
* added FilterBadges
2019-10-16 11:47:04 -04:00
Andytr1
a2503d7067 Merge pull request #132 from edx/andytr1/use-label-for-events
Update event properties to use label for google analytics
2019-10-08 10:21:51 -04:00
atesker
7413c2100f use label 2019-10-08 08:56:15 -04:00
Andytr1
8733ab5e1f Merge pull request #131 from edx/andytr1/add-constants-for-analytics
added action constants and comments
2019-10-07 12:54:55 -04:00
atesker
8dc5836daa added event constants 2019-10-07 12:33:12 -04:00
Andytr1
993809b5b0 Merge pull request #129 from edx/andytr1/analytics-move-events-from-frontend-and-add-courseid
EDUCATOR-4715 working on moving analytics events to redux
2019-10-07 10:34:49 -04:00
atesker
9665859ea2 working on moving analytics events to redux
EDUCATOR-4715 - make analytics use redux hooks
2019-10-04 15:24:36 -04:00
Andytr1
386bbea84b Merge pull request #128 from edx/andytr1/analytics-course-id-final-fix
EDUCATOR-4685 final fix for using label with course id
2019-10-04 11:30:41 -04:00
atesker
ee8e56a792 EDUCATOR-4685 final fix for using label with course id 2019-10-04 10:00:24 -04:00
Matt Hughes
80aa4f10a0 Merge pull request #127 from edx/matthugs/option-c-EDUCATOR-4686-trivial-copy-change
Set better expectations around file upload processing time
2019-10-03 11:50:15 -04:00
Alex Dusenbery
2d0bf8c322 upgrade nose-sass (amongst others) 2019-10-03 11:03:59 -04:00
Matt Hughes
eaa2db5019 Set better expectations around file upload processing time
JIRA:EDUCATOR-4686
2019-10-03 10:16:00 -04:00
Andytr1
de6b2f1219 Merge pull request #125 from edx/andytr1/analytics-add-course-id
EDUCATOR-4685 add course id to label
2019-10-03 09:47:38 -04:00
Matt Hughes
12db9448d8 Merge pull request #124 from edx/andytr1/display-max-possible-score-for-not-started-work
EDUCATOR-4365 - display max grade for not started assignments
2019-10-02 15:30:00 -04:00
atesker
52da367d18 EDUCATOR-4685 add course id to label
different location of label
2019-10-02 14:46:23 -04:00
Matt Hughes
e8cbc66e6f clear out redux state related to the modal consistently
... as we already do with component state for these sorts of fields
2019-10-02 13:51:43 -04:00
Matt Hughes
bc39443d94 improving state management of these modal fields 2019-10-02 13:17:23 -04:00
Matt Hughes
15cdd7a256 handle the two distinct data sources for this information properly
JIRA:EDUCATOR-4711
2019-10-02 12:19:12 -04:00
atesker
81601ca6ea EDUCATOR-4365 - display max grade for not started assignments 2019-10-01 12:07:26 -04:00
Andytr1
c773c35bbc Merge pull request #123 from edx/andytr1/intervention-fix-cohort-filter-4627
EDUCATOR-4627
2019-09-12 11:39:36 -04:00
atesker
b4e77b2b3f EDUCATOR-4627 2019-09-12 11:13:04 -04:00
Matt Hughes
56b90cd223 Merge pull request #122 from edx/matthugs/some-more-ui-updates
some more ui updates
2019-09-11 10:02:38 -04:00
Matt Hughes
7fa24969ac Limit the amount of space the uname & email columns can take up
This is meant to alleviate concerns about the table not showing grade information
while the drawer is open and few assignment columns are
visible (e.g. when the "specific assignment" filter is set).
2019-09-10 18:08:21 -04:00
Matt Hughes
924dcda088 Add disclaimer about masters-specific features
JIRA:EDUCATOR-4637
2019-09-10 17:01:02 -04:00
Andytr1
18f9ffcfde Merge pull request #120 from edx/andytr1/grade-events-updated
updated event names
2019-09-10 16:42:19 -04:00
atesker
1e7871795c updated event names
added comments

added more detailed comments

added more detailed comments
2019-09-10 16:37:36 -04:00
Matt Hughes
cd309c47e1 Merge pull request #121 from edx/matthugs/gradebook-styling-issues
Gradebook styling issues
2019-09-10 14:59:49 -04:00
Matt Hughes
5d9d2af578 Break table text line for long usernames as well
... as for file names
2019-09-10 14:15:16 -04:00
Matt Hughes
c5306296c9 Fix IE & Firefox styling issue
negative margins don't play consistently with flexbox cross-browser

JIRA:EDUCATOR-4622
2019-09-10 14:15:16 -04:00
Jansen Kantor
49e3fd23d7 Merge pull request #119 from edx/jkantor/copypasta
remove incorrectly copied block
2019-09-09 12:05:10 -04:00
Matt Hughes
7bdd1533c3 Merge pull request #117 from edx/matthugs/push-aside-drawer-for-filters
Push-aside drawer for filters
2019-09-09 10:57:31 -04:00
jansenk
22cf805960 remove incorrectly copied block 2019-09-09 10:22:05 -04:00
Matt Hughes
be84c91981 Move filters to push-aside drawer
- makes gradebook full-width (to accommodate push-aside)
- while the drawer is open the rest of the page is left-right
  scrollable

JIRA:EDUCATOR-4579
2019-09-09 09:43:59 -04:00
Nimisha Asthagiri
4434857ada Merge pull request #116 from edx/arch/rename-repo
Rename gradebook to frontend-app-gradebook
2019-09-06 14:23:51 -04:00
Andytr1
7764f78386 Merge pull request #118 from edx/andytr1/new-events-in-gradebook
EDUCATOR-4524 - new events
2019-09-06 12:45:41 -04:00
atesker
b4a5288b4b EDUCATOR-4524 - new events 2019-09-06 12:05:54 -04:00
Nimisha Asthagiri
caea72b92e Rename gradebook to frontend-app-gradebook 2019-09-06 07:19:06 -04:00
Kyle McCormick
93128ac728 Update openedx.yaml for Masters squad reorg (#115) 2019-09-05 09:01:15 -04:00
Andytr1
05bd0d740c Merge pull request #114 from edx/andytr1/change-message-after-grade-edit
EDUCATOR-4591 - update save message
2019-09-03 11:21:09 -04:00
atesker
f041d6b16d EDUCATOR-4591 - update save message 2019-09-03 10:23:25 -04:00
Matt Hughes
b90df949b9 Merge pull request #113 from edx/matthugs/add-configurability-of-bulk-management-features
Add support for waffling off bulk management
2019-08-20 16:32:46 -04:00
Matt Hughes
9c1f5ed239 Add support for waffling off bulk management
even for masters courses
2019-08-20 15:20:27 -04:00
Matt Hughes
f38cd91fb1 Merge pull request #112 from edx/matthugs/add-intervention-filters
add intervention filters
2019-08-19 10:18:49 -04:00
Matt Hughes
588528abf6 Moved interventions download button to main view 2019-08-16 15:20:54 -04:00
Matt Hughes
d62b9ca520 Move the score view input closer to the portion of the UI it affects 2019-08-16 14:05:54 -04:00
Matt Hughes
ec08ef4bcb Apply filters to interventions report
JIRA:EDUCATOR-4537
2019-08-16 14:02:06 -04:00
Michael Roytman
d5b3edc850 Merge pull request #111 from edx/mroytman/EDUCATOR-4432-course-level-grade-filter
mroytman/educator 4432 course level grade filter
2019-08-15 06:54:44 -04:00
Michael Roytman
4f7d764d20 add course level grade filters to the gradebook 2019-08-15 06:27:09 -04:00
Matt Hughes
2dacc2c037 Merge pull request #110 from edx/matthugs/add-assig-grade-filter
Add assignment grade filter
2019-08-13 15:05:54 -04:00
Matt Hughes
88fa2e60f4 bug fix with a corner case I'd missed
switching back to "all" for the assignment with assignment grade
filters set caused the lms api to be misused
2019-08-13 13:45:18 -04:00
Matt Hughes
2822e36b7b Michael's code review comments 2019-08-13 11:28:22 -04:00
Matt Hughes
f46fe3f8ee correct poor choice of abbreviation 2019-08-13 10:52:02 -04:00
Matt Hughes
e787d3a697 Add filters for assignment grades
When assignment grade filters are set, changing which assignment is
chosen will also cause the grades to be refreshed from the
server (since the subset of learners being viewed may be different).

JIRA:EDUCATOR-4541
2019-08-12 15:01:54 -04:00
Matt Hughes
d77737f132 Merge pull request #108 from edx/matthugs/add-filter-for-specific-assignment
add filter for specific assignment
2019-08-02 10:35:17 -04:00
Matt Hughes
9ed5c6cb34 Add new filter for assignments
JIRA:EDUCATOR-4514

- Filter applies both to gradebook view and to CSV export used for bulk
  management.
- Fixes a bug with the cohort filter being applied to bulk management
  CSVs
- Sets us up to add new filters more easily
- New filter interoperates with existing assignment type filter to
  limit options
2019-08-02 10:29:38 -04:00
Michael Roytman
5b16a5dbb2 Merge pull request #109 from edx/mroytman/EDUCATOR-4509-pull-out-bulk-management
pull out bulk management tools to main tab and remove redundant funct…
2019-08-01 14:21:19 -04:00
Michael Roytman
2c9c505b32 pull out bulk management tools to main tab and remove redundant functionality across tabs 2019-08-01 14:13:42 -04:00
Andytr1
9425e06f8f Merge pull request #107 from edx/andytr1/interventions-ui-actual
EDUCATOR-4532 add button to download interventions
2019-07-26 14:47:14 -04:00
atesker
e7134d8f68 EDUCATOR-4532 add button to download interventions 2019-07-26 08:35:24 -04:00
Dave St.Germain
e547d67f19 Merge pull request #106 from edx/mroytman/EDUCATOR-4434-gradebook-user-counts
add selection counts to gradebook
2019-07-25 09:54:28 -04:00
Michael Roytman
8b1d5aacf2 add selection counts to gradebook 2019-07-24 16:31:47 -04:00
Matt Hughes
5a243e1b5f Merge pull request #105 from edx/matthugs/EDUCATOR-4435
Add download of row-by-row status CSV echo for bulk operation history table
2019-07-24 14:33:37 -04:00
Matt Hughes
a957e16424 Adding tabular history of grade bulk management CSV uploads
JIRA:EDUCATOR-4435
2019-07-24 14:16:10 -04:00
Andytr1
0d12eaf979 Merge pull request #104 from edx/andytr1/fix-studentkey-display
fix studen key label
2019-07-17 13:25:00 -04:00
atesker
3e2787b0e0 fix studen key label
put height back in

fix trailing space
2019-07-17 13:19:37 -04:00
Andytr1
35c632b716 Merge pull request #103 from edx/andytr1/gradebook-remove-edit-by-name
removed username and fake max score
2019-07-16 14:29:42 -04:00
atesker
801e2d6d59 removed username and fake max score
display correct overrides

code review cleanup2
2019-07-16 14:23:50 -04:00
Andytr1
7bd81d7a2b Merge pull request #102 from edx/andytr1/gradebook-remove-edit-by-name
removed username and fake max score
2019-07-15 14:48:36 -04:00
atesker
2b8ecf2c78 removed username and fake max score 2019-07-15 14:22:24 -04:00
Andytr1
de13ac5093 Merge pull request #101 from edx/andytr1/gradebook-fix-override-display
show correct override
2019-07-12 16:36:01 -04:00
atesker
ade906896c show correct override 2019-07-12 16:01:53 -04:00
Andytr1
43181e39cc Merge pull request #100 from edx/andytr1/gradebook-fix-error-with-empty-override
bug fix on empty override
2019-07-12 10:26:26 -04:00
atesker
f60a9647b4 bug fix on empty override 2019-07-11 17:50:33 -04:00
Andytr1
7dc1ffdd42 Merge pull request #99 from edx/andytr1/gradebook-show-override-history
EDUCATOR-4353 - ui - show override history
2019-07-10 15:29:16 -04:00
atesker
2913f1d965 EDUCATOR-4353 - ui - show override history
updated unit tests

EDUCATOR-4353 - major improvements to service etc

remove unused

unit tests fix

code review continued

update unit test after merge

code review - showing error dialog
2019-07-10 14:07:58 -04:00
Matt Hughes
7f85fb12e3 Merge pull request #98 from edx/matthugs/add-table-for-bulk-management-operation-history
Add table corresponding to what new history endpoint supports
2019-07-10 10:08:54 -04:00
Matt Hughes
90f66d3759 added an action for errors (currently unhandled) 2019-07-02 12:19:48 -04:00
Matt Hughes
fb09bee8d9 addressed another todo starting a selector module for grades 2019-07-02 11:37:54 -04:00
Matt Hughes
e31495b00b address some TODOs & fix a unit test's expectation 2019-07-02 11:34:26 -04:00
Matt Hughes
0557f29e0b Add table corresponding to what new history endpoint supports 2019-07-01 17:28:28 -04:00
Matt Hughes
e08b13f218 Merge pull request #95 from edx/matthugs/grade-management-shell-modal
Add grade management tab to Gradebook for courses containing a Masters track
2019-06-24 15:17:08 -04:00
Matt Hughes
dee42eee7e Added bulk management tab for CSV export/import
The bulk management tab will only be shown for masters courses,
i.e. those containing a masters track

JIRA:EDUCATOR-4343
JIRA:EDUCATOR-4431
2019-06-24 13:51:11 -04:00
Andytr1
1a0fe945a5 Merge pull request #97 from edx/mroytman/add-email-student-key
mroytman/add email student key
2019-06-24 13:26:11 -04:00
Michael Roytman
704144f9d1 first attempt at including email and student key 2019-06-13 08:27:23 -04:00
Kyle McCormick
a1ab29702f Switch to AGPL 2019-05-20 16:32:37 -04:00
Adam Stankiewicz
f619308ce5 Merge pull request #92 from edx/astankiewicz/fix-forward-intlprovider
fix forward for including intlprovider
2019-05-16 15:59:22 -04:00
Adam Stankiewicz
c235da8cc0 fix forward for including intlprovider 2019-05-16 12:52:51 -07:00
Adam Stankiewicz
777e0696ee Merge pull request #91 from edx/astankiewicz/footer-update
Upgrade @edx/frontend-component-footer, update 'edX for Business' & social links
2019-05-16 14:45:39 -04:00
Adam Stankiewicz
95b8ba146e version bump footer component 2019-05-16 11:03:11 -07:00
Adam Stankiewicz
8463a28c7d Change to example.com 2019-05-16 08:11:26 -07:00
Adam Stankiewicz
bdee388dbd Adding local env config variables 2019-05-16 07:55:33 -07:00
Adam Stankiewicz
e0633dc816 Upgrade @edx/frontend-component-footer and update 'edX for Business' and social links 2019-05-15 12:46:09 -04:00
Simon Chen
668620a5f0 Merge pull request #90 from edx/kdmccormick/supporting-team
Add masters-neem as a supporting team
2019-05-13 11:23:47 -04:00
Kyle McCormick
cfbb02a54c Add masters-neem as a supporting team 2019-05-13 10:59:27 -04:00
Kyle McCormick
b22b44bbf5 Change repo owner from edx/educator-neem to schenedx
Per recent change to OEP-2 https://github.com/edx/open-edx-proposals/pull/112/files
2019-05-13 10:22:44 -04:00
Douglas Hall
80d9e32fba Merge pull request #88 from edx/douglashall/upgrade_auth
Upgrade frontend-auth to 4.0.0
2019-04-11 13:48:24 -04:00
Douglas Hall
ef8975df86 Upgrade frontend-auth to 4.0.0 2019-04-11 13:37:23 -04:00
Richard I Reilly
09e482e893 Merge pull request #87 from edx/rir/remove-sorting
Remove remove all unneeded sorting
2019-02-04 10:45:29 -05:00
Rick Reilly
d4421d47fc Remove remove all unneeded sorting 2019-02-04 10:39:49 -05:00
Kyle McCormick
0ef8e773cc Merge pull request #85 from edx/kdmccormick/page-size
EDUCATOR-3936 Increase users per page from 10 to 25
2019-01-25 11:46:38 -05:00
Kyle McCormick
1dac20b866 EDUCATOR-3936 Increase users per page from 10 to 25 2019-01-25 11:39:38 -05:00
Zachary Hancock
ed2d715ce0 Merge pull request #84 from edx/zhancock/assignment-type-filter
persist assignment type filter
2019-01-24 16:47:22 -05:00
Zach Hancock
c82c49ea59 persist assignment type filter 2019-01-24 16:42:57 -05:00
Richard I Reilly
a9f8aec5f9 Merge pull request #86 from edx/rir/search-affordance
Cosmetic changes to give search more affordance
2019-01-24 16:20:52 -05:00
Rick Reilly
a63e9a5347 Cosmetic changes to give search more affordance 2019-01-24 16:06:53 -05:00
Richard I Reilly
2581812118 Merge pull request #82 from edx/rir/cleanup
Remove the 'is_graded' filter. The api will ensure all subsection gra…
2019-01-23 14:23:55 -05:00
Rick Reilly
c4fe803a95 Remove the 'is_graded' filter. The api will ensure all subsection grades we get are 'is_graded=true' 2019-01-23 13:34:50 -05:00
Richard I Reilly
93be5329ca Merge pull request #79 from edx/rir/lint
fix(lint): Fix all eslint issues and prop validation
2019-01-23 12:21:33 -05:00
Rick Reilly
80ba7e7152 fix(lint): Fix all eslint issues and prop validation 2019-01-23 12:18:32 -05:00
Alex Dusenbery
f88526aa3a Include expired course modes when fetching data from course enrollment API. 2019-01-23 10:16:07 -05:00
Simon Chen
c0f08eee58 Merge pull request #80 from edx/schen/improve_analytics
fix(analytics): Add the proper labels to analytics for gradebook
2019-01-22 13:43:29 -05:00
Simon Chen
ef62ea35dc fix(analytics): Add the proper labels to analytics for gradebook 2019-01-22 13:25:41 -05:00
Simon Chen
34eaa31776 Merge pull request #78 from edx/schen/EDUCATOR-3925
fix(bug): make sure gradebook rounding handle null input
2019-01-17 14:44:23 -05:00
Simon Chen
a7316e6824 fix(bug): make sure gradebook rounding handle null input 2019-01-17 14:37:21 -05:00
Alex Dusenbery
c0ab04f20c Merge pull request #77 from edx/aed/adrs
Add ADRs about API usage and UX.
2019-01-17 11:19:24 -05:00
Alex Dusenbery
ed72e7c203 Add ADRs about API usage and UX. 2019-01-16 17:00:21 -05:00
Simon Chen
223d9a00bd Merge pull request #76 from edx/schen/analytics_setup
Add segment library integration with Gradebook to track events
2019-01-16 16:10:41 -05:00
Simon Chen
8379f48e50 fix(analytics): Add segment integration into Gradebook
Gradebook should now have segment.io tracking
2019-01-16 13:41:35 -05:00
Jansen Kantor
9e1268e388 Merge pull request #75 from edx/jkantor/a11y-2
fix(a11y): add select aria-labels, row headers
2019-01-16 10:16:14 -05:00
jansenk
57e0f2254a fix(a11y): add select aria-labels, row headers
EDUCATOR-3858
2019-01-15 16:40:38 -05:00
Douglas Hall
2cc14191b4 Merge pull request #71 from edx/douglashall/frontend-component-footer
Move footer component to npm package
2019-01-10 14:32:19 -05:00
Douglas Hall
603dbeb823 fix(footer): move footer component to npm package 2019-01-09 16:38:01 -05:00
Zachary Hancock
55cb1f4140 Merge pull request #74 from edx/zhancock/openedx-meta
add metadata for openedx releases
2019-01-08 16:39:25 -05:00
Zach Hancock
55648a62ff add metadata for openedx releases 2019-01-08 15:28:30 -05:00
Zachary Hancock
62f9d24704 Merge pull request #73 from edx/zhancock/devstack-integration
move project run/setup to devstack
2019-01-08 11:19:19 -05:00
Zach Hancock
f036b0cf34 move project run/setup to devstack 2019-01-07 14:10:55 -05:00
Jansen Kantor
67493d1e9e Merge pull request #69 from edx/jkantor/change-message
changed role error message and don't show during loading
2019-01-03 10:59:37 -05:00
jansenk
e5bca7e526 changed role error message and don't show during loading 2019-01-02 16:35:06 -05:00
Jansen Kantor
52c5357ce7 Merge pull request #66 from edx/jkantor/change-pagination-buttons
implement gradebook pagination button feedback
2019-01-02 16:19:43 -05:00
jansenk
d469cc2de7 implement gradebook pagination button feedback
refactor buttons to a pure function component
change labels
disable rather than hide

EDUCATOR-3825
2019-01-02 16:11:01 -05:00
Jansen Kantor
86092f22b3 Merge pull request #65 from edx/jkantor/disable-student-groups
disable rather than hide empty groups and cohorts
2019-01-02 09:22:19 -05:00
Zachary Hancock
c8cb07228f Merge pull request #63 from edx/zhancock/edit-modal
Gradebook edit modal updates
2018-12-27 10:04:57 -05:00
Jansen Kantor
a1946e7bc4 Merge pull request #64 from edx/jkantor/roles-filter
request filtered roles
2018-12-21 15:08:09 -05:00
jansenk
01d80e0fff disable rather than hide empty groups and cohorts
EDUCATOR-3824
2018-12-21 14:23:19 -05:00
jansenk
e6da087e83 request filtered roles 2018-12-21 12:53:07 -05:00
Jansen Kantor
ac5eaed5cb Merge pull request #62 from edx/jkantor/staff
fix(auth) allow global staff to view gradebook
2018-12-21 11:46:02 -05:00
jansenk
88997ca242 fix(auth) allow global staff to view gradebook 2018-12-21 11:34:28 -05:00
Zach Hancock
d5daf9086f gradebook edit modal message 2018-12-21 10:09:35 -05:00
Simon Chen
8a01a60d63 Merge pull request #61 from edx/schen/default_select
fix(functionality): Make sure we default select radio button
2018-12-20 10:06:27 -05:00
Simon Chen
66cdcc7f2a fix(functionality): Make sure we default select radio button 2018-12-20 09:57:14 -05:00
Robert Raposa
0c73d66666 Merge pull request #59 from edx/robrap/footer-logo
Update footer logo.
2018-12-19 16:13:55 -05:00
albemarle
28e3e6d0e6 Merge pull request #60 from edx/home-link
add aria-label for edX Home
2018-12-19 15:29:57 -05:00
albemarle
6473bafa3d edx -> edX 2018-12-19 15:16:37 -05:00
Jansen Kantor
167901e665 Merge pull request #55 from edx/jkantor/role-error
render error message if user is not allowed to view gradebook
2018-12-19 15:15:34 -05:00
albemarle
50a0d6e579 add aria-label for edX Home 2018-12-19 15:09:58 -05:00
Robert Raposa
a284c286f5 Update footer logo.
ARCH-322
2018-12-19 14:04:31 -05:00
jansenk
dd967e703c render error message if user is not allowed to view gradebook 2018-12-19 14:00:27 -05:00
Richard I Reilly
725dc071e3 Merge pull request #58 from edx/rir/base-html-fixes
Add necessary meta tag to the base html file, language attribute for …
2018-12-19 13:37:45 -05:00
Rick Reilly
3da7730f23 Add necessary meta tag to the base html file, language attribute for a11y, and title 2018-12-19 11:20:33 -05:00
Richard I Reilly
bea36fb387 Merge pull request #57 from edx/kill-the-cats
Minor clean-up.
2018-12-18 18:29:09 -05:00
Robert Raposa
1408b0ae7e Minor clean-up. 2018-12-18 17:35:37 -05:00
Robert Raposa
7b817a4234 Merge pull request #56 from edx/dynamic-copyright
ARCH-321: Dynamic copyright in footer.
2018-12-18 17:13:42 -05:00
Richard I Reilly
a762c47d77 Merge pull request #37 from edx/add-footer
ARCH-308: Reimplement LMS footer in React in Gradebook.
2018-12-18 16:55:08 -05:00
Robert Raposa
aecb93c252 Dynamic copyright in footer.
ARCH-321
2018-12-18 16:51:27 -05:00
Robert Raposa
5a489b1bd5 Add footer matching LMS courses footer.
Note: There are still some follow-up tasks in ARCH-308
for analytics, i18n, etc. This gets the base functionality
in place.

ARCH-308
2018-12-18 14:19:01 -05:00
Simon Chen
5c642a1be5 Merge pull request #54 from edx/schen/alert
Update the color of the alert from red to yellow
2018-12-13 15:30:20 -05:00
Simon Chen
9a0e0e0ece Update the color of the alert from red to yellow 2018-12-13 14:50:38 -05:00
Alex Dusenbery
7486a342e2 Merge pull request #53 from edx/aed/show-attempted
Distinguish unattempted subsections.
2018-12-13 14:20:18 -05:00
Alex Dusenbery
fd807c54f8 Update README.md 2018-12-12 16:12:15 -05:00
Alex Dusenbery
9b894b502f Distinguish unattempted subsections. 2018-12-12 15:03:42 -05:00
Douglas Hall
afd9692f6b Merge pull request #51 from edx/douglashall/fix_prod_webpack_config
fix(webpack): use the appropriate css loader in prod webpack config
2018-12-12 10:56:04 -05:00
Douglas Hall
8f77dea222 fix(webpack): use the appropriate css loader in prod webpack config 2018-12-12 09:48:15 -05:00
Simon Chen
3bf3acaaec Merge pull request #50 from edx/schen/frozen_grades
fix(feat): Prevent editing if course grades are frozen
2018-12-11 13:45:23 -05:00
Simon Chen
f1ab3d0330 fix(feat): Prevent editing if course grades are frozen 2018-12-11 13:29:00 -05:00
Simon Chen
8d89bc16b1 Merge pull request #48 from edx/schen/update_wording
Update link wording
2018-12-10 15:29:01 -05:00
Alex Dusenbery
d93476c198 fix(UI): round numerator of grade ratios. 2018-12-10 14:26:19 -05:00
Simon Chen
b46d47286b Update link wording 2018-12-10 14:22:49 -05:00
Simon Chen
4aecfce14a Merge pull request #42 from edx/schen/test3
Add all the tests to reducers
2018-12-10 13:01:03 -05:00
Douglas Hall
14f7ad01b8 Merge pull request #46 from douglashall/douglashall/fix_logo
fix(header): fix edX logo in header
2018-12-10 12:29:39 -05:00
Douglas Hall
104cb30ef5 fix(header): fix edX logo in header 2018-12-10 12:16:58 -05:00
Simon Chen
e9bc4cebe4 Merge pull request #43 from edx/schen/fix_local
fix(auth): fix locally running gradebook auth refresh issue
2018-12-07 14:15:03 -05:00
Simon Chen
559180592c fix(auth): fix locally running gradebook auth refresh issue 2018-12-07 14:09:50 -05:00
Richard I Reilly
0f1f0ae89d Merge pull request #41 from edx/rir/header
Add super simple header
2018-12-07 14:04:14 -05:00
Simon Chen
a812ee3816 Add all the tests to reducers 2018-12-07 12:11:21 -05:00
Rick Reilly
2e725e0441 Add super simple header 2018-12-07 11:48:26 -05:00
Simon Chen
a1c2ccc539 Merge pull request #40 from edx/schen/tests2
Add more unit tests on actions and reducers
2018-12-07 10:59:02 -05:00
Simon Chen
a70ddd79f6 Add more unit tests on actions and reducers 2018-12-07 10:12:13 -05:00
Simon Chen
dd82054bbc Merge pull request #39 from edx/schen/setup-test
feat(test): Setup unit testing
2018-12-05 14:20:58 -05:00
Jansen Kantor
6a4bc67841 Merge pull request #38 from edx/encoding
fix(UI) specify utf8 to avoid incorrect character rendering
2018-12-05 14:15:51 -05:00
Simon Chen
adfefac85d feat(test): Setup unit testing 2018-12-05 13:52:33 -05:00
jkantor
c92144c436 fix(UI) specify utf8 to avoid incorrect character rendering 2018-12-05 13:35:28 -05:00
Jansen Kantor
ca0156ea4c Merge pull request #36 from edx/rounded-percents
fix(UI) rounded percentages to two decimal places
2018-12-05 13:33:38 -05:00
jkantor
61c4bc11bd fix(UI) rounded percentages to two decimal places 2018-12-05 10:53:55 -05:00
Jansen Kantor
db25a18f9d Merge pull request #35 from edx/updateMessage-filter
fix(UI) box should appear after editing grade
2018-12-04 14:01:05 -05:00
jkantor
0d7fa18acd fix(UI) box should appear after editing grade 2018-12-04 13:38:27 -05:00
299 changed files with 69986 additions and 18740 deletions

View File

@@ -1,17 +0,0 @@
{
"presets": [
[
"env",
{
"targets": {
"browsers": ["last 2 versions", "ie 11"]
}
}
],
"babel-preset-react"
],
"plugins": [
"transform-object-rest-spread",
"transform-class-properties"
]
}

32
.env Normal file
View File

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

39
.env.development Normal file
View File

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

View File

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

View File

@@ -1,24 +0,0 @@
{
"extends": "eslint-config-edx",
"parser": "babel-eslint",
"rules": {
"import/no-extraneous-dependencies": [
"error",
{
"devDependencies": [
"config/*.js",
"**/*.test.jsx",
"**/*.test.js"
]
}
],
// https://github.com/evcohen/eslint-plugin-jsx-a11y/issues/340#issuecomment-338424908
"jsx-a11y/anchor-is-valid": [ "error", {
"components": [ "Link" ],
"specialLink": [ "to" ]
}]
},
"env": {
"jest": true
}
}

21
.eslintrc.js Normal file
View File

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

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.
* @openedx/content-aurora

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: @openedx/content-aurora

View File

@@ -0,0 +1,19 @@
# 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 }}

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

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

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

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

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

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

12
.gitignore vendored
View File

@@ -5,9 +5,21 @@ npm-debug.log
coverage
dist/
src/i18n/transifex_input.json
temp/babel-plugin-react-intl
### pyenv ###
.python-version
### Emacs ###
*~
*.swo
*.swp
### Development environments ###
.idea
.vscode
### transifex ###
src/i18n/transifex_input.json
temp

View File

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

View File

@@ -1,34 +0,0 @@
language: node_js
node_js:
- lts/*
cache:
directories:
- "~/.npm"
notifications:
email:
recipients:
- adusenbery@edx.org
- rreilly@edx.org
- schen@edx.org
on_success: never
on_failure: always
webhooks: https://www.travisbuddy.com/
on_success: never
before_install:
- npm install -g npm@latest
- 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 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=

9
.tx/config Normal file
View File

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

View File

@@ -1,29 +0,0 @@
# Copied from https://github.com/BretFisher/node-docker-good-defaults/blob/master/Dockerfile
FROM node:8.9.3
# Create app directory
RUN mkdir -p /edx/app
ARG NODE_ENV=production
ENV NODE_ENV $NODE_ENV
ARG PORT=80
ENV PORT $PORT
EXPOSE $PORT 1991
WORKDIR /edx
# Install app dependencies
# A wildcard is used to ensure both package.json AND package-lock.json are copied
# where available (npm@5+)
COPY package*.json ./
# If you are building your code for production
# RUN npm install --only=production
RUN npm install
ENV PATH /edx/app/node_modules/.bin:$PATH
WORKDIR /edx/app
COPY . /edx/app
ENTRYPOINT npm install && npm run start

149
LICENSE
View File

@@ -1,23 +1,21 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
@@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
@@ -72,7 +60,7 @@ modification follow.
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
@@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
@@ -635,40 +633,29 @@ the "copyright" line and a pointer to where the full notice is found.
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
GNU Affero General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<http://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.

View File

@@ -1,32 +1,65 @@
shell: ## run a shell on the cookie-cutter container
docker exec -it edx.gradebook /bin/bash
build:
docker-compose build
up: ## bring up cookie-cutter container
docker-compose up
up-detached: ## bring up cookie-cutter container in detached mode
docker-compose up -d
logs: ## show logs for cookie-cutter container
docker-compose logs -f
down: ## stop and remove cookie-cutter container
docker-compose down
npm-install-%: ## install specified % npm package on the cookie-cutter container
docker exec npm install $* --save-dev
npm-install-%: ## install specified % npm package
npm install $* --save-dev
git add package.json
restart:
make down
make up
transifex_resource = frontend-app-gradebook
transifex_langs = "ar,fr,es_419,zh_CN"
restart-detached:
make down
make up-detached
transifex_utils = ./node_modules/.bin/transifex-utils.js
i18n = ./src/i18n
transifex_input = $(i18n)/transifex_input.json
tx_url1 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/translation/en/strings/
tx_url2 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/source/
# This directory must match .babelrc .
transifex_temp = ./temp/babel-plugin-react-intl
NPM_TESTS=build i18n_extract lint test is-es5
.PHONY: test
test: $(addprefix test.npm.,$(NPM_TESTS)) ## validate ci suite
.PHONY: test.npm.*
test.npm.%: validate-no-uncommitted-package-lock-changes
test -d node_modules || $(MAKE) requirements
npm run $(*)
.PHONY: requirements
requirements: ## install ci requirements
npm ci
i18n.extract:
# Pulling display strings from .jsx files into .json files...
rm -rf $(transifex_temp)
npm run-script i18n_extract
i18n.concat:
# Gathering JSON messages into one file...
$(transifex_utils) $(transifex_temp) $(transifex_input)
extract_translations: | requirements i18n.extract i18n.concat
# Despite the name, we actually need this target to detect changes in the incoming translated message files as well.
detect_changed_source_translations:
# Checking for changed translations...
git diff --exit-code $(i18n)
# Pushes translations to Transifex. You must run make extract_translations first.
push_translations:
# Pushing strings to Transifex...
tx push -s
# Fetching hashes from Transifex...
./node_modules/reactifex/bash_scripts/get_hashed_strings.sh $(tx_url1)
# Writing out comments to file...
$(transifex_utils) $(transifex_temp) --comments
# Pushing comments to Transifex...
./node_modules/reactifex/bash_scripts/put_comments.sh $(tx_url2)
# Pulls translations from Transifex.
pull_translations:
tx pull -f --mode reviewed --languages=$(transifex_langs)
# This target is used by CI.
validate-no-uncommitted-package-lock-changes:
# Checking for package-lock.json changes...
git diff --exit-code package-lock.json

View File

@@ -1,40 +1,76 @@
[![Build Status](https://api.travis-ci.org/edx/gradebook.svg?branch=master)](https://travis-ci.org/edx/gradebook) [![Coveralls](https://img.shields.io/coveralls/edx/gradebook.svg?branch=master)](https://coveralls.io/github/edx/gradebook)
[![npm_version](https://img.shields.io/npm/v/@edx/gradebook.svg)](@edx/gradebook)
[![npm_downloads](https://img.shields.io/npm/dt/@edx/gradebook.svg)](@edx/gradebook)
[![license](https://img.shields.io/npm/l/@edx/gradebook.svg)](@edx/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#override-learner-subsection-scores-in-bulk)
## Should I use Gradebook in my course?
### What does this offer over the legacy gradebook?
The micro-frontend offers a great deal more granularity when searching for problems, an easy interface for editing grades, an
audit trail for seeing who edited what grade and what reason they gave (if any) for doing so.
UsageProblems can be filtered by student as in the traditional gradebook, but can also be filtered by scores to see who
scored within a certain range, and by assignment types (note: Not problem types, but categories like Exams or
Homework).
### What does the legacy gradebook offer that this project does not?
This project does not (yet, at least) create any graphs, which the traditional gradebook does. It also does not give
quick links to the problems for the instructor to visit. It expects the instructor to be familiar with the problems they
are grading and which unit they refer to.
The gradebook is expected to be much more performant for larger numbers of students as well. The Instructor Dashboard
link for the legacy gradebook reports that "this feature is available only to courses with a small number of enrolled
learners." However, this project comes with no such warning.
### Who should not change to this gradebook?
Groups whose instructors need not ever manually override grades do not need this project, but may not be any worse off
depending on their needs. Instructors that expect to review grades infrequently enough that not having a direct link
to the problem in question will have a worse UX than the legacy gradebook provides. Instructors that rely on the graphs
generated by the current gradebook might find the lack of autogenerated graphs to be frustrating.
## Quickstart
### Installation
To install gradebook into your project:
```
npm i --save @edx/gradebook
npm i --save @edx/frontend-app-gradebook
```
## Running the UI Standalone
After cloning the repository, run `make up-detached` in the `gradebook` directory - this will build and start the `gradebook` web application in a docker container.
To install the project please refer to the [`edX Developer Stack`](https://github.com/edx/devstack) instructions.
The web application runs on port **1991**, so when you go to `http://localhost:1991` you should see the UI.
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 logs` in the `gradebook` directory.
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 `make up-detached` 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.
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:1991`, 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`:
Assuming you've got the UI running at `http://localhost:1994`, you can configure the LMS in edx-platform
to point to your local gradebook from the instructor dashboard by putting this setting in `lms/env/private.py`:
```
WRITABLE_GRADEBOOK_URL = 'http://localhost:1991'
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:
@@ -44,10 +80,22 @@ 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
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

3
babel.config.js Normal file
View File

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

View File

@@ -1,15 +0,0 @@
// This is the common Webpack config. The dev and prod Webpack configs both
// inherit config defined here.
const path = require('path');
module.exports = {
entry: {
app: path.resolve(__dirname, '../src/index.jsx'),
},
output: {
path: path.resolve(__dirname, '../dist'),
},
resolve: {
extensions: ['.js', '.jsx'],
},
};

View File

@@ -1,106 +0,0 @@
// This is the dev Webpack config. All settings here should prefer a fast build
// time at the expense of creating larger, unoptimized bundles.
const Merge = require('webpack-merge');
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const webpack = require('webpack');
const commonConfig = require('./webpack.common.config.js');
module.exports = Merge.smart(commonConfig, {
mode: 'development',
entry: [
// enable react's custom hot dev client so we get errors reported in the browser
require.resolve('react-dev-utils/webpackHotDevClient'),
path.resolve(__dirname, '../src/index.jsx'),
],
module: {
// Specify file-by-file rules to Webpack. Some file-types need a particular kind of loader.
rules: [
// The babel-loader transforms newer ES2015+ syntax to older ES5 for older browsers.
// Babel is configured with the .babelrc file at the root of the project.
{
test: /\.(js|jsx)$/,
include: [
path.resolve(__dirname, '../src'),
],
loader: 'babel-loader',
options: {
// Caches result of loader to the filesystem. Future builds will attempt to read from the
// cache to avoid needing to run the expensive recompilation process on each run.
cacheDirectory: true,
},
},
// We are not extracting CSS from the javascript bundles in development because extracting
// prevents hot-reloading from working, it increases build time, and we don't care about
// flash-of-unstyled-content issues in development.
{
test: /(.scss|.css)$/,
use: [
'style-loader', // creates style nodes from JS strings
{
loader: 'css-loader', // translates CSS into CommonJS
options: {
sourceMap: true,
},
},
{
loader: 'sass-loader', // compiles Sass to CSS
options: {
sourceMap: true,
includePaths: [
path.join(__dirname, '../node_modules'),
path.join(__dirname, '../src'),
],
},
},
],
},
// Webpack, by default, uses the url-loader for images and fonts that are required/included by
// files it processes, which just base64 encodes them and inlines them in the javascript
// bundles. This makes the javascript bundles ginormous and defeats caching so we will use the
// file-loader instead to copy the files directly to the output directory.
{
test: /\.(woff2?|ttf|svg|eot)(\?v=\d+\.\d+\.\d+)?$/,
loader: 'file-loader',
},
],
},
// Specify additional processing or side-effects done on the Webpack output bundles as a whole.
plugins: [
// Generates an HTML file in the output directory.
new HtmlWebpackPlugin({
inject: true, // Appends script tags linking to the webpack bundles at the end of the body
template: path.resolve(__dirname, '../public/index.html'),
}),
new webpack.EnvironmentPlugin({
NODE_ENV: 'development',
BASE_URL: 'localhost:1991',
LMS_BASE_URL: 'http://localhost:18000',
LOGIN_URL: 'http://localhost:18000/login',
LOGOUT_URL: 'http://localhost:18000/login',
CSRF_TOKEN_API_PATH: '/csrf/api/v1/token',
REFRESH_ACCESS_TOKEN_ENDPOINT: 'http://localhost:18000/login',
DATA_API_BASE_URL: 'http://localhost:8000',
// LMS_CLIENT_ID should match the lms DOT client application id your LMS container
LMS_CLIENT_ID: 'login-service-client-id',
SEGMENT_KEY: null,
FEATURE_FLAGS: {},
ACCESS_TOKEN_COOKIE_NAME: 'edx-jwt-cookie-header-payload',
CSRF_COOKIE_NAME: 'csrftoken',
}),
// when the --hot option is not passed in as part of the command
// the HotModuleReplacementPlugin has to be specified in the Webpack configuration
// https://webpack.js.org/configuration/dev-server/#devserver-hot
new webpack.HotModuleReplacementPlugin(),
],
// This configures webpack-dev-server which serves bundles from memory and provides live
// reloading.
devServer: {
host: '0.0.0.0',
port: 1991,
historyApiFallback: true,
hot: true,
inline: true,
},
});

View File

@@ -1,112 +0,0 @@
// This is the prod Webpack config. All settings here should prefer smaller,
// optimized bundles at the expense of a longer build time.
const Merge = require('webpack-merge');
const commonConfig = require('./webpack.common.config.js');
const path = require('path');
const webpack = require('webpack');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = Merge.smart(commonConfig, {
mode: 'production',
devtool: 'source-map',
output: {
filename: '[name].[chunkhash].js',
path: path.resolve(__dirname, '../dist'),
},
module: {
// Specify file-by-file rules to Webpack. Some file-types need a particular kind of loader.
rules: [
// The babel-loader transforms newer ES2015+ syntax to older ES5 for older browsers.
// Babel is configured with the .babelrc file at the root of the project.
{
test: /\.(js|jsx)$/,
include: [
path.resolve(__dirname, '../src'),
],
loader: 'babel-loader',
},
// Webpack, by default, includes all CSS in the javascript bundles. Unfortunately, that means:
// a) The CSS won't be cached by browsers separately (a javascript change will force CSS
// re-download). b) Since CSS is applied asyncronously, it causes an ugly
// flash-of-unstyled-content.
//
// To avoid these problems, we extract the CSS from the bundles into separate CSS files that
// can be included as <link> tags in the HTML <head> manually.
//
// We will not do this in development because it prevents hot-reloading from working and it
// increases build time.
{
test: /(.scss|.css)$/,
use: ExtractTextPlugin.extract({
// creates style nodes from JS strings, only used if extracting fails
fallback: 'style-loader',
use: [
{
loader: 'css-loader', // translates CSS into CommonJS
options: {
sourceMap: true,
minimize: true,
},
},
{
loader: 'sass-loader', // compiles Sass to CSS
options: {
sourceMap: true,
includePaths: [
path.join(__dirname, '../node_modules'),
path.join(__dirname, '../src'),
],
},
},
],
}),
},
// Webpack, by default, uses the url-loader for images and fonts that are required/included by
// files it processes, which just base64 encodes them and inlines them in the javascript
// bundles. This makes the javascript bundles ginormous and defeats caching so we will use the
// file-loader instead to copy the files directly to the output directory.
{
test: /\.(woff2?|ttf|svg|eot)(\?v=\d+\.\d+\.\d+)?$/,
loader: 'file-loader',
},
],
},
// New in Webpack 4. Replaces CommonChunksPlugin. Extract common modules among all chunks to one
// common chunk and extract the Webpack runtime to a single runtime chunk.
optimization: {
runtimeChunk: 'single',
splitChunks: {
chunks: 'all',
},
},
// Specify additional processing or side-effects done on the Webpack output bundles as a whole.
plugins: [
// Writes the extracted CSS from each entry to a file in the output directory.
new ExtractTextPlugin({
filename: '[name].min.css',
allChunks: true,
}),
// Generates an HTML file in the output directory.
new HtmlWebpackPlugin({
inject: true, // Appends script tags linking to the webpack bundles at the end of the body
template: path.resolve(__dirname, '../public/index.html'),
}),
new webpack.EnvironmentPlugin({
NODE_ENV: 'production',
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,
}),
],
});

View File

@@ -1,20 +0,0 @@
version: "2"
services:
web:
build:
context: .
dockerfile: Dockerfile
args:
- NODE_ENV=development
container_name: edx.gradebook
image: edxops/front-end-cookie-cutter:latest
volumes:
- .:/edx/app:delegated
- notused:/edx/app/node_modules
ports:
- "1991:1991"
environment:
- NODE_ENV=development
volumes:
notused:

View File

@@ -1,77 +0,0 @@
# 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`.

40
documentation/CI.md Executable file
View File

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

@@ -0,0 +1,46 @@
Usage of the bulk-update API
============================
Context
=======
The LMS Grades API exposes a set of Gradebook-related endpoints:
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,
the course grade updates that occur during calls to this API are synchronous - the entire update operation
is completed before a response is given to the client.
For decisions made about the implementation of this API, see:
https://github.com/edx/edx-platform/blob/master/lms/djangoapps/grades/docs/decisions/0001-gradebook-api.rst
Decision
========
The Gradebook front-end will post data about a single subsection and user in a single request
to the ``bulk-update`` API. That is, we currently need only the "update" aspect of this
endpoint, and not the "bulk" aspect, for satisfying the requirements of the current UX.
Status
======
Accepted (circa December 2018)
Consequences
============
This is a scenario in which the implementation of the API is coupled to the
UX that depends on the API. Because the course grade update is synchronous, it means
the API response can contain the updated subsection and course grade data. Because
a response from the API contains this data, the UI can operate in a very familiar way:
- A user clicks a button to submit a request with grade update data to the update endpoint.
- On the server, the subsection and course grades are modified.
- In the meantime, the client-side user looks at a spinner.
- A response is returned with updated data and the spinner goes away.
- Updated data is displayed to the user, along with a message indicative of the update.
If the update becomes asynchronous, the user experience outlined above has to change.
Because a single call to this endpoint updates grades data for only a single user,
the endpoint does not necessarily have to utilize an asynchronous operation at this time.

View File

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

@@ -0,0 +1,58 @@
# Test Setup
Instructions for setting up environments and data for testing Gradebook.
## Set up a course with graded content
A course with graded content is the first prerequisite to testing. Use an existing course (e.g. the DemoX Demonstration Course in Devstack) or see [Building and Running an edX Course > Developing Your Course](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/developing_course/index.html) for notes on how to develop a course from scratch.
Notably, the course needs a grading policy and subsections with scoreable content.
After creating subsections with content, they need to be configured with an "Assignment Type" to be included in grading.
Suggested resources:
- [Establishing a Grading Policy For Your Course](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/grading/index.html)
- [Adding Exercises and Tools](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/grading/index.html)
- [Set the Assignment Type and Due Date for a Subsection](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/developing_course/course_subsections.html#set-the-assignment-type-and-due-date-for-a-subsection)
## Enable Gradebook for course
See README.md #Quickstart for more detailed instructions.
As an admin user, visit Django Admin (`{lms-url}/admin`) to modify features.
- In Grades > Persistent Grades Enabled flag, click "Add persistent grades enabled flag"
- [ ] Enable the flag globally or for the course and click "Save"
- In Django-Waffle > Switches, click "Add switch"
- [ ] Set name to `grades.assume_zero_grade_if_absent`, select "Active", and click "Save"
- In Waffle_Utils > Waffle flag course overrides:
- [ ] Add a new flag called `grades.writeable_gradebook`, select "Force On", and enable it for your course
## Enable Bulk Management
Bulk Management is an added feature to allow modifying grades in bulk via CSV upload. Bulk Management is default enabled for Master's track courses but can be selectively enabled for other courses with a waffle flag following the steps below.
- In Waffle_Utils > Waffle flag course overrides:
- [ ] Add a new flag called `grades.bulk_management`, select "Force On", and enable it for your course.
## Create a Master's track for testing Master's-only features
[source](https://openedx.atlassian.net/wiki/spaces/MS/pages/1453818012/Add+a+learner+into+a+master+s+track)
Add a Master's track in your course:
- As an admin user, go to Django Admin (`{lms-url}/admin`) > Course Modes and add a new course mode
- Set the Mode to "Master's"
- Set any valid price and currency values
- Click "Save"
Enroll a student in the Master's track:
- As a staff/admin user, go to `{lms-url}/support/enrollment`
- Search for the username or email of student to enroll
- In the results table row matching the user/course, click the "Change Enrollment" button
- Select the "Master's" enrollment mode and click "Submit enrollment change"
## Setup different types of students in course
To fully test features the course should have at least:
- [ ] An audit-track student
- [ ] A master's-track student
- [ ] A staff member
- [ ] A non-staff user

15
jest.config.js Normal file
View File

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

9
openedx.yaml Normal file
View File

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

67655
package-lock.json generated Executable file → Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,106 +1,88 @@
{
"name": "@edx/gradebook",
"version": "0.1.0",
"name": "@edx/frontend-app-gradebook",
"version": "1.5.0",
"description": "edx editable gradebook-ui to manipulate grade overrides on subsections",
"repository": {
"type": "git",
"url": "git+https://github.com/edx/gradebook.git"
"url": "git+https://github.com/edx/frontend-app-gradebook.git"
},
"scripts": {
"build": "NODE_ENV=production BABEL_ENV=production webpack --config=config/webpack.prod.config.js",
"build": "fedx-scripts webpack",
"coveralls": "cat ./coverage/lcov.info | coveralls",
"is-es5": "es-check es5 ./dist/*.js",
"lint": "eslint --ext .js --ext .jsx .",
"precommit": "npm run lint",
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
"lint": "fedx-scripts eslint --ext .jsx,.js src/",
"lint-fix": "fedx-scripts eslint --fix --ext .jsx,.js src/",
"prepush": "npm run lint",
"semantic-release": "semantic-release",
"start": "NODE_ENV=development BABEL_ENV=development node_modules/.bin/webpack-dev-server --config=config/webpack.dev.config.js --progress",
"test": "jest --coverage --passWithNoTests",
"travis-deploy-once": "travis-deploy-once"
"start": "fedx-scripts webpack-dev-server --progress",
"test": "TZ=GMT fedx-scripts jest --coverage --passWithNoTests",
"watch-tests": "jest --watch"
},
"author": "edX",
"license": "AGPL-3.0",
"homepage": "https://github.com/edx/gradebook#readme",
"homepage": "https://github.com/edx/frontend-app-gradebook#readme",
"publishConfig": {
"access": "public"
},
"browserslist": [
"last 2 versions",
"not ie > 0",
"not ie_mob > 0"
],
"dependencies": {
"@edx/edx-bootstrap": "^0.4.3",
"@edx/frontend-auth": "^1.2.1",
"@edx/paragon": "^3.7.2",
"babel-polyfill": "^6.26.0",
"classnames": "^2.2.5",
"email-prop-type": "^1.1.5",
"font-awesome": "^4.7.0",
"history": "^4.7.2",
"prop-types": "^15.5.10",
"query-string": "^6.2.0",
"react": "^16.2.0",
"react-dom": "^16.2.0",
"react-redux": "^5.0.7",
"react-router": "^4.2.0",
"react-router-dom": "^4.2.2",
"@edx/brand": "npm:@edx/brand-edx.org@^1.3.2",
"@edx/frontend-component-footer": "10.2.1",
"@edx/frontend-component-header": "2.4.5",
"@edx/frontend-platform": "1.15.1",
"@edx/paragon": "19.6.0",
"@fortawesome/fontawesome-svg-core": "^1.2.25",
"@fortawesome/free-brands-svg-icons": "^5.11.2",
"@fortawesome/free-solid-svg-icons": "^5.11.2",
"@fortawesome/react-fontawesome": "^0.1.5",
"@redux-beacon/segment": "^1.0.0",
"@reduxjs/toolkit": "^1.5.1",
"classnames": "^2.2.6",
"core-js": "3.6.5",
"email-prop-type": "^1.1.7",
"enzyme": "^3.10.0",
"enzyme-to-json": "^3.6.2",
"font-awesome": "4.7.0",
"history": "4.10.1",
"prop-types": "15.7.2",
"query-string": "6.13.0",
"react": "16.14.0",
"react-dom": "16.14.0",
"react-intl": "^2.9.0",
"react-redux": "^7.1.1",
"react-router": "5.2.0",
"react-router-dom": "5.2.0",
"react-router-redux": "^5.0.0-alpha.9",
"redux": "^3.7.2",
"redux-devtools-extension": "^2.13.2",
"redux-logger": "^3.0.6",
"redux-thunk": "^2.2.0",
"whatwg-fetch": "^2.0.3"
"redux": "4.0.5",
"redux-beacon": "^2.1.0",
"redux-devtools-extension": "2.13.8",
"redux-logger": "3.0.6",
"redux-thunk": "2.3.0",
"regenerator-runtime": "^0.13.7",
"sass": "^1.49.0",
"util": "^0.12.3",
"whatwg-fetch": "^2.0.4"
},
"devDependencies": {
"babel-cli": "^6.26.0",
"babel-eslint": "^8.2.2",
"babel-jest": "^22.4.0",
"babel-loader": "^7.1.2",
"babel-plugin-transform-class-properties": "^6.24.1",
"babel-plugin-transform-object-rest-spread": "^6.26.0",
"babel-preset-env": "^1.6.1",
"babel-preset-react": "^6.24.1",
"codecov": "^3.0.0",
"css-loader": "^0.28.9",
"enzyme": "^3.3.0",
"enzyme-adapter-react-16": "^1.1.1",
"es-check": "^2.0.2",
"eslint-config-edx": "^4.0.3",
"extract-text-webpack-plugin": "^4.0.0-beta.0",
"fetch-mock": "^6.3.0",
"file-loader": "^1.1.9",
"html-webpack-harddisk-plugin": "^0.2.0",
"html-webpack-plugin": "^3.0.3",
"husky": "^0.14.3",
"@edx/frontend-build": "9.1.1",
"axios": "0.21.1",
"axios-mock-adapter": "^1.17.0",
"codecov": "^3.6.1",
"enzyme-adapter-react-16": "^1.14.0",
"es-check": "^2.3.0",
"fetch-mock": "^6.5.2",
"husky": "2.7.0",
"identity-obj-proxy": "^3.0.0",
"jest": "^22.4.0",
"node-sass": "^4.7.2",
"react-dev-utils": "^5.0.0",
"react-test-renderer": "^16.2.0",
"redux-mock-store": "^1.5.1",
"sass-loader": "^6.0.6",
"semantic-release": "^15.10.7",
"style-loader": "^0.20.2",
"travis-deploy-once": "^5.0.9",
"webpack": "^4.1.0",
"webpack-cli": "^2.0.10",
"webpack-dev-server": "^3.1.0",
"webpack-merge": "^4.1.1"
},
"jest": {
"setupFiles": [
"./src/setupTest.js"
],
"moduleNameMapper": {
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/__mocks__/fileMock.js",
"\\.(css|scss)$": "identity-obj-proxy"
},
"collectCoverageFrom": [
"src/**/*.{js,jsx}"
],
"coveragePathIgnorePatterns": [
"/node_modules/",
"src/setupTest.js",
"src/index.js",
"/tests/"
],
"transformIgnorePatterns": [
"/node_modules/(?!(@edx/paragon)/).*/"
]
"jest": "24.9.0",
"react-dev-utils": "^5.0.3",
"react-test-renderer": "^16.10.1",
"reactifex": "1.1.1",
"redux-mock-store": "^1.5.3",
"semantic-release": "^17.2.3"
}
}

View File

@@ -1,6 +1,11 @@
<!doctype html>
<html>
<head></head>
<html lang="en-us">
<head>
<title>Gradebook | <%= process.env.SITE_NAME %></title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="shortcut icon" href="<%=htmlWebpackPlugin.options.FAVICON_URL%>" type="image/x-icon" />
</head>
<body>
<div id="root"></div>
</body>

34
src/App.jsx Executable file
View File

@@ -0,0 +1,34 @@
import React from 'react';
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
import { AppProvider } from '@edx/frontend-platform/react';
import Footer from '@edx/frontend-component-footer';
import Header from '@edx/frontend-component-header';
import { routePath } from 'data/constants/app';
import store from 'data/store';
import GradebookPage from 'containers/GradebookPage';
import './App.scss';
const App = () => (
<AppProvider store={store}>
<Router>
<div>
<Header />
<main>
<Switch>
<Route
exact
path={routePath}
component={GradebookPage}
/>
</Switch>
</main>
<Footer logo={process.env.LOGO_POWERED_BY_OPEN_EDX_URL_SVG} />
</div>
</Router>
</AppProvider>
);
export default App;

View File

@@ -1,9 +1,18 @@
@import "~@edx/edx-bootstrap/sass/edx/theme";
@import "~bootstrap/scss/bootstrap";
// 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";
@import "~@edx/paragon/src/SearchField/SearchField";
$input-focus-box-shadow: $input-box-shadow; // hack to get upgrade to paragon 4.0.0 to work
@import "./components/Gradebook/gradebook";
@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";

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

@@ -0,0 +1,74 @@
import React from 'react';
import { shallow } from 'enzyme';
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
import { AppProvider } from '@edx/frontend-platform/react';
import Footer from '@edx/frontend-component-footer';
import Header from '@edx/frontend-component-header';
import { routePath } from 'data/constants/app';
import store from 'data/store';
import GradebookPage from 'containers/GradebookPage';
import App from './App';
jest.mock('react-router-dom', () => ({
BrowserRouter: () => 'BrowserRouter',
Route: () => 'Route',
Switch: () => 'Switch',
}));
jest.mock('@edx/frontend-platform/react', () => ({
AppProvider: () => 'AppProvider',
}));
jest.mock('data/constants/app', () => ({
routePath: '/:courseId',
}));
jest.mock('@edx/frontend-component-footer', () => 'Footer');
jest.mock('data/store', () => 'testStore');
jest.mock('containers/GradebookPage', () => 'GradebookPage');
jest.mock('@edx/frontend-component-header', () => 'Header');
const logo = 'fakeLogo.png';
let el;
let router;
describe('App router component', () => {
test('snapshot', () => {
expect(shallow(<App />)).toMatchSnapshot();
});
describe('component', () => {
beforeEach(() => {
process.env.LOGO_POWERED_BY_OPEN_EDX_URL_SVG = logo;
el = shallow(<App />);
router = el.childAt(0);
});
describe('AppProvider', () => {
test('AppProvider is the parent component, passed the redux store props', () => {
expect(el.type()).toBe(AppProvider);
expect(el.props().store).toEqual(store);
});
});
describe('Router', () => {
test('first child of AppProvider', () => {
expect(router.type()).toBe(Router);
});
test('Header is above/outside-of the routing', () => {
expect(router.childAt(0).childAt(0).type()).toBe(Header);
expect(router.childAt(0).childAt(1).type()).toBe('main');
});
test('Routing - GradebookPage is only route', () => {
expect(router.find('main')).toEqual(shallow(
<main>
<Switch>
<Route exact path={routePath} component={GradebookPage} />
</Switch>
</main>,
));
});
});
test('Footer logo drawn from env variable', () => {
expect(router.find(Footer).props().logo).toEqual(logo);
});
});
});

View File

@@ -0,0 +1,23 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`App router component snapshot 1`] = `
<AppProvider
store="testStore"
>
<BrowserRouter>
<div>
<Header />
<main>
<Switch>
<Route
component="GradebookPage"
exact={true}
path="/:courseId"
/>
</Switch>
</main>
<Footer />
</div>
</BrowserRouter>
</AppProvider>
`;

View File

@@ -0,0 +1,54 @@
/* eslint-disable react/sort-comp, react/button-has-type */
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { connect } from 'react-redux';
import { Alert } from '@edx/paragon';
import selectors from 'data/selectors';
import messages from './messages';
/**
* <BulkManagementAlerts />
* Alerts to display at the top of the BulkManagement tab
*/
export const BulkManagementAlerts = ({
bulkImportError,
uploadSuccess,
}) => (
<>
<Alert
variant="danger"
show={!!bulkImportError}
dismissible={false}
>
{bulkImportError}
</Alert>
<Alert
variant="success"
show={uploadSuccess}
dismissible={false}
>
<FormattedMessage {...messages.successDialog} />
</Alert>
</>
);
BulkManagementAlerts.defaultProps = {
bulkImportError: '',
uploadSuccess: false,
};
BulkManagementAlerts.propTypes = {
// redux
bulkImportError: PropTypes.string,
uploadSuccess: PropTypes.bool,
};
export const mapStateToProps = (state) => ({
bulkImportError: selectors.grades.bulkImportError(state),
uploadSuccess: selectors.grades.uploadSuccess(state),
});
export default connect(mapStateToProps)(BulkManagementAlerts);

View File

@@ -0,0 +1,89 @@
import React from 'react';
import { shallow } from 'enzyme';
import { Alert } from '@edx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import selectors from 'data/selectors';
import messages from './messages';
import { BulkManagementAlerts, mapStateToProps } from './BulkManagementAlerts';
jest.mock('@edx/frontend-platform/i18n', () => ({
defineMessages: m => m,
FormattedMessage: () => 'FormattedMessage',
}));
jest.mock('@edx/paragon', () => ({
Alert: () => 'Alert',
}));
jest.mock('data/selectors', () => ({
__esModule: true,
default: {
grades: {
bulkImportError: (state) => ({ bulkImportError: state }),
uploadSuccess: (state) => ({ uploadSuccess: state }),
},
},
}));
const errorMessage = 'Oh noooooo';
describe('BulkManagementAlerts', () => {
describe('component', () => {
let el;
describe('no errer, no upload success', () => {
beforeEach(() => {
el = shallow(<BulkManagementAlerts />);
});
test('snapshot - bulkImportError closed, success closed', () => {
expect(el).toMatchSnapshot();
});
test('closed danger alert', () => {
expect(el.childAt(0).is(Alert)).toEqual(true);
expect(el.childAt(0).props().show).toEqual(false);
expect(el.childAt(0).props().variant).toEqual('danger');
});
test('closed success alert', () => {
expect(el.childAt(1).is(Alert)).toEqual(true);
expect(el.childAt(1).props().show).toEqual(false);
expect(el.childAt(1).props().variant).toEqual('success');
});
});
describe('no errer, no upload success', () => {
beforeEach(() => {
el = shallow(<BulkManagementAlerts uploadSuccess bulkImportError={errorMessage} />);
});
const assertions = [
'danger alert open with bulkImportError',
'success alert open with messages.successDialog',
];
test(`snapshot - ${assertions.join(', ')}`, () => {
expect(el).toMatchSnapshot();
});
test('open danger alert with bulkImportError content', () => {
expect(el.childAt(0).is(Alert)).toEqual(true);
expect(el.childAt(0).children().text()).toEqual(errorMessage);
expect(el.childAt(0).props().show).toEqual(true);
});
test('open success alert with messages.successDialog content', () => {
expect(el.childAt(1).is(Alert)).toEqual(true);
expect(el.childAt(1).children().getElement()).toEqual(
<FormattedMessage {...messages.successDialog} />,
);
expect(el.childAt(1).props().show).toEqual(true);
});
});
});
describe('mapStateToProps', () => {
let mapped;
const testState = { a: 'puppy', named: 'Ember' };
beforeEach(() => {
mapped = mapStateToProps(testState);
});
test('bulkImportError from grades.bulkImportError', () => {
expect(mapped.bulkImportError).toEqual(selectors.grades.bulkImportError(testState));
});
test('uploadSuccess from grades.uploadSuccess', () => {
expect(mapped.uploadSuccess).toEqual(selectors.grades.uploadSuccess(testState));
});
});
});

View File

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

View File

@@ -0,0 +1,62 @@
/* 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 { Table } from '@edx/paragon';
import { bulkManagementColumns } from 'data/constants/app';
import selectors from 'data/selectors';
import ResultsSummary from './ResultsSummary';
export const mapHistoryRows = ({
resultsSummary,
originalFilename,
user,
...rest
}) => ({
resultsSummary: (<ResultsSummary {...resultsSummary} />),
filename: (<span className="wrap-text-in-cell">{originalFilename}</span>),
user: (<span className="wrap-text-in-cell">{user}</span>),
...rest,
});
/**
* <HistoryTable />
* Table with history of bulk management uploads, including a results summary which
* displays total, skipped, and failed uploads
*/
export const HistoryTable = ({
bulkManagementHistory,
}) => (
<>
<Table
data={bulkManagementHistory.map(mapHistoryRows)}
hasFixedColumnWidths
columns={bulkManagementColumns}
className="table-striped"
/>
</>
);
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

@@ -0,0 +1,109 @@
/* eslint-disable import/no-named-as-default */
import React from 'react';
import { shallow } from 'enzyme';
import { Table } from '@edx/paragon';
import selectors from 'data/selectors';
import { bulkManagementColumns } from 'data/constants/app';
import ResultsSummary from './ResultsSummary';
import { HistoryTable, mapStateToProps } from './HistoryTable';
jest.mock('@edx/frontend-platform/i18n', () => ({
defineMessages: m => m,
FormattedMessage: () => 'FormattedMessage',
}));
jest.mock('@edx/paragon', () => ({
Table: () => 'Table',
}));
jest.mock('data/selectors', () => ({
__esModule: true,
default: {
grades: {
bulkManagementHistoryEntries: jest.fn(state => ({ historyEntries: state })),
},
},
}));
jest.mock('./ResultsSummary', () => 'ResultsSummary');
describe('HistoryTable', () => {
describe('component', () => {
const entry1 = {
originalFilename: 'blue.png',
user: 'Eifel',
timeUploaded: '65',
resultsSummary: {
rowId: 12,
courseId: 'Da Bu Dee',
text: 'Da ba daa',
},
};
const entry2 = {
originalFilename: 'allStar.jpg',
user: 'Smashmouth',
timeUploaded: '2000s?',
resultsSummary: {
courseId: 'rockstar',
rowId: 2,
text: 'all that glitters is gold',
},
};
const props = {
bulkManagementHistory: [entry1, entry2],
};
let el;
describe('snapshot', () => {
beforeEach(() => {
el = shallow(<HistoryTable {...props} />);
});
test('snapshot - loads formatted table', () => {
expect(el).toMatchSnapshot();
});
describe('history table', () => {
let table;
beforeEach(() => {
table = el.find(Table);
});
describe('data (from bulkManagementHistory.map(this.formatHistoryRow)', () => {
const fieldAssertions = [
'maps resultsSummay to ResultsSummary',
'wraps filename and user',
'forwards the rest',
];
test(`snapshot: ${fieldAssertions.join(', ')}`, () => {
expect(table.props().data).toMatchSnapshot();
});
test(fieldAssertions.join(', '), () => {
const rows = table.props().data;
expect(rows[0].resultsSummary).toEqual(<ResultsSummary {...entry1.resultsSummary} />);
expect(rows[0].user).toEqual(<span className="wrap-text-in-cell">{entry1.user}</span>);
expect(
rows[0].filename,
).toEqual(<span className="wrap-text-in-cell">{entry1.originalFilename}</span>);
expect(rows[1].resultsSummary).toEqual(<ResultsSummary {...entry2.resultsSummary} />);
expect(rows[1].user).toEqual(<span className="wrap-text-in-cell">{entry2.user}</span>);
expect(
rows[1].filename,
).toEqual(<span className="wrap-text-in-cell">{entry2.originalFilename}</span>);
});
});
test('columns from bulkManagementColumns', () => {
expect(table.props().columns).toEqual(bulkManagementColumns);
});
});
});
});
describe('mapStateToProps', () => {
const testState = { a: 'simple', test: 'state' };
let mapped;
beforeEach(() => {
mapped = mapStateToProps(testState);
});
test('bulkManagementHistory from grades.bulkManagementHistoryEntries', () => {
expect(
mapped.bulkManagementHistory,
).toEqual(selectors.grades.bulkManagementHistoryEntries(testState));
});
});
});

View File

@@ -0,0 +1,38 @@
/* eslint-disable react/sort-comp, react/button-has-type */
import React from 'react';
import PropTypes from 'prop-types';
import { Hyperlink, Icon } from '@edx/paragon';
import { Download } from '@edx/paragon/icons';
import lms from 'data/services/lms';
/**
* <ResultsSummary {...{ courseId, rowId, text }} />
* displays a result summary cell for a single bulk management upgrade history entry.
* @param {string} courseId - course identifier
* @param {number} rowId - row/error identifier
* @param {string} text - summary string
*/
const ResultsSummary = ({
rowId,
text,
}) => (
<Hyperlink
href={lms.urls.bulkGradesUrlByRow(rowId)}
destination="www.edx.org"
target="_blank"
rel="noopener noreferrer"
showLaunchIcon={false}
>
<Icon src={Download} className="d-inline-block" />
{text}
</Hyperlink>
);
ResultsSummary.propTypes = {
rowId: PropTypes.number.isRequired,
text: PropTypes.string.isRequired,
};
export default ResultsSummary;

View File

@@ -0,0 +1,53 @@
import React from 'react';
import { shallow } from 'enzyme';
import { Icon } from '@edx/paragon';
import { Download } from '@edx/paragon/icons';
import lms from 'data/services/lms';
import ResultsSummary from './ResultsSummary';
jest.mock('@edx/paragon', () => ({
Hyperlink: () => 'Hyperlink',
Icon: () => 'Icon',
}));
jest.mock('@edx/paragon/icons', () => ({
Download: 'DownloadIcon',
}));
jest.mock('data/services/lms', () => ({
urls: {
bulkGradesUrlByRow: jest.fn((rowId) => ({ url: { rowId } })),
},
}));
describe('ResultsSummary component', () => {
const props = {
rowId: 42,
text: 'texty',
};
let el;
const assertions = [
'safe hyperlink with bulkGradesUrl with course and row id',
'download icon',
'results text',
];
beforeEach(() => {
el = shallow(<ResultsSummary {...props} />);
});
test(`snapshot - ${assertions.join(', ')}`, () => {
expect(el).toMatchSnapshot();
});
test('Hyperlink has target="_blank" and rel="noopener noreferrer"', () => {
expect(el.props().target).toEqual('_blank');
expect(el.props().rel).toEqual('noopener noreferrer');
});
test('Hyperlink has href to bulkGradesUrl', () => {
expect(el.props().href).toEqual(lms.urls.bulkGradesUrlByRow(props.rowId));
});
test('displays Download Icon and text', () => {
const icon = el.childAt(0);
expect(icon.is(Icon)).toEqual(true);
expect(icon.props().src).toEqual(Download);
expect(el.childAt(1).text()).toEqual(props.text);
});
});

View File

@@ -0,0 +1,45 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`BulkManagementAlerts component no errer, no upload success snapshot - bulkImportError closed, success closed 1`] = `
<Fragment>
<Alert
dismissible={false}
show={false}
variant="danger"
/>
<Alert
dismissible={false}
show={false}
variant="success"
>
<FormattedMessage
defaultMessage="CSV processing. File uploads may take several minutes to complete."
description="Success Dialog message in BulkManagement Tab File Upload Form"
id="gradebook.BulkManagementHistoryView.successDialog"
/>
</Alert>
</Fragment>
`;
exports[`BulkManagementAlerts component no errer, no upload success snapshot - danger alert open with bulkImportError, success alert open with messages.successDialog 1`] = `
<Fragment>
<Alert
dismissible={false}
show={true}
variant="danger"
>
Oh noooooo
</Alert>
<Alert
dismissible={false}
show={true}
variant="success"
>
<FormattedMessage
defaultMessage="CSV processing. File uploads may take several minutes to complete."
description="Success Dialog message in BulkManagement Tab File Upload Form"
id="gradebook.BulkManagementHistoryView.successDialog"
/>
</Alert>
</Fragment>
`;

View File

@@ -0,0 +1,119 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`HistoryTable component snapshot history table data (from bulkManagementHistory.map(this.formatHistoryRow) snapshot: maps resultsSummay to ResultsSummary, wraps filename and user, forwards the rest 1`] = `
Array [
Object {
"filename": <span
className="wrap-text-in-cell"
>
blue.png
</span>,
"resultsSummary": <ResultsSummary
courseId="Da Bu Dee"
rowId={12}
text="Da ba daa"
/>,
"timeUploaded": "65",
"user": <span
className="wrap-text-in-cell"
>
Eifel
</span>,
},
Object {
"filename": <span
className="wrap-text-in-cell"
>
allStar.jpg
</span>,
"resultsSummary": <ResultsSummary
courseId="rockstar"
rowId={2}
text="all that glitters is gold"
/>,
"timeUploaded": "2000s?",
"user": <span
className="wrap-text-in-cell"
>
Smashmouth
</span>,
},
]
`;
exports[`HistoryTable component snapshot snapshot - loads formatted table 1`] = `
<Fragment>
<Table
className="table-striped"
columns={
Array [
Object {
"columnSortable": false,
"key": "filename",
"label": "Gradebook",
"width": "col-5",
},
Object {
"columnSortable": false,
"key": "resultsSummary",
"label": "Download Summary",
"width": "col",
},
Object {
"columnSortable": false,
"key": "user",
"label": "Who",
"width": "col-1",
},
Object {
"columnSortable": false,
"key": "timeUploaded",
"label": "When",
"width": "col",
},
]
}
data={
Array [
Object {
"filename": <span
className="wrap-text-in-cell"
>
blue.png
</span>,
"resultsSummary": <ResultsSummary
courseId="Da Bu Dee"
rowId={12}
text="Da ba daa"
/>,
"timeUploaded": "65",
"user": <span
className="wrap-text-in-cell"
>
Eifel
</span>,
},
Object {
"filename": <span
className="wrap-text-in-cell"
>
allStar.jpg
</span>,
"resultsSummary": <ResultsSummary
courseId="rockstar"
rowId={2}
text="all that glitters is gold"
/>,
"timeUploaded": "2000s?",
"user": <span
className="wrap-text-in-cell"
>
Smashmouth
</span>,
},
]
}
hasFixedColumnWidths={true}
/>
</Fragment>
`;

View File

@@ -0,0 +1,23 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ResultsSummary component snapshot - safe hyperlink with bulkGradesUrl with course and row id, download icon, results text 1`] = `
<Hyperlink
destination="www.edx.org"
href={
Object {
"url": Object {
"rowId": 42,
},
}
}
rel="noopener noreferrer"
showLaunchIcon={false}
target="_blank"
>
<Icon
className="d-inline-block"
src="DownloadIcon"
/>
texty
</Hyperlink>
`;

View File

@@ -0,0 +1,26 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`BulkManagementHistoryView component snapshot snapshot - loads heading from messages.BulkManagementHistoryView.heading, <BulkManagementAlerts />, <HistoryTable /> 1`] = `
<div
className="bulk-management-history-view"
>
<h4>
<FormattedMessage
defaultMessage="Bulk Management History"
description="Heading text for BulkManagement History Tab"
id="gradebook.BulkManagementHistoryView.heading"
/>
</h4>
<p
className="help-text"
>
<FormattedMessage
defaultMessage="Below is a log of previous grade imports. To download a CSV of your gradebook and import grades for override, return to the Gradebook. Please note, after importing grades, it may take a few seconds to process the override."
description="Bulk Management History View help text"
id="gradebook.BulkManagementHistoryView"
/>
</p>
<BulkManagementAlerts />
<HistoryTable />
</div>
`;

View File

@@ -0,0 +1,24 @@
/* 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

@@ -0,0 +1,44 @@
/* eslint-disable import/no-named-as-default */
import React from 'react';
import { shallow } from 'enzyme';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { BulkManagementHistoryView } from '.';
import BulkManagementAlerts from './BulkManagementAlerts';
import HistoryTable from './HistoryTable';
import messages from './messages';
jest.mock('./BulkManagementAlerts', () => 'BulkManagementAlerts');
jest.mock('./HistoryTable', () => 'HistoryTable');
describe('BulkManagementHistoryView', () => {
describe('component', () => {
let el;
beforeEach(() => {
el = shallow(<BulkManagementHistoryView />);
});
describe('snapshot', () => {
const snapshotSegments = [
'heading from messages.BulkManagementHistoryView.heading',
'<BulkManagementAlerts />',
'<HistoryTable />',
];
test(`snapshot - loads ${snapshotSegments.join(', ')}`, () => {
expect(el).toMatchSnapshot();
});
test('heading - h4 loaded from messages', () => {
const heading = el.find('h4');
expect(heading.getElement()).toEqual((
<h4>
<FormattedMessage {...messages.heading} />
</h4>
));
});
test('heading, then alerts, then upload form, then table', () => {
expect(el.childAt(0).is('h4')).toEqual(true);
expect(el.childAt(2).is(BulkManagementAlerts)).toEqual(true);
expect(el.childAt(3).is(HistoryTable)).toEqual(true);
});
});
});
});

View File

@@ -0,0 +1,21 @@
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,23 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Header snapshot - has edx link with logo url 1`] = `
<div
className="mb-3"
>
<header
className="d-flex justify-content-center align-items-center p-3 border-bottom-blue"
>
<Hyperlink
destination="undefined/dashboard"
>
<img
alt="edX logo"
height="30"
src="www.ourLogo.url"
width="60"
/>
</Hyperlink>
<div />
</header>
</div>
`;

View File

@@ -0,0 +1,21 @@
import React from 'react';
import { Hyperlink } from '@edx/paragon';
import { getConfig } from '@edx/frontend-platform';
/**
* <EdxHeader />
* Gradebook MFE app header.
* Displays edx logo, linked to lms dashboard
*/
const EdxHeader = () => (
<div className="mb-3">
<header className="d-flex justify-content-center align-items-center p-3 border-bottom-blue">
<Hyperlink destination={`${getConfig().LMS_BASE_URL}/dashboard`}>
<img src={getConfig().LOGO_URL} alt="edX logo" height="30" width="60" />
</Hyperlink>
<div />
</header>
</div>
);
export default EdxHeader;

View File

@@ -0,0 +1,21 @@
import React from 'react';
import { shallow } from 'enzyme';
import { getConfig } from '@edx/frontend-platform';
import Header from '.';
jest.mock('@edx/paragon', () => ({
Hyperlink: () => 'Hyperlink',
}));
jest.mock('@edx/frontend-platform', () => ({
getConfig: jest.fn(),
}));
describe('Header', () => {
test('snapshot - has edx link with logo url', () => {
const url = 'www.ourLogo.url';
getConfig.mockReturnValue({ LOGO_URL: url });
expect(shallow(<Header />)).toMatchSnapshot();
});
});

View File

@@ -1,81 +0,0 @@
.spinner-overlay {
position: absolute;
height: 100%;
width: 100%;
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-container{
width: 500px;
@media only screen and (min-width: 640px) {
width: 630px;
}
@media only screen and (min-width: 992px) {
width: 900px;
}
@media only screen and (min-width: 1200px) {
width: 1024px;
}
}
.back-link{
float:right;
}
.student-filters{
display: flex;
.label{
padding-top: 30px;
}
.form-group{
margin-left: 10px;
}
}
.gbook {
overflow-x: scroll;
.table {
padding-left: 244px;
}
.table tr th:first-child {
position: absolute;
width: 160px;
height:50px;
display: block;
background-color: #fff;
border-bottom: none;
}
.table tr td:first-child {
position: absolute;
width: 160px;
height:50px;
display: block;
background-color: #fff;
}
.table tr td:nth-child(2) {
box-sizing: content-box;
padding-left: 170px;
}
.table tr th:nth-child(2) {
padding-left: 170px;
}
.link-style {
color: #0075b4;
&:hover, &:focus {
color: #004368;
text-decoration: underline;
}
}
}

View File

@@ -1,373 +0,0 @@
import React from 'react';
import {
Button,
InputSelect,
Modal,
SearchField,
StatusAlert,
Table,
Icon,
} from '@edx/paragon';
import queryString from 'query-string';
import { configuration } from '../../config';
export default class Gradebook extends React.Component {
constructor(props) {
super(props);
this.state = {
filterValue: '',
modalOpen: false,
modalModel: [{}],
updateVal: 0,
updateModuleId: null,
updateUserId: null,
};
}
componentDidMount() {
const urlQuery = queryString.parse(this.props.location.search);
this.props.getUserGrades(
this.props.match.params.courseId,
urlQuery.cohort,
urlQuery.track,
);
this.props.getTracks(this.props.match.params.courseId);
this.props.getCohorts(this.props.match.params.courseId);
this.props.getAssignmentTypes(this.props.match.params.courseId);
}
setNewModalState = (userEntry, subsection) => {
this.setState({
modalModel: [{
username: userEntry.username,
currentGrade: `${subsection.score_earned}/${subsection.score_possible}`,
adjustedGrade: (
<span>
<input
style={{ width: '25px' }}
type="text"
onChange={event => this.setState({ updateVal: event.target.value })}
/> / {subsection.score_possible}
</span>
),
assignmentName: `${subsection.subsection_name}`,
}],
modalOpen: true,
updateModuleId: subsection.module_id,
updateUserId: userEntry.user_id,
});
}
handleAdjustedGradeClick = () => {
this.props.updateGrades(
this.props.match.params.courseId, [
{
user_id: this.state.updateUserId,
usage_id: this.state.updateModuleId,
grade: {
earned_graded_override: this.state.updateVal,
},
},
],
this.state.filterValue,
this.props.selectedCohort,
this.props.selectedTrack,
);
this.setState({
modalModel: [{}],
modalOpen: false,
updateModuleId: null,
updateUserId: null,
});
}
updateQueryParams = (queryKey, queryValue) => {
const parsed = queryString.parse(this.props.location.search);
parsed[queryKey] = queryValue;
return `?${queryString.stringify(parsed)}`;
};
mapAssignmentTypeEntries = (entries) => {
const mapped = entries.map(entry => ({
id: entry,
label: entry,
}));
mapped.unshift({ id: 0, label: 'All' });
return mapped;
};
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;
};
updateAssignmentTypes = (event) => {
this.props.filterColumns(event, this.props.grades[0]);
}
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.match.params.courseId,
this.props.selectedCohort,
selectedTrackSlug,
);
const updatedQueryStrings = this.updateQueryParams('track', selectedTrackSlug);
this.props.history.push(updatedQueryStrings);
};
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.match.params.courseId,
selectedCohortId,
this.props.selectedTrack,
);
const updatedQueryStrings = this.updateQueryParams('cohort', selectedCohortId);
this.props.history.push(updatedQueryStrings);
};
mapSelectedAssignmentTypeEntry = (entry) => {
const selectedAssignmentTypeEntry = this.props.assignmentTypes
.find(x => x.id === parseInt(entry, 10));
if (selectedAssignmentTypeEntry) {
return selectedAssignmentTypeEntry.name;
}
return 'All';
};
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';
};
formatter = {
percent: entries => entries.map((entry) => {
const results = { username: entry.username };
const assignments = entry.section_breakdown
.filter(section => section.is_graded)
.reduce((acc, subsection) => {
acc[subsection.label] = (
<button
className="btn btn-header link-style"
onClick={() => this.setNewModalState(entry, subsection)}
>
{subsection.percent * 100}%
</button>);
return acc;
}, {});
const totals = { total: `${entry.percent * 100}%` };
return Object.assign(results, assignments, totals);
}),
absolute: entries => entries.map((entry) => {
const results = { username: entry.username };
const assignments = entry.section_breakdown
.filter(section => section.is_graded)
.reduce((acc, subsection) => {
acc[subsection.label] = (
<button
className="btn btn-header link-style"
onClick={() => this.setNewModalState(entry, subsection)}
>
{subsection.score_earned}/{subsection.score_possible}
</button>);
return acc;
}, {});
const totals = { total: `${entry.percent * 100}/100` };
return Object.assign(results, assignments, totals);
}),
};
lmsInstructorDashboardUrl = courseId => `${configuration.LMS_BASE_URL}/courses/${courseId}/instructor`;
render() {
return (
<div className="d-flex justify-content-center">
{ this.props.showSpinner && <div className="spinner-overlay"><Icon className={['fa', 'fa-spinner', 'fa-spin', 'fa-5x', 'color-black']} /></div>}
<div className="card gradebook-container">
<div className="card-body">
<a
href={this.lmsInstructorDashboardUrl(this.props.match.params.courseId)}
className="back-link"
>
Back to Dashboard
</a>
<h1>Gradebook</h1>
<h3> {this.props.match.params.courseId}</h3>
<hr />
<div className="d-flex justify-content-between" >
<div>
<div>
Score View:
<span>
<input
id="score-view-percent"
className="ml-2 mr-1"
type="radio"
name="score-view"
value="percent"
onClick={() => this.props.toggleFormat('percent')}
/>
<label className="mr-2" htmlFor="score-view-percent">Percent</label>
</span>
<span>
<input
id="score-view-absolute"
type="radio"
name="score-view"
value="absolute"
className="mr-1"
onClick={() => this.props.toggleFormat('absolute')}
/>
<label htmlFor="score-view-absolute">Absolute</label>
</span>
</div>
{ this.props.assignmnetTypes.length > 0 &&
<div className="student-filters">
<span className="label">
Assignment Types:
</span>
<InputSelect
name="assignment-types"
value={this.mapSelectedTrackEntry(this.props.selectedAssignmentType)}
options={this.mapAssignmentTypeEntries(this.props.assignmnetTypes)}
onChange={this.updateAssignmentTypes}
/>
</div>
}
{(this.props.tracks.length > 0 || this.props.cohorts.length > 0) &&
<div className="student-filters">
<span className="label">
Student Groups:
</span>
{this.props.tracks.length > 0 &&
<InputSelect
name="Tracks"
value={this.mapSelectedTrackEntry(this.props.selectedTrack)}
options={this.mapTracksEntries(this.props.tracks)}
onChange={this.updateTracks}
/>
}
{this.props.cohorts.length > 0 &&
<InputSelect
name="Cohorts"
value={this.mapSelectedCohortEntry(this.props.selectedCohort)}
options={this.mapCohortsEntries(this.props.cohorts)}
onChange={this.updateCohorts}
/>
}
</div>
}
</div>
<div>
<div style={{ marginLeft: '10px', marginBottom: '10px' }}>
<a href={`${this.lmsInstructorDashboardUrl(this.props.match.params.courseId)}#view-data_download`}>Download Grade Report</a>
</div>
<SearchField
onSubmit={value => this.props.searchForUser(this.props.match.params.courseId, value, this.props.selectedCohort, this.props.selectedTrack)}
onChange={filterValue => this.setState({ filterValue })}
onClear={() => this.props.getUserGrades(this.props.match.params.courseId, this.props.selectedCohort, this.props.selectedTrack)}
value={this.state.filterValue}
/>
<div className="d-flex justify-content-end" style={{ marginTop: '20px' }}>
<Button
label="Previous"
buttonType="primary"
style={{ visibility: (!this.props.prevPage ? 'hidden' : 'visible') }}
onClick={() => this.props.getPrevNextGrades(this.props.prevPage, this.props.selectedCohort, this.props.selectedTrack)}
/>
<div style={{ width: '10px' }} />
<Button
label="Next"
buttonType="primary"
style={{ visibility: (!this.props.nextPage ? 'hidden' : 'visible') }}
onClick={() => this.props.getPrevNextGrades(this.props.nextPage, this.props.selectedCohort, this.props.selectedTrack)}
/>
</div>
</div>
</div>
<br />
<StatusAlert
alertType="success"
dialog="The grade has been successfully edited."
onClose={() => this.props.updateBanner(false)}
open={this.props.showSuccess}
/>
<div className="gbook">
<Table
columns={this.props.headings}
data={this.formatter[this.props.format](this.props.grades)}
tableSortable
defaultSortDirection="asc"
defaultSortedColumn="username"
/>
</div>
<Modal
open={this.state.modalOpen}
title="Edit Grades"
body={(
<div>
<h3>{this.state.modalModel[0].assignmentName}</h3>
<Table
columns={[{ label: 'Username', key: 'username' }, { label: 'Current grade', key: 'currentGrade' }, { label: 'Adjusted grade', key: 'adjustedGrade' }]}
data={this.state.modalModel}
/>
</div>
)}
buttons={[
<Button
label="Edit Grade"
buttonType="primary"
onClick={this.handleAdjustedGradeClick}
/>,
]}
onClose={() => this.setState({
modalOpen: false,
modalModel: [{}],
updateVal: 0,
updateModuleId: null,
updateUserId: null,
})}
/>
</div>
</div>
</div>
);
}
}

View File

@@ -0,0 +1,44 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AssignmentFilter Component snapshots basic snapshot 1`] = `
<div
className="student-filters"
>
<SelectGroup
disabled={false}
id="assignment"
label={
<FormattedMessage
defaultMessage="Assignment"
description="Assignment filter select label in Gradebook Filters"
id="gradebook.GradebookFilters.assignmentFilterLabel"
/>
}
onChange={[MockFunction handleChange]}
options={
Array [
<option
value=""
>
All
</option>,
<option
value="assgN1"
>
assgN1
:
subLabel1
</option>,
<option
value="assgN2"
>
assgN2
:
subLabel2
</option>,
]
}
value="assgN1"
/>
</div>
`;

View File

@@ -0,0 +1,98 @@
/* eslint-disable react/sort-comp, react/button-has-type */
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import selectors from 'data/selectors';
import actions from 'data/actions';
import thunkActions from 'data/thunkActions';
import messages from '../messages';
import SelectGroup from '../SelectGroup';
const { fetchGradesIfAssignmentGradeFiltersSet } = thunkActions.grades;
export class AssignmentFilter extends React.Component {
constructor(props) {
super(props);
this.handleChange = this.handleChange.bind(this);
}
handleChange(event) {
const assignment = event.target.value;
const selectedFilterOption = this.props.assignmentFilterOptions.find(
({ label }) => label === assignment,
);
const { type, id } = selectedFilterOption || {};
const typedValue = { label: assignment, type, id };
this.props.updateAssignmentFilter(typedValue);
this.props.updateQueryParams({ assignment: id });
this.props.fetchGradesIfAssignmentGradeFiltersSet();
}
get options() {
const mapper = ({ label, subsectionLabel }) => (
<option key={label} value={label}>
{label}: {subsectionLabel}
</option>
);
return ([
<option key="0" value="">All</option>,
...this.props.assignmentFilterOptions.map(mapper),
]);
}
render() {
return (
<div className="student-filters">
<SelectGroup
id="assignment"
label={<FormattedMessage {...messages.assignment} />}
value={this.props.selectedAssignment}
onChange={this.handleChange}
disabled={this.props.assignmentFilterOptions.length === 0}
options={this.options}
/>
</div>
);
}
}
AssignmentFilter.defaultProps = {
assignmentFilterOptions: [],
selectedAssignment: '',
};
AssignmentFilter.propTypes = {
updateQueryParams: PropTypes.func.isRequired,
// redux
assignmentFilterOptions: PropTypes.arrayOf(PropTypes.shape({
label: PropTypes.string,
subsectionLabel: PropTypes.string,
type: PropTypes.string,
id: PropTypes.string,
})),
selectedAssignment: PropTypes.string,
fetchGradesIfAssignmentGradeFiltersSet: PropTypes.func.isRequired,
updateAssignmentFilter: PropTypes.func.isRequired,
};
export const mapStateToProps = (state) => {
const { filters } = selectors;
return {
assignmentFilterOptions: filters.selectableAssignmentLabels(state),
selectedAssignment: filters.selectedAssignmentLabel(state),
selectedAssignmentType: filters.assignmentType(state),
selectedCohort: filters.cohort(state),
selectedTrack: filters.track(state),
};
};
export const mapDispatchToProps = {
updateAssignmentFilter: actions.filters.update.assignment,
fetchGradesIfAssignmentGradeFiltersSet,
};
export default connect(mapStateToProps, mapDispatchToProps)(AssignmentFilter);

View File

@@ -0,0 +1,162 @@
import React from 'react';
import { mount, shallow } from 'enzyme';
import selectors from 'data/selectors';
import actions from 'data/actions';
import { fetchGradesIfAssignmentGradeFiltersSet } from 'data/thunkActions/grades';
import {
AssignmentFilter,
mapStateToProps,
mapDispatchToProps,
} from '.';
jest.mock('data/thunkActions/grades', () => ({
updateGradesIfAssignmentGradeFiltersSet: jest.fn(),
}));
jest.mock('data/selectors', () => ({
/** Mocking to use passed state for validation purposes */
filters: {
selectableAssignmentLabels: jest.fn(() => ([{
label: 'assigNment',
subsectionLabel: 'subsection',
type: 'assignMentType',
id: 'subsectionId',
}])),
selectedAssignmentLabel: jest.fn(() => 'assigNment'),
assignmentType: jest.fn(() => 'assignMentType'),
cohort: jest.fn(() => 'COhort'),
track: jest.fn(() => 'traCK'),
},
}));
describe('AssignmentFilter', () => {
let props = {
assignmentFilterOptions: [
{
label: 'assgN1',
subsectionLabel: 'subLabel1',
type: 'assgn_Type1',
id: 'assgn_iD1',
},
{
label: 'assgN2',
subsectionLabel: 'subLabel2',
type: 'assgn_Type2',
id: 'assgn_iD2',
},
],
selectedAssignment: 'assgN1',
};
beforeEach(() => {
props = {
...props,
updateQueryParams: jest.fn(),
fetchGradesIfAssignmentGradeFiltersSet: jest.fn(),
updateAssignmentFilter: jest.fn(),
};
});
describe('Component', () => {
describe('behavior', () => {
describe('handleChange', () => {
let el;
const newAssgn = 'assgN1';
const event = { target: { value: newAssgn } };
const selected = props.assignmentFilterOptions[0];
beforeEach(() => {
el = mount(<AssignmentFilter {...props} />);
el.instance().handleChange(event);
});
it('calls props.updateAssignmentFilter with selection', () => {
expect(props.updateAssignmentFilter).toHaveBeenCalledWith({
label: newAssgn,
type: selected.type,
id: selected.id,
});
});
it('calls props.updateQueryParams with selected assignment id',
() => {
expect(props.updateQueryParams).toHaveBeenCalledWith({
assignment: selected.id,
});
});
it('calls props.fetchGradesIfAssignmentGradeFiltersSet', () => {
const method = props.fetchGradesIfAssignmentGradeFiltersSet;
expect(method).toHaveBeenCalledWith();
});
describe('no selected option', () => {
const value = 'fake';
beforeEach(() => {
el = mount(<AssignmentFilter {...props} />);
el.instance().handleChange({ target: { value } });
});
it('calls props.updateAssignmentFilter with selection', () => {
expect(props.updateAssignmentFilter).toHaveBeenCalledWith({
label: value,
type: undefined,
id: undefined,
});
});
it('calls props.updateQueryParams with selected assignment id',
() => {
expect(props.updateQueryParams).toHaveBeenCalledWith({
assignment: undefined,
});
});
it('calls props.fetchGradesIfAssignmentGradeFiltersSet', () => {
const method = props.fetchGradesIfAssignmentGradeFiltersSet;
expect(method).toHaveBeenCalledWith();
});
});
});
});
describe('snapshots', () => {
test('basic snapshot', () => {
const el = shallow(<AssignmentFilter {...props} />);
el.instance().handleChange = jest.fn().mockName('handleChange');
expect(el.instance().render()).toMatchSnapshot();
});
});
});
describe('mapStateToProps', () => {
const state = {
filters: {
assignment: { label: 'assigNment' },
assignmentType: 'assignMentType',
cohort: 'COhort',
track: 'traCK',
},
};
describe('assignmentFilterOptions', () => {
it('is selected from filters.selectableAssignmentLabels', () => {
expect(
mapStateToProps(state).assignmentFilterOptions,
).toEqual(
selectors.filters.selectableAssignmentLabels(state),
);
});
});
describe('selectedAssignment', () => {
it('is selected from filters.selectedAssignmentLabel', () => {
expect(
mapStateToProps(state).selectedAssignment,
).toEqual(
selectors.filters.selectedAssignmentLabel(state),
);
});
});
});
describe('mapDispatchToProps', () => {
test('updateAssignmentFilter', () => {
expect(mapDispatchToProps.updateAssignmentFilter).toEqual(
actions.filters.update.assignment,
);
});
test('fetchGradesIfAsssignmentGradeFiltersSet', () => {
const prop = mapDispatchToProps.fetchGradesIfAssignmentGradeFiltersSet;
expect(prop).toEqual(fetchGradesIfAssignmentGradeFiltersSet);
});
});
});

View File

@@ -0,0 +1,95 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AssignmentGradeFilter Component snapshots buttons and groups disabled if no selected assignment 1`] = `
<div
className="grade-filter-inputs"
>
<PercentGroup
disabled={true}
id="assignmentGradeMin"
label={
<FormattedMessage
defaultMessage="Min Grade"
description="Min-grade filter select label in Gradebook Filters"
id="gradebook.GradebookFilters.minGradeFilterLabel"
/>
}
onChange={[MockFunction handleSetMin]}
value="2"
/>
<PercentGroup
disabled={true}
id="assignmentGradeMax"
label={
<FormattedMessage
defaultMessage="Max Grade"
description="Max-grade filter select label in Gradebook Filters"
id="gradebook.GradebookFilters.maxGradeFilterLabel"
/>
}
onChange={[MockFunction handleSetMax]}
value="98"
/>
<div
className="grade-filter-action"
>
<ForwardRef
active={false}
disabled={true}
name="assignmentGradeMinMax"
onClick={[MockFunction handleSubmit]}
type="submit"
variant="outline-secondary"
>
Apply
</ForwardRef>
</div>
</div>
`;
exports[`AssignmentGradeFilter Component snapshots smoke test 1`] = `
<div
className="grade-filter-inputs"
>
<PercentGroup
disabled={false}
id="assignmentGradeMin"
label={
<FormattedMessage
defaultMessage="Min Grade"
description="Min-grade filter select label in Gradebook Filters"
id="gradebook.GradebookFilters.minGradeFilterLabel"
/>
}
onChange={[MockFunction handleSetMin]}
value="2"
/>
<PercentGroup
disabled={false}
id="assignmentGradeMax"
label={
<FormattedMessage
defaultMessage="Max Grade"
description="Max-grade filter select label in Gradebook Filters"
id="gradebook.GradebookFilters.maxGradeFilterLabel"
/>
}
onChange={[MockFunction handleSetMax]}
value="98"
/>
<div
className="grade-filter-action"
>
<ForwardRef
active={false}
disabled={false}
name="assignmentGradeMinMax"
onClick={[MockFunction handleSubmit]}
type="submit"
variant="outline-secondary"
>
Apply
</ForwardRef>
</div>
</div>
`;

View File

@@ -0,0 +1,103 @@
/* eslint-disable react/sort-comp, react/button-has-type */
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon';
import selectors from 'data/selectors';
import actions from 'data/actions';
import thunkActions from 'data/thunkActions';
import messages from '../messages';
import PercentGroup from '../PercentGroup';
export class AssignmentGradeFilter extends React.Component {
constructor(props) {
super(props);
this.handleSubmit = this.handleSubmit.bind(this);
this.handleSetMax = this.handleSetMax.bind(this);
this.handleSetMin = this.handleSetMin.bind(this);
}
handleSubmit() {
this.props.updateAssignmentLimits(this.props.localAssignmentLimits);
this.props.fetchGrades();
this.props.updateQueryParams(this.props.localAssignmentLimits);
}
handleSetMax({ target: { value } }) {
this.props.setFilter({ assignmentGradeMax: value });
}
handleSetMin({ target: { value } }) {
this.props.setFilter({ assignmentGradeMin: value });
}
render() {
const {
localAssignmentLimits: { assignmentGradeMax, assignmentGradeMin },
} = this.props;
return (
<div className="grade-filter-inputs">
<PercentGroup
id="assignmentGradeMin"
label={<FormattedMessage {...messages.minGrade} />}
value={assignmentGradeMin}
disabled={!this.props.selectedAssignment}
onChange={this.handleSetMin}
/>
<PercentGroup
id="assignmentGradeMax"
label={<FormattedMessage {...messages.maxGrade} />}
value={assignmentGradeMax}
disabled={!this.props.selectedAssignment}
onChange={this.handleSetMax}
/>
<div className="grade-filter-action">
<Button
type="submit"
variant="outline-secondary"
name="assignmentGradeMinMax"
disabled={!this.props.selectedAssignment}
onClick={this.handleSubmit}
>
Apply
</Button>
</div>
</div>
);
}
}
AssignmentGradeFilter.defaultProps = {
selectedAssignment: '',
};
AssignmentGradeFilter.propTypes = {
updateQueryParams: PropTypes.func.isRequired,
// redux
fetchGrades: PropTypes.func.isRequired,
localAssignmentLimits: PropTypes.shape({
assignmentGradeMax: PropTypes.string,
assignmentGradeMin: PropTypes.string,
}).isRequired,
selectedAssignment: PropTypes.string,
setFilter: PropTypes.func.isRequired,
updateAssignmentLimits: PropTypes.func.isRequired,
};
export const mapStateToProps = (state) => ({
localAssignmentLimits: selectors.app.assignmentGradeLimits(state),
selectedAssignment: selectors.filters.selectedAssignmentLabel(state),
});
export const mapDispatchToProps = {
fetchGrades: thunkActions.grades.fetchGrades,
setFilter: actions.app.setLocalFilter,
updateAssignmentLimits: actions.filters.update.assignmentLimits,
};
export default connect(mapStateToProps, mapDispatchToProps)(AssignmentGradeFilter);

View File

@@ -0,0 +1,143 @@
import React from 'react';
import { mount, shallow } from 'enzyme';
import actions from 'data/actions';
import selectors from 'data/selectors';
import { fetchGrades } from 'data/thunkActions/grades';
import {
AssignmentGradeFilter,
mapStateToProps,
mapDispatchToProps,
} from '.';
jest.mock('data/selectors', () => ({
__esModule: true,
default: {
app: {},
filters: {},
grades: {},
},
}));
jest.mock('data/thunkActions/grades', () => ({
fetchGrades: jest.fn(),
}));
describe('AssignmentGradeFilter', () => {
let props = {};
beforeEach(() => {
props = {
...props,
updateQueryParams: jest.fn(),
fetchGrades: jest.fn(),
localAssignmentLimits: {
assignmentGradeMax: '98',
assignmentGradeMin: '2',
},
selectedAssignment: 'Potions 101.5',
setFilter: jest.fn(),
updateAssignmentLimits: jest.fn(),
};
});
describe('Component', () => {
describe('behavior', () => {
let el;
beforeEach(() => {
el = mount(<AssignmentGradeFilter {...props} />);
});
describe('handleSubmit', () => {
beforeEach(() => {
el.instance().handleSubmit();
});
it('calls props.updateAssignmentLimits with local assignment limits', () => {
expect(props.updateAssignmentLimits).toHaveBeenCalledWith(props.localAssignmentLimits);
});
it('calls fetchUserGrades', () => {
expect(props.fetchGrades).toHaveBeenCalledWith();
});
it('updates queryParams with assignment grade min and max', () => {
expect(props.updateQueryParams).toHaveBeenCalledWith(props.localAssignmentLimits);
});
});
describe('handleSetMin', () => {
it('calls setFilters for assignmentGradeMin', () => {
const testVal = 23;
el.instance().handleSetMin({ target: { value: testVal } });
expect(props.setFilter).toHaveBeenCalledWith({
assignmentGradeMin: testVal,
});
});
});
describe('handleSetMax', () => {
it('calls setFilters for assignmentGradeMax', () => {
const testVal = 92;
el.instance().handleSetMax({ target: { value: testVal } });
expect(props.setFilter).toHaveBeenCalledWith({
assignmentGradeMax: testVal,
});
});
});
});
describe('snapshots', () => {
let el;
const mockMethods = () => {
el.instance().handleSubmit = jest.fn().mockName('handleSubmit');
el.instance().handleSetMax = jest.fn().mockName('handleSetMax');
el.instance().handleSetMin = jest.fn().mockName('handleSetMin');
};
test('smoke test', () => {
el = shallow(<AssignmentGradeFilter {...props} />);
mockMethods(el);
expect(el.instance().render()).toMatchSnapshot();
});
test('buttons and groups disabled if no selected assignment', () => {
el = shallow(<AssignmentGradeFilter
{...props}
selectedAssignment={undefined}
/>);
mockMethods(el);
expect(el.instance().render()).toMatchSnapshot();
});
});
});
describe('mapStateToProps', () => {
const testState = { belle: 'in', the: 'castle' };
let mappedProps;
beforeEach(() => {
selectors.app.assignmentGradeLimits = jest.fn((state) => ({ gradeLimits: state }));
selectors.filters.selectedAssignmentLabel = jest.fn((state) => ({ assignmentLabel: state }));
mappedProps = mapStateToProps(testState);
});
describe('localAssignmentLimits', () => {
it('returns selectors.app.assignmentGradeLimits', () => {
expect(
mappedProps.localAssignmentLimits,
).toEqual(selectors.app.assignmentGradeLimits(testState));
});
});
describe('selectedAsssignment', () => {
it('returns selectors.filters.selectedAssignmentLabel', () => {
expect(
mappedProps.selectedAssignment,
).toEqual(selectors.filters.selectedAssignmentLabel(testState));
});
});
});
describe('mapDispatchToProps', () => {
test('fetchGrades', () => {
expect(mapDispatchToProps.fetchGrades).toEqual(fetchGrades);
});
test('setFilters', () => {
expect(mapDispatchToProps.setFilter).toEqual(actions.app.setLocalFilter);
});
test('updateAssignmentLimits', () => {
expect(
mapDispatchToProps.updateAssignmentLimits,
).toEqual(
actions.filters.update.assignmentLimits,
);
});
});
});

View File

@@ -0,0 +1,79 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AssignmentTypeFilter Component snapshots SelectGroup disabled if no assignmentFilterOptions 1`] = `
<div
className="student-filters"
>
<SelectGroup
disabled={true}
id="assignment-types"
label={
<FormattedMessage
defaultMessage="Assignment Types"
description="Assignment Types filter select label in Gradebook Filters"
id="gradebook.GradebookFilters.assignmentTypesLabel"
/>
}
onChange={[MockFunction handleChange]}
options={
Array [
<option
value=""
>
All
</option>,
<option
value="assignMentType1"
>
assignMentType1
</option>,
<option
value="AssigNmentType2"
>
AssigNmentType2
</option>,
]
}
value="assigNmentType2"
/>
</div>
`;
exports[`AssignmentTypeFilter Component snapshots smoke test 1`] = `
<div
className="student-filters"
>
<SelectGroup
disabled={false}
id="assignment-types"
label={
<FormattedMessage
defaultMessage="Assignment Types"
description="Assignment Types filter select label in Gradebook Filters"
id="gradebook.GradebookFilters.assignmentTypesLabel"
/>
}
onChange={[MockFunction handleChange]}
options={
Array [
<option
value=""
>
All
</option>,
<option
value="assignMentType1"
>
assignMentType1
</option>,
<option
value="AssigNmentType2"
>
AssigNmentType2
</option>,
]
}
value="assigNmentType2"
/>
</div>
`;

View File

@@ -0,0 +1,81 @@
/* eslint-disable react/sort-comp, react/button-has-type */
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import selectors from 'data/selectors';
import actions from 'data/actions';
import SelectGroup from '../SelectGroup';
import messages from '../messages';
export class AssignmentTypeFilter extends React.Component {
constructor(props) {
super(props);
this.handleChange = this.handleChange.bind(this);
}
handleChange(event) {
const assignmentType = event.target.value;
this.props.filterAssignmentType(assignmentType);
this.props.updateQueryParams({ assignmentType });
}
get options() {
const mapper = (entry) => (
<option key={entry} value={entry}>{entry}</option>
);
return [
<option key="0" value="">All</option>,
...this.props.assignmentTypes.map(mapper),
];
}
render() {
return (
<div className="student-filters">
<SelectGroup
id="assignment-types"
label={<FormattedMessage {...messages.assignmentTypes} />}
value={this.props.selectedAssignmentType}
onChange={this.handleChange}
disabled={this.props.assignmentFilterOptions.length === 0}
options={this.options}
/>
</div>
);
}
}
AssignmentTypeFilter.defaultProps = {
assignmentTypes: [],
assignmentFilterOptions: [],
selectedAssignmentType: '',
};
AssignmentTypeFilter.propTypes = {
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,
selectedAssignmentType: PropTypes.string,
};
export const mapStateToProps = (state) => ({
assignmentTypes: selectors.assignmentTypes.allAssignmentTypes(state),
assignmentFilterOptions: selectors.filters.selectableAssignmentLabels(state),
selectedAssignmentType: selectors.filters.assignmentType(state),
});
export const mapDispatchToProps = {
filterAssignmentType: actions.filters.update.assignmentType,
};
export default connect(mapStateToProps, mapDispatchToProps)(AssignmentTypeFilter);

View File

@@ -0,0 +1,135 @@
import React from 'react';
import { shallow } from 'enzyme';
import selectors from 'data/selectors';
import actions from 'data/actions';
import {
AssignmentTypeFilter,
mapStateToProps,
mapDispatchToProps,
} from '.';
jest.mock('data/selectors', () => ({
/** Mocking to use passed state for validation purposes */
assignmentTypes: {
allAssignmentTypes: jest.fn(() => (['assignment', 'labs'])),
},
filters: {
selectableAssignmentLabels: jest.fn(() => ([{
label: 'assigNment',
subsectionLabel: 'subsection',
type: 'assignMentType',
id: 'subsectionId',
}])),
assignmentType: jest.fn(() => 'assignMentType'),
},
}));
describe('AssignmentTypeFilter', () => {
let props = {
assignmentTypes: ['assignMentType1', 'AssigNmentType2'],
assignmentFilterOptions: [
{ label: 'filterLabel1', subsectionLabel: 'filterSubLabel2' },
{ label: 'filterLabel2', subsectionLabel: 'filterSubLabel1' },
],
selectedAssignmentType: 'assigNmentType2',
};
beforeEach(() => {
props = {
...props,
filterAssignmentType: jest.fn(),
updateQueryParams: jest.fn(),
};
});
describe('Component', () => {
describe('behavior', () => {
describe('handleChange', () => {
let el;
const newType = 'new Type';
const event = { target: { value: newType } };
beforeEach(() => {
el = shallow(<AssignmentTypeFilter {...props} />);
el.instance().handleChange(event);
});
it('calls props.filterAssignmentType with new type', () => {
expect(props.filterAssignmentType).toHaveBeenCalledWith(
newType,
);
});
it('updates queryParams with assignmentType', () => {
expect(props.updateQueryParams).toHaveBeenCalledWith({
assignmentType: newType,
});
});
});
});
describe('snapshots', () => {
let el;
const mockMethods = () => {
el.instance().handleChange = jest.fn().mockName('handleChange');
};
test('smoke test', () => {
el = shallow(<AssignmentTypeFilter {...props} />);
mockMethods(el);
expect(el.instance().render()).toMatchSnapshot();
});
test('SelectGroup disabled if no assignmentFilterOptions', () => {
el = shallow(<AssignmentTypeFilter
{...props}
assignmentFilterOptions={[]}
/>);
mockMethods(el);
expect(el.instance().render()).toMatchSnapshot();
});
});
});
describe('mapStateToProps', () => {
const state = {
assignmentTypes: {
results: ['assignMentType1', 'assignMentType2'],
},
filters: {
assignmentType: 'selectedAssignMent',
cohort: 'selectedCOHOrt',
track: 'SELectedTrack',
},
};
describe('assignmentTypes', () => {
it('is selected from assignmentTypes.allAssignmentTypes', () => {
expect(
mapStateToProps(state).assignmentTypes,
).toEqual(
selectors.assignmentTypes.allAssignmentTypes(state),
);
});
});
describe('assignmentFilterOptions', () => {
it('is selected from filters.selectableAssignmentLabels', () => {
expect(
mapStateToProps(state).assignmentFilterOptions,
).toEqual(
selectors.filters.selectableAssignmentLabels(state),
);
});
});
describe('selectedAssignmentType', () => {
it('is selected from filters.assignmentType', () => {
expect(
mapStateToProps(state).selectedAssignmentType,
).toEqual(
selectors.filters.assignmentType(state),
);
});
});
});
describe('mapDispatchToProps', () => {
test('filterAssignmentType', () => {
expect(mapDispatchToProps.filterAssignmentType).toEqual(
actions.filters.update.assignmentType,
);
});
});
});

View File

@@ -0,0 +1,44 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CourseGradeFilter Component snapshots basic snapshot 1`] = `
<React.Fragment>
<div
className="grade-filter-inputs"
>
<PercentGroup
id="minimum-grade"
label={
<FormattedMessage
defaultMessage="Min Grade"
description="Min-grade filter select label in Gradebook Filters"
id="gradebook.GradebookFilters.minGradeFilterLabel"
/>
}
onChange={[MockFunction handleUpdateMin]}
value="5"
/>
<PercentGroup
id="maximum-grade"
label={
<FormattedMessage
defaultMessage="Max Grade"
description="Max-grade filter select label in Gradebook Filters"
id="gradebook.GradebookFilters.maxGradeFilterLabel"
/>
}
onChange={[MockFunction handleUpdateMax]}
value="92"
/>
</div>
<div
className="grade-filter-action"
>
<Button
onClick={[MockFunction handleApplyClick]}
variant="outline-secondary"
>
Apply
</Button>
</div>
</React.Fragment>
`;

View File

@@ -0,0 +1,103 @@
/* eslint-disable react/sort-comp, react/button-has-type, import/no-named-as-default */
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { Button } from '@edx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import selectors from 'data/selectors';
import actions from 'data/actions';
import thunkActions from 'data/thunkActions';
import messages from '../messages';
import PercentGroup from '../PercentGroup';
export class CourseGradeFilter extends React.Component {
constructor(props) {
super(props);
this.handleApplyClick = this.handleApplyClick.bind(this);
this.handleUpdateMin = this.handleUpdateMin.bind(this);
this.handleUpdateMax = this.handleUpdateMax.bind(this);
this.updateCourseGradeFilters = this.updateCourseGradeFilters.bind(this);
}
handleApplyClick() {
if (this.props.areLimitsValid) {
this.updateCourseGradeFilters();
}
}
handleUpdateMin({ target: { value } }) {
this.props.setLocalFilter({ courseGradeMin: value });
}
handleUpdateMax({ target: { value } }) {
this.props.setLocalFilter({ courseGradeMax: value });
}
updateCourseGradeFilters() {
this.props.updateFilter(this.props.localCourseLimits);
this.props.fetchGrades();
this.props.updateQueryParams(this.props.localCourseLimits);
}
render() {
const {
localCourseLimits: { courseGradeMin, courseGradeMax },
} = this.props;
return (
<>
<div className="grade-filter-inputs">
<PercentGroup
id="minimum-grade"
label={<FormattedMessage {...messages.minGrade} />}
value={courseGradeMin}
onChange={this.handleUpdateMin}
/>
<PercentGroup
id="maximum-grade"
label={<FormattedMessage {...messages.maxGrade} />}
value={courseGradeMax}
onChange={this.handleUpdateMax}
/>
</div>
<div className="grade-filter-action">
<Button
variant="outline-secondary"
onClick={this.handleApplyClick}
>
Apply
</Button>
</div>
</>
);
}
}
CourseGradeFilter.propTypes = {
updateQueryParams: PropTypes.func.isRequired,
// Redux
areLimitsValid: PropTypes.bool.isRequired,
fetchGrades: PropTypes.func.isRequired,
localCourseLimits: PropTypes.shape({
courseGradeMin: PropTypes.string.isRequired,
courseGradeMax: PropTypes.string.isRequired,
}).isRequired,
setLocalFilter: PropTypes.func.isRequired,
updateFilter: PropTypes.func.isRequired,
};
export const mapStateToProps = (state) => ({
areLimitsValid: selectors.app.areCourseGradeFiltersValid(state),
localCourseLimits: selectors.app.courseGradeLimits(state),
});
export const mapDispatchToProps = {
fetchGrades: thunkActions.grades.fetchGrades,
setLocalFilter: actions.app.setLocalFilter,
updateFilter: actions.filters.update.courseGradeLimits,
};
export default connect(mapStateToProps, mapDispatchToProps)(CourseGradeFilter);

View File

@@ -0,0 +1,150 @@
/* eslint-disable import/no-named-as-default */
import React from 'react';
import { shallow } from 'enzyme';
import actions from 'data/actions';
import selectors from 'data/selectors';
import { fetchGrades } from 'data/thunkActions/grades';
import {
CourseGradeFilter,
mapStateToProps,
mapDispatchToProps,
} from '.';
jest.mock('@edx/paragon', () => ({
Button: () => 'Button',
}));
jest.mock('../PercentGroup', () => 'PercentGroup');
jest.mock('data/thunkActions/grades', () => ({
fetchGrades: jest.fn(),
}));
jest.mock('data/selectors', () => ({
__esModule: true,
default: {
app: {
areCourseGradeFiltersValid: jest.fn(state => ({ areCourseGradeFiltersValid: state })),
courseGradeLimits: jest.fn(state => ({ courseGradeLimits: state })),
},
},
}));
describe('CourseGradeFilter', () => {
let props = {
localCourseLimits: {
courseGradeMin: '5',
courseGradeMax: '92',
},
areLimitsValid: true,
};
beforeEach(() => {
props = {
...props,
fetchGrades: jest.fn(),
setLocalFilter: jest.fn(),
updateQueryParams: jest.fn(),
updateFilter: jest.fn(),
};
});
describe('Component', () => {
describe('snapshots', () => {
test('basic snapshot', () => {
const el = shallow(<CourseGradeFilter {...props} />);
el.instance().handleUpdateMin = jest.fn().mockName(
'handleUpdateMin',
);
el.instance().handleUpdateMax = jest.fn().mockName(
'handleUpdateMax',
);
el.instance().handleApplyClick = jest.fn().mockName(
'handleApplyClick',
);
expect(el.instance().render()).toMatchSnapshot();
});
});
describe('behavior', () => {
let el;
const testVal = 'TESTvalue';
beforeEach(() => {
el = shallow(<CourseGradeFilter {...props} />);
});
describe('handleApplyClick', () => {
beforeEach(() => {
el.instance().updateCourseGradeFilters = jest.fn();
});
it('calls updateCourseGradeFilters is limits are valid', () => {
el.instance().handleApplyClick();
expect(el.instance().updateCourseGradeFilters).toHaveBeenCalledWith();
});
it('does not call updateCourseGradeFilters if limits are not valid', () => {
el.setProps({ areLimitsValid: false });
el.instance().handleApplyClick();
expect(el.instance().updateCourseGradeFilters).not.toHaveBeenCalled();
});
});
describe('updateCourseGradeFilters', () => {
beforeEach(() => {
el.instance().updateCourseGradeFilters();
});
it('calls props.updateFilter with selection', () => {
expect(props.updateFilter).toHaveBeenCalledWith(props.localCourseLimits);
});
it('calls props.getUserGrades with selection', () => {
expect(props.fetchGrades).toHaveBeenCalledWith();
});
it('updates query params with courseGradeMin and courseGradeMax', () => {
expect(props.updateQueryParams).toHaveBeenCalledWith(props.localCourseLimits);
});
});
describe('handleUpdateMin', () => {
it('calls props.setCourseGradeMin with event value', () => {
el.instance().handleUpdateMin(
{ target: { value: testVal } },
);
expect(props.setLocalFilter).toHaveBeenCalledWith({
courseGradeMin: testVal,
});
});
});
describe('handleUpdateMax', () => {
it('calls props.setCourseGradeMax with event value', () => {
el.instance().handleUpdateMax(
{ target: { value: testVal } },
);
expect(props.setLocalFilter).toHaveBeenCalledWith({
courseGradeMax: testVal,
});
});
});
});
});
describe('mapStateToProps', () => {
const testState = { peanut: 'butter', jelly: 'time' };
let mapped;
beforeEach(() => {
mapped = mapStateToProps(testState);
});
test('areLimitsValid from app.areCourseGradeFiltersValid', () => {
expect(mapped.areLimitsValid).toEqual(selectors.app.areCourseGradeFiltersValid(testState));
});
test('localCourseLimits from app.courseGradeLimits', () => {
expect(mapped.localCourseLimits).toEqual(selectors.app.courseGradeLimits(testState));
});
});
describe('mapDispatchToProps', () => {
test('fetchGrades from thunkActions.grades.fetchGrades', () => {
expect(mapDispatchToProps.fetchGrades).toEqual(fetchGrades);
});
test('setLocalFilter from actions.app.setLocalFilter', () => {
expect(mapDispatchToProps.setLocalFilter).toEqual(actions.app.setLocalFilter);
});
test('updateFilter from actions.filters.update.courseGradeLimits', () => {
expect(mapDispatchToProps.updateFilter).toEqual(actions.filters.update.courseGradeLimits);
});
});
});

View File

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

View File

@@ -0,0 +1,39 @@
/* eslint-disable react/sort-comp */
import React from 'react';
import PropTypes from 'prop-types';
import { Form } from '@edx/paragon';
const PercentGroup = ({
id,
label,
value,
disabled,
onChange,
}) => (
<div className="percent-group">
<Form.Group controlId={id}>
<Form.Label>{label}</Form.Label>
<Form.Control
type="number"
min={0}
max={100}
step={1}
{...{ value, disabled, onChange }}
/>
</Form.Group>
<span className="input-percent-label">%</span>
</div>
);
PercentGroup.defaultProps = {
disabled: false,
};
PercentGroup.propTypes = {
id: PropTypes.string.isRequired,
label: PropTypes.node.isRequired,
value: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
disabled: PropTypes.bool,
};
export default PercentGroup;

View File

@@ -0,0 +1,33 @@
import React from 'react';
import { shallow } from 'enzyme';
import PercentGroup from './PercentGroup';
describe('PercentGroup', () => {
let props = {
id: 'group id',
label: 'Group Label',
value: 'group VALUE',
disabled: false,
};
beforeEach(() => {
props = {
...props,
onChange: jest.fn().mockName('props.onChange'),
};
});
describe('Component', () => {
describe('snapshots', () => {
test('basic snapshot', () => {
const el = shallow(<PercentGroup {...props} />);
expect(el).toMatchSnapshot();
});
test('disabled', () => {
const el = shallow(<PercentGroup {...props} disabled />);
expect(el).toMatchSnapshot();
});
});
});
});

View File

@@ -0,0 +1,36 @@
/* eslint-disable react/sort-comp, react/button-has-type */
import React from 'react';
import PropTypes from 'prop-types';
import { Form } from '@edx/paragon';
const SelectGroup = ({
id,
label,
value,
onChange,
disabled,
options,
}) => (
<div className="student-filters">
<Form.Group controlId={id}>
<Form.Label>{label}</Form.Label>
<Form.Control as="select" {...{ value, onChange, disabled }}>
{options}
</Form.Control>
</Form.Group>
</div>
);
SelectGroup.propTypes = {
id: PropTypes.string.isRequired,
label: PropTypes.node.isRequired,
value: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
disabled: PropTypes.bool,
options: PropTypes.arrayOf(PropTypes.node).isRequired,
};
SelectGroup.defaultProps = {
disabled: false,
};
export default SelectGroup;

View File

@@ -0,0 +1,38 @@
import React from 'react';
import { shallow } from 'enzyme';
import SelectGroup from './SelectGroup';
describe('SelectGroup', () => {
let props = {
id: 'group id',
label: 'Group Label',
value: 'group VALUE',
disabled: false,
options: [
<option value="opt1" key="opt1">Option 1</option>,
<option value="opt2" key="opt2">Option 2</option>,
<option value="opt3" key="opt3">Option 3</option>,
],
};
beforeEach(() => {
props = {
...props,
onChange: jest.fn().mockName('props.onChange'),
};
});
describe('Component', () => {
describe('snapshots', () => {
test('basic snapshot', () => {
const el = shallow(<SelectGroup {...props} />);
expect(el).toMatchSnapshot();
});
test('disabled', () => {
const el = shallow(<SelectGroup {...props} disabled />);
expect(el).toMatchSnapshot();
});
});
});
});

View File

@@ -0,0 +1,190 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`StudentGroupsFilter Component snapshots Cohorts group disabled if no cohorts 1`] = `
<React.Fragment>
<SelectGroup
disabled={false}
id="Tracks"
label="Tracks"
onChange={[Function]}
options={
Array [
<option
value="Track-All"
>
Track-All
</option>,
<option
value="TracK1"
>
TracK1
</option>,
<option
value="TracK2"
>
TracK2
</option>,
<option
value="TRACK3"
>
TRACK3
</option>,
]
}
value="TracK2"
/>
<SelectGroup
disabled={true}
id="Cohorts"
label="Cohorts"
onChange={[Function]}
options={
Array [
<option
value="Cohort-All"
>
Cohort-All
</option>,
]
}
value="cohorT3"
/>
</React.Fragment>
`;
exports[`StudentGroupsFilter Component snapshots basic snapshot 1`] = `
<React.Fragment>
<SelectGroup
disabled={false}
id="Tracks"
label="Tracks"
onChange={[MockFunction updateTracks]}
options={
Array [
<option
value="Track-All"
>
Track-All
</option>,
<option
value="TracK1"
>
TracK1
</option>,
<option
value="TracK2"
>
TracK2
</option>,
<option
value="TRACK3"
>
TRACK3
</option>,
]
}
value="TracK2"
/>
<SelectGroup
disabled={false}
id="Cohorts"
label="Cohorts"
onChange={[MockFunction updateCohorts]}
options={
Array [
<option
value="Cohort-All"
>
Cohort-All
</option>,
<option
value="cohorT1"
>
cohorT1
</option>,
<option
value="cohorT2"
>
cohorT2
</option>,
<option
value="cohorT3"
>
cohorT3
</option>,
]
}
value="cohorT3"
/>
</React.Fragment>
`;
exports[`StudentGroupsFilter Component snapshots mapCohortsEntries cohort options: [Cohort-All, <{slug, name}...>] 1`] = `
Array [
<option
value="Cohort-All"
>
Cohort-All
</option>,
<option
value="cohorT1"
>
cohorT1
</option>,
<option
value="cohorT2"
>
cohorT2
</option>,
<option
value="cohorT3"
>
cohorT3
</option>,
]
`;
exports[`StudentGroupsFilter Component snapshots mapTracksEntries cohort options: [Track-All, <{id, name}...>] 1`] = `
Array [
<option
value="Track-All"
>
Track-All
</option>,
<option
value="TracK1"
>
TracK1
</option>,
<option
value="TracK2"
>
TracK2
</option>,
<option
value="TRACK3"
>
TRACK3
</option>,
]
`;
exports[`StudentGroupsFilter optionFactory returns a list of options with a default first entry 1`] = `
Array [
<option
value="All-Ponies"
>
All-Ponies
</option>,
<option
value="RDash"
>
RDash
</option>,
<option
value="PPie"
>
PPie
</option>,
]
`;

View File

@@ -0,0 +1,152 @@
/* eslint-disable react/sort-comp, react/button-has-type, import/no-named-as-default */
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import actions from 'data/actions';
import selectors from 'data/selectors';
import thunkActions from 'data/thunkActions';
import messages from '../messages';
import SelectGroup from '../SelectGroup';
export const optionFactory = ({ data, defaultOption, key }) => [
<option value={defaultOption} key="0">{defaultOption}</option>,
...data.map(
entry => (<option key={entry[key]} value={entry.name}>{entry.name}</option>),
),
];
export class StudentGroupsFilter extends React.Component {
constructor(props) {
super(props);
this.mapCohortsEntries = this.mapCohortsEntries.bind(this);
this.mapTracksEntries = this.mapTracksEntries.bind(this);
this.updateCohorts = this.updateCohorts.bind(this);
this.updateTracks = this.updateTracks.bind(this);
}
mapCohortsEntries() {
return optionFactory({
data: this.props.cohorts,
defaultOption: this.translate(messages.cohortAll),
key: 'id',
});
}
mapTracksEntries() {
return optionFactory({
data: this.props.tracks,
defaultOption: this.translate(messages.trackAll),
key: 'slug',
});
}
selectedTrackSlugFromEvent({ target: { value } }) {
const selectedTrackItem = this.props.tracksByName[value];
return selectedTrackItem ? selectedTrackItem.slug : null;
}
selectedCohortIdFromEvent({ target: { value } }) {
const selectedCohortItem = this.props.cohortsByName[value];
return selectedCohortItem ? selectedCohortItem.id.toString() : null;
}
updateTracks(event) {
const track = this.selectedTrackSlugFromEvent(event);
this.props.updateQueryParams({ track });
this.props.updateTrack(track);
this.props.fetchGrades();
}
updateCohorts(event) {
const cohort = this.selectedCohortIdFromEvent(event);
this.props.updateQueryParams({ cohort });
this.props.updateCohort(cohort);
this.props.fetchGrades();
}
translate(message) {
return this.props.intl.formatMessage(message);
}
render() {
return (
<>
<SelectGroup
id="Tracks"
label={this.translate(messages.tracks)}
value={this.props.selectedTrackEntry.name}
onChange={this.updateTracks}
options={this.mapTracksEntries()}
/>
<SelectGroup
id="Cohorts"
label={this.translate(messages.cohorts)}
value={this.props.selectedCohortEntry.name}
disabled={this.props.cohorts.length === 0}
onChange={this.updateCohorts}
options={this.mapCohortsEntries()}
/>
</>
);
}
}
StudentGroupsFilter.defaultProps = {
cohorts: [],
cohortsByName: {},
selectedCohortEntry: { name: '' },
selectedTrackEntry: { name: '' },
tracks: [],
tracksByName: {},
};
StudentGroupsFilter.propTypes = {
updateQueryParams: PropTypes.func.isRequired,
// injected
intl: intlShape.isRequired,
// redux
cohorts: PropTypes.arrayOf(PropTypes.shape({
name: PropTypes.string,
id: PropTypes.number,
})),
cohortsByName: PropTypes.objectOf(PropTypes.shape({
name: PropTypes.string,
id: PropTypes.number,
})),
fetchGrades: PropTypes.func.isRequired,
selectedTrackEntry: PropTypes.shape({ name: PropTypes.string }),
selectedCohortEntry: PropTypes.shape({ name: PropTypes.string }),
tracks: PropTypes.arrayOf(PropTypes.shape({
name: PropTypes.string,
slug: PropTypes.string,
})),
tracksByName: PropTypes.objectOf(PropTypes.shape({
name: PropTypes.string,
slug: PropTypes.string,
})),
updateCohort: PropTypes.func.isRequired,
updateTrack: PropTypes.func.isRequired,
};
export const mapStateToProps = (state) => ({
cohorts: selectors.cohorts.allCohorts(state),
cohortsByName: selectors.cohorts.cohortsByName(state),
selectedCohortEntry: selectors.root.selectedCohortEntry(state),
selectedTrackEntry: selectors.root.selectedTrackEntry(state),
tracks: selectors.tracks.allTracks(state),
tracksByName: selectors.tracks.tracksByName(state),
});
export const mapDispatchToProps = {
fetchGrades: thunkActions.grades.fetchGrades,
updateCohort: actions.filters.update.cohort,
updateTrack: actions.filters.update.track,
};
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(StudentGroupsFilter));

View File

@@ -0,0 +1,239 @@
/* eslint-disable import/no-named-as-default */
import React from 'react';
import { shallow } from 'enzyme';
import { fetchGrades } from 'data/thunkActions/grades';
import actions from 'data/actions';
import selectors from 'data/selectors';
import {
optionFactory,
StudentGroupsFilter,
mapStateToProps,
mapDispatchToProps,
} from '.';
jest.mock('data/selectors', () => ({
__esModule: true,
default: {
root: {
selectedCohortEntry: jest.fn(state => ({ selectedCohortEntry: state })),
selectedTrackEntry: jest.fn(state => ({ selectedTrackEntry: state })),
},
cohorts: {
allCohorts: jest.fn(state => ({ allCohorts: state })),
cohortsByName: jest.fn(state => ({ cohortsByName: state })),
},
tracks: {
allTracks: jest.fn(state => ({ allTracks: state })),
tracksByName: jest.fn(state => ({ tracksByName: state })),
},
},
}));
jest.mock('data/thunkActions/grades', () => ({
fetchGrades: jest.fn(),
}));
describe('StudentGroupsFilter', () => {
let props = {
cohorts: [
{ name: 'cohorT1', id: 8001 },
{ name: 'cohorT2', id: 8002 },
{ name: 'cohorT3', id: 8003 },
],
tracks: [
{ name: 'TracK1', slug: 'TracK1_slug' },
{ name: 'TracK2', slug: 'TracK2_slug' },
{ name: 'TRACK3', slug: 'TRACK3_slug' },
],
};
describe('optionFactory', () => {
it('returns a list of options with a default first entry', () => {
const data = [{ cMark: 'rainbow', name: 'RDash' }, { cMark: 'balloons', name: 'PPie' }];
const defaultOption = 'All-Ponies';
const key = 'cMark';
const options = optionFactory({ data, defaultOption, key });
expect(options).toMatchSnapshot();
});
});
describe('Component', () => {
beforeEach(() => {
props = {
...props,
intl: { formatMessage: (msg) => msg.defaultMessage },
cohortsByName: {
[props.cohorts[0].name]: props.cohorts[0],
[props.cohorts[1].name]: props.cohorts[1],
[props.cohorts[2].name]: props.cohorts[2],
},
tracksByName: {
[props.tracks[0].name]: props.tracks[0],
[props.tracks[1].name]: props.tracks[1],
[props.tracks[2].name]: props.tracks[2],
},
fetchGrades: jest.fn(),
selectedCohortEntry: props.cohorts[2],
selectedTrackEntry: props.tracks[1],
updateQueryParams: jest.fn(),
updateCohort: jest.fn().mockName('updateCohort'),
updateTrack: jest.fn().mockName('updateTrack'),
};
});
describe('snapshots', () => {
let el;
beforeEach(() => {
el = shallow(<StudentGroupsFilter {...props} />);
});
test('basic snapshot', () => {
el.instance().updateTracks = jest.fn().mockName(
'updateTracks',
);
el.instance().updateCohorts = jest.fn().mockName(
'updateCohorts',
);
expect(el.instance().render()).toMatchSnapshot();
});
test('Cohorts group disabled if no cohorts', () => {
el.setProps({ cohorts: [] });
expect(el.instance().render()).toMatchSnapshot();
});
describe('mapCohortsEntries', () => {
test('cohort options: [Cohort-All, <{slug, name}...>]', () => {
expect(el.instance().mapCohortsEntries()).toMatchSnapshot();
});
});
describe('mapTracksEntries', () => {
test('cohort options: [Track-All, <{id, name}...>]', () => {
expect(el.instance().mapTracksEntries()).toMatchSnapshot();
});
});
});
describe('behavior', () => {
let el;
beforeEach(() => {
el = shallow(<StudentGroupsFilter {...props} />);
});
describe('selectedCohortIdFromEvent', () => {
it('returns the id of the cohort with the name matching the event', () => {
expect(
el.instance().selectedCohortIdFromEvent(
{ target: { value: props.cohorts[1].name } },
),
).toEqual(props.cohorts[1].id.toString());
});
it('returns null if no matching cohort is found', () => {
expect(
el.instance().selectedCohortIdFromEvent(
{ target: { value: 'FAKE' } },
),
).toEqual(null);
});
});
describe('selectedTrackSlugFromEvent', () => {
it('returns the slug of the track with the name matching the event', () => {
expect(
el.instance().selectedTrackSlugFromEvent(
{ target: { value: props.tracks[1].name } },
),
).toEqual(props.tracks[1].slug);
});
it('returns null if no matching track is found', () => {
expect(
el.instance().selectedTrackSlugFromEvent(
{ target: { value: 'FAKE' } },
),
).toEqual(null);
});
});
describe('updateTracks', () => {
const selectedSlug = 'SLUG';
beforeEach(() => {
el = shallow(<StudentGroupsFilter {...props} />);
jest.spyOn(
el.instance(),
'selectedTrackSlugFromEvent',
).mockReturnValue(selectedSlug);
el.instance().updateTracks({ target: {} });
});
it('calls updateTrack with new value', () => {
expect(props.updateTrack).toHaveBeenCalledWith(selectedSlug);
});
it('calls fetchGrades', () => {
expect(props.fetchGrades).toHaveBeenCalledWith();
});
it('updates queryParams with track value', () => {
expect(props.updateQueryParams).toHaveBeenCalledWith({
track: selectedSlug,
});
});
});
describe('updateCohorts', () => {
const selectedId = 23;
beforeEach(() => {
el = shallow(<StudentGroupsFilter {...props} />);
jest.spyOn(
el.instance(),
'selectedCohortIdFromEvent',
).mockReturnValue(selectedId);
el.instance().updateCohorts({ target: {} });
});
it('calls updateCohort with new value', () => {
expect(props.updateCohort).toHaveBeenCalledWith(selectedId);
});
it('calls fetchGrades', () => {
expect(props.fetchGrades).toHaveBeenCalledWith();
});
it('updates queryParams with cohort value', () => {
expect(props.updateQueryParams).toHaveBeenCalledWith({
cohort: selectedId,
});
});
});
});
});
describe('mapStateToProps', () => {
const testState = { h: 'e', l: 'l', o: 'oooooooooo' };
let mappedProps;
beforeAll(() => {
mappedProps = mapStateToProps(testState);
});
test('cohorts from selectors.cohorts.allCohorts', () => {
expect(mappedProps.cohorts).toEqual(selectors.cohorts.allCohorts(testState));
});
test('cohortsByName from selectors.cohorts.cohortsByName', () => {
expect(mappedProps.cohortsByName).toEqual(selectors.cohorts.cohortsByName(testState));
});
test('selectedCohortEntry from selectors.root.selectedCohortEntry', () => {
expect(
mappedProps.selectedCohortEntry,
).toEqual(selectors.root.selectedCohortEntry(testState));
});
test('selectedTrackEntry from selectors.root.selectedTrackEntry', () => {
expect(
mappedProps.selectedTrackEntry,
).toEqual(selectors.root.selectedTrackEntry(testState));
});
test('tracks from selectors.tracks.allTracks', () => {
expect(mappedProps.tracks).toEqual(selectors.tracks.allTracks(testState));
});
test('tracksByName from selectors.tracks.tracksByName', () => {
expect(mappedProps.tracksByName).toEqual(selectors.tracks.tracksByName(testState));
});
});
describe('mapDispatchToProps', () => {
test('fetchGrades from thunkActions.grades.fetchGrades', () => {
expect(mapDispatchToProps.fetchGrades).toEqual(fetchGrades);
});
test('updateCohort from actions.filters.update.cohort', () => {
expect(mapDispatchToProps.updateCohort).toEqual(actions.filters.update.cohort);
});
test('updateTrack from actions.filters.update.track', () => {
expect(mapDispatchToProps.updateTrack).toEqual(actions.filters.update.track);
});
});
});

View File

@@ -0,0 +1,71 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PercentGroup Component snapshots basic snapshot 1`] = `
<div
className="percent-group"
>
<FormGroup
as="div"
controlId="group id"
isInvalid={false}
isValid={false}
>
<FormLabel
isInline={false}
>
Group Label
</FormLabel>
<ForwardRef
as="input"
disabled={false}
max={100}
min={0}
onChange={[MockFunction props.onChange]}
plaintext={false}
step={1}
type="number"
value="group VALUE"
/>
</FormGroup>
<span
className="input-percent-label"
>
%
</span>
</div>
`;
exports[`PercentGroup Component snapshots disabled 1`] = `
<div
className="percent-group"
>
<FormGroup
as="div"
controlId="group id"
isInvalid={false}
isValid={false}
>
<FormLabel
isInline={false}
>
Group Label
</FormLabel>
<ForwardRef
as="input"
disabled={true}
max={100}
min={0}
onChange={[MockFunction props.onChange]}
plaintext={false}
step={1}
type="number"
value="group VALUE"
/>
</FormGroup>
<span
className="input-percent-label"
>
%
</span>
</div>
`;

View File

@@ -0,0 +1,91 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SelectGroup Component snapshots basic snapshot 1`] = `
<div
className="student-filters"
>
<FormGroup
as="div"
controlId="group id"
isInvalid={false}
isValid={false}
>
<FormLabel
isInline={false}
>
Group Label
</FormLabel>
<ForwardRef
as="select"
disabled={false}
onChange={[MockFunction props.onChange]}
plaintext={false}
value="group VALUE"
>
<option
key="opt1"
value="opt1"
>
Option 1
</option>
<option
key="opt2"
value="opt2"
>
Option 2
</option>
<option
key="opt3"
value="opt3"
>
Option 3
</option>
</ForwardRef>
</FormGroup>
</div>
`;
exports[`SelectGroup Component snapshots disabled 1`] = `
<div
className="student-filters"
>
<FormGroup
as="div"
controlId="group id"
isInvalid={false}
isValid={false}
>
<FormLabel
isInline={false}
>
Group Label
</FormLabel>
<ForwardRef
as="select"
disabled={true}
onChange={[MockFunction props.onChange]}
plaintext={false}
value="group VALUE"
>
<option
key="opt1"
value="opt1"
>
Option 1
</option>
<option
key="opt2"
value="opt2"
>
Option 2
</option>
<option
key="opt3"
value="opt3"
>
Option 3
</option>
</ForwardRef>
</FormGroup>
</div>
`;

View File

@@ -0,0 +1,98 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`GradebookFilters Component snapshots basic snapshot 1`] = `
<React.Fragment>
<div
className="filter-sidebar-header"
>
<h2>
<Icon
className="fa fa-filter"
/>
</h2>
<IconButton
alt="Close Filters"
aria-label="Close Filters"
className="p-1"
iconAs="Icon"
onClick={[MockFunction this.props.closeMenu]}
src="paragon.icons.Close"
/>
</div>
<Collapsible
className="filter-group mb-3"
defaultOpen={true}
title={
<FormattedMessage
defaultMessage="Assignments"
description="Assignment filter group label in Gradebook Filters"
id="gradebook.GradebookFilters.assignmentsFilterLabel"
/>
}
>
<div>
<AssignmentTypeFilter
updateQueryParams={[MockFunction this.props.updateQueryParams]}
/>
<AssignmentFilter
updateQueryParams={[MockFunction this.props.updateQueryParams]}
/>
<AssignmentGradeFilter
updateQueryParams={[MockFunction this.props.updateQueryParams]}
/>
</div>
</Collapsible>
<Collapsible
className="filter-group mb-3"
defaultOpen={true}
title={
<FormattedMessage
defaultMessage="Overall Grade"
description="Overall Grade filter group label in Gradebook Filters"
id="gradebook.GradebookFilters.overallGradeFilterLabel"
/>
}
>
<CourseGradeFilter
updateQueryParams={[MockFunction this.props.updateQueryParams]}
/>
</Collapsible>
<Collapsible
className="filter-group mb-3"
defaultOpen={true}
title={
<FormattedMessage
defaultMessage="Student Groups"
description="Student Groups filter group label in Gradebook Filters"
id="gradebook.GradebookFilters.studentGroupsFilterLabel"
/>
}
>
<StudentGroupsFilter
updateQueryParams={[MockFunction this.props.updateQueryParams]}
/>
</Collapsible>
<Collapsible
className="filter-group mb-3"
defaultOpen={true}
title={
<FormattedMessage
defaultMessage="Include Course Team Members"
description="Include Course Team Members filter label in Gradebook Filters"
id="gradebook.GradebookFilters.includeCourseTeamMembersFilterLabel"
/>
}
>
<Checkbox
checked={true}
onChange={[MockFunction handleIncludeTeamMembersChange]}
>
<FormattedMessage
defaultMessage="Include Course Team Members"
description="Include Course Team Members filter label in Gradebook Filters"
id="gradebook.GradebookFilters.includeCourseTeamMembersFilterLabel"
/>
</Checkbox>
</Collapsible>
</React.Fragment>
`;

View File

@@ -0,0 +1,124 @@
/* eslint-disable react/sort-comp, import/no-named-as-default */
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import {
Collapsible,
Icon,
IconButton,
Form,
} from '@edx/paragon';
import { Close } from '@edx/paragon/icons';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import actions from 'data/actions';
import selectors from 'data/selectors';
import thunkActions from 'data/thunkActions';
import messages from './messages';
import AssignmentTypeFilter from './AssignmentTypeFilter';
import AssignmentFilter from './AssignmentFilter';
import AssignmentGradeFilter from './AssignmentGradeFilter';
import CourseGradeFilter from './CourseGradeFilter';
import StudentGroupsFilter from './StudentGroupsFilter';
export class GradebookFilters extends React.Component {
constructor(props) {
super(props);
this.state = {
includeCourseRoleMembers: this.props.includeCourseRoleMembers,
};
this.handleIncludeTeamMembersChange = this.handleIncludeTeamMembersChange.bind(this);
}
handleIncludeTeamMembersChange(event) {
const includeCourseRoleMembers = event.target.checked;
this.setState({ includeCourseRoleMembers });
this.props.updateIncludeCourseRoleMembers(includeCourseRoleMembers);
this.props.fetchGrades();
this.props.updateQueryParams({ includeCourseRoleMembers });
}
collapsibleGroup = (title, content) => (
<Collapsible
title={<FormattedMessage {...title} />}
defaultOpen
className="filter-group mb-3"
>
{content}
</Collapsible>
);
render() {
const {
intl,
updateQueryParams,
} = this.props;
return (
<>
<div className="filter-sidebar-header">
<h2><Icon className="fa fa-filter" /></h2>
<IconButton
className="p-1"
onClick={this.props.closeMenu}
iconAs={Icon}
src={Close}
alt={intl.formatMessage(messages.closeFilters)}
aria-label={intl.formatMessage(messages.closeFilters)}
/>
</div>
{this.collapsibleGroup(messages.assignments, (
<div>
<AssignmentTypeFilter updateQueryParams={updateQueryParams} />
<AssignmentFilter updateQueryParams={updateQueryParams} />
<AssignmentGradeFilter updateQueryParams={updateQueryParams} />
</div>
))}
{this.collapsibleGroup(messages.overallGrade, (
<CourseGradeFilter updateQueryParams={updateQueryParams} />
))}
{this.collapsibleGroup(messages.studentGroups, (
<StudentGroupsFilter updateQueryParams={updateQueryParams} />
))}
{this.collapsibleGroup(messages.includeCourseTeamMembers, (
<Form.Checkbox
checked={this.state.includeCourseRoleMembers}
onChange={this.handleIncludeTeamMembersChange}
>
<FormattedMessage {...messages.includeCourseTeamMembers} />
</Form.Checkbox>
))}
</>
);
}
}
GradebookFilters.defaultProps = {
includeCourseRoleMembers: false,
};
GradebookFilters.propTypes = {
updateQueryParams: PropTypes.func.isRequired,
// injected
intl: intlShape.isRequired,
// redux
closeMenu: PropTypes.func.isRequired,
fetchGrades: PropTypes.func.isRequired,
includeCourseRoleMembers: PropTypes.bool,
updateIncludeCourseRoleMembers: PropTypes.func.isRequired,
};
export const mapStateToProps = (state) => ({
includeCourseRoleMembers: selectors.filters.includeCourseRoleMembers(state),
});
export const mapDispatchToProps = {
closeMenu: thunkActions.app.filterMenu.close,
fetchGrades: thunkActions.grades.fetchGrades,
updateIncludeCourseRoleMembers: actions.filters.update.includeCourseRoleMembers,
};
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(GradebookFilters));

View File

@@ -0,0 +1,71 @@
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',
},
});
export default messages;

View File

@@ -0,0 +1,126 @@
import React from 'react';
import { shallow } from 'enzyme';
import actions from 'data/actions';
import selectors from 'data/selectors';
import thunkActions from 'data/thunkActions';
import {
GradebookFilters,
mapStateToProps,
mapDispatchToProps,
} from '.';
jest.mock('@edx/paragon', () => ({
Collapsible: 'Collapsible',
Form: {
Checkbox: 'Checkbox',
},
Icon: 'Icon',
IconButton: 'IconButton',
}));
jest.mock('@edx/paragon/icons', () => ({
Close: 'paragon.icons.Close',
}));
jest.mock('./AssignmentTypeFilter', () => 'AssignmentTypeFilter');
jest.mock('./AssignmentFilter', () => 'AssignmentFilter');
jest.mock('./AssignmentGradeFilter', () => 'AssignmentGradeFilter');
jest.mock('./CourseGradeFilter', () => 'CourseGradeFilter');
jest.mock('./StudentGroupsFilter', () => 'StudentGroupsFilter');
jest.mock('data/selectors', () => ({
__esModule: true,
default: {
filters: {
includeCourseRoleMembers: jest.fn((state) => ({ includeCourseRoleMembers: state })),
},
},
}));
jest.mock('data/thunkActions', () => ({
__esModule: true,
default: {
app: { filterMenu: { close: jest.fn() } },
grades: { fetchGrades: jest.fn() },
},
}));
describe('GradebookFilters', () => {
let props = {
includeCourseRoleMembers: true,
};
beforeEach(() => {
props = {
...props,
intl: { formatMessage: (msg) => msg.defaultMessage },
closeMenu: jest.fn().mockName('this.props.closeMenu'),
fetchGrades: jest.fn(),
updateIncludeCourseRoleMembers: jest.fn(),
updateQueryParams: jest.fn().mockName('this.props.updateQueryParams'),
};
});
describe('Component', () => {
describe('behavior', () => {
describe('handleIncludeTeamMembersChange', () => {
let el;
beforeEach(() => {
el = shallow(<GradebookFilters {...props} />);
el.instance().setState = jest.fn();
});
it('calls setState with newVal', () => {
el.instance().handleIncludeTeamMembersChange(
{ target: { checked: true } },
);
expect(
el.instance().setState,
).toHaveBeenCalledWith({ includeCourseRoleMembers: true });
});
it('calls props.updateIncludeCourseRoleMembers with newVal', () => {
el.instance().handleIncludeTeamMembersChange(
{ target: { checked: false } },
);
expect(
props.updateIncludeCourseRoleMembers,
).toHaveBeenCalledWith(false);
});
it('calls props.updateQueryParams with newVal', () => {
el.instance().handleIncludeTeamMembersChange(
{ target: { checked: true } },
);
expect(
props.updateQueryParams,
).toHaveBeenCalledWith({ includeCourseRoleMembers: true });
});
});
});
describe('snapshots', () => {
test('basic snapshot', () => {
const el = shallow(<GradebookFilters {...props} />);
el.instance().handleIncludeTeamMembersChange = jest.fn().mockName(
'handleIncludeTeamMembersChange',
);
expect(el.instance().render()).toMatchSnapshot();
});
});
});
describe('mapStateToProps', () => {
const testState = { A: 'laska' };
test('includeCourseRoleMembers from filters.includeCourseRoleMembers', () => {
expect(
mapStateToProps(testState).includeCourseRoleMembers,
).toEqual(selectors.filters.includeCourseRoleMembers(testState));
});
});
describe('mapDispatchToProps', () => {
test('fetchGrades from thunkActions.grades.fetchGrades', () => {
expect(mapDispatchToProps.fetchGrades).toEqual(thunkActions.grades.fetchGrades);
});
describe('updateIncludeCourseRoleMembers', () => {
test('from actions.filters.update.includeCourseRoleMembers', () => {
expect(mapDispatchToProps.updateIncludeCourseRoleMembers).toEqual(
actions.filters.update.includeCourseRoleMembers,
);
});
});
});
});

View File

@@ -0,0 +1,261 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`GradebookHeader component snapshots default values (grades frozen, cannot view). unauthorized warning, but no grades frozen warning 1`] = `
<div
className="gradebook-header"
>
<a
className="mb-3"
href="http://localhost:18000/courses/fakeID/instructor"
>
<span
aria-hidden="true"
>
&lt;&lt;
</span>
<FormattedMessage
defaultMessage="Back to Dashboard"
description="Button text to take user back to LMS dashboard in Gradebook Header"
id="gradebook.GradebookHeader.backButton"
/>
</a>
<h1>
<FormattedMessage
defaultMessage="Gradebook"
description="Top-level app title in Gradebook Header component"
id="gradebook.GradebookHeader.appLabel"
/>
</h1>
<div
className="subtitle-row d-flex justify-content-between align-items-center"
>
<h3>
fakeID
</h3>
</div>
<div
className="alert alert-warning"
role="alert"
>
<FormattedMessage
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"
id="gradebook.GradebookHeader.unauthorizedWarning"
/>
</div>
</div>
`;
exports[`GradebookHeader component snapshots grades frozen, can view. grades frozen warning but no unauthorized warning 1`] = `
<div
className="gradebook-header"
>
<a
className="mb-3"
href="http://localhost:18000/courses/fakeID/instructor"
>
<span
aria-hidden="true"
>
&lt;&lt;
</span>
<FormattedMessage
defaultMessage="Back to Dashboard"
description="Button text to take user back to LMS dashboard in Gradebook Header"
id="gradebook.GradebookHeader.backButton"
/>
</a>
<h1>
<FormattedMessage
defaultMessage="Gradebook"
description="Top-level app title in Gradebook Header component"
id="gradebook.GradebookHeader.appLabel"
/>
</h1>
<div
className="subtitle-row d-flex justify-content-between align-items-center"
>
<h3>
fakeID
</h3>
</div>
<div
className="alert alert-warning"
role="alert"
>
<FormattedMessage
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"
id="gradebook.GradebookHeader.frozenWarning"
/>
</div>
</div>
`;
exports[`GradebookHeader component snapshots grades frozen, cannot view unauthorized warning, and grades frozen warning. 1`] = `
<div
className="gradebook-header"
>
<a
className="mb-3"
href="http://localhost:18000/courses/fakeID/instructor"
>
<span
aria-hidden="true"
>
&lt;&lt;
</span>
<FormattedMessage
defaultMessage="Back to Dashboard"
description="Button text to take user back to LMS dashboard in Gradebook Header"
id="gradebook.GradebookHeader.backButton"
/>
</a>
<h1>
<FormattedMessage
defaultMessage="Gradebook"
description="Top-level app title in Gradebook Header component"
id="gradebook.GradebookHeader.appLabel"
/>
</h1>
<div
className="subtitle-row d-flex justify-content-between align-items-center"
>
<h3>
fakeID
</h3>
</div>
<div
className="alert alert-warning"
role="alert"
>
<FormattedMessage
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"
id="gradebook.GradebookHeader.frozenWarning"
/>
</div>
<div
className="alert alert-warning"
role="alert"
>
<FormattedMessage
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"
id="gradebook.GradebookHeader.unauthorizedWarning"
/>
</div>
</div>
`;
exports[`GradebookHeader component snapshots show bulk management, active view is bulkManagementHistory view toggle view button to grades 1`] = `
<div
className="gradebook-header"
>
<a
className="mb-3"
href="http://localhost:18000/courses/fakeID/instructor"
>
<span
aria-hidden="true"
>
&lt;&lt;
</span>
<FormattedMessage
defaultMessage="Back to Dashboard"
description="Button text to take user back to LMS dashboard in Gradebook Header"
id="gradebook.GradebookHeader.backButton"
/>
</a>
<h1>
<FormattedMessage
defaultMessage="Gradebook"
description="Top-level app title in Gradebook Header component"
id="gradebook.GradebookHeader.appLabel"
/>
</h1>
<div
className="subtitle-row d-flex justify-content-between align-items-center"
>
<h3>
fakeID
</h3>
<Button
onClick={[MockFunction this.handleToggleViewClick]}
variant="tertiary"
>
<FormattedMessage
defaultMessage="Return to Gradebook"
description="Button text for button navigating to Grades view."
id="gradebook.GradebookHeader.toGradesView"
/>
</Button>
</div>
<div
className="alert alert-warning"
role="alert"
>
<FormattedMessage
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"
id="gradebook.GradebookHeader.unauthorizedWarning"
/>
</div>
</div>
`;
exports[`GradebookHeader component snapshots show bulk management, active view is grades view toggle view button to activity log 1`] = `
<div
className="gradebook-header"
>
<a
className="mb-3"
href="http://localhost:18000/courses/fakeID/instructor"
>
<span
aria-hidden="true"
>
&lt;&lt;
</span>
<FormattedMessage
defaultMessage="Back to Dashboard"
description="Button text to take user back to LMS dashboard in Gradebook Header"
id="gradebook.GradebookHeader.backButton"
/>
</a>
<h1>
<FormattedMessage
defaultMessage="Gradebook"
description="Top-level app title in Gradebook Header component"
id="gradebook.GradebookHeader.appLabel"
/>
</h1>
<div
className="subtitle-row d-flex justify-content-between align-items-center"
>
<h3>
fakeID
</h3>
<Button
onClick={[MockFunction this.handleToggleViewClick]}
variant="tertiary"
>
<FormattedMessage
defaultMessage="View Bulk Management History"
description="Button text for button navigating to Bulk Managment Activity Log"
id="gradebook.GradebookHeader.toActivityLogButton"
/>
</Button>
</div>
<div
className="alert alert-warning"
role="alert"
>
<FormattedMessage
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"
id="gradebook.GradebookHeader.unauthorizedWarning"
/>
</div>
</div>
`;

View File

@@ -0,0 +1,106 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon';
import { configuration } from 'config';
import { views } from 'data/constants/app';
import actions from 'data/actions';
import selectors from 'data/selectors';
import messages from './messages';
export class GradebookHeader extends React.Component {
constructor(props) {
super(props);
this.handleToggleViewClick = this.handleToggleViewClick.bind(this);
}
get toggleViewMessage() {
return this.props.activeView === views.grades
? messages.toActivityLog
: messages.toGradesView;
}
lmsInstructorDashboardUrl = courseId => (
`${configuration.LMS_BASE_URL}/courses/${courseId}/instructor`
);
handleToggleViewClick() {
const newView = this.props.activeView === views.grades ? views.bulkManagementHistory : views.grades;
this.props.setView(newView);
}
render() {
return (
<div className="gradebook-header">
<a
href={this.lmsInstructorDashboardUrl(this.props.courseId)}
className="mb-3"
>
<span aria-hidden="true">{'<< '}</span>
<FormattedMessage {...messages.backToDashboard} />
</a>
<h1>
<FormattedMessage {...messages.gradebook} />
</h1>
<div className="subtitle-row d-flex justify-content-between align-items-center">
<h3>{this.props.courseId}</h3>
{ this.props.showBulkManagement && (
<Button
variant="tertiary"
onClick={this.handleToggleViewClick}
>
<FormattedMessage {...this.toggleViewMessage} />
</Button>
)}
</div>
{this.props.areGradesFrozen
&& (
<div className="alert alert-warning" role="alert">
<FormattedMessage {...messages.frozenWarning} />
</div>
)}
{(this.props.canUserViewGradebook === false) && (
<div className="alert alert-warning" role="alert">
<FormattedMessage {...messages.unauthorizedWarning} />
</div>
)}
</div>
);
}
}
GradebookHeader.defaultProps = {
// redux
courseId: '',
areGradesFrozen: false,
canUserViewGradebook: false,
showBulkManagement: false,
};
GradebookHeader.propTypes = {
// redux
activeView: PropTypes.string.isRequired,
courseId: PropTypes.string,
areGradesFrozen: PropTypes.bool,
canUserViewGradebook: PropTypes.bool,
setView: PropTypes.func.isRequired,
showBulkManagement: PropTypes.bool,
};
export const mapStateToProps = (state) => ({
activeView: selectors.app.activeView(state),
courseId: selectors.app.courseId(state),
areGradesFrozen: selectors.assignmentTypes.areGradesFrozen(state),
canUserViewGradebook: selectors.roles.canUserViewGradebook(state),
showBulkManagement: selectors.root.showBulkManagement(state),
});
export const mapDispatchToProps = {
setView: actions.app.setView,
};
export default connect(mapStateToProps, mapDispatchToProps)(GradebookHeader);

View File

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

@@ -0,0 +1,152 @@
import React from 'react';
import { shallow } from 'enzyme';
import { Button } from '@edx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import actions from 'data/actions';
import selectors from 'data/selectors';
import { views } from 'data/constants/app';
import messages from './messages';
import { GradebookHeader, mapDispatchToProps, mapStateToProps } from '.';
jest.mock('@edx/paragon', () => ({
Button: () => 'Button',
}));
jest.mock('@edx/frontend-platform/i18n', () => ({
defineMessages: m => m,
FormattedMessage: () => 'FormattedMessage',
}));
jest.mock('data/actions', () => ({
__esModule: true,
default: {
app: { setView: jest.fn() },
},
}));
jest.mock('data/selectors', () => ({
__esModule: true,
default: {
app: {
activeView: jest.fn(state => ({ aciveView: state })),
courseId: jest.fn(state => ({ courseId: state })),
},
assignmentTypes: { areGradesFrozen: jest.fn(state => ({ areGradesFrozen: state })) },
roles: { canUserViewGradebook: jest.fn(state => ({ canUserViewGradebook: state })) },
root: { showBulkManagement: jest.fn(state => ({ showBulkManagement: state })) },
},
}));
const courseId = 'fakeID';
describe('GradebookHeader component', () => {
const props = {
activeView: views.grades,
areGradesFrozen: false,
canUserViewGradebook: false,
courseId,
showBulkManagement: false,
};
beforeEach(() => {
props.setView = jest.fn();
});
describe('snapshots', () => {
let el;
beforeEach(() => {
el = shallow(<GradebookHeader {...props} />);
el.instance().handleToggleViewClick = jest.fn().mockName('this.handleToggleViewClick');
});
describe('default values (grades frozen, cannot view).', () => {
test('unauthorized warning, but no grades frozen warning', () => {
expect(el.instance().render()).toMatchSnapshot();
});
});
describe('grades frozen, cannot view', () => {
test('unauthorized warning, and grades frozen warning.', () => {
el.setProps({ areGradesFrozen: true });
expect(el.instance().render()).toMatchSnapshot();
});
});
describe('grades frozen, can view.', () => {
test('grades frozen warning but no unauthorized warning', () => {
el.setProps({ areGradesFrozen: true, canUserViewGradebook: true });
expect(el.instance().render()).toMatchSnapshot();
});
});
describe('show bulk management, active view is grades view', () => {
test('toggle view button to activity log', () => {
el.setProps({ showBulkManagement: true });
expect(el.find(Button).getElement()).toEqual((
<Button
variant="tertiary"
onClick={el.instance().handleToggleViewClick}
>
<FormattedMessage {...messages.toActivityLog} />
</Button>
));
expect(el.instance().render()).toMatchSnapshot();
});
});
describe('show bulk management, active view is bulkManagementHistory view', () => {
test('toggle view button to grades', () => {
el.setProps({ showBulkManagement: true, activeView: views.bulkManagementHistory });
expect(el.find(Button).getElement()).toEqual((
<Button
variant="tertiary"
onClick={el.instance().handleToggleViewClick}
>
<FormattedMessage {...messages.toGradesView} />
</Button>
));
expect(el.instance().render()).toMatchSnapshot();
});
});
});
describe('behavior', () => {
let el;
beforeEach(() => {
el = shallow(<GradebookHeader {...props} />);
});
describe('handleToggleViewClick', () => {
test('calls setView with activity view if activeView is grades', () => {
el.instance().handleToggleViewClick();
expect(props.setView).toHaveBeenCalledWith(views.bulkManagementHistory);
});
test('calls setView with grades view if activeView is bulkManagementHistory', () => {
el.setProps({ activeView: views.bulkManagementHistory });
el.instance().handleToggleViewClick();
expect(props.setView).toHaveBeenCalledWith(views.grades);
});
});
});
describe('mapStateToProps', () => {
let mapped;
const testState = { a: 'test', example: 'state' };
beforeEach(() => {
mapped = mapStateToProps(testState);
});
test('activeView from app.activeView', () => {
expect(mapped.activeView).toEqual(selectors.app.activeView(testState));
});
test('courseId from app.courseId', () => {
expect(mapped.courseId).toEqual(selectors.app.courseId(testState));
});
test('areGradesFrozen from assignmentTypes selector', () => {
expect(
mapped.areGradesFrozen,
).toEqual(selectors.assignmentTypes.areGradesFrozen(testState));
});
test('canUserViewGradebook from roles selector', () => {
expect(
mapped.canUserViewGradebook,
).toEqual(selectors.roles.canUserViewGradebook(testState));
});
test('showBulkManagement from root showBulkManagement selector', () => {
expect(mapped.showBulkManagement).toEqual(selectors.root.showBulkManagement(testState));
});
});
describe('mapDispatchToProps', () => {
test('setView from actions.app.setView', () => {
expect(mapDispatchToProps.setView).toEqual(actions.app.setView);
});
});
});

View File

@@ -0,0 +1,71 @@
/* eslint-disable react/sort-comp, react/button-has-type */
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { views } from 'data/constants/app';
import actions from 'data/actions';
import selectors from 'data/selectors';
import NetworkButton from 'components/NetworkButton';
import ImportGradesButton from './ImportGradesButton';
import messages from './BulkManagementControls.messages';
/**
* <BulkManagementControls />
* Provides download buttons for Bulk Management and Intervention reports, only if
* showBulkManagement is set in redus.
*/
export class BulkManagementControls extends React.Component {
constructor(props) {
super(props);
this.handleClickExportGrades = this.handleClickExportGrades.bind(this);
this.handleViewActivityLog = this.handleViewActivityLog.bind(this);
}
handleClickExportGrades() {
this.props.downloadBulkGradesReport();
window.location.assign(this.props.gradeExportUrl);
}
handleViewActivityLog() {
this.props.setView(views.bulkManagementHistory);
}
render() {
return this.props.showBulkManagement && (
<div className="d-flex">
<NetworkButton
label={messages.downloadGradesBtn}
onClick={this.handleClickExportGrades}
/>
<ImportGradesButton />
</div>
);
}
}
BulkManagementControls.defaultProps = {
showBulkManagement: false,
};
BulkManagementControls.propTypes = {
// redux
downloadBulkGradesReport: PropTypes.func.isRequired,
gradeExportUrl: PropTypes.string.isRequired,
showBulkManagement: PropTypes.bool,
setView: PropTypes.func.isRequired,
};
export const mapStateToProps = (state) => ({
gradeExportUrl: selectors.root.gradeExportUrl(state),
showBulkManagement: selectors.root.showBulkManagement(state),
});
export const mapDispatchToProps = {
downloadBulkGradesReport: actions.grades.downloadReport.bulkGrades,
setView: actions.app.setView,
};
export default connect(mapStateToProps, mapDispatchToProps)(BulkManagementControls);

View File

@@ -0,0 +1,11 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
downloadGradesBtn: {
id: 'gradebook.GradesView.BulkManagementControls.bulkManagementLabel',
defaultMessage: 'Download Grades',
description: 'A labeled button that allows an admin user to download course grades all at once (in bulk).',
},
});
export default messages;

View File

@@ -0,0 +1,124 @@
import React from 'react';
import { shallow } from 'enzyme';
import actions from 'data/actions';
import selectors from 'data/selectors';
import { views } from 'data/constants/app';
import {
BulkManagementControls,
mapStateToProps,
mapDispatchToProps,
} from './BulkManagementControls';
jest.mock('./ImportGradesButton', () => 'ImportGradesButton');
jest.mock('components/NetworkButton', () => 'NetworkButton');
jest.mock('data/selectors', () => ({
__esModule: true,
default: {
root: {
gradeExportUrl: (state) => ({ gradeExportUrl: state }),
interventionExportUrl: (state) => ({ interventionExportUrl: state }),
showBulkManagement: (state) => ({ showBulkManagement: state }),
},
},
}));
jest.mock('data/actions', () => ({
__esModule: true,
default: {
app: { setView: jest.fn() },
grades: {
downloadReport: {
bulkGrades: jest.fn(),
intervention: jest.fn(),
},
},
},
}));
describe('BulkManagementControls', () => {
describe('component', () => {
let el;
let props = {
gradeExportUrl: 'gradesGoHere',
interventionExportUrl: 'interventionsGoHere',
};
beforeEach(() => {
props = {
...props,
downloadBulkGradesReport: jest.fn(),
downloadInterventionReport: jest.fn(),
setView: jest.fn(),
};
});
test('snapshot - empty if showBulkManagement is not truthy', () => {
expect(shallow(<BulkManagementControls {...props} />)).toEqual({});
});
describe('behavior', () => {
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();
el = shallow(<BulkManagementControls {...props} showBulkManagement />);
});
afterAll(() => {
// restore `window.location` to the `jsdom` `Location` object
window.location = oldWindowLocation;
});
describe('handleViewActivityLog', () => {
it('calls props.setView(views.bulkManagementHistory)', () => {
el.instance().handleViewActivityLog();
expect(props.setView).toHaveBeenCalledWith(views.bulkManagementHistory);
});
});
describe('handleClickExportGrades', () => {
const assertions = [
'calls props.downloadBulkGradesReport',
'sets location to props.gradeExportUrl',
];
it(assertions.join(' and '), () => {
el.instance().handleClickExportGrades();
expect(props.downloadBulkGradesReport).toHaveBeenCalled();
expect(window.location.assign).toHaveBeenCalledWith(props.gradeExportUrl);
});
});
});
});
describe('mapStateToProps', () => {
let mapped;
const testState = { do: 'not', test: 'me' };
beforeEach(() => {
mapped = mapStateToProps(testState);
});
test('gradeExportUrl from root.gradeExportUrl', () => {
expect(mapped.gradeExportUrl).toEqual(selectors.root.gradeExportUrl(testState));
});
test('showBulkManagement from root.showBulkManagement', () => {
expect(mapped.showBulkManagement).toEqual(selectors.root.showBulkManagement(testState));
});
});
describe('mapDispatchToProps', () => {
test('downloadBulkGradesReport from actions.grades.downloadReport.bulkGrades', () => {
expect(
mapDispatchToProps.downloadBulkGradesReport,
).toEqual(actions.grades.downloadReport.bulkGrades);
});
test('setView from actions.app.setView', () => {
expect(mapDispatchToProps.setView).toEqual(actions.app.setView);
});
});
});

View File

@@ -0,0 +1,26 @@
import React from 'react';
import PropTypes from 'prop-types';
/**
* HistoryHeader
* simple display container for an individual history table header
* @param {string} id - header id
* @param {string} label - header label
* @param {string} value - header value
*/
const HistoryHeader = ({ id, label, value }) => (
<div>
<div className={`grade-history-header grade-history-${id}`}>{label}: </div>
<div>{value}</div>
</div>
);
HistoryHeader.defaultProps = {
value: null,
};
HistoryHeader.propTypes = {
id: PropTypes.string.isRequired,
label: PropTypes.node.isRequired,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
};
export default HistoryHeader;

View File

@@ -0,0 +1,17 @@
import React from 'react';
import { shallow } from 'enzyme';
import HistoryHeader from './HistoryHeader';
describe('HistoryHeader', () => {
const props = {
id: 'water',
label: 'Brita',
value: 'hydration',
};
describe('Component', () => {
test('snapshot', () => {
expect(shallow(<HistoryHeader {...props} />)).toMatchSnapshot();
});
});
});

View File

@@ -0,0 +1,68 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import selectors from 'data/selectors';
import messages from './messages';
import HistoryHeader from './HistoryHeader';
/**
* <ModalHeaders />
* Provides a list of HistoryHeaders for the student name, assignment,
* original grade, and current override grade.
*/
export const ModalHeaders = ({
modalState,
originalGrade,
currentGrade,
}) => (
<div>
<HistoryHeader
id="assignment"
label={<FormattedMessage {...messages.assignmentHeader} />}
value={modalState.assignmentName}
/>
<HistoryHeader
id="student"
label={<FormattedMessage {...messages.studentHeader} />}
value={modalState.updateUserName}
/>
<HistoryHeader
id="original-grade"
label={<FormattedMessage {...messages.originalGradeHeader} />}
value={originalGrade}
/>
<HistoryHeader
id="current-grade"
label={<FormattedMessage {...messages.currentGradeHeader} />}
value={currentGrade}
/>
</div>
);
ModalHeaders.defaultProps = {
currentGrade: null,
originalGrade: null,
};
ModalHeaders.propTypes = {
// redux
currentGrade: PropTypes.number,
originalGrade: PropTypes.number,
modalState: PropTypes.shape({
assignmentName: PropTypes.string.isRequired,
updateUserName: PropTypes.string,
}).isRequired,
};
export const mapStateToProps = (state) => ({
modalState: {
assignmentName: selectors.app.modalState.assignmentName(state),
updateUserName: selectors.app.modalState.updateUserName(state),
},
currentGrade: selectors.grades.gradeOverrideCurrentEarnedGradedOverride(state),
originalGrade: selectors.grades.gradeOriginalEarnedGraded(state),
});
export default connect(mapStateToProps)(ModalHeaders);

View File

@@ -0,0 +1,94 @@
import React from 'react';
import { shallow } from 'enzyme';
import selectors from 'data/selectors';
import {
ModalHeaders,
mapStateToProps,
} from './ModalHeaders';
jest.mock('./HistoryHeader', () => 'HistoryHeader');
jest.mock('data/selectors', () => ({
__esModule: true,
default: {
app: {
editUpdateData: jest.fn(state => ({ editUpdateData: state })),
modalState: {
assignmentName: jest.fn(state => ({ assignmentName: state })),
updateUserName: jest.fn(state => ({ updateUserName: state })),
},
},
grades: {
gradeOverrideCurrentEarnedGradedOverride: jest.fn(state => ({ currentGrade: state })),
gradeOriginalEarnedGraded: jest.fn(state => ({ originalGrade: state })),
},
},
}));
describe('ModalHeaders', () => {
let el;
const props = {
currentGrade: 2,
originalGrade: 20,
modalState: {
assignmentName: 'Qwerty',
updateUserName: 'Uiop',
},
};
describe('Component', () => {
describe('snapshots', () => {
beforeEach(() => {
});
describe('gradeOverrideHistoryError is and empty and open is true', () => {
test('modal open and StatusAlert showing', () => {
el = shallow(<ModalHeaders {...props} />);
expect(el).toMatchSnapshot();
});
});
describe('gradeOverrideHistoryError is empty and open is false', () => {
test('modal closed and StatusAlert closed', () => {
el = shallow(
<ModalHeaders {...props} open={false} gradeOverrideHistoryError="" />,
);
expect(el).toMatchSnapshot();
});
});
});
});
describe('mapStateToProps', () => {
const testState = { he: 'lives in a', pineapple: 'under the sea' };
let mapped;
beforeEach(() => {
mapped = mapStateToProps(testState);
});
describe('modalState', () => {
test('assignmentName from app.modalState.assignmentName', () => {
expect(
mapped.modalState.assignmentName,
).toEqual(selectors.app.modalState.assignmentName(testState));
});
test('updateUserName from app.modalState.updateUserName', () => {
expect(
mapped.modalState.updateUserName,
).toEqual(selectors.app.modalState.updateUserName(testState));
});
});
describe('originalGrade', () => {
test('from grades.gradeOverrideCurrentEarnedGradedOverride', () => {
expect(mapped.currentGrade).toEqual(
selectors.grades.gradeOverrideCurrentEarnedGradedOverride(testState),
);
});
});
describe('originalGrade', () => {
test('from grades.gradeOriginalEarnedGrades', () => {
expect(mapped.originalGrade).toEqual(
selectors.grades.gradeOriginalEarnedGraded(testState),
);
});
});
});
});

View File

@@ -0,0 +1,77 @@
/* eslint-disable react/sort-comp, react/button-has-type */
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { Form } from '@edx/paragon';
import selectors from 'data/selectors';
import actions from 'data/actions';
/**
* <AdjustedGradeInput />
* Input control for adjusting the grade of a unit
* displays an "/ ${possibleGrade} if there is one in the data model.
*/
export class AdjustedGradeInput extends React.Component {
constructor(props) {
super(props);
this.onChange = this.onChange.bind(this);
}
onChange = ({ target }) => {
let adjustedGradeValue;
switch (true) {
case target.value < 0:
adjustedGradeValue = 0;
break;
case this.props.possibleGrade && target.value > this.props.possibleGrade:
adjustedGradeValue = this.props.possibleGrade;
break;
default:
adjustedGradeValue = target.value;
}
this.props.setModalState({ adjustedGradeValue });
};
render() {
return (
<span>
<Form.Control
type="number"
name="adjustedGradeValue"
min="0"
max={this.props.possibleGrade ? this.props.possibleGrade : ''}
value={this.props.value}
onChange={this.onChange}
/>
{this.props.possibleGrade && ` / ${this.props.possibleGrade}`}
</span>
);
}
}
AdjustedGradeInput.defaultProps = {
possibleGrade: null,
};
AdjustedGradeInput.propTypes = {
value: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
]).isRequired,
possibleGrade: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
]),
setModalState: PropTypes.func.isRequired,
};
export const mapStateToProps = (state) => ({
possibleGrade: selectors.root.editModalPossibleGrade(state),
value: selectors.app.modalState.adjustedGradeValue(state),
});
export const mapDispatchToProps = {
setModalState: actions.app.setModalState,
};
export default connect(mapStateToProps, mapDispatchToProps)(AdjustedGradeInput);

View File

@@ -0,0 +1,117 @@
import React from 'react';
import { shallow } from 'enzyme';
import actions from 'data/actions';
import selectors from 'data/selectors';
import {
AdjustedGradeInput,
mapStateToProps,
mapDispatchToProps,
} from './AdjustedGradeInput';
jest.mock('@edx/paragon', () => ({
Form: { Control: () => 'Form.Control' },
}));
jest.mock('data/selectors', () => ({
__esModule: true,
default: {
root: {
editModalPossibleGrade: jest.fn(state => ({ updateUserName: state })),
},
app: {
modalState: { adjustedGradeValue: jest.fn(state => ({ adjustedGradeValue: state })) },
},
},
}));
jest.mock('data/actions', () => ({
__esModule: true,
default: {
app: { setModalState: jest.fn() },
},
}));
describe('AdjustedGradeInput', () => {
let el;
let props = {
value: 1,
possibleGrade: 5,
};
beforeEach(() => {
props = {
...props,
setModalState: jest.fn(),
};
});
describe('Component', () => {
beforeEach(() => {
el = shallow(<AdjustedGradeInput {...props} />);
});
describe('snapshots', () => {
test('displays input control and "out of possible grade" label', () => {
el.instance().onChange = jest.fn().mockName('this.onChange');
expect(el.instance().render()).toMatchSnapshot();
});
});
describe('behavior', () => {
describe('onChange', () => {
it('calls props.setModalState event target value with correct value', () => {
const value = 3;
el.instance().onChange({ target: { value } });
expect(props.setModalState).toHaveBeenCalledWith({
adjustedGradeValue: value,
});
});
it('calls props.setModalState event target value with a value more then the possibleGrade value', () => {
const value = 42;
el.instance().onChange({ target: { value } });
expect(props.setModalState).toHaveBeenCalledWith({
adjustedGradeValue: props.possibleGrade,
});
});
it('calls props.setModalState event target value with less then 0', () => {
const value = -5;
el.instance().onChange({ target: { value } });
expect(props.setModalState).toHaveBeenCalledWith({
adjustedGradeValue: 0,
});
});
it('calls props.setModalState event target value without possibleGrade value', () => {
const value = 100;
const newEl = shallow(<AdjustedGradeInput {...props} possibleGrade={null} />);
newEl.instance().onChange({ target: { value } });
expect(props.setModalState).toHaveBeenCalledWith({
adjustedGradeValue: value,
});
});
});
});
});
describe('mapStateToProps', () => {
const testState = { like: 'no one', ever: 'was' };
let mapped;
beforeEach(() => {
mapped = mapStateToProps(testState);
});
describe('modalState', () => {
test('possibleGrade from root.editModalPossibleGrade', () => {
expect(
mapped.possibleGrade,
).toEqual(selectors.root.editModalPossibleGrade(testState));
});
test('updateUserName from app.modalState.updateUserName', () => {
expect(
mapped.value,
).toEqual(selectors.app.modalState.adjustedGradeValue(testState));
});
});
});
describe('mapDispatchToProps', () => {
test('setModalState from actions.app.setModalState', () => {
expect(mapDispatchToProps.setModalState).toEqual(actions.app.setModalState);
});
});
});

View File

@@ -0,0 +1,55 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { Form } from '@edx/paragon';
import selectors from 'data/selectors';
import actions from 'data/actions';
/**
* <ReasonInput />
* Input control for the "reason for change" field in the Edit modal.
*/
export class ReasonInput extends React.Component {
constructor(props) {
super(props);
this.ref = React.createRef();
this.onChange = this.onChange.bind(this);
}
componentDidMount() {
this.ref.current.focus();
}
onChange = (event) => {
this.props.setModalState({ reasonForChange: event.target.value });
};
render() {
return (
<Form.Control
type="text"
name="reasonForChange"
value={this.props.value}
onChange={this.onChange}
ref={this.ref}
/>
);
}
}
ReasonInput.propTypes = {
// redux
setModalState: PropTypes.func.isRequired,
value: PropTypes.string.isRequired,
};
export const mapStateToProps = (state) => ({
value: selectors.app.modalState.reasonForChange(state),
});
export const mapDispatchToProps = {
setModalState: actions.app.setModalState,
};
export default connect(mapStateToProps, mapDispatchToProps)(ReasonInput);

View File

@@ -0,0 +1,90 @@
import React from 'react';
import { shallow } from 'enzyme';
import actions from 'data/actions';
import selectors from 'data/selectors';
import {
ReasonInput,
mapStateToProps,
mapDispatchToProps,
} from './ReasonInput';
jest.mock('@edx/paragon', () => ({
Form: { Control: () => 'Form.Control' },
}));
jest.mock('data/selectors', () => ({
__esModule: true,
default: {
app: {
modalState: { reasonForChange: jest.fn(state => ({ reasonForChange: state })) },
},
},
}));
jest.mock('data/actions', () => ({
__esModule: true,
default: {
app: { setModalState: jest.fn() },
},
}));
describe('ReasonInput', () => {
let el;
let props = {
value: 'did not answer the question',
};
beforeEach(() => {
props = {
...props,
setModalState: jest.fn(),
};
});
describe('Component', () => {
beforeEach(() => {
el = shallow(<ReasonInput {...props} />, { disableLifecycleMethods: true });
});
describe('snapshots', () => {
test('displays reason for change input control', () => {
el.instance().onChange = jest.fn().mockName('this.onChange');
expect(el.instance().render()).toMatchSnapshot();
});
});
describe('behavior', () => {
describe('onChange', () => {
it('calls props.setModalState event target value', () => {
const value = 42;
el.instance().onChange({ target: { value } });
expect(props.setModalState).toHaveBeenCalledWith({
reasonForChange: value,
});
});
});
describe('componentDidMount', () => {
it('focuses the input ref', () => {
const focus = jest.fn();
expect(el.instance().ref).toEqual({ current: null });
el.instance().ref.current = { focus };
el.instance().componentDidMount();
expect(el.instance().ref.current.focus).toHaveBeenCalledWith();
});
});
});
});
describe('mapStateToProps', () => {
const testState = { to: { catchThem: 'my real test', trainThem: 'my cause!' } };
let mapped;
beforeEach(() => {
mapped = mapStateToProps(testState);
});
describe('modalState', () => {
test('value from app.modalState.reasonForChange', () => {
expect(mapped.value).toEqual(selectors.app.modalState.reasonForChange(testState));
});
});
});
describe('mapDispatchToProps', () => {
test('setModalState from actions.app.setModalState', () => {
expect(mapDispatchToProps.setModalState).toEqual(actions.app.setModalState);
});
});
});

View File

@@ -0,0 +1,15 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AdjustedGradeInput Component snapshots displays input control and "out of possible grade" label 1`] = `
<span>
<Control
max={5}
min="0"
name="adjustedGradeValue"
onChange={[MockFunction this.onChange]}
type="number"
value={1}
/>
/ 5
</span>
`;

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