Compare commits

...

46 Commits

Author SHA1 Message Date
Brian Smith
e08dc1ddc3 feat(deps): update header to 5.6.0 (#409) 2024-10-22 19:19:22 -04:00
Feanil Patel
3d2dd5006a Merge pull request #403 from openedx/feanil/ubuntu_upgrade
build: Switch to ubuntu-latest for builds
2024-09-20 10:27:51 -04:00
Bilal Qamar
694b1a75fc build: Upgrade to Node 20 (#406) 2024-09-19 17:50:39 -04:00
Feanil Patel
eff1ac0900 Merge branch 'master' into feanil/ubuntu_upgrade 2024-09-13 09:35:09 -04:00
Bilal Qamar
918463de91 test: Add Node 20 to CI matrix (#404) 2024-09-11 13:29:47 -04:00
Feanil Patel
a2d119aa43 build: Switch to ubuntu-latest for builds
This code does not have any dependencies that are specific to any specific
version of ubuntu.  So instead of testing on a specific version and then needing
to do work to keep the versions up-to-date, we switch to the ubuntu-latest
target which should be sufficient for testing purposes.

This work is being done as a part of https://github.com/openedx/platform-roadmap/issues/377

closes https://github.com/openedx/frontend-app-gradebook/issues/401
2024-09-09 10:00:30 -04:00
Bilal Qamar
ccb7865100 feat: updated frontend-build & frontend-platform major versions (#374)
* feat: bumped frontend-platform to v6

* chore: bumped jest to v29

* fix: updated snapshots for failing tests

* refactor: updated frontend-build, updated snapshots

* feat: updated build and platform major versions, along with edx packages

* refactor: updated index.test to resolve failing test

* refactor: major version upgrade for react-unit-test-utils

* refactor: updated package-lock
2024-08-02 16:35:00 +05:00
Adolfo R. Brandes
997d205ac6 build: Update codecov and use token (#398)
Update codecov to the latest version and start using the org-wide token for uploads.

See https://github.com/openedx/wg-frontend/issues/179
2024-06-17 12:03:01 -03:00
Adolfo R. Brandes
13433e969f Merge pull request #310 from raccoongang/lunyachek/fix/edit-grades-modal-rows-counter-olive
fix: Fix rows counter in the Edit Grade modal window
2024-05-30 14:13:07 -03:00
Farhaan Bukhsh
c21a81eb55 Merge pull request #393 from openedx/farhaan/add-catalog-file
chore: Adds catalog-info.yml for the project
2024-05-23 13:27:14 +05:30
Farhaan Bukhsh
9675a6e9a9 chore: Adds catalog-info.yml for the project
Signed-off-by: Farhaan Bukhsh <farhaan@opencraft.com>
2024-05-23 12:19:51 +05:30
Adolfo R. Brandes
d9a0a11936 Merge pull request #391 from brian-smith-tcril/footer-slot
feat: use frontend-plugin-framework to provide a FooterSlot
2024-05-17 13:10:25 -03:00
Brian Smith
13a19a274c feat: use frontend-plugin-framework to provide a FooterSlot 2024-05-17 10:49:02 -04:00
Feanil Patel
f4edf956bb Merge pull request #392 from salman2013/salman/Add-renovate-json-file
Add renovate configuration file
2024-05-15 12:05:41 -04:00
salman2013
c3c328fddb chore: add renovate configuration file 2024-05-15 15:23:02 +05:00
Adolfo R. Brandes
75725c16f4 Merge pull request #390 from DmytroAlipov/fix-original-grade-value
fix: Grade value is displayed in the modal
2024-05-01 12:37:00 -03:00
Dima Alipov
d59a4bf54d fix: grade value is displayed in the modal 2024-04-21 13:38:35 +03:00
Stanislav Lunyachek
18cede45a6 fix: Fix rows counter in the Edit Grade modal window 2024-04-18 23:34:33 +03:00
Adolfo R. Brandes
c3823c39b0 Merge pull request #387 from DmytroAlipov/fix-second-opening-grade
fix: "An unexpected error occurred" after second opening any grade
2024-04-08 14:48:52 -03:00
Adolfo R. Brandes
ef8e20f2b3 Merge pull request #363 from raccoongang/fix/fix-responsive-issues-master
fix: fix sidebar scrolling and adaptation for mobile
2024-04-03 15:27:29 -03:00
Samir Sabri
d683e874b7 feat!: remove Transifex calls for OEP-58 2024-03-18 14:38:42 -04:00
Dima Alipov
4e9270ab8e fix: "An unexpected error occurred" after second opening any grade 2024-03-16 18:57:48 +02:00
Adolfo R. Brandes
6450d8648b Merge pull request #383 from brian-smith-tcril/update-build-paragon
chore(deps): update paragon and frontend-build to openedx scope
2024-02-27 11:46:22 -03:00
Brian Smith
9c7c848df5 chore(deps): update paragon and frontend-build to openedx scope 2024-02-23 14:27:48 -05:00
Omar Al-Ithawi
9ad10108ec feat: tutor-mfe compatiblilty for atlas pull (#379)
- install atlas
 - remove `--filter` to pull all languages by default
 - use ATLAS_OPTIONS to allow custom `--filter`
 - include frontend-platform in `atlas pull`

Refs: FC-0012 OEP-58
2024-02-02 11:15:59 -05:00
Syed Ali Abbas Zaidi
338498543a feat: migrate enzyme to react-unit-test-utils (#378)
* feat: migrate enzyme to react-unit-test-utils

* refactor: remove unnecessary usage of shallow wrapper
2024-01-30 12:31:48 +05:00
Adolfo R. Brandes
788476193c Merge pull request #375 from arbrandes/fix-route
fix: Route with PUBLIC_PATH
2023-12-15 13:23:34 -03:00
Adolfo R. Brandes
cb658776c6 fix: Route with PUBLIC_PATH
Prior to this change, the MFE would fail to render with any PUBLIC_PATH
set.  Because that is now handled entirely by `frontend-platform`, we
can avoid referring to PUBLIC_PATH entirely, here.
2023-12-08 16:32:16 -03:00
Mashal Malik
a4eff6991f refactor: updated README file to reflect template changes (#369) 2023-10-26 08:47:40 -04:00
ihor-romaniuk
f1c9140c8e fix: fix sidebar scrolling and adaptation for mobile
- fix text overload in heading
- fix appearance for sidebar with a short main content
- fix transformation of search and filter button on mobile
- fix extra empty space below main content table
- fix adaptation modal content to mobile view
2023-10-23 15:53:35 +03:00
Feanil Patel
ba9bd466a3 chore: Update to the new version of brand-openedx in the new scope. (#359)
Part of https://github.com/openedx/axim-engineering/issues/23

This updates the `@edx/brand` alias to point to the `brand-openedx` package at
the `openedx` scope. This does not impact imports because this package is used
via an alias.
2023-10-20 17:23:36 -04:00
Mashal Malik
e44f5dde44 refactor: update lock file version (#347) 2023-10-19 10:18:44 +05:00
Syed Ali Abbas Zaidi
d46ce000bb chore: bump frontend-platform (#356) 2023-10-16 13:11:40 +05:00
Muhammad Abdullah Waheed
bf3b37caa4 feat: babel-plugin-react-intl to babel-plugin-formatjs migration (#354)
* feat: babel-plugin-react-intl to babel-plugin-formatjs migration

* fix: upgraded frontend-build to fix security issue
2023-10-11 13:31:57 -04:00
0x29a
295048b4e9 fix: add limited_staff to allowedRoles 2023-10-10 10:29:21 -04:00
Diana Catalina Olarte
1c70458590 fix: replace Field.Email reference for Field.Text 2023-09-25 11:10:13 -03:00
Syed Ali Abbas Zaidi
247e9f3668 feat: upgrade react router to v6 (#319)
* feat: upgrade react router to v6

* refactor: removed remaining router v5 code

* refactor: improve code coverage
2023-09-18 11:27:40 -04:00
Bilal Qamar
5e96dbf614 feat: update react & react-dom to v17 (#337)
* feat: update react & react-dom to v17

* refactor: updated edx packages

* build: update react-redux

* refactor: update package-lock

* refactor: bumped frontend-build

---------

Co-authored-by: mashal-m <mashal.malik@arbisoft.com>
2023-08-09 14:53:55 +05:00
dependabot[bot]
44197f673d build(deps): bump semver from 5.7.1 to 5.7.2 (#343)
Bumps [semver](https://github.com/npm/node-semver) from 5.7.1 to 5.7.2.
- [Release notes](https://github.com/npm/node-semver/releases)
- [Changelog](https://github.com/npm/node-semver/blob/v5.7.2/CHANGELOG.md)
- [Commits](https://github.com/npm/node-semver/compare/v5.7.1...v5.7.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-07-28 14:20:37 -04:00
Omar Al-Ithawi
11b62cce1d feat: include paragon in atlas pull (#344)
This is a follow up to #341

This pull request is part of the [FC-0012 project](https://openedx.atlassian.net/l/cp/XGS0iCcQ) which is sparked by the [Translation Infrastructure update OEP-58](https://open-edx-proposals.readthedocs.io/en/latest/architectural-decisions/oep-0058-arch-translations-management.html#specification).
2023-07-22 13:30:25 -04:00
Peter Kulko
78d521cd95 feat: upgrade dependencies and add LANGUAGE_PREFERENCE_COOKIE_NAME (#341) 2023-07-15 10:44:41 -04:00
Ben Warzeski
10cac378b1 refactor: update tests for ImportGradesButton to use react-unit-test-… (#338)
* fix: update package-lock

* chore: update unit test library version

* fix: move react-unit-test-utils to dependency

* fix: unit-test-utils version

* fix: update package-lock
2023-07-10 10:36:02 -04:00
Mashal Malik
9a92e39b6c Major version upgrade of paragon (#300)
* build: major version upgrade of paragon

* build: update react-intl

* refactor: remove react intl pkg

* refactor: update indentation
2023-06-26 17:17:22 +05:00
Ben Warzeski
39bff6e276 Bw/cm3 grades view (#334)
* fix: downgrade jest to avoid a date bug

* chore: add paragon icons and components to mocks

* chore: top-level formatDate util

* chore: redux transform hooks

* chore: add top-level data selectors

* chore: redux hooks

* refactor: update GradebookHeader component

* refactor: update GradebookFilters components

* refactor: update BulkManagementControls

* refactor: update EditModal component

* refactor: update FilterMenuToggle

* refactor: update FilteredUsersLabel;

* refactor: update GradebookTable

* refactor: update ImportSuccessToast

* refactor: update PageButtons

* refactor: update FilterBadges

* refactor: update ScoreViewInput

* refactor: update InterventionsReport

* refactor: update SearchControls

* refactor: update StatusAlerts

* chore: fix text name

* refactor: update SpinnerIcon

* chore: remove stale component

* refactor: update GradesView top component

* chore: remove old snapshots

* chore: update package-lock to node 18
2023-05-25 12:08:46 -04:00
Bilal Qamar
3be81e02ea 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-05-23 19:11:54 +05:00
jszewczulak
ffecce993e feat: added "Get Feedback" widget (#330) 2023-05-11 09:53:48 -04:00
253 changed files with 12787 additions and 44715 deletions

2
.env
View File

@@ -10,6 +10,7 @@ DATA_API_BASE_URL=''
SEGMENT_KEY='' SEGMENT_KEY=''
FEATURE_FLAGS={} FEATURE_FLAGS={}
ACCESS_TOKEN_COOKIE_NAME='' ACCESS_TOKEN_COOKIE_NAME=''
LANGUAGE_PREFERENCE_COOKIE_NAME=''
NEW_RELIC_APP_ID='' NEW_RELIC_APP_ID=''
NEW_RELIC_LICENSE_KEY='' NEW_RELIC_LICENSE_KEY=''
SITE_NAME='' SITE_NAME=''
@@ -32,3 +33,4 @@ ENTERPRISE_MARKETING_UTM_CAMPAIGN=''
ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM='' ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM=''
APP_ID='' APP_ID=''
MFE_CONFIG_API_URL='' MFE_CONFIG_API_URL=''
DISPLAY_FEEDBACK_WIDGET='true'

View File

@@ -7,11 +7,11 @@ LOGOUT_URL='http://localhost:18000/login'
LOGO_URL=https://edx-cdn.org/v3/default/logo.svg LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
LOGO_POWERED_BY_OPEN_EDX_URL_SVG=https://edx-cdn.org/v3/stage/open-edx-tag.svg
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
CSRF_TOKEN_API_PATH='/csrf/api/v1/token' CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh' REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload' ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
USER_INFO_COOKIE_NAME='edx-user-info' USER_INFO_COOKIE_NAME='edx-user-info'
SITE_NAME=localhost SITE_NAME=localhost
DATA_API_BASE_URL='http://localhost:8000' DATA_API_BASE_URL='http://localhost:8000'
@@ -39,3 +39,4 @@ ENTERPRISE_MARKETING_UTM_CAMPAIGN='example.com Referral'
ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM='Footer' ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM='Footer'
APP_ID='' APP_ID=''
MFE_CONFIG_API_URL='' MFE_CONFIG_API_URL=''
DISPLAY_FEEDBACK_WIDGET='false'

View File

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

View File

@@ -1,5 +1,5 @@
// eslint-disable-next-line import/no-extraneous-dependencies // eslint-disable-next-line import/no-extraneous-dependencies
const { createConfig } = require('@edx/frontend-build'); const { createConfig } = require('@openedx/frontend-build');
const config = createConfig('eslint', { const config = createConfig('eslint', {
rules: { rules: {

33
.github/renovate.json vendored Normal file
View File

@@ -0,0 +1,33 @@
{
"extends": [
"config:base",
"schedule:weekly",
":automergeLinters",
":automergeMinor",
":automergeTesters",
":enableVulnerabilityAlerts",
":rebaseStalePrs",
":semanticCommits",
":updateNotScheduled"
],
"packageRules": [
{
"matchDepTypes": [
"devDependencies"
],
"matchUpdateTypes": [
"lockFileMaintenance",
"minor",
"patch",
"pin"
],
"automerge": true
},
{
"matchPackagePatterns": ["@edx", "@openedx"],
"matchUpdateTypes": ["minor", "patch"],
"automerge": true
}
],
"timezone": "America/New_York"
}

View File

@@ -10,24 +10,20 @@ on:
jobs: jobs:
test: test:
runs-on: ubuntu-20.04 runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
node: [16] node: [18, 20]
npm: [8.5.x]
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v2 uses: actions/checkout@v3
- name: Setup Nodejs - name: Setup Nodejs
uses: actions/setup-node@v1 uses: actions/setup-node@v4
with: with:
node-version: ${{ matrix.node }} node-version: ${{ matrix.node }}
- name: Install npm 8.5.x
run: npm install -g npm@${{ matrix.npm }}
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
@@ -47,7 +43,10 @@ jobs:
run: npm run build run: npm run build
- name: Run Coverage - name: Run Coverage
uses: codecov/codecov-action@v2 uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: true
- name: Send failure notification - name: Send failure notification
if: ${{ failure() }} if: ${{ failure() }}

View File

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

View File

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

2
.gitignore vendored
View File

@@ -23,3 +23,5 @@ temp/babel-plugin-react-intl
### transifex ### ### transifex ###
src/i18n/transifex_input.json src/i18n/transifex_input.json
temp temp
src/i18n/messages/

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
20

View File

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

View File

@@ -1,18 +1,14 @@
npm-install-%: ## install specified % npm package npm-install-%: ## install specified % npm package
npm install $* --save-dev npm ci $* --save-dev
git add package.json git add package.json
export TRANSIFEX_RESOURCE = frontend-app-gradebook
transifex_langs = "ar,de,es_419,fa_IR,fr,fr_CA,hi,it,pt,ru,uk,zh_CN"
intl_imports = ./node_modules/.bin/intl-imports.js intl_imports = ./node_modules/.bin/intl-imports.js
transifex_utils = ./node_modules/.bin/transifex-utils.js transifex_utils = ./node_modules/.bin/transifex-utils.js
i18n = ./src/i18n i18n = ./src/i18n
transifex_input = $(i18n)/transifex_input.json transifex_input = $(i18n)/transifex_input.json
tx_url1 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/translation/en/strings/
tx_url2 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/source/
# This directory must match .babelrc . # This directory must match .babelrc .
transifex_temp = ./temp/babel-plugin-react-intl transifex_temp = ./temp/babel-plugin-formatjs
NPM_TESTS=build i18n_extract lint test is-es5 NPM_TESTS=build i18n_extract lint test is-es5
@@ -39,39 +35,20 @@ i18n.concat:
extract_translations: | requirements i18n.extract i18n.concat extract_translations: | requirements i18n.extract i18n.concat
# Despite the name, we actually need this target to detect changes in the incoming translated message files as well.
detect_changed_source_translations:
# Checking for changed translations...
git diff --exit-code $(i18n)
# Pushes translations to Transifex. You must run make extract_translations first.
push_translations:
# Pushing strings to Transifex...
tx push -s
# Fetching hashes from Transifex...
./node_modules/@edx/reactifex/bash_scripts/get_hashed_strings_v3.sh
# Writing out comments to file...
$(transifex_utils) $(transifex_temp) --comments --v3-scripts-path
# Pushing comments to Transifex...
./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh
ifeq ($(OPENEDX_ATLAS_PULL),)
# Pulls translations from Transifex.
pull_translations:
tx pull -t -f --mode reviewed --languages=$(transifex_langs)
else
# Experimental: OEP-58 Pulls translations using atlas # Experimental: OEP-58 Pulls translations using atlas
pull_translations: pull_translations:
rm -rf src/i18n/messages rm -rf src/i18n/messages
mkdir src/i18n/messages mkdir src/i18n/messages
cd src/i18n/messages \ cd src/i18n/messages \
&& atlas pull --filter=$(transifex_langs) \ && atlas pull $(ATLAS_OPTIONS) \
translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \ translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \
translations/frontend-component-header/src/i18n/messages:frontend-component-header \ translations/frontend-component-header/src/i18n/messages:frontend-component-header \
translations/frontend-platform/src/i18n/messages:frontend-platform \
translations/paragon/src/i18n/messages:paragon \
translations/frontend-app-gradebook/src/i18n/messages:frontend-app-gradebook translations/frontend-app-gradebook/src/i18n/messages:frontend-app-gradebook
$(intl_imports) frontend-component-header frontend-component-footer frontend-app-gradebook $(intl_imports) frontend-platform paragon frontend-component-header frontend-component-footer frontend-app-gradebook
endif
# This target is used by CI. # This target is used by CI.
validate-no-uncommitted-package-lock-changes: validate-no-uncommitted-package-lock-changes:

View File

@@ -1,3 +1,5 @@
# frontend-app-gradebook
[![Build Status](https://api.travis-ci.com/edx/frontend-app-gradebook.svg?branch=master)](https://travis-ci.com/edx/frontend-app-gradebook) [![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) [![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_version](https://img.shields.io/npm/v/@edx/frontend-app-gradebook.svg)](@edx/frontend-app-gradebook)
@@ -5,7 +7,7 @@
[![license](https://img.shields.io/npm/l/@edx/frontend-app-gradebook.svg)](@edx/frontend-app-gradebook) [![license](https://img.shields.io/npm/l/@edx/frontend-app-gradebook.svg)](@edx/frontend-app-gradebook)
[![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release)
# Gradebook # Purpose
Gradebook allows course staff to view, filter, and override subsection grades for a course. Additionally for Masters courses, Gradebook enables bulk management of subsection grades. Gradebook allows course staff to view, filter, and override subsection grades for a course. Additionally for Masters courses, Gradebook enables bulk management of subsection grades.
@@ -47,7 +49,8 @@ depending on their needs. Instructors that expect to review grades infrequently
to the problem in question will have a worse UX than the legacy gradebook provides. Instructors that rely on the graphs to the problem in question will have a worse UX than the legacy gradebook provides. Instructors that rely on the graphs
generated by the current gradebook might find the lack of autogenerated graphs to be frustrating. generated by the current gradebook might find the lack of autogenerated graphs to be frustrating.
## Quickstart ## Getting Started
### Installation ### Installation
@@ -55,6 +58,20 @@ To install gradebook into your project:
``` ```
npm i --save @edx/frontend-app-gradebook npm i --save @edx/frontend-app-gradebook
``` ```
Cloning and Startup
===================
1. Clone your new repo:
``git clone https://github.com/openedx/frontend-app-gradebook.git``
2. Install npm dependencies:
``cd frontend-app-gradebook && npm install``
3. Start the dev server:
``npm start``
## Running the UI Standalone ## Running the UI Standalone
@@ -91,6 +108,11 @@ check the ``enabled`` and ``enabled for all courses`` boxes.
numbers for grades. If your gradebook isn't accepting your changes, or the changes aren't resulting in sane, numbers for grades. If your gradebook isn't accepting your changes, or the changes aren't resulting in sane,
recalculated grade values, verify you've set all flags correctly. recalculated grade values, verify you've set all flags correctly.
## Plugins
This MFE can be customized using [Frontend Plugin Framework](https://github.com/openedx/frontend-plugin-framework).
The parts of this MFE that can be customized in that manner are documented [here](/src/plugin-slots).
## Running tests ## Running tests
1. Assuming that you're operating in the context of the edX devstack, 1. Assuming that you're operating in the context of the edX devstack,
@@ -119,3 +141,57 @@ running gradebook container.
## Authentication with backend API services ## Authentication with backend API services
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. 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.
License
=======
The code in this repository is licensed under the AGPLv3 unless otherwise
noted.
Contributing
============
Contributions are very welcome. Please read `How To Contribute`_ for details.
.. _How To Contribute: https://openedx.org/r/how-to-contribute
This project is currently accepting all types of contributions, bug fixes,
security fixes, maintenance work, or new features. However, please make sure
to have a discussion about your new feature idea with the maintainers prior to
beginning development to maximize the chances of your change being accepted.
You can start a conversation by creating a new issue on this repo summarizing
your idea.
Getting Help
===========
If you're having trouble, we have discussion forums at
https://discuss.openedx.org where you can connect with others in the community.
Our real-time conversations are on Slack. You can request a `Slack
invitation`_, then join our `community Slack workspace`_. Because this is a
frontend repository, the best place to discuss it would be in the `#wg-frontend
channel`_.
For anything non-trivial, the best path is to open an issue in this repository
with as many details about the issue you are facing as you can provide.
https://github.com/openedx/frontend-app-gradebook/issues
For more information about these options, see the `Getting Help`_ page.
.. _Slack invitation: https://openedx.org/slack
.. _community Slack workspace: https://openedx.slack.com/
.. _#wg-frontend channel: https://openedx.slack.com/archives/C04BM6YC7A6
.. _Getting Help: https://openedx.org/community/connect
The Open edX Code of Conduct
============================
All community members are expected to follow the `Open edX Code of Conduct`_.
.. _Open edX Code of Conduct: https://openedx.org/code-of-conduct/
Reporting Security Issues
=========================
Please do not report security issues in public. Please email security@openedx.org.

View File

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

13
catalog-info.yaml Normal file
View File

@@ -0,0 +1,13 @@
# This file records information about this repo. Its use is described in OEP-55:
# https://open-edx-proposals.readthedocs.io/en/latest/processes/oep-0055-proc-project-maintainers.html
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: "frontend-app-gradebook"
description: "The frontend (MFE) for Open edX Gradebook"
annotations:
openedx.org/arch-interest-groups: ""
spec:
owner: user:farhaanbukhsh
type: 'website'
lifecycle: 'experimental'

View File

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

46530
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,7 @@
"scripts": { "scripts": {
"build": "fedx-scripts webpack", "build": "fedx-scripts webpack",
"is-es5": "es-check es5 ./dist/*.js", "is-es5": "es-check es5 ./dist/*.js",
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null", "i18n_extract": "fedx-scripts formatjs extract",
"lint": "fedx-scripts eslint --ext .jsx,.js src/", "lint": "fedx-scripts eslint --ext .jsx,.js src/",
"lint-fix": "fedx-scripts eslint --fix --ext .jsx,.js src/", "lint-fix": "fedx-scripts eslint --fix --ext .jsx,.js src/",
"prepush": "npm run lint", "prepush": "npm run lint",
@@ -28,11 +28,14 @@
"extends @edx/browserslist-config" "extends @edx/browserslist-config"
], ],
"dependencies": { "dependencies": {
"@edx/brand": "npm:@edx/brand-openedx@^1.2.0", "@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/frontend-component-footer": "^12.0.0", "@edx/frontend-component-header": "^5.6.0",
"@edx/frontend-component-header": "^4.0.0", "@edx/frontend-platform": "8.0.0",
"@edx/frontend-platform": "^4.2.0", "@edx/openedx-atlas": "^0.6.0",
"@edx/paragon": "^19.25.4", "@edx/react-unit-test-utils": "^3.0.0",
"@openedx/frontend-plugin-framework": "^1.2.1",
"@openedx/frontend-slot-footer": "^1.0.2",
"@openedx/paragon": "^22.1.1",
"@edx/reactifex": "^2.1.1", "@edx/reactifex": "^2.1.1",
"@fortawesome/fontawesome-svg-core": "^1.2.25", "@fortawesome/fontawesome-svg-core": "^1.2.25",
"@fortawesome/free-brands-svg-icons": "^5.11.2", "@fortawesome/free-brands-svg-icons": "^5.11.2",
@@ -43,18 +46,16 @@
"classnames": "^2.2.6", "classnames": "^2.2.6",
"core-js": "3.6.5", "core-js": "3.6.5",
"email-prop-type": "^1.1.7", "email-prop-type": "^1.1.7",
"enzyme": "^3.10.0",
"enzyme-to-json": "^3.6.2",
"font-awesome": "4.7.0", "font-awesome": "4.7.0",
"history": "4.10.1", "history": "4.10.1",
"prop-types": "15.8.1", "prop-types": "15.8.1",
"query-string": "6.13.0", "query-string": "6.13.0",
"react": "16.14.0", "react": "17.0.2",
"react-dom": "16.14.0", "react-dom": "17.0.2",
"react-helmet": "^6.1.0", "react-helmet": "^6.1.0",
"react-redux": "^7.1.1", "react-redux": "^7.2.9",
"react-router": "5.2.0", "react-router": "6.15.0",
"react-router-dom": "5.2.0", "react-router-dom": "6.15.0",
"react-router-redux": "^5.0.0-alpha.9", "react-router-redux": "^5.0.0-alpha.9",
"redux": "4.0.5", "redux": "4.0.5",
"redux-beacon": "^2.1.0", "redux-beacon": "^2.1.0",
@@ -67,18 +68,17 @@
}, },
"devDependencies": { "devDependencies": {
"@edx/browserslist-config": "^1.1.1", "@edx/browserslist-config": "^1.1.1",
"@edx/frontend-build": "^12.4.15", "@openedx/frontend-build": "14.0.3",
"@testing-library/react": "^12.1.0", "@testing-library/react": "12.1.5",
"axios": "0.21.2", "axios": "0.21.2",
"axios-mock-adapter": "^1.17.0", "axios-mock-adapter": "^1.17.0",
"enzyme-adapter-react-16": "^1.14.0",
"es-check": "^2.3.0", "es-check": "^2.3.0",
"fetch-mock": "^6.5.2", "fetch-mock": "^6.5.2",
"husky": "2.7.0", "husky": "2.7.0",
"identity-obj-proxy": "^3.0.0", "identity-obj-proxy": "^3.0.0",
"jest": "29.3.1", "jest": "^29.7.0",
"react-dev-utils": "^12.0.1", "react-dev-utils": "^12.0.1",
"react-test-renderer": "^16.10.1", "react-test-renderer": "17.0.2",
"reactifex": "1.1.1", "reactifex": "1.1.1",
"redux-mock-store": "^1.5.3", "redux-mock-store": "^1.5.3",
"semantic-release": "^19.0.3" "semantic-release": "^19.0.3"

View File

@@ -1,12 +1,11 @@
import React from 'react'; import React from 'react';
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom'; import { Route, Routes } from 'react-router-dom';
import { AppProvider } from '@edx/frontend-platform/react'; import { AppProvider } from '@edx/frontend-platform/react';
import Footer from '@edx/frontend-component-footer'; import FooterSlot from '@openedx/frontend-slot-footer';
import Header from '@edx/frontend-component-header'; import Header from '@edx/frontend-component-header';
import { routePath } from 'data/constants/app';
import store from 'data/store'; import store from 'data/store';
import GradebookPage from 'containers/GradebookPage'; import GradebookPage from 'containers/GradebookPage';
import './App.scss'; import './App.scss';
@@ -15,21 +14,18 @@ import Head from './head/Head';
const App = () => ( const App = () => (
<AppProvider store={store}> <AppProvider store={store}>
<Head /> <Head />
<Router> <div>
<div> <Header />
<Header /> <main>
<main> <Routes>
<Switch> <Route
<Route path="/:courseId"
exact element={<GradebookPage />}
path={routePath} />
component={GradebookPage} </Routes>
/> </main>
</Switch> <FooterSlot />
</main> </div>
<Footer logo={process.env.LOGO_POWERED_BY_OPEN_EDX_URL_SVG} />
</div>
</Router>
</AppProvider> </AppProvider>
); );

View File

@@ -1,7 +1,7 @@
// frontend-app-*/src/index.scss // frontend-app-*/src/index.scss
@import "~@edx/brand/paragon/fonts"; @import "~@edx/brand/paragon/fonts";
@import "~@edx/brand/paragon/variables"; @import "~@edx/brand/paragon/variables";
@import "~@edx/paragon/scss/core/core"; @import "~@openedx/paragon/scss/core/core";
@import "~@edx/brand/paragon/overrides"; @import "~@edx/brand/paragon/overrides";
$fa-font-path: "~font-awesome/fonts"; $fa-font-path: "~font-awesome/fonts";

View File

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

View File

@@ -5,20 +5,17 @@ exports[`App router component snapshot 1`] = `
store="testStore" store="testStore"
> >
<Head /> <Head />
<BrowserRouter> <div>
<div> <Header />
<Header /> <main>
<main> <Routes>
<Switch> <Route
<Route element={<GradebookPage />}
component="GradebookPage" path="/:courseId"
exact={true} />
path="/:courseId" </Routes>
/> </main>
</Switch> <FooterSlot />
</main> </div>
<Footer />
</div>
</BrowserRouter>
</AppProvider> </AppProvider>
`; `;

View File

@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
import { FormattedMessage } from '@edx/frontend-platform/i18n'; import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { Alert } from '@edx/paragon'; import { Alert } from '@openedx/paragon';
import selectors from 'data/selectors'; import selectors from 'data/selectors';
import messages from './messages'; import messages from './messages';

View File

@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { shallow } from 'enzyme'; import { shallow } from '@edx/react-unit-test-utils';
import { Alert } from '@edx/paragon'; import { Alert } from '@openedx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n'; import { FormattedMessage } from '@edx/frontend-platform/i18n';
import selectors from 'data/selectors'; import selectors from 'data/selectors';
@@ -12,7 +12,7 @@ jest.mock('@edx/frontend-platform/i18n', () => ({
defineMessages: m => m, defineMessages: m => m,
FormattedMessage: () => 'FormattedMessage', FormattedMessage: () => 'FormattedMessage',
})); }));
jest.mock('@edx/paragon', () => ({ jest.mock('@openedx/paragon', () => ({
Alert: () => 'Alert', Alert: () => 'Alert',
})); }));
jest.mock('data/selectors', () => ({ jest.mock('data/selectors', () => ({
@@ -35,17 +35,17 @@ describe('BulkManagementAlerts', () => {
el = shallow(<BulkManagementAlerts />); el = shallow(<BulkManagementAlerts />);
}); });
test('snapshot - bulkImportError closed, success closed', () => { test('snapshot - bulkImportError closed, success closed', () => {
expect(el).toMatchSnapshot(); expect(el.snapshot).toMatchSnapshot();
}); });
test('closed danger alert', () => { test('closed danger alert', () => {
expect(el.childAt(0).is(Alert)).toEqual(true); expect(el.instance.children[0].type).toBe('Alert');
expect(el.childAt(0).props().show).toEqual(false); expect(el.instance.findByType(Alert)[0].props.show).toEqual(false);
expect(el.childAt(0).props().variant).toEqual('danger'); expect(el.instance.findByType(Alert)[0].props.variant).toEqual('danger');
}); });
test('closed success alert', () => { test('closed success alert', () => {
expect(el.childAt(1).is(Alert)).toEqual(true); expect(el.instance.children[1].type).toBe('Alert');
expect(el.childAt(1).props().show).toEqual(false); expect(el.instance.findByType(Alert)[1].props.show).toEqual(false);
expect(el.childAt(1).props().variant).toEqual('success'); expect(el.instance.findByType(Alert)[1].props.variant).toEqual('success');
}); });
}); });
describe('no errer, no upload success', () => { describe('no errer, no upload success', () => {
@@ -57,19 +57,19 @@ describe('BulkManagementAlerts', () => {
'success alert open with messages.successDialog', 'success alert open with messages.successDialog',
]; ];
test(`snapshot - ${assertions.join(', ')}`, () => { test(`snapshot - ${assertions.join(', ')}`, () => {
expect(el).toMatchSnapshot(); expect(el.snapshot).toMatchSnapshot();
}); });
test('open danger alert with bulkImportError content', () => { test('open danger alert with bulkImportError content', () => {
expect(el.childAt(0).is(Alert)).toEqual(true); expect(el.instance.children[0].type).toBe('Alert');
expect(el.childAt(0).children().text()).toEqual(errorMessage); expect(el.instance.findByType(Alert)[0].children[0].el).toEqual(errorMessage);
expect(el.childAt(0).props().show).toEqual(true); expect(el.instance.findByType(Alert)[0].props.show).toEqual(true);
}); });
test('open success alert with messages.successDialog content', () => { test('open success alert with messages.successDialog content', () => {
expect(el.childAt(1).is(Alert)).toEqual(true); expect(el.instance.children[1].type).toBe('Alert');
expect(el.childAt(1).children().getElement()).toEqual( expect(el.shallowWrapper.props.children[1].props.children).toEqual(
<FormattedMessage {...messages.successDialog} />, <FormattedMessage {...messages.successDialog} />,
); );
expect(el.childAt(1).props().show).toEqual(true); expect(el.instance.children[1].props.show).toEqual(true);
}); });
}); });
}); });

View File

@@ -3,7 +3,7 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { DataTable } from '@edx/paragon'; import { DataTable } from '@openedx/paragon';
import { bulkManagementColumns } from 'data/constants/app'; import { bulkManagementColumns } from 'data/constants/app';
import selectors from 'data/selectors'; import selectors from 'data/selectors';

View File

@@ -1,7 +1,7 @@
/* eslint-disable import/no-named-as-default */ /* eslint-disable import/no-named-as-default */
import React from 'react'; import React from 'react';
import { shallow } from 'enzyme'; import { shallow } from '@edx/react-unit-test-utils';
import { DataTable } from '@edx/paragon'; import { DataTable } from '@openedx/paragon';
import selectors from 'data/selectors'; import selectors from 'data/selectors';
import { bulkManagementColumns } from 'data/constants/app'; import { bulkManagementColumns } from 'data/constants/app';
@@ -9,7 +9,7 @@ import { bulkManagementColumns } from 'data/constants/app';
import ResultsSummary from './ResultsSummary'; import ResultsSummary from './ResultsSummary';
import { HistoryTable, mapStateToProps } from './HistoryTable'; import { HistoryTable, mapStateToProps } from './HistoryTable';
jest.mock('@edx/paragon', () => ({ DataTable: () => 'DataTable' })); jest.mock('@openedx/paragon', () => ({ DataTable: () => 'DataTable' }));
jest.mock('@edx/frontend-platform/i18n', () => ({ jest.mock('@edx/frontend-platform/i18n', () => ({
defineMessages: m => m, defineMessages: m => m,
@@ -56,12 +56,12 @@ describe('HistoryTable', () => {
el = shallow(<HistoryTable {...props} />); el = shallow(<HistoryTable {...props} />);
}); });
test('snapshot - loads formatted table', () => { test('snapshot - loads formatted table', () => {
expect(el).toMatchSnapshot(); expect(el.snapshot).toMatchSnapshot();
}); });
describe('history table', () => { describe('history table', () => {
let table; let table;
beforeEach(() => { beforeEach(() => {
table = el.find(DataTable); table = el.instance.findByType(DataTable);
}); });
describe('data (from bulkManagementHistory.map(this.formatHistoryRow)', () => { describe('data (from bulkManagementHistory.map(this.formatHistoryRow)', () => {
const fieldAssertions = [ const fieldAssertions = [
@@ -70,10 +70,10 @@ describe('HistoryTable', () => {
'forwards the rest', 'forwards the rest',
]; ];
test(`snapshot: ${fieldAssertions.join(', ')}`, () => { test(`snapshot: ${fieldAssertions.join(', ')}`, () => {
expect(table.props().data).toMatchSnapshot(); expect(table[0].props.data).toMatchSnapshot();
}); });
test(fieldAssertions.join(', '), () => { test(fieldAssertions.join(', '), () => {
const rows = table.props().data; const rows = table[0].props.data;
expect(rows[0].resultsSummary).toEqual(<ResultsSummary {...entry1.resultsSummary} />); expect(rows[0].resultsSummary).toEqual(<ResultsSummary {...entry1.resultsSummary} />);
expect(rows[0].user).toEqual(<span className="wrap-text-in-cell">{entry1.user}</span>); expect(rows[0].user).toEqual(<span className="wrap-text-in-cell">{entry1.user}</span>);
expect( expect(
@@ -87,7 +87,7 @@ describe('HistoryTable', () => {
}); });
}); });
test('columns from bulkManagementColumns', () => { test('columns from bulkManagementColumns', () => {
expect(table.props().columns).toEqual(bulkManagementColumns); expect(table[0].props.columns).toEqual(bulkManagementColumns);
}); });
}); });
}); });

View File

@@ -2,8 +2,8 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Hyperlink, Icon } from '@edx/paragon'; import { Hyperlink, Icon } from '@openedx/paragon';
import { Download } from '@edx/paragon/icons'; import { Download } from '@openedx/paragon/icons';
import lms from 'data/services/lms'; import lms from 'data/services/lms';

View File

@@ -1,17 +1,16 @@
import React from 'react'; import React from 'react';
import { shallow } from 'enzyme'; import { shallow } from '@edx/react-unit-test-utils';
import { Icon } from '@edx/paragon'; import { Download } from '@openedx/paragon/icons';
import { Download } from '@edx/paragon/icons';
import lms from 'data/services/lms'; import lms from 'data/services/lms';
import ResultsSummary from './ResultsSummary'; import ResultsSummary from './ResultsSummary';
jest.mock('@edx/paragon', () => ({ jest.mock('@openedx/paragon', () => ({
Hyperlink: () => 'Hyperlink', Hyperlink: () => 'Hyperlink',
Icon: () => 'Icon', Icon: () => 'Icon',
})); }));
jest.mock('@edx/paragon/icons', () => ({ jest.mock('@openedx/paragon/icons', () => ({
Download: 'DownloadIcon', Download: 'DownloadIcon',
})); }));
jest.mock('data/services/lms', () => ({ jest.mock('data/services/lms', () => ({
@@ -35,19 +34,19 @@ describe('ResultsSummary component', () => {
el = shallow(<ResultsSummary {...props} />); el = shallow(<ResultsSummary {...props} />);
}); });
test(`snapshot - ${assertions.join(', ')}`, () => { test(`snapshot - ${assertions.join(', ')}`, () => {
expect(el).toMatchSnapshot(); expect(el.snapshot).toMatchSnapshot();
}); });
test('Hyperlink has target="_blank" and rel="noopener noreferrer"', () => { test('Hyperlink has target="_blank" and rel="noopener noreferrer"', () => {
expect(el.props().target).toEqual('_blank'); expect(el.instance.props.target).toEqual('_blank');
expect(el.props().rel).toEqual('noopener noreferrer'); expect(el.instance.props.rel).toEqual('noopener noreferrer');
}); });
test('Hyperlink has href to bulkGradesUrl', () => { test('Hyperlink has href to bulkGradesUrl', () => {
expect(el.props().href).toEqual(lms.urls.bulkGradesUrlByRow(props.rowId)); expect(el.instance.props.href).toEqual(lms.urls.bulkGradesUrlByRow(props.rowId));
}); });
test('displays Download Icon and text', () => { test('displays Download Icon and text', () => {
const icon = el.childAt(0); const icon = el.instance.children[0];
expect(icon.is(Icon)).toEqual(true); expect(icon.type).toEqual('Icon');
expect(icon.props().src).toEqual(Download); expect(icon.props.src).toEqual(Download);
expect(el.childAt(1).text()).toEqual(props.text); expect(el.instance.children[1].el).toEqual(props.text);
}); });
}); });

View File

@@ -1,8 +1,8 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`HistoryTable component snapshot history table data (from bulkManagementHistory.map(this.formatHistoryRow) snapshot: maps resultsSummay to ResultsSummary, wraps filename and user, forwards the rest 1`] = ` exports[`HistoryTable component snapshot history table data (from bulkManagementHistory.map(this.formatHistoryRow) snapshot: maps resultsSummay to ResultsSummary, wraps filename and user, forwards the rest 1`] = `
Array [ [
Object { {
"filename": <span "filename": <span
className="wrap-text-in-cell" className="wrap-text-in-cell"
> >
@@ -20,7 +20,7 @@ Array [
Eifel Eifel
</span>, </span>,
}, },
Object { {
"filename": <span "filename": <span
className="wrap-text-in-cell" className="wrap-text-in-cell"
> >
@@ -45,26 +45,26 @@ exports[`HistoryTable component snapshot snapshot - loads formatted table 1`] =
<DataTable <DataTable
className="table-striped" className="table-striped"
columns={ columns={
Array [ [
Object { {
"Header": "Gradebook", "Header": "Gradebook",
"accessor": "filename", "accessor": "filename",
"columnSortable": false, "columnSortable": false,
"width": "col-5", "width": "col-5",
}, },
Object { {
"Header": "Download Summary", "Header": "Download Summary",
"accessor": "resultsSummary", "accessor": "resultsSummary",
"columnSortable": false, "columnSortable": false,
"width": "col", "width": "col",
}, },
Object { {
"Header": "Who", "Header": "Who",
"accessor": "user", "accessor": "user",
"columnSortable": false, "columnSortable": false,
"width": "col-1", "width": "col-1",
}, },
Object { {
"Header": "When", "Header": "When",
"accessor": "timeUploaded", "accessor": "timeUploaded",
"columnSortable": false, "columnSortable": false,
@@ -73,8 +73,8 @@ exports[`HistoryTable component snapshot snapshot - loads formatted table 1`] =
] ]
} }
data={ data={
Array [ [
Object { {
"filename": <span "filename": <span
className="wrap-text-in-cell" className="wrap-text-in-cell"
> >
@@ -92,7 +92,7 @@ exports[`HistoryTable component snapshot snapshot - loads formatted table 1`] =
Eifel Eifel
</span>, </span>,
}, },
Object { {
"filename": <span "filename": <span
className="wrap-text-in-cell" className="wrap-text-in-cell"
> >

View File

@@ -4,8 +4,8 @@ exports[`ResultsSummary component snapshot - safe hyperlink with bulkGradesUrl w
<Hyperlink <Hyperlink
destination="www.edx.org" destination="www.edx.org"
href={ href={
Object { {
"url": Object { "url": {
"rowId": 42, "rowId": 42,
}, },
} }

View File

@@ -1,6 +1,6 @@
/* eslint-disable import/no-named-as-default */ /* eslint-disable import/no-named-as-default */
import React from 'react'; import React from 'react';
import { shallow } from 'enzyme'; import { shallow } from '@edx/react-unit-test-utils';
import { FormattedMessage } from '@edx/frontend-platform/i18n'; import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { BulkManagementHistoryView } from '.'; import { BulkManagementHistoryView } from '.';
@@ -24,20 +24,23 @@ describe('BulkManagementHistoryView', () => {
'<HistoryTable />', '<HistoryTable />',
]; ];
test(`snapshot - loads ${snapshotSegments.join(', ')}`, () => { test(`snapshot - loads ${snapshotSegments.join(', ')}`, () => {
expect(el).toMatchSnapshot(); expect(el.snapshot).toMatchSnapshot();
}); });
test('heading - h4 loaded from messages', () => { test('heading - h4 loaded from messages', () => {
const heading = el.find('h4'); const heading = el.instance.findByType('h4')[0];
expect(heading.getElement()).toEqual(( const expectedHeading = shallow(
<h4> <h4>
<FormattedMessage {...messages.heading} /> <FormattedMessage {...messages.heading} />
</h4> </h4>,
)); );
expect(heading.el.type).toEqual(expectedHeading.type);
expect(heading.el.props).toEqual(expectedHeading.props);
}); });
test('heading, then alerts, then upload form, then table', () => { test('heading, then alerts, then upload form, then table', () => {
expect(el.childAt(0).is('h4')).toEqual(true); expect(el.instance.children[0].type).toEqual('h4');
expect(el.childAt(2).is(BulkManagementAlerts)).toEqual(true); expect(el.instance.children[2].type).toEqual(BulkManagementAlerts);
expect(el.childAt(3).is(HistoryTable)).toEqual(true); expect(el.instance.children[3].type).toEqual(HistoryTable);
}); });
}); });
}); });

View File

@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { Hyperlink } from '@edx/paragon'; import { Hyperlink } from '@openedx/paragon';
import { getConfig } from '@edx/frontend-platform'; import { getConfig } from '@edx/frontend-platform';
/** /**

View File

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

View File

@@ -10,7 +10,7 @@ exports[`AssignmentFilter component render snapshot 1`] = `
label="Assignment" label="Assignment"
onChange={[MockFunction]} onChange={[MockFunction]}
options={ options={
Array [ [
<option <option
value="" value=""
> >

View File

@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { shallow } from 'enzyme'; import { shallow } from '@edx/react-unit-test-utils';
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import SelectGroup from '../SelectGroup'; import SelectGroup from '../SelectGroup';
@@ -38,10 +38,10 @@ describe('AssignmentFilter component', () => {
}); });
describe('render', () => { describe('render', () => {
test('snapshot', () => { test('snapshot', () => {
expect(el).toMatchSnapshot(); expect(el.snapshot).toMatchSnapshot();
}); });
test('filter options', () => { test('filter options', () => {
const { options } = el.find(SelectGroup).props(); const { options } = el.instance.findByType(SelectGroup)[0].props;
expect(options.length).toEqual(5); expect(options.length).toEqual(5);
const testOption = assignmentFilterOptions[0]; const testOption = assignmentFilterOptions[0];
const optionProps = options[1].props; const optionProps = options[1].props;

View File

@@ -2,7 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon'; import { Button } from '@openedx/paragon';
import useAssignmentGradeFilterData from './hooks'; import useAssignmentGradeFilterData from './hooks';
import messages from '../messages'; import messages from '../messages';

View File

@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { shallow } from 'enzyme'; import { shallow } from '@edx/react-unit-test-utils';
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon'; import { Button } from '@openedx/paragon';
import PercentGroup from '../PercentGroup'; import PercentGroup from '../PercentGroup';
import useAssignmentGradeFilterData from './hooks'; import useAssignmentGradeFilterData from './hooks';
@@ -37,20 +37,20 @@ describe('AssignmentFilter component', () => {
describe('render', () => { describe('render', () => {
describe('with selected assignment', () => { describe('with selected assignment', () => {
test('snapshot', () => { test('snapshot', () => {
expect(el).toMatchSnapshot(); expect(el.snapshot).toMatchSnapshot();
}); });
it('renders a PercentGroup for both Max and Min filters', () => { it('renders a PercentGroup for both Max and Min filters', () => {
let props = el.find(PercentGroup).at(0).props(); let { props } = el.instance.findByType(PercentGroup)[0];
expect(props.value).toEqual(hookData.assignmentGradeMin); expect(props.value).toEqual(hookData.assignmentGradeMin);
expect(props.disabled).toEqual(false); expect(props.disabled).toEqual(false);
expect(props.onChange).toEqual(hookData.handleSetMin); expect(props.onChange).toEqual(hookData.handleSetMin);
props = el.find(PercentGroup).at(1).props(); props = el.instance.findByType(PercentGroup)[1].props;
expect(props.value).toEqual(hookData.assignmentGradeMax); expect(props.value).toEqual(hookData.assignmentGradeMax);
expect(props.disabled).toEqual(false); expect(props.disabled).toEqual(false);
expect(props.onChange).toEqual(hookData.handleSetMax); expect(props.onChange).toEqual(hookData.handleSetMax);
}); });
it('renders a submit button', () => { it('renders a submit button', () => {
const props = el.find(Button).props(); const { props } = el.instance.findByType(Button)[0];
expect(props.disabled).toEqual(false); expect(props.disabled).toEqual(false);
expect(props.onClick).toEqual(hookData.handleSubmit); expect(props.onClick).toEqual(hookData.handleSubmit);
}); });
@@ -64,12 +64,12 @@ describe('AssignmentFilter component', () => {
el = shallow(<AssignmentFilter updateQueryParams={updateQueryParams} />); el = shallow(<AssignmentFilter updateQueryParams={updateQueryParams} />);
}); });
test('snapshot', () => { test('snapshot', () => {
expect(el).toMatchSnapshot(); expect(el.snapshot).toMatchSnapshot();
}); });
it('disables controls', () => { it('disables controls', () => {
let props = el.find(PercentGroup).at(0).props(); let { props } = el.instance.findByType(PercentGroup)[0];
expect(props.disabled).toEqual(true); expect(props.disabled).toEqual(true);
props = el.find(PercentGroup).at(1).props(); props = el.instance.findByType(PercentGroup)[1].props;
expect(props.disabled).toEqual(true); expect(props.disabled).toEqual(true);
}); });
}); });

View File

@@ -10,7 +10,7 @@ exports[`AssignmentFilterType component render snapshot 1`] = `
label="Assignment Types" label="Assignment Types"
onChange={[MockFunction]} onChange={[MockFunction]}
options={ options={
Array [ [
<option <option
value="" value=""
> >

View File

@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { shallow } from 'enzyme'; import { shallow } from '@edx/react-unit-test-utils';
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import SelectGroup from '../SelectGroup'; import SelectGroup from '../SelectGroup';
@@ -34,10 +34,10 @@ describe('AssignmentFilterType component', () => {
}); });
describe('render', () => { describe('render', () => {
test('snapshot', () => { test('snapshot', () => {
expect(el).toMatchSnapshot(); expect(el.snapshot).toMatchSnapshot();
}); });
test('filter options', () => { test('filter options', () => {
const { options } = el.find(SelectGroup).props(); const { options } = el.instance.findByType(SelectGroup)[0].props;
expect(options.length).toEqual(5); expect(options.length).toEqual(5);
const optionProps = options[1].props; const optionProps = options[1].props;
expect(optionProps.value).toEqual(assignmentTypes[0]); expect(optionProps.value).toEqual(assignmentTypes[0]);

View File

@@ -1,6 +1,6 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Button } from '@edx/paragon'; import { Button } from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import messages from '../messages'; import messages from '../messages';

View File

@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { shallow } from 'enzyme'; import { shallow } from '@edx/react-unit-test-utils';
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon'; import { Button } from '@openedx/paragon';
import PercentGroup from '../PercentGroup'; import PercentGroup from '../PercentGroup';
import useCourseGradeFilterData from './hooks'; import useCourseGradeFilterData from './hooks';
@@ -42,18 +42,18 @@ describe('CourseFilter component', () => {
describe('render', () => { describe('render', () => {
describe('with selected assignment', () => { describe('with selected assignment', () => {
test('snapshot', () => { test('snapshot', () => {
expect(el).toMatchSnapshot(); expect(el.snapshot).toMatchSnapshot();
}); });
it('renders a PercentGroup for both Max and Min filters', () => { it('renders a PercentGroup for both Max and Min filters', () => {
let props = el.find(PercentGroup).at(0).props(); let { props } = el.instance.findByType(PercentGroup)[0];
expect(props.value).toEqual(hookData.min.value); expect(props.value).toEqual(hookData.min.value);
expect(props.onChange).toEqual(hookData.min.onChange); expect(props.onChange).toEqual(hookData.min.onChange);
props = el.find(PercentGroup).at(1).props(); props = el.instance.findByType(PercentGroup)[1].props;
expect(props.value).toEqual(hookData.max.value); expect(props.value).toEqual(hookData.max.value);
expect(props.onChange).toEqual(hookData.max.onChange); expect(props.onChange).toEqual(hookData.max.onChange);
}); });
it('renders a submit button', () => { it('renders a submit button', () => {
const props = el.find(Button).props(); const { props } = el.instance.findByType(Button)[0];
expect(props.disabled).toEqual(false); expect(props.disabled).toEqual(false);
expect(props.onClick).toEqual(hookData.handleApplyClick); expect(props.onClick).toEqual(hookData.handleApplyClick);
}); });
@@ -64,10 +64,10 @@ describe('CourseFilter component', () => {
el = shallow(<CourseFilter updateQueryParams={updateQueryParams} />); el = shallow(<CourseFilter updateQueryParams={updateQueryParams} />);
}); });
test('snapshot', () => { test('snapshot', () => {
expect(el).toMatchSnapshot(); expect(el.snapshot).toMatchSnapshot();
}); });
it('disables submit', () => { it('disables submit', () => {
const props = el.find(Button).props(); const { props } = el.instance.findByType(Button)[0];
expect(props.disabled).toEqual(true); expect(props.disabled).toEqual(true);
}); });
}); });

View File

@@ -2,7 +2,7 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Form } from '@edx/paragon'; import { Form } from '@openedx/paragon';
const PercentGroup = ({ const PercentGroup = ({
id, id,

View File

@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { shallow } from 'enzyme'; import { shallow } from '@edx/react-unit-test-utils';
import PercentGroup from './PercentGroup'; import PercentGroup from './PercentGroup';
@@ -22,11 +22,11 @@ describe('PercentGroup', () => {
describe('snapshots', () => { describe('snapshots', () => {
test('basic snapshot', () => { test('basic snapshot', () => {
const el = shallow(<PercentGroup {...props} />); const el = shallow(<PercentGroup {...props} />);
expect(el).toMatchSnapshot(); expect(el.snapshot).toMatchSnapshot();
}); });
test('disabled', () => { test('disabled', () => {
const el = shallow(<PercentGroup {...props} disabled />); const el = shallow(<PercentGroup {...props} disabled />);
expect(el).toMatchSnapshot(); expect(el.snapshot).toMatchSnapshot();
}); });
}); });
}); });

View File

@@ -2,7 +2,7 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Form } from '@edx/paragon'; import { Form } from '@openedx/paragon';
const SelectGroup = ({ const SelectGroup = ({
id, id,

View File

@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { shallow } from 'enzyme'; import { shallow } from '@edx/react-unit-test-utils';
import SelectGroup from './SelectGroup'; import SelectGroup from './SelectGroup';
@@ -27,11 +27,11 @@ describe('SelectGroup', () => {
describe('snapshots', () => { describe('snapshots', () => {
test('basic snapshot', () => { test('basic snapshot', () => {
const el = shallow(<SelectGroup {...props} />); const el = shallow(<SelectGroup {...props} />);
expect(el).toMatchSnapshot(); expect(el.snapshot).toMatchSnapshot();
}); });
test('disabled', () => { test('disabled', () => {
const el = shallow(<SelectGroup {...props} disabled />); const el = shallow(<SelectGroup {...props} disabled />);
expect(el).toMatchSnapshot(); expect(el.snapshot).toMatchSnapshot();
}); });
}); });
}); });

View File

@@ -7,7 +7,7 @@ exports[`StudentGroupsFilter component render snapshot 1`] = `
label="Tracks" label="Tracks"
onChange={[MockFunction]} onChange={[MockFunction]}
options={ options={
Array [ [
<option <option
value="Track-All" value="Track-All"
> >
@@ -43,7 +43,7 @@ exports[`StudentGroupsFilter component render snapshot 1`] = `
label="Cohorts" label="Cohorts"
onChange={[MockFunction]} onChange={[MockFunction]}
options={ options={
Array [ [
<option <option
value="Cohort-All" value="Cohort-All"
> >

View File

@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { shallow } from 'enzyme'; import { shallow } from '@edx/react-unit-test-utils';
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import SelectGroup from '../SelectGroup'; import SelectGroup from '../SelectGroup';
@@ -48,14 +48,14 @@ describe('StudentGroupsFilter component', () => {
}); });
describe('render', () => { describe('render', () => {
test('snapshot', () => { test('snapshot', () => {
expect(el).toMatchSnapshot(); expect(el.snapshot).toMatchSnapshot();
}); });
test('track options', () => { test('track options', () => {
const { const {
options, options,
onChange, onChange,
value, value,
} = el.find(SelectGroup).at(0).props(); } = el.instance.findByType(SelectGroup)[0].props;
expect(value).toEqual(props.tracks.value); expect(value).toEqual(props.tracks.value);
expect(onChange).toEqual(props.tracks.handleChange); expect(onChange).toEqual(props.tracks.handleChange);
expect(options.length).toEqual(5); expect(options.length).toEqual(5);
@@ -70,7 +70,7 @@ describe('StudentGroupsFilter component', () => {
onChange, onChange,
disabled, disabled,
value, value,
} = el.find(SelectGroup).at(1).props(); } = el.instance.findByType(SelectGroup)[1].props;
expect(value).toEqual(props.cohorts.value); expect(value).toEqual(props.cohorts.value);
expect(disabled).toEqual(false); expect(disabled).toEqual(false);
expect(onChange).toEqual(props.cohorts.handleChange); expect(onChange).toEqual(props.cohorts.handleChange);

View File

@@ -16,7 +16,7 @@ exports[`GradebookFilters render snapshot 1`] = `
className="p-1" className="p-1"
iconAs="Icon" iconAs="Icon"
onClick={[MockFunction hook.closeMenu]} onClick={[MockFunction hook.closeMenu]}
src={[Function]} src="Close"
/> />
</div> </div>
<Collapsible <Collapsible

View File

@@ -3,7 +3,7 @@ import { actions, selectors, thunkActions } from 'data/redux/hooks';
export const useGradebookFiltersData = ({ updateQueryParams }) => { export const useGradebookFiltersData = ({ updateQueryParams }) => {
const includeCourseRoleMembers = selectors.filters.useIncludeCourseRoleMembers(); const includeCourseRoleMembers = selectors.filters.useIncludeCourseRoleMembers();
const updateIncludeCourseRoleMembers = actions.filters.useUpdateIncludeCourseRoleMembers(); const updateIncludeCourseRoleMembers = actions.filters.useUpdateIncludeCourseRoleMembers();
const closeMenu = thunkActions.app.useCloseFilterMenu(); const closeMenu = thunkActions.app.filterMenu.useCloseMenu();
const fetchGrades = thunkActions.grades.useFetchGrades(); const fetchGrades = thunkActions.grades.useFetchGrades();
const handleIncludeTeamMembersChange = ({ target: { checked } }) => { const handleIncludeTeamMembersChange = ({ target: { checked } }) => {

View File

@@ -9,7 +9,9 @@ jest.mock('data/redux/hooks', () => ({
filters: { useIncludeCourseRoleMembers: jest.fn() }, filters: { useIncludeCourseRoleMembers: jest.fn() },
}, },
thunkActions: { thunkActions: {
app: { useCloseFilterMenu: jest.fn() }, app: {
filterMenu: { useCloseMenu: jest.fn() },
},
grades: { useFetchGrades: jest.fn() }, grades: { useFetchGrades: jest.fn() },
}, },
})); }));
@@ -18,7 +20,7 @@ selectors.filters.useIncludeCourseRoleMembers.mockReturnValue(true);
const updateIncludeCourseRoleMembers = jest.fn(); const updateIncludeCourseRoleMembers = jest.fn();
actions.filters.useUpdateIncludeCourseRoleMembers.mockReturnValue(updateIncludeCourseRoleMembers); actions.filters.useUpdateIncludeCourseRoleMembers.mockReturnValue(updateIncludeCourseRoleMembers);
const closeFilterMenu = jest.fn(); const closeFilterMenu = jest.fn();
thunkActions.app.useCloseFilterMenu.mockReturnValue(closeFilterMenu); thunkActions.app.filterMenu.useCloseMenu.mockReturnValue(closeFilterMenu);
const fetchGrades = jest.fn(); const fetchGrades = jest.fn();
thunkActions.grades.useFetchGrades.mockReturnValue(fetchGrades); thunkActions.grades.useFetchGrades.mockReturnValue(fetchGrades);
@@ -34,7 +36,7 @@ describe('GradebookFiltersData component hooks', () => {
it('initializes hooks', () => { it('initializes hooks', () => {
expect(actions.filters.useUpdateIncludeCourseRoleMembers).toHaveBeenCalledWith(); expect(actions.filters.useUpdateIncludeCourseRoleMembers).toHaveBeenCalledWith();
expect(selectors.filters.useIncludeCourseRoleMembers).toHaveBeenCalledWith(); expect(selectors.filters.useIncludeCourseRoleMembers).toHaveBeenCalledWith();
expect(thunkActions.app.useCloseFilterMenu).toHaveBeenCalledWith(); expect(thunkActions.app.filterMenu.useCloseMenu).toHaveBeenCalledWith();
expect(thunkActions.grades.useFetchGrades).toHaveBeenCalledWith(); expect(thunkActions.grades.useFetchGrades).toHaveBeenCalledWith();
}); });
}); });

View File

@@ -6,8 +6,8 @@ import {
Icon, Icon,
IconButton, IconButton,
Form, Form,
} from '@edx/paragon'; } from '@openedx/paragon';
import { Close } from '@edx/paragon/icons'; import { Close } from '@openedx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import messages from './messages'; import messages from './messages';

View File

@@ -1,8 +1,8 @@
import React from 'react'; import React from 'react';
import { shallow } from 'enzyme'; import { shallow } from '@edx/react-unit-test-utils';
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { Collapsible } from '@edx/paragon'; import { Collapsible } from '@openedx/paragon';
import { formatMessage } from 'testUtils'; import { formatMessage } from 'testUtils';
@@ -49,10 +49,10 @@ describe('GradebookFilters', () => {
}); });
describe('render', () => { describe('render', () => {
test('snapshot', () => { test('snapshot', () => {
expect(el).toMatchSnapshot(); expect(el.snapshot).toMatchSnapshot();
}); });
test('Assignment filters', () => { test('Assignment filters', () => {
expect(el.find(Collapsible).at(0).children()).toMatchObject(shallow( expect(el.instance.findByType(Collapsible)[0].children[0]).toMatchObject(shallow(
<div> <div>
<AssignmentTypeFilter updateQueryParams={updateQueryParams} /> <AssignmentTypeFilter updateQueryParams={updateQueryParams} />
<AssignmentFilter updateQueryParams={updateQueryParams} /> <AssignmentFilter updateQueryParams={updateQueryParams} />
@@ -61,22 +61,22 @@ describe('GradebookFilters', () => {
)); ));
}); });
test('CourseGrade filters', () => { test('CourseGrade filters', () => {
expect(el.find(Collapsible).at(1).children()).toMatchObject(shallow( expect(el.instance.findByType(Collapsible)[1].children[0]).toMatchObject(shallow(
<CourseGradeFilter updateQueryParams={updateQueryParams} />, <CourseGradeFilter updateQueryParams={updateQueryParams} />,
)); ));
}); });
test('StudentGroups filters', () => { test('StudentGroups filters', () => {
expect(el.find(Collapsible).at(2).children()).toMatchObject(shallow( expect(el.instance.findByType(Collapsible)[2].children[0]).toMatchObject(shallow(
<StudentGroupsFilter updateQueryParams={updateQueryParams} />, <StudentGroupsFilter updateQueryParams={updateQueryParams} />,
)); ));
}); });
test('includeCourseTeamMembers', () => { test('includeCourseTeamMembers', () => {
const checkbox = el.find(Collapsible).at(3).children(); const checkbox = el.instance.findByType(Collapsible)[3].children[0];
expect(checkbox.props()).toEqual({ expect(checkbox.props).toEqual({
checked: true, checked: true,
onChange: hookProps.includeCourseTeamMembers.handleChange, onChange: hookProps.includeCourseTeamMembers.handleChange,
children: formatMessage(messages.includeCourseTeamMembers),
}); });
expect(checkbox.children[0].el).toEqual(formatMessage(messages.includeCourseTeamMembers));
}); });
}); });
}); });

View File

@@ -0,0 +1,139 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`GradebookHeader component render default view shapshot 1`] = `
<div
className="gradebook-header"
>
<a
className="mb-3"
href="test-dashboard-url"
>
<span
aria-hidden="true"
>
&lt;&lt;
</span>
Back to Dashboard
</a>
<h1>
Gradebook
</h1>
<div
className="subtitle-row d-flex justify-content-between align-items-center"
>
<h2
className="text-break"
>
test-course-id
</h2>
</div>
</div>
`;
exports[`GradebookHeader component render frozen grades snapshot: show frozen warning 1`] = `
<div
className="gradebook-header"
>
<a
className="mb-3"
href="test-dashboard-url"
>
<span
aria-hidden="true"
>
&lt;&lt;
</span>
Back to Dashboard
</a>
<h1>
Gradebook
</h1>
<div
className="subtitle-row d-flex justify-content-between align-items-center"
>
<h2
className="text-break"
>
test-course-id
</h2>
</div>
<div
className="alert alert-warning"
role="alert"
>
The grades for this course are now frozen. Editing of grades is no longer allowed.
</div>
</div>
`;
exports[`GradebookHeader component render show bulk management snapshot: show toggle view message button with handleToggleViewClick method 1`] = `
<div
className="gradebook-header"
>
<a
className="mb-3"
href="test-dashboard-url"
>
<span
aria-hidden="true"
>
&lt;&lt;
</span>
Back to Dashboard
</a>
<h1>
Gradebook
</h1>
<div
className="subtitle-row d-flex justify-content-between align-items-center"
>
<h2
className="text-break"
>
test-course-id
</h2>
<Button
onClick={[MockFunction hooks.handleToggleViewClick]}
variant="tertiary"
>
toggle-view-message
</Button>
</div>
</div>
`;
exports[`GradebookHeader component render user cannot view gradebook snapshot: show unauthorized warning 1`] = `
<div
className="gradebook-header"
>
<a
className="mb-3"
href="test-dashboard-url"
>
<span
aria-hidden="true"
>
&lt;&lt;
</span>
Back to Dashboard
</a>
<h1>
Gradebook
</h1>
<div
className="subtitle-row d-flex justify-content-between align-items-center"
>
<h2
className="text-break"
>
test-course-id
</h2>
</div>
<div
className="alert alert-warning"
role="alert"
>
You are not authorized to view the gradebook for this course.
</div>
</div>
`;

View File

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

View File

@@ -0,0 +1,35 @@
import { views } from 'data/constants/app';
import { actions, selectors } from 'data/redux/hooks';
import messages from './messages';
export const useGradebookHeaderData = () => {
const activeView = selectors.app.useActiveView();
const courseId = selectors.app.useCourseId();
const areGradesFrozen = selectors.assignmentTypes.useAreGradesFrozen();
const canUserViewGradebook = selectors.roles.useCanUserViewGradebook();
const showBulkManagement = selectors.root.useShowBulkManagement();
const setView = actions.app.useSetView();
const handleToggleViewClick = () => setView(
activeView === views.grades
? views.bulkManagementHistory
: views.grades,
);
const toggleViewMessage = activeView === views.grades
? messages.toActivityLog
: messages.toGradesView;
return {
areGradesFrozen,
canUserViewGradebook,
courseId,
showBulkManagement,
handleToggleViewClick,
toggleViewMessage,
};
};
export default useGradebookHeaderData;

View File

@@ -0,0 +1,90 @@
import { views } from 'data/constants/app';
import { actions, selectors } from 'data/redux/hooks';
import messages from './messages';
import useGradebookHeaderData from './hooks';
jest.mock('data/redux/hooks', () => ({
actions: {
app: {
useSetView: jest.fn(),
},
},
selectors: {
app: {
useActiveView: jest.fn(),
useCourseId: jest.fn(),
},
assignmentTypes: {
useAreGradesFrozen: jest.fn(),
},
roles: {
useCanUserViewGradebook: jest.fn(),
},
root: {
useShowBulkManagement: jest.fn(),
},
},
}));
const activeView = 'test-active-view';
selectors.app.useActiveView.mockReturnValue(activeView);
const courseId = 'test-course-id';
selectors.app.useCourseId.mockReturnValue(courseId);
const areGradesFrozen = 'test-are-grades-frozen';
selectors.assignmentTypes.useAreGradesFrozen.mockReturnValue(areGradesFrozen);
const canUserViewGradebook = 'test-can-user-view-gradebook';
selectors.roles.useCanUserViewGradebook.mockReturnValue(canUserViewGradebook);
const showBulkManagement = 'test-show-bulk-management';
selectors.root.useShowBulkManagement.mockReturnValue(showBulkManagement);
const setView = jest.fn();
actions.app.useSetView.mockReturnValue(setView);
let out;
describe('useGradebookHeaderData hooks', () => {
describe('initialization', () => {
it('initializes redux hooks', () => {
out = useGradebookHeaderData();
expect(selectors.app.useActiveView).toHaveBeenCalled();
expect(selectors.app.useCourseId).toHaveBeenCalled();
expect(selectors.assignmentTypes.useAreGradesFrozen).toHaveBeenCalled();
expect(selectors.roles.useCanUserViewGradebook).toHaveBeenCalled();
expect(selectors.root.useShowBulkManagement).toHaveBeenCalled();
expect(actions.app.useSetView).toHaveBeenCalled();
});
});
describe('output', () => {
test('redux fields', () => {
out = useGradebookHeaderData();
expect(out.areGradesFrozen).toEqual(areGradesFrozen);
expect(out.canUserViewGradebook).toEqual(canUserViewGradebook);
expect(out.courseId).toEqual(courseId);
expect(out.showBulkManagement).toEqual(showBulkManagement);
});
describe('handleToggleViewClick', () => {
it('calls setView with bulkManagemnetHistory message if grades view is active', () => {
selectors.app.useActiveView.mockReturnValueOnce(views.grades);
out = useGradebookHeaderData();
out.handleToggleViewClick();
expect(setView).toHaveBeenCalledWith(views.bulkManagementHistory);
});
it('calls setView with grades view if grades view is not active', () => {
out = useGradebookHeaderData();
out.handleToggleViewClick();
expect(setView).toHaveBeenCalledWith(views.grades);
});
});
describe('toggleViewMessage', () => {
it('returns toActivityLog message if grades view is active', () => {
selectors.app.useActiveView.mockReturnValueOnce(views.grades);
out = useGradebookHeaderData();
expect(out.toggleViewMessage).toEqual(messages.toActivityLog);
});
it('returns toGradesView message if grades view is not active', () => {
out = useGradebookHeaderData();
expect(out.toggleViewMessage).toEqual(messages.toGradesView);
});
});
});
});

View File

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

View File

@@ -0,0 +1,77 @@
import React from 'react';
import { shallow } from '@edx/react-unit-test-utils';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Button } from '@openedx/paragon';
import { formatMessage } from 'testUtils';
import { instructorDashboardUrl } from 'data/services/lms/urls';
import useGradebookHeaderData from './hooks';
import GradebookHeader from '.';
jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() }));
jest.mock('data/services/lms/urls', () => ({
instructorDashboardUrl: jest.fn(),
}));
instructorDashboardUrl.mockReturnValue('test-dashboard-url');
const hookProps = {
areGradesFrozen: false,
canUserViewGradebook: true,
courseId: 'test-course-id',
handleToggleViewClick: jest.fn().mockName('hooks.handleToggleViewClick'),
showBulkManagement: false,
toggleViewMessage: { defaultMessage: 'toggle-view-message' },
};
useGradebookHeaderData.mockReturnValue(hookProps);
let el;
describe('GradebookHeader component', () => {
beforeAll(() => {
el = shallow(<GradebookHeader />);
});
describe('behavior', () => {
it('initializes hooks', () => {
expect(useGradebookHeaderData).toHaveBeenCalledWith();
expect(useIntl).toHaveBeenCalledWith();
});
});
describe('render', () => {
describe('default view', () => {
test('shapshot', () => {
expect(el.snapshot).toMatchSnapshot();
});
});
describe('show bulk management', () => {
beforeEach(() => {
useGradebookHeaderData.mockReturnValueOnce({ ...hookProps, showBulkManagement: true });
el = shallow(<GradebookHeader />);
});
test('snapshot: show toggle view message button with handleToggleViewClick method', () => {
expect(el.snapshot).toMatchSnapshot();
const { onClick } = el.instance.findByType(Button)[0].props;
expect(onClick).toEqual(hookProps.handleToggleViewClick);
expect(el.instance.findByType(Button)[0].children[0].el).toEqual(formatMessage(hookProps.toggleViewMessage));
});
});
describe('frozen grades', () => {
beforeEach(() => {
useGradebookHeaderData.mockReturnValueOnce({ ...hookProps, areGradesFrozen: true });
el = shallow(<GradebookHeader />);
});
test('snapshot: show frozen warning', () => {
expect(el.snapshot).toMatchSnapshot();
});
});
describe('user cannot view gradebook', () => {
beforeEach(() => {
useGradebookHeaderData.mockReturnValueOnce({ ...hookProps, canUserViewGradebook: false });
el = shallow(<GradebookHeader />);
});
test('snapshot: show unauthorized warning', () => {
expect(el.snapshot).toMatchSnapshot();
});
});
});
});

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,19 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`BulkManagementControls render snapshot - show - network and import buttons 1`] = `
<div
className="d-flex"
>
<NetworkButton
label={
{
"defaultMessage": "Download Grades",
"description": "A labeled button that allows an admin user to download course grades all at once (in bulk).",
"id": "gradebook.GradesView.BulkManagementControls.bulkManagementLabel",
}
}
onClick={[MockFunction]}
/>
<ImportGradesButton />
</div>
`;

View File

@@ -0,0 +1,18 @@
import { actions, selectors } from 'data/redux/hooks';
export const useBulkManagementControlsData = () => {
const gradeExportUrl = selectors.root.useGradeExportUrl();
const showBulkManagement = selectors.root.useShowBulkManagement();
const downloadBulkGradesReport = actions.grades.useDownloadBulkGradesReport();
const handleClickExportGrades = () => {
downloadBulkGradesReport();
window.location.assign(gradeExportUrl);
};
return {
show: showBulkManagement,
handleClickExportGrades,
};
};
export default useBulkManagementControlsData;

View File

@@ -0,0 +1,72 @@
import { actions, selectors } from 'data/redux/hooks';
import useBulkManagementControlsData from './hooks';
jest.mock('data/redux/hooks', () => ({
actions: {
grades: {
useDownloadBulkGradesReport: jest.fn(),
},
},
selectors: {
root: {
useGradeExportUrl: jest.fn(),
useShowBulkManagement: jest.fn(),
},
},
}));
const downloadBulkGrades = jest.fn();
actions.grades.useDownloadBulkGradesReport.mockReturnValue(downloadBulkGrades);
const gradeExportUrl = 'test-grade-export-url';
selectors.root.useGradeExportUrl.mockReturnValue(gradeExportUrl);
selectors.root.useShowBulkManagement.mockReturnValue(true);
let hook;
describe('useBulkManagementControlsData', () => {
const oldWindowLocation = window.location;
beforeAll(() => {
delete window.location;
window.location = Object.defineProperties(
{},
{
...Object.getOwnPropertyDescriptors(oldWindowLocation),
assign: { configurable: true, value: jest.fn() },
},
);
});
beforeEach(() => {
window.location.assign.mockReset();
hook = useBulkManagementControlsData();
});
afterAll(() => {
// restore `window.location` to the `jsdom` `Location` object
window.location = oldWindowLocation;
});
describe('initialization', () => {
it('initializes redux hooks', () => {
expect(selectors.root.useGradeExportUrl).toHaveBeenCalledWith();
expect(selectors.root.useShowBulkManagement).toHaveBeenCalledWith();
expect(actions.grades.useDownloadBulkGradesReport).toHaveBeenCalledWith();
});
});
describe('output', () => {
it('forwards show from showBulkManagement', () => {
expect(hook.show).toEqual(true);
selectors.root.useShowBulkManagement.mockReturnValue(false);
hook = useBulkManagementControlsData();
expect(hook.show).toEqual(false);
});
describe('handleClickExportGrades', () => {
beforeEach(() => {
hook.handleClickExportGrades();
});
it('downloads bulk grades report', () => {
expect(downloadBulkGrades).toHaveBeenCalledWith();
});
it('sets window location to grade export url', () => {
expect(window.location.assign).toHaveBeenCalledWith(gradeExportUrl);
});
});
});
});

View File

@@ -0,0 +1,33 @@
/* eslint-disable react/sort-comp, react/button-has-type */
import React from 'react';
import NetworkButton from 'components/NetworkButton';
import ImportGradesButton from '../ImportGradesButton';
import useBulkManagementControlsData from './hooks';
import messages from './messages';
/**
* <BulkManagementControls />
* Provides download buttons for Bulk Management and Intervention reports, only if
* showBulkManagement is set in redus.
*/
export const BulkManagementControls = () => {
const {
show,
handleClickExportGrades,
} = useBulkManagementControlsData();
if (!show) { return null; }
return (
<div className="d-flex">
<NetworkButton
label={messages.downloadGradesBtn}
onClick={handleClickExportGrades}
/>
<ImportGradesButton />
</div>
);
};
export default BulkManagementControls;

View File

@@ -0,0 +1,32 @@
import React from 'react';
import { shallow } from '@edx/react-unit-test-utils';
import useBulkManagementControlsData from './hooks';
import BulkManagementControls from '.';
jest.mock('../ImportGradesButton', () => 'ImportGradesButton');
jest.mock('components/NetworkButton', () => 'NetworkButton');
jest.mock('./hooks', () => jest.fn());
const hookProps = {
show: true,
handleClickExportGrades: jest.fn(),
};
useBulkManagementControlsData.mockReturnValue(hookProps);
describe('BulkManagementControls', () => {
describe('behavior', () => {
shallow(<BulkManagementControls />);
expect(useBulkManagementControlsData).toHaveBeenCalledWith();
});
describe('render', () => {
test('snapshot - show - network and import buttons', () => {
expect(shallow(<BulkManagementControls />).snapshot).toMatchSnapshot();
});
test('snapshot - empty if show is not truthy', () => {
useBulkManagementControlsData.mockReturnValueOnce({ ...hookProps, show: false });
expect(shallow(<BulkManagementControls />).isEmptyRender()).toEqual(true);
});
});
});

View File

@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { shallow } from 'enzyme'; import { shallow } from '@edx/react-unit-test-utils';
import HistoryHeader from './HistoryHeader'; import HistoryHeader from './HistoryHeader';
@@ -11,7 +11,7 @@ describe('HistoryHeader', () => {
}; };
describe('Component', () => { describe('Component', () => {
test('snapshot', () => { test('snapshot', () => {
expect(shallow(<HistoryHeader {...props} />)).toMatchSnapshot(); expect(shallow(<HistoryHeader {...props} />).snapshot).toMatchSnapshot();
}); });
}); });
}); });

View File

@@ -1,68 +1,53 @@
import React from 'react'; 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 selectors from 'data/selectors'; import { StrictDict } from 'utils';
import { selectors } from 'data/redux/hooks';
import messages from './messages'; import messages from './messages';
import HistoryHeader from './HistoryHeader'; import HistoryHeader from './HistoryHeader';
export const HistoryKeys = StrictDict({
assignment: 'assignment',
student: 'student',
originalGrade: 'original-grade',
currentGrade: 'current-grade',
});
/** /**
* <ModalHeaders /> * <ModalHeaders />
* Provides a list of HistoryHeaders for the student name, assignment, * Provides a list of HistoryHeaders for the student name, assignment,
* original grade, and current override grade. * original grade, and current override grade.
*/ */
export const ModalHeaders = ({ export const ModalHeaders = () => {
modalState, const { assignmentName, updateUserName } = selectors.app.useModalData();
originalGrade, const { gradeOverrideCurrentEarnedGradedOverride, gradeOriginalEarnedGraded } = selectors.grades.useGradeData();
currentGrade, const { formatMessage } = useIntl();
}) => ( return (
<div> <div>
<HistoryHeader <HistoryHeader
id="assignment" id={HistoryKeys.assignment}
label={<FormattedMessage {...messages.assignmentHeader} />} label={formatMessage(messages.assignmentHeader)}
value={modalState.assignmentName} value={assignmentName}
/> />
<HistoryHeader <HistoryHeader
id="student" id={HistoryKeys.student}
label={<FormattedMessage {...messages.studentHeader} />} label={formatMessage(messages.studentHeader)}
value={modalState.updateUserName} value={updateUserName}
/> />
<HistoryHeader <HistoryHeader
id="original-grade" id={HistoryKeys.originalGrade}
label={<FormattedMessage {...messages.originalGradeHeader} />} label={formatMessage(messages.originalGradeHeader)}
value={originalGrade} value={gradeOriginalEarnedGraded}
/> />
<HistoryHeader <HistoryHeader
id="current-grade" id={HistoryKeys.currentGrade}
label={<FormattedMessage {...messages.currentGradeHeader} />} label={formatMessage(messages.currentGradeHeader)}
value={currentGrade} value={gradeOverrideCurrentEarnedGradedOverride}
/> />
</div> </div>
); );
ModalHeaders.defaultProps = {
currentGrade: null,
originalGrade: null,
};
ModalHeaders.propTypes = {
// redux
currentGrade: PropTypes.number,
originalGrade: PropTypes.number,
modalState: PropTypes.shape({
assignmentName: PropTypes.string.isRequired,
updateUserName: PropTypes.string,
}).isRequired,
}; };
export const mapStateToProps = (state) => ({ export default ModalHeaders;
modalState: {
assignmentName: selectors.app.modalState.assignmentName(state),
updateUserName: selectors.app.modalState.updateUserName(state),
},
currentGrade: selectors.grades.gradeOverrideCurrentEarnedGradedOverride(state),
originalGrade: selectors.grades.gradeOriginalEarnedGraded(state),
});
export default connect(mapStateToProps)(ModalHeaders);

View File

@@ -1,93 +1,84 @@
import React from 'react'; import React from 'react';
import { shallow } from 'enzyme'; import { shallow } from '@edx/react-unit-test-utils';
import selectors from 'data/selectors'; import { useIntl } from '@edx/frontend-platform/i18n';
import { selectors } from 'data/redux/hooks';
import { import { formatMessage } from 'testUtils';
ModalHeaders,
mapStateToProps, import HistoryHeader from './HistoryHeader';
} from './ModalHeaders'; import ModalHeaders, { HistoryKeys } from './ModalHeaders';
import messages from './messages';
jest.mock('./HistoryHeader', () => 'HistoryHeader'); jest.mock('./HistoryHeader', () => 'HistoryHeader');
jest.mock('data/selectors', () => ({ jest.mock('data/redux/hooks', () => ({
__esModule: true, selectors: {
default: { app: { useModalData: jest.fn() },
app: { grades: { useGradeData: jest.fn() },
editUpdateData: jest.fn(state => ({ editUpdateData: state })),
modalState: {
assignmentName: jest.fn(state => ({ assignmentName: state })),
updateUserName: jest.fn(state => ({ updateUserName: state })),
},
},
grades: {
gradeOverrideCurrentEarnedGradedOverride: jest.fn(state => ({ currentGrade: state })),
gradeOriginalEarnedGraded: jest.fn(state => ({ originalGrade: state })),
},
}, },
})); }));
describe('ModalHeaders', () => {
let el;
const props = {
currentGrade: 2,
originalGrade: 20,
modalState: {
assignmentName: 'Qwerty',
updateUserName: 'Uiop',
},
};
describe('Component', () => { const modalData = {
describe('snapshots', () => { assignmentName: 'test-assignment-name',
beforeEach(() => { updateUserName: 'test-user-name',
}); };
describe('gradeOverrideHistoryError is and empty and open is true', () => { selectors.app.useModalData.mockReturnValue(modalData);
test('modal open and StatusAlert showing', () => { const gradeData = {
el = shallow(<ModalHeaders {...props} />); gradeOverrideCurrentEarnedGradedOverride: 'test-current-grade',
expect(el).toMatchSnapshot(); gradeOriginalEarnedGraded: 'test-original-grade',
}); };
}); selectors.grades.useGradeData.mockReturnValue(gradeData);
describe('gradeOverrideHistoryError is empty and open is false', () => {
test('modal closed and StatusAlert closed', () => { let el;
el = shallow( describe('ModalHeaders', () => {
<ModalHeaders {...props} open={false} gradeOverrideHistoryError="" />, beforeEach(() => {
); jest.clearAllMocks();
expect(el).toMatchSnapshot(); el = shallow(<ModalHeaders />);
}); });
}); describe('behavior', () => {
it('initializes intl', () => {
expect(useIntl).toHaveBeenCalled();
});
it('initializes redux hooks', () => {
expect(selectors.app.useModalData).toHaveBeenCalled();
expect(selectors.grades.useGradeData).toHaveBeenCalled();
}); });
}); });
describe('render', () => {
describe('mapStateToProps', () => { test('snapshot', () => {
const testState = { he: 'lives in a', pineapple: 'under the sea' }; expect(el.snapshot).toMatchSnapshot();
let mapped;
beforeEach(() => {
mapped = mapStateToProps(testState);
}); });
describe('modalState', () => { test('assignment header', () => {
test('assignmentName from app.modalState.assignmentName', () => { const headerProps = el.instance.findByType(HistoryHeader)[0].props;
expect( expect(headerProps).toMatchObject({
mapped.modalState.assignmentName, id: HistoryKeys.assignment,
).toEqual(selectors.app.modalState.assignmentName(testState)); label: formatMessage(messages.assignmentHeader),
}); value: modalData.assignmentName,
test('updateUserName from app.modalState.updateUserName', () => {
expect(
mapped.modalState.updateUserName,
).toEqual(selectors.app.modalState.updateUserName(testState));
}); });
}); });
describe('originalGrade', () => { test('student header', () => {
test('from grades.gradeOverrideCurrentEarnedGradedOverride', () => { const headerProps = el.instance.findByType(HistoryHeader)[1].props;
expect(mapped.currentGrade).toEqual( expect(headerProps).toMatchObject({
selectors.grades.gradeOverrideCurrentEarnedGradedOverride(testState), id: HistoryKeys.student,
); label: formatMessage(messages.studentHeader),
value: modalData.updateUserName,
}); });
}); });
describe('originalGrade', () => { test('originalGrade header', () => {
test('from grades.gradeOriginalEarnedGrades', () => { const headerProps = el.instance.findByType(HistoryHeader)[2].props;
expect(mapped.originalGrade).toEqual( expect(headerProps).toMatchObject({
selectors.grades.gradeOriginalEarnedGraded(testState), id: HistoryKeys.originalGrade,
); label: formatMessage(messages.originalGradeHeader),
value: gradeData.gradeOriginalEarnedGraded,
});
});
test('currentGrade header', () => {
const headerProps = el.instance.findByType(HistoryHeader)[3].props;
expect(headerProps).toMatchObject({
id: HistoryKeys.currentGrade,
label: formatMessage(messages.currentGradeHeader),
value: gradeData.gradeOverrideCurrentEarnedGradedOverride,
}); });
}); });
}); });

View File

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

View File

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

View File

@@ -0,0 +1,13 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AdjustedGradeInput component render snapshot 1`] = `
<span>
<Form.Control
name="adjustedGradeValue"
onChange={[MockFunction hook.onChange]}
type="text"
value="test-value"
/>
some-hint-text
</span>
`;

View File

@@ -0,0 +1,21 @@
import { actions, selectors } from 'data/redux/hooks';
import { getLocalizedSlash } from 'i18n/utils';
const useAdjustedGradeInputData = () => {
const possibleGrade = selectors.root.useEditModalPossibleGrade();
const value = selectors.app.useModalData().adjustedGradeValue;
const setModalState = actions.app.useSetModalState();
const hintText = possibleGrade && ` ${getLocalizedSlash()} ${possibleGrade}`;
const onChange = ({ target }) => {
setModalState({ adjustedGradeValue: target.value });
};
return {
value,
onChange,
hintText,
};
};
export default useAdjustedGradeInputData;

View File

@@ -0,0 +1,67 @@
import { getLocalizedSlash } from 'i18n/utils';
import { actions, selectors } from 'data/redux/hooks';
import useAdjustedGradeInputData from './hooks';
jest.mock('data/redux/hooks', () => ({
selectors: {
root: {
useEditModalPossibleGrade: jest.fn(),
},
app: {
useModalData: jest.fn(),
},
},
actions: {
app: {
useSetModalState: jest.fn(),
},
},
}));
jest.mock('i18n/utils', () => ({ getLocalizedSlash: jest.fn() }));
const localizedSlash = 'localized-slash';
getLocalizedSlash.mockReturnValue(localizedSlash);
const possibleGrade = 105;
selectors.root.useEditModalPossibleGrade.mockReturnValue(possibleGrade);
const modalData = { adjustedGradeValue: 70 };
const setModalState = jest.fn();
selectors.app.useModalData.mockReturnValue(modalData);
actions.app.useSetModalState.mockReturnValue(setModalState);
let out;
describe('useAdjustedGradeInputData hook', () => {
beforeEach(() => {
jest.clearAllMocks();
out = useAdjustedGradeInputData();
});
describe('behavior', () => {
it('initializes redux hooks', () => {
expect(selectors.root.useEditModalPossibleGrade).toHaveBeenCalled();
expect(selectors.app.useModalData).toHaveBeenCalled();
expect(actions.app.useSetModalState).toHaveBeenCalled();
});
});
describe('output', () => {
it('forwards adjusted grade value as value from modal data', () => {
expect(out.value).toEqual(modalData.adjustedGradeValue);
});
describe('hintText', () => {
it('passes an undefined value if possibleGrade is not available', () => {
selectors.root.useEditModalPossibleGrade.mockReturnValueOnce(undefined);
out = useAdjustedGradeInputData();
expect(out.hintText).toEqual(undefined);
});
it('passes localized slash and possible grade if available', () => {
expect(out.hintText).toEqual(` ${localizedSlash} ${possibleGrade}`);
});
});
describe('onChange', () => {
it('sets modal state with event target value', () => {
const testValue = 'test-value';
out.onChange({ target: { value: testValue } });
expect(setModalState).toHaveBeenCalledWith({ adjustedGradeValue: testValue });
});
});
});
});

View File

@@ -0,0 +1,33 @@
import React from 'react';
import { Form } from '@openedx/paragon';
import useAdjustedGradeInputData from './hooks';
/**
* <AdjustedGradeInput />
* Input control for adjusting the grade of a unit
* displays an "/ ${possibleGrade} if there is one in the data model.
*/
export const AdjustedGradeInput = () => {
const {
value,
onChange,
hintText,
} = useAdjustedGradeInputData();
return (
<span>
<Form.Control
type="text"
name="adjustedGradeValue"
value={value}
onChange={onChange}
/>
{hintText}
</span>
);
};
AdjustedGradeInput.propTypes = {};
export default AdjustedGradeInput;

View File

@@ -0,0 +1,38 @@
import React from 'react';
import { shallow } from '@edx/react-unit-test-utils';
import { Form } from '@openedx/paragon';
import useAdjustedGradeInputData from './hooks';
import AdjustedGradeInput from '.';
jest.mock('./hooks', () => jest.fn());
const hookProps = {
hintText: 'some-hint-text',
onChange: jest.fn().mockName('hook.onChange'),
value: 'test-value',
};
useAdjustedGradeInputData.mockReturnValue(hookProps);
let el;
describe('AdjustedGradeInput component', () => {
beforeEach(() => {
jest.clearAllMocks();
el = shallow(<AdjustedGradeInput />);
});
describe('behavior', () => {
it('initializes hook data', () => {
expect(useAdjustedGradeInputData).toHaveBeenCalled();
});
});
describe('render', () => {
test('snapshot', () => {
expect(el.snapshot).toMatchSnapshot();
const control = el.instance.findByType(Form.Control)[0];
expect(control.props.value).toEqual(hookProps.value);
expect(control.props.onChange).toEqual(hookProps.onChange);
expect(el.instance.children[1].el).toContain(hookProps.hintText);
});
});
});

View File

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

View File

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

View File

@@ -0,0 +1,11 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ReasonInput component render snapshot 1`] = `
<Form.Control
data-testid="reason-input-control"
name="reasonForChange"
onChange={[MockFunction hook.onChange]}
type="text"
value="test-value"
/>
`;

View File

@@ -0,0 +1,25 @@
import React from 'react';
import { actions, selectors } from 'data/redux/hooks';
const useReasonInputData = () => {
const ref = React.useRef();
const { reasonForChange } = selectors.app.useModalData();
const setModalState = actions.app.useSetModalState();
React.useEffect(() => {
ref.current.focus();
}, [ref]);
const onChange = (event) => {
setModalState({ reasonForChange: event.target.value });
};
return {
value: reasonForChange,
onChange,
ref,
};
};
export default useReasonInputData;

View File

@@ -0,0 +1,63 @@
import React from 'react';
import { actions, selectors } from 'data/redux/hooks';
import useReasonInputData from './hooks';
jest.mock('data/redux/hooks', () => ({
selectors: {
app: {
useModalData: jest.fn(),
},
},
actions: {
app: {
useSetModalState: jest.fn(),
},
},
}));
const modalData = { reasonForChange: 'test-reason-for-change' };
const setModalState = jest.fn();
selectors.app.useModalData.mockReturnValue(modalData);
actions.app.useSetModalState.mockReturnValue(setModalState);
const ref = { current: { focus: jest.fn() }, useRef: true };
React.useRef.mockReturnValue(ref);
let out;
describe('useReasonInputData hook', () => {
beforeEach(() => {
jest.clearAllMocks();
out = useReasonInputData();
});
describe('behavior', () => {
it('initializes ref', () => {
expect(React.useRef).toHaveBeenCalled();
});
it('initializes redux hooks', () => {
expect(selectors.app.useModalData).toHaveBeenCalled();
expect(actions.app.useSetModalState).toHaveBeenCalled();
});
it('focuses ref on load', () => {
const [[cb, prereqs]] = React.useEffect.mock.calls;
expect(prereqs).toEqual([ref]);
cb();
expect(ref.current.focus).toHaveBeenCalled();
});
});
describe('output', () => {
it('forwards reasonForChange as value from modal data', () => {
expect(out.value).toEqual(modalData.reasonForChange);
});
it('forwards ref', () => {
expect(out.ref).toEqual(ref);
});
describe('onChange', () => {
it('sets modal state with event target value', () => {
const testValue = 'test-value';
out.onChange({ target: { value: testValue } });
expect(setModalState).toHaveBeenCalledWith({ reasonForChange: testValue });
});
});
});
});

View File

@@ -0,0 +1,27 @@
import React from 'react';
import { Form } from '@openedx/paragon';
import useReasonInputData from './hooks';
export const controlTestId = 'reason-input-control';
/**
* <ReasonInput />
* Input control for the "reason for change" field in the Edit modal.
*/
export const ReasonInput = () => {
const { ref, value, onChange } = useReasonInputData();
return (
<Form.Control
type="text"
name="reasonForChange"
data-testid={controlTestId}
{...{ value, onChange, ref }}
/>
);
};
ReasonInput.propTypes = {};
export default ReasonInput;

View File

@@ -0,0 +1,37 @@
import React from 'react';
import { shallow } from '@edx/react-unit-test-utils';
import { Form } from '@openedx/paragon';
import useReasonInputData from './hooks';
import ReasonInput from '.';
jest.mock('./hooks', () => jest.fn());
const hookProps = {
ref: 'reason-input-ref',
onChange: jest.fn().mockName('hook.onChange'),
value: 'test-value',
};
useReasonInputData.mockReturnValue(hookProps);
let el;
describe('ReasonInput component', () => {
beforeEach(() => {
jest.clearAllMocks();
el = shallow(<ReasonInput />);
});
describe('behavior', () => {
it('initializes hook data', () => {
expect(useReasonInputData).toHaveBeenCalled();
});
});
describe('render', () => {
test('snapshot', () => {
expect(el.snapshot).toMatchSnapshot();
const control = el.instance.findByType(Form.Control)[0];
expect(control.props.value).toEqual(hookProps.value);
expect(control.props.onChange).toEqual(hookProps.onChange);
});
});
});

View File

@@ -0,0 +1,26 @@
import React from 'react';
import { render } from '@testing-library/react';
import useReasonInputData from './hooks';
import ReasonInput, { controlTestId } from '.';
jest.unmock('react');
jest.unmock('@openedx/paragon');
jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() }));
const focus = jest.fn();
const props = {
value: 'test-value',
onChange: jest.fn(),
ref: { current: { focus }, useRef: jest.fn() },
};
useReasonInputData.mockReturnValue(props);
let el;
describe('ReasonInput ref', () => {
it('loads ref from hook', () => {
el = render(<ReasonInput />);
const control = el.getByTestId(controlTestId);
expect(control).toEqual(props.ref.current);
});
});

View File

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

View File

@@ -1,10 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ReasonInput Component snapshots displays reason for change input control 1`] = `
<Control
name="reasonForChange"
onChange={[MockFunction this.onChange]}
type="text"
value="did not answer the question"
/>
`;

View File

@@ -0,0 +1,25 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`OverrideTable component render snapshot 1`] = `
<DataTable
columns="test-columns"
data={
[
{
"test": "data",
},
{
"andOther": "test-data",
},
{
"adjustedGrade": <AdjustedGradeInput />,
"date": {
"formatted": 2000-01-01T00:00:00.000Z,
},
"reason": <ReasonInput />,
},
]
}
itemCount={3}
/>
`;

View File

@@ -1,64 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`OverrideTable Component snapshots basic snapshot shows a row for each entry and one editable row 1`] = `
<DataTable
columns={
Array [
Object {
"Header": <FormattedMessage
defaultMessage="Date"
description="Edit Modal Override Table Date column header"
id="gradebook.GradesView.EditModal.Overrides.dateHeader"
/>,
"accessor": "date",
},
Object {
"Header": <FormattedMessage
defaultMessage="Grader"
description="Edit Modal Override Table Grader column header"
id="gradebook.GradesView.EditModal.Overrides.graderHeader"
/>,
"accessor": "grader",
},
Object {
"Header": <FormattedMessage
defaultMessage="Reason"
description="Edit Modal Override Table Reason column header"
id="gradebook.GradesView.EditModal.Overrides.reasonHeader"
/>,
"accessor": "reason",
},
Object {
"Header": <FormattedMessage
defaultMessage="Adjusted grade"
description="Edit Modal Override Table Adjusted grade column header"
id="gradebook.GradesView.EditModal.Overrides.adjustedGradeHeader"
/>,
"accessor": "adjustedGrade",
},
]
}
data={
Array [
Object {
"adjustedGrade": 0,
"date": "yesterday",
"grader": "me",
"reason": "you ate my sandwich",
},
Object {
"adjustedGrade": 20,
"date": "today",
"grader": "me",
"reason": "you brought me a new sandwich",
},
Object {
"adjustedGrade": <AdjustedGradeInput />,
"date": "todaaaaaay",
"reason": <ReasonInput />,
},
]
}
itemCount={2}
/>
`;

View File

@@ -0,0 +1,26 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { gradeOverrideHistoryColumns as columns } from 'data/constants/app';
import { selectors } from 'data/redux/hooks';
import messages from './messages';
const useOverrideTableData = () => {
const { formatMessage } = useIntl();
const hide = selectors.grades.useHasOverrideErrors();
const gradeOverrides = selectors.grades.useGradeData().gradeOverrideHistoryResults || [];
const tableProps = {};
if (!hide) {
tableProps.columns = [
{ Header: formatMessage(messages.dateHeader), accessor: columns.date },
{ Header: formatMessage(messages.graderHeader), accessor: columns.grader },
{ Header: formatMessage(messages.reasonHeader), accessor: columns.reason },
{ Header: formatMessage(messages.adjustedGradeHeader), accessor: columns.adjustedGrade },
];
tableProps.data = gradeOverrides;
}
return { hide, ...tableProps };
};
export default useOverrideTableData;

View File

@@ -0,0 +1,78 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { formatMessage } from 'testUtils';
import { gradeOverrideHistoryColumns as columns } from 'data/constants/app';
import { selectors } from 'data/redux/hooks';
import useOverrideTableData from './hooks';
import messages from './messages';
jest.mock('data/redux/hooks', () => ({
selectors: {
grades: {
useHasOverrideErrors: jest.fn(),
useGradeData: jest.fn(),
},
},
}));
selectors.grades.useHasOverrideErrors.mockReturnValue(false);
const gradeOverrides = ['some', 'override', 'data'];
const gradeData = { gradeOverrideHistoryResults: gradeOverrides };
selectors.grades.useGradeData.mockReturnValue(gradeData);
let out;
describe('useOverrideTableData', () => {
beforeEach(() => {
jest.clearAllMocks();
out = useOverrideTableData();
});
describe('behavior', () => {
it('initializes intl hook', () => {
expect(useIntl).toHaveBeenCalled();
});
it('initializes redux hooks', () => {
expect(selectors.grades.useHasOverrideErrors).toHaveBeenCalled();
expect(selectors.grades.useGradeData).toHaveBeenCalled();
});
});
describe('output', () => {
describe('no errors', () => {
test('hide is false', () => {
expect(out.hide).toEqual(false);
});
describe('columns', () => {
test('date column', () => {
const { Header, accessor } = out.columns[0];
expect(Header).toEqual(formatMessage(messages.dateHeader));
expect(accessor).toEqual(columns.date);
});
test('grader column', () => {
const { Header, accessor } = out.columns[1];
expect(Header).toEqual(formatMessage(messages.graderHeader));
expect(accessor).toEqual(columns.grader);
});
test('reason column', () => {
const { Header, accessor } = out.columns[2];
expect(Header).toEqual(formatMessage(messages.reasonHeader));
expect(accessor).toEqual(columns.reason);
});
test('adjustedGrade column', () => {
const { Header, accessor } = out.columns[3];
expect(Header).toEqual(formatMessage(messages.adjustedGradeHeader));
expect(accessor).toEqual(columns.adjustedGrade);
});
});
test('data passed from grade data', () => {
expect(out.data).toEqual(gradeOverrides);
});
});
describe('with errors', () => {
it('returns hide true and no other fields', () => {
selectors.grades.useHasOverrideErrors.mockReturnValue(true);
out = useOverrideTableData();
expect(out).toEqual({ hide: true });
});
});
});
});

View File

@@ -1,73 +1,42 @@
/* eslint-disable react/sort-comp, react/button-has-type, import/no-named-as-default */ /* eslint-disable react/sort-comp, react/button-has-type, import/no-named-as-default */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { DataTable } from '@edx/paragon'; import { DataTable } from '@openedx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { gradeOverrideHistoryColumns as columns } from 'data/constants/app'; import { formatDateForDisplay } from 'utils';
import selectors from 'data/selectors';
import messages from './messages';
import ReasonInput from './ReasonInput'; import ReasonInput from './ReasonInput';
import AdjustedGradeInput from './AdjustedGradeInput'; import AdjustedGradeInput from './AdjustedGradeInput';
import useOverrideTableData from './hooks';
/** /**
* <OverrideTable /> * <OverrideTable />
* Table containing previous grade override entries, and an "edit" row * Table containing previous grade override entries, and an "edit" row
* with todays date, an AdjustedGradeInput and a ReasonInput * with todays date, an AdjustedGradeInput and a ReasonInput
*/ */
export const OverrideTable = ({
hide, export const OverrideTable = () => {
gradeOverrides, const { hide, columns, data } = useOverrideTableData();
todaysDate,
}) => { if (hide) { return null; }
if (hide) {
return null; const tableData = [
} ...data,
{
adjustedGrade: <AdjustedGradeInput />,
date: formatDateForDisplay(new Date()),
reason: <ReasonInput />,
},
];
return ( return (
<DataTable <DataTable
columns={[ columns={columns}
{ Header: <FormattedMessage {...messages.dateHeader} />, accessor: columns.date }, data={tableData}
{ Header: <FormattedMessage {...messages.graderHeader} />, accessor: columns.grader }, itemCount={tableData.length}
{ Header: <FormattedMessage {...messages.reasonHeader} />, accessor: columns.reason },
{
Header: <FormattedMessage {...messages.adjustedGradeHeader} />,
accessor: columns.adjustedGrade,
},
]}
data={[
...gradeOverrides,
{
adjustedGrade: <AdjustedGradeInput />,
date: todaysDate,
reason: <ReasonInput />,
},
]}
itemCount={gradeOverrides.length}
/> />
); );
}; };
OverrideTable.defaultProps = { OverrideTable.propTypes = {};
gradeOverrides: [],
};
OverrideTable.propTypes = {
// redux
gradeOverrides: PropTypes.arrayOf(PropTypes.shape({
date: PropTypes.string,
grader: PropTypes.string,
reason: PropTypes.string,
adjustedGrade: PropTypes.number,
})),
hide: PropTypes.bool.isRequired,
todaysDate: PropTypes.string.isRequired,
};
export const mapStateToProps = (state) => ({ export default OverrideTable;
hide: selectors.grades.hasOverrideErrors(state),
gradeOverrides: selectors.grades.gradeOverrides(state),
todaysDate: selectors.app.modalState.todaysDate(state),
});
export default connect(mapStateToProps)(OverrideTable);

View File

@@ -0,0 +1,65 @@
import React from 'react';
import { shallow } from '@edx/react-unit-test-utils';
import { DataTable } from '@openedx/paragon';
import { formatDateForDisplay } from 'utils';
import AdjustedGradeInput from './AdjustedGradeInput';
import ReasonInput from './ReasonInput';
import useOverrideTableData from './hooks';
import OverrideTable from '.';
jest.mock('utils', () => ({
formatDateForDisplay: (date) => ({ formatted: date }),
}));
jest.mock('./hooks', () => jest.fn());
jest.mock('./AdjustedGradeInput', () => 'AdjustedGradeInput');
jest.mock('./ReasonInput', () => 'ReasonInput');
const hookProps = {
hide: false,
data: [
{ test: 'data' },
{ andOther: 'test-data' },
],
columns: 'test-columns',
};
useOverrideTableData.mockReturnValue(hookProps);
let el;
describe('OverrideTable component', () => {
beforeEach(() => {
jest
.clearAllMocks()
.useFakeTimers('modern')
.setSystemTime(new Date('2000-01-01').getTime());
el = shallow(<OverrideTable />);
});
describe('behavior', () => {
it('initializes hook data', () => {
expect(useOverrideTableData).toHaveBeenCalled();
});
});
describe('render', () => {
test('null render if hide', () => {
useOverrideTableData.mockReturnValueOnce({ ...hookProps, hide: true });
el = shallow(<OverrideTable />);
expect(el.isEmptyRender()).toEqual(true);
});
test('snapshot', () => {
expect(el.snapshot).toMatchSnapshot();
const table = el.instance.findByType(DataTable)[0];
expect(table.props.columns).toEqual(hookProps.columns);
const data = [...table.props.data];
const inputRow = data.pop();
const formattedDate = formatDateForDisplay(new Date());
expect(data).toEqual(hookProps.data);
expect(inputRow).toMatchObject({
adjustedGrade: <AdjustedGradeInput />,
date: formattedDate,
reason: <ReasonInput />,
});
});
});
});

View File

@@ -1,81 +0,0 @@
import React from 'react';
import { shallow } from 'enzyme';
import selectors from 'data/selectors';
import {
OverrideTable,
mapStateToProps,
} from '.';
jest.mock('@edx/paragon', () => ({ DataTable: () => 'DataTable' }));
jest.mock('./ReasonInput', () => 'ReasonInput');
jest.mock('./AdjustedGradeInput', () => 'AdjustedGradeInput');
jest.mock('data/selectors', () => ({
__esModule: true,
default: {
app: {
modalState: {
todaysDate: jest.fn(state => ({ todaysDate: state })),
},
},
grades: {
hasOverrideErrors: jest.fn(state => ({ hasOverrideErrors: state })),
gradeOverrides: jest.fn(state => ({ gradeOverrides: state })),
},
},
}));
describe('OverrideTable', () => {
const props = {
gradeOverrides: [
{
date: 'yesterday',
grader: 'me',
reason: 'you ate my sandwich',
adjustedGrade: 0,
},
{
date: 'today',
grader: 'me',
reason: 'you brought me a new sandwich',
adjustedGrade: 20,
},
],
hide: false,
todaysDate: 'todaaaaaay',
};
describe('Component', () => {
describe('snapshots', () => {
it('returns null if hide is true', () => {
expect(shallow(<OverrideTable {...props} hide />)).toEqual({});
});
describe('basic snapshot', () => {
test('shows a row for each entry and one editable row', () => {
expect(shallow(<OverrideTable {...props} />)).toMatchSnapshot();
});
});
});
});
describe('mapStateToProps', () => {
const testState = { I: 'wanna', be: 'the', very: 'best' };
let mapped;
beforeEach(() => {
mapped = mapStateToProps(testState);
});
describe('modalState', () => {
test('hide from grades.hasOverrideErrors', () => {
expect(mapped.hide).toEqual(selectors.grades.hasOverrideErrors(testState));
});
test('gradeOverrides from grades.gradeOverrides', () => {
expect(mapped.gradeOverrides).toEqual(selectors.grades.gradeOverrides(testState));
});
test('todaysData from app.modalState.todaysDate', () => {
expect(mapped.todaysDate).toEqual(selectors.app.modalState.todaysDate(testState));
});
});
});
});

View File

@@ -1,99 +1,26 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ModalHeaders Component snapshots gradeOverrideHistoryError is and empty and open is true modal open and StatusAlert showing 1`] = ` exports[`ModalHeaders render snapshot 1`] = `
<div> <div>
<HistoryHeader <HistoryHeader
id="assignment" id="assignment"
label={ label="Assignment"
<FormattedMessage value="test-assignment-name"
defaultMessage="Assignment"
description="Edit Modal Assignment header"
id="gradebook.GradesView.EditModal.headers.assignment"
/>
}
value="Qwerty"
/> />
<HistoryHeader <HistoryHeader
id="student" id="student"
label={ label="Student"
<FormattedMessage value="test-user-name"
defaultMessage="Student"
description="Edit Modal Student header"
id="gradebook.GradesView.EditModal.headers.student"
/>
}
value="Uiop"
/> />
<HistoryHeader <HistoryHeader
id="original-grade" id="original-grade"
label={ label="Original Grade"
<FormattedMessage value="test-original-grade"
defaultMessage="Original Grade"
description="Edit Modal Original Grade header"
id="gradebook.GradesView.EditModal.headers.originalGrade"
/>
}
value={20}
/> />
<HistoryHeader <HistoryHeader
id="current-grade" id="current-grade"
label={ label="Current Grade"
<FormattedMessage value="test-current-grade"
defaultMessage="Current Grade"
description="Edit Modal Current Grade header"
id="gradebook.GradesView.EditModal.headers.currentGrade"
/>
}
value={2}
/>
</div>
`;
exports[`ModalHeaders Component snapshots gradeOverrideHistoryError is empty and open is false modal closed and StatusAlert closed 1`] = `
<div>
<HistoryHeader
id="assignment"
label={
<FormattedMessage
defaultMessage="Assignment"
description="Edit Modal Assignment header"
id="gradebook.GradesView.EditModal.headers.assignment"
/>
}
value="Qwerty"
/>
<HistoryHeader
id="student"
label={
<FormattedMessage
defaultMessage="Student"
description="Edit Modal Student header"
id="gradebook.GradesView.EditModal.headers.student"
/>
}
value="Uiop"
/>
<HistoryHeader
id="original-grade"
label={
<FormattedMessage
defaultMessage="Original Grade"
description="Edit Modal Original Grade header"
id="gradebook.GradesView.EditModal.headers.originalGrade"
/>
}
value={20}
/>
<HistoryHeader
id="current-grade"
label={
<FormattedMessage
defaultMessage="Current Grade"
description="Edit Modal Current Grade header"
id="gradebook.GradesView.EditModal.headers.currentGrade"
/>
}
value={2}
/> />
</div> </div>
`; `;

View File

@@ -0,0 +1,91 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`EditModal component render with error snapshot 1`] = `
<ModalDialog
hasCloseButton={true}
isFullscreenOnMobile={true}
isOpen="test-is-open"
onClose={[MockFunction hooks.onClose]}
size="xl"
title="Edit Grades"
>
<ModalDialog.Body>
<div>
<ModalHeaders />
<Alert
dismissible={false}
show={true}
variant="danger"
>
test-error
</Alert>
<OverrideTable />
<div>
Showing most recent actions (max 5). To see more, please contact support
</div>
<div>
Note: Once you save, your changes will be visible to students.
</div>
</div>
</ModalDialog.Body>
<ModalDialog.Footer>
<ActionRow>
<ModalDialog.CloseButton
variant="tertiary"
>
Cancel
</ModalDialog.CloseButton>
<Button
onClick={[MockFunction hooks.handleAdjustedGradeClick]}
variant="primary"
>
Save Grades
</Button>
</ActionRow>
</ModalDialog.Footer>
</ModalDialog>
`;
exports[`EditModal component render without error snapshot 1`] = `
<ModalDialog
hasCloseButton={true}
isFullscreenOnMobile={true}
isOpen="test-is-open"
onClose={[MockFunction hooks.onClose]}
size="xl"
title="Edit Grades"
>
<ModalDialog.Body>
<div>
<ModalHeaders />
<Alert
dismissible={false}
show={false}
variant="danger"
/>
<OverrideTable />
<div>
Showing most recent actions (max 5). To see more, please contact support
</div>
<div>
Note: Once you save, your changes will be visible to students.
</div>
</div>
</ModalDialog.Body>
<ModalDialog.Footer>
<ActionRow>
<ModalDialog.CloseButton
variant="tertiary"
>
Cancel
</ModalDialog.CloseButton>
<Button
onClick={[MockFunction hooks.handleAdjustedGradeClick]}
variant="primary"
>
Save Grades
</Button>
</ActionRow>
</ModalDialog.Footer>
</ModalDialog>
`;

View File

@@ -1,125 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`EditModal Component snapshots gradeOverrideHistoryError is and empty and open is true modal open and StatusAlert showing 1`] = `
<ModalDialog
hasCloseButton={true}
isFullscreenOnMobile={true}
isOpen={true}
onClose={[MockFunction this.closeAssignmentModal]}
size="xl"
title="Edit Grades"
>
<ModalDialog.Body>
<div>
<ModalHeaders />
<Alert
dismissible={false}
show={true}
variant="danger"
>
Weve been trying to contact you regarding...
</Alert>
<OverrideTable />
<div>
<FormattedMessage
defaultMessage="Showing most recent actions (max 5). To see more, please contact support"
description="Edit Modal visibility hint message"
id="gradebook.GradesView.EditModal.contactSupport"
/>
</div>
<div>
<FormattedMessage
defaultMessage="Note: Once you save, your changes will be visible to students."
description="Edit Modal saved changes effect hint"
id="gradebook.GradesView.EditModal.saveVisibility"
/>
</div>
</div>
</ModalDialog.Body>
<ModalDialog.Footer>
<ActionRow>
<ModalDialog.CloseButton
variant="tertiary"
>
<FormattedMessage
defaultMessage="Cancel"
description="Edit Modal close button text"
id="gradebook.GradesView.EditModal.closeText"
/>
</ModalDialog.CloseButton>
<Button
onClick={[MockFunction this.handleAdjustedGradeClick]}
variant="primary"
>
<FormattedMessage
defaultMessage="Save Grades"
description="Edit Modal Save button label"
id="gradebook.GradesView.EditModal.saveGrade"
/>
</Button>
</ActionRow>
</ModalDialog.Footer>
</ModalDialog>
`;
exports[`EditModal Component snapshots gradeOverrideHistoryError is empty and open is false modal closed and StatusAlert closed 1`] = `
<ModalDialog
hasCloseButton={true}
isFullscreenOnMobile={true}
isOpen={false}
onClose={[MockFunction this.closeAssignmentModal]}
size="xl"
title="Edit Grades"
>
<ModalDialog.Body>
<div>
<ModalHeaders />
<Alert
dismissible={false}
show={false}
variant="danger"
>
</Alert>
<OverrideTable />
<div>
<FormattedMessage
defaultMessage="Showing most recent actions (max 5). To see more, please contact support"
description="Edit Modal visibility hint message"
id="gradebook.GradesView.EditModal.contactSupport"
/>
</div>
<div>
<FormattedMessage
defaultMessage="Note: Once you save, your changes will be visible to students."
description="Edit Modal saved changes effect hint"
id="gradebook.GradesView.EditModal.saveVisibility"
/>
</div>
</div>
</ModalDialog.Body>
<ModalDialog.Footer>
<ActionRow>
<ModalDialog.CloseButton
variant="tertiary"
>
<FormattedMessage
defaultMessage="Cancel"
description="Edit Modal close button text"
id="gradebook.GradesView.EditModal.closeText"
/>
</ModalDialog.CloseButton>
<Button
onClick={[MockFunction this.handleAdjustedGradeClick]}
variant="primary"
>
<FormattedMessage
defaultMessage="Save Grades"
description="Edit Modal Save button label"
id="gradebook.GradesView.EditModal.saveGrade"
/>
</Button>
</ActionRow>
</ModalDialog.Footer>
</ModalDialog>
`;

View File

@@ -0,0 +1,29 @@
import { selectors, actions, thunkActions } from 'data/redux/hooks';
export const useEditModalData = () => {
const error = selectors.grades.useGradeData().gradeOverrideHistoryError;
const isOpen = selectors.app.useModalData().open;
const closeModal = actions.app.useCloseModal();
const doneViewingAssignment = actions.grades.useDoneViewingAssignment();
const updateGrades = thunkActions.grades.useUpdateGrades();
const onClose = () => {
doneViewingAssignment();
closeModal();
};
const handleAdjustedGradeClick = () => {
updateGrades();
doneViewingAssignment();
closeModal();
};
return {
onClose,
error,
handleAdjustedGradeClick,
isOpen,
};
};
export default useEditModalData;

View File

@@ -0,0 +1,68 @@
import { selectors, actions, thunkActions } from 'data/redux/hooks';
import useEditModalData from './hooks';
jest.mock('data/redux/hooks', () => ({
actions: {
app: { useCloseModal: jest.fn() },
grades: { useDoneViewingAssignment: jest.fn() },
},
selectors: {
app: { useModalData: jest.fn() },
grades: { useGradeData: jest.fn() },
},
thunkActions: {
grades: { useUpdateGrades: jest.fn() },
},
}));
const closeModal = jest.fn();
const doneViewingAssignment = jest.fn();
const updateGrades = jest.fn();
actions.app.useCloseModal.mockReturnValue(closeModal);
actions.grades.useDoneViewingAssignment.mockReturnValue(doneViewingAssignment);
thunkActions.grades.useUpdateGrades.mockReturnValue(updateGrades);
const gradeData = { gradeOverridHistoryError: 'test-error' };
const modalData = { open: true };
selectors.app.useModalData.mockReturnValue(modalData);
selectors.grades.useGradeData.mockReturnValue(gradeData);
let out;
describe('useEditModalData', () => {
beforeEach(() => {
jest.clearAllMocks();
out = useEditModalData();
});
describe('behavior', () => {
it('initializes redux hooks', () => {
expect(selectors.grades.useGradeData).toHaveBeenCalled();
expect(selectors.app.useModalData).toHaveBeenCalled();
expect(actions.app.useCloseModal).toHaveBeenCalled();
expect(actions.grades.useDoneViewingAssignment).toHaveBeenCalled();
expect(thunkActions.grades.useUpdateGrades).toHaveBeenCalled();
});
});
describe('output', () => {
it('forwards error from gradeData.gradeOverrideHistoryError', () => {
expect(out.error).toEqual(gradeData.gradeOverrideHistoryError);
});
it('forwards isOpen from modalData.open', () => {
expect(out.isOpen).toEqual(modalData.open);
});
describe('handleAdjustedGradeClick', () => {
it('updates grades, calls doneViewingAssignment and closeModal', () => {
out.handleAdjustedGradeClick();
expect(updateGrades).toHaveBeenCalled();
expect(doneViewingAssignment).toHaveBeenCalled();
expect(closeModal).toHaveBeenCalled();
});
});
test('onClose calls doneViewingAssignment and closeModal', () => {
out.onClose();
expect(doneViewingAssignment).toHaveBeenCalled();
expect(closeModal).toHaveBeenCalled();
expect(updateGrades).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,23 +1,17 @@
/* eslint-disable react/sort-comp, react/button-has-type, import/no-named-as-default */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { import {
Button, Button,
Alert, Alert,
ModalDialog, ModalDialog,
ActionRow, ActionRow,
} from '@edx/paragon'; } from '@openedx/paragon';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import selectors from 'data/selectors';
import actions from 'data/actions';
import thunkActions from 'data/thunkActions';
import messages from './messages';
import OverrideTable from './OverrideTable'; import OverrideTable from './OverrideTable';
import ModalHeaders from './ModalHeaders'; import ModalHeaders from './ModalHeaders';
import useEditModalData from './hooks';
import messages from './messages';
/** /**
* <EditModal /> * <EditModal />
@@ -28,87 +22,48 @@ import ModalHeaders from './ModalHeaders';
* adjusting the grade. * adjusting the grade.
* (also provides a close button that clears the modal state) * (also provides a close button that clears the modal state)
*/ */
export class EditModal extends React.Component { export const EditModal = () => {
constructor(props) { const { formatMessage } = useIntl();
super(props); const {
this.closeAssignmentModal = this.closeAssignmentModal.bind(this); onClose,
this.handleAdjustedGradeClick = this.handleAdjustedGradeClick.bind(this); error,
} handleAdjustedGradeClick,
isOpen,
} = useEditModalData();
closeAssignmentModal() { return (
this.props.doneViewingAssignment(); <ModalDialog
this.props.closeModal(); title={formatMessage(messages.title)}
} isOpen={isOpen}
onClose={onClose}
size="xl"
hasCloseButton
isFullscreenOnMobile
>
<ModalDialog.Body>
<div>
<ModalHeaders />
<Alert variant="danger" show={!!error} dismissible={false}>
{error}
</Alert>
<OverrideTable />
<div>{formatMessage(messages.visibility)}</div>
<div>{formatMessage(messages.saveVisibility)}</div>
</div>
</ModalDialog.Body>
handleAdjustedGradeClick() { <ModalDialog.Footer>
this.props.updateGrades(); <ActionRow>
this.closeAssignmentModal(); <ModalDialog.CloseButton variant="tertiary">
} {formatMessage(messages.closeText)}
</ModalDialog.CloseButton>
render() { <Button variant="primary" onClick={handleAdjustedGradeClick}>
return ( {formatMessage(messages.saveGrade)}
<ModalDialog </Button>
title={this.props.intl.formatMessage(messages.title)} </ActionRow>
isOpen={this.props.open} </ModalDialog.Footer>
onClose={this.closeAssignmentModal} </ModalDialog>
size="xl" );
hasCloseButton
isFullscreenOnMobile
>
<ModalDialog.Body>
<div>
<ModalHeaders />
<Alert
variant="danger"
show={!!this.props.gradeOverrideHistoryError}
dismissible={false}
>
{this.props.gradeOverrideHistoryError}
</Alert>
<OverrideTable />
<div><FormattedMessage {...messages.visibility} /></div>
<div><FormattedMessage {...messages.saveVisibility} /></div>
</div>
</ModalDialog.Body>
<ModalDialog.Footer>
<ActionRow>
<ModalDialog.CloseButton variant="tertiary">
<FormattedMessage {...messages.closeText} />
</ModalDialog.CloseButton>
<Button variant="primary" onClick={this.handleAdjustedGradeClick}>
<FormattedMessage {...messages.saveGrade} />
</Button>
</ActionRow>
</ModalDialog.Footer>
</ModalDialog>
);
}
}
EditModal.defaultProps = {
gradeOverrideHistoryError: '',
}; };
EditModal.propTypes = { export default EditModal;
// redux
gradeOverrideHistoryError: PropTypes.string,
open: PropTypes.bool.isRequired,
closeModal: PropTypes.func.isRequired,
doneViewingAssignment: PropTypes.func.isRequired,
updateGrades: PropTypes.func.isRequired,
// injected
intl: intlShape.isRequired,
};
export const mapStateToProps = (state) => ({
gradeOverrideHistoryError: selectors.grades.gradeOverrideHistoryError(state),
open: selectors.app.modalState.open(state),
});
export const mapDispatchToProps = {
closeModal: actions.app.closeModal,
doneViewingAssignment: actions.grades.doneViewingAssignment,
updateGrades: thunkActions.grades.updateGrades,
};
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(EditModal));

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