Compare commits

..

54 Commits

Author SHA1 Message Date
jszewczulak
4b2e46e387 feat: lint fix 2023-05-11 09:33:03 -04:00
jszewczulak
7ce1808b0a feat: added lightning.js 2023-05-11 09:33:03 -04:00
jszewczulak
e62cc3d135 feat: added logic to render feedback widget in PROD only 2023-05-11 09:33:03 -04:00
jszewczulak
c7a3cc0341 feat: added logic to render feedback widget in PROD only 2023-05-11 09:33:00 -04:00
jszewczulak
113f66783a feat: added new property 2023-05-11 09:30:47 -04:00
jszewczulak
19230c4506 feat: added "Get Feedback" widget 2023-05-11 09:30:47 -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
150 changed files with 26756 additions and 31633 deletions

1
.env
View File

@@ -32,3 +32,4 @@ ENTERPRISE_MARKETING_UTM_CAMPAIGN=''
ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM=''
APP_ID=''
MFE_CONFIG_API_URL=''
DISPLAY_FEEDBACK_WIDGET='true'

View File

@@ -39,3 +39,4 @@ ENTERPRISE_MARKETING_UTM_CAMPAIGN='example.com Referral'
ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM='Footer'
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'],
},
},
};

View File

@@ -16,4 +16,4 @@ jobs:
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 }}
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

View File

@@ -7,4 +7,4 @@ on:
jobs:
commitlint:
uses: edx/.github/.github/workflows/commitlint.yml@master
uses: openedx/.github/.github/workflows/commitlint.yml@master

View File

@@ -10,4 +10,4 @@ on:
jobs:
version-check:
uses: edx/.github/.github/workflows/lockfileversion-check.yml@master
uses: openedx/.github/.github/workflows/lockfileversion-check.yml@master

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

28
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,15 +49,29 @@ 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 --languages=$(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
$(intl_imports) frontend-component-header frontend-component-footer frontend-app-gradebook
endif
# This target is used by CI.
validate-no-uncommitted-package-lock-changes:

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)
[![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)
@@ -57,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.
@@ -117,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

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

51520
package-lock.json generated

File diff suppressed because it is too large Load Diff

37
package.json Executable file → Normal file
View File

@@ -1,10 +1,10 @@
{
"name": "@edx/frontend-app-gradebook",
"version": "1.6.0",
"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",
@@ -20,21 +20,20 @@
},
"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": [
"last 2 versions",
"not ie > 0",
"not ie_mob > 0"
"extends @edx/browserslist-config"
],
"dependencies": {
"@edx/brand": "npm:@edx/brand-edx.org@^1.3.2",
"@edx/frontend-component-footer": "^11.1.1",
"@edx/frontend-component-header": "^3.1.1",
"@edx/frontend-platform": "2.5.0",
"@edx/paragon": "19.6.0",
"@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",
@@ -48,12 +47,11 @@
"enzyme-to-json": "^3.6.2",
"font-awesome": "4.7.0",
"history": "4.10.1",
"prop-types": "15.7.2",
"prop-types": "15.8.1",
"query-string": "6.13.0",
"react": "16.14.0",
"react-dom": "16.14.0",
"react-helmet": "^6.1.0",
"react-intl": "^2.9.0",
"react-redux": "^7.1.1",
"react-router": "5.2.0",
"react-router-dom": "5.2.0",
@@ -65,23 +63,24 @@
"redux-thunk": "2.3.0",
"regenerator-runtime": "^0.13.7",
"sass": "^1.49.0",
"util": "^0.12.3",
"whatwg-fetch": "^2.0.4"
},
"devDependencies": {
"@edx/frontend-build": "9.1.1",
"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",
"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": "29.3.1",
"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"
"semantic-release": "^19.0.3"
}
}

View File

@@ -4,7 +4,7 @@ exports[`App router component snapshot 1`] = `
<AppProvider
store="testStore"
>
<injectIntl(ShimmedIntlComponent) />
<Head />
<BrowserRouter>
<div>
<Header />

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={[Function]}
/>
</div>
<Collapsible
className="filter-group mb-3"
defaultOpen={true}
title="Assignments"
>
<div>
<AssignmentTypeFilter
updateQueryParams={[MockFunction]}
/>
<AssignmentFilter
updateQueryParams={[MockFunction]}
/>
<AssignmentGradeFilter
updateQueryParams={[MockFunction]}
/>
</div>
</Collapsible>
<Collapsible
className="filter-group mb-3"
defaultOpen={true}
title="Overall Grade"
>
<CourseGradeFilter
updateQueryParams={[MockFunction]}
/>
</Collapsible>
<Collapsible
className="filter-group mb-3"
defaultOpen={true}
title="Student Groups"
>
<StudentGroupsFilter
updateQueryParams={[MockFunction]}
/>
</Collapsible>
<Collapsible
className="filter-group mb-3"
defaultOpen={true}
title="Include Course Team Members"
>
<Form.Checkbox
checked={true}
onChange={[MockFunction hook.handleChange]}
>
Include Course Team Members
</Form.Checkbox>
</Collapsible>
</Fragment>
`;

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>
<AssignmentTypeFilter
updateQueryParams={[MockFunction this.props.updateQueryParams]}
/>
<AssignmentFilter
updateQueryParams={[MockFunction this.props.updateQueryParams]}
/>
<AssignmentGradeFilter
updateQueryParams={[MockFunction this.props.updateQueryParams]}
/>
</div>
</Collapsible>
<Collapsible
className="filter-group mb-3"
defaultOpen={true}
title={
<FormattedMessage
defaultMessage="Overall Grade"
description="Overall Grade filter group label in Gradebook Filters"
id="gradebook.GradebookFilters.overallGradeFilterLabel"
/>
}
>
<CourseGradeFilter
updateQueryParams={[MockFunction this.props.updateQueryParams]}
/>
</Collapsible>
<Collapsible
className="filter-group mb-3"
defaultOpen={true}
title={
<FormattedMessage
defaultMessage="Student Groups"
description="Student Groups filter group label in Gradebook Filters"
id="gradebook.GradebookFilters.studentGroupsFilterLabel"
/>
}
>
<StudentGroupsFilter
updateQueryParams={[MockFunction this.props.updateQueryParams]}
/>
</Collapsible>
<Collapsible
className="filter-group mb-3"
defaultOpen={true}
title={
<FormattedMessage
defaultMessage="Include Course Team Members"
description="Include Course Team Members filter label in Gradebook Filters"
id="gradebook.GradebookFilters.includeCourseTeamMembersFilterLabel"
/>
}
>
<Checkbox
checked={true}
onChange={[MockFunction handleIncludeTeamMembersChange]}
>
<FormattedMessage
defaultMessage="Include Course Team Members"
description="Include Course Team Members filter label in Gradebook Filters"
id="gradebook.GradebookFilters.includeCourseTeamMembersFilterLabel"
/>
</Checkbox>
</Collapsible>
</React.Fragment>
`;

View File

@@ -0,0 +1,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.useCloseFilterMenu();
const fetchGrades = thunkActions.grades.useFetchGrades();
const handleIncludeTeamMembersChange = ({ target: { checked } }) => {
updateIncludeCourseRoleMembers(checked);
fetchGrades();
updateQueryParams({ includeCourseRoleMembers: checked });
};
return {
closeMenu,
includeCourseTeamMembers: {
handleChange: handleIncludeTeamMembersChange,
value: includeCourseRoleMembers,
},
};
};
export default useGradebookFiltersData;

View File

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

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

View File

@@ -29,9 +29,9 @@ exports[`GradebookHeader component snapshots default values (grades frozen, cann
<div
className="subtitle-row d-flex justify-content-between align-items-center"
>
<h3>
<h2>
fakeID
</h3>
</h2>
</div>
<div
className="alert alert-warning"
@@ -75,9 +75,9 @@ exports[`GradebookHeader component snapshots grades frozen, can view. grades fro
<div
className="subtitle-row d-flex justify-content-between align-items-center"
>
<h3>
<h2>
fakeID
</h3>
</h2>
</div>
<div
className="alert alert-warning"
@@ -121,9 +121,9 @@ exports[`GradebookHeader component snapshots grades frozen, cannot view unauthor
<div
className="subtitle-row d-flex justify-content-between align-items-center"
>
<h3>
<h2>
fakeID
</h3>
</h2>
</div>
<div
className="alert alert-warning"
@@ -177,9 +177,9 @@ exports[`GradebookHeader component snapshots show bulk management, active view i
<div
className="subtitle-row d-flex justify-content-between align-items-center"
>
<h3>
<h2>
fakeID
</h3>
</h2>
<Button
onClick={[MockFunction this.handleToggleViewClick]}
variant="tertiary"
@@ -233,9 +233,9 @@ exports[`GradebookHeader component snapshots show bulk management, active view i
<div
className="subtitle-row d-flex justify-content-between align-items-center"
>
<h3>
<h2>
fakeID
</h3>
</h2>
<Button
onClick={[MockFunction this.handleToggleViewClick]}
variant="tertiary"

View File

@@ -18,6 +18,11 @@ export class GradebookHeader extends React.Component {
this.handleToggleViewClick = this.handleToggleViewClick.bind(this);
}
handleToggleViewClick() {
const newView = this.props.activeView === views.grades ? views.bulkManagementHistory : views.grades;
this.props.setView(newView);
}
get toggleViewMessage() {
return this.props.activeView === views.grades
? messages.toActivityLog
@@ -28,11 +33,6 @@ export class GradebookHeader extends React.Component {
`${getConfig().LMS_BASE_URL}/courses/${courseId}/instructor`
);
handleToggleViewClick() {
const newView = this.props.activeView === views.grades ? views.bulkManagementHistory : views.grades;
this.props.setView(newView);
}
render() {
return (
<div className="gradebook-header">
@@ -47,7 +47,7 @@ export class GradebookHeader extends React.Component {
<FormattedMessage {...messages.gradebook} />
</h1>
<div className="subtitle-row d-flex justify-content-between align-items-center">
<h3>{this.props.courseId}</h3>
<h2>{this.props.courseId}</h2>
{ this.props.showBulkManagement && (
<Button
variant="tertiary"

View File

@@ -7,7 +7,7 @@ import { Form } from '@edx/paragon';
import selectors from 'data/selectors';
import actions from 'data/actions';
import { getLocale, isRtl } from '@edx/frontend-platform/i18n';
import { getLocalizedSlash } from 'i18n/utils';
/**
* <AdjustedGradeInput />
@@ -21,32 +21,19 @@ export class AdjustedGradeInput extends React.Component {
}
onChange = ({ target }) => {
let adjustedGradeValue;
switch (true) {
case target.value < 0:
adjustedGradeValue = 0;
break;
case this.props.possibleGrade && target.value > this.props.possibleGrade:
adjustedGradeValue = this.props.possibleGrade;
break;
default:
adjustedGradeValue = target.value;
}
this.props.setModalState({ adjustedGradeValue });
this.props.setModalState({ adjustedGradeValue: target.value });
};
render() {
return (
<span>
<Form.Control
type="number"
type="text"
name="adjustedGradeValue"
min="0"
max={this.props.possibleGrade ? this.props.possibleGrade : ''}
value={this.props.value}
onChange={this.onChange}
/>
{this.props.possibleGrade && ` ${isRtl(getLocale()) ? '\\' : '/'} ${this.props.possibleGrade}`}
{this.props.possibleGrade && ` ${getLocalizedSlash()} ${this.props.possibleGrade}`}
</span>
);
}

View File

@@ -54,34 +54,9 @@ describe('AdjustedGradeInput', () => {
});
describe('behavior', () => {
describe('onChange', () => {
it('calls props.setModalState event target value with correct value', () => {
const value = 3;
el.instance().onChange({ target: { value } });
expect(props.setModalState).toHaveBeenCalledWith({
adjustedGradeValue: value,
});
});
it('calls props.setModalState event target value with a value more then the possibleGrade value', () => {
it('calls props.setModalState event target value', () => {
const value = 42;
el.instance().onChange({ target: { value } });
expect(props.setModalState).toHaveBeenCalledWith({
adjustedGradeValue: props.possibleGrade,
});
});
it('calls props.setModalState event target value with less then 0', () => {
const value = -5;
el.instance().onChange({ target: { value } });
expect(props.setModalState).toHaveBeenCalledWith({
adjustedGradeValue: 0,
});
});
it('calls props.setModalState event target value without possibleGrade value', () => {
const value = 100;
const newEl = shallow(<AdjustedGradeInput {...props} possibleGrade={null} />);
newEl.instance().onChange({ target: { value } });
expect(props.setModalState).toHaveBeenCalledWith({
adjustedGradeValue: value,
});

View File

@@ -3,11 +3,9 @@
exports[`AdjustedGradeInput Component snapshots displays input control and "out of possible grade" label 1`] = `
<span>
<Control
max={5}
min="0"
name="adjustedGradeValue"
onChange={[MockFunction this.onChange]}
type="number"
type="text"
value={1}
/>
/ 5

View File

@@ -59,6 +59,6 @@ exports[`OverrideTable Component snapshots basic snapshot shows a row for each e
},
]
}
itemCount={3}
itemCount={2}
/>
`;

View File

@@ -26,16 +26,6 @@ export const OverrideTable = ({
if (hide) {
return null;
}
const tableData = [
...gradeOverrides,
{
adjustedGrade: <AdjustedGradeInput />,
date: todaysDate,
reason: <ReasonInput />,
},
];
return (
<DataTable
columns={[
@@ -47,8 +37,15 @@ export const OverrideTable = ({
accessor: columns.adjustedGrade,
},
]}
data={tableData}
itemCount={tableData.length}
data={[
...gradeOverrides,
{
adjustedGrade: <AdjustedGradeInput />,
date: todaysDate,
reason: <ReasonInput />,
},
]}
itemCount={gradeOverrides.length}
/>
);
};

View File

@@ -1,8 +1,15 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`EditMoal Component snapshots gradeOverrideHistoryError is and empty and open is true modal open and StatusAlert showing 1`] = `
<Modal
body={
exports[`EditModal Component snapshots gradeOverrideHistoryError is and empty and open is true modal open and StatusAlert showing 1`] = `
<ModalDialog
hasCloseButton={true}
isFullscreenOnMobile={true}
isOpen={true}
onClose={[MockFunction this.closeAssignmentModal]}
size="xl"
title="Edit Grades"
>
<ModalDialog.Body>
<div>
<ModalHeaders />
<Alert
@@ -28,9 +35,18 @@ exports[`EditMoal Component snapshots gradeOverrideHistoryError is and empty and
/>
</div>
</div>
}
buttons={
Array [
</ModalDialog.Body>
<ModalDialog.Footer>
<ActionRow>
<ModalDialog.CloseButton
variant="tertiary"
>
<FormattedMessage
defaultMessage="Cancel"
description="Edit Modal close button text"
id="gradebook.GradesView.EditModal.closeText"
/>
</ModalDialog.CloseButton>
<Button
onClick={[MockFunction this.handleAdjustedGradeClick]}
variant="primary"
@@ -40,31 +56,22 @@ exports[`EditMoal Component snapshots gradeOverrideHistoryError is and empty and
description="Edit Modal Save button label"
id="gradebook.GradesView.EditModal.saveGrade"
/>
</Button>,
]
}
closeText={
<FormattedMessage
defaultMessage="Cancel"
description="Edit Modal close button text"
id="gradebook.GradesView.EditModal.closeText"
/>
}
onClose={[MockFunction this.closeAssignmentModal]}
open={true}
title={
<FormattedMessage
defaultMessage="Edit Grades"
description="Edit Modal title"
id="gradebook.GradesView.EditModal.title"
/>
}
/>
</Button>
</ActionRow>
</ModalDialog.Footer>
</ModalDialog>
`;
exports[`EditMoal Component snapshots gradeOverrideHistoryError is empty and open is false modal closed and StatusAlert closed 1`] = `
<Modal
body={
exports[`EditModal Component snapshots gradeOverrideHistoryError is empty and open is false modal closed and StatusAlert closed 1`] = `
<ModalDialog
hasCloseButton={true}
isFullscreenOnMobile={true}
isOpen={false}
onClose={[MockFunction this.closeAssignmentModal]}
size="xl"
title="Edit Grades"
>
<ModalDialog.Body>
<div>
<ModalHeaders />
<Alert
@@ -90,9 +97,18 @@ exports[`EditMoal Component snapshots gradeOverrideHistoryError is empty and ope
/>
</div>
</div>
}
buttons={
Array [
</ModalDialog.Body>
<ModalDialog.Footer>
<ActionRow>
<ModalDialog.CloseButton
variant="tertiary"
>
<FormattedMessage
defaultMessage="Cancel"
description="Edit Modal close button text"
id="gradebook.GradesView.EditModal.closeText"
/>
</ModalDialog.CloseButton>
<Button
onClick={[MockFunction this.handleAdjustedGradeClick]}
variant="primary"
@@ -102,24 +118,8 @@ exports[`EditMoal Component snapshots gradeOverrideHistoryError is empty and ope
description="Edit Modal Save button label"
id="gradebook.GradesView.EditModal.saveGrade"
/>
</Button>,
]
}
closeText={
<FormattedMessage
defaultMessage="Cancel"
description="Edit Modal close button text"
id="gradebook.GradesView.EditModal.closeText"
/>
}
onClose={[MockFunction this.closeAssignmentModal]}
open={false}
title={
<FormattedMessage
defaultMessage="Edit Grades"
description="Edit Modal title"
id="gradebook.GradesView.EditModal.title"
/>
}
/>
</Button>
</ActionRow>
</ModalDialog.Footer>
</ModalDialog>
`;

View File

@@ -5,10 +5,11 @@ import { connect } from 'react-redux';
import {
Button,
Modal,
Alert,
ModalDialog,
ActionRow,
} from '@edx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import selectors from 'data/selectors';
import actions from 'data/actions';
@@ -46,11 +47,15 @@ export class EditModal extends React.Component {
render() {
return (
<Modal
open={this.props.open}
title={<FormattedMessage {...messages.title} />}
closeText={<FormattedMessage {...messages.closeText} />}
body={(
<ModalDialog
title={this.props.intl.formatMessage(messages.title)}
isOpen={this.props.open}
onClose={this.closeAssignmentModal}
size="xl"
hasCloseButton
isFullscreenOnMobile
>
<ModalDialog.Body>
<div>
<ModalHeaders />
<Alert
@@ -64,14 +69,18 @@ export class EditModal extends React.Component {
<div><FormattedMessage {...messages.visibility} /></div>
<div><FormattedMessage {...messages.saveVisibility} /></div>
</div>
)}
buttons={[
<Button variant="primary" onClick={this.handleAdjustedGradeClick}>
<FormattedMessage {...messages.saveGrade} />
</Button>,
]}
onClose={this.closeAssignmentModal}
/>
</ModalDialog.Body>
<ModalDialog.Footer>
<ActionRow>
<ModalDialog.CloseButton variant="tertiary">
<FormattedMessage {...messages.closeText} />
</ModalDialog.CloseButton>
<Button variant="primary" onClick={this.handleAdjustedGradeClick}>
<FormattedMessage {...messages.saveGrade} />
</Button>
</ActionRow>
</ModalDialog.Footer>
</ModalDialog>
);
}
}
@@ -87,6 +96,8 @@ EditModal.propTypes = {
closeModal: PropTypes.func.isRequired,
doneViewingAssignment: PropTypes.func.isRequired,
updateGrades: PropTypes.func.isRequired,
// injected
intl: intlShape.isRequired,
};
export const mapStateToProps = (state) => ({
@@ -100,4 +111,4 @@ export const mapDispatchToProps = {
updateGrades: thunkActions.grades.updateGrades,
};
export default connect(mapStateToProps, mapDispatchToProps)(EditModal);
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(EditModal));

View File

@@ -9,15 +9,11 @@ import {
EditModal,
mapDispatchToProps,
mapStateToProps,
} from '.';
}
from '.';
jest.mock('./OverrideTable', () => 'OverrideTable');
jest.mock('./ModalHeaders', () => 'ModalHeaders');
jest.mock('@edx/paragon', () => ({
Button: () => 'Button',
Modal: () => 'Modal',
Alert: () => 'Alert',
}));
jest.mock('data/actions', () => ({
__esModule: true,
default: {
@@ -44,7 +40,7 @@ jest.mock('data/selectors', () => ({
},
},
}));
describe('EditMoal', () => {
describe('EditModal', () => {
let props;
beforeEach(() => {
props = {
@@ -53,6 +49,8 @@ describe('EditMoal', () => {
closeModal: jest.fn(),
doneViewingAssignment: jest.fn(),
updateGrades: jest.fn(),
intl: { formatMessage: (msg) => msg.defaultMessage },
};
});

View File

@@ -2,20 +2,23 @@
import React from 'react';
import { shallow } from 'enzyme';
import * as constants from 'data/constants/filters';
import FilterBadges from '.';
import FilterBadge from './FilterBadge';
jest.mock('./FilterBadge', () => 'FilterBadge');
const order = ['filter1', 'filter2', 'filter3'];
jest.mock('data/constants/filters', () => ({
...jest.requireActual('data/constants/filters'),
badgeOrder: order,
}));
describe('FilterBadges', () => {
describe('component', () => {
let el;
let handleClose;
const order = ['filter1', 'filter2', 'filter3'];
beforeEach(() => {
handleClose = jest.fn().mockName('this.props.handleClose');
constants.badgeOrder = order;
el = shallow(<FilterBadges handleClose={handleClose} />);
});
test('snapshot - has a filterbadge with handleClose for each filter in badgeOrder', () => {

View File

@@ -29,18 +29,16 @@ Username.propTypes = {
};
/**
* Fields.Email
* Simple label field for email value.
* @param {string} email - email for display
* Fields.Text
* Simple label field for text value.
* @param {string} value - value for display
*/
const Email = ({ email }) => (
<span className="wrap-text-in-cell">{email}</span>
);
Email.propTypes = {
email: PropTypes.string.isRequired,
const Text = ({ value }) => (<span className="wrap-text-in-cell">{value}</span>);
Text.propTypes = {
value: PropTypes.string.isRequired,
};
export default StrictDict({
Email,
Text,
Username,
});

View File

@@ -41,13 +41,13 @@ describe('Gradebook Table Fields', () => {
});
});
describe('Email', () => {
const email = 'myTag@place.com';
describe('Text', () => {
const value = 'myTag@place.com';
test('snapshot', () => {
expect(shallow(<Fields.Email email={email} />)).toMatchSnapshot();
expect(shallow(<Fields.Text value={value} />)).toMatchSnapshot();
});
test('wraps entry email', () => {
expect(shallow(<Fields.Email email={email} />).text()).toEqual(email);
test('wraps entry value', () => {
expect(shallow(<Fields.Text value={value} />).text()).toEqual(value);
});
});
});

View File

@@ -45,6 +45,13 @@ const TotalGradeLabelReplacement = () => (
</div>
);
/**
* Asterisk to display next to heading labels that are only used for masters students
*/
const mastersOnlyFieldAsterisk = (
<span className="font-weight-normal">*</span>
);
/**
* <UsernameLabelReplacement />
* Username column header. Lists that Student Key is possibly available
@@ -56,11 +63,24 @@ const UsernameLabelReplacement = () => (
</div>
<div className="font-weight-normal student-key">
<FormattedMessage {...messages.studentKeyLabel} />
{ mastersOnlyFieldAsterisk }
</div>
</div>
);
/**
* <MastersOnlyLabelReplacement {message}>
* Column header for fields that are only available for masters students
*/
const MastersOnlyLabelReplacement = (message) => (
<div>
<FormattedMessage {...message} />
{ mastersOnlyFieldAsterisk }
</div>
);
export default StrictDict({
TotalGradeLabelReplacement,
UsernameLabelReplacement,
MastersOnlyLabelReplacement,
});

View File

@@ -9,6 +9,7 @@ import LabelReplacements from './LabelReplacements';
const {
TotalGradeLabelReplacement,
UsernameLabelReplacement,
MastersOnlyLabelReplacement,
} = LabelReplacements;
jest.mock('@edx/paragon', () => ({
@@ -35,6 +36,16 @@ describe('LabelReplacements', () => {
expect(shallow(<UsernameLabelReplacement />)).toMatchSnapshot();
});
});
describe('MastersOnlyLabelReplacement', () => {
test('snapshot', () => {
const message = {
id: 'id',
defaultMessage: 'defaultMessAge',
description: 'desCripTion',
};
expect(shallow(<MastersOnlyLabelReplacement {...message} />)).toMatchSnapshot();
});
});
});
describe('snapshot', () => {

View File

@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Gradebook Table Fields Email snapshot 1`] = `
exports[`Gradebook Table Fields Text snapshot 1`] = `
<span
className="wrap-text-in-cell"
>

View File

@@ -1,5 +1,20 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`LabelReplacements MastersOnlyLabelReplacement snapshot 1`] = `
<div>
<FormattedMessage
defaultMessage="defaultMessAge"
description="desCripTion"
id="id"
/>
<span
className="font-weight-normal"
>
*
</span>
</div>
`;
exports[`LabelReplacements TotalGradeLabelReplacement displays overlay tooltip 1`] = `
<Tooltip
id="course-grade-tooltip"
@@ -73,10 +88,15 @@ exports[`LabelReplacements UsernameLabelReplacement snapshot 1`] = `
className="font-weight-normal student-key"
>
<FormattedMessage
defaultMessage="Student Key*"
defaultMessage="Student Key"
description="Gradebook table Student Key label"
id="gradebook.GradesView.table.labels.studentKey"
/>
<span
className="font-weight-normal"
>
*
</span>
</div>
</div>
`;

View File

@@ -13,7 +13,15 @@ exports[`GradebookTable component snapshot - fields1 and 2 between email and tot
"accessor": "Username",
},
Object {
"Header": <FormattedMessage
"Header": <MastersOnlyLabelReplacement
defaultMessage="Full Name"
description="Gradebook table full name column header"
id="gradebook.GradesView.table.headings.fullName"
/>,
"accessor": "Full Name",
},
Object {
"Header": <MastersOnlyLabelReplacement
defaultMessage="Email"
description="Gradebook table email column header"
id="gradebook.GradesView.table.headings.email"
@@ -48,13 +56,7 @@ exports[`GradebookTable component snapshot - fields1 and 2 between email and tot
<DataTable.TableControlBar />
<DataTable.Table />
<DataTable.EmptyTable
content={
<FormattedMessage
defaultMessage="No results found"
description="Gradebook table message when no learner results were found"
id="gradebook.GradesView.table.noResultsFound"
/>
}
content="No results found"
/>
</DataTable>
</div>

View File

@@ -4,10 +4,11 @@ import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { DataTable } from '@edx/paragon';
import { FormattedMessage, getLocale, isRtl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import selectors from 'data/selectors';
import { Headings } from 'data/constants/grades';
import { getLocalizedPercentSign } from 'i18n/utils';
import messages from './messages';
import Fields from './Fields';
@@ -37,28 +38,27 @@ export class GradebookTable extends React.Component {
} else if (heading === Headings.username) {
label = <LabelReplacements.UsernameLabelReplacement />;
} else if (heading === Headings.email) {
label = <FormattedMessage {...messages.emailHeading} />;
label = <LabelReplacements.MastersOnlyLabelReplacement {...messages.emailHeading} />;
} else if (heading === Headings.fullName) {
label = <LabelReplacements.MastersOnlyLabelReplacement {...messages.fullNameHeading} />;
} else {
label = heading;
}
return { Header: label, accessor: heading };
}
mapRows(entry) {
const dataRow = {
[Headings.username]: (
<Fields.Username username={entry.username} userKey={entry.external_user_key} />
),
[Headings.email]: (<Fields.Email email={entry.email} />),
[Headings.totalGrade]: `${roundGrade(entry.percent * 100)}${isRtl(getLocale()) ? '\u200f' : ''}%`,
};
entry.section_breakdown.forEach(subsection => {
dataRow[subsection.label] = (
<GradeButton {...{ entry, subsection }} />
);
});
return dataRow;
}
mapRows = entry => ({
[Headings.username]: (
<Fields.Username username={entry.username} userKey={entry.external_user_key} />
),
[Headings.fullName]: (<Fields.Text value={entry.full_name} />),
[Headings.email]: (<Fields.Text value={entry.email} />),
[Headings.totalGrade]: `${roundGrade(entry.percent * 100)}${getLocalizedPercentSign()}`,
...entry.section_breakdown.reduce((acc, subsection) => ({
...acc,
[subsection.label]: <GradeButton {...{ entry, subsection }} />,
}), {}),
});
nullMethod() {
return null;
@@ -77,7 +77,7 @@ export class GradebookTable extends React.Component {
>
<DataTable.TableControlBar />
<DataTable.Table />
<DataTable.EmptyTable content={<FormattedMessage {...messages.noResultsFound} />} />
<DataTable.EmptyTable content={this.props.intl.formatMessage(messages.noResultsFound)} />
</DataTable>
</div>
);
@@ -106,6 +106,8 @@ GradebookTable.propTypes = {
user_name: PropTypes.string,
})),
headings: PropTypes.arrayOf(PropTypes.string).isRequired,
// injected
intl: intlShape.isRequired,
};
export const mapStateToProps = (state) => ({
@@ -113,4 +115,4 @@ export const mapStateToProps = (state) => ({
headings: selectors.root.getHeadings(state),
});
export default connect(mapStateToProps)(GradebookTable);
export default injectIntl(connect(mapStateToProps)(GradebookTable));

View File

@@ -1,6 +1,11 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
fullNameHeading: {
id: 'gradebook.GradesView.table.headings.fullName',
defaultMessage: 'Full Name',
description: 'Gradebook table full name column header',
},
emailHeading: {
id: 'gradebook.GradesView.table.headings.email',
defaultMessage: 'Email',
@@ -18,7 +23,7 @@ const messages = defineMessages({
},
studentKeyLabel: {
id: 'gradebook.GradesView.table.labels.studentKey',
defaultMessage: 'Student Key*',
defaultMessage: 'Student Key',
description: 'Gradebook table Student Key label',
},
usernameLabel: {

View File

@@ -2,7 +2,6 @@ import React from 'react';
import { shallow } from 'enzyme';
import { DataTable } from '@edx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import selectors from 'data/selectors';
import { Headings } from 'data/constants/grades';
@@ -22,7 +21,7 @@ jest.mock('./Fields', () => ({
__esModule: true,
default: {
Username: () => 'Fields.Username',
Email: () => 'Fields.Email',
Text: () => 'Fields.Text',
},
}));
jest.mock('./LabelReplacements', () => ({
@@ -30,6 +29,7 @@ jest.mock('./LabelReplacements', () => ({
default: {
TotalGradeLabelReplacement: () => 'TotalGradeLabelReplacement',
UsernameLabelReplacement: () => 'UsernameLabelReplacement',
MastersOnlyLabelReplacement: () => 'MastersOnlyLabelReplacement',
},
}));
jest.mock('./GradeButton', () => 'GradeButton');
@@ -75,11 +75,14 @@ describe('GradebookTable', () => {
],
headings: [
Headings.username,
Headings.fullName,
Headings.email,
fields.field1,
fields.field2,
Headings.totalGrade,
],
intl: { formatMessage: (msg) => msg.defaultMessage },
};
test('snapshot - fields1 and 2 between email and totalGrade, mocked rows', () => {
el = shallow(<GradebookTable {...props} />);
@@ -102,17 +105,22 @@ describe('GradebookTable', () => {
expect(heading.accessor).toEqual(Headings.username);
expect(heading.Header.type).toEqual(LabelReplacements.UsernameLabelReplacement);
});
test('email sets key and Header from header', () => {
test('full name sets key and Header from header', () => {
const heading = headings[1];
expect(heading.accessor).toEqual(Headings.fullName);
expect(heading.Header).toEqual(<LabelReplacements.MastersOnlyLabelReplacement {...messages.fullNameHeading} />);
});
test('email sets key and Header from header', () => {
const heading = headings[2];
expect(heading.accessor).toEqual(Headings.email);
expect(heading.Header).toEqual(<FormattedMessage {...messages.emailHeading} />);
expect(heading.Header).toEqual(<LabelReplacements.MastersOnlyLabelReplacement {...messages.emailHeading} />);
});
test('subsections set key and Header from header', () => {
expect(headings[2]).toEqual({ accessor: fields.field1, Header: fields.field1 });
expect(headings[3]).toEqual({ accessor: fields.field2, Header: fields.field2 });
expect(headings[3]).toEqual({ accessor: fields.field1, Header: fields.field1 });
expect(headings[4]).toEqual({ accessor: fields.field2, Header: fields.field2 });
});
test('totalGrade sets key and replaces Header with component', () => {
const heading = headings[4];
const heading = headings[5];
expect(heading.accessor).toEqual(Headings.totalGrade);
expect(heading.Header.type).toEqual(LabelReplacements.TotalGradeLabelReplacement);
});
@@ -137,10 +145,15 @@ describe('GradebookTable', () => {
userKey: entry.external_user_key,
});
});
test('email set to Email Field', () => {
test('fullName set to Text Field', () => {
const field = row[Headings.fullName];
expect(field.type).toEqual(Fields.Text);
expect(field.props).toEqual({ value: entry.full_name });
});
test('email set to Text Field', () => {
const field = row[Headings.email];
expect(field.type).toEqual(Fields.Email);
expect(field.props).toEqual({ email: entry.email });
expect(field.type).toEqual(Fields.Text);
expect(field.props).toEqual({ value: entry.email });
});
test('totalGrade set to rounded percent grade * 100', () => {
expect(

View File

@@ -24,7 +24,7 @@
.search-help-text {
margin-left: 20px;
}
h4 {
h3 {
font-weight: bold;
margin-top: 0rem;
}

View File

@@ -1,98 +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 {
Form,
FormControl,
FormGroup,
} from '@edx/paragon';
import selectors from 'data/selectors';
import thunkActions from 'data/thunkActions';
import NetworkButton from 'components/NetworkButton';
import messages from './ImportGradesButton.messages';
/**
* <ImportGradesButton />
* File-type input wrapped with hidden control such that when a valid file is
* added, it is automattically uploaded.
*/
export class ImportGradesButton 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.submitImportGradesButtonData(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>
<NetworkButton
className="import-grades-btn"
label={messages.importGradesBtnText}
onClick={this.handleClickImportGrades}
import
/>
</>
);
}
}
ImportGradesButton.propTypes = {
// redux
gradeExportUrl: PropTypes.string.isRequired,
submitImportGradesButtonData: PropTypes.func.isRequired,
};
export const mapStateToProps = (state) => ({
gradeExportUrl: selectors.root.gradeExportUrl(state),
});
export const mapDispatchToProps = {
submitImportGradesButtonData: thunkActions.grades.submitImportGradesButtonData,
};
export default connect(mapStateToProps, mapDispatchToProps)(ImportGradesButton);

View File

@@ -1,212 +0,0 @@
/* eslint-disable import/no-named-as-default */
import React from 'react';
import { shallow } from 'enzyme';
import TestRenderer from 'react-test-renderer';
import {
Form,
FormControl,
FormGroup,
} from '@edx/paragon';
import NetworkButton from 'components/NetworkButton';
import selectors from 'data/selectors';
import thunkActions from 'data/thunkActions';
import { ImportGradesButton, mapStateToProps, mapDispatchToProps } from './ImportGradesButton';
import messages from './ImportGradesButton.messages';
jest.mock('@edx/frontend-platform/i18n', () => ({
defineMessages: m => m,
FormattedMessage: () => 'FormattedMessage',
}));
jest.mock('components/NetworkButton', () => 'NetworkButton');
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: { submitImportGradesButtonData: jest.fn() },
},
}));
const mockRef = { click: jest.fn(), files: [] };
describe('ImportGradesButton', () => {
beforeEach(() => {
mockRef.click.mockClear();
});
describe('component', () => {
let props;
let el;
let inst;
beforeEach(() => {
props = {
gradeExportUrl: 'fakeUrl',
submitImportGradesButtonData: jest.fn(),
};
});
describe('snapshot', () => {
const snapshotSegments = [
'export form w/ alerts and file input',
'import btn',
];
test(`snapshot - loads ${snapshotSegments.join(', ')}`, () => {
jest.mock('@edx/paragon', () => ({
Form: () => 'Form',
FormControl: () => 'FormControl',
FormGroup: () => 'FormGroup',
}));
el = shallow(<ImportGradesButton {...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(
<ImportGradesButton {...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(NetworkButton);
});
test('handleClickImportGrade on click', () => {
expect(btn.props.onClick).toEqual(el.getInstance().handleClickImportGrades);
});
test('label from messages.importGradesBtnText and import true', () => {
expect(btn.props.label).toEqual(messages.importGradesBtnText);
expect(btn.props.import).toEqual(true);
});
});
});
describe('fileInput helper', () => {
test('links to fileInputRef.current', () => {
el = TestRenderer.create(
<ImportGradesButton {...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(
<ImportGradesButton {...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.submitImportGradesButtonData).not.toHaveBeenCalled();
fileInput.mockReturnValue({ files: [] });
el.getInstance().handleFileInputChange();
expect(props.submitImportGradesButtonData).not.toHaveBeenCalled();
});
it('calls submitImportGradesButtonData 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(<ImportGradesButton {...props} submitImportGradesButtonData={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('submitImportGradesButtonData from thunkActions.grades', () => {
expect(
mapDispatchToProps.submitImportGradesButtonData,
).toEqual(thunkActions.grades.submitImportGradesButtonData);
});
});
});

View File

@@ -0,0 +1,34 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ImportGradesButton component render snapshot 1`] = `
<Fragment>
<Form
action="test-grade-export-url"
method="post"
>
<Form.Group
controlId="csv"
>
<Form.Control
className="d-none"
data-testid="file-control"
label="Upload Grade CSV"
onChange={[MockFunction]}
type="file"
/>
</Form.Group>
</Form>
<NetworkButton
className="import-grades-btn"
import={true}
label={
Object {
"defaultMessage": "Import Grades",
"description": "A labeled button to import grades in the BulkManagement Tab File Upload Form",
"id": "gradebook.GradesView.importGradesBtnText",
}
}
onClick={[MockFunction]}
/>
</Fragment>
`;

View File

@@ -0,0 +1,30 @@
import { useRef } from 'react';
import { selectors, thunkActions } from 'data/redux/hooks';
export const useImportButtonData = () => {
const gradeExportUrl = selectors.root.useGradeExportUrl();
const submitImportGradesButtonData = thunkActions.grades.useSubmitImportGradesButtonData();
const fileInputRef = useRef();
const handleClickImportGrades = () => fileInputRef.current?.click();
const handleFileInputChange = () => {
if (fileInputRef.current?.files[0]) {
const clearInput = () => {
fileInputRef.current.value = null;
};
const formData = new FormData();
formData.append('csv', fileInputRef.current.files[0]);
submitImportGradesButtonData(formData).then(clearInput);
}
};
return {
fileInputRef,
gradeExportUrl,
handleClickImportGrades,
handleFileInputChange,
};
};
export default useImportButtonData;

View File

@@ -0,0 +1,81 @@
import React from 'react';
import { selectors, thunkActions } from 'data/redux/hooks';
import useImportButtonData from './hooks';
jest.mock('data/redux/hooks', () => ({
selectors: {
root: { useGradeExportUrl: jest.fn() },
},
thunkActions: {
grades: { useSubmitImportGradesButtonData: jest.fn() },
},
}));
let out;
let submitThen;
const submit = jest.fn().mockReturnValue(new Promise((resolve) => {
submitThen = resolve;
}));
const gradeExportUrl = 'test-grade-export-url';
selectors.root.useGradeExportUrl.mockReturnValue(gradeExportUrl);
thunkActions.grades.useSubmitImportGradesButtonData.mockReturnValue(submit);
const testFile = 'test-file';
const testFormData = new FormData();
testFormData.append('csv', testFile);
const ref = {
current: { click: jest.fn(), files: [testFile], value: 'test-value' },
};
describe('useAssignmentFilterData hook', () => {
beforeEach(() => {
jest.clearAllMocks();
React.useRef.mockReturnValue(ref);
out = useImportButtonData();
});
describe('behavior', () => {
it('initializes redux hooks', () => {
expect(selectors.root.useGradeExportUrl).toHaveBeenCalledWith();
expect(thunkActions.grades.useSubmitImportGradesButtonData).toHaveBeenCalledWith();
});
it('initializes react ref', () => {
expect(React.useRef).toHaveBeenCalled();
});
});
describe('output', () => {
describe('handleClickImportGrades', () => {
it('clicks the file input if populated', () => {
out.handleClickImportGrades();
expect(ref.current.click).toHaveBeenCalled();
});
it('does not crash if no file input available', () => {
React.useRef.mockReturnValueOnce({ current: null });
out = useImportButtonData();
out.handleClickImportGrades();
expect(ref.current.click).not.toHaveBeenCalled();
});
});
describe('handleFileInputChange', () => {
it('does not crash if no file input available', () => {
React.useRef.mockReturnValueOnce({ current: null });
out = useImportButtonData();
out.handleFileInputChange();
});
it('submits formData, clearingInput on success', async () => {
out.handleFileInputChange();
const [[callArg]] = submit.mock.calls;
expect([...callArg.entries()]).toEqual([...testFormData.entries()]);
await submitThen();
expect(out.fileInputRef.current.value).toEqual(null);
});
});
it('passes fileInputRef from hook', () => {
expect(out.fileInputRef).toEqual(ref);
});
it('passes gradeExportUrl from hook', () => {
expect(out.gradeExportUrl).toEqual(gradeExportUrl);
});
});
});

View File

@@ -0,0 +1,50 @@
/* eslint-disable react/button-has-type, import/no-named-as-default */
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Form } from '@edx/paragon';
import NetworkButton from 'components/NetworkButton';
import messages from './messages';
import useImportGradesButtonData from './hooks';
/**
* <ImportGradesButton />
* File-type input wrapped with hidden control such that when a valid file is
* added, it is automattically uploaded.
*/
export const ImportGradesButton = () => {
const {
fileInputRef,
gradeExportUrl,
handleClickImportGrades,
handleFileInputChange,
} = useImportGradesButtonData();
const { formatMessage } = useIntl();
return (
<>
<Form action={gradeExportUrl} method="post">
<Form.Group controlId="csv">
<Form.Control
data-testid="file-control"
className="d-none"
type="file"
label={formatMessage(messages.csvUploadLabel)}
onChange={handleFileInputChange}
ref={fileInputRef}
/>
</Form.Group>
</Form>
<NetworkButton
className="import-grades-btn"
label={messages.importGradesBtnText}
onClick={handleClickImportGrades}
import
/>
</>
);
};
ImportGradesButton.propTypes = {};
export default ImportGradesButton;

View File

@@ -0,0 +1,45 @@
import React from 'react';
import { shallow } from 'enzyme';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Form } from '@edx/paragon';
import NetworkButton from 'components/NetworkButton';
import useImportGradesButtonData from './hooks';
import ImportGradesButton from '.';
jest.mock('components/NetworkButton', () => 'NetworkButton');
jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() }));
let el;
let props;
describe('ImportGradesButton component', () => {
beforeAll(() => {
props = {
fileInputRef: { current: null },
gradeExportUrl: 'test-grade-export-url',
handleClickImportGrades: jest.fn(),
handleFileInputChange: jest.fn(),
};
useImportGradesButtonData.mockReturnValue(props);
el = shallow(<ImportGradesButton />);
});
describe('behavior', () => {
it('initializes hooks', () => {
expect(useImportGradesButtonData).toHaveBeenCalledWith();
expect(useIntl).toHaveBeenCalledWith();
});
});
describe('render', () => {
test('snapshot', () => {
expect(el).toMatchSnapshot();
});
test('Form', () => {
expect(el.find(Form).props().action).toEqual(props.gradeExportUrl);
expect(el.find(Form.Control).props().onChange).toEqual(props.handleFileInputChange);
});
test('import button', () => {
expect(el.find(NetworkButton).props().onClick).toEqual(props.handleClickImportGrades);
});
});
});

View File

@@ -0,0 +1,27 @@
import React from 'react';
import { render } from '@testing-library/react';
import useImportGradesButtonData from './hooks';
import ImportGradesButton from '.';
jest.unmock('react');
jest.unmock('@edx/paragon');
jest.mock('components/NetworkButton', () => 'network-button');
jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() }));
const props = {
fileInputRef: { current: { click: jest.fn() }, useRef: jest.fn() },
gradeExportUrl: 'test-grade-export-utl',
handleClickImportGrades: jest.fn(),
handleFileInputChange: jest.fn(),
};
useImportGradesButtonData.mockReturnValue(props);
let el;
describe('ImportGradesButton ref test', () => {
it('loads ref from hook', () => {
el = render(<ImportGradesButton />);
const input = el.getByTestId('file-control');
expect(input).toEqual(props.fileInputRef.current);
});
});

View File

@@ -1,45 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ImportGradesButton 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="A labeled button to upload a CSV containing course grades."
id="gradebook.BulkManagementHistoryView.csvUploadLabel"
/>
}
onChange={[MockFunction this.handleFileInputChange]}
plaintext={false}
type="file"
/>
</FormGroup>
</Form>
<NetworkButton
className="import-grades-btn"
import={true}
label={
Object {
"defaultMessage": "Import Grades",
"description": "A labeled button to import grades in the BulkManagement Tab File Upload Form",
"id": "gradebook.GradesView.importGradesBtnText",
}
}
onClick={[MockFunction this.handleClickImportGrades]}
/>
</React.Fragment>
`;

View File

@@ -4,7 +4,7 @@ exports[`GradesView Component snapshots basic snapshot 1`] = `
<React.Fragment>
<SpinnerIcon />
<InterventionsReport />
<h4
<h3
className="step-message-1"
>
<FormattedMessage
@@ -12,7 +12,7 @@ exports[`GradesView Component snapshots basic snapshot 1`] = `
description="Filter controls container heading string"
id="gradebook.GradesView.filterHeading"
/>
</h4>
</h3>
<div
className="d-flex justify-content-between"
>
@@ -23,13 +23,13 @@ exports[`GradesView Component snapshots basic snapshot 1`] = `
handleClose={[MockFunction this.handleFilterBadgeClose]}
/>
<StatusAlerts />
<h4>
<h3>
<FormattedMessage
defaultMessage="Step 2: View or Modify Individual Grades"
description="Alert text for invalid minimum course grade"
id="gradebook.GradesView.gradebookStepHeading"
/>
</h4>
</h3>
<div
className="d-flex justify-content-between align-items-center mb-2"
>

View File

@@ -46,9 +46,9 @@ export class GradesView extends React.Component {
<SpinnerIcon />
<InterventionsReport />
<h4 className="step-message-1">
<h3 className="step-message-1">
<FormattedMessage {...messages.filterStepHeading} />
</h4>
</h3>
<div className="d-flex justify-content-between">
<FilterMenuToggle />
@@ -58,7 +58,7 @@ export class GradesView extends React.Component {
<FilterBadges handleClose={this.handleFilterBadgeClose} />
<StatusAlerts />
<h4><FormattedMessage {...messages.gradebookStepHeading} /></h4>
<h3><FormattedMessage {...messages.gradebookStepHeading} /></h3>
<div className="d-flex justify-content-between align-items-center mb-2">
<ScoreViewInput />

View File

@@ -8,8 +8,10 @@ jest.mock('@reduxjs/toolkit', () => ({
describe('redux action utils', () => {
describe('formatDateForDisplay', () => {
it('returns the datetime as a formatted string', () => {
expect(utils.formatDateForDisplay(new Date('Jun 3 2021 11:59 AM EDT'))).toEqual(
'June 3, 2021 at 03:59 PM UTC',
// using toLocaleTimeString because mac/linux seems to generate strings
const date = new Date('Jun 3 2021 11:59 AM EDT');
expect(utils.formatDateForDisplay(date)).toEqual(
`June 3, 2021 at ${date.toLocaleTimeString('en-US', utils.timeOptions)}`,
);
});
});

View File

@@ -63,21 +63,3 @@ export const gradeOverrideHistoryColumns = StrictDict({
grader: 'grader',
reason: 'reason',
});
/**
* Display strings for various app components.
* Note: this is a temporary storage location for these strings, before we put them in
* messages files for translation.
*/
export const messages = StrictDict({
BulkManagementTab: StrictDict({
csvUploadLabel: 'Upload Grade CSV',
heading: 'Use this feature by downloading a CSV for bulk management, overriding grades locally, and coming back here to upload.',
importBtnText: 'Import Grades',
successDialog: 'CSV processing. File uploads may take several minutes to complete.',
hints: [
'Results appear in the table below.',
'Grade processing may take a few seconds.',
],
}),
});

View File

@@ -3,6 +3,7 @@ import { StrictDict } from 'utils';
const EMAIL_HEADING = 'Email';
const TOTAL_COURSE_GRADE_HEADING = 'Total Grade (%)';
const USERNAME_HEADING = 'Username';
const FULL_NAME_HEADING = 'Full Name';
const GradeFormats = StrictDict({
absolute: 'absolute',
@@ -10,15 +11,17 @@ const GradeFormats = StrictDict({
});
const Headings = StrictDict({
email: 'Email',
totalGrade: 'Total Grade (%)',
username: 'Username',
email: EMAIL_HEADING,
totalGrade: TOTAL_COURSE_GRADE_HEADING,
username: USERNAME_HEADING,
fullName: FULL_NAME_HEADING,
});
export {
EMAIL_HEADING,
TOTAL_COURSE_GRADE_HEADING,
USERNAME_HEADING,
FULL_NAME_HEADING,
GradeFormats,
Headings,
};

View File

@@ -33,7 +33,7 @@ const initialState = {
searchValue: '',
};
const app = (state = initialState, { type, payload }) => {
const app = (state = initialState, { type, payload } = {}) => {
switch (type) {
case actions.closeModal.toString():
return { ...state, modalState: { ...initialState.modalState } };

View File

@@ -6,7 +6,7 @@ const initialState = {
errorFetching: false,
};
const assignmentTypes = (state = initialState, { type, payload }) => {
const assignmentTypes = (state = initialState, { type, payload } = {}) => {
switch (type) {
case actions.fetching.started.toString():
return {

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