Compare commits

..

114 Commits

Author SHA1 Message Date
Ben Warzeski
8e19b5774c fix: merge cruft leftover file 2023-05-15 11:32:42 -04:00
Ben Warzeski
f7a4309888 fix: table tests 2023-05-15 11:16:48 -04:00
Omar Al-Ithawi
3e3a73e2bb feat: use atlas in make pull_translations (#325)
Changes
-------
 - Bump frontend-platform to bring `intl-imports.js` script
 - Move all i18n imports into `src/i18n/index.js` so `intl-imports.js` can
   override it with latest translations
 - Add `atlas` into `make pull_translations` when `OPENEDX_ATLAS_PULL`
   environment variable is set.
 - Refactored i18n utils into own file to avoid overwriting them by
   atlas

Refs: [FC-0012 project](https://openedx.atlassian.net/l/cp/XGS0iCcQ) implementing Translation Infrastructure OEP-58.
2023-05-15 10:58:11 -04:00
Ben Warzeski
af8d7182ef fix: date test 2023-05-15 10:57:48 -04:00
Ben Warzeski
d898a9cc2f fix: null renders 2023-05-15 10:57:25 -04:00
Ben Warzeski
f4b839f4d8 fix: lint 2023-05-15 10:57:24 -04:00
Ben Warzeski
f41b237d08 fix: redux transform tests 2023-05-15 10:57:24 -04:00
Ben Warzeski
6dd2fb3dd6 refactor: GradebookFilters component modernization 2023-05-15 10:57:23 -04:00
Ben Warzeski
b173681edb refactor: GradesView component modernization 2023-05-15 10:57:23 -04:00
Ben Warzeski
5fcde3b9e8 refactor: ImportSuccessToast component modernization 2023-05-15 10:57:22 -04:00
Ben Warzeski
35ee68ea9d refactor: SearchControls component modernization 2023-05-15 10:57:22 -04:00
Ben Warzeski
dde8e759b6 refactor: ScoreViewInput component modernization 2023-05-15 10:57:22 -04:00
Ben Warzeski
6b149e9ce0 refactor: PageButtons component modernization 2023-05-15 10:57:21 -04:00
Ben Warzeski
4cf5ba7a07 refactor: FilteredUsersLabel component modernization 2023-05-15 10:57:21 -04:00
Ben Warzeski
7a506324a8 refactor: FilterMenuToggle component modernization 2023-05-15 10:57:20 -04:00
Ben Warzeski
7f54cc4917 refactor: FilterBadges component modernization 2023-05-15 10:57:20 -04:00
Ben Warzeski
134ace9483 refactor: GradebookTable component modernization 2023-05-15 10:57:18 -04:00
Ben Warzeski
0e6f52fca9 refactor: EditModal component updates 2023-05-15 10:50:31 -04:00
Ben Warzeski
ca64cc614a feat: redux hooks 2023-05-15 10:50:19 -04:00
Ben Warzeski
1ad297c46c refactor: testing architecture updates 2023-05-15 10:50:17 -04:00
Ben Warzeski
f2bb0d7c2a refactor: interventsions report component 2023-05-15 10:49:45 -04:00
Ben Warzeski
f76f3d64c9 refactor: bulk management controls component 2023-05-15 10:49:45 -04:00
Ben Warzeski
db56d76d37 refactor: gradebook header component refactor 2023-05-15 10:49:44 -04:00
jszewczulak
ffecce993e feat: added "Get Feedback" widget (#330) 2023-05-11 09:53:48 -04:00
Omar Al-Ithawi
ae1702d182 feat: use atlas in make pull_translations (#325)
Changes
-------
 - Bump frontend-platform to bring `intl-imports.js` script
 - Move all i18n imports into `src/i18n/index.js` so `intl-imports.js` can
   override it with latest translations
 - Add `atlas` into `make pull_translations` when `OPENEDX_ATLAS_PULL`
   environment variable is set.
 - Refactored i18n utils into own file to avoid overwriting them by
   atlas

Refs: [FC-0012 project](https://openedx.atlassian.net/l/cp/XGS0iCcQ) implementing Translation Infrastructure OEP-58.
2023-05-09 10:32:40 -04:00
Peter Kulko
67789481fb chore: use openedx brand as a default theme (#327) 2023-05-04 21:22:46 +03:00
Jansen Kantor
543cd623e1 feat: add column for full name for masters students (#321)
* feat: add column for full name for masters students

* refactor: move masters asterisk out of messages file

* refactor: simpletext -> text

* refactor: asterisk const
2023-04-28 13:04:31 -04:00
dependabot[bot]
ba31b713e2 build(deps): bump webpack from 5.75.0 to 5.79.0
Bumps [webpack](https://github.com/webpack/webpack) from 5.75.0 to 5.79.0.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Commits](https://github.com/webpack/webpack/compare/v5.75.0...v5.79.0)

---
updated-dependencies:
- dependency-name: webpack
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-04-28 10:42:41 -04:00
jansenk
84fe2c6628 chore: update browserslist DB 2023-04-28 10:34:43 -04:00
Leangseu Kim
b87447b543 fix: file input handler 2023-04-26 15:32:02 -04:00
leangseu-edx
541a661dcc Lk/fix filter (#320)
* fix: cohort and track filtering

* chore: normalize using response.data instead of chaining
2023-03-23 11:16:23 -04:00
Leangseu Kim
abc68f4224 chore: Update transifex api from v2 to v3 2023-03-07 10:00:49 -05:00
Leangseu Kim
14a5d4f849 chore: network button take message object not translated string 2023-03-06 14:35:52 -05:00
leangseu-edx
b0e173dbba fix: export selector grade url (#315) 2023-03-06 12:41:31 -05:00
Ben Warzeski
e1c8b01531 refactor: update GradebookFilter components to function component (#308)
* feat: update assignmetn filter component to function component

* chore: redux hook tests

* fix: tests

* chore: update more components to hooks

* chore: moar componentsAssignment

* chore: moar component (StudentGroupsFilter)

* chore: gradebook filter component update

* chore: fix a few places of typo

* chore: use global store for dispatch to avoid out of component

* chore: compromise the test string for local machine

* chore: linting

* test: add more tests for coverage 100%

* chore: linting

* chore: add coverage badge on readme

* chore: bump package version

---------

Co-authored-by: Leangseu Kim <lkim@edx.org>
2023-03-06 11:02:30 -05:00
Feanil Patel
182dc396d5 build: Updating a missing workflow file add-depr-ticket-to-depr-board.yml.
The .github/workflows/add-depr-ticket-to-depr-board.yml workflow is missing or needs an update to stay in
sync with the current standard for this workflow as defined in the
`.github` repo of the `openedx` GitHub org.
2023-02-28 10:33:37 -05:00
Feanil Patel
e191aa9717 build: Creating a missing workflow file add-remove-label-on-comment.yml.
The .github/workflows/add-remove-label-on-comment.yml workflow is missing or needs an update to stay in
sync with the current standard for this workflow as defined in the
`.github` repo of the `openedx` GitHub org.
2023-02-28 10:33:37 -05:00
Feanil Patel
401916471b build: Creating a missing workflow file self-assign-issue.yml.
The .github/workflows/self-assign-issue.yml workflow is missing or needs an update to stay in
sync with the current standard for this workflow as defined in the
`.github` repo of the `openedx` GitHub org.
2023-02-28 10:33:37 -05:00
jansenk
cee43bddcb chore: update browserslist DB 2023-02-22 16:12:22 -05:00
Bilal Qamar
6be4aac16e refactor: upgraded frontend-build version to v12
PR #254
2023-01-26 09:57:49 -03:00
Eugene Dyudyunov
85607d7e97 fix: correct grades minmax values (#277)
When the assignment type is selected, but assignment id isn't - the
courseGradeMax, courseGradeMin assignmentGradeMax and assignmentGradeMin
values become nullable. This leads to incorrect filtering results.

Fix:
- Preserve the courseGradeMax and courseGradeMin values in such case;
2023-01-24 13:50:57 -05:00
dependabot[bot]
af1b82bc1a build(deps): bump cookiejar from 2.1.3 to 2.1.4 (#306)
Bumps [cookiejar](https://github.com/bmeck/node-cookiejar) from 2.1.3 to 2.1.4.
- [Release notes](https://github.com/bmeck/node-cookiejar/releases)
- [Commits](https://github.com/bmeck/node-cookiejar/commits)

---
updated-dependencies:
- dependency-name: cookiejar
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-24 13:41:53 -05:00
dependabot[bot]
7923f77d8b build(deps): bump minimatch and recursive-readdir (#296)
Bumps [minimatch](https://github.com/isaacs/minimatch) and [recursive-readdir](https://github.com/jergason/recursive-readdir). These dependencies needed to be updated together.

Updates `minimatch` from 3.0.4 to 3.1.2
- [Release notes](https://github.com/isaacs/minimatch/releases)
- [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/minimatch/compare/v3.0.4...v3.1.2)

Updates `recursive-readdir` from 2.2.2 to 2.2.3
- [Release notes](https://github.com/jergason/recursive-readdir/releases)
- [Changelog](https://github.com/jergason/recursive-readdir/blob/master/CHANGELOG.md)
- [Commits](https://github.com/jergason/recursive-readdir/commits/v2.2.3)

---
updated-dependencies:
- dependency-name: minimatch
  dependency-type: indirect
- dependency-name: recursive-readdir
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-23 16:38:48 -05:00
Jansen Kantor
b84186ab0c style: improve slash and percent sign i18n (#276)
* style: slight cleanup of slash and percent sign i18n

* test: add test for helper methods
2023-01-23 16:37:51 -05:00
Emad Rad
0c4675cfa2 Feature: Persian language Support (#279)
* feat: fa_IR code added to transifex_langs

* feat: Persian language fa-ir added to messages

* feat: Persian translations added

* refactor: reorder imports alphabetically
2023-01-23 10:21:59 -05:00
Jansen Kantor
607b47be24 chore: remove util package as webpack 5 does not like (#304)
Co-authored-by: Ben Warzeski <bwarzeski@edx.org>
2023-01-20 15:02:26 -05:00
Matt Hughes
f6b2902914 Fix aXe output for improperly incrementing header numbers (#138)
* fix: aXe output for improperly incrementing header numbers

* fix: updated branch and moved change into new files

* test: update snapshots

Co-authored-by: jansenk <jkantor@edx.org>
2023-01-20 10:49:41 -05:00
dependabot[bot]
b682b91f0a build(deps): bump node-notifier and jest (#302)
Removes [node-notifier](https://github.com/mikaelbr/node-notifier). It's no longer used after updating ancestor dependency [jest](https://github.com/facebook/jest/tree/HEAD/packages/jest). These dependencies need to be updated together.


Removes `node-notifier`

Updates `jest` from 24.9.0 to 29.3.1
- [Release notes](https://github.com/facebook/jest/releases)
- [Changelog](https://github.com/facebook/jest/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/jest/commits/v29.3.1/packages/jest)

---
updated-dependencies:
- dependency-name: node-notifier
  dependency-type: indirect
- dependency-name: jest
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-19 17:05:34 -05:00
Sarina Canelake
fcb4248521 Fix github url strings (org edx -> openedx) (#261)
* fix: fix github url strings (org edx -> openedx)

* fix: update path to .github workflows to read from openedx org
2023-01-19 16:54:52 -05:00
Muhammad Abdullah Waheed
23dfed82d0 Automate Browserlist DB Update (#274)
* feat: added cron github action to auto update brwoserlist DB periodically

* refactor: used a shared script to update broswerslist DB, create PR and automerge it
2023-01-19 16:14:08 -05:00
dependabot[bot]
d0ab0eca8f build(deps-dev): bump semantic-release from 17.4.7 to 19.0.3
Bumps [semantic-release](https://github.com/semantic-release/semantic-release) from 17.4.7 to 19.0.3.
- [Release notes](https://github.com/semantic-release/semantic-release/releases)
- [Commits](https://github.com/semantic-release/semantic-release/compare/v17.4.7...v19.0.3)

---
updated-dependencies:
- dependency-name: semantic-release
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-01-06 15:42:32 -05:00
dependabot[bot]
c690dde838 build(deps): bump semver-regex from 3.1.3 to 3.1.4
Bumps [semver-regex](https://github.com/sindresorhus/semver-regex) from 3.1.3 to 3.1.4.
- [Release notes](https://github.com/sindresorhus/semver-regex/releases)
- [Commits](https://github.com/sindresorhus/semver-regex/commits/v3.1.4)

---
updated-dependencies:
- dependency-name: semver-regex
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-01-06 13:45:28 -05:00
Leangseu Kim
011737b492 chore: DataTable Empty expect string as content 2023-01-05 17:24:07 -05:00
Leangseu Kim
524116a601 fix: grade modal always showing 2023-01-05 17:24:07 -05:00
Leangseu Kim
db5414e97f chore: update paragon 2023-01-05 17:24:07 -05:00
Leangseu Kim
456edd453e build: use shared browserslist config 2023-01-05 17:24:07 -05:00
dependabot[bot]
ecceda2343 build(deps): bump got and @edx/frontend-build (#294)
* build(deps): bump async from 2.6.3 to 2.6.4

Bumps [async](https://github.com/caolan/async) from 2.6.3 to 2.6.4.
- [Release notes](https://github.com/caolan/async/releases)
- [Changelog](https://github.com/caolan/async/blob/v2.6.4/CHANGELOG.md)
- [Commits](https://github.com/caolan/async/compare/v2.6.3...v2.6.4)

---
updated-dependencies:
- dependency-name: async
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore: add eslint

* chore: update commend

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Leangseu Kim <lkim@edx.org>
2023-01-04 15:58:47 -05:00
dependabot[bot]
f5706635e0 build(deps): bump async from 2.6.3 to 2.6.4
Bumps [async](https://github.com/caolan/async) from 2.6.3 to 2.6.4.
- [Release notes](https://github.com/caolan/async/releases)
- [Changelog](https://github.com/caolan/async/blob/v2.6.4/CHANGELOG.md)
- [Commits](https://github.com/caolan/async/compare/v2.6.3...v2.6.4)

---
updated-dependencies:
- dependency-name: async
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-01-04 15:08:48 -05:00
dependabot[bot]
933f6c0a6f build(deps): bump eventsource and react-dev-utils
Bumps [eventsource](https://github.com/EventSource/eventsource) to 2.0.2 and updates ancestor dependency [react-dev-utils](https://github.com/facebook/create-react-app/tree/HEAD/packages/react-dev-utils). These dependencies need to be updated together.


Updates `eventsource` from 0.1.6 to 2.0.2
- [Release notes](https://github.com/EventSource/eventsource/releases)
- [Changelog](https://github.com/EventSource/eventsource/blob/master/HISTORY.md)
- [Commits](https://github.com/EventSource/eventsource/compare/v0.1.6...v2.0.2)

Updates `react-dev-utils` from 5.0.3 to 12.0.1
- [Release notes](https://github.com/facebook/create-react-app/releases)
- [Changelog](https://github.com/facebook/create-react-app/blob/main/CHANGELOG-1.x.md)
- [Commits](https://github.com/facebook/create-react-app/commits/react-dev-utils@12.0.1/packages/react-dev-utils)

---
updated-dependencies:
- dependency-name: eventsource
  dependency-type: indirect
- dependency-name: react-dev-utils
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-01-04 15:08:08 -05:00
dependabot[bot]
229033b742 build(deps): bump json5 from 1.0.1 to 1.0.2
Bumps [json5](https://github.com/json5/json5) from 1.0.1 to 1.0.2.
- [Release notes](https://github.com/json5/json5/releases)
- [Changelog](https://github.com/json5/json5/blob/main/CHANGELOG.md)
- [Commits](https://github.com/json5/json5/compare/v1.0.1...v1.0.2)

---
updated-dependencies:
- dependency-name: json5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-01-04 15:00:43 -05:00
dependabot[bot]
da447b12ed build(deps): bump terser from 4.8.0 to 4.8.1
Bumps [terser](https://github.com/terser/terser) from 4.8.0 to 4.8.1.
- [Release notes](https://github.com/terser/terser/releases)
- [Changelog](https://github.com/terser/terser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/terser/terser/commits)

---
updated-dependencies:
- dependency-name: terser
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-01-04 14:59:37 -05:00
dependabot[bot]
c7b5979067 build(deps-dev): bump axios from 0.21.1 to 0.21.2
Bumps [axios](https://github.com/axios/axios) from 0.21.1 to 0.21.2.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/master/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v0.21.1...v0.21.2)

---
updated-dependencies:
- dependency-name: axios
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-01-04 14:54:30 -05:00
dependabot[bot]
8bf130b099 build(deps): bump loader-utils from 1.4.0 to 1.4.2
Bumps [loader-utils](https://github.com/webpack/loader-utils) from 1.4.0 to 1.4.2.
- [Release notes](https://github.com/webpack/loader-utils/releases)
- [Changelog](https://github.com/webpack/loader-utils/blob/v1.4.2/CHANGELOG.md)
- [Commits](https://github.com/webpack/loader-utils/compare/v1.4.0...v1.4.2)

---
updated-dependencies:
- dependency-name: loader-utils
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-01-04 14:53:09 -05:00
dependabot[bot]
9d442b0edb build(deps): bump decode-uri-component from 0.2.0 to 0.2.2
Bumps [decode-uri-component](https://github.com/SamVerschueren/decode-uri-component) from 0.2.0 to 0.2.2.
- [Release notes](https://github.com/SamVerschueren/decode-uri-component/releases)
- [Commits](https://github.com/SamVerschueren/decode-uri-component/compare/v0.2.0...v0.2.2)

---
updated-dependencies:
- dependency-name: decode-uri-component
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-01-04 14:52:04 -05:00
Muhammad Abdullah Waheed
84cdacd4e8 feat: added new translations in Makefile
And updated all the translations themselves.

Issue: https://github.com/openedx/frontend-wg/issues/119
PR: https://github.com/openedx/frontend-app-gradebook/pull/260
2022-12-09 07:45:06 -03:00
Ghassan Maslamani
4fcc3f863f feat: ensure lms api synced with latest value in config
This change make it possible if LMS url to be changed, that
  the last value will be picked.

  This is simlair openedx/frontend-app-course-authoring/pull/389
  which issue overhangio/tutor-mfe/issues/86, the fixes is needed
  so that dynamic config would work with tutor:
  overhangio/tutor-mfe/pull/69
2022-12-08 18:21:19 +00:00
mashal-m
79679c23f2 refactor: migrate off paragon modal derpecation 2022-11-30 14:05:03 +00:00
mashal-m
9b2436991b refactor: migrate off paragon modal derpecation 2022-11-30 14:05:03 +00:00
edX requirements bot
c95f2d6b22 fix: -t flag added in pull translation command (#285) 2022-11-30 16:46:27 +05:00
Kyle McCormick
4f43e65f03 fix: use getConfig in order to support runtime configuration (#286)
Before, gradebook was reading config from `process.env`
directly, which locked the app into using only
static (build-time) configuration. In order to
enable dynamic (runtime) configuration, we update
gradebook to use frontend-platform's standard
configuration interface: `mergeConfig()` and `getConfig()`.

Bumps version from 1.5.0 to 1.6.0.
(I would normally just do a patch release for a fix, but the version
 was hasn't been bumped for a while, so adding in full runtime
 configuration support seemed like it warranted a proper
 minor version bump.)

Co-authored-by: Ghassan Maslamani <ghassan.maslamani@gmail.com>
2022-11-28 11:14:32 -05:00
ihor-romaniuk
50bf7d236a fix: add an asterisk to the 'Email' name of column 2022-11-23 10:45:36 -05:00
Diana Olarte
d2723e5bc1 feat: allow runtime configuration (#253)
Allows frontend-app-gradebook to be configured at
runtime using the LMS's new MFE Configuration API.

Part of https://github.com/openedx/frontend-wg/issues/103
2022-10-17 12:12:42 -04:00
Simon Chen
03fa143fc1 Merge pull request #266 from fennec-tech/rtl-use-backslash-to-write-fractions
RTL: use backslash to write fractions (grades)
2022-09-29 15:02:24 -04:00
Simon Chen
075846f869 Merge pull request #265 from fennec-tech/rtl-fix-percentage-direction
RTL: fix (%) symbol to follow text direction
2022-09-29 14:49:14 -04:00
Abderraouf Mehdi Bouhali
1208d27d92 fix(rtl): use backslash to write fractions (grades) 2022-09-29 11:34:30 +01:00
Abderraouf Mehdi Bouhali
e345716bd4 fix(rtl): force % symbol to follow text direction 2022-09-29 10:11:26 +01:00
Maman Khan
2121a63c83 Removing codecov and coveralls packages (#245)
* fix: removed coveralls and codecov packages with update in ci uploader

* fix: pinning npm to version 8.5.x

Co-authored-by: Abdullah Waheed <abdullah.waheed@arbisoft.com>
2022-09-28 10:47:57 -04:00
Matt Hughes
47cab71b3c fix: do not squish the table with word wrapping
we should handle word wrapping down at the level of specific cells
which we can opt-in to non-default word-wrapping
2022-07-12 10:54:00 -04:00
Leangseu Kim
2d8af2ec00 fix: update and resolve dependencies 2022-07-07 13:23:23 -04:00
Leangseu Kim
d55abbe91e fix: search blur state for every single charater input 2022-07-07 10:05:35 -04:00
Abdullah Waheed
a75f365bdd refactor: migrated StatusAlert to Alert in GradesBook component
refactor: migrated StatusAlert to Alert in StatusAlerts component

refactor: updated unit tests mocks to set snapshot issues
2022-06-30 21:35:01 -04:00
Muhammad Abdullah Waheed
bbb7e895a5 Paragon table deprecation updation (#246)
* refactor: updated table deprecation of HistoryTable

* refactor: updated table deprecation of OverrideTable

* refactor: updated table deprecation of GradebookTable

* refactor: updated unit tests of OverrideTable according to new changes

* fix: error for css

* fix: strict dictionary error for react component

* chore: update css and handle syntax errors

* chore: update unit test

* fix: remove datatable row status and update tests

* fix: test coverage

Co-authored-by: Leangseu Kim <lkim@edx.org>
Co-authored-by: Ben Warzeski <bwarzeski@edx.org>
2022-06-30 14:22:29 -04:00
Muhammad Abdullah Waheed
bf70fd1450 fix: added specific npm version to fix peerDependency issue (#247) 2022-06-16 15:06:30 +05:00
Jawayria
af2ece8290 Merge pull request #242 from openedx/jenkins/version-check-c6a4685
feat: Add package-lock file version check
2022-05-06 15:56:23 +05:00
edX requirements bot
620827d772 feat: Add package-lock file version check 2022-04-29 08:48:48 -04:00
edX requirements bot
c6a4685bf5 chore!: Dropped support for Node 12 (#239)
* chore!: Dropped support for Node 12

* fix: dropped Node 12

Co-authored-by: Jawayria <39649635+Jawayria@users.noreply.github.com>
2022-04-14 12:53:54 -04:00
Leangseu Kim
8dd2237f9c chore: update package lock after update to node 16 2022-04-12 10:36:36 -04: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
366 changed files with 49057 additions and 29435 deletions

4
.env
View File

@@ -30,4 +30,6 @@ ENTERPRISE_MARKETING_URL=''
ENTERPRISE_MARKETING_UTM_SOURCE=''
ENTERPRISE_MARKETING_UTM_CAMPAIGN=''
ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM=''
BULK_MANAGEMENT_SPECIAL_ACCESS_COURSE_IDS=''
APP_ID=''
MFE_CONFIG_API_URL=''
DISPLAY_FEEDBACK_WIDGET='true'

View File

@@ -23,6 +23,7 @@ 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'
@@ -36,4 +37,6 @@ ENTERPRISE_MARKETING_URL='http://example.com'
ENTERPRISE_MARKETING_UTM_SOURCE='example.com'
ENTERPRISE_MARKETING_UTM_CAMPAIGN='example.com Referral'
ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM='Footer'
BULK_MANAGEMENT_SPECIAL_ACCESS_COURSE_IDS=''
APP_ID=''
MFE_CONFIG_API_URL=''
DISPLAY_FEEDBACK_WIDGET='false'

View File

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

View File

@@ -1,3 +1,4 @@
// eslint-disable-next-line import/no-extraneous-dependencies
const { createConfig } = require('@edx/frontend-build');
const config = createConfig('eslint', {
@@ -6,14 +7,22 @@ const config = createConfig('eslint', {
'import/no-named-as-default-member': 'off',
'import/no-self-import': 'off',
'spaced-comment': ['error', 'always', { 'block': { 'exceptions': ['*'] } }],
// TOD: Remove this rule once we have a better way to handle this.
'import/no-import-module-exports': 'off',
'no-import-assign': 'off',
'default-param-last': 'off',
},
overrides: [{
files: ['*.test.js'], rules: { 'no-import-assign': 'off' },
}],
});
config.settings = {
"import/resolver": {
'import/resolver': {
node: {
paths: ["src", "node_modules"],
extensions: [".js", ".jsx"],
paths: ['src', 'node_modules'],
extensions: ['.js', '.jsx'],
},
},
};

2
.github/CODEOWNERS vendored
View File

@@ -3,4 +3,4 @@
# These owners will be the default owners for everything in
# the repo. Unless a later match takes precedence, they will
# be requested for review when someone opens a pull request.
* @edx/masters-devs-gta
* @openedx/content-aurora

View File

@@ -26,4 +26,4 @@ Collectively, these should be completed by reviewers of this PR:
- [ ] I've tested the new functionality
FYI: @edx/masters-devs-gta
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 }}

View File

@@ -0,0 +1,20 @@
# This workflow runs when a comment is made on the ticket
# If the comment starts with "label: " it tries to apply
# the label indicated in rest of comment.
# If the comment starts with "remove label: ", it tries
# to remove the indicated label.
# Note: Labels are allowed to have spaces and this script does
# not parse spaces (as often a space is legitimate), so the command
# "label: really long lots of words label" will apply the
# label "really long lots of words label"
name: Allows for the adding and removing of labels via comment
on:
issue_comment:
types: [created]
jobs:
add_remove_labels:
uses: openedx/.github/.github/workflows/add-remove-label-on-comment.yml@master

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

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

10
.github/workflows/commitlint.yml vendored 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: openedx/.github/.github/workflows/commitlint.yml@master

View File

@@ -0,0 +1,13 @@
#check package-lock file version
name: Lockfile Version check
on:
push:
branches:
- master
pull_request:
jobs:
version-check:
uses: openedx/.github/.github/workflows/lockfileversion-check.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
.github/workflows/self-assign-issue.yml vendored Normal file
View File

@@ -0,0 +1,12 @@
# This workflow runs when a comment is made on the ticket
# If the comment starts with "assign me" it assigns the author to the
# ticket (case insensitive)
name: Assign comment author to ticket if they say "assign me"
on:
issue_comment:
types: [created]
jobs:
self_assign_by_comment:
uses: openedx/.github/.github/workflows/self-assign-issue.yml@master

View File

@@ -0,0 +1,12 @@
name: Update Browserslist DB
on:
schedule:
- cron: '0 0 * * 1'
workflow_dispatch:
jobs:
update-browserslist:
uses: openedx/.github/.github/workflows/update-browserslist-db.yml@master
secrets:
requirements_bot_github_token: ${{ secrets.requirements_bot_github_token }}

2
.gitignore vendored
View File

@@ -5,6 +5,8 @@ npm-debug.log
coverage
dist/
src/i18n/transifex_input.json
temp/babel-plugin-react-intl
### pyenv ###
.python-version

View File

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

View File

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

View File

@@ -1,8 +1,9 @@
[main]
host = https://www.transifex.com
[edx-platform.frontend-app-gradebook]
[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
type = KEYVALUEJSON

30
Makefile Executable file → Normal file
View File

@@ -1,10 +1,10 @@
npm-install-%: ## install specified % npm package
npm install $* --save-dev
git add package.json
export TRANSIFEX_RESOURCE = frontend-app-gradebook
transifex_langs = "ar,de,es_419,fa_IR,fr,fr_CA,hi,it,pt,ru,uk,zh_CN"
transifex_resource = frontend-app-gradebook
transifex_langs = "ar,fr,es_419,zh_CN"
intl_imports = ./node_modules/.bin/intl-imports.js
transifex_utils = ./node_modules/.bin/transifex-utils.js
i18n = ./src/i18n
transifex_input = $(i18n)/transifex_input.json
@@ -49,17 +49,31 @@ push_translations:
# Pushing strings to Transifex...
tx push -s
# Fetching hashes from Transifex...
./node_modules/reactifex/bash_scripts/get_hashed_strings.sh $(tx_url1)
./node_modules/@edx/reactifex/bash_scripts/get_hashed_strings_v3.sh
# Writing out comments to file...
$(transifex_utils) $(transifex_temp) --comments
$(transifex_utils) $(transifex_temp) --comments --v3-scripts-path
# Pushing comments to Transifex...
./node_modules/reactifex/bash_scripts/put_comments.sh $(tx_url2)
./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh
ifeq ($(OPENEDX_ATLAS_PULL),)
# Pulls translations from Transifex.
pull_translations:
tx pull -f --mode reviewed --language=$(transifex_langs)
tx pull -t -f --mode reviewed --languages=$(transifex_langs)
else
# Experimental: OEP-58 Pulls translations using atlas
pull_translations:
rm -rf src/i18n/messages
mkdir src/i18n/messages
cd src/i18n/messages \
&& atlas pull --filter=$(transifex_langs) \
translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \
translations/frontend-component-header/src/i18n/messages:frontend-component-header \
translations/frontend-app-gradebook/src/i18n/messages:frontend-app-gradebook
# This target is used by Travis.
$(intl_imports) frontend-component-header frontend-component-footer frontend-app-gradebook
endif
# 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,4 +1,5 @@
[![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)
[![Build Status](https://api.travis-ci.com/edx/frontend-app-gradebook.svg?branch=master)](https://travis-ci.com/edx/frontend-app-gradebook)
[![Codecov](https://img.shields.io/codecov/c/gh/openedx/frontend-app-gradebook)](https://app.codecov.io/gh/openedx/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)
@@ -22,19 +23,13 @@ For existing documentation see:
### What does this offer over the legacy gradebook?
![A screenshot of the grade listings](documentation/screenshots/grade-listings.png)
The micro-frontend offers a great deal more granularity when searching for problems, an easy interface for editing grades, an
audit trail for seeing who edited what grade and what reason they gave (if any) for doing so.
![Screenshot of the grade editing interface](documentation/screenshots/grade-editing.png)
UsageProblems can be filtered by student as in the traditional gradebook, but can also be filtered by scores to see who
scored within a certain range, and by assignment types (note: Not problem types, but categories like Exams or
Homework).
![Screenshot of the filtering options](documentation/screenshots/grade-filtering.png)
### What does the legacy gradebook offer that this project does not?
This project does not (yet, at least) create any graphs, which the traditional gradebook does. It also does not give
@@ -63,7 +58,7 @@ npm i --save @edx/frontend-app-gradebook
## Running the UI Standalone
To install the project please refer to the [`edX Developer Stack`](https://github.com/edx/devstack) instructions.
To install the project please refer to the [`edX Developer Stack`](https://github.com/openedx/devstack) instructions.
The web application runs on port **1994**, so when you go to `http://localhost:1994/course-v1:edX+DemoX+Demo_Course` you should see the UI (assuming you have such a Demo Course in your devstack). Note that you always have to provide a course id to actually see a gradebook.
@@ -123,4 +118,4 @@ running gradebook container.
## Authentication with backend API services
See the [`@edx/frontend-auth`](https://github.com/edx/frontend-auth) repo for information about securing routes in your application that require user authentication.
See the [`@edx/frontend-auth`](https://github.com/edx-unsupported/frontend-auth) repo for information about securing routes in your application that require user authentication.

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

@@ -5,7 +5,7 @@ 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
https://github.com/openedx/edx-platform/blob/master/lms/djangoapps/grades/api/v1/gradebook_views.py
The ``bulk-update`` endpoint defined therein allows for the creation/modification of subsection
grades for multiple users and sections in a single request. This allows clients of the API to limit
the number of network requests made and to more easily manage client-side data. Moreover,
@@ -13,7 +13,7 @@ the course grade updates that occur during calls to this API are synchronous - t
is completed before a response is given to the client.
For decisions made about the implementation of this API, see:
https://github.com/edx/edx-platform/blob/master/lms/djangoapps/grades/docs/decisions/0001-gradebook-api.rst
https://github.com/openedx/edx-platform/blob/master/lms/djangoapps/grades/docs/decisions/0001-gradebook-api.rst
Decision
========

Binary file not shown.

Before

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 319 KiB

View File

@@ -68,33 +68,40 @@ Confirm the following workflows:
- [ ] Clicking "Save Grade" applies the override, shows the successful "grade has been edited" banner and updates score in grades table (may take a few seconds).
- [ ] Opening back up the "Edit Grades" modal shows the change as an entry in the override history table.
- [ ] *Masters only*: "Bulk Management" allows overriding grades in bulk.
- [ ] *Master's (or selectively-enabled) only*: "Bulk Management" allows overriding grades in bulk.
- Open a non-masters-track course.
- [ ] Verify that the "Bulk Management" tab does not appear.
- [ ] Verify that the "Bulk Management" button does not appear.
- [ ] Verify that the "Download Interventions" interface does not appear.
- Open a masters-track course.
- [ ] Verify that the "Bulk Management" tab appears to the right of the "Grades" tab.
- [ ] Verify that the "Bulk Management" button appears.
- Click the "Bulk Management" button. This downloads existing student/assignment info.
- [ ] 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.
- Add values in the "new_override-{subsection-short-id}" columns for student grades to be overridden and save the CSV file.
- [ ] Clicking the "Bulk Management" tab shows the Bulk Management page.
- 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.)
- Navigate back to the "Bulk Management" tab.
- [ ] 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 "Interventions" tab does not appear.
- [ ] Verify that the "Interventions" button does not appear.
- [ ] 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 "Interventions" tab appears to the right of the "Grades" tab.
- [ ] Verify that the "Interventions" button appears.
- Click on the "Interventions" button to generate a CSV students and activity info.
- [ ] 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

@@ -14,7 +14,7 @@ Suggested resources:
- [Adding Exercises and Tools](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/grading/index.html)
- [Set the Assignment Type and Due Date for a Subsection](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/developing_course/course_subsections.html#set-the-assignment-type-and-due-date-for-a-subsection)
## Enable Gradebook and feature toggles for course
## Enable Gradebook for course
See README.md #Quickstart for more detailed instructions.
@@ -25,7 +25,13 @@ As an admin user, visit Django Admin (`{lms-url}/admin`) to modify features.
- [ ] Set name to `grades.assume_zero_grade_if_absent`, select "Active", and click "Save"
- In Waffle_Utils > Waffle flag course overrides:
- [ ] Add a new flag called `grades.writeable_gradebook`, select "Force On", and enable it for your course
- [ ] Add a new flag called `grades.bulk_management`, select "Force On", and enable it for your course
## 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

View File

@@ -11,5 +11,6 @@ module.exports = createConfig('jest', {
coveragePathIgnorePatterns: [
'src/segment.js',
'src/postcss.config.js',
'testUtils', // don't unit test jest mocking tools
],
});

61045
package-lock.json generated

File diff suppressed because it is too large Load Diff

50
package.json Executable file → Normal file
View File

@@ -1,14 +1,13 @@
{
"name": "@edx/frontend-app-gradebook",
"version": "1.4.43",
"version": "1.6.2",
"description": "edx editable gradebook-ui to manipulate grade overrides on subsections",
"repository": {
"type": "git",
"url": "git+https://github.com/edx/frontend-app-gradebook.git"
"url": "git+https://github.com/openedx/frontend-app-gradebook.git"
},
"scripts": {
"build": "fedx-scripts webpack",
"coveralls": "cat ./coverage/lcov.info | coveralls",
"is-es5": "es-check es5 ./dist/*.js",
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
"lint": "fedx-scripts eslint --ext .jsx,.js src/",
@@ -17,20 +16,24 @@
"semantic-release": "semantic-release",
"start": "fedx-scripts webpack-dev-server --progress",
"test": "TZ=GMT fedx-scripts jest --coverage --passWithNoTests",
"watch-tests": "jest --watch",
"travis-deploy-once": "travis-deploy-once"
"watch-tests": "jest --watch"
},
"author": "edX",
"license": "AGPL-3.0",
"homepage": "https://github.com/edx/frontend-app-gradebook#readme",
"homepage": "https://github.com/openedx/frontend-app-gradebook#readme",
"publishConfig": {
"access": "public"
},
"browserslist": [
"extends @edx/browserslist-config"
],
"dependencies": {
"@edx/brand": "npm:@edx/brand-edx.org@^1.3.2",
"@edx/frontend-component-footer": "10.1.1",
"@edx/frontend-platform": "1.9.5",
"@edx/paragon": "14.16.4",
"@edx/brand": "npm:@edx/brand-openedx@^1.2.0",
"@edx/frontend-component-footer": "^12.0.0",
"@edx/frontend-component-header": "^4.0.0",
"@edx/frontend-platform": "^4.2.0",
"@edx/paragon": "^19.25.4",
"@edx/reactifex": "^2.1.1",
"@fortawesome/fontawesome-svg-core": "^1.2.25",
"@fortawesome/free-brands-svg-icons": "^5.11.2",
"@fortawesome/free-solid-svg-icons": "^5.11.2",
@@ -44,13 +47,12 @@
"enzyme-to-json": "^3.6.2",
"font-awesome": "4.7.0",
"history": "4.10.1",
"node-sass": "^4.14.1",
"prop-types": "15.7.2",
"prop-types": "15.8.1",
"query-string": "6.13.0",
"react": "16.13.1",
"react-dom": "16.13.1",
"react-intl": "^2.9.0",
"react-redux": "^5.1.1",
"react": "16.14.0",
"react-dom": "16.14.0",
"react-helmet": "^6.1.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",
@@ -60,25 +62,25 @@
"redux-logger": "3.0.6",
"redux-thunk": "2.3.0",
"regenerator-runtime": "^0.13.7",
"util": "^0.12.3",
"sass": "^1.49.0",
"whatwg-fetch": "^2.0.4"
},
"devDependencies": {
"@edx/frontend-build": "5.5.2",
"axios": "0.21.1",
"@edx/browserslist-config": "^1.1.1",
"@edx/frontend-build": "^12.4.15",
"@testing-library/react": "^12.1.0",
"axios": "0.21.2",
"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": "24.9.0",
"react-dev-utils": "^5.0.3",
"jest": "^26.6.3",
"react-dev-utils": "^12.0.1",
"react-test-renderer": "^16.10.1",
"reactifex": "1.1.1",
"redux-mock-store": "^1.5.3",
"semantic-release": "^17.2.3",
"travis-deploy-once": "^5.0.11"
"semantic-release": "^19.0.3"
}
}

View File

@@ -1,36 +1,36 @@
import React from 'react';
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
import { Provider } from 'react-redux';
import { IntlProvider } from 'react-intl';
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 EdxHeader from 'components/EdxHeader';
import './App.scss';
import Head from './head/Head';
const App = () => (
<IntlProvider locale="en">
<Provider store={store}>
<Router>
<div>
<EdxHeader />
<main>
<Switch>
<Route
exact
path={routePath}
component={GradebookPage}
/>
</Switch>
</main>
<Footer logo={process.env.LOGO_POWERED_BY_OPEN_EDX_URL_SVG} />
</div>
</Router>
</Provider>
</IntlProvider>
<AppProvider store={store}>
<Head />
<Router>
<div>
<Header />
<main>
<Switch>
<Route
exact
path={routePath}
component={GradebookPage}
/>
</Switch>
</main>
<Footer logo={process.env.LOGO_POWERED_BY_OPEN_EDX_URL_SVG} />
</div>
</Router>
</AppProvider>
);
export default App;

View File

@@ -9,8 +9,10 @@ $fa-font-path: "~font-awesome/fonts";
$input-focus-box-shadow: $input-box-shadow; // hack to get upgrade to paragon 4.0.0 to work
@import "~@edx/frontend-component-header/dist/index";
@import "~@edx/frontend-component-footer/dist/_footer";
@import "./components/GradesTab/GradesTab";
@import "./components/GradesView/GradesView";
@import "./components/BulkManagementHistoryView/BulkManagementHistoryView";
@import "./components/WithSidebar/WithSidebar";
@import "./components/GradebookFilters/GradebookFilters";

View File

@@ -2,28 +2,25 @@ import React from 'react';
import { shallow } from 'enzyme';
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
import { Provider } from 'react-redux';
import { IntlProvider } from 'react-intl';
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 EdxHeader from 'components/EdxHeader';
import App from './App';
import Head from './head/Head';
jest.mock('react-router-dom', () => ({
BrowserRouter: () => 'BrowserRouter',
Route: () => 'Route',
Switch: () => 'Switch',
}));
jest.mock('react-redux', () => ({
Provider: () => 'Provider',
}));
jest.mock('react-intl', () => ({
IntlProvider: () => 'IntlProvider',
jest.mock('@edx/frontend-platform/react', () => ({
AppProvider: () => 'AppProvider',
}));
jest.mock('data/constants/app', () => ({
routePath: '/:courseId',
@@ -31,7 +28,7 @@ jest.mock('data/constants/app', () => ({
jest.mock('@edx/frontend-component-footer', () => 'Footer');
jest.mock('data/store', () => 'testStore');
jest.mock('containers/GradebookPage', () => 'GradebookPage');
jest.mock('components/EdxHeader', () => 'EdxHeader');
jest.mock('@edx/frontend-component-header', () => 'Header');
const logo = 'fakeLogo.png';
let el;
@@ -45,28 +42,25 @@ describe('App router component', () => {
beforeEach(() => {
process.env.LOGO_POWERED_BY_OPEN_EDX_URL_SVG = logo;
el = shallow(<App />);
router = el.childAt(0).childAt(0);
router = el.childAt(1);
});
describe('IntlProvider', () => {
test('outer-wrapper component', () => {
expect(el.type()).toBe(IntlProvider);
});
test('"en" locale', () => {
expect(el.props().locale).toEqual('en');
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('Provider, inside IntlProvider', () => {
test('first child, passed the redux store props', () => {
expect(el.childAt(0).type()).toBe(Provider);
expect(el.childAt(0).props().store).toEqual(store);
describe('Head', () => {
test('first child of AppProvider', () => {
expect(el.childAt(0).type()).toBe(Head);
});
});
describe('Router', () => {
test('first child of Provider', () => {
test('second child of AppProvider', () => {
expect(router.type()).toBe(Router);
});
test('EdxHeader is above/outside-of the routing', () => {
expect(router.childAt(0).childAt(0).type()).toBe(EdxHeader);
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', () => {

View File

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

View File

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

View File

@@ -3,14 +3,12 @@ import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Table } from '@edx/paragon';
import { DataTable } from '@edx/paragon';
import { bulkManagementColumns } from 'data/constants/app';
import selectors from 'data/selectors';
import ResultsSummary from './ResultsSummary';
import messages from './messages';
export const mapHistoryRows = ({
resultsSummary,
@@ -32,20 +30,13 @@ export const mapHistoryRows = ({
export const HistoryTable = ({
bulkManagementHistory,
}) => (
<>
<p>
<FormattedMessage {...messages.hint1} />
<br />
<FormattedMessage {...messages.hint2} />
</p>
<Table
data={bulkManagementHistory.map(mapHistoryRows)}
hasFixedColumnWidths
columns={bulkManagementColumns}
className="table-striped"
/>
</>
<DataTable
data={bulkManagementHistory.map(mapHistoryRows)}
hasFixedColumnWidths
columns={bulkManagementColumns}
className="table-striped"
itemCount={bulkManagementHistory.length}
/>
);
HistoryTable.defaultProps = {
bulkManagementHistory: [],

View File

@@ -1,23 +1,20 @@
/* eslint-disable import/no-named-as-default */
import React from 'react';
import { shallow } from 'enzyme';
import { Table } from '@edx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { DataTable } from '@edx/paragon';
import selectors from 'data/selectors';
import { bulkManagementColumns } from 'data/constants/app';
import ResultsSummary from './ResultsSummary';
import { HistoryTable, mapStateToProps } from './HistoryTable';
import messages from './messages';
jest.mock('@edx/paragon', () => ({ DataTable: () => 'DataTable' }));
jest.mock('@edx/frontend-platform/i18n', () => ({
defineMessages: m => m,
FormattedMessage: () => 'FormattedMessage',
}));
jest.mock('@edx/paragon', () => ({
Table: () => 'Table',
}));
jest.mock('data/selectors', () => ({
__esModule: true,
default: {
@@ -58,23 +55,13 @@ describe('HistoryTable', () => {
beforeEach(() => {
el = shallow(<HistoryTable {...props} />);
});
const snapshotSegments = [
'hints display',
'formatted table',
];
test(`snapshot - loads ${snapshotSegments.join(', ')}`, () => {
test('snapshot - loads formatted table', () => {
expect(el).toMatchSnapshot();
});
test('hints with break in between', () => {
const hints = el.find('p');
expect(hints.childAt(0).getElement()).toEqual(<FormattedMessage {...messages.hint1} />);
expect(hints.childAt(1).is('br')).toEqual(true);
expect(hints.childAt(2).getElement()).toEqual(<FormattedMessage {...messages.hint2} />);
});
describe('history table', () => {
let table;
beforeEach(() => {
table = el.find(Table);
table = el.find(DataTable);
});
describe('data (from bulkManagementHistory.map(this.formatHistoryRow)', () => {
const fieldAssertions = [

View File

@@ -15,7 +15,7 @@ exports[`BulkManagementAlerts component no errer, no upload success snapshot - b
<FormattedMessage
defaultMessage="CSV processing. File uploads may take several minutes to complete."
description="Success Dialog message in BulkManagement Tab File Upload Form"
id="gradebook.BulkManagementTab.successDialog"
id="gradebook.BulkManagementHistoryView.successDialog"
/>
</Alert>
</Fragment>
@@ -38,7 +38,7 @@ exports[`BulkManagementAlerts component no errer, no upload success snapshot - d
<FormattedMessage
defaultMessage="CSV processing. File uploads may take several minutes to complete."
description="Success Dialog message in BulkManagement Tab File Upload Form"
id="gradebook.BulkManagementTab.successDialog"
id="gradebook.BulkManagementHistoryView.successDialog"
/>
</Alert>
</Fragment>

View File

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

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

@@ -4,20 +4,21 @@ import { FormattedMessage } from '@edx/frontend-platform/i18n';
import messages from './messages';
import BulkManagementAlerts from './BulkManagementAlerts';
import FileUploadForm from './FileUploadForm';
import HistoryTable from './HistoryTable';
/**
* <BulkManagementTab />
* <BulkManagementHistoryView />
* top-level view for managing uploads of bulk management override csvs.
*/
export const BulkManagementTab = () => (
<div>
<h4><FormattedMessage {...(messages.heading)} /></h4>
export const BulkManagementHistoryView = () => (
<div className="bulk-management-history-view">
<h4><FormattedMessage {...messages.heading} /></h4>
<p className="help-text">
<FormattedMessage {...messages.helpText} />
</p>
<BulkManagementAlerts />
<FileUploadForm />
<HistoryTable />
</div>
);
export default BulkManagementTab;
export default BulkManagementHistoryView;

View File

@@ -3,27 +3,24 @@ import React from 'react';
import { shallow } from 'enzyme';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { BulkManagementTab } from '.';
import { BulkManagementHistoryView } from '.';
import BulkManagementAlerts from './BulkManagementAlerts';
import FileUploadForm from './FileUploadForm';
import HistoryTable from './HistoryTable';
import messages from './messages';
jest.mock('./BulkManagementAlerts', () => 'BulkManagementAlerts');
jest.mock('./FileUploadForm', () => 'FileUploadForm');
jest.mock('./HistoryTable', () => 'HistoryTable');
describe('BulkManagementTab', () => {
describe('BulkManagementHistoryView', () => {
describe('component', () => {
let el;
beforeEach(() => {
el = shallow(<BulkManagementTab />);
el = shallow(<BulkManagementHistoryView />);
});
describe('snapshot', () => {
const snapshotSegments = [
'heading from messages.BulkManagementTab.heading',
'heading from messages.BulkManagementHistoryView.heading',
'<BulkManagementAlerts />',
'<FileUploadForm />',
'<HistoryTable />',
];
test(`snapshot - loads ${snapshotSegments.join(', ')}`, () => {
@@ -39,8 +36,7 @@ describe('BulkManagementTab', () => {
});
test('heading, then alerts, then upload form, then table', () => {
expect(el.childAt(0).is('h4')).toEqual(true);
expect(el.childAt(1).is(BulkManagementAlerts)).toEqual(true);
expect(el.childAt(2).is(FileUploadForm)).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

@@ -1,95 +0,0 @@
/* eslint-disable react/button-has-type, import/no-named-as-default */
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import {
Button,
Form,
FormControl,
FormGroup,
} from '@edx/paragon';
import selectors from 'data/selectors';
import thunkActions from 'data/thunkActions';
import messages from './messages';
/**
* <FileUploadForm />
* File-type input wrapped with hidden control such that when a valid file is
* added, it is automattically uploaded.
*/
export class FileUploadForm extends React.Component {
constructor(props) {
super(props);
this.fileInputRef = React.createRef();
this.handleClickImportGrades = this.handleClickImportGrades.bind(this);
this.handleFileInputChange = this.handleFileInputChange.bind(this);
}
get fileInput() {
return this.fileInputRef.current;
}
get formData() {
const data = new FormData();
data.append('csv', this.fileInput.files[0]);
return data;
}
get hasFile() {
return this.fileInput && this.fileInput.files[0];
}
handleClickImportGrades() {
if (this.fileInput) { this.fileInput.click(); }
}
handleFileInputChange() {
return this.hasFile && (
this.props.submitFileUploadFormData(this.formData).then(
() => { this.fileInput.value = null; },
)
);
}
render() {
const { gradeExportUrl } = this.props;
return (
<>
<Form action={gradeExportUrl} method="post">
<FormGroup controlId="csv">
<FormControl
className="d-none"
type="file"
label={<FormattedMessage {...messages.csvUploadLabel} />}
onChange={this.handleFileInputChange}
ref={this.fileInputRef}
/>
</FormGroup>
</Form>
<Button variant="primary" onClick={this.handleClickImportGrades}>
<FormattedMessage {...messages.importBtnText} />
</Button>
</>
);
}
}
FileUploadForm.propTypes = {
// redux
gradeExportUrl: PropTypes.string.isRequired,
submitFileUploadFormData: PropTypes.func.isRequired,
};
export const mapStateToProps = (state) => ({
gradeExportUrl: selectors.root.gradeExportUrl(state),
});
export const mapDispatchToProps = {
submitFileUploadFormData: thunkActions.grades.submitFileUploadFormData,
};
export default connect(mapStateToProps, mapDispatchToProps)(FileUploadForm);

View File

@@ -1,214 +0,0 @@
/* eslint-disable import/no-named-as-default */
import React from 'react';
import { shallow } from 'enzyme';
import TestRenderer from 'react-test-renderer';
import {
Button,
Form,
FormControl,
FormGroup,
} from '@edx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import selectors from 'data/selectors';
import thunkActions from 'data/thunkActions';
import { FileUploadForm, mapStateToProps, mapDispatchToProps } from './FileUploadForm';
import messages from './messages';
jest.mock('@edx/frontend-platform/i18n', () => ({
defineMessages: m => m,
FormattedMessage: () => 'FormattedMessage',
}));
jest.mock('data/selectors', () => ({
__esModule: true,
default: {
grades: {
bulkImportError: jest.fn(state => ({ bulkImportError: state })),
},
root: {
gradeExportUrl: jest.fn(state => ({ gradeExportUrl: state })),
},
},
}));
jest.mock('data/thunkActions', () => ({
__esModule: true,
default: {
grades: { submitFileUploadFormData: jest.fn() },
},
}));
jest.mock('./BulkManagementAlerts', () => 'BulkManagementAlerts');
jest.mock('./ResultsSummary', () => 'ResultsSummary');
const mockRef = { click: jest.fn(), files: [] };
describe('FileUploadForm', () => {
beforeEach(() => {
mockRef.click.mockClear();
});
describe('component', () => {
let props;
let el;
let inst;
beforeEach(() => {
props = {
gradeExportUrl: 'fakeUrl',
submitFileUploadFormData: jest.fn(),
};
});
describe('snapshot', () => {
const snapshotSegments = [
'export form w/ alerts and file input',
'import btn',
];
test(`snapshot - loads ${snapshotSegments.join(', ')}`, () => {
jest.mock('@edx/paragon', () => ({
Button: () => 'Button',
Form: () => 'Form',
FormControl: () => 'FormControl',
FormGroup: () => 'FormGroup',
}));
el = shallow(<FileUploadForm {...props} />);
el.instance().handleFileInputChange = jest.fn().mockName('this.handleFileInputChange');
el.instance().fileInputRef = jest.fn().mockName('this.fileInputRef');
el.instance().handleClickImportGrades = jest.fn().mockName('this.handleClickImportGrades');
el.instance().formatHistoryRow = jest.fn(entry => entry.originalFilename);
expect(el.instance().render()).toMatchSnapshot();
});
});
describe('render', () => {
beforeEach(() => {
el = TestRenderer.create(
<FileUploadForm {...props} />,
{ createNodeMock: () => mockRef },
);
inst = el.root;
});
describe('alert form', () => {
let form;
beforeEach(() => {
form = inst.findByType(Form);
});
test('post action points to gradeExportUrl', () => {
expect(form.props.action).toEqual(props.gradeExportUrl);
expect(form.props.method).toEqual('post');
});
describe('file input', () => {
let formGroup;
beforeEach(() => {
formGroup = inst.findByType(FormGroup);
});
test('group with controlId="csv"', () => {
expect(formGroup.props.controlId).toEqual('csv');
});
test('file control with onChange from handleFileInputChange', () => {
const control = inst.findByType(FormControl);
expect(
control.props.onChange,
).toEqual(el.getInstance().handleFileInputChange);
});
test('fileInputRef points to control', () => {
expect(
// eslint-disable-next-line no-underscore-dangle
inst.findByType(FormControl)._fiber.ref,
).toEqual(el.getInstance().fileInputRef);
});
});
});
describe('import button', () => {
let btn;
beforeEach(() => {
btn = inst.findByType(Button);
});
test('handleClickImportGrade on click', () => {
expect(btn.props.onClick).toEqual(el.getInstance().handleClickImportGrades);
});
test('text from messages.importBtn', () => {
const messageEl = btn.findByType(FormattedMessage);
expect(messageEl.props).toEqual(messages.importBtnText);
});
});
});
describe('fileInput helper', () => {
test('links to fileInputRef.current', () => {
el = TestRenderer.create(
<FileUploadForm {...props} />,
{ createNodeMock: () => mockRef },
);
expect(el.getInstance().fileInput).not.toEqual(undefined);
expect(el.getInstance().fileInput).toEqual(el.getInstance().fileInputRef.current);
});
});
describe('behavior', () => {
let fileInput;
beforeEach(() => {
el = TestRenderer.create(
<FileUploadForm {...props} />,
{ createNodeMock: () => mockRef },
);
fileInput = jest.spyOn(el.getInstance(), 'fileInput', 'get');
});
describe('handleFileInputChange', () => {
it('does nothing (does not fail) if fileInput has not loaded', () => {
fileInput.mockReturnValue(null);
el.getInstance().handleClickImportGrades();
expect(mockRef.click).not.toHaveBeenCalled();
});
it('calls fileInput.click if is loaded', () => {
el.getInstance().handleClickImportGrades();
expect(mockRef.click).toHaveBeenCalled();
});
});
describe('handleClickImportGrades', () => {
it('does nothing if file input has not loaded with files', () => {
fileInput.mockReturnValue(null);
el.getInstance().handleFileInputChange();
expect(props.submitFileUploadFormData).not.toHaveBeenCalled();
fileInput.mockReturnValue({ files: [] });
el.getInstance().handleFileInputChange();
expect(props.submitFileUploadFormData).not.toHaveBeenCalled();
});
it('calls submitFileUploadFormData and then clears fileInput if has files', () => {
fileInput.mockReturnValue({ files: ['some', 'files'], value: 'a value' });
const formData = { fake: 'form data' };
jest.spyOn(el.getInstance(), 'formData', 'get').mockReturnValue(formData);
const submit = jest.fn(() => ({ then: (thenCB) => { thenCB(); } }));
el.update(<FileUploadForm {...props} submitFileUploadFormData={submit} />);
el.getInstance().handleFileInputChange();
expect(submit).toHaveBeenCalledWith(formData);
expect(el.getInstance().fileInput.value).toEqual(null);
});
});
describe('formData', () => {
test('returns FormData object with csv value from fileInput.files[0]', () => {
const file = { a: 'fake file' };
const value = 'aValue';
fileInput.mockReturnValue({ files: [file], value });
const expected = new FormData();
expected.append('csv', file);
expect([...el.getInstance().formData.entries()]).toEqual([...expected.entries()]);
});
});
});
});
describe('mapStateToProps', () => {
const testState = { a: 'simple', test: 'state' };
let mapped;
beforeEach(() => {
mapped = mapStateToProps(testState);
});
test('gradeExportUrl from root.gradeExportUrl', () => {
expect(mapped.gradeExportUrl).toEqual(selectors.root.gradeExportUrl(testState));
});
});
describe('mapDispatchToProps', () => {
test('submitFileUploadFormData from thunkActions.grades', () => {
expect(
mapDispatchToProps.submitFileUploadFormData,
).toEqual(thunkActions.grades.submitFileUploadFormData);
});
});
});

View File

@@ -1,45 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`FileUploadForm component snapshot snapshot - loads export form w/ alerts and file input, import btn 1`] = `
<React.Fragment>
<Form
action="fakeUrl"
inline={false}
method="post"
>
<FormGroup
as="div"
controlId="csv"
isInvalid={false}
isValid={false}
>
<ForwardRef
as="input"
className="d-none"
label={
<FormattedMessage
defaultMessage="Upload Grade CSV"
description="Button in BulkManagementTab Alerts"
id="gradebook.BulkManagementTab.csvUploadLabel"
/>
}
onChange={[MockFunction this.handleFileInputChange]}
plaintext={false}
type="file"
/>
</FormGroup>
</Form>
<ForwardRef
active={false}
disabled={false}
onClick={[MockFunction this.handleClickImportGrades]}
variant="primary"
>
<FormattedMessage
defaultMessage="Import Grades"
description="Button in BulkManagement Tab File Upload Form"
id="gradebook.BulkManagementTab.importBtnText"
/>
</ForwardRef>
</React.Fragment>
`;

View File

@@ -1,132 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`HistoryTable component snapshot history table data (from bulkManagementHistory.map(this.formatHistoryRow) snapshot: maps resultsSummay to ResultsSummary, wraps filename and user, forwards the rest 1`] = `
Array [
Object {
"filename": <span
className="wrap-text-in-cell"
>
blue.png
</span>,
"resultsSummary": <ResultsSummary
courseId="Da Bu Dee"
rowId={12}
text="Da ba daa"
/>,
"timeUploaded": "65",
"user": <span
className="wrap-text-in-cell"
>
Eifel
</span>,
},
Object {
"filename": <span
className="wrap-text-in-cell"
>
allStar.jpg
</span>,
"resultsSummary": <ResultsSummary
courseId="rockstar"
rowId={2}
text="all that glitters is gold"
/>,
"timeUploaded": "2000s?",
"user": <span
className="wrap-text-in-cell"
>
Smashmouth
</span>,
},
]
`;
exports[`HistoryTable component snapshot snapshot - loads hints display, formatted table 1`] = `
<Fragment>
<p>
<FormattedMessage
defaultMessage="Results appear in the table below."
description="Hint text on BulkManagement Tab History Table"
id="gradebook.BulkManagementTab.hint1"
/>
<br />
<FormattedMessage
defaultMessage="Grade processing may take a few seconds."
description="Hint text on BulkManagement Tab History Table"
id="gradebook.BulkManagementTab.hint2"
/>
</p>
<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

@@ -1,16 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`BulkManagementTab component snapshot snapshot - loads heading from messages.BulkManagementTab.heading, <BulkManagementAlerts />, <FileUploadForm />, <HistoryTable /> 1`] = `
<div>
<h4>
<FormattedMessage
defaultMessage="Use this feature by downloading a CSV for bulk management, overriding grades locally, and coming back here to upload."
description="Heading text for BulkManagement Tab"
id="gradebook.BulkManagementTab.heading"
/>
</h4>
<BulkManagementAlerts />
<FileUploadForm />
<HistoryTable />
</div>
`;

View File

@@ -1,36 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
csvUploadLabel: {
id: 'gradebook.BulkManagementTab.csvUploadLabel',
defaultMessage: 'Upload Grade CSV',
description: 'Button in BulkManagementTab Alerts',
},
heading: {
id: 'gradebook.BulkManagementTab.heading',
defaultMessage: 'Use this feature by downloading a CSV for bulk management, overriding grades locally, and coming back here to upload.',
description: 'Heading text for BulkManagement Tab',
},
hint1: {
id: 'gradebook.BulkManagementTab.hint1',
defaultMessage: 'Results appear in the table below.',
description: 'Hint text on BulkManagement Tab History Table',
},
hint2: {
id: 'gradebook.BulkManagementTab.hint2',
defaultMessage: 'Grade processing may take a few seconds.',
description: 'Hint text on BulkManagement Tab History Table',
},
importBtnText: {
id: 'gradebook.BulkManagementTab.importBtnText',
defaultMessage: 'Import Grades',
description: 'Button in BulkManagement Tab File Upload Form',
},
successDialog: {
id: 'gradebook.BulkManagementTab.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,52 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AssignmentFilter component render snapshot 1`] = `
<div
className="student-filters"
>
<SelectGroup
disabled={false}
id="assignment"
label="Assignment"
onChange={[MockFunction]}
options={
Array [
<option
value=""
>
All
</option>,
<option
value="label1"
>
label1
:
sLabel1
</option>,
<option
value="label2"
>
label2
:
sLabel2
</option>,
<option
value="label3"
>
label3
:
sLabel3
</option>,
<option
value="label4"
>
label4
:
sLabel4
</option>,
]
}
value="test-label"
/>
</div>
`;

View File

@@ -1,44 +0,0 @@
// 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,33 @@
import {
selectors,
actions,
thunkActions,
} from 'data/redux/hooks';
export const useAssignmentFilterData = ({
updateQueryParams,
}) => {
const assignmentFilterOptions = selectors.filters.useSelectableAssignmentLabels();
const selectedAssignmentLabel = selectors.filters.useSelectedAssignmentLabel() || '';
const updateAssignmentFilter = actions.filters.useUpdateAssignment();
const conditionalFetch = thunkActions.grades.useFetchGradesIfAssignmentGradeFiltersSet();
const handleChange = ({ target: { value: assignment } }) => {
const selectedFilterOption = assignmentFilterOptions.find(
({ label }) => label === assignment,
);
const { type, id } = selectedFilterOption || {};
updateAssignmentFilter({ label: assignment, type, id });
updateQueryParams({ assignment: id });
conditionalFetch();
};
return {
handleChange,
selectedAssignmentLabel,
assignmentFilterOptions,
};
};
export default useAssignmentFilterData;

View File

@@ -0,0 +1,88 @@
import { selectors, actions, thunkActions } from 'data/redux/hooks';
import useAssignmentFilterData from './hooks';
jest.mock('data/redux/hooks', () => ({
selectors: {
filters: {
useSelectableAssignmentLabels: jest.fn(),
useSelectedAssignmentLabel: jest.fn(),
},
},
actions: {
filters: { useUpdateAssignment: jest.fn() },
},
thunkActions: {
grades: { useFetchGradesIfAssignmentGradeFiltersSet: jest.fn() },
},
}));
let out;
const testKey = 'test-key';
const event = { target: { value: testKey } };
const testId = 'test-id';
const testType = 'test-type';
const testLabel = { label: testKey, id: testId, type: testType };
const selectableAssignmentLabels = [
{ label: 'some' },
{ label: 'test' },
{ label: 'labels' },
testLabel,
];
const selectedAssignmentLabel = 'test-assignment-label';
selectors.filters.useSelectableAssignmentLabels.mockReturnValue(selectableAssignmentLabels);
selectors.filters.useSelectedAssignmentLabel.mockReturnValue(selectedAssignmentLabel);
const updateAssignment = jest.fn();
const fetch = jest.fn();
actions.filters.useUpdateAssignment.mockReturnValue(updateAssignment);
thunkActions.grades.useFetchGradesIfAssignmentGradeFiltersSet.mockReturnValue(fetch);
const updateQueryParams = jest.fn();
describe('useAssignmentFilterData hook', () => {
beforeEach(() => {
out = useAssignmentFilterData({ updateQueryParams });
});
describe('behavior', () => {
it('initializes redux hooks', () => {
expect(selectors.filters.useSelectableAssignmentLabels).toHaveBeenCalledWith();
expect(selectors.filters.useSelectedAssignmentLabel).toHaveBeenCalledWith();
expect(actions.filters.useUpdateAssignment).toHaveBeenCalledWith();
expect(thunkActions.grades.useFetchGradesIfAssignmentGradeFiltersSet)
.toHaveBeenCalledWith();
});
});
describe('output', () => {
describe('handleEvent', () => {
beforeEach(() => {
out.handleChange(event);
});
it('updates assignment filter with selected filter', () => {
expect(updateAssignment).toHaveBeenCalledWith(testLabel);
});
it('updates queryParams', () => {
expect(updateQueryParams).toHaveBeenCalledWith({ assignment: testId });
});
it('updates assignment filter with only label if no match', () => {
out.handleChange({ target: { value: 'no-match' } });
expect(updateAssignment).toHaveBeenCalledWith({ label: 'no-match' });
});
it('calls conditional fetch', () => {
expect(fetch).toHaveBeenCalled();
});
});
it('passes selectedAssignmentLabel from hook', () => {
expect(out.selectedAssignmentLabel).toEqual(selectedAssignmentLabel);
});
test('selectedAssignmentLabel is empty string if not set', () => {
selectors.filters.useSelectedAssignmentLabel.mockReturnValue(undefined);
out = useAssignmentFilterData({ updateQueryParams });
expect(out.selectedAssignmentLabel).toEqual('');
});
it('passes assignmentFilterOptions from hook', () => {
expect(out.assignmentFilterOptions).toEqual(selectableAssignmentLabels);
});
});
});

View File

@@ -1,98 +1,44 @@
/* 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 { useIntl } from '@edx/frontend-platform/i18n';
import messages from '../messages';
import SelectGroup from '../SelectGroup';
import useAssignmentFilterData from './hooks';
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: '',
const AssignmentFilter = ({ updateQueryParams }) => {
const {
handleChange,
selectedAssignmentLabel,
assignmentFilterOptions,
} = useAssignmentFilterData({ updateQueryParams });
const { formatMessage } = useIntl();
const filterOptions = assignmentFilterOptions.map(({ label, subsectionLabel }) => (
<option key={label} value={label}>
{label}: {subsectionLabel}
</option>
));
return (
<div className="student-filters">
<SelectGroup
id="assignment"
label={formatMessage(messages.assignment)}
value={selectedAssignmentLabel}
onChange={handleChange}
disabled={assignmentFilterOptions.length === 0}
options={[
<option key="0" value="">All</option>,
...filterOptions,
]}
/>
</div>
);
};
AssignmentFilter.propTypes = {
updateQueryParams: PropTypes.func.isRequired,
// 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);
export default AssignmentFilter;

View File

@@ -0,0 +1,53 @@
import React from 'react';
import { shallow } from 'enzyme';
import { useIntl } from '@edx/frontend-platform/i18n';
import SelectGroup from '../SelectGroup';
import useAssignmentFilterData from './hooks';
import AssignmentFilter from '.';
jest.mock('../SelectGroup', () => 'SelectGroup');
jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() }));
const handleChange = jest.fn();
const selectedAssignmentLabel = 'test-label';
const assignmentFilterOptions = [
{ label: 'label1', subsectionLabel: 'sLabel1' },
{ label: 'label2', subsectionLabel: 'sLabel2' },
{ label: 'label3', subsectionLabel: 'sLabel3' },
{ label: 'label4', subsectionLabel: 'sLabel4' },
];
useAssignmentFilterData.mockReturnValue({
handleChange,
selectedAssignmentLabel,
assignmentFilterOptions,
});
const updateQueryParams = jest.fn();
let el;
describe('AssignmentFilter component', () => {
beforeAll(() => {
el = shallow(<AssignmentFilter updateQueryParams={updateQueryParams} />);
});
describe('behavior', () => {
it('initializes hooks', () => {
expect(useAssignmentFilterData).toHaveBeenCalledWith({ updateQueryParams });
expect(useIntl).toHaveBeenCalledWith();
});
});
describe('render', () => {
test('snapshot', () => {
expect(el).toMatchSnapshot();
});
test('filter options', () => {
const { options } = el.find(SelectGroup).props();
expect(options.length).toEqual(5);
const testOption = assignmentFilterOptions[0];
const optionProps = options[1].props;
expect(optionProps.value).toEqual(testOption.label);
expect(optionProps.children.join(''))
.toEqual(`${testOption.label}: ${testOption.subsectionLabel}`);
});
});
});

View File

@@ -1,162 +0,0 @@
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,67 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AssignmentFilter component render with selected assignment snapshot 1`] = `
<div
className="grade-filter-inputs"
>
<PercentGroup
disabled={false}
id="assignmentGradeMin"
label="Min Grade"
onChange={[MockFunction]}
value={23}
/>
<PercentGroup
disabled={false}
id="assignmentGradeMax"
label="Max Grade"
onChange={[MockFunction]}
value={300}
/>
<div
className="grade-filter-action"
>
<Button
disabled={false}
name="assignmentGradeMinMax"
type="submit"
variant="outline-secondary"
>
Apply
</Button>
</div>
</div>
`;
exports[`AssignmentFilter component render without selected assignment snapshot 1`] = `
<div
className="grade-filter-inputs"
>
<PercentGroup
disabled={true}
id="assignmentGradeMin"
label="Min Grade"
onChange={[MockFunction]}
value={23}
/>
<PercentGroup
disabled={true}
id="assignmentGradeMax"
label="Max Grade"
onChange={[MockFunction]}
value={300}
/>
<div
className="grade-filter-action"
>
<Button
disabled={true}
name="assignmentGradeMinMax"
type="submit"
variant="outline-secondary"
>
Apply
</Button>
</div>
</div>
`;

View File

@@ -1,95 +0,0 @@
// 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,36 @@
/* eslint-disable react/sort-comp, react/button-has-type */
import { selectors, actions, thunkActions } from 'data/redux/hooks';
const useAssignmentGradeFilterData = ({ updateQueryParams }) => {
const localAssignmentLimits = selectors.app.useAssignmentGradeLimits();
const selectedAssignment = selectors.filters.useSelectedAssignmentLabel();
const fetchGrades = thunkActions.grades.useFetchGrades();
const setFilter = actions.app.useSetLocalFilter();
const updateAssignmentLimits = actions.filters.useUpdateAssignmentLimits();
const handleSubmit = () => {
updateAssignmentLimits(localAssignmentLimits);
fetchGrades();
updateQueryParams(localAssignmentLimits);
};
const handleSetMax = ({ target: { value } }) => {
setFilter({ assignmentGradeMax: value });
};
const handleSetMin = ({ target: { value } }) => {
setFilter({ assignmentGradeMin: value });
};
const { assignmentGradeMax, assignmentGradeMin } = localAssignmentLimits;
return {
assignmentGradeMin,
assignmentGradeMax,
selectedAssignment,
handleSetMax,
handleSetMin,
handleSubmit,
};
};
export default useAssignmentGradeFilterData;

View File

@@ -0,0 +1,81 @@
import { selectors, actions, thunkActions } from 'data/redux/hooks';
import useAssignmentGradeFilterData from './hooks';
jest.mock('data/redux/hooks', () => ({
selectors: {
app: { useAssignmentGradeLimits: jest.fn() },
filters: { useSelectedAssignmentLabel: jest.fn() },
},
actions: {
app: { useSetLocalFilter: jest.fn() },
filters: { useUpdateAssignmentLimits: jest.fn() },
},
thunkActions: {
grades: { useFetchGrades: jest.fn() },
},
}));
let out;
const assignmentGradeLimits = { assignmentGradeMax: 200, assignmentGradeMin: 3 };
const selectedAssignmentLabel = 'test-assignment-label';
selectors.app.useAssignmentGradeLimits.mockReturnValue(assignmentGradeLimits);
selectors.filters.useSelectedAssignmentLabel.mockReturnValue(selectedAssignmentLabel);
const setLocalFilter = jest.fn();
const updateAssignmentLimits = jest.fn();
const fetch = jest.fn();
actions.app.useSetLocalFilter.mockReturnValue(setLocalFilter);
actions.filters.useUpdateAssignmentLimits.mockReturnValue(updateAssignmentLimits);
thunkActions.grades.useFetchGrades.mockReturnValue(fetch);
const testValue = 42;
const updateQueryParams = jest.fn();
describe('useAssignmentFilterData hook', () => {
beforeEach(() => {
out = useAssignmentGradeFilterData({ updateQueryParams });
});
describe('behavior', () => {
it('initializes redux hooks', () => {
expect(selectors.app.useAssignmentGradeLimits).toHaveBeenCalledWith();
expect(selectors.filters.useSelectedAssignmentLabel).toHaveBeenCalledWith();
expect(actions.app.useSetLocalFilter).toHaveBeenCalledWith();
expect(actions.filters.useUpdateAssignmentLimits).toHaveBeenCalledWith();
expect(thunkActions.grades.useFetchGrades).toHaveBeenCalledWith();
});
});
describe('output', () => {
describe('handleSubmit', () => {
beforeEach(() => {
out.handleSubmit();
});
it('updates assignment limits filter', () => {
expect(updateAssignmentLimits).toHaveBeenCalledWith(assignmentGradeLimits);
});
it('updates queryParams', () => {
expect(updateQueryParams).toHaveBeenCalledWith(assignmentGradeLimits);
});
it('calls conditional fetch', () => {
expect(fetch).toHaveBeenCalled();
});
});
test('handleSetMax sets assignmentGradeMax', () => {
out.handleSetMax({ target: { value: testValue } });
expect(setLocalFilter).toHaveBeenCalledWith({ assignmentGradeMax: testValue });
});
test('handleSetMin sets assignmentGradeMin', () => {
out.handleSetMin({ target: { value: testValue } });
expect(setLocalFilter).toHaveBeenCalledWith({ assignmentGradeMin: testValue });
});
it('passes selectedAssignment from hook', () => {
expect(out.selectedAssignment).toEqual(selectedAssignmentLabel);
});
it('passes assignmentGradeMin and assignmentGradeMax from hook', () => {
expect(out.assignmentGradeMax).toEqual(assignmentGradeLimits.assignmentGradeMax);
expect(out.assignmentGradeMin).toEqual(assignmentGradeLimits.assignmentGradeMin);
});
});
});

View File

@@ -1,103 +1,56 @@
/* 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 { useIntl } 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 useAssignmentGradeFilterData from './hooks';
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>
export const AssignmentGradeFilter = ({ updateQueryParams }) => {
const {
assignmentGradeMin,
assignmentGradeMax,
selectedAssignment,
handleSetMax,
handleSetMin,
handleSubmit,
} = useAssignmentGradeFilterData({ updateQueryParams });
const { formatMessage } = useIntl();
return (
<div className="grade-filter-inputs">
<PercentGroup
id="assignmentGradeMin"
label={formatMessage(messages.minGrade)}
value={assignmentGradeMin}
disabled={!selectedAssignment}
onChange={handleSetMin}
/>
<PercentGroup
id="assignmentGradeMax"
label={formatMessage(messages.maxGrade)}
value={assignmentGradeMax}
disabled={!selectedAssignment}
onChange={handleSetMax}
/>
<div className="grade-filter-action">
<Button
type="submit"
variant="outline-secondary"
name="assignmentGradeMinMax"
disabled={!selectedAssignment}
onClick={handleSubmit}
>
{formatMessage(messages.apply)}
</Button>
</div>
);
}
}
AssignmentGradeFilter.defaultProps = {
selectedAssignment: '',
</div>
);
};
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);
export default AssignmentGradeFilter;

View File

@@ -0,0 +1,77 @@
import React from 'react';
import { shallow } from 'enzyme';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon';
import PercentGroup from '../PercentGroup';
import useAssignmentGradeFilterData from './hooks';
import AssignmentFilter from '.';
jest.mock('../PercentGroup', () => 'PercentGroup');
jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() }));
const hookData = {
handleChange: jest.fn(),
handleSetMax: jest.fn(),
handleSetMin: jest.fn(),
selectedAssignment: 'test-assignment',
assignmentGradeMax: 300,
assignmentGradeMin: 23,
};
useAssignmentGradeFilterData.mockReturnValue(hookData);
const updateQueryParams = jest.fn();
let el;
describe('AssignmentFilter component', () => {
beforeEach(() => {
jest.clearAllMocks();
el = shallow(<AssignmentFilter updateQueryParams={updateQueryParams} />);
});
describe('behavior', () => {
it('initializes hooks', () => {
expect(useAssignmentGradeFilterData).toHaveBeenCalledWith({ updateQueryParams });
expect(useIntl).toHaveBeenCalledWith();
});
});
describe('render', () => {
describe('with selected assignment', () => {
test('snapshot', () => {
expect(el).toMatchSnapshot();
});
it('renders a PercentGroup for both Max and Min filters', () => {
let props = el.find(PercentGroup).at(0).props();
expect(props.value).toEqual(hookData.assignmentGradeMin);
expect(props.disabled).toEqual(false);
expect(props.onChange).toEqual(hookData.handleSetMin);
props = el.find(PercentGroup).at(1).props();
expect(props.value).toEqual(hookData.assignmentGradeMax);
expect(props.disabled).toEqual(false);
expect(props.onChange).toEqual(hookData.handleSetMax);
});
it('renders a submit button', () => {
const props = el.find(Button).props();
expect(props.disabled).toEqual(false);
expect(props.onClick).toEqual(hookData.handleSubmit);
});
});
describe('without selected assignment', () => {
beforeEach(() => {
useAssignmentGradeFilterData.mockReturnValueOnce({
...hookData,
selectedAssignment: null,
});
el = shallow(<AssignmentFilter updateQueryParams={updateQueryParams} />);
});
test('snapshot', () => {
expect(el).toMatchSnapshot();
});
it('disables controls', () => {
let props = el.find(PercentGroup).at(0).props();
expect(props.disabled).toEqual(true);
props = el.find(PercentGroup).at(1).props();
expect(props.disabled).toEqual(true);
});
});
});
});

View File

@@ -1,143 +0,0 @@
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,44 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AssignmentFilterType component render snapshot 1`] = `
<div
className="student-filters"
>
<SelectGroup
disabled={true}
id="assignment-types"
label="Assignment Types"
onChange={[MockFunction]}
options={
Array [
<option
value=""
>
All
</option>,
<option
value="test-type"
>
test-type
</option>,
<option
value="type1"
>
type1
</option>,
<option
value="type2"
>
type2
</option>,
<option
value="type3"
>
type3
</option>,
]
}
value="test-type"
/>
</div>
`;

View File

@@ -1,79 +0,0 @@
// 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,22 @@
import { selectors, actions } from 'data/redux/hooks';
export const useAssignmentTypeFilterData = ({ updateQueryParams }) => {
const assignmentTypes = selectors.assignmentTypes.useAllAssignmentTypes() || {};
const assignmentFilterOptions = selectors.filters.useSelectableAssignmentLabels();
const selectedAssignmentType = selectors.filters.useAssignmentType() || '';
const filterAssignmentType = actions.filters.useUpdateAssignmentType();
const handleChange = (event) => {
const assignmentType = event.target.value;
filterAssignmentType(assignmentType);
updateQueryParams({ assignmentType });
};
return {
assignmentTypes,
handleChange,
isDisabled: assignmentFilterOptions.length === 0,
selectedAssignmentType,
};
};
export default useAssignmentTypeFilterData;

View File

@@ -0,0 +1,92 @@
import { selectors, actions } from 'data/redux/hooks';
import useAssignmentTypeFilterData from './hooks';
jest.mock('data/redux/hooks', () => ({
selectors: {
assignmentTypes: {
useAllAssignmentTypes: jest.fn(),
},
filters: {
useSelectableAssignmentLabels: jest.fn(),
useAssignmentType: jest.fn(),
},
},
actions: {
filters: { useUpdateAssignmentType: jest.fn() },
},
}));
let out;
const testId = 'test-id';
const testKey = 'test-key';
const testType = 'test-type';
const allTypes = [testType, 'and', 'some', 'other', 'types'];
selectors.assignmentTypes.useAllAssignmentTypes.mockReturnValue(allTypes);
const event = { target: { value: testType } };
const testLabel = { label: testKey, id: testId, type: testType };
const selectableAssignmentLabels = [
{ label: 'some' },
{ label: 'test' },
{ label: 'labels' },
testLabel,
];
selectors.filters.useSelectableAssignmentLabels.mockReturnValue(selectableAssignmentLabels);
selectors.filters.useAssignmentType.mockReturnValue(testType);
const updateAssignmentType = jest.fn();
actions.filters.useUpdateAssignmentType.mockReturnValue(updateAssignmentType);
const updateQueryParams = jest.fn();
describe('useAssignmentTypeFilterData hook', () => {
beforeEach(() => {
out = useAssignmentTypeFilterData({ updateQueryParams });
});
describe('behavior', () => {
it('initializes redux hooks', () => {
expect(selectors.assignmentTypes.useAllAssignmentTypes).toHaveBeenCalledWith();
expect(selectors.filters.useSelectableAssignmentLabels).toHaveBeenCalledWith();
expect(selectors.filters.useAssignmentType).toHaveBeenCalledWith();
expect(actions.filters.useUpdateAssignmentType).toHaveBeenCalledWith();
});
});
describe('output', () => {
describe('handleEvent', () => {
beforeEach(() => {
out.handleChange(event);
});
it('updates assignmentType filter with selected filter', () => {
expect(updateAssignmentType).toHaveBeenCalledWith(testType);
});
it('updates queryParams', () => {
expect(updateQueryParams).toHaveBeenCalledWith({ assignmentType: testType });
});
});
describe('selectedAssignmentType', () => {
it('returns selected assignmentType', () => {
expect(out.selectedAssignmentType).toEqual(testType);
});
it('returns empty string if no assignmentType is selected', () => {
selectors.filters.useAssignmentType.mockReturnValue(undefined);
out = useAssignmentTypeFilterData({ updateQueryParams });
expect(out.selectedAssignmentType).toEqual('');
});
});
it('passes assignmentTypes from hook', () => {
expect(out.assignmentTypes).toEqual(allTypes);
});
test('assignmentTypes is empty object if hook returns undefined', () => {
selectors.assignmentTypes.useAllAssignmentTypes.mockReturnValue(undefined);
out = useAssignmentTypeFilterData({ updateQueryParams });
expect(out.assignmentTypes).toEqual({});
});
it('returns isDisabled if assigmentFilterOptions is empty', () => {
expect(out.isDisabled).toEqual(false);
selectors.assignmentTypes.useAllAssignmentTypes.mockReturnValue([]);
out = useAssignmentTypeFilterData({ updateQueryParams });
});
});
});

View File

@@ -1,81 +1,42 @@
/* 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 { useIntl } from '@edx/frontend-platform/i18n';
import SelectGroup from '../SelectGroup';
import messages from '../messages';
import useAssignmentTypeFilterData from './hooks';
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: '',
export const AssignmentTypeFilter = ({ updateQueryParams }) => {
const {
assignmentTypes,
handleChange,
isDisabled,
selectedAssignmentType,
} = useAssignmentTypeFilterData({ updateQueryParams });
const { formatMessage } = useIntl();
return (
<div className="student-filters">
<SelectGroup
id="assignment-types"
label={formatMessage(messages.assignmentTypes)}
value={selectedAssignmentType}
onChange={handleChange}
disabled={isDisabled}
options={[
<option key="0" value="">All</option>,
...assignmentTypes.map(entry => (
<option key={entry} value={entry}>{entry}</option>
)),
]}
/>
</div>
);
};
AssignmentTypeFilter.propTypes = {
updateQueryParams: PropTypes.func.isRequired,
// 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);
export default AssignmentTypeFilter;

View File

@@ -0,0 +1,47 @@
import React from 'react';
import { shallow } from 'enzyme';
import { useIntl } from '@edx/frontend-platform/i18n';
import SelectGroup from '../SelectGroup';
import useAssignmentFilterTypeData from './hooks';
import AssignmentFilterType from '.';
jest.mock('../SelectGroup', () => 'SelectGroup');
jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() }));
const handleChange = jest.fn();
const testType = 'test-type';
const assignmentTypes = [testType, 'type1', 'type2', 'type3'];
useAssignmentFilterTypeData.mockReturnValue({
handleChange,
selectedAssignmentType: testType,
assignmentTypes,
isDisabled: true,
});
const updateQueryParams = jest.fn();
let el;
describe('AssignmentFilterType component', () => {
beforeAll(() => {
el = shallow(<AssignmentFilterType updateQueryParams={updateQueryParams} />);
});
describe('behavior', () => {
it('initializes hooks', () => {
expect(useAssignmentFilterTypeData).toHaveBeenCalledWith({ updateQueryParams });
expect(useIntl).toHaveBeenCalledWith();
});
});
describe('render', () => {
test('snapshot', () => {
expect(el).toMatchSnapshot();
});
test('filter options', () => {
const { options } = el.find(SelectGroup).props();
expect(options.length).toEqual(5);
const optionProps = options[1].props;
expect(optionProps.value).toEqual(assignmentTypes[0]);
expect(optionProps.children).toEqual(testType);
});
});
});

View File

@@ -1,135 +0,0 @@
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,63 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CourseFilter component render if disabled snapshot 1`] = `
<Fragment>
<div
className="grade-filter-inputs"
>
<PercentGroup
id="minimum-grade"
label="Min Grade"
onChange={[MockFunction]}
value={23}
/>
<PercentGroup
id="maximum-grade"
label="Max Grade"
onChange={[MockFunction]}
value={300}
/>
</div>
<div
className="grade-filter-action"
>
<Button
disabled={true}
variant="outline-secondary"
>
Apply
</Button>
</div>
</Fragment>
`;
exports[`CourseFilter component render with selected assignment snapshot 1`] = `
<Fragment>
<div
className="grade-filter-inputs"
>
<PercentGroup
id="minimum-grade"
label="Min Grade"
onChange={[MockFunction]}
value={23}
/>
<PercentGroup
id="maximum-grade"
label="Max Grade"
onChange={[MockFunction]}
value={300}
/>
</div>
<div
className="grade-filter-action"
>
<Button
disabled={false}
variant="outline-secondary"
>
Apply
</Button>
</div>
</Fragment>
`;

View File

@@ -1,44 +0,0 @@
// 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,33 @@
import { actions, selectors, thunkActions } from 'data/redux/hooks';
export const useCourseGradeFilterData = ({
updateQueryParams,
}) => {
const isDisabled = !selectors.app.useAreCourseGradeFiltersValid();
const localCourseLimits = selectors.app.useCourseGradeLimits();
const fetchGrades = thunkActions.grades.useFetchGrades();
const setLocalFilter = actions.app.useSetLocalFilter();
const updateFilter = actions.filters.useUpdateCourseGradeLimits();
const handleApplyClick = () => {
updateFilter(localCourseLimits);
fetchGrades();
updateQueryParams(localCourseLimits);
};
const { courseGradeMin, courseGradeMax } = localCourseLimits;
return {
max: {
value: courseGradeMax,
onChange: (e) => setLocalFilter({ courseGradeMax: e.target.value }),
},
min: {
value: courseGradeMin,
onChange: (e) => setLocalFilter({ courseGradeMin: e.target.value }),
},
handleApplyClick,
isDisabled,
};
};
export default useCourseGradeFilterData;

View File

@@ -0,0 +1,78 @@
import { selectors, actions, thunkActions } from 'data/redux/hooks';
import useCourseTypeFilterData from './hooks';
jest.mock('data/redux/hooks', () => ({
selectors: {
app: {
useAreCourseGradeFiltersValid: jest.fn(),
useCourseGradeLimits: jest.fn(),
},
},
actions: {
app: { useSetLocalFilter: jest.fn() },
filters: { useUpdateCourseGradeLimits: jest.fn() },
},
thunkActions: {
grades: { useFetchGrades: jest.fn() },
},
}));
let out;
const courseGradeLimits = { courseGradeMax: 120, courseGradeMin: 32 };
selectors.app.useAreCourseGradeFiltersValid.mockReturnValue(true);
selectors.app.useCourseGradeLimits.mockReturnValue(courseGradeLimits);
const setLocalFilter = jest.fn();
actions.app.useSetLocalFilter.mockReturnValue(setLocalFilter);
const updateCourseGradeLimits = jest.fn();
actions.filters.useUpdateCourseGradeLimits.mockReturnValue(updateCourseGradeLimits);
const fetch = jest.fn();
thunkActions.grades.useFetchGrades.mockReturnValue(fetch);
const testValue = 55;
const updateQueryParams = jest.fn();
describe('useCourseTypeFilterData hook', () => {
beforeEach(() => {
jest.clearAllMocks();
out = useCourseTypeFilterData({ updateQueryParams });
});
describe('behavior', () => {
it('initializes redux hooks', () => {
expect(selectors.app.useAreCourseGradeFiltersValid).toHaveBeenCalledWith();
expect(selectors.app.useCourseGradeLimits).toHaveBeenCalledWith();
expect(actions.app.useSetLocalFilter).toHaveBeenCalledWith();
expect(actions.filters.useUpdateCourseGradeLimits).toHaveBeenCalledWith();
expect(thunkActions.grades.useFetchGrades).toHaveBeenCalledWith();
});
});
describe('output', () => {
it('returns isDisabled if assigmentFilterOptions is empty', () => {
expect(out.isDisabled).toEqual(false);
selectors.app.useAreCourseGradeFiltersValid.mockReturnValue(false);
out = useCourseTypeFilterData({ updateQueryParams });
expect(out.isDisabled).toEqual(true);
});
test('min value and onChange', () => {
const { courseGradeMin } = courseGradeLimits;
expect(out.min.value).toEqual(courseGradeMin);
out.min.onChange({ target: { value: testValue } });
expect(setLocalFilter).toHaveBeenCalledWith({ courseGradeMin: testValue });
});
test('max value and onChange', () => {
const { courseGradeMax } = courseGradeLimits;
expect(out.max.value).toEqual(courseGradeMax);
out.max.onChange({ target: { value: testValue } });
expect(setLocalFilter).toHaveBeenCalledWith({ courseGradeMax: testValue });
});
it('updates filter, fetches grades, and updates query params on apply click', () => {
out.handleApplyClick();
expect(updateCourseGradeLimits).toHaveBeenCalledWith(courseGradeLimits);
expect(fetch).toHaveBeenCalledWith();
expect(updateQueryParams).toHaveBeenCalledWith(courseGradeLimits);
});
});
});

View File

@@ -1,103 +1,52 @@
/* 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 { useIntl } from '@edx/frontend-platform/i18n';
import messages from '../messages';
import PercentGroup from '../PercentGroup';
import useCourseGradeFilterData from './hooks';
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);
}
export const CourseGradeFilter = ({ updateQueryParams }) => {
const {
max,
min,
isDisabled,
handleApplyClick,
} = useCourseGradeFilterData({ updateQueryParams });
const { formatMessage } = useIntl();
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>
</>
);
}
}
return (
<>
<div className="grade-filter-inputs">
<PercentGroup
id="minimum-grade"
label={formatMessage(messages.minGrade)}
value={min.value}
onChange={min.onChange}
/>
<PercentGroup
id="maximum-grade"
label={formatMessage(messages.maxGrade)}
value={max.value}
onChange={max.onChange}
/>
</div>
<div className="grade-filter-action">
<Button
variant="outline-secondary"
onClick={handleApplyClick}
disabled={isDisabled}
>
{formatMessage(messages.apply)}
</Button>
</div>
</>
);
};
CourseGradeFilter.propTypes = {
updateQueryParams: PropTypes.func.isRequired,
// 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);
export default CourseGradeFilter;

View File

@@ -0,0 +1,75 @@
import React from 'react';
import { shallow } from 'enzyme';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon';
import PercentGroup from '../PercentGroup';
import useCourseGradeFilterData from './hooks';
import CourseFilter from '.';
jest.mock('../PercentGroup', () => 'PercentGroup');
jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() }));
const hookData = {
handleChange: jest.fn(),
max: {
value: 300,
onChange: jest.fn(),
},
min: {
value: 23,
onChange: jest.fn(),
},
selectedCourse: 'test-assignment',
isDisabled: false,
};
useCourseGradeFilterData.mockReturnValue(hookData);
const updateQueryParams = jest.fn();
let el;
describe('CourseFilter component', () => {
beforeEach(() => {
jest.clearAllMocks();
el = shallow(<CourseFilter updateQueryParams={updateQueryParams} />);
});
describe('behavior', () => {
it('initializes hooks', () => {
expect(useCourseGradeFilterData).toHaveBeenCalledWith({ updateQueryParams });
expect(useIntl).toHaveBeenCalledWith();
});
});
describe('render', () => {
describe('with selected assignment', () => {
test('snapshot', () => {
expect(el).toMatchSnapshot();
});
it('renders a PercentGroup for both Max and Min filters', () => {
let props = el.find(PercentGroup).at(0).props();
expect(props.value).toEqual(hookData.min.value);
expect(props.onChange).toEqual(hookData.min.onChange);
props = el.find(PercentGroup).at(1).props();
expect(props.value).toEqual(hookData.max.value);
expect(props.onChange).toEqual(hookData.max.onChange);
});
it('renders a submit button', () => {
const props = el.find(Button).props();
expect(props.disabled).toEqual(false);
expect(props.onClick).toEqual(hookData.handleApplyClick);
});
});
describe('if disabled', () => {
beforeEach(() => {
useCourseGradeFilterData.mockReturnValueOnce({ ...hookData, isDisabled: true });
el = shallow(<CourseFilter updateQueryParams={updateQueryParams} />);
});
test('snapshot', () => {
expect(el).toMatchSnapshot();
});
it('disables submit', () => {
const props = el.find(Button).props();
expect(props.disabled).toEqual(true);
});
});
});
});

View File

@@ -1,150 +0,0 @@
/* 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,72 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`StudentGroupsFilter component render snapshot 1`] = `
<Fragment>
<SelectGroup
id="Tracks"
label="Tracks"
onChange={[MockFunction]}
options={
Array [
<option
value="Track-All"
>
Track-All
</option>,
<option
value="v1"
>
n1
</option>,
<option
value="v2"
>
n2
</option>,
<option
value="v3"
>
n3
</option>,
<option
value="v4"
>
n4
</option>,
]
}
value="test-track"
/>
<SelectGroup
disabled={false}
id="Cohorts"
label="Cohorts"
onChange={[MockFunction]}
options={
Array [
<option
value="Cohort-All"
>
Cohort-All
</option>,
<option
value="v1"
>
n1
</option>,
<option
value="v2"
>
n2
</option>,
<option
value="v3"
>
n3
</option>,
]
}
value="test-cohort"
/>
</Fragment>
`;

View File

@@ -1,190 +0,0 @@
// 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,46 @@
import { actions, selectors, thunkActions } from 'data/redux/hooks';
export const useStudentGroupsFilterData = ({ updateQueryParams }) => {
const selectedCohortEntry = selectors.root.useSelectedCohortEntry();
const selectedTrackEntry = selectors.root.useSelectedTrackEntry();
const cohorts = selectors.cohorts.useAllCohorts();
const tracks = selectors.tracks.useAllTracks();
const updateCohort = actions.filters.useUpdateCohort();
const updateTrack = actions.filters.useUpdateTrack();
const fetchGrades = thunkActions.grades.useFetchGrades();
const handleUpdateTrack = (event) => {
const selectedTrackItem = tracks.find(track => track.slug === event.target.value);
const track = selectedTrackItem ? selectedTrackItem.slug.toString() : null;
updateQueryParams({ track });
updateTrack(track);
fetchGrades();
};
const handleUpdateCohort = (event) => {
const selectedCohortItem = cohorts.find(cohort => cohort.id === parseInt(event.target.value, 10));
const cohort = selectedCohortItem ? selectedCohortItem.id.toString() : null;
// the param expected to be cohort_id
updateQueryParams({ cohort });
updateCohort(cohort);
fetchGrades();
};
return {
cohorts: {
value: selectedCohortEntry?.id || '',
isDisabled: cohorts.length === 0,
handleChange: handleUpdateCohort,
entries: cohorts.map(({ id: value, name }) => ({ value, name })),
},
tracks: {
value: selectedTrackEntry?.slug || '',
handleChange: handleUpdateTrack,
entries: tracks.map(({ slug: value, name }) => ({ value, name })),
},
};
};
export default useStudentGroupsFilterData;

View File

@@ -0,0 +1,141 @@
import { selectors, actions, thunkActions } from 'data/redux/hooks';
import useAssignmentFilterData from './hooks';
jest.mock('data/redux/hooks', () => ({
selectors: {
root: {
useSelectedCohortEntry: jest.fn(),
useSelectedTrackEntry: jest.fn(),
},
cohorts: { useAllCohorts: jest.fn() },
tracks: { useAllTracks: jest.fn() },
},
actions: {
filters: {
useUpdateCohort: jest.fn(),
useUpdateTrack: jest.fn(),
},
},
thunkActions: {
grades: { useFetchGrades: jest.fn() },
},
}));
let out;
const testCohort = { name: 'cohort-name', id: 999 };
selectors.root.useSelectedCohortEntry.mockReturnValue(testCohort);
const testTrack = { name: 'track-name', slug: 8080 };
selectors.root.useSelectedTrackEntry.mockReturnValue(testTrack);
const allCohorts = [
testCohort,
{ name: 'cohort1', id: 11 },
{ name: 'cohort2', id: 22 },
{ name: 'cohort3', id: 33 },
];
selectors.cohorts.useAllCohorts.mockReturnValue(allCohorts);
const allTracks = [
testTrack,
{ name: 'track1', slug: 111 },
{ name: 'track2', slug: 222 },
{ name: 'track3', slug: 333 },
];
selectors.tracks.useAllTracks.mockReturnValue(allTracks);
const updateCohort = jest.fn();
actions.filters.useUpdateCohort.mockReturnValue(updateCohort);
const updateTrack = jest.fn();
actions.filters.useUpdateTrack.mockReturnValue(updateTrack);
const fetch = jest.fn();
thunkActions.grades.useFetchGrades.mockReturnValue(fetch);
const updateQueryParams = jest.fn();
describe('useAssignmentFilterData hook', () => {
beforeEach(() => {
jest.clearAllMocks();
out = useAssignmentFilterData({ updateQueryParams });
});
describe('behavior', () => {
it('initializes redux hooks', () => {
expect(selectors.root.useSelectedCohortEntry).toHaveBeenCalledWith();
expect(selectors.root.useSelectedTrackEntry).toHaveBeenCalledWith();
expect(selectors.cohorts.useAllCohorts).toHaveBeenCalledWith();
expect(selectors.tracks.useAllTracks).toHaveBeenCalledWith();
expect(actions.filters.useUpdateCohort).toHaveBeenCalledWith();
expect(actions.filters.useUpdateTrack).toHaveBeenCalledWith();
expect(thunkActions.grades.useFetchGrades).toHaveBeenCalledWith();
});
});
describe('output', () => {
describe('cohorts', () => {
test('value from hook', () => {
expect(out.cohorts.value).toEqual(testCohort.id);
});
test('disabled iff no cohorts found', () => {
expect(out.cohorts.isDisabled).toEqual(false);
selectors.cohorts.useAllCohorts.mockReturnValueOnce([]);
out = useAssignmentFilterData({ updateQueryParams });
expect(out.cohorts.isDisabled).toEqual(true);
});
test('entries map id to value', () => {
const { entries } = out.cohorts;
expect(entries[0]).toEqual({ value: testCohort.id, name: testCohort.name });
expect(entries[1]).toEqual({ value: allCohorts[1].id, name: allCohorts[1].name });
expect(entries[2]).toEqual({ value: allCohorts[2].id, name: allCohorts[2].name });
expect(entries[3]).toEqual({ value: allCohorts[3].id, name: allCohorts[3].name });
});
test('value defaults to empty string', () => {
selectors.root.useSelectedCohortEntry.mockReturnValueOnce(null);
out = useAssignmentFilterData({ updateQueryParams });
expect(out.cohorts.value).toEqual('');
});
describe('handleEvent', () => {
it('updates filter and query params and fetches grades', () => {
out.cohorts.handleChange({ target: { value: testCohort.id } });
expect(updateCohort).toHaveBeenCalledWith(testCohort.id.toString());
expect(updateQueryParams).toHaveBeenCalledWith({ cohort: testCohort.id.toString() });
expect(fetch).toHaveBeenCalled();
});
it('passes null if no matching track is found', () => {
out.cohorts.handleChange({ target: { value: 'fake-name' } });
expect(updateCohort).toHaveBeenCalledWith(null);
expect(updateQueryParams).toHaveBeenCalledWith({ cohort: null });
expect(fetch).toHaveBeenCalled();
});
});
});
describe('tracks', () => {
test('value from hook', () => {
expect(out.tracks.value).toEqual(testTrack.slug);
});
test('entries map slug to value', () => {
const { entries } = out.tracks;
expect(entries[0]).toEqual({ value: testTrack.slug, name: testTrack.name });
expect(entries[1]).toEqual({ value: allTracks[1].slug, name: allTracks[1].name });
expect(entries[2]).toEqual({ value: allTracks[2].slug, name: allTracks[2].name });
expect(entries[3]).toEqual({ value: allTracks[3].slug, name: allTracks[3].name });
});
test('value defaults to empty string', () => {
selectors.root.useSelectedTrackEntry.mockReturnValueOnce(null);
out = useAssignmentFilterData({ updateQueryParams });
expect(out.tracks.value).toEqual('');
});
describe('handleEvent', () => {
it('updates filter and query params and fetches grades', () => {
out.tracks.handleChange({ target: { value: testTrack.slug } });
expect(updateTrack).toHaveBeenCalledWith(testTrack.slug.toString());
expect(updateQueryParams).toHaveBeenCalledWith({ track: testTrack.slug.toString() });
expect(fetch).toHaveBeenCalled();
});
it('passes null if no matching track is found', () => {
out.tracks.handleChange({ target: { value: 'fake-name' } });
expect(updateTrack).toHaveBeenCalledWith(null);
expect(updateQueryParams).toHaveBeenCalledWith({ track: null });
expect(fetch).toHaveBeenCalled();
});
});
});
});
});

View File

@@ -1,152 +1,53 @@
/* 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 { useIntl } from '@edx/frontend-platform/i18n';
import messages from '../messages';
import SelectGroup from '../SelectGroup';
import useStudentGroupsFilterData from './hooks';
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>),
),
];
const mapOptions = ({ value, name }) => (
<option key={name} value={value}>{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: {},
export const StudentGroupsFilter = ({ updateQueryParams }) => {
const { tracks, cohorts } = useStudentGroupsFilterData({ updateQueryParams });
const { formatMessage } = useIntl();
return (
<>
<SelectGroup
id="Tracks"
label={formatMessage(messages.tracks)}
value={tracks.value}
onChange={tracks.handleChange}
options={[
<option value={formatMessage(messages.trackAll)} key="0">
{formatMessage(messages.trackAll)}
</option>,
...tracks.entries.map(mapOptions),
]}
/>
<SelectGroup
id="Cohorts"
label={formatMessage(messages.cohorts)}
value={cohorts.value}
disabled={cohorts.isDisabled}
onChange={cohorts.handleChange}
options={[
<option value={formatMessage(messages.cohortAll)} key="0">
{formatMessage(messages.cohortAll)}
</option>,
...cohorts.entries.map(mapOptions),
]}
/>
</>
);
};
StudentGroupsFilter.propTypes = {
updateQueryParams: PropTypes.func.isRequired,
// 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));
export default StudentGroupsFilter;

View File

@@ -0,0 +1,84 @@
import React from 'react';
import { shallow } from 'enzyme';
import { useIntl } from '@edx/frontend-platform/i18n';
import SelectGroup from '../SelectGroup';
import useStudentGroupsFilterData from './hooks';
import StudentGroupsFilter from '.';
jest.mock('../SelectGroup', () => 'SelectGroup');
jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() }));
const props = {
cohorts: {
value: 'test-cohort',
entries: [
{ value: 'v1', name: 'n1' },
{ value: 'v2', name: 'n2' },
{ value: 'v3', name: 'n3' },
],
handleChange: jest.fn(),
isDisabled: false,
},
tracks: {
value: 'test-track',
entries: [
{ value: 'v1', name: 'n1' },
{ value: 'v2', name: 'n2' },
{ value: 'v3', name: 'n3' },
{ value: 'v4', name: 'n4' },
],
handleChange: jest.fn(),
},
};
useStudentGroupsFilterData.mockReturnValue(props);
const updateQueryParams = jest.fn();
let el;
describe('StudentGroupsFilter component', () => {
beforeAll(() => {
jest.clearAllMocks();
el = shallow(<StudentGroupsFilter updateQueryParams={updateQueryParams} />);
});
describe('behavior', () => {
it('initializes hooks', () => {
expect(useStudentGroupsFilterData).toHaveBeenCalledWith({ updateQueryParams });
expect(useIntl).toHaveBeenCalledWith();
});
});
describe('render', () => {
test('snapshot', () => {
expect(el).toMatchSnapshot();
});
test('track options', () => {
const {
options,
onChange,
value,
} = el.find(SelectGroup).at(0).props();
expect(value).toEqual(props.tracks.value);
expect(onChange).toEqual(props.tracks.handleChange);
expect(options.length).toEqual(5);
const testEntry = props.tracks.entries[0];
const optionProps = options[1].props;
expect(optionProps.value).toEqual(testEntry.value);
expect(optionProps.children).toEqual(testEntry.name);
});
test('cohort options', () => {
const {
options,
onChange,
disabled,
value,
} = el.find(SelectGroup).at(1).props();
expect(value).toEqual(props.cohorts.value);
expect(disabled).toEqual(false);
expect(onChange).toEqual(props.cohorts.handleChange);
expect(options.length).toEqual(4);
const testEntry = props.cohorts.entries[0];
const optionProps = options[1].props;
expect(optionProps.value).toEqual(testEntry.value);
expect(optionProps.children).toEqual(testEntry.name);
});
});
});

View File

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

@@ -4,29 +4,22 @@ exports[`PercentGroup Component snapshots basic snapshot 1`] = `
<div
className="percent-group"
>
<FormGroup
as="div"
<Form.Group
controlId="group id"
isInvalid={false}
isValid={false}
>
<FormLabel
isInline={false}
>
<Form.Label>
Group Label
</FormLabel>
<ForwardRef
as="input"
</Form.Label>
<Form.Control
disabled={false}
max={100}
min={0}
onChange={[MockFunction props.onChange]}
plaintext={false}
step={1}
type="number"
value="group VALUE"
/>
</FormGroup>
</Form.Group>
<span
className="input-percent-label"
>
@@ -39,29 +32,22 @@ exports[`PercentGroup Component snapshots disabled 1`] = `
<div
className="percent-group"
>
<FormGroup
as="div"
<Form.Group
controlId="group id"
isInvalid={false}
isValid={false}
>
<FormLabel
isInline={false}
>
<Form.Label>
Group Label
</FormLabel>
<ForwardRef
as="input"
</Form.Label>
<Form.Control
disabled={true}
max={100}
min={0}
onChange={[MockFunction props.onChange]}
plaintext={false}
step={1}
type="number"
value="group VALUE"
/>
</FormGroup>
</Form.Group>
<span
className="input-percent-label"
>

View File

@@ -4,22 +4,16 @@ exports[`SelectGroup Component snapshots basic snapshot 1`] = `
<div
className="student-filters"
>
<FormGroup
as="div"
<Form.Group
controlId="group id"
isInvalid={false}
isValid={false}
>
<FormLabel
isInline={false}
>
<Form.Label>
Group Label
</FormLabel>
<ForwardRef
</Form.Label>
<Form.Control
as="select"
disabled={false}
onChange={[MockFunction props.onChange]}
plaintext={false}
value="group VALUE"
>
<option
@@ -40,8 +34,8 @@ exports[`SelectGroup Component snapshots basic snapshot 1`] = `
>
Option 3
</option>
</ForwardRef>
</FormGroup>
</Form.Control>
</Form.Group>
</div>
`;
@@ -49,22 +43,16 @@ exports[`SelectGroup Component snapshots disabled 1`] = `
<div
className="student-filters"
>
<FormGroup
as="div"
<Form.Group
controlId="group id"
isInvalid={false}
isValid={false}
>
<FormLabel
isInline={false}
>
<Form.Label>
Group Label
</FormLabel>
<ForwardRef
</Form.Label>
<Form.Control
as="select"
disabled={true}
onChange={[MockFunction props.onChange]}
plaintext={false}
value="group VALUE"
>
<option
@@ -85,7 +73,7 @@ exports[`SelectGroup Component snapshots disabled 1`] = `
>
Option 3
</option>
</ForwardRef>
</FormGroup>
</Form.Control>
</Form.Group>
</div>
`;

View File

@@ -0,0 +1,70 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`GradebookFilters render snapshot 1`] = `
<Fragment>
<div
className="filter-sidebar-header"
>
<h2>
<Icon
className="fa fa-filter"
/>
</h2>
<IconButton
alt="Close Filters"
aria-label="Close Filters"
className="p-1"
iconAs="Icon"
onClick={[MockFunction hook.closeMenu]}
src="Close"
/>
</div>
<Collapsible
className="filter-group mb-3"
defaultOpen={true}
title="Assignments"
>
<div>
<AssignmentTypeFilter
updateQueryParams={[MockFunction]}
/>
<AssignmentFilter
updateQueryParams={[MockFunction]}
/>
<AssignmentGradeFilter
updateQueryParams={[MockFunction]}
/>
</div>
</Collapsible>
<Collapsible
className="filter-group mb-3"
defaultOpen={true}
title="Overall Grade"
>
<CourseGradeFilter
updateQueryParams={[MockFunction]}
/>
</Collapsible>
<Collapsible
className="filter-group mb-3"
defaultOpen={true}
title="Student Groups"
>
<StudentGroupsFilter
updateQueryParams={[MockFunction]}
/>
</Collapsible>
<Collapsible
className="filter-group mb-3"
defaultOpen={true}
title="Include Course Team Members"
>
<Form.Checkbox
checked={true}
onChange={[MockFunction hook.handleChange]}
>
Include Course Team Members
</Form.Checkbox>
</Collapsible>
</Fragment>
`;

View File

@@ -1,98 +0,0 @@
// 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>
<Connect(AssignmentTypeFilter)
updateQueryParams={[MockFunction]}
/>
<Connect(AssignmentFilter)
updateQueryParams={[MockFunction]}
/>
<Connect(AssignmentGradeFilter)
updateQueryParams={[MockFunction]}
/>
</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"
/>
}
>
<Connect(CourseGradeFilter)
updateQueryParams={[MockFunction]}
/>
</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"
/>
}
>
<InjectIntl(ShimmedIntlComponent)
updateQueryParams={[MockFunction]}
/>
</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,23 @@
import { actions, selectors, thunkActions } from 'data/redux/hooks';
export const useGradebookFiltersData = ({ updateQueryParams }) => {
const includeCourseRoleMembers = selectors.filters.useIncludeCourseRoleMembers();
const updateIncludeCourseRoleMembers = actions.filters.useUpdateIncludeCourseRoleMembers();
const closeMenu = thunkActions.app.filterMenu.useCloseMenu();
const fetchGrades = thunkActions.grades.useFetchGrades();
const handleIncludeTeamMembersChange = ({ target: { checked } }) => {
updateIncludeCourseRoleMembers(checked);
fetchGrades();
updateQueryParams({ includeCourseRoleMembers: checked });
};
return {
closeMenu,
includeCourseTeamMembers: {
handleChange: handleIncludeTeamMembersChange,
value: includeCourseRoleMembers,
},
};
};
export default useGradebookFiltersData;

View File

@@ -0,0 +1,59 @@
import { actions, selectors, thunkActions } from 'data/redux/hooks';
import * as hooks from './hooks';
jest.mock('data/redux/hooks', () => ({
actions: {
filters: { useUpdateIncludeCourseRoleMembers: jest.fn() },
},
selectors: {
filters: { useIncludeCourseRoleMembers: jest.fn() },
},
thunkActions: {
app: {
filterMenu: { useCloseMenu: jest.fn() },
},
grades: { useFetchGrades: jest.fn() },
},
}));
selectors.filters.useIncludeCourseRoleMembers.mockReturnValue(true);
const updateIncludeCourseRoleMembers = jest.fn();
actions.filters.useUpdateIncludeCourseRoleMembers.mockReturnValue(updateIncludeCourseRoleMembers);
const closeFilterMenu = jest.fn();
thunkActions.app.filterMenu.useCloseMenu.mockReturnValue(closeFilterMenu);
const fetchGrades = jest.fn();
thunkActions.grades.useFetchGrades.mockReturnValue(fetchGrades);
const updateQueryParams = jest.fn();
let out;
describe('GradebookFiltersData component hooks', () => {
describe('useGradebookFiltersData', () => {
beforeEach(() => {
out = hooks.useGradebookFiltersData({ updateQueryParams });
});
describe('behavior', () => {
it('initializes hooks', () => {
expect(actions.filters.useUpdateIncludeCourseRoleMembers).toHaveBeenCalledWith();
expect(selectors.filters.useIncludeCourseRoleMembers).toHaveBeenCalledWith();
expect(thunkActions.app.filterMenu.useCloseMenu).toHaveBeenCalledWith();
expect(thunkActions.grades.useFetchGrades).toHaveBeenCalledWith();
});
});
describe('output', () => {
test('closeMenu', () => {
expect(out.closeMenu).toEqual(closeFilterMenu);
});
test('includeCourseTeamMembers value', () => {
expect(out.includeCourseTeamMembers.value).toEqual(true);
});
test('includeCourseTeamMembers handleChange', () => {
const event = { target: { checked: false } };
out.includeCourseTeamMembers.handleChange(event);
expect(updateIncludeCourseRoleMembers).toHaveBeenCalledWith(false);
expect(fetchGrades).toHaveBeenCalledWith();
expect(updateQueryParams).toHaveBeenCalledWith({ includeCourseRoleMembers: false });
});
});
});
});

View File

@@ -1,7 +1,5 @@
/* 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,
@@ -10,11 +8,7 @@ import {
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 { useIntl } from '@edx/frontend-platform/i18n';
import messages from './messages';
import AssignmentTypeFilter from './AssignmentTypeFilter';
@@ -22,103 +16,74 @@ import AssignmentFilter from './AssignmentFilter';
import AssignmentGradeFilter from './AssignmentGradeFilter';
import CourseGradeFilter from './CourseGradeFilter';
import StudentGroupsFilter from './StudentGroupsFilter';
import useGradebookFiltersData from './hooks';
export class GradebookFilters extends React.Component {
constructor(props) {
super(props);
this.state = {
includeCourseRoleMembers: this.props.includeCourseRoleMembers,
};
this.handleIncludeTeamMembersChange = this.handleIncludeTeamMembersChange.bind(this);
}
export const GradebookFilters = ({ updateQueryParams }) => {
const {
closeMenu,
includeCourseTeamMembers,
} = useGradebookFiltersData({ updateQueryParams });
const { formatMessage } = useIntl();
const collapsibleClassName = 'filter-group mb-3';
return (
<>
<div className="filter-sidebar-header">
<h2><Icon className="fa fa-filter" /></h2>
<IconButton
className="p-1"
onClick={closeMenu}
iconAs={Icon}
src={Close}
alt={formatMessage(messages.closeFilters)}
aria-label={formatMessage(messages.closeFilters)}
/>
</div>
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)}
/>
<Collapsible
title={formatMessage(messages.assignments)}
defaultOpen
className={collapsibleClassName}
>
<div>
<AssignmentTypeFilter updateQueryParams={updateQueryParams} />
<AssignmentFilter updateQueryParams={updateQueryParams} />
<AssignmentGradeFilter updateQueryParams={updateQueryParams} />
</div>
</Collapsible>
{this.collapsibleGroup(messages.assignments, (
<div>
<AssignmentTypeFilter updateQueryParams={updateQueryParams} />
<AssignmentFilter updateQueryParams={updateQueryParams} />
<AssignmentGradeFilter updateQueryParams={updateQueryParams} />
</div>
))}
<Collapsible
title={formatMessage(messages.overallGrade)}
defaultOpen
className={collapsibleClassName}
>
<CourseGradeFilter updateQueryParams={updateQueryParams} />
</Collapsible>
{this.collapsibleGroup(messages.overallGrade, (
<CourseGradeFilter updateQueryParams={updateQueryParams} />
))}
<Collapsible
title={formatMessage(messages.studentGroups)}
defaultOpen
className={collapsibleClassName}
>
<StudentGroupsFilter updateQueryParams={updateQueryParams} />
</Collapsible>
{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,
<Collapsible
title={formatMessage(messages.includeCourseTeamMembers)}
defaultOpen
className={collapsibleClassName}
>
<Form.Checkbox
checked={includeCourseTeamMembers.value}
onChange={includeCourseTeamMembers.handleChange}
>
{formatMessage(messages.includeCourseTeamMembers)}
</Form.Checkbox>
</Collapsible>
</>
);
};
GradebookFilters.propTypes = {
updateQueryParams: PropTypes.func.isRequired,
// 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));
export default GradebookFilters;

View File

@@ -0,0 +1,82 @@
import React from 'react';
import { shallow } from 'enzyme';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Collapsible } from '@edx/paragon';
import { formatMessage } from 'testUtils';
import AssignmentTypeFilter from './AssignmentTypeFilter';
import AssignmentFilter from './AssignmentFilter';
import AssignmentGradeFilter from './AssignmentGradeFilter';
import CourseGradeFilter from './CourseGradeFilter';
import StudentGroupsFilter from './StudentGroupsFilter';
import messages from './messages';
import useGradebookFiltersData from './hooks';
import GradebookFilters from '.';
jest.mock('./AssignmentTypeFilter', () => 'AssignmentTypeFilter');
jest.mock('./AssignmentFilter', () => 'AssignmentFilter');
jest.mock('./AssignmentGradeFilter', () => 'AssignmentGradeFilter');
jest.mock('./CourseGradeFilter', () => 'CourseGradeFilter');
jest.mock('./StudentGroupsFilter', () => 'StudentGroupsFilter');
jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() }));
const hookProps = {
closeMenu: jest.fn().mockName('hook.closeMenu'),
includeCourseTeamMembers: {
value: true,
handleChange: jest.fn().mockName('hook.handleChange'),
},
};
useGradebookFiltersData.mockReturnValue(hookProps);
let el;
const updateQueryParams = jest.fn();
describe('GradebookFilters', () => {
beforeEach(() => {
jest.clearAllMocks();
el = shallow(<GradebookFilters updateQueryParams={updateQueryParams} />);
});
describe('behavior', () => {
it('initializes hooks', () => {
expect(useGradebookFiltersData).toHaveBeenCalledWith({ updateQueryParams });
expect(useIntl).toHaveBeenCalledWith();
});
});
describe('render', () => {
test('snapshot', () => {
expect(el).toMatchSnapshot();
});
test('Assignment filters', () => {
expect(el.find(Collapsible).at(0).children()).toMatchObject(shallow(
<div>
<AssignmentTypeFilter updateQueryParams={updateQueryParams} />
<AssignmentFilter updateQueryParams={updateQueryParams} />
<AssignmentGradeFilter updateQueryParams={updateQueryParams} />
</div>,
));
});
test('CourseGrade filters', () => {
expect(el.find(Collapsible).at(1).children()).toMatchObject(shallow(
<CourseGradeFilter updateQueryParams={updateQueryParams} />,
));
});
test('StudentGroups filters', () => {
expect(el.find(Collapsible).at(2).children()).toMatchObject(shallow(
<StudentGroupsFilter updateQueryParams={updateQueryParams} />,
));
});
test('includeCourseTeamMembers', () => {
const checkbox = el.find(Collapsible).at(3).children();
expect(checkbox.props()).toEqual({
checked: true,
onChange: hookProps.includeCourseTeamMembers.handleChange,
children: formatMessage(messages.includeCourseTeamMembers),
});
});
});
});

View File

@@ -66,6 +66,11 @@ const messages = defineMessages({
defaultMessage: 'Close Filters',
description: 'Button label for Close button in Gradebook Filters',
},
apply: {
id: 'gradebook.GradebookFilters.apply',
defaultMessage: 'Apply',
description: 'Apply filter button text',
},
});
export default messages;

View File

@@ -1,121 +0,0 @@
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('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(),
};
});
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,
);
});
});
});
});

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