Compare commits

...

35 Commits

Author SHA1 Message Date
Bilal Qamar
3644172d94 feat: upgraded to node v18, added .nvmrc and updated workflows (#317)
* Merge branch 'master' of github.com:edx/frontend-app-gradebook

* feat: upgraded to node v18, added .nvmrc and updated workflows

* build: updated frontend-build, frontend-platform, component-footer & component-header packages

* refactor: updated packages

* fix: resolved test case failure window redefine issue

* Merge branch 'master' of github.com:edx/frontend-app-gradebook into bilalqamar95/node-v18-upgrade

* refactor: pinned node to v18.15 in nvmrc
2023-06-09 09:20:39 +02: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
126 changed files with 15505 additions and 44801 deletions

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

@@ -11,22 +11,18 @@ on:
jobs:
test:
runs-on: ubuntu-20.04
strategy:
matrix:
node: [16]
npm: [8.5.x]
steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v3
- name: Setup Nodejs Env
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
- name: Setup Nodejs
uses: actions/setup-node@v1
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node }}
- name: Install npm 8.5.x
run: npm install -g npm@${{ matrix.npm }}
node-version: ${{ env.NODE_VER }}
- name: Install dependencies
run: npm ci

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

View File

@@ -15,10 +15,13 @@ jobs:
with:
fetch-depth: 0
- name: Setup Nodejs Env
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
- name: Setup Node.js
uses: actions/setup-node@v1
with:
node-version: 12
node-version: ${{ env.NODE_VER }}
- name: Install dependencies
run: npm ci

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

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
18.15

View File

@@ -1,9 +1,8 @@
npm-install-%: ## install specified % npm package
npm install $* --save-dev
git add package.json
transifex_resource = frontend-app-gradebook
transifex_langs = "ar,fr,es_419,zh_CN,pt,it,de,uk,ru,hi,fr_CA"
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_utils = ./node_modules/.bin/transifex-utils.js
i18n = ./src/i18n
@@ -49,11 +48,11 @@ 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
# Pulls translations from Transifex.
pull_translations:

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

54560
package-lock.json generated

File diff suppressed because it is too large Load Diff

26
package.json Executable file → Normal file
View File

@@ -1,10 +1,10 @@
{
"name": "@edx/frontend-app-gradebook",
"version": "1.6.0",
"version": "1.6.1",
"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/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",
@@ -65,23 +64,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.8.27",
"@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';
/**
* <AdjustedGradeInput />
@@ -33,7 +33,7 @@ export class AdjustedGradeInput extends React.Component {
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

@@ -2,58 +2,23 @@
exports[`EditModal Component snapshots gradeOverrideHistoryError is and empty and open is true modal open and StatusAlert showing 1`] = `
<ModalDialog
closeLabel="Close"
hasCloseButton={true}
isBlocking={false}
isFullscreenOnMobile={true}
isFullscreenScroll={false}
isOpen={true}
onClose={[MockFunction this.closeAssignmentModal]}
size="xl"
variant="default"
title="Edit Grades"
>
<ModalDialogHeader
as="div"
>
<ModalDialogTitle
as="h2"
>
<FormattedMessage
defaultMessage="Edit Grades"
description="Edit Modal title"
id="gradebook.GradesView.EditModal.title"
/>
</ModalDialogTitle>
</ModalDialogHeader>
<ModalDialogBody
as="div"
>
<ModalDialog.Body>
<div>
<ModalHeaders />
<ForwardRef
closeLabel="Dismiss"
<Alert
dismissible={false}
onClose={[Function]}
show={true}
stacked={false}
transition={
Object {
"$$typeof": Symbol(react.forward_ref),
"defaultProps": Object {
"appear": false,
"in": false,
"mountOnEnter": false,
"timeout": 300,
"unmountOnExit": false,
},
"displayName": "Fade",
"render": [Function],
}
}
variant="danger"
>
Weve been trying to contact you regarding...
</ForwardRef>
</Alert>
<OverrideTable />
<div>
<FormattedMessage
@@ -70,37 +35,10 @@ exports[`EditModal Component snapshots gradeOverrideHistoryError is and empty an
/>
</div>
</div>
</ModalDialogBody>
<ModalDialogFooter
as="div"
>
<ActionRow
as="div"
isStacked={false}
>
<ForwardRef
as={
Object {
"$$typeof": Symbol(react.forward_ref),
"Deprecated": [Function],
"defaultProps": Object {
"active": false,
"children": undefined,
"className": undefined,
"disabled": false,
"iconAfter": undefined,
"iconBefore": undefined,
"variant": "primary",
},
"propTypes": Object {
"children": [Function],
"className": [Function],
"iconAfter": [Function],
"iconBefore": [Function],
},
"render": [Function],
}
}
</ModalDialog.Body>
<ModalDialog.Footer>
<ActionRow>
<ModalDialog.CloseButton
variant="tertiary"
>
<FormattedMessage
@@ -108,10 +46,8 @@ exports[`EditModal Component snapshots gradeOverrideHistoryError is and empty an
description="Edit Modal close button text"
id="gradebook.GradesView.EditModal.closeText"
/>
</ForwardRef>
<ForwardRef
active={false}
disabled={false}
</ModalDialog.CloseButton>
<Button
onClick={[MockFunction this.handleAdjustedGradeClick]}
variant="primary"
>
@@ -120,66 +56,31 @@ exports[`EditModal Component snapshots gradeOverrideHistoryError is and empty an
description="Edit Modal Save button label"
id="gradebook.GradesView.EditModal.saveGrade"
/>
</ForwardRef>
</Button>
</ActionRow>
</ModalDialogFooter>
</ModalDialog.Footer>
</ModalDialog>
`;
exports[`EditModal Component snapshots gradeOverrideHistoryError is empty and open is false modal closed and StatusAlert closed 1`] = `
<ModalDialog
closeLabel="Close"
hasCloseButton={true}
isBlocking={false}
isFullscreenOnMobile={true}
isFullscreenScroll={false}
isOpen={true}
isOpen={false}
onClose={[MockFunction this.closeAssignmentModal]}
size="xl"
variant="default"
title="Edit Grades"
>
<ModalDialogHeader
as="div"
>
<ModalDialogTitle
as="h2"
>
<FormattedMessage
defaultMessage="Edit Grades"
description="Edit Modal title"
id="gradebook.GradesView.EditModal.title"
/>
</ModalDialogTitle>
</ModalDialogHeader>
<ModalDialogBody
as="div"
>
<ModalDialog.Body>
<div>
<ModalHeaders />
<ForwardRef
closeLabel="Dismiss"
<Alert
dismissible={false}
onClose={[Function]}
show={false}
stacked={false}
transition={
Object {
"$$typeof": Symbol(react.forward_ref),
"defaultProps": Object {
"appear": false,
"in": false,
"mountOnEnter": false,
"timeout": 300,
"unmountOnExit": false,
},
"displayName": "Fade",
"render": [Function],
}
}
variant="danger"
>
</ForwardRef>
</Alert>
<OverrideTable />
<div>
<FormattedMessage
@@ -196,37 +97,10 @@ exports[`EditModal Component snapshots gradeOverrideHistoryError is empty and op
/>
</div>
</div>
</ModalDialogBody>
<ModalDialogFooter
as="div"
>
<ActionRow
as="div"
isStacked={false}
>
<ForwardRef
as={
Object {
"$$typeof": Symbol(react.forward_ref),
"Deprecated": [Function],
"defaultProps": Object {
"active": false,
"children": undefined,
"className": undefined,
"disabled": false,
"iconAfter": undefined,
"iconBefore": undefined,
"variant": "primary",
},
"propTypes": Object {
"children": [Function],
"className": [Function],
"iconAfter": [Function],
"iconBefore": [Function],
},
"render": [Function],
}
}
</ModalDialog.Body>
<ModalDialog.Footer>
<ActionRow>
<ModalDialog.CloseButton
variant="tertiary"
>
<FormattedMessage
@@ -234,10 +108,8 @@ exports[`EditModal Component snapshots gradeOverrideHistoryError is empty and op
description="Edit Modal close button text"
id="gradebook.GradesView.EditModal.closeText"
/>
</ForwardRef>
<ForwardRef
active={false}
disabled={false}
</ModalDialog.CloseButton>
<Button
onClick={[MockFunction this.handleAdjustedGradeClick]}
variant="primary"
>
@@ -246,8 +118,8 @@ exports[`EditModal Component snapshots gradeOverrideHistoryError is empty and op
description="Edit Modal Save button label"
id="gradebook.GradesView.EditModal.saveGrade"
/>
</ForwardRef>
</Button>
</ActionRow>
</ModalDialogFooter>
</ModalDialog.Footer>
</ModalDialog>
`;

View File

@@ -9,7 +9,7 @@ import {
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';
@@ -47,19 +47,14 @@ export class EditModal extends React.Component {
render() {
return (
<ModalDialog
isOpen
title={this.props.intl.formatMessage(messages.title)}
isOpen={this.props.open}
onClose={this.closeAssignmentModal}
size="xl"
hasCloseButton
isFullscreenOnMobile
>
<ModalDialog.Header>
<ModalDialog.Title>
<FormattedMessage {...messages.title} />
</ModalDialog.Title>
</ModalDialog.Header>
<ModalDialog.Body>
<div>
<ModalHeaders />
@@ -101,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) => ({
@@ -114,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

@@ -10,7 +10,7 @@ import {
mapDispatchToProps,
mapStateToProps,
}
from '.';
from '.';
jest.mock('./OverrideTable', () => 'OverrideTable');
jest.mock('./ModalHeaders', () => 'ModalHeaders');
@@ -49,6 +49,8 @@ describe('EditModal', () => {
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

@@ -33,9 +33,7 @@ Username.propTypes = {
* Simple label field for email value.
* @param {string} email - email for display
*/
const Email = ({ email }) => (
<span className="wrap-text-in-cell">{email}</span>
);
const Email = ({ email }) => <span className="wrap-text-in-cell">{email}</span>;
Email.propTypes = {
email: PropTypes.string.isRequired,
};

View File

@@ -48,13 +48,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 { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import selectors from 'data/selectors';
import { Headings } from 'data/constants/grades';
import { getLocalizedPercentSign } from 'i18n';
import messages from './messages';
import Fields from './Fields';
@@ -44,21 +45,17 @@ export class GradebookTable extends React.Component {
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.email]: (<Fields.Email email={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 +74,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 +103,8 @@ GradebookTable.propTypes = {
user_name: PropTypes.string,
})),
headings: PropTypes.arrayOf(PropTypes.string).isRequired,
// injected
intl: intlShape.isRequired,
};
export const mapStateToProps = (state) => ({
@@ -113,4 +112,4 @@ export const mapStateToProps = (state) => ({
headings: selectors.root.getHeadings(state),
});
export default connect(mapStateToProps)(GradebookTable);
export default injectIntl(connect(mapStateToProps)(GradebookTable));

View File

@@ -80,6 +80,8 @@ describe('GradebookTable', () => {
fields.field2,
Headings.totalGrade,
],
intl: { formatMessage: (msg) => msg.defaultMessage },
};
test('snapshot - fields1 and 2 between email and totalGrade, mocked rows', () => {
el = shallow(<GradebookTable {...props} />);

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,31 @@
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 hasFile = fileInputRef.current && fileInputRef.current.files[0];
const handleClickImportGrades = () => hasFile && fileInputRef.current.click();
const handleFileInputChange = () => {
if (hasFile) {
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

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

View File

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

View File

@@ -2,7 +2,7 @@ import actions from '../actions/config';
const initialState = {};
const reducer = (state = initialState, action) => {
const reducer = (state = initialState, action = {}) => {
switch (action.type) {
case actions.gotBulkManagementConfig.toString():
return {

View File

@@ -5,7 +5,7 @@ import initialFilters from '../constants/filters';
const initialState = {};
const reducer = (state = initialState, { type: actionType, payload }) => {
const reducer = (state = initialState, { type: actionType, payload } = {}) => {
switch (actionType) {
case actions.initialize.toString():
return {

View File

@@ -30,7 +30,7 @@ const initialState = {
isDownloadGradesActive: false,
};
const grades = (state = initialState, { type, payload }) => {
const grades = (state = initialState, { type, payload } = {}) => {
switch (type) {
case actions.banner.open.toString():
return {

View File

@@ -4,7 +4,7 @@ const initialState = {
canUserViewGradebook: true,
};
const roles = (state = initialState, action) => {
const roles = (state = initialState, action = {}) => {
switch (action.type) {
case actions.fetching.received.toString():
return {

View File

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

View File

@@ -0,0 +1,22 @@
import { StrictDict } from 'utils';
import actions from 'data/actions';
import { actionHook } from './utils';
const app = StrictDict({
useSetLocalFilter: actionHook(actions.app.setLocalFilter),
});
const filters = StrictDict({
useUpdateAssignment: actionHook(actions.filters.update.assignment),
useUpdateAssignmentLimits: actionHook(actions.filters.update.assignmentLimits),
useUpdateAssignmentType: actionHook(actions.filters.update.assignmentType),
useUpdateCohort: actionHook(actions.filters.update.cohort),
useUpdateCourseGradeLimits: actionHook(actions.filters.update.courseGradeLimits),
useUpdateIncludeCourseRoleMembers: actionHook(actions.filters.update.includeCourseRoleMembers),
useUpdateTrack: actionHook(actions.filters.update.track),
});
export default StrictDict({
app,
filters,
});

View File

@@ -0,0 +1,50 @@
import { keyStore } from 'utils';
import actions from 'data/actions';
import { actionHook } from './utils';
import actionHooks from './actions';
jest.mock('data/actions', () => ({
app: {
setLocalFilter: jest.fn(),
},
filters: {
update: {
assignment: jest.fn(),
assignmentLimits: jest.fn(),
},
},
}));
jest.mock('./utils', () => ({
actionHook: (action) => ({ actionHook: action }),
}));
let hooks;
const testActionHook = (hookKey, action) => {
test(hookKey, () => {
expect(hooks[hookKey]).toEqual(actionHook(action));
});
};
describe('action hooks', () => {
describe('app', () => {
const hookKeys = keyStore(actionHooks.app);
beforeEach(() => { hooks = actionHooks.app; });
testActionHook(hookKeys.useSetLocalFilter, actions.app.setLocalFilter);
});
describe('filters', () => {
const hookKeys = keyStore(actionHooks.filters);
const actionGroup = actions.filters.update;
beforeEach(() => { hooks = actionHooks.filters; });
testActionHook(hookKeys.useUpdateAssignment, actionGroup.assignment);
testActionHook(hookKeys.useUpdateAssignmentLimits, actionGroup.assignmentLimits);
testActionHook(hookKeys.useUpdateCohort, actionGroup.updateCohort);
testActionHook(hookKeys.useUpdateCourseGradeLimits, actionGroup.courseGradeLimits);
testActionHook(
hookKeys.useUpdateIncludeCourseRoleMembers,
actionGroup.updateIncludeCourseRoleMembers,
);
testActionHook(hookKeys.useUpdateTrack, actionGroup.updateTrack);
});
});

View File

@@ -0,0 +1,15 @@
import { StrictDict } from 'utils';
import selectorHooks from './selectors';
import actionHooks from './actions';
import thunkActionHooks from './thunkActions';
export const selectors = selectorHooks;
export const actions = actionHooks;
export const thunkActions = thunkActionHooks;
export default StrictDict({
selectors,
actions,
thunkActions,
});

View File

@@ -0,0 +1,17 @@
import hooks from '.';
import selectors from './selectors';
import actions from './actions';
import thunkActions from './thunkActions';
jest.mock('./selectors', () => jest.fn());
jest.mock('./actions', () => jest.fn());
jest.mock('./thunkActions', () => jest.fn());
describe('redux hooks', () => {
it('exports selectors, actions, and thunkActions', () => {
expect(hooks.actions).toEqual(actions);
expect(hooks.selectors).toEqual(selectors);
expect(hooks.thunkActions).toEqual(thunkActions);
});
});

View File

@@ -0,0 +1,49 @@
import { useSelector } from 'react-redux';
import { StrictDict } from 'utils';
import selectors from 'data/selectors';
export const root = StrictDict({
useGradeExportUrl: () => useSelector(selectors.root.gradeExportUrl),
useSelectedCohortEntry: () => useSelector(selectors.root.selectedCohortEntry),
useSelectedTrackEntry: () => useSelector(selectors.root.selectedTrackEntry),
});
export const app = StrictDict({
useAssignmentGradeLimits: () => useSelector(selectors.app.assignmentGradeLimits),
useAreCourseGradeFiltersValid: () => useSelector(selectors.app.areCourseGradeFiltersValid),
useCourseGradeLimits: () => useSelector(selectors.app.courseGradeLimits),
});
export const assignmentTypes = StrictDict({
useAllAssignmentTypes: () => useSelector(selectors.assignmentTypes.allAssignmentTypes),
});
export const cohorts = StrictDict({
useAllCohorts: () => useSelector(selectors.cohorts.allCohorts),
// maybe not needed?
useCohortsByName: () => useSelector(selectors.cohorts.cohortsByName),
});
export const filters = StrictDict({
useData: () => useSelector(selectors.filters.allFilters),
useIncludeCourseRoleMembers: () => useSelector(selectors.filters.includeCourseRoleMembers),
useSelectableAssignmentLabels: () => useSelector(selectors.filters.selectableAssignmentLabels),
useSelectedAssignmentLabel: () => useSelector(selectors.filters.selectedAssignmentLabel),
useAssignmentType: () => useSelector(selectors.filters.assignmentType),
});
export const tracks = StrictDict({
useAllTracks: () => useSelector(selectors.tracks.allTracks),
// maybe not needed?
useTracksByName: () => useSelector(selectors.tracks.tracksByName),
});
export default StrictDict({
app,
assignmentTypes,
cohorts,
filters,
tracks,
root,
});

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